@llblab/pi-telegram 0.51.6 → 0.52.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +15 -12
- package/BACKLOG.md +16 -9
- package/CHANGELOG.md +19 -0
- package/LICENSE +21 -0
- package/README.md +17 -6
- package/dist/lib/activity-verbosity.d.ts +1 -5
- package/dist/lib/activity-verbosity.js +5 -7
- package/dist/lib/bindings.d.ts +5 -1
- package/dist/lib/bindings.js +7 -1
- package/dist/lib/bus-api.js +8 -5
- package/dist/lib/bus-follower.d.ts +54 -56
- package/dist/lib/bus-follower.js +206 -139
- package/dist/lib/bus-leader.d.ts +21 -2
- package/dist/lib/bus-leader.js +181 -25
- package/dist/lib/bus-transport.d.ts +4 -1
- package/dist/lib/bus-transport.js +28 -4
- package/dist/lib/bus.d.ts +52 -56
- package/dist/lib/bus.js +78 -246
- package/dist/lib/channel-posts.d.ts +1 -3
- package/dist/lib/channel-posts.js +23 -26
- package/dist/lib/command-templates.d.ts +0 -5
- package/dist/lib/command-templates.js +5 -5
- package/dist/lib/commands.d.ts +0 -23
- package/dist/lib/commands.js +15 -15
- package/dist/lib/config.d.ts +0 -17
- package/dist/lib/config.js +12 -14
- package/dist/lib/delivery.d.ts +0 -4
- package/dist/lib/delivery.js +2 -5
- package/dist/lib/extension.js +258 -61
- package/dist/lib/generative-apps.d.ts +0 -19
- package/dist/lib/inbound.d.ts +0 -2
- package/dist/lib/inbound.js +2 -2
- package/dist/lib/journal.d.ts +183 -16
- package/dist/lib/journal.js +1175 -248
- package/dist/lib/keyboard.d.ts +0 -2
- package/dist/lib/keyboard.js +2 -2
- package/dist/lib/lifecycle.js +1 -1
- package/dist/lib/locks.d.ts +100 -12
- package/dist/lib/locks.js +380 -14
- package/dist/lib/logging.d.ts +30 -12
- package/dist/lib/logging.js +129 -72
- package/dist/lib/media.d.ts +28 -5
- package/dist/lib/media.js +26 -8
- package/dist/lib/menu-model.d.ts +0 -11
- package/dist/lib/menu-model.js +8 -8
- package/dist/lib/menu-settings.d.ts +0 -18
- package/dist/lib/menu-settings.js +18 -18
- package/dist/lib/menu-status.d.ts +4 -4
- package/dist/lib/menu-status.js +12 -7
- package/dist/lib/menu-thinking.d.ts +1 -1
- package/dist/lib/menu-thinking.js +1 -1
- package/dist/lib/menu.d.ts +1 -0
- package/dist/lib/menu.js +4 -3
- package/dist/lib/outbound-attachments.d.ts +0 -1
- package/dist/lib/outbound-attachments.js +1 -1
- package/dist/lib/outbound-buttons.js +1 -8
- package/dist/lib/outbound-markup.d.ts +1 -6
- package/dist/lib/outbound-markup.js +4 -21
- package/dist/lib/outbound.d.ts +0 -8
- package/dist/lib/outbound.js +5 -5
- package/dist/lib/paths.d.ts +50 -7
- package/dist/lib/paths.js +155 -17
- package/dist/lib/pi.d.ts +1 -0
- package/dist/lib/pi.js +2 -1
- package/dist/lib/polling.d.ts +0 -5
- package/dist/lib/polling.js +5 -5
- package/dist/lib/preview.d.ts +0 -3
- package/dist/lib/preview.js +3 -3
- package/dist/lib/prompt-templates.d.ts +0 -1
- package/dist/lib/prompt-templates.js +1 -1
- package/dist/lib/queue.d.ts +5 -15
- package/dist/lib/queue.js +94 -11
- package/dist/lib/recovery.d.ts +29 -9
- package/dist/lib/recovery.js +104 -33
- package/dist/lib/replies.d.ts +0 -4
- package/dist/lib/replies.js +1 -1
- package/dist/lib/routing.d.ts +74 -8
- package/dist/lib/routing.js +2280 -307
- package/dist/lib/sections.d.ts +0 -2
- package/dist/lib/sections.js +1 -1
- package/dist/lib/status.d.ts +44 -9
- package/dist/lib/status.js +149 -27
- package/dist/lib/sync.d.ts +0 -5
- package/dist/lib/sync.js +5 -4
- package/dist/lib/telegram-api.d.ts +13 -3
- package/dist/lib/telegram-api.js +38 -23
- package/dist/lib/thread-cleanup-manager.d.ts +1 -0
- package/dist/lib/thread-cleanup-manager.js +18 -21
- package/dist/lib/thread-naming.d.ts +32 -5
- package/dist/lib/thread-naming.js +191 -7
- package/dist/lib/thread-reconciler.d.ts +2 -0
- package/dist/lib/thread-reconciler.js +1 -1
- package/dist/lib/threads.d.ts +293 -59
- package/dist/lib/threads.js +1689 -369
- package/dist/lib/turns.d.ts +0 -1
- package/dist/lib/turns.js +1 -1
- package/dist/lib/updates.d.ts +191 -28
- package/dist/lib/updates.js +1048 -114
- package/dist/lib/wire.d.ts +12 -0
- package/dist/lib/wire.js +21 -0
- package/dist/lib/workspace-admission.d.ts +25 -17
- package/dist/lib/workspace-admission.js +95 -20
- package/dist/lib/workspace-identity.d.ts +20 -0
- package/dist/lib/workspace-identity.js +103 -0
- package/dist/lib/workspace-retirement.d.ts +43 -8
- package/dist/lib/workspace-retirement.js +148 -52
- package/dist/package.json +6 -6
- package/dist/skills/generated-control-surface/SKILL.md +3 -3
- package/dist/skills/telegram-bridge/references/diagnosis.md +3 -3
- package/docs/README.md +1 -1
- package/docs/activity.md +1 -1
- package/docs/architecture.md +156 -36
- package/docs/callback-namespaces.md +1 -1
- package/docs/delivery.md +1 -1
- package/docs/generative-apps.md +2 -2
- package/docs/inbound.md +1 -1
- package/docs/multi-instance-bus.md +125 -19
- package/docs/public-api.md +3 -1
- package/docs/ui-style.md +23 -9
- package/docs/updates.md +1 -1
- package/lib/activity-verbosity.ts +5 -9
- package/lib/bindings.ts +7 -0
- package/lib/bus-api.ts +9 -4
- package/lib/bus-follower.ts +243 -224
- package/lib/bus-leader.ts +178 -31
- package/lib/bus-transport.ts +30 -8
- package/lib/bus.ts +110 -334
- package/lib/channel-posts.ts +29 -29
- package/lib/command-templates.ts +5 -5
- package/lib/commands.ts +15 -15
- package/lib/config.ts +12 -17
- package/lib/delivery.ts +2 -8
- package/lib/extension.ts +261 -72
- package/lib/generative-apps.ts +0 -22
- package/lib/inbound.ts +2 -2
- package/lib/journal.ts +1250 -325
- package/lib/keyboard.ts +2 -2
- package/lib/lifecycle.ts +1 -1
- package/lib/locks.ts +402 -23
- package/lib/logging.ts +151 -101
- package/lib/media.ts +51 -9
- package/lib/menu-model.ts +8 -8
- package/lib/menu-settings.ts +18 -18
- package/lib/menu-status.ts +11 -0
- package/lib/menu-thinking.ts +2 -2
- package/lib/menu.ts +5 -1
- package/lib/outbound-attachments.ts +1 -1
- package/lib/outbound-buttons.ts +1 -10
- package/lib/outbound-markup.ts +5 -24
- package/lib/outbound.ts +5 -5
- package/lib/paths.ts +177 -17
- package/lib/pi.ts +5 -2
- package/lib/polling.ts +5 -5
- package/lib/preview.ts +3 -3
- package/lib/prompt-templates.ts +1 -1
- package/lib/queue.ts +93 -12
- package/lib/recovery.ts +104 -44
- package/lib/replies.ts +1 -1
- package/lib/routing.ts +2010 -375
- package/lib/sections.ts +1 -1
- package/lib/status.ts +156 -36
- package/lib/sync.ts +4 -4
- package/lib/telegram-api.ts +53 -33
- package/lib/thread-cleanup-manager.ts +24 -21
- package/lib/thread-naming.ts +267 -9
- package/lib/thread-reconciler.ts +3 -1
- package/lib/threads.ts +1736 -488
- package/lib/turns.ts +1 -1
- package/lib/updates.ts +1094 -151
- package/lib/wire.ts +28 -0
- package/lib/workspace-admission.ts +97 -47
- package/lib/workspace-identity.ts +147 -0
- package/lib/workspace-retirement.ts +188 -89
- package/package.json +6 -6
- package/scripts/audit-exports.mjs +100 -0
- package/scripts/check-downgrade.mjs +80 -43
- package/skills/generated-control-surface/SKILL.md +3 -3
- package/skills/telegram-bridge/references/diagnosis.md +3 -3
package/lib/updates.ts
CHANGED
|
@@ -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,13 @@ 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,
|
|
59
|
+
type TelegramRoutingInputExpiryResult,
|
|
42
60
|
type TelegramUpdateJournalQueueDiscardResult,
|
|
43
61
|
type TelegramUpdateJournalQueueHandoffAcceptResult,
|
|
44
62
|
type TelegramUpdateJournalQueueHandoffCancelResult,
|
|
@@ -134,30 +152,6 @@ function getTelegramReactionEmoji(
|
|
|
134
152
|
return candidates.find((emoji) => emojis.has(emoji));
|
|
135
153
|
}
|
|
136
154
|
|
|
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
155
|
function getTelegramQueueReactionTransition(
|
|
162
156
|
oldReactions: TelegramReactionType[],
|
|
163
157
|
newReactions: TelegramReactionType[],
|
|
@@ -283,6 +277,8 @@ export interface TelegramGuestMessage {
|
|
|
283
277
|
from?: TelegramUser;
|
|
284
278
|
message_id?: number;
|
|
285
279
|
text?: string;
|
|
280
|
+
/** Text sent together with a photo, document or other media. */
|
|
281
|
+
caption?: string;
|
|
286
282
|
reply_to_message?: TelegramUpdateMessage;
|
|
287
283
|
}
|
|
288
284
|
|
|
@@ -405,7 +401,7 @@ type TelegramForeignUpdateSettlementFailure =
|
|
|
405
401
|
sourceUpdateId?: number;
|
|
406
402
|
};
|
|
407
403
|
|
|
408
|
-
|
|
404
|
+
class TelegramForeignUpdateSettlementError extends Error {
|
|
409
405
|
readonly settlement: TelegramForeignUpdateSettlementFailure;
|
|
410
406
|
|
|
411
407
|
constructor(
|
|
@@ -463,8 +459,8 @@ export interface TelegramUpdateFlow
|
|
|
463
459
|
}
|
|
464
460
|
|
|
465
461
|
export type TelegramUpdateAdmissionOutcome =
|
|
466
|
-
| { kind: "complete" }
|
|
467
|
-
| { kind: "deferred" }
|
|
462
|
+
| { kind: "complete"; expectedSource?: TelegramDeferredSourceEvidence }
|
|
463
|
+
| { kind: "deferred"; routingReview?: true }
|
|
468
464
|
| {
|
|
469
465
|
kind: "queued";
|
|
470
466
|
queueKind: "prompt" | "control";
|
|
@@ -481,9 +477,63 @@ const TELEGRAM_UPDATE_ADMISSION_BINDING = Symbol(
|
|
|
481
477
|
"telegram.update-admission.binding",
|
|
482
478
|
);
|
|
483
479
|
|
|
480
|
+
export type TelegramDeferredUpdateAbandonmentAuthority = Pick<
|
|
481
|
+
TelegramUpdateJournalPendingAbandonmentInput, "operatorAuthorityId" | "isCurrent"
|
|
482
|
+
>;
|
|
483
|
+
|
|
484
|
+
export interface TelegramDeferredAbandonmentRecoveryRequest {
|
|
485
|
+
journalBindingKey: string;
|
|
486
|
+
afterUpdateId?: number;
|
|
487
|
+
isCurrent: () => boolean;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const TELEGRAM_HELD_SOURCE_INSPECTION_LIMIT = 20;
|
|
491
|
+
|
|
492
|
+
/** A generation-owned post-drain hint for the new-world restart; never execution, completion or deletion authority by itself. */
|
|
493
|
+
export interface TelegramHeldSourcePreparation<TContext> {
|
|
494
|
+
ctx: TContext;
|
|
495
|
+
journalBindingKey: string;
|
|
496
|
+
/** Exact prepared worker owner, context, key and authority; the consumer adds its own domain fences. */
|
|
497
|
+
isCurrent: () => boolean;
|
|
498
|
+
signal: AbortSignal;
|
|
499
|
+
/** Chooser-clock sources that must survive cold spending until their fixed expiry. */
|
|
500
|
+
routingSourceIds?: readonly number[];
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
export interface TelegramDeferredAbandonmentRecoveryPage {
|
|
504
|
+
sources: {
|
|
505
|
+
/** Detached original evidence, not proof of current eligibility or archive integrity. */
|
|
506
|
+
original: TelegramUpdateJournalEntry;
|
|
507
|
+
retry: (authority: TelegramDeferredUpdateAbandonmentAuthority) =>
|
|
508
|
+
TelegramUpdateJournalPendingAbandonmentResult | undefined;
|
|
509
|
+
}[];
|
|
510
|
+
nextAfterUpdateId?: number;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
type TelegramHistoricalInputPredicate = (original: TelegramUpdateJournalEntry) => boolean;
|
|
514
|
+
|
|
515
|
+
/** Read-only exact-source observation by its live worker; not an acceptance or removal grant. */
|
|
516
|
+
export interface TelegramDeferredSourceEvidence extends TelegramUpdateJournalEntryDigest {
|
|
517
|
+
journalBindingKey: string;
|
|
518
|
+
/** Supplied only after caller-owned acceptance publication; requires a durable removal ACK. */
|
|
519
|
+
completionSha256?: string;
|
|
520
|
+
}
|
|
521
|
+
|
|
484
522
|
interface TelegramUpdateAdmissionBinding {
|
|
485
523
|
sourceUpdateId: number;
|
|
486
524
|
report: (outcome: TelegramUpdateAdmissionOutcome) => void;
|
|
525
|
+
abandon?: (authority: TelegramDeferredUpdateAbandonmentAuthority) =>
|
|
526
|
+
TelegramUpdateJournalPendingAbandonmentResult | undefined;
|
|
527
|
+
armRoutingInput?: (operatorUserId: number, sourceUpdateIds: readonly number[]) => TelegramUpdateJournalRoutingInput | undefined;
|
|
528
|
+
acquireRouting?: (select?: boolean, sourceUpdateIds?: readonly number[]) => () => void;
|
|
529
|
+
inspectSource?: () => TelegramDeferredSourceEvidence | undefined;
|
|
530
|
+
supportsAbandonment?: (journalBindingKey: string) => boolean;
|
|
531
|
+
inspectAbandoning?: (request: TelegramDeferredAbandonmentRecoveryRequest) =>
|
|
532
|
+
TelegramDeferredAbandonmentRecoveryPage | undefined;
|
|
533
|
+
inspectHistorical?: (request: TelegramDeferredAbandonmentRecoveryRequest) =>
|
|
534
|
+
TelegramDeferredAbandonmentRecoveryPage | undefined;
|
|
535
|
+
isHistorical?: (matchesOriginal?: TelegramHistoricalInputPredicate) => boolean;
|
|
536
|
+
isHistoricalReviewHeld?: () => boolean;
|
|
487
537
|
}
|
|
488
538
|
|
|
489
539
|
export type TelegramQueueAdmissionReceiptLike = TelegramQueueAdmissionReceipt;
|
|
@@ -520,6 +570,7 @@ export function bindTelegramUpdateAdmissionSource<
|
|
|
520
570
|
>(
|
|
521
571
|
update: TUpdate,
|
|
522
572
|
report: TelegramUpdateAdmissionBinding["report"],
|
|
573
|
+
controls?: Pick<TelegramUpdateAdmissionBinding, "abandon" | "armRoutingInput" | "acquireRouting" | "inspectSource" | "supportsAbandonment" | "inspectAbandoning" | "inspectHistorical" | "isHistorical" | "isHistoricalReviewHeld">,
|
|
523
574
|
): TUpdate {
|
|
524
575
|
if (!Number.isSafeInteger(update.update_id) || update.update_id < 0) {
|
|
525
576
|
throw new TelegramUpdateAdmissionOutcomeError(
|
|
@@ -529,6 +580,7 @@ export function bindTelegramUpdateAdmissionSource<
|
|
|
529
580
|
const binding: TelegramUpdateAdmissionBinding = {
|
|
530
581
|
sourceUpdateId: update.update_id,
|
|
531
582
|
report,
|
|
583
|
+
...controls,
|
|
532
584
|
};
|
|
533
585
|
const callbackQuery = update.callback_query
|
|
534
586
|
? bindTelegramUpdateAdmissionCarrier(
|
|
@@ -575,6 +627,26 @@ export function bindTelegramUpdateAdmissionSource<
|
|
|
575
627
|
} as TUpdate;
|
|
576
628
|
}
|
|
577
629
|
|
|
630
|
+
/**
|
|
631
|
+
* Conservative read-only census: any retained entry naming this Thread, in any nested Telegram object or state,
|
|
632
|
+
* is unresolved custody. It cannot see updates still in transit before journal append.
|
|
633
|
+
*/
|
|
634
|
+
export function collectTelegramJournalThreadUpdateIds(
|
|
635
|
+
entries: readonly { updateId: number; update: unknown }[],
|
|
636
|
+
target: { chatId: number; threadId: number },
|
|
637
|
+
): number[] {
|
|
638
|
+
const matches = (value: unknown, depth: number): boolean => {
|
|
639
|
+
if (depth > 8 || !value || typeof value !== "object") return false;
|
|
640
|
+
const record = value as Record<string, unknown>;
|
|
641
|
+
if (record.message_thread_id === target.threadId) {
|
|
642
|
+
const chat = record.chat as { id?: unknown } | undefined;
|
|
643
|
+
if (!chat || typeof chat !== "object" || chat.id === target.chatId) return true;
|
|
644
|
+
}
|
|
645
|
+
return Object.values(record).some(child => matches(child, depth + 1));
|
|
646
|
+
};
|
|
647
|
+
return entries.filter(entry => matches(entry.update, 0)).map(entry => entry.updateId).sort((left, right) => left - right);
|
|
648
|
+
}
|
|
649
|
+
|
|
578
650
|
export function collectTelegramAdmissionSourceUpdateIds(
|
|
579
651
|
values: readonly unknown[],
|
|
580
652
|
): number[] {
|
|
@@ -586,29 +658,132 @@ export function collectTelegramAdmissionSourceUpdateIds(
|
|
|
586
658
|
return [...sourceUpdateIds].sort((left, right) => left - right);
|
|
587
659
|
}
|
|
588
660
|
|
|
661
|
+
/** Observe the original journal entry, never the mutable routed carrier, through current worker authority. */
|
|
662
|
+
export function inspectTelegramDeferredSource(value: unknown): TelegramDeferredSourceEvidence | undefined {
|
|
663
|
+
const execution = getTelegramUpdateExecutionFence(value);
|
|
664
|
+
if (execution?.isCurrent() !== true) return undefined;
|
|
665
|
+
return getTelegramUpdateAdmissionBinding(value)?.inspectSource?.();
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** A route-owned pre-disposition acceptance publisher; wraps only this carrier, never the shared worker binding. */
|
|
669
|
+
export function bindTelegramUpdateCompletionAcceptance<TValue>(value: TValue,
|
|
670
|
+
publish: () => TelegramDeferredSourceEvidence): TValue {
|
|
671
|
+
const binding = getTelegramUpdateAdmissionBinding(value);
|
|
672
|
+
if (!binding || getTelegramUpdateExecutionFence(value)?.isCurrent() !== true) {
|
|
673
|
+
throw new TelegramUpdateAdmissionOutcomeError("Telegram completion acceptance requires a live source carrier.");
|
|
674
|
+
}
|
|
675
|
+
let retained: TelegramDeferredSourceEvidence | undefined;
|
|
676
|
+
let queued = false;
|
|
677
|
+
const scoped: TelegramUpdateAdmissionBinding = { ...binding, report(outcome) {
|
|
678
|
+
if (outcome.kind !== "complete") {
|
|
679
|
+
if (outcome.kind === "queued") queued = true;
|
|
680
|
+
binding.report(outcome); return;
|
|
681
|
+
}
|
|
682
|
+
assertTelegramUpdateExecutionCurrent(value);
|
|
683
|
+
// A command's trailing implicit completion cannot dispose receipt-owned or uncertain queued work.
|
|
684
|
+
if (queued) {
|
|
685
|
+
if (outcome.expectedSource) throw new TelegramUpdateAdmissionOutcomeError("Telegram completion acceptance cannot dispose a queued source.");
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
if (!retained) {
|
|
689
|
+
const published = validateTelegramUpdateAdmissionOutcome({ kind: "complete", expectedSource: publish() },
|
|
690
|
+
binding.sourceUpdateId, new Set([binding.sourceUpdateId]));
|
|
691
|
+
if (published.kind !== "complete" || !published.expectedSource?.completionSha256) {
|
|
692
|
+
throw new TelegramUpdateAdmissionOutcomeError("Telegram completion acceptance lacks its scoped journal proof.");
|
|
693
|
+
}
|
|
694
|
+
retained = published.expectedSource;
|
|
695
|
+
}
|
|
696
|
+
assertTelegramUpdateExecutionCurrent(value);
|
|
697
|
+
if (outcome.expectedSource && (outcome.expectedSource.updateId !== retained.updateId ||
|
|
698
|
+
outcome.expectedSource.journalBindingKey !== retained.journalBindingKey || outcome.expectedSource.sourceSha256 !== retained.sourceSha256 ||
|
|
699
|
+
outcome.expectedSource.completionSha256 !== undefined && outcome.expectedSource.completionSha256 !== retained.completionSha256)) {
|
|
700
|
+
throw new TelegramUpdateAdmissionOutcomeError("Telegram completion acceptance conflicts with its reported source.");
|
|
701
|
+
}
|
|
702
|
+
binding.report({ kind: "complete", expectedSource: { ...retained } });
|
|
703
|
+
} };
|
|
704
|
+
return carryTelegramUpdateExecutionFence(value, bindTelegramUpdateAdmissionCarrier(value, scoped)!);
|
|
705
|
+
}
|
|
706
|
+
|
|
589
707
|
/** Report source completion; true means reported, not a durable settlement acknowledgement. */
|
|
590
|
-
export function reportTelegramUpdateCompleted(value: unknown): boolean {
|
|
708
|
+
export function reportTelegramUpdateCompleted(value: unknown, expectedSource?: TelegramDeferredSourceEvidence): boolean {
|
|
591
709
|
const binding = getTelegramUpdateAdmissionBinding(value);
|
|
592
710
|
if (!binding) return false;
|
|
593
711
|
const execution = getTelegramUpdateExecutionFence(value);
|
|
594
712
|
if (execution && !execution.isCurrent()) return false;
|
|
595
|
-
binding.report({ kind: "complete" })
|
|
713
|
+
binding.report(validateTelegramUpdateAdmissionOutcome({ kind: "complete", ...(expectedSource !== undefined ? { expectedSource } : {}) },
|
|
714
|
+
binding.sourceUpdateId, new Set([binding.sourceUpdateId])));
|
|
596
715
|
return true;
|
|
597
716
|
}
|
|
598
717
|
|
|
599
718
|
export function reportTelegramUpdateDeferred(value: unknown): boolean {
|
|
600
719
|
const binding = getTelegramUpdateAdmissionBinding(value);
|
|
601
720
|
if (!binding) return false;
|
|
721
|
+
const execution = getTelegramUpdateExecutionFence(value);
|
|
722
|
+
if (execution && !execution.isCurrent()) return false;
|
|
602
723
|
binding.report({ kind: "deferred" });
|
|
603
724
|
return true;
|
|
604
725
|
}
|
|
605
726
|
|
|
727
|
+
/** Exact source-bound cancellation; undefined means no eligible attempt was made. */
|
|
728
|
+
export function abandonTelegramDeferredUpdate(
|
|
729
|
+
value: unknown,
|
|
730
|
+
authority: TelegramDeferredUpdateAbandonmentAuthority,
|
|
731
|
+
): TelegramUpdateJournalPendingAbandonmentResult | undefined {
|
|
732
|
+
return getTelegramUpdateAdmissionBinding(value)?.abandon?.(authority);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/** Capability for chooser publication; the eventual action still requires exact deferred authority. */
|
|
736
|
+
export function supportsTelegramDeferredAbandonment(value: unknown, journalBindingKey: string): boolean {
|
|
737
|
+
return getTelegramUpdateAdmissionBinding(value)?.supportsAbandonment?.(journalBindingKey) === true;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** Observe protected attempts through a current carrier; the UI still owns human authorization. */
|
|
741
|
+
export function inspectTelegramAbandoningUpdates(value: unknown, request: TelegramDeferredAbandonmentRecoveryRequest):
|
|
742
|
+
TelegramDeferredAbandonmentRecoveryPage | undefined {
|
|
743
|
+
return getTelegramUpdateAdmissionBinding(value)?.inspectAbandoning?.(request);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** Historical inspection is not human confirmation or permission to stop delivery. */
|
|
747
|
+
export function inspectTelegramHistoricalInputs(value: unknown, request: TelegramDeferredAbandonmentRecoveryRequest):
|
|
748
|
+
TelegramDeferredAbandonmentRecoveryPage | undefined {
|
|
749
|
+
return getTelegramUpdateAdmissionBinding(value)?.inspectHistorical?.(request);
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
export function isTelegramHistoricalInput(value: unknown, matchesOriginal?: TelegramHistoricalInputPredicate): boolean {
|
|
753
|
+
return getTelegramUpdateAdmissionBinding(value)?.isHistorical?.(matchesOriginal) === true;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** Routing's last-boundary hold when ownership changed after early classification. */
|
|
757
|
+
export function reportTelegramHistoricalRoutingReview(value: unknown): boolean {
|
|
758
|
+
if (!isTelegramHistoricalInput(value) || getTelegramUpdateExecutionFence(value)?.isCurrent() !== true) return false;
|
|
759
|
+
getTelegramUpdateAdmissionBinding(value)!.report({ kind: "deferred", routingReview: true });
|
|
760
|
+
return true;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** Arm only a positively published chooser; the journal owns the immutable hour deadline. */
|
|
764
|
+
export function armTelegramRoutingInputs(values: readonly unknown[], operatorUserId: number): TelegramUpdateJournalRoutingInput | undefined {
|
|
765
|
+
const first = values[0], execution = getTelegramUpdateExecutionFence(first);
|
|
766
|
+
if (!execution || values.some(value => getTelegramUpdateExecutionFence(value)?.signal !== execution.signal)) return undefined;
|
|
767
|
+
for (const value of values) assertTelegramUpdateExecutionCurrent(value);
|
|
768
|
+
const ids = collectTelegramAdmissionSourceUpdateIds(values);
|
|
769
|
+
if (!ids.length) return undefined;
|
|
770
|
+
return getTelegramUpdateAdmissionBinding(first)?.armRoutingInput?.(operatorUserId, ids);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** Reserve source execution; an actual choice must also freeze its durable TTL before any effect. */
|
|
774
|
+
export function acquireTelegramUpdateRouting(value: unknown, select = false, sourceUpdateIds?: readonly number[]): () => void {
|
|
775
|
+
assertTelegramUpdateExecutionCurrent(value);
|
|
776
|
+
return getTelegramUpdateAdmissionBinding(value)?.acquireRouting?.(select, sourceUpdateIds) ?? (() => {});
|
|
777
|
+
}
|
|
778
|
+
|
|
606
779
|
export function reportTelegramQueueAdmission(
|
|
607
780
|
values: readonly unknown[],
|
|
608
781
|
receipts: readonly TelegramQueueAdmissionReceiptLike[],
|
|
609
782
|
): boolean {
|
|
610
783
|
const bindings = new Map<number, TelegramUpdateAdmissionBinding>();
|
|
611
784
|
for (const value of values) {
|
|
785
|
+
const execution = getTelegramUpdateExecutionFence(value);
|
|
786
|
+
if (execution && !execution.isCurrent()) return false;
|
|
612
787
|
const binding = getTelegramUpdateAdmissionBinding(value);
|
|
613
788
|
if (!binding) continue;
|
|
614
789
|
const existing = bindings.get(binding.sourceUpdateId);
|
|
@@ -1122,10 +1297,12 @@ function getForeignTelegramCallbackOwnership(
|
|
|
1122
1297
|
getTargetOwnership?: TelegramTargetOwnershipLookup;
|
|
1123
1298
|
},
|
|
1124
1299
|
): TelegramMessageOwnershipView | undefined {
|
|
1125
|
-
const
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1300
|
+
const messageTarget = getTelegramCallbackMessageTarget(query);
|
|
1301
|
+
const currentInstanceId = deps.getCurrentInstanceId?.();
|
|
1302
|
+
const knownOwner = messageTarget && deps.getMessageOwnership?.(messageTarget.chatId, messageTarget.messageId);
|
|
1303
|
+
// A locally published chooser stays local when Restore moves its Thread to a follower.
|
|
1304
|
+
if (currentInstanceId && knownOwner?.instanceId === currentInstanceId) return undefined;
|
|
1305
|
+
const messageOwnership = getForeignTelegramMessageOwnership(messageTarget, deps);
|
|
1129
1306
|
const targetOwnership = getForeignTelegramTargetOwnership(
|
|
1130
1307
|
query.message ? getTelegramMessageTarget(query.message) : undefined,
|
|
1131
1308
|
deps,
|
|
@@ -1621,7 +1798,10 @@ export async function executeTelegramUpdatePlan<
|
|
|
1621
1798
|
) {
|
|
1622
1799
|
assertExecutionCurrent();
|
|
1623
1800
|
await deps.handleUnboundTelegramTopicMessage(plan.message, deps.ctx);
|
|
1624
|
-
|
|
1801
|
+
// A terminal historical hold intentionally suspends this exact carrier.
|
|
1802
|
+
// Do not turn its acknowledged deferral into a retryable execution failure.
|
|
1803
|
+
if (getTelegramUpdateExecutionFence(plan.message) !== deps.execution ||
|
|
1804
|
+
getTelegramUpdateAdmissionBinding(plan.message)?.isHistoricalReviewHeld?.() !== true) assertExecutionCurrent();
|
|
1625
1805
|
return;
|
|
1626
1806
|
}
|
|
1627
1807
|
if (plan.kind === "edited-message") {
|
|
@@ -1640,9 +1820,9 @@ export async function executeTelegramUpdatePlan<
|
|
|
1640
1820
|
|
|
1641
1821
|
// --- Durable update worker ---
|
|
1642
1822
|
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1823
|
+
const TELEGRAM_UPDATE_RETRY_BASE_DELAY_MS = 1_000;
|
|
1824
|
+
const TELEGRAM_UPDATE_RETRY_MAX_DELAY_MS = 60_000;
|
|
1825
|
+
const TELEGRAM_UPDATE_WORKER_BATCH_SIZE = 64;
|
|
1646
1826
|
|
|
1647
1827
|
export type TelegramUpdateWorkerPhase =
|
|
1648
1828
|
| "stopped"
|
|
@@ -1679,6 +1859,10 @@ export interface TelegramUpdateWorkerStateSnapshot {
|
|
|
1679
1859
|
journalSerializedBytes: number;
|
|
1680
1860
|
oldestAdmittedAtMs?: number;
|
|
1681
1861
|
deferredClaimCount: number;
|
|
1862
|
+
/** Protected cancellation attempts, not proof of a committed discard. */
|
|
1863
|
+
abandoningClaimCount?: number;
|
|
1864
|
+
/** Historical sources held before routing, not evidence of prior non-delivery. */
|
|
1865
|
+
historicalClaimCount?: number;
|
|
1682
1866
|
queuedClaimCount: number;
|
|
1683
1867
|
foreignQueuedCount: number;
|
|
1684
1868
|
foreignQueuedOwner?: TelegramUpdateJournalQueueOwner;
|
|
@@ -1712,6 +1896,7 @@ export interface TelegramUpdateWorkerJournalSnapshot {
|
|
|
1712
1896
|
update: TelegramJournaledUpdate;
|
|
1713
1897
|
readonly preApprovalExcluded?: boolean;
|
|
1714
1898
|
admittedAtMs: number;
|
|
1899
|
+
routingInput?: TelegramUpdateJournalRoutingInput;
|
|
1715
1900
|
state: "pending" | "retry-wait" | "queued" | "failed";
|
|
1716
1901
|
inputClaim?: TelegramUpdateJournalInputClaim;
|
|
1717
1902
|
queueKind?: "prompt" | "control";
|
|
@@ -1736,13 +1921,27 @@ export interface TelegramUpdateWorkerJournalSnapshot {
|
|
|
1736
1921
|
serializedBytes: number;
|
|
1737
1922
|
}
|
|
1738
1923
|
|
|
1924
|
+
export interface TelegramRoutingInputExpirySource {
|
|
1925
|
+
original: TelegramUpdateJournalEntry;
|
|
1926
|
+
journalBindingKey: string;
|
|
1927
|
+
isCurrent(): boolean;
|
|
1928
|
+
expire(): TelegramRoutingInputExpiryResult | undefined;
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1739
1931
|
export interface TelegramUpdateWorkerJournalPort {
|
|
1932
|
+
routingInputs?: TelegramRoutingInputJournal;
|
|
1740
1933
|
read: () => TelegramUpdateWorkerJournalSnapshot;
|
|
1934
|
+
inspectPendingRetention?: (entry: TelegramUpdateJournalEntry) =>
|
|
1935
|
+
TelegramUpdateJournalPendingRetentionEvidence | undefined;
|
|
1936
|
+
abandonPending?: (input: TelegramUpdateJournalPendingAbandonmentInput) =>
|
|
1937
|
+
TelegramUpdateJournalPendingAbandonmentResult;
|
|
1741
1938
|
/** Optional strict observation; it must not recover, repair or grant new receipt authority. */
|
|
1742
1939
|
isQueueReceiptCurrent?: (
|
|
1743
1940
|
receipt: TelegramQueueAdmissionReceiptLike,
|
|
1744
1941
|
owner: TelegramUpdateJournalQueueOwner,
|
|
1745
1942
|
) => boolean;
|
|
1943
|
+
/** Strict full-group origin observation for prepared partial scopes; no writer or readiness acquisition. */
|
|
1944
|
+
inspectQueuedReceipt?: (expected: TelegramUpdateJournalQueuedCompletion) => TelegramUpdateJournalQueuedReceiptEvidence | undefined;
|
|
1746
1945
|
markQueued: (receipt: {
|
|
1747
1946
|
queueKind: "prompt" | "control";
|
|
1748
1947
|
receiptId: string;
|
|
@@ -1763,6 +1962,11 @@ export interface TelegramUpdateWorkerJournalPort {
|
|
|
1763
1962
|
) => {
|
|
1764
1963
|
removedUpdateIds: readonly number[];
|
|
1765
1964
|
};
|
|
1965
|
+
completeQueuedExact?: (receipts: readonly {
|
|
1966
|
+
queueKind: "prompt" | "control"; receiptId: string; sourceUpdateIds: readonly number[]; queueOwner: TelegramUpdateJournalQueueOwner;
|
|
1967
|
+
}[], completions: readonly TelegramUpdateJournalSourceCompletion[]) => {
|
|
1968
|
+
removedUpdateIds: readonly number[]; sourceCompletions?: readonly TelegramUpdateJournalSourceCompletion[];
|
|
1969
|
+
};
|
|
1766
1970
|
markExecutionFailure: (input: {
|
|
1767
1971
|
updateId: number;
|
|
1768
1972
|
expectedAttemptCount: number;
|
|
@@ -1778,6 +1982,32 @@ export interface TelegramUpdateWorkerJournalPort {
|
|
|
1778
1982
|
removeCompleted: (updateIds: readonly number[]) => {
|
|
1779
1983
|
removedUpdateIds: readonly number[];
|
|
1780
1984
|
};
|
|
1985
|
+
/** No ID-only fallback is allowed for a guarded completion report. */
|
|
1986
|
+
removeCompletedExact?: (updateIds: readonly number[], expectedSources: readonly TelegramUpdateJournalEntryDigest[],
|
|
1987
|
+
completions?: readonly TelegramUpdateJournalSourceCompletion[]) => {
|
|
1988
|
+
removedUpdateIds: readonly number[];
|
|
1989
|
+
sourceCompletions?: readonly TelegramUpdateJournalSourceCompletion[];
|
|
1990
|
+
};
|
|
1991
|
+
inspectSourceCompletion?: (expected: TelegramUpdateJournalSourceCompletion) => TelegramUpdateJournalSourceCompletion | undefined;
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
export type TelegramQueueSourceCompletion = TelegramDeferredSourceEvidence & { completionSha256: string };
|
|
1995
|
+
|
|
1996
|
+
function normalizeTelegramQueueSourceCompletions(value: unknown, sourceUpdateIds: ReadonlySet<number>, journalBindingKey: string, full = true): TelegramQueueSourceCompletion[] {
|
|
1997
|
+
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.");
|
|
1998
|
+
const seen = new Set<number>(), scopes = new Set<string>();
|
|
1999
|
+
return value.map(source => {
|
|
2000
|
+
if (!Number.isSafeInteger(source?.updateId) || !sourceUpdateIds.has(source.updateId) || seen.has(source.updateId)) {
|
|
2001
|
+
throw new Error("Telegram scoped queue completion has invalid source membership.");
|
|
2002
|
+
}
|
|
2003
|
+
const normalized = validateTelegramUpdateAdmissionOutcome({ kind: "complete", expectedSource: source }, source.updateId, sourceUpdateIds);
|
|
2004
|
+
if (normalized.kind !== "complete" || !normalized.expectedSource?.completionSha256 ||
|
|
2005
|
+
normalized.expectedSource.journalBindingKey !== journalBindingKey || scopes.has(normalized.expectedSource.completionSha256)) {
|
|
2006
|
+
throw new Error("Telegram scoped queue completion has invalid source scope.");
|
|
2007
|
+
}
|
|
2008
|
+
seen.add(source.updateId); scopes.add(normalized.expectedSource.completionSha256);
|
|
2009
|
+
return { ...normalized.expectedSource, completionSha256: normalized.expectedSource.completionSha256 };
|
|
2010
|
+
}).sort((a, b) => a.updateId - b.updateId);
|
|
1781
2011
|
}
|
|
1782
2012
|
|
|
1783
2013
|
export interface TelegramUpdateRetryPolicy {
|
|
@@ -1806,12 +2036,19 @@ export interface TelegramUpdateWorkerRuntimeDeps<TContext> {
|
|
|
1806
2036
|
hasAuthority: (ctx: TContext) => boolean;
|
|
1807
2037
|
getJournalBindingKey?: () => string | undefined;
|
|
1808
2038
|
getRecipientBindingKey?: () => string | undefined;
|
|
2039
|
+
/** True selects legacy historical review/spending; retain protects unsupported originals without disposition authority. */
|
|
2040
|
+
shouldReviewHistoricalInput?: (entry: TelegramUpdateJournalEntry, ctx: TContext, signal: AbortSignal) => boolean | "retain" | Promise<boolean | "retain">;
|
|
2041
|
+
/** New-world restart: spend unclocked classified routing input without delivery, copy or completion. Chooser clocks wait for expiry; interrupted private abandonment keeps exact recovery. */
|
|
2042
|
+
spendHistoricalInput?: boolean;
|
|
2043
|
+
/** Holds protected live or retry sources before execution; never cancels or disposes them. */
|
|
2044
|
+
shouldHoldPendingInput?: (entry: TelegramUpdateJournalEntry, ctx: TContext, signal: AbortSignal) => boolean | Promise<boolean>;
|
|
1809
2045
|
getQueueOwnerIdentity?: (
|
|
1810
2046
|
ctx: TContext,
|
|
1811
2047
|
) => TelegramUpdateJournalQueueOwnerIdentity;
|
|
1812
2048
|
isContextCurrent?: (ctx: TContext) => boolean;
|
|
1813
2049
|
createAbortController?: () => AbortController;
|
|
1814
2050
|
getNowMs?: () => number;
|
|
2051
|
+
expireRoutingInput?: (source: TelegramRoutingInputExpirySource, ctx: TContext, signal: AbortSignal) => Promise<void> | void;
|
|
1815
2052
|
retryPolicy?: Partial<TelegramUpdateRetryPolicy>;
|
|
1816
2053
|
classifyExecutionFailure?: (
|
|
1817
2054
|
error: unknown,
|
|
@@ -1822,11 +2059,18 @@ export interface TelegramUpdateWorkerRuntimeDeps<TContext> {
|
|
|
1822
2059
|
batchSize?: number;
|
|
1823
2060
|
yieldToEventLoop?: () => Promise<void>;
|
|
1824
2061
|
onStateChange?: (state: TelegramUpdateWorkerStateSnapshot) => void;
|
|
2062
|
+
/** One nonblocking hint after a quiescent validated startup projection, never execution or deletion authority. */
|
|
2063
|
+
onHeldSourcesPrepared?: (input: TelegramHeldSourcePreparation<TContext>) => Promise<void> | void;
|
|
2064
|
+
/** Prepared readiness barrier after durable queue admission; never authorizes replay or source removal. */
|
|
2065
|
+
beforeQueueReceiptPublished?: (receipt: TelegramQueueAdmissionReceiptLike, queueOwner: TelegramUpdateJournalQueueOwner,
|
|
2066
|
+
ctx: TContext, isCurrent: () => boolean) => Promise<void | readonly TelegramQueueSourceCompletion[]> | void | readonly TelegramQueueSourceCompletion[];
|
|
2067
|
+
/** Post-ACK hint only; it must not request Pi dispatch or inherit a Workspace admission lease. */
|
|
2068
|
+
onQueueReceiptCompleted?: (receipt: TelegramQueueAdmissionReceiptLike, ctx: TContext) => void;
|
|
1825
2069
|
onQueueReceiptCommitted?: (
|
|
1826
2070
|
receipt: TelegramQueueAdmissionReceiptLike,
|
|
1827
2071
|
ctx: TContext,
|
|
1828
2072
|
) => void;
|
|
1829
|
-
onUpdateCompleted?: (updateId: number, ctx: TContext) => void;
|
|
2073
|
+
onUpdateCompleted?: (updateId: number, ctx: TContext, journalBindingKey?: string) => void;
|
|
1830
2074
|
recordRuntimeEvent?: (
|
|
1831
2075
|
category: string,
|
|
1832
2076
|
error: unknown,
|
|
@@ -1847,6 +2091,20 @@ export interface TelegramUpdateWorkerRuntime<TContext> {
|
|
|
1847
2091
|
outcome: TelegramUpdateAdmissionOutcome;
|
|
1848
2092
|
signal: AbortSignal;
|
|
1849
2093
|
}) => void;
|
|
2094
|
+
armRoutingInput?: (input: { updateId: number; signal: AbortSignal; operatorUserId: number; sourceUpdateIds: readonly number[] }) => TelegramUpdateJournalRoutingInput | undefined;
|
|
2095
|
+
selectRoutingInput?: (input: { updateId: number; signal: AbortSignal; operatorUserId: number; sourceUpdateIds: readonly number[] }) => boolean;
|
|
2096
|
+
isRoutingInputCurrent?: (input: { updateId: number; signal: AbortSignal }) => boolean;
|
|
2097
|
+
supportsDeferredAbandonment?: (input: { updateId: number; signal: AbortSignal; journalBindingKey: string }) => boolean;
|
|
2098
|
+
inspectAbandoning?: (input: TelegramDeferredAbandonmentRecoveryRequest & { signal: AbortSignal }) =>
|
|
2099
|
+
TelegramDeferredAbandonmentRecoveryPage | undefined;
|
|
2100
|
+
inspectHistorical?: (input: TelegramDeferredAbandonmentRecoveryRequest & { signal: AbortSignal }) =>
|
|
2101
|
+
TelegramDeferredAbandonmentRecoveryPage | undefined;
|
|
2102
|
+
inspectDeferredSource?: (input: { updateId: number; signal: AbortSignal }) => TelegramDeferredSourceEvidence | undefined;
|
|
2103
|
+
isHistoricalSource?: (input: { updateId: number; signal: AbortSignal; matchesOriginal?: TelegramHistoricalInputPredicate }) => boolean;
|
|
2104
|
+
abandonDeferred?: (input: TelegramDeferredUpdateAbandonmentAuthority & {
|
|
2105
|
+
updateId: number;
|
|
2106
|
+
signal: AbortSignal;
|
|
2107
|
+
}) => TelegramUpdateJournalPendingAbandonmentResult | undefined;
|
|
1850
2108
|
settleCustodied: (input: {
|
|
1851
2109
|
updateId: number;
|
|
1852
2110
|
result: TelegramCustodiedExecutionResult;
|
|
@@ -1858,17 +2116,22 @@ export interface TelegramUpdateWorkerRuntime<TContext> {
|
|
|
1858
2116
|
getQueueReceiptOwner: (
|
|
1859
2117
|
receipt: TelegramQueueAdmissionReceiptLike,
|
|
1860
2118
|
) => TelegramUpdateJournalQueueOwner | undefined;
|
|
2119
|
+
/** Completion-only owner observation; issued attempts are never dispatch readiness. */
|
|
2120
|
+
getQueueReceiptSettlementOwner?: (receipt: TelegramQueueAdmissionReceiptLike, ctx: TContext,
|
|
2121
|
+
reason: TelegramQueueReceiptCompletionReason) => TelegramUpdateJournalQueueOwner | undefined;
|
|
1861
2122
|
completeQueueReceipts: (input: {
|
|
1862
2123
|
receipts: readonly TelegramQueueAdmissionReceiptLike[];
|
|
1863
2124
|
ctx: TContext;
|
|
1864
2125
|
reason: TelegramQueueReceiptCompletionReason;
|
|
2126
|
+
/** Prepared immutable scopes for every source; the exact receipt owner still authorizes disposal. */
|
|
2127
|
+
sourceCompletions?: readonly TelegramQueueSourceCompletion[];
|
|
1865
2128
|
}) => boolean;
|
|
1866
2129
|
stop: () => Promise<void>;
|
|
1867
2130
|
waitForDrain: () => Promise<void>;
|
|
1868
2131
|
getState: () => TelegramUpdateWorkerStateSnapshot;
|
|
1869
2132
|
}
|
|
1870
2133
|
|
|
1871
|
-
|
|
2134
|
+
class TelegramUpdateAdmissionOutcomeError extends Error {
|
|
1872
2135
|
constructor(message: string) {
|
|
1873
2136
|
super(message);
|
|
1874
2137
|
this.name = "TelegramUpdateAdmissionOutcomeError";
|
|
@@ -1880,9 +2143,11 @@ interface TelegramUpdateWorkerOwner<TContext> {
|
|
|
1880
2143
|
ctx: TContext;
|
|
1881
2144
|
controller: AbortController;
|
|
1882
2145
|
queueOwnerIdentity: TelegramUpdateJournalQueueOwnerIdentity;
|
|
2146
|
+
startupUpdateIds?: ReadonlySet<number>;
|
|
2147
|
+
heldSourcesPreparedIssued?: true;
|
|
1883
2148
|
}
|
|
1884
2149
|
|
|
1885
|
-
type TelegramUpdateWorkerClaim = "deferred" | "queued";
|
|
2150
|
+
type TelegramUpdateWorkerClaim = "deferred" | "queued" | "abandoning" | "historical" | "retained";
|
|
1886
2151
|
type TelegramUpdateWorkerDrainResult = "idle" | "blocked" | "aborted";
|
|
1887
2152
|
type TelegramUpdateWorkerExecutionSettlement =
|
|
1888
2153
|
| { ok: true; outcome: TelegramUpdateAdmissionOutcome; custodied?: false }
|
|
@@ -1899,16 +2164,6 @@ function getTelegramUpdateWorkerStateSnapshot(
|
|
|
1899
2164
|
return { ...state };
|
|
1900
2165
|
}
|
|
1901
2166
|
|
|
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
2167
|
function validateTelegramUpdateAdmissionOutcome(
|
|
1913
2168
|
value: unknown,
|
|
1914
2169
|
currentUpdateId: number,
|
|
@@ -1919,8 +2174,23 @@ function validateTelegramUpdateAdmissionOutcome(
|
|
|
1919
2174
|
`Telegram update ${currentUpdateId} returned no admission outcome.`,
|
|
1920
2175
|
);
|
|
1921
2176
|
}
|
|
1922
|
-
if (value.kind === "complete")
|
|
1923
|
-
|
|
2177
|
+
if (value.kind === "complete") {
|
|
2178
|
+
if (value.expectedSource === undefined) return { kind: "complete" };
|
|
2179
|
+
const source = value.expectedSource;
|
|
2180
|
+
if (!isTelegramUpdateAdmissionRecord(source) || Object.keys(source).some(key => !["journalBindingKey", "updateId", "sourceSha256", "completionSha256"].includes(key)) ||
|
|
2181
|
+
source.updateId !== currentUpdateId || !isTelegramUpdateAdmissionString(source.journalBindingKey) ||
|
|
2182
|
+
typeof source.sourceSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(source.sourceSha256) ||
|
|
2183
|
+
source.completionSha256 !== undefined && (typeof source.completionSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(source.completionSha256))) {
|
|
2184
|
+
throw new TelegramUpdateAdmissionOutcomeError(`Telegram update ${currentUpdateId} returned an invalid completion source.`);
|
|
2185
|
+
}
|
|
2186
|
+
return { kind: "complete", expectedSource: { journalBindingKey: source.journalBindingKey,
|
|
2187
|
+
updateId: currentUpdateId, sourceSha256: source.sourceSha256,
|
|
2188
|
+
...(source.completionSha256 !== undefined ? { completionSha256: source.completionSha256 } : {}) } };
|
|
2189
|
+
}
|
|
2190
|
+
if (value.kind === "deferred") {
|
|
2191
|
+
if (value.routingReview !== undefined && value.routingReview !== true) throw new TelegramUpdateAdmissionOutcomeError("Invalid historical review outcome.");
|
|
2192
|
+
return { kind: "deferred", ...(value.routingReview === true ? { routingReview: true } : {}) };
|
|
2193
|
+
}
|
|
1924
2194
|
if (value.kind === "queued") {
|
|
1925
2195
|
if (
|
|
1926
2196
|
(value.queueKind !== "prompt" && value.queueKind !== "control") ||
|
|
@@ -2072,12 +2342,23 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2072
2342
|
);
|
|
2073
2343
|
}
|
|
2074
2344
|
const getNowMs = deps.getNowMs ?? Date.now;
|
|
2345
|
+
const onHeldSourcesPrepared = deps.onHeldSourcesPrepared;
|
|
2346
|
+
const spendHistorical = deps.spendHistoricalInput === true;
|
|
2347
|
+
const routingJournal = deps.journal.routingInputs;
|
|
2348
|
+
const armRouting = routingJournal?.arm.bind(routingJournal);
|
|
2349
|
+
const selectRouting = routingJournal?.select.bind(routingJournal);
|
|
2350
|
+
const expireRouting = routingJournal?.expire.bind(routingJournal);
|
|
2351
|
+
const expireRoutingObserver = deps.expireRoutingInput;
|
|
2075
2352
|
const batchSize = deps.batchSize ?? TELEGRAM_UPDATE_WORKER_BATCH_SIZE;
|
|
2076
2353
|
if (!Number.isSafeInteger(batchSize) || batchSize <= 0) {
|
|
2077
2354
|
throw new Error("Telegram update worker batch size must be positive.");
|
|
2078
2355
|
}
|
|
2079
2356
|
const yieldToEventLoop = deps.yieldToEventLoop ??
|
|
2080
2357
|
(() => new Promise<void>((resolve) => setTimeout(resolve, 0)));
|
|
2358
|
+
const completeQueuedExact = deps.journal.completeQueuedExact?.bind(deps.journal);
|
|
2359
|
+
const completeExact = deps.journal.removeCompletedExact?.bind(deps.journal);
|
|
2360
|
+
const inspectCompletion = deps.journal.inspectSourceCompletion?.bind(deps.journal);
|
|
2361
|
+
const observeQueuedCompletion = deps.onQueueReceiptCompleted;
|
|
2081
2362
|
const fallbackQueueOwnerInstanceId = `worker-${randomUUID()}`;
|
|
2082
2363
|
const fallbackQueueOwnerProcessId = process.pid > 0 ? process.pid : 1;
|
|
2083
2364
|
const createAbortController =
|
|
@@ -2100,6 +2381,12 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2100
2381
|
sessionGeneration: generation,
|
|
2101
2382
|
},
|
|
2102
2383
|
);
|
|
2384
|
+
const isQueueOwnerIdentityCurrent = (expected: TelegramUpdateWorkerOwner<TContext>): boolean => {
|
|
2385
|
+
if (!deps.getQueueOwnerIdentity) return true;
|
|
2386
|
+
const live = deps.getQueueOwnerIdentity(expected.ctx), captured = expected.queueOwnerIdentity;
|
|
2387
|
+
return !!live && live.instanceId === captured.instanceId && live.processId === captured.processId &&
|
|
2388
|
+
live.processBirthId === captured.processBirthId && live.sessionGeneration === captured.sessionGeneration;
|
|
2389
|
+
};
|
|
2103
2390
|
const state: TelegramUpdateWorkerStateSnapshot = {
|
|
2104
2391
|
phase: "stopped",
|
|
2105
2392
|
generation: 0,
|
|
@@ -2113,6 +2400,11 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2113
2400
|
unsettledExecutionCount: 0,
|
|
2114
2401
|
};
|
|
2115
2402
|
const claims = new Map<number, TelegramUpdateWorkerClaim>();
|
|
2403
|
+
const deferredSources = new Map<number, {
|
|
2404
|
+
entry: TelegramUpdateWorkerJournalSnapshot["entries"][number];
|
|
2405
|
+
journalBindingKey: string;
|
|
2406
|
+
historical?: true;
|
|
2407
|
+
}>();
|
|
2116
2408
|
const unsettledExecutionsByUpdateId = new Map<
|
|
2117
2409
|
number,
|
|
2118
2410
|
Set<Promise<TelegramUpdateWorkerExecutionSettlement>>
|
|
@@ -2122,8 +2414,15 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2122
2414
|
{
|
|
2123
2415
|
receipt: TelegramQueueAdmissionReceiptLike;
|
|
2124
2416
|
queueOwner: TelegramUpdateJournalQueueOwner;
|
|
2417
|
+
sourceCompletions?: TelegramQueueSourceCompletion[];
|
|
2125
2418
|
}
|
|
2126
2419
|
>();
|
|
2420
|
+
const requiredScopedQueueReceipts = new Set<string>();
|
|
2421
|
+
const scopedQueueCompletionAttempts = new Map<string, {
|
|
2422
|
+
owner: TelegramUpdateWorkerOwner<TContext>; journalBindingKey: string; reason: TelegramQueueReceiptCompletionReason;
|
|
2423
|
+
receipts: { queueKind: "prompt" | "control"; receiptId: string; sourceUpdateIds: readonly number[]; queueOwner: TelegramUpdateJournalQueueOwner }[];
|
|
2424
|
+
completions: TelegramUpdateJournalSourceCompletion[];
|
|
2425
|
+
}>();
|
|
2127
2426
|
const unsettledExecutions =
|
|
2128
2427
|
new Set<Promise<TelegramUpdateWorkerExecutionSettlement>>();
|
|
2129
2428
|
let owner: TelegramUpdateWorkerOwner<TContext> | undefined;
|
|
@@ -2134,6 +2433,8 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2134
2433
|
let retryTimer: unknown;
|
|
2135
2434
|
let retryTimerAtMs: number | undefined;
|
|
2136
2435
|
let retryTimerToken: object | undefined;
|
|
2436
|
+
let routingTimer: unknown;
|
|
2437
|
+
let routingTimerToken: object | undefined;
|
|
2137
2438
|
let launchDrain: () => void = () => {};
|
|
2138
2439
|
|
|
2139
2440
|
const recordRuntimeEvent = (
|
|
@@ -2158,12 +2459,20 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2158
2459
|
const updateClaimCounts = (): void => {
|
|
2159
2460
|
let deferredClaimCount = 0;
|
|
2160
2461
|
let queuedClaimCount = 0;
|
|
2462
|
+
let abandoningClaimCount = 0;
|
|
2463
|
+
let historicalClaimCount = 0;
|
|
2161
2464
|
for (const claim of claims.values()) {
|
|
2162
2465
|
if (claim === "queued") queuedClaimCount += 1;
|
|
2163
2466
|
else deferredClaimCount += 1;
|
|
2467
|
+
if (claim === "abandoning") abandoningClaimCount += 1;
|
|
2468
|
+
if (claim === "historical" || claim === "retained") historicalClaimCount += 1;
|
|
2164
2469
|
}
|
|
2165
2470
|
state.deferredClaimCount = deferredClaimCount;
|
|
2166
2471
|
state.queuedClaimCount = queuedClaimCount;
|
|
2472
|
+
if (abandoningClaimCount > 0) state.abandoningClaimCount = abandoningClaimCount;
|
|
2473
|
+
else delete state.abandoningClaimCount;
|
|
2474
|
+
if (historicalClaimCount > 0) state.historicalClaimCount = historicalClaimCount;
|
|
2475
|
+
else delete state.historicalClaimCount;
|
|
2167
2476
|
};
|
|
2168
2477
|
|
|
2169
2478
|
const releaseDeferredClaims = (updateIds: readonly number[]): void => {
|
|
@@ -2171,6 +2480,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2171
2480
|
for (const updateId of updateIds) {
|
|
2172
2481
|
if (claims.get(updateId) !== "deferred") continue;
|
|
2173
2482
|
claims.delete(updateId);
|
|
2483
|
+
deferredSources.delete(updateId);
|
|
2174
2484
|
changed = true;
|
|
2175
2485
|
}
|
|
2176
2486
|
if (!changed) return;
|
|
@@ -2233,11 +2543,18 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2233
2543
|
: {}),
|
|
2234
2544
|
});
|
|
2235
2545
|
|
|
2236
|
-
const
|
|
2546
|
+
const inspectQueueReceipt = deps.journal.isQueueReceiptCurrent?.bind(deps.journal);
|
|
2547
|
+
const inspectQueuedSources = deps.journal.inspectQueuedReceipt?.bind(deps.journal);
|
|
2548
|
+
const prepareQueueReceipt = deps.beforeQueueReceiptPublished;
|
|
2549
|
+
const pendingQueuePublications = new Map<string, { receipt: TelegramQueueAdmissionReceiptLike;
|
|
2550
|
+
queueOwner: TelegramUpdateJournalQueueOwner; owner: TelegramUpdateWorkerOwner<TContext>; task: Promise<void> }>();
|
|
2551
|
+
const publishCommittedQueueReceipt = async (
|
|
2237
2552
|
receipt: TelegramQueueAdmissionReceiptLike,
|
|
2238
2553
|
queueOwner: TelegramUpdateJournalQueueOwner,
|
|
2239
|
-
|
|
2240
|
-
): boolean => {
|
|
2554
|
+
expectedOwner: TelegramUpdateWorkerOwner<TContext>,
|
|
2555
|
+
): Promise<boolean> => {
|
|
2556
|
+
const ctx = expectedOwner.ctx;
|
|
2557
|
+
let sourceCompletions: TelegramQueueSourceCompletion[] | undefined;
|
|
2241
2558
|
const normalized = bindQueueReceiptToJournal(receipt);
|
|
2242
2559
|
const existing = committedQueueReceipts.get(receipt.receiptId);
|
|
2243
2560
|
if (existing) {
|
|
@@ -2257,29 +2574,86 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2257
2574
|
}
|
|
2258
2575
|
return false;
|
|
2259
2576
|
}
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2577
|
+
if (prepareQueueReceipt) {
|
|
2578
|
+
const pending = pendingQueuePublications.get(normalized.receiptId);
|
|
2579
|
+
if (pending) {
|
|
2580
|
+
if (pending.owner !== expectedOwner || !areTelegramQueueAdmissionReceiptsEqual(pending.receipt, normalized) ||
|
|
2581
|
+
!areTelegramUpdateJournalQueueOwnersEqual(pending.queueOwner, queueOwner)) {
|
|
2582
|
+
throw new TelegramUpdateAdmissionOutcomeError("Telegram queue publication has conflicting in-flight authority.");
|
|
2583
|
+
}
|
|
2584
|
+
await pending.task;
|
|
2585
|
+
return false;
|
|
2586
|
+
}
|
|
2587
|
+
const journalBindingKey = deps.getJournalBindingKey?.();
|
|
2588
|
+
const current = (): boolean => owner === expectedOwner && !expectedOwner.controller.signal.aborted && deps.hasAuthority(ctx) && deps.isContextCurrent?.(ctx) !== false &&
|
|
2589
|
+
isQueueOwnerIdentityCurrent(expectedOwner) && deps.getJournalBindingKey?.() === journalBindingKey;
|
|
2590
|
+
if (!journalBindingKey || !current() || !inspectQueueReceipt ||
|
|
2591
|
+
inspectQueueReceipt(normalizeQueueReceipt(normalized), { ...queueOwner }) !== true || !current()) {
|
|
2592
|
+
throw new TelegramUpdateAdmissionOutcomeError("Telegram queue publication requires exact current receipt inspection.");
|
|
2593
|
+
}
|
|
2594
|
+
const task = Promise.resolve().then(async () => {
|
|
2595
|
+
if (!current()) throw new TelegramUpdateAdmissionOutcomeError("Telegram queue publication authority ended before acceptance.");
|
|
2596
|
+
const prepared = await prepareQueueReceipt(normalizeQueueReceipt(normalized), { ...queueOwner }, ctx, current);
|
|
2597
|
+
if (prepared !== undefined) {
|
|
2598
|
+
sourceCompletions = normalizeTelegramQueueSourceCompletions(prepared, new Set(normalized.sourceUpdateIds), journalBindingKey, false);
|
|
2599
|
+
if (!completeQueuedExact || !inspectCompletion) throw new Error("Telegram scoped queue terminal capabilities are unavailable.");
|
|
2600
|
+
if (sourceCompletions.length !== normalized.sourceUpdateIds.length) {
|
|
2601
|
+
if (!current() || !inspectQueuedSources) throw new Error("Telegram partial queue scopes require strict current whole-receipt origin inspection.");
|
|
2602
|
+
const expected = { queueKind: normalized.queueKind, receiptId: normalized.receiptId,
|
|
2603
|
+
sourceUpdateIds: [...normalized.sourceUpdateIds], queueOwner: { ...queueOwner } };
|
|
2604
|
+
const proof = inspectQueuedSources(structuredClone(expected));
|
|
2605
|
+
if (!current() || !proof || !isDeepStrictEqual(proof.receipt, expected) ||
|
|
2606
|
+
!Array.isArray(proof.sources) || proof.sources.length !== expected.sourceUpdateIds.length ||
|
|
2607
|
+
proof.sources.some((source, index) => source.updateId !== expected.sourceUpdateIds[index] || typeof source.sourceSha256 !== "string" || !/^[a-f0-9]{64}$/u.test(source.sourceSha256)) ||
|
|
2608
|
+
sourceCompletions.some(scope => proof.sources.find(source => source.updateId === scope.updateId)?.sourceSha256 !== scope.sourceSha256)) {
|
|
2609
|
+
throw new Error("Telegram partial queue scope origin was not confirmed under whole-receipt authority.");
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
for (const { journalBindingKey: _binding, ...completion } of sourceCompletions) {
|
|
2613
|
+
if (!current() || inspectCompletion({ ...completion }) !== undefined || !current()) {
|
|
2614
|
+
throw new Error("Telegram queued source completion authority is contradictory.");
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
if (!current() || inspectQueueReceipt(normalizeQueueReceipt(normalized), { ...queueOwner }) !== true || !current()) {
|
|
2619
|
+
throw new TelegramUpdateAdmissionOutcomeError("Telegram queue publication authority changed after acceptance.");
|
|
2620
|
+
}
|
|
2621
|
+
publishReady();
|
|
2622
|
+
}).finally(() => { if (pendingQueuePublications.get(normalized.receiptId)?.task === task) pendingQueuePublications.delete(normalized.receiptId); });
|
|
2623
|
+
pendingQueuePublications.set(normalized.receiptId, { receipt: normalizeQueueReceipt(normalized), queueOwner: { ...queueOwner }, owner: expectedOwner, task });
|
|
2624
|
+
await task;
|
|
2625
|
+
return true;
|
|
2271
2626
|
}
|
|
2627
|
+
publishReady();
|
|
2272
2628
|
return true;
|
|
2629
|
+
|
|
2630
|
+
function publishReady(): void {
|
|
2631
|
+
committedQueueReceipts.set(receipt.receiptId, {
|
|
2632
|
+
receipt: normalized,
|
|
2633
|
+
queueOwner: { ...queueOwner },
|
|
2634
|
+
...(sourceCompletions ? { sourceCompletions: sourceCompletions.map(value => ({ ...value })) } : {}),
|
|
2635
|
+
});
|
|
2636
|
+
if (sourceCompletions) requiredScopedQueueReceipts.add(receipt.receiptId);
|
|
2637
|
+
try {
|
|
2638
|
+
deps.onQueueReceiptCommitted?.(normalizeQueueReceipt(normalized), ctx);
|
|
2639
|
+
} catch (error) {
|
|
2640
|
+
recordRuntimeEvent(error, {
|
|
2641
|
+
phase: "queue-receipt-observer",
|
|
2642
|
+
receiptId: normalized.receiptId,
|
|
2643
|
+
});
|
|
2644
|
+
}
|
|
2645
|
+
}
|
|
2273
2646
|
};
|
|
2274
2647
|
|
|
2275
2648
|
const getCurrentQueueReceipt = (receipt: TelegramQueueAdmissionReceiptLike) => {
|
|
2649
|
+
if (scopedQueueCompletionAttempts.has(receipt.receiptId)) return undefined;
|
|
2276
2650
|
const committed = committedQueueReceipts.get(receipt.receiptId);
|
|
2277
2651
|
if (!committed || !areTelegramQueueAdmissionReceiptsEqual(
|
|
2278
2652
|
committed.receipt, normalizeQueueReceipt(receipt),
|
|
2279
2653
|
)) return undefined;
|
|
2280
2654
|
try {
|
|
2281
|
-
if (
|
|
2282
|
-
|
|
2655
|
+
if (inspectQueueReceipt &&
|
|
2656
|
+
inspectQueueReceipt(normalizeQueueReceipt(committed.receipt), { ...committed.queueOwner }) !== true) {
|
|
2283
2657
|
return undefined;
|
|
2284
2658
|
}
|
|
2285
2659
|
} catch (error) {
|
|
@@ -2327,10 +2701,10 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2327
2701
|
}, Math.max(0, nextRetryAtMs - getNowMs()));
|
|
2328
2702
|
};
|
|
2329
2703
|
|
|
2330
|
-
const refreshJournalState = (
|
|
2704
|
+
const refreshJournalState = async (
|
|
2331
2705
|
snapshot: TelegramUpdateWorkerJournalSnapshot,
|
|
2332
2706
|
expectedOwner: TelegramUpdateWorkerOwner<TContext>,
|
|
2333
|
-
): number | undefined => {
|
|
2707
|
+
): Promise<number | undefined> => {
|
|
2334
2708
|
// Validate the whole snapshot before reconstructing any queue authority.
|
|
2335
2709
|
if ((snapshot.version !== TELEGRAM_UPDATE_JOURNAL_VERSION &&
|
|
2336
2710
|
snapshot.version !== TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION &&
|
|
@@ -2367,7 +2741,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2367
2741
|
oldestAdmittedAtMs === undefined
|
|
2368
2742
|
? entry.admittedAtMs
|
|
2369
2743
|
: Math.min(oldestAdmittedAtMs, entry.admittedAtMs);
|
|
2370
|
-
if (entry.state === "retry-wait" && entry.nextRetryAtMs !== undefined) {
|
|
2744
|
+
if (entry.state === "retry-wait" && entry.nextRetryAtMs !== undefined && !claims.has(entry.updateId)) {
|
|
2371
2745
|
scheduledRetryAtMs =
|
|
2372
2746
|
scheduledRetryAtMs === undefined
|
|
2373
2747
|
? entry.nextRetryAtMs
|
|
@@ -2449,21 +2823,25 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2449
2823
|
}
|
|
2450
2824
|
for (const [updateId, claim] of claims) {
|
|
2451
2825
|
if (
|
|
2452
|
-
!availableUpdateIds.has(updateId) ||
|
|
2826
|
+
(claim !== "abandoning" && !availableUpdateIds.has(updateId)) ||
|
|
2453
2827
|
(claim === "queued" && !locallyOwnedQueuedUpdateIds.has(updateId))
|
|
2454
2828
|
) {
|
|
2455
2829
|
claims.delete(updateId);
|
|
2456
2830
|
}
|
|
2457
2831
|
}
|
|
2832
|
+
for (const updateId of deferredSources.keys()) {
|
|
2833
|
+
const claim = claims.get(updateId);
|
|
2834
|
+
if (claim !== "deferred" && claim !== "abandoning" && claim !== "historical" && claim !== "retained") deferredSources.delete(updateId);
|
|
2835
|
+
}
|
|
2458
2836
|
for (const [receiptId, receipt] of queuedReceiptEntries) {
|
|
2459
|
-
publishCommittedQueueReceipt(
|
|
2837
|
+
await publishCommittedQueueReceipt(
|
|
2460
2838
|
{
|
|
2461
2839
|
queueKind: receipt.queueKind,
|
|
2462
2840
|
receiptId,
|
|
2463
2841
|
sourceUpdateIds: receipt.sourceUpdateIds,
|
|
2464
2842
|
},
|
|
2465
2843
|
receipt.queueOwner,
|
|
2466
|
-
expectedOwner
|
|
2844
|
+
expectedOwner,
|
|
2467
2845
|
);
|
|
2468
2846
|
}
|
|
2469
2847
|
for (const [receiptId] of committedQueueReceipts) {
|
|
@@ -2598,10 +2976,8 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2598
2976
|
expectedOwner: TelegramUpdateWorkerOwner<TContext>,
|
|
2599
2977
|
currentUpdateId: number,
|
|
2600
2978
|
outcome: TelegramQueuedUpdateAdmissionOutcome,
|
|
2601
|
-
):
|
|
2602
|
-
| "
|
|
2603
|
-
| "duplicate"
|
|
2604
|
-
| Exclude<TelegramUpdateWorkerDrainResult, "idle"> => {
|
|
2979
|
+
): "committed" | "duplicate" | Exclude<TelegramUpdateWorkerDrainResult, "idle"> |
|
|
2980
|
+
Promise<"committed" | "duplicate" | Exclude<TelegramUpdateWorkerDrainResult, "idle">> => {
|
|
2605
2981
|
const normalized = normalizeQueueReceipt(outcome);
|
|
2606
2982
|
const existing = committedQueueReceipts.get(normalized.receiptId);
|
|
2607
2983
|
if (existing) {
|
|
@@ -2674,21 +3050,8 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2674
3050
|
for (const sourceUpdateId of normalized.sourceUpdateIds) {
|
|
2675
3051
|
claims.set(sourceUpdateId, "queued");
|
|
2676
3052
|
}
|
|
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";
|
|
3053
|
+
return publishCommittedQueueReceipt(normalized, queueOwner, expectedOwner).then(() => "committed" as const,
|
|
3054
|
+
error => blockWithFailure("invalid-outcome", "queue-receipt-publish", error, currentUpdateId));
|
|
2692
3055
|
};
|
|
2693
3056
|
|
|
2694
3057
|
const persistExecutionFailure = (
|
|
@@ -2774,16 +3137,45 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2774
3137
|
|
|
2775
3138
|
const commitCompletedBatch = (
|
|
2776
3139
|
expectedOwner: TelegramUpdateWorkerOwner<TContext>,
|
|
2777
|
-
|
|
3140
|
+
completions: readonly { updateId: number; expectedSource?: TelegramDeferredSourceEvidence; spent?: true }[],
|
|
2778
3141
|
): Exclude<TelegramUpdateWorkerDrainResult, "idle"> | undefined => {
|
|
3142
|
+
const updateIds = completions.map(value => value.updateId);
|
|
2779
3143
|
if (updateIds.length === 0) return undefined;
|
|
2780
3144
|
const commitAuthority = checkAuthority(
|
|
2781
3145
|
expectedOwner,
|
|
2782
3146
|
updateIds[updateIds.length - 1],
|
|
2783
3147
|
);
|
|
2784
3148
|
if (commitAuthority) return commitAuthority;
|
|
3149
|
+
const journalBindingKey = deps.getJournalBindingKey?.();
|
|
2785
3150
|
try {
|
|
2786
|
-
const
|
|
3151
|
+
const expectedSources = completions.flatMap(value => value.expectedSource ? [value.expectedSource] : []);
|
|
3152
|
+
if (expectedSources.some(value => value.journalBindingKey !== journalBindingKey) ||
|
|
3153
|
+
(expectedSources.length > 0 && (deps.isContextCurrent?.(expectedOwner.ctx) === false || !isQueueOwnerIdentityCurrent(expectedOwner)))) {
|
|
3154
|
+
throw new Error("Telegram guarded completion source authority changed.");
|
|
3155
|
+
}
|
|
3156
|
+
const scoped = expectedSources.flatMap(({ updateId, sourceSha256, completionSha256 }) => completionSha256 !== undefined
|
|
3157
|
+
? [{ updateId, sourceSha256, completionSha256 }] : []).sort((a, b) => a.updateId - b.updateId);
|
|
3158
|
+
if (scoped.length > 0 && !inspectCompletion) throw new Error("Telegram source completion inspection is unavailable.");
|
|
3159
|
+
let removed: { removedUpdateIds: readonly number[]; sourceCompletions?: readonly TelegramUpdateJournalSourceCompletion[] };
|
|
3160
|
+
if (expectedSources.length > 0) {
|
|
3161
|
+
if (!completeExact) throw new Error("Telegram exact source completion is unavailable.");
|
|
3162
|
+
removed = completeExact(updateIds, expectedSources.map(({ updateId, sourceSha256 }) => ({ updateId, sourceSha256 })),
|
|
3163
|
+
scoped.length > 0 ? scoped.map(completion => ({ ...completion })) : undefined);
|
|
3164
|
+
} else removed = deps.journal.removeCompleted(updateIds);
|
|
3165
|
+
if (scoped.length > 0) {
|
|
3166
|
+
const afterCommit = checkAuthority(expectedOwner, updateIds.at(-1));
|
|
3167
|
+
if (afterCommit) return afterCommit;
|
|
3168
|
+
if (deps.isContextCurrent?.(expectedOwner.ctx) === false || !isQueueOwnerIdentityCurrent(expectedOwner) ||
|
|
3169
|
+
deps.getJournalBindingKey?.() !== journalBindingKey) throw new Error("Telegram source completion ACK authority changed.");
|
|
3170
|
+
if (!isDeepStrictEqual(removed.sourceCompletions, scoped)) throw new Error("Telegram source completion ACK was not confirmed.");
|
|
3171
|
+
for (const completion of scoped) {
|
|
3172
|
+
if (!isDeepStrictEqual(inspectCompletion!({ ...completion }), completion)) throw new Error("Telegram source completion ACK was not retained.");
|
|
3173
|
+
const afterRead = checkAuthority(expectedOwner, completion.updateId);
|
|
3174
|
+
if (afterRead) return afterRead;
|
|
3175
|
+
if (deps.isContextCurrent?.(expectedOwner.ctx) === false || !isQueueOwnerIdentityCurrent(expectedOwner) ||
|
|
3176
|
+
deps.getJournalBindingKey?.() !== journalBindingKey) throw new Error("Telegram source completion ACK authority changed.");
|
|
3177
|
+
}
|
|
3178
|
+
}
|
|
2787
3179
|
const removedIds = new Set(removed.removedUpdateIds);
|
|
2788
3180
|
if (updateIds.some((updateId) => !removedIds.has(updateId))) {
|
|
2789
3181
|
throw new Error(
|
|
@@ -2799,12 +3191,15 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2799
3191
|
);
|
|
2800
3192
|
}
|
|
2801
3193
|
const completedAtMs = getNowMs();
|
|
2802
|
-
for (const updateId of
|
|
3194
|
+
for (const { updateId, spent } of completions) {
|
|
2803
3195
|
claims.delete(updateId);
|
|
3196
|
+
deferredSources.delete(updateId);
|
|
3197
|
+
// Spending is disposition, not completion: no task, cleanup or session observer runs.
|
|
3198
|
+
if (spent) continue;
|
|
2804
3199
|
state.lastCompletedUpdateId = updateId;
|
|
2805
3200
|
state.lastCompletedAtMs = completedAtMs;
|
|
2806
3201
|
try {
|
|
2807
|
-
deps.onUpdateCompleted?.(updateId, expectedOwner.ctx);
|
|
3202
|
+
deps.onUpdateCompleted?.(updateId, expectedOwner.ctx, journalBindingKey);
|
|
2808
3203
|
} catch (error) {
|
|
2809
3204
|
recordRuntimeEvent(error, {
|
|
2810
3205
|
phase: "update-completion-observer",
|
|
@@ -2815,6 +3210,94 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2815
3210
|
return undefined;
|
|
2816
3211
|
};
|
|
2817
3212
|
|
|
3213
|
+
const clearRoutingTimer = (): void => {
|
|
3214
|
+
if (routingTimer !== undefined) cancelRetry(routingTimer);
|
|
3215
|
+
routingTimer = undefined; routingTimerToken = undefined;
|
|
3216
|
+
};
|
|
3217
|
+
const reconcileRoutingInputs = async (snapshot: TelegramUpdateWorkerJournalSnapshot, expectedOwner: TelegramUpdateWorkerOwner<TContext>): Promise<boolean> => {
|
|
3218
|
+
clearRoutingTimer();
|
|
3219
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(snapshot.version) || !expireRouting || !expireRoutingObserver) return false;
|
|
3220
|
+
let nextAtMs: number | undefined, changed = false;
|
|
3221
|
+
const binding = deps.getJournalBindingKey?.(), snapshotIds = new Set(snapshot.entries.map(entry => entry.updateId));
|
|
3222
|
+
// Lost expiry ACKs reconcile the exact held original against its body-free discard tombstone.
|
|
3223
|
+
const candidates = [...snapshot.entries, ...[...deferredSources.values()]
|
|
3224
|
+
.filter(source => source.journalBindingKey === binding && !snapshotIds.has(source.entry.updateId))
|
|
3225
|
+
.map(source => source.entry)];
|
|
3226
|
+
for (const entry of candidates) {
|
|
3227
|
+
const lifetime = entry.routingInput;
|
|
3228
|
+
if (!lifetime || entry.state !== "pending" || !binding || claims.get(entry.updateId) === "queued" ||
|
|
3229
|
+
unsettledExecutionsByUpdateId.get(entry.updateId)?.size) continue;
|
|
3230
|
+
// Classify the first startup snapshot before constructing any historical expiry carrier.
|
|
3231
|
+
if (expectedOwner.startupUpdateIds?.has(entry.updateId) && !claims.has(entry.updateId) && deps.shouldReviewHistoricalInput) {
|
|
3232
|
+
nextAtMs = Math.min(nextAtMs ?? Infinity, Math.max(getNowMs() + 1, lifetime.expiresAtMs));
|
|
3233
|
+
continue;
|
|
3234
|
+
}
|
|
3235
|
+
if (getNowMs() < lifetime.expiresAtMs) { nextAtMs = Math.min(nextAtMs ?? Infinity, lifetime.expiresAtMs); continue; }
|
|
3236
|
+
const existing = deferredSources.get(entry.updateId);
|
|
3237
|
+
if (existing && !isDeepStrictEqual(existing.entry, entry)) {
|
|
3238
|
+
// Lost clock/selection ACKs may leave only this immutable metadata behind its exact source; reconciliation grants expiry, never another dispatch.
|
|
3239
|
+
const previous = existing.entry.routingInput;
|
|
3240
|
+
if (!isDeepStrictEqual({ ...existing.entry, routingInput: lifetime }, entry) ||
|
|
3241
|
+
(previous && (!isDeepStrictEqual({ ...previous, phase: lifetime.phase }, lifetime) ||
|
|
3242
|
+
(previous.phase === "selected" && lifetime.phase !== "selected")))) continue;
|
|
3243
|
+
existing.entry = structuredClone(entry);
|
|
3244
|
+
}
|
|
3245
|
+
const source = existing ?? { entry: structuredClone(entry), journalBindingKey: binding, historical: true as const };
|
|
3246
|
+
deferredSources.set(entry.updateId, source);
|
|
3247
|
+
if (!claims.has(entry.updateId)) claims.set(entry.updateId, "historical");
|
|
3248
|
+
const current = () => owner === expectedOwner && !expectedOwner.controller.signal.aborted &&
|
|
3249
|
+
deps.isContextCurrent?.(expectedOwner.ctx) !== false && deps.getJournalBindingKey?.() === binding &&
|
|
3250
|
+
deps.hasAuthority(expectedOwner.ctx) && isQueueOwnerIdentityCurrent(expectedOwner) && deferredSources.get(entry.updateId) === source &&
|
|
3251
|
+
isDeepStrictEqual(source.entry, entry) && claims.get(entry.updateId) !== "queued";
|
|
3252
|
+
let committed: TelegramRoutingInputExpiryResult | undefined;
|
|
3253
|
+
try {
|
|
3254
|
+
if (current()) await expireRoutingObserver({ original: structuredClone(entry), journalBindingKey: binding, isCurrent: current,
|
|
3255
|
+
expire() {
|
|
3256
|
+
if (!current()) return undefined;
|
|
3257
|
+
if (committed) return { ...committed, duplicate: true };
|
|
3258
|
+
claims.set(entry.updateId, "abandoning");
|
|
3259
|
+
committed = expireRouting!({ entry: source.entry, journalBindingKey: binding, operatorUserId: lifetime.operatorUserId, isCurrent: current });
|
|
3260
|
+
// Source expiry is terminal before chooser/cleanup awaits; later failures must not retain a deferred/retry source.
|
|
3261
|
+
claims.delete(entry.updateId); deferredSources.delete(entry.updateId);
|
|
3262
|
+
state.journalEntryCount = committed.entryCount; state.journalSerializedBytes = committed.serializedBytes;
|
|
3263
|
+
updateClaimCounts(); notifyStateChange(); changed ||= !committed.duplicate;
|
|
3264
|
+
return committed;
|
|
3265
|
+
} }, expectedOwner.ctx, expectedOwner.controller.signal);
|
|
3266
|
+
} catch (error) { recordRuntimeEvent(error, { phase: "routing-input-expiry", updateId: entry.updateId }); }
|
|
3267
|
+
if (!committed) nextAtMs = Math.min(nextAtMs ?? Infinity, getNowMs() + 60_000);
|
|
3268
|
+
}
|
|
3269
|
+
if (nextAtMs !== undefined && owner === expectedOwner && !expectedOwner.controller.signal.aborted) {
|
|
3270
|
+
const token = {}; routingTimerToken = token;
|
|
3271
|
+
routingTimer = scheduleRetry(() => {
|
|
3272
|
+
if (routingTimerToken !== token || owner !== expectedOwner || expectedOwner.controller.signal.aborted) return;
|
|
3273
|
+
routingTimer = undefined; routingTimerToken = undefined; pendingSignal = true; launchDrain();
|
|
3274
|
+
}, Math.max(1, nextAtMs - getNowMs()));
|
|
3275
|
+
}
|
|
3276
|
+
return changed;
|
|
3277
|
+
};
|
|
3278
|
+
const mutateRoutingSource = (input: { updateId: number; signal: AbortSignal; operatorUserId: number; sourceUpdateIds: readonly number[] }, select: boolean) => {
|
|
3279
|
+
const expectedOwner = owner, ids = [...input.sourceUpdateIds];
|
|
3280
|
+
if (!ids.length || !ids.includes(input.updateId) || new Set(ids).size !== ids.length || ids.some(id => !Number.isSafeInteger(id) || id < 0)) return undefined;
|
|
3281
|
+
const sources = ids.map(id => deferredSources.get(id)), binding = sources[0]?.journalBindingKey;
|
|
3282
|
+
const current = () => !!expectedOwner && owner === expectedOwner && expectedOwner.controller.signal === input.signal && !input.signal.aborted &&
|
|
3283
|
+
!!binding && sources.every((source, index) => !!source && deferredSources.get(ids[index]!) === source && !source.historical &&
|
|
3284
|
+
source.journalBindingKey === binding && claims.get(ids[index]!) !== "queued" && claims.get(ids[index]!) !== "abandoning") &&
|
|
3285
|
+
deps.getJournalBindingKey?.() === binding && deps.isContextCurrent?.(expectedOwner.ctx) !== false &&
|
|
3286
|
+
deps.hasAuthority(expectedOwner.ctx) && isQueueOwnerIdentityCurrent(expectedOwner);
|
|
3287
|
+
if (!current() || !armRouting || !selectRouting || !expireRoutingObserver) return undefined;
|
|
3288
|
+
const authority = { journalBindingKey: binding!, entries: sources.map(source => structuredClone(source!.entry)), operatorUserId: input.operatorUserId, isCurrent: current };
|
|
3289
|
+
const result = select ? selectRouting(authority) : { issued: false, entries: armRouting({ ...authority, publishedAtMs: getNowMs() }) };
|
|
3290
|
+
if (result.entries.length !== ids.length || result.entries.some((entry, index) => entry.updateId !== ids[index] || !entry.routingInput) || !current()) {
|
|
3291
|
+
throw new Error("Telegram routing input lifetime ACK was not confirmed.");
|
|
3292
|
+
}
|
|
3293
|
+
const retained = deps.journal.read().entries;
|
|
3294
|
+
if (result.entries.some(entry => !isDeepStrictEqual(retained.find(value => value.updateId === entry.updateId), entry)) || !current()) {
|
|
3295
|
+
throw new Error("Telegram routing input lifetime ACK was not retained.");
|
|
3296
|
+
}
|
|
3297
|
+
result.entries.forEach((entry, index) => { sources[index]!.entry = structuredClone(entry); });
|
|
3298
|
+
pendingSignal = true; launchDrain();
|
|
3299
|
+
return { issued: result.issued, lifetime: { ...result.entries[0]!.routingInput! } };
|
|
3300
|
+
};
|
|
2818
3301
|
const drain = async (
|
|
2819
3302
|
expectedOwner: TelegramUpdateWorkerOwner<TContext>,
|
|
2820
3303
|
): Promise<TelegramUpdateWorkerDrainResult> => {
|
|
@@ -2825,10 +3308,15 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2825
3308
|
let scheduledRetryAtMs: number | undefined;
|
|
2826
3309
|
try {
|
|
2827
3310
|
snapshot = deps.journal.read();
|
|
2828
|
-
scheduledRetryAtMs = refreshJournalState(snapshot, expectedOwner);
|
|
3311
|
+
scheduledRetryAtMs = await refreshJournalState(snapshot, expectedOwner);
|
|
3312
|
+
// One complete validated baseline per generation, before batching or execution.
|
|
3313
|
+
// Replayed v1 entries do not prove that a prior process never forwarded them.
|
|
3314
|
+
expectedOwner.startupUpdateIds ??= new Set(snapshot.entries.map(entry => entry.updateId));
|
|
2829
3315
|
} catch (error) {
|
|
2830
3316
|
return blockWithFailure("journal-read", "journal-read", error);
|
|
2831
3317
|
}
|
|
3318
|
+
if (await reconcileRoutingInputs(snapshot, expectedOwner)) continue;
|
|
3319
|
+
if (owner !== expectedOwner || expectedOwner.controller.signal.aborted) return "aborted";
|
|
2832
3320
|
const nowMs = getNowMs();
|
|
2833
3321
|
const entries: TelegramUpdateWorkerJournalSnapshot["entries"][number][] =
|
|
2834
3322
|
[];
|
|
@@ -2885,7 +3373,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2885
3373
|
transition("idle");
|
|
2886
3374
|
return "idle";
|
|
2887
3375
|
}
|
|
2888
|
-
const completedUpdateIds: number[] = [];
|
|
3376
|
+
const completedUpdateIds: { updateId: number; expectedSource?: TelegramDeferredSourceEvidence; spent?: true }[] = [];
|
|
2889
3377
|
let snapshotInvalidated = false;
|
|
2890
3378
|
for (const entry of entries) {
|
|
2891
3379
|
const priorExecutions = unsettledExecutionsByUpdateId.get(entry.updateId);
|
|
@@ -2907,9 +3395,88 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2907
3395
|
break;
|
|
2908
3396
|
}
|
|
2909
3397
|
if (entry.preApprovalExcluded === true) {
|
|
2910
|
-
completedUpdateIds.push(entry.updateId);
|
|
3398
|
+
completedUpdateIds.push({ updateId: entry.updateId });
|
|
2911
3399
|
continue;
|
|
2912
3400
|
}
|
|
3401
|
+
if (isTelegramUpdateJournalLegacyFamilyVersion(snapshot.version) && entry.state === "pending" && deps.journal.inspectPendingRetention) {
|
|
3402
|
+
const authorityResult = checkAuthority(expectedOwner, entry.updateId);
|
|
3403
|
+
if (authorityResult) return authorityResult;
|
|
3404
|
+
let retained: TelegramUpdateJournalPendingRetentionEvidence | undefined;
|
|
3405
|
+
let unverifiable = false;
|
|
3406
|
+
try { retained = deps.journal.inspectPendingRetention(entry); }
|
|
3407
|
+
catch (error) {
|
|
3408
|
+
unverifiable = true;
|
|
3409
|
+
recordRuntimeEvent(error, { phase: "pending-retention-inspection", updateId: entry.updateId });
|
|
3410
|
+
}
|
|
3411
|
+
if (retained || unverifiable) {
|
|
3412
|
+
// A copy is protection evidence only. A fresh owner must explicitly retry
|
|
3413
|
+
// cancellation; neither startup nor a malformed copy may dispatch the source.
|
|
3414
|
+
claims.set(entry.updateId, "abandoning");
|
|
3415
|
+
const journalBindingKey = retained?.journalBindingKey ?? deps.getJournalBindingKey?.();
|
|
3416
|
+
if (journalBindingKey) deferredSources.set(entry.updateId, { entry, journalBindingKey });
|
|
3417
|
+
transition("deferred", entry.updateId);
|
|
3418
|
+
continue;
|
|
3419
|
+
}
|
|
3420
|
+
}
|
|
3421
|
+
if (isTelegramUpdateJournalLegacyFamilyVersion(snapshot.version) && (entry.state === "pending" || entry.state === "retry-wait")) {
|
|
3422
|
+
const historical = expectedOwner.startupUpdateIds.has(entry.updateId);
|
|
3423
|
+
const journalBindingKey = deps.getJournalBindingKey?.();
|
|
3424
|
+
// Classifiers and handler projections receive no mutable alias to CAS evidence.
|
|
3425
|
+
if (journalBindingKey) deferredSources.set(entry.updateId, { entry: structuredClone(entry), journalBindingKey,
|
|
3426
|
+
...(historical ? { historical: true } : {}) });
|
|
3427
|
+
const classifyPending = historical && entry.state === "pending" ? deps.shouldReviewHistoricalInput : deps.shouldHoldPendingInput;
|
|
3428
|
+
if (classifyPending) {
|
|
3429
|
+
const checkHistoricalAuthority = (): TelegramUpdateWorkerDrainResult | undefined => {
|
|
3430
|
+
if (owner !== expectedOwner || expectedOwner.controller.signal.aborted) return "aborted";
|
|
3431
|
+
if (deps.getJournalBindingKey?.() !== journalBindingKey || deps.isContextCurrent?.(expectedOwner.ctx) === false) {
|
|
3432
|
+
transition("blocked", entry.updateId, "authority-lost");
|
|
3433
|
+
return "blocked";
|
|
3434
|
+
}
|
|
3435
|
+
return checkAuthority(expectedOwner, entry.updateId);
|
|
3436
|
+
};
|
|
3437
|
+
const beforeClassification = checkHistoricalAuthority();
|
|
3438
|
+
if (beforeClassification) return beforeClassification;
|
|
3439
|
+
let review: boolean | "retain";
|
|
3440
|
+
try { review = await classifyPending(structuredClone(entry), expectedOwner.ctx, expectedOwner.controller.signal); }
|
|
3441
|
+
catch (error) {
|
|
3442
|
+
const authorityResult = checkHistoricalAuthority();
|
|
3443
|
+
if (authorityResult) return authorityResult;
|
|
3444
|
+
return blockWithFailure("execution", "historical-review-classification", error, entry.updateId);
|
|
3445
|
+
}
|
|
3446
|
+
const authorityResult = checkHistoricalAuthority();
|
|
3447
|
+
if (authorityResult) return authorityResult;
|
|
3448
|
+
if (typeof review !== "boolean" && !(historical && entry.state === "pending" && review === "retain"))
|
|
3449
|
+
return blockWithFailure("execution", "historical-review-classification",
|
|
3450
|
+
new TelegramUpdateAdmissionOutcomeError("Historical classification must return a boolean or retain verdict."), entry.updateId);
|
|
3451
|
+
// Classification may await a domain read; accepted/changed source evidence wins.
|
|
3452
|
+
try {
|
|
3453
|
+
const current = deps.journal.read();
|
|
3454
|
+
if (current.version !== snapshot.version || JSON.stringify(current.entries.find(candidate => candidate.updateId === entry.updateId)) !== JSON.stringify(entry)) {
|
|
3455
|
+
snapshotInvalidated = true;
|
|
3456
|
+
break;
|
|
3457
|
+
}
|
|
3458
|
+
} catch (error) { return blockWithFailure("journal-read", "historical-source-recheck", error, entry.updateId); }
|
|
3459
|
+
if (review === "retain") {
|
|
3460
|
+
claims.set(entry.updateId, "retained");
|
|
3461
|
+
transition("deferred", entry.updateId);
|
|
3462
|
+
continue;
|
|
3463
|
+
}
|
|
3464
|
+
if (review && historical && spendHistorical && !entry.routingInput && classifyPending === deps.shouldReviewHistoricalInput) {
|
|
3465
|
+
completedUpdateIds.push({ updateId: entry.updateId, spent: true });
|
|
3466
|
+
continue;
|
|
3467
|
+
}
|
|
3468
|
+
if (review) {
|
|
3469
|
+
claims.set(entry.updateId, "historical");
|
|
3470
|
+
transition("deferred", entry.updateId);
|
|
3471
|
+
continue;
|
|
3472
|
+
}
|
|
3473
|
+
}
|
|
3474
|
+
if (entry.state === "pending" && entry.routingInput) {
|
|
3475
|
+
claims.set(entry.updateId, "historical");
|
|
3476
|
+
transition("deferred", entry.updateId);
|
|
3477
|
+
continue;
|
|
3478
|
+
}
|
|
3479
|
+
}
|
|
2913
3480
|
clearRetryTimer();
|
|
2914
3481
|
transition("executing", entry.updateId);
|
|
2915
3482
|
const execution = await executeWithinOwner(expectedOwner, entry.update);
|
|
@@ -2958,7 +3525,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2958
3525
|
}
|
|
2959
3526
|
for (const sourceUpdateId of receipt.sourceUpdateIds) claims.set(sourceUpdateId, "queued");
|
|
2960
3527
|
try {
|
|
2961
|
-
publishCommittedQueueReceipt(receipt, outcome.queueReceipt.queueOwner, expectedOwner
|
|
3528
|
+
await publishCommittedQueueReceipt(receipt, outcome.queueReceipt.queueOwner, expectedOwner);
|
|
2962
3529
|
} catch (error) {
|
|
2963
3530
|
return blockWithFailure("invalid-outcome", "queue-receipt-publish", error,
|
|
2964
3531
|
entry.updateId);
|
|
@@ -2975,7 +3542,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2975
3542
|
if (postExecutionAuthority) return postExecutionAuthority;
|
|
2976
3543
|
const claimableUpdateIds = new Set<number>([
|
|
2977
3544
|
entry.updateId,
|
|
2978
|
-
...claims.
|
|
3545
|
+
...[...claims].filter(([, claim]) => claim !== "abandoning" && claim !== "historical" && claim !== "retained").map(([id]) => id),
|
|
2979
3546
|
]);
|
|
2980
3547
|
let outcome: TelegramUpdateAdmissionOutcome;
|
|
2981
3548
|
try {
|
|
@@ -2993,7 +3560,10 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
2993
3560
|
);
|
|
2994
3561
|
}
|
|
2995
3562
|
if (outcome.kind === "deferred") {
|
|
2996
|
-
|
|
3563
|
+
if (outcome.routingReview && !deferredSources.get(entry.updateId)?.historical) {
|
|
3564
|
+
return blockWithFailure("invalid-outcome", "historical-review-source", new TelegramUpdateAdmissionOutcomeError("Historical review needs an exact startup source."), entry.updateId);
|
|
3565
|
+
}
|
|
3566
|
+
claims.set(entry.updateId, outcome.routingReview ? "historical" : "deferred");
|
|
2997
3567
|
transition("deferred", entry.updateId);
|
|
2998
3568
|
continue;
|
|
2999
3569
|
}
|
|
@@ -3003,7 +3573,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3003
3573
|
completedUpdateIds,
|
|
3004
3574
|
);
|
|
3005
3575
|
if (completionResult) return completionResult;
|
|
3006
|
-
const queuedResult = commitQueuedOutcome(
|
|
3576
|
+
const queuedResult = await commitQueuedOutcome(
|
|
3007
3577
|
expectedOwner,
|
|
3008
3578
|
entry.updateId,
|
|
3009
3579
|
outcome,
|
|
@@ -3015,7 +3585,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3015
3585
|
snapshotInvalidated = true;
|
|
3016
3586
|
break;
|
|
3017
3587
|
}
|
|
3018
|
-
completedUpdateIds.push(entry.updateId);
|
|
3588
|
+
completedUpdateIds.push({ updateId: entry.updateId, ...(outcome.expectedSource ? { expectedSource: outcome.expectedSource } : {}) });
|
|
3019
3589
|
}
|
|
3020
3590
|
const completionResult = commitCompletedBatch(
|
|
3021
3591
|
expectedOwner,
|
|
@@ -3037,6 +3607,11 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3037
3607
|
launchDrain = (): void => {
|
|
3038
3608
|
const expectedOwner = owner;
|
|
3039
3609
|
if (!expectedOwner || drainPromise) return;
|
|
3610
|
+
let startupKey: string | undefined;
|
|
3611
|
+
if (onHeldSourcesPrepared) {
|
|
3612
|
+
try { startupKey = deps.getJournalBindingKey?.(); } catch { /* Missing startup identity grants no notification. */ }
|
|
3613
|
+
}
|
|
3614
|
+
let lastResult: TelegramUpdateWorkerDrainResult | undefined;
|
|
3040
3615
|
const run = async (): Promise<void> => {
|
|
3041
3616
|
while (
|
|
3042
3617
|
pendingSignal &&
|
|
@@ -3046,7 +3621,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3046
3621
|
pendingSignal = false;
|
|
3047
3622
|
// Keep newer wakes in this run so waitForDrain also covers them. A failure
|
|
3048
3623
|
// observed after signal() still keeps its latch; this loop never clears it.
|
|
3049
|
-
await drain(expectedOwner);
|
|
3624
|
+
lastResult = await drain(expectedOwner);
|
|
3050
3625
|
}
|
|
3051
3626
|
};
|
|
3052
3627
|
const operation = run();
|
|
@@ -3056,6 +3631,23 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3056
3631
|
drainPromise = undefined;
|
|
3057
3632
|
if (pendingSignal && !expectedOwner.controller.signal.aborted) {
|
|
3058
3633
|
launchDrain();
|
|
3634
|
+
} else if (lastResult === "idle" && startupKey && onHeldSourcesPrepared && !expectedOwner.heldSourcesPreparedIssued) {
|
|
3635
|
+
try {
|
|
3636
|
+
const ctx = expectedOwner.ctx, key = startupKey;
|
|
3637
|
+
const isCurrent = () => owner === expectedOwner && !blocked && !expectedOwner.controller.signal.aborted &&
|
|
3638
|
+
deps.getJournalBindingKey?.() === key && deps.isContextCurrent?.(ctx) !== false && deps.hasAuthority(ctx);
|
|
3639
|
+
if (!isCurrent()) return;
|
|
3640
|
+
expectedOwner.heldSourcesPreparedIssued = true;
|
|
3641
|
+
// Do not couple lifecycle/stop or the worker drain to a held controller/API reply.
|
|
3642
|
+
void Promise.resolve().then(() => isCurrent() ? onHeldSourcesPrepared({ ctx,
|
|
3643
|
+
journalBindingKey: key, signal: expectedOwner.controller.signal, isCurrent,
|
|
3644
|
+
routingSourceIds: [...deferredSources].filter(([, source]) => source.journalBindingKey === key && !!source.entry.routingInput)
|
|
3645
|
+
.map(([id]) => id) }) : undefined).catch(error => {
|
|
3646
|
+
try { deps.recordRuntimeEvent?.("inbound-worker", error, { phase: "held-source-preparation" }); } catch { /* Diagnostic only. */ }
|
|
3647
|
+
});
|
|
3648
|
+
} catch (error) {
|
|
3649
|
+
try { deps.recordRuntimeEvent?.("inbound-worker", error, { phase: "held-source-preparation" }); } catch { /* Diagnostic only. */ }
|
|
3650
|
+
}
|
|
3059
3651
|
}
|
|
3060
3652
|
}
|
|
3061
3653
|
};
|
|
@@ -3072,6 +3664,73 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3072
3664
|
drainPromise = tracked;
|
|
3073
3665
|
};
|
|
3074
3666
|
|
|
3667
|
+
const abandonSource = (input: Parameters<NonNullable<TelegramUpdateWorkerRuntime<TContext>["abandonDeferred"]>>[0],
|
|
3668
|
+
historicalSource?: ReturnType<typeof deferredSources.get>): TelegramUpdateJournalPendingAbandonmentResult | undefined => {
|
|
3669
|
+
const expectedOwner = owner;
|
|
3670
|
+
const source = deferredSources.get(input.updateId);
|
|
3671
|
+
const abandonPending = deps.journal.abandonPending;
|
|
3672
|
+
const isCurrent = () => {
|
|
3673
|
+
if (!expectedOwner || owner !== expectedOwner || input.signal.aborted ||
|
|
3674
|
+
expectedOwner.controller.signal !== input.signal || !source ||
|
|
3675
|
+
source.entry.routingInput?.phase === "selected" || (source.historical && historicalSource !== source) ||
|
|
3676
|
+
(claims.get(input.updateId) !== "deferred" && claims.get(input.updateId) !== "abandoning" &&
|
|
3677
|
+
!(claims.get(input.updateId) === "historical" && historicalSource === source))) return false;
|
|
3678
|
+
return input.isCurrent() && deps.isContextCurrent?.(expectedOwner.ctx) !== false &&
|
|
3679
|
+
deps.getJournalBindingKey?.() === source.journalBindingKey && deps.hasAuthority(expectedOwner.ctx);
|
|
3680
|
+
};
|
|
3681
|
+
if (!source || !abandonPending || !isCurrent()) return undefined;
|
|
3682
|
+
// Retain this claim on failure, including unknown commit: never reopen dispatch.
|
|
3683
|
+
claims.set(input.updateId, "abandoning");
|
|
3684
|
+
try {
|
|
3685
|
+
const result = abandonPending({ ...input, entry: source.entry,
|
|
3686
|
+
journalBindingKey: source.journalBindingKey, isCurrent });
|
|
3687
|
+
claims.delete(input.updateId);
|
|
3688
|
+
deferredSources.delete(input.updateId);
|
|
3689
|
+
state.journalEntryCount = result.entryCount;
|
|
3690
|
+
state.journalSerializedBytes = result.serializedBytes;
|
|
3691
|
+
transition("idle");
|
|
3692
|
+
pendingSignal = true;
|
|
3693
|
+
launchDrain();
|
|
3694
|
+
return result;
|
|
3695
|
+
} catch (error) {
|
|
3696
|
+
recordRuntimeEvent(error, { phase: "pending-abandonment", updateId: input.updateId });
|
|
3697
|
+
updateClaimCounts();
|
|
3698
|
+
notifyStateChange();
|
|
3699
|
+
throw error;
|
|
3700
|
+
}
|
|
3701
|
+
};
|
|
3702
|
+
|
|
3703
|
+
const inspectRecovery = (input: TelegramDeferredAbandonmentRecoveryRequest & { signal: AbortSignal },
|
|
3704
|
+
kind: "abandoning" | "historical"): TelegramDeferredAbandonmentRecoveryPage | undefined => {
|
|
3705
|
+
const expectedOwner = owner;
|
|
3706
|
+
if (!expectedOwner) return undefined;
|
|
3707
|
+
const { signal, journalBindingKey, isCurrent: callerIsCurrent, afterUpdateId } = input;
|
|
3708
|
+
const isOwnerCurrent = () => owner === expectedOwner && expectedOwner.controller.signal === signal && !signal.aborted;
|
|
3709
|
+
const isCurrent = () => isOwnerCurrent() && callerIsCurrent() && deps.isContextCurrent?.(expectedOwner.ctx) !== false &&
|
|
3710
|
+
deps.getJournalBindingKey?.() === journalBindingKey && deps.hasAuthority(expectedOwner.ctx);
|
|
3711
|
+
if (!isCurrent() || !deps.journal.abandonPending || !deps.journal.inspectPendingRetention ||
|
|
3712
|
+
(afterUpdateId !== undefined && (!Number.isSafeInteger(afterUpdateId) || afterUpdateId < 0))) return undefined;
|
|
3713
|
+
const matches = [...deferredSources].filter(([id, source]) => claims.get(id) === kind &&
|
|
3714
|
+
source.journalBindingKey === journalBindingKey && (afterUpdateId === undefined || id > afterUpdateId))
|
|
3715
|
+
.sort(([a], [b]) => a - b);
|
|
3716
|
+
const sources = matches.slice(0, TELEGRAM_HELD_SOURCE_INSPECTION_LIMIT).map(([updateId, source]) => {
|
|
3717
|
+
let committed: TelegramUpdateJournalPendingAbandonmentResult | undefined;
|
|
3718
|
+
return { original: structuredClone(source.entry), retry(authority: TelegramDeferredUpdateAbandonmentAuthority) {
|
|
3719
|
+
const authorized = () => isOwnerCurrent() && authority.isCurrent() && isCurrent();
|
|
3720
|
+
if (!authorized()) return undefined;
|
|
3721
|
+
if (committed) return { ...committed, duplicate: true };
|
|
3722
|
+
const stillProtected = () => authorized() && (claims.get(updateId) === kind || claims.get(updateId) === "abandoning") &&
|
|
3723
|
+
deferredSources.get(updateId) === source;
|
|
3724
|
+
if (!stillProtected()) return undefined;
|
|
3725
|
+
committed = abandonSource({ ...authority, updateId, signal, isCurrent: stillProtected }, source);
|
|
3726
|
+
return committed;
|
|
3727
|
+
} };
|
|
3728
|
+
});
|
|
3729
|
+
return isCurrent() ? { sources,
|
|
3730
|
+
...(matches.length > TELEGRAM_HELD_SOURCE_INSPECTION_LIMIT ? { nextAfterUpdateId: matches[TELEGRAM_HELD_SOURCE_INSPECTION_LIMIT - 1]![0] } : {}) } : undefined;
|
|
3731
|
+
};
|
|
3732
|
+
|
|
3733
|
+
|
|
3075
3734
|
return {
|
|
3076
3735
|
start(ctx) {
|
|
3077
3736
|
if (owner) {
|
|
@@ -3082,6 +3741,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3082
3741
|
return;
|
|
3083
3742
|
}
|
|
3084
3743
|
clearRetryTimer();
|
|
3744
|
+
clearRoutingTimer();
|
|
3085
3745
|
const nowMs = getNowMs();
|
|
3086
3746
|
const generation = ++nextGeneration;
|
|
3087
3747
|
const queueOwnerIdentity = resolveQueueOwnerIdentity(ctx, generation);
|
|
@@ -3092,6 +3752,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3092
3752
|
queueOwnerIdentity,
|
|
3093
3753
|
};
|
|
3094
3754
|
claims.clear();
|
|
3755
|
+
deferredSources.clear();
|
|
3095
3756
|
committedQueueReceipts.clear();
|
|
3096
3757
|
blocked = false;
|
|
3097
3758
|
pendingSignal = true;
|
|
@@ -3104,6 +3765,8 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3104
3765
|
state.journalSerializedBytes = 0;
|
|
3105
3766
|
state.oldestAdmittedAtMs = undefined;
|
|
3106
3767
|
state.deferredClaimCount = 0;
|
|
3768
|
+
delete state.abandoningClaimCount;
|
|
3769
|
+
delete state.historicalClaimCount;
|
|
3107
3770
|
state.queuedClaimCount = 0;
|
|
3108
3771
|
state.foreignQueuedCount = 0;
|
|
3109
3772
|
delete state.foreignQueuedOwner;
|
|
@@ -3133,7 +3796,51 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3133
3796
|
pendingSignal = true;
|
|
3134
3797
|
launchDrain();
|
|
3135
3798
|
},
|
|
3136
|
-
|
|
3799
|
+
armRoutingInput(input) { return mutateRoutingSource(input, false)?.lifetime; },
|
|
3800
|
+
selectRoutingInput(input) { return mutateRoutingSource(input, true)?.issued === true; },
|
|
3801
|
+
isRoutingInputCurrent(input) {
|
|
3802
|
+
return owner?.controller.signal === input.signal && !input.signal.aborted &&
|
|
3803
|
+
deferredSources.has(input.updateId) && claims.get(input.updateId) !== "abandoning";
|
|
3804
|
+
},
|
|
3805
|
+
inspectDeferredSource(input) {
|
|
3806
|
+
const expectedOwner = owner;
|
|
3807
|
+
const source = deferredSources.get(input.updateId);
|
|
3808
|
+
const current = (): boolean => {
|
|
3809
|
+
if (!expectedOwner || owner !== expectedOwner || input.signal.aborted || expectedOwner.controller.signal !== input.signal ||
|
|
3810
|
+
!source || deferredSources.get(input.updateId) !== source || claims.get(input.updateId) !== "deferred" ||
|
|
3811
|
+
deps.getJournalBindingKey?.() !== source.journalBindingKey || deps.isContextCurrent?.(expectedOwner.ctx) === false ||
|
|
3812
|
+
!deps.hasAuthority(expectedOwner.ctx)) return false;
|
|
3813
|
+
return isQueueOwnerIdentityCurrent(expectedOwner);
|
|
3814
|
+
};
|
|
3815
|
+
if (!current()) return undefined;
|
|
3816
|
+
const snapshot = deps.journal.read();
|
|
3817
|
+
const entry = snapshot.entries.find(value => value.updateId === input.updateId);
|
|
3818
|
+
if (!isTelegramUpdateJournalLegacyFamilyVersion(snapshot.version) || !entry ||
|
|
3819
|
+
JSON.stringify(entry) !== JSON.stringify(source!.entry) || !current()) return undefined;
|
|
3820
|
+
return { journalBindingKey: source!.journalBindingKey, ...createTelegramUpdateJournalEntryDigest(entry) };
|
|
3821
|
+
},
|
|
3822
|
+
isHistoricalSource(input) {
|
|
3823
|
+
const source = deferredSources.get(input.updateId);
|
|
3824
|
+
const isCurrent = () => owner?.controller.signal === input.signal && !input.signal.aborted &&
|
|
3825
|
+
source?.historical === true && deferredSources.get(input.updateId) === source;
|
|
3826
|
+
if (!isCurrent()) return false;
|
|
3827
|
+
// Domain eligibility observes original evidence, never mutable handler projections.
|
|
3828
|
+
return (!input.matchesOriginal || input.matchesOriginal(structuredClone(source!.entry)) === true) && isCurrent();
|
|
3829
|
+
},
|
|
3830
|
+
supportsDeferredAbandonment(input) {
|
|
3831
|
+
const expectedOwner = owner;
|
|
3832
|
+
const source = deferredSources.get(input.updateId);
|
|
3833
|
+
if (!expectedOwner || input.signal.aborted || expectedOwner.controller.signal !== input.signal ||
|
|
3834
|
+
!source || source.historical || input.journalBindingKey !== source.journalBindingKey ||
|
|
3835
|
+
!deps.journal.abandonPending || !deps.journal.inspectPendingRetention ||
|
|
3836
|
+
claims.get(input.updateId) === "queued") return false;
|
|
3837
|
+
return deps.isContextCurrent?.(expectedOwner.ctx) !== false &&
|
|
3838
|
+
deps.getJournalBindingKey?.() === source.journalBindingKey && deps.hasAuthority(expectedOwner.ctx);
|
|
3839
|
+
},
|
|
3840
|
+
abandonDeferred(input) { return abandonSource(input); },
|
|
3841
|
+
inspectAbandoning(input) { return inspectRecovery(input, "abandoning"); },
|
|
3842
|
+
inspectHistorical(input) { return inspectRecovery(input, "historical"); },
|
|
3843
|
+
async settleDeferred(input) {
|
|
3137
3844
|
const expectedOwner = owner;
|
|
3138
3845
|
if (
|
|
3139
3846
|
!expectedOwner ||
|
|
@@ -3154,10 +3861,20 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3154
3861
|
return;
|
|
3155
3862
|
}
|
|
3156
3863
|
const claim = claims.get(input.updateId);
|
|
3864
|
+
if (claim === "abandoning" || claim === "historical" || claim === "retained" ||
|
|
3865
|
+
(!claim && routingJournal?.inspectExpiry(input.updateId))) return;
|
|
3157
3866
|
if (input.outcome.kind === "complete") {
|
|
3158
3867
|
// Expiry may retire only a still-deferred source, never accepted queue work.
|
|
3159
3868
|
if (claim !== "deferred") return;
|
|
3160
|
-
|
|
3869
|
+
let completion: TelegramUpdateAdmissionOutcome;
|
|
3870
|
+
try { completion = validateTelegramUpdateAdmissionOutcome(input.outcome, input.updateId, new Set([input.updateId])); }
|
|
3871
|
+
catch (error) {
|
|
3872
|
+
blockWithFailure("invalid-outcome", "late-invalid-completion", error, input.updateId);
|
|
3873
|
+
return;
|
|
3874
|
+
}
|
|
3875
|
+
if (completion.kind !== "complete") return;
|
|
3876
|
+
const result = commitCompletedBatch(expectedOwner, [{ updateId: input.updateId,
|
|
3877
|
+
...(completion.expectedSource ? { expectedSource: completion.expectedSource } : {}) }]);
|
|
3161
3878
|
if (!result) transition("idle", input.updateId);
|
|
3162
3879
|
return;
|
|
3163
3880
|
}
|
|
@@ -3182,7 +3899,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3182
3899
|
outcome = validateTelegramUpdateAdmissionOutcome(
|
|
3183
3900
|
input.outcome,
|
|
3184
3901
|
input.updateId,
|
|
3185
|
-
new Set([input.updateId, ...claims.
|
|
3902
|
+
new Set([input.updateId, ...[...claims].filter(([, claim]) => claim !== "abandoning" && claim !== "historical" && claim !== "retained").map(([id]) => id)]),
|
|
3186
3903
|
);
|
|
3187
3904
|
} catch (error) {
|
|
3188
3905
|
blockWithFailure(
|
|
@@ -3217,11 +3934,8 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3217
3934
|
releaseDeferredClaims(outcome.sourceUpdateIds);
|
|
3218
3935
|
return;
|
|
3219
3936
|
}
|
|
3220
|
-
const
|
|
3221
|
-
|
|
3222
|
-
input.updateId,
|
|
3223
|
-
outcome,
|
|
3224
|
-
);
|
|
3937
|
+
const publication = commitQueuedOutcome(expectedOwner, input.updateId, outcome);
|
|
3938
|
+
const result = typeof publication === "string" ? publication : await publication;
|
|
3225
3939
|
if (result === "blocked") {
|
|
3226
3940
|
releaseDeferredClaims(outcome.sourceUpdateIds);
|
|
3227
3941
|
return;
|
|
@@ -3229,7 +3943,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3229
3943
|
if (result === "aborted") return;
|
|
3230
3944
|
transition("queued", input.updateId);
|
|
3231
3945
|
},
|
|
3232
|
-
settleCustodied(input) {
|
|
3946
|
+
async settleCustodied(input) {
|
|
3233
3947
|
const expectedOwner = owner;
|
|
3234
3948
|
if (!expectedOwner || expectedOwner.controller.signal !== input.signal || input.signal.aborted)
|
|
3235
3949
|
return;
|
|
@@ -3277,7 +3991,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3277
3991
|
}
|
|
3278
3992
|
for (const sourceUpdateId of receipt.sourceUpdateIds) claims.set(sourceUpdateId, "queued");
|
|
3279
3993
|
try {
|
|
3280
|
-
publishCommittedQueueReceipt(receipt, input.result.queueReceipt.queueOwner, expectedOwner
|
|
3994
|
+
await publishCommittedQueueReceipt(receipt, input.result.queueReceipt.queueOwner, expectedOwner);
|
|
3281
3995
|
} catch (error) {
|
|
3282
3996
|
blockWithFailure("invalid-outcome", "queue-receipt-publish", error, input.updateId);
|
|
3283
3997
|
return;
|
|
@@ -3289,7 +4003,19 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3289
4003
|
const committed = getCurrentQueueReceipt(receipt);
|
|
3290
4004
|
return committed ? { ...committed.queueOwner } : undefined;
|
|
3291
4005
|
},
|
|
3292
|
-
|
|
4006
|
+
getQueueReceiptSettlementOwner(receipt, ctx, reason) {
|
|
4007
|
+
const expectedOwner = owner, committed = committedQueueReceipts.get(receipt.receiptId);
|
|
4008
|
+
if (!expectedOwner || expectedOwner.controller.signal.aborted ||
|
|
4009
|
+
!(deps.isContextCurrent?.(ctx) ?? expectedOwner.ctx === ctx) || !isQueueOwnerIdentityCurrent(expectedOwner) ||
|
|
4010
|
+
!committed || !areTelegramQueueAdmissionReceiptsEqual(committed.receipt, normalizeQueueReceipt(receipt)) ||
|
|
4011
|
+
(deps.getJournalBindingKey && committed.receipt.journalBindingKey !== deps.getJournalBindingKey()) ||
|
|
4012
|
+
!isTelegramUpdateJournalQueueOwnerProcess(committed.queueOwner, expectedOwner.queueOwnerIdentity)) return undefined;
|
|
4013
|
+
const attempt = scopedQueueCompletionAttempts.get(receipt.receiptId);
|
|
4014
|
+
if (attempt ? attempt.owner !== expectedOwner || attempt.reason !== reason || attempt.journalBindingKey !== deps.getJournalBindingKey?.()
|
|
4015
|
+
: !getCurrentQueueReceipt(receipt)) return undefined;
|
|
4016
|
+
return { ...committed.queueOwner };
|
|
4017
|
+
},
|
|
4018
|
+
completeQueueReceipts: function completeQueueReceipts(input): boolean {
|
|
3293
4019
|
const expectedOwner = owner;
|
|
3294
4020
|
if (
|
|
3295
4021
|
!expectedOwner ||
|
|
@@ -3298,8 +4024,10 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3298
4024
|
) {
|
|
3299
4025
|
return false;
|
|
3300
4026
|
}
|
|
3301
|
-
|
|
3302
|
-
const
|
|
4027
|
+
// An ACK permits only local completion reconciliation, never execution readiness.
|
|
4028
|
+
const receipts = input.sourceCompletions === undefined ? input.receipts.filter(receipt => !isQueueReceiptCompletionAcknowledged(receipt)) : input.receipts;
|
|
4029
|
+
if (receipts.length === 0) return input.sourceCompletions === undefined;
|
|
4030
|
+
const normalizedReceipts = receipts.map(normalizeQueueReceipt);
|
|
3303
4031
|
const receiptIds = new Set<string>();
|
|
3304
4032
|
const sourceUpdateIds = new Set<number>();
|
|
3305
4033
|
const queuedCompletions: Array<{
|
|
@@ -3343,11 +4071,87 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3343
4071
|
sourceUpdateIds.add(updateId);
|
|
3344
4072
|
}
|
|
3345
4073
|
}
|
|
4074
|
+
const completionBinding = deps.getJournalBindingKey?.();
|
|
4075
|
+
const ordinaryObserverBound = normalizedReceipts.every(receipt =>
|
|
4076
|
+
committedQueueReceipts.get(receipt.receiptId)!.receipt.journalBindingKey === completionBinding);
|
|
4077
|
+
const ordinaryCompletionCurrent = (): boolean => ordinaryObserverBound && owner === expectedOwner &&
|
|
4078
|
+
!expectedOwner.controller.signal.aborted && (deps.isContextCurrent?.(input.ctx) ?? expectedOwner.ctx === input.ctx) &&
|
|
4079
|
+
isQueueOwnerIdentityCurrent(expectedOwner) && deps.getJournalBindingKey?.() === completionBinding;
|
|
3346
4080
|
let removedUpdateIds: readonly number[];
|
|
4081
|
+
let scopedCompletionCurrent: (() => boolean) | undefined;
|
|
3347
4082
|
try {
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
)
|
|
4083
|
+
const prepared = normalizedReceipts.flatMap(receipt => committedQueueReceipts.get(receipt.receiptId)!.sourceCompletions ?? []);
|
|
4084
|
+
const sourceCompletions = input.sourceCompletions === undefined ? (prepared.length ? prepared : undefined) : input.sourceCompletions;
|
|
4085
|
+
if (input.sourceCompletions !== undefined && prepared.some(expected => !input.sourceCompletions!.some(value => isDeepStrictEqual(value, expected)))) {
|
|
4086
|
+
throw new Error("Telegram queued completion conflicts with its prepared source scopes.");
|
|
4087
|
+
}
|
|
4088
|
+
if (input.sourceCompletions === undefined && prepared.length && receipts.some(receipt => !requiredScopedQueueReceipts.has(receipt.receiptId))) {
|
|
4089
|
+
const scoped = receipts.filter(receipt => requiredScopedQueueReceipts.has(receipt.receiptId));
|
|
4090
|
+
const ordinary = receipts.filter(receipt => !requiredScopedQueueReceipts.has(receipt.receiptId));
|
|
4091
|
+
if (!scoped.length || !ordinary.length) throw new Error("Telegram mixed queue completion has incomplete scoped authority.");
|
|
4092
|
+
const binding = deps.getJournalBindingKey?.();
|
|
4093
|
+
const current = (): boolean => owner === expectedOwner && !expectedOwner.controller.signal.aborted &&
|
|
4094
|
+
(deps.isContextCurrent?.(input.ctx) ?? expectedOwner.ctx === input.ctx) && isQueueOwnerIdentityCurrent(expectedOwner) &&
|
|
4095
|
+
!!binding && deps.getJournalBindingKey?.() === binding;
|
|
4096
|
+
// Independent whole receipts need independent ACKs. Ordinary siblings must not acquire invented scopes.
|
|
4097
|
+
if (!current() || !completeQueueReceipts({ receipts: scoped, ctx: input.ctx, reason: input.reason }) || !current()) return false;
|
|
4098
|
+
return completeQueueReceipts({ receipts: ordinary, ctx: input.ctx, reason: input.reason });
|
|
4099
|
+
}
|
|
4100
|
+
if (sourceCompletions === undefined) {
|
|
4101
|
+
if (queuedCompletions.some(receipt => requiredScopedQueueReceipts.has(receipt.receiptId))) {
|
|
4102
|
+
throw new Error("Telegram scoped queue completion cannot downgrade required source scopes.");
|
|
4103
|
+
}
|
|
4104
|
+
removedUpdateIds = deps.journal.completeQueued(queuedCompletions).removedUpdateIds;
|
|
4105
|
+
} else {
|
|
4106
|
+
for (const receipt of queuedCompletions) requiredScopedQueueReceipts.add(receipt.receiptId);
|
|
4107
|
+
queuedCompletions.sort((a, b) => a.receiptId < b.receiptId ? -1 : a.receiptId > b.receiptId ? 1 : 0);
|
|
4108
|
+
const journalBindingKey = deps.getJournalBindingKey?.();
|
|
4109
|
+
const current = (): boolean => owner === expectedOwner && !expectedOwner.controller.signal.aborted &&
|
|
4110
|
+
(deps.isContextCurrent?.(input.ctx) ?? expectedOwner.ctx === input.ctx) && isQueueOwnerIdentityCurrent(expectedOwner) &&
|
|
4111
|
+
deps.getJournalBindingKey?.() === journalBindingKey;
|
|
4112
|
+
if (!journalBindingKey || !current() || !completeQueuedExact || !inspectCompletion ||
|
|
4113
|
+
!Array.isArray(sourceCompletions) || input.sourceCompletions !== undefined && sourceCompletions.length !== sourceUpdateIds.size) {
|
|
4114
|
+
throw new Error("Telegram scoped queue completion requires full current source authority.");
|
|
4115
|
+
}
|
|
4116
|
+
scopedCompletionCurrent = current;
|
|
4117
|
+
const completions = normalizeTelegramQueueSourceCompletions(sourceCompletions, sourceUpdateIds, journalBindingKey, input.sourceCompletions !== undefined)
|
|
4118
|
+
.map(({ journalBindingKey: _binding, ...completion }) => completion);
|
|
4119
|
+
if (queuedCompletions.some(receipt => !completions.some(scope => receipt.sourceUpdateIds.includes(scope.updateId)))) {
|
|
4120
|
+
throw new Error("Telegram scoped queue completion requires a proven origin for every whole receipt.");
|
|
4121
|
+
}
|
|
4122
|
+
const previous = queuedCompletions.map(receipt => scopedQueueCompletionAttempts.get(receipt.receiptId));
|
|
4123
|
+
const retained = previous.find(attempt => attempt !== undefined);
|
|
4124
|
+
if (retained && (previous.some(attempt => attempt !== retained) || retained.owner !== expectedOwner ||
|
|
4125
|
+
retained.journalBindingKey !== journalBindingKey || retained.reason !== input.reason ||
|
|
4126
|
+
!isDeepStrictEqual(retained.receipts, queuedCompletions) || !isDeepStrictEqual(retained.completions, completions))) {
|
|
4127
|
+
throw new Error("Telegram scoped queue completion has conflicting issued authority.");
|
|
4128
|
+
}
|
|
4129
|
+
if (!retained) {
|
|
4130
|
+
const attempt = { owner: expectedOwner, journalBindingKey, reason: input.reason,
|
|
4131
|
+
receipts: queuedCompletions.map(receipt => ({ ...receipt, sourceUpdateIds: [...receipt.sourceUpdateIds], queueOwner: { ...receipt.queueOwner } })),
|
|
4132
|
+
completions: completions.map(completion => ({ ...completion })) };
|
|
4133
|
+
if (!current()) throw new Error("Telegram scoped queue completion authority changed before disposal.");
|
|
4134
|
+
// Mark before issuance: an exception may follow a committed rename, and cannot license another disposal.
|
|
4135
|
+
for (const receipt of queuedCompletions) scopedQueueCompletionAttempts.set(receipt.receiptId, attempt);
|
|
4136
|
+
const result = completeQueuedExact(queuedCompletions.map(receipt => ({ ...receipt, sourceUpdateIds: [...receipt.sourceUpdateIds], queueOwner: { ...receipt.queueOwner } })),
|
|
4137
|
+
completions.map(completion => ({ ...completion })));
|
|
4138
|
+
if (!current() || !isDeepStrictEqual(result.sourceCompletions, completions)) {
|
|
4139
|
+
throw new Error("Telegram scoped queue completion ACK was not confirmed.");
|
|
4140
|
+
}
|
|
4141
|
+
const removed = new Set(result.removedUpdateIds);
|
|
4142
|
+
if (removed.size !== sourceUpdateIds.size || [...sourceUpdateIds].some(id => !removed.has(id))) {
|
|
4143
|
+
throw new Error("Telegram scoped queue completion did not remove every source.");
|
|
4144
|
+
}
|
|
4145
|
+
}
|
|
4146
|
+
for (const completion of completions) {
|
|
4147
|
+
if (!current() || !isDeepStrictEqual(inspectCompletion({ ...completion }), completion) || !current()) {
|
|
4148
|
+
throw new Error("Telegram scoped queue completion ACK was not retained under current authority.");
|
|
4149
|
+
}
|
|
4150
|
+
}
|
|
4151
|
+
// Every immutable whole receipt has a scoped queued-origin witness; native queued ACK continuity proves its complete removal.
|
|
4152
|
+
// Unscoped siblings receive only receipt acknowledgement, never a source marker or absence-based proof.
|
|
4153
|
+
removedUpdateIds = [...sourceUpdateIds];
|
|
4154
|
+
}
|
|
3351
4155
|
} catch (error) {
|
|
3352
4156
|
blockWithFailure(
|
|
3353
4157
|
"journal-write",
|
|
@@ -3370,12 +4174,14 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3370
4174
|
);
|
|
3371
4175
|
return false;
|
|
3372
4176
|
}
|
|
3373
|
-
|
|
4177
|
+
receipts.forEach((receipt, index) => {
|
|
3374
4178
|
acknowledgedQueueReceiptCompletions.set(receipt, normalizedReceipts[index]!);
|
|
3375
4179
|
});
|
|
3376
4180
|
for (const updateId of sourceUpdateIds) claims.delete(updateId);
|
|
3377
4181
|
for (const receiptId of receiptIds) {
|
|
3378
4182
|
committedQueueReceipts.delete(receiptId);
|
|
4183
|
+
requiredScopedQueueReceipts.delete(receiptId);
|
|
4184
|
+
scopedQueueCompletionAttempts.delete(receiptId);
|
|
3379
4185
|
}
|
|
3380
4186
|
const completedUpdateIds = [...sourceUpdateIds];
|
|
3381
4187
|
state.lastCompletedUpdateId = Math.max(
|
|
@@ -3389,8 +4195,19 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3389
4195
|
);
|
|
3390
4196
|
updateClaimCounts();
|
|
3391
4197
|
notifyStateChange();
|
|
3392
|
-
|
|
3393
|
-
|
|
4198
|
+
if (scopedCompletionCurrent) for (const receipt of normalizedReceipts) {
|
|
4199
|
+
if (!scopedCompletionCurrent()) break;
|
|
4200
|
+
try { observeQueuedCompletion?.(normalizeQueueReceipt(receipt), input.ctx); }
|
|
4201
|
+
catch (error) { recordRuntimeEvent(error, { phase: "queue-receipt-completion-observer", receiptId: receipt.receiptId }); }
|
|
4202
|
+
}
|
|
4203
|
+
// Ordinary queued disposal is source completion too; notify only after a whole positive ACK and fresh origin authority.
|
|
4204
|
+
if (!scopedCompletionCurrent) for (const updateId of completedUpdateIds) {
|
|
4205
|
+
try {
|
|
4206
|
+
if (!ordinaryCompletionCurrent()) break;
|
|
4207
|
+
deps.onUpdateCompleted?.(updateId, input.ctx, completionBinding);
|
|
4208
|
+
} catch (error) { recordRuntimeEvent(error, { phase: "queue-source-completion-observer", updateId }); }
|
|
4209
|
+
}
|
|
4210
|
+
if (!scopedCompletionCurrent || scopedCompletionCurrent()) { pendingSignal = true; launchDrain(); }
|
|
3394
4211
|
return true;
|
|
3395
4212
|
},
|
|
3396
4213
|
async stop() {
|
|
@@ -3398,12 +4215,14 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3398
4215
|
if (!expectedOwner) return;
|
|
3399
4216
|
pendingSignal = false;
|
|
3400
4217
|
clearRetryTimer();
|
|
4218
|
+
clearRoutingTimer();
|
|
3401
4219
|
expectedOwner.controller.abort();
|
|
3402
4220
|
await drainPromise?.catch(() => undefined);
|
|
3403
4221
|
if (owner !== expectedOwner) return;
|
|
3404
4222
|
owner = undefined;
|
|
3405
4223
|
drainPromise = undefined;
|
|
3406
4224
|
claims.clear();
|
|
4225
|
+
deferredSources.clear();
|
|
3407
4226
|
committedQueueReceipts.clear();
|
|
3408
4227
|
blocked = false;
|
|
3409
4228
|
state.phase = "stopped";
|
|
@@ -3411,6 +4230,8 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3411
4230
|
state.currentUpdateId = undefined;
|
|
3412
4231
|
state.blockedReason = undefined;
|
|
3413
4232
|
state.deferredClaimCount = 0;
|
|
4233
|
+
delete state.abandoningClaimCount;
|
|
4234
|
+
delete state.historicalClaimCount;
|
|
3414
4235
|
state.queuedClaimCount = 0;
|
|
3415
4236
|
state.foreignQueuedCount = 0;
|
|
3416
4237
|
delete state.foreignQueuedOwner;
|
|
@@ -3429,8 +4250,9 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
|
|
|
3429
4250
|
state.unsettledExecutionCount = unsettledExecutions.size;
|
|
3430
4251
|
notifyStateChange();
|
|
3431
4252
|
},
|
|
3432
|
-
waitForDrain() {
|
|
3433
|
-
|
|
4253
|
+
async waitForDrain() {
|
|
4254
|
+
await (drainPromise ?? Promise.resolve());
|
|
4255
|
+
await Promise.allSettled([...pendingQueuePublications.values()].map(value => value.task));
|
|
3434
4256
|
},
|
|
3435
4257
|
getState() {
|
|
3436
4258
|
return getTelegramUpdateWorkerStateSnapshot(state);
|
|
@@ -3647,6 +4469,15 @@ export interface TelegramUpdateAdmissionHandleDeps<
|
|
|
3647
4469
|
},
|
|
3648
4470
|
) => void | Promise<void>;
|
|
3649
4471
|
onLateOutcomeError?: (error: unknown, updateId: number) => void;
|
|
4472
|
+
abandonDeferred?: TelegramUpdateWorkerRuntime<TContext>["abandonDeferred"];
|
|
4473
|
+
armRoutingInput?: TelegramUpdateWorkerRuntime<TContext>["armRoutingInput"];
|
|
4474
|
+
selectRoutingInput?: TelegramUpdateWorkerRuntime<TContext>["selectRoutingInput"];
|
|
4475
|
+
isRoutingInputCurrent?: TelegramUpdateWorkerRuntime<TContext>["isRoutingInputCurrent"];
|
|
4476
|
+
supportsDeferredAbandonment?: TelegramUpdateWorkerRuntime<TContext>["supportsDeferredAbandonment"];
|
|
4477
|
+
inspectAbandoning?: TelegramUpdateWorkerRuntime<TContext>["inspectAbandoning"];
|
|
4478
|
+
inspectHistorical?: TelegramUpdateWorkerRuntime<TContext>["inspectHistorical"];
|
|
4479
|
+
inspectDeferredSource?: TelegramUpdateWorkerRuntime<TContext>["inspectDeferredSource"];
|
|
4480
|
+
isHistoricalSource?: TelegramUpdateWorkerRuntime<TContext>["isHistoricalSource"];
|
|
3650
4481
|
}
|
|
3651
4482
|
|
|
3652
4483
|
function mergeTelegramReportedAdmissionOutcome(
|
|
@@ -3656,7 +4487,17 @@ function mergeTelegramReportedAdmissionOutcome(
|
|
|
3656
4487
|
): TelegramUpdateAdmissionOutcome {
|
|
3657
4488
|
if (!current || current.kind === "deferred") return next;
|
|
3658
4489
|
if (next.kind === "deferred") return current;
|
|
3659
|
-
if (current.kind === "complete" && next.kind === "complete")
|
|
4490
|
+
if (current.kind === "complete" && next.kind === "complete") {
|
|
4491
|
+
if (!current.expectedSource) return next;
|
|
4492
|
+
if (!next.expectedSource) return current;
|
|
4493
|
+
if (current.expectedSource.updateId === next.expectedSource.updateId &&
|
|
4494
|
+
current.expectedSource.journalBindingKey === next.expectedSource.journalBindingKey &&
|
|
4495
|
+
current.expectedSource.sourceSha256 === next.expectedSource.sourceSha256) {
|
|
4496
|
+
if (!current.expectedSource.completionSha256) return next;
|
|
4497
|
+
if (!next.expectedSource.completionSha256 || current.expectedSource.completionSha256 === next.expectedSource.completionSha256) return current;
|
|
4498
|
+
}
|
|
4499
|
+
throw new TelegramUpdateAdmissionOutcomeError(`Telegram update ${updateId} reported conflicting completion sources.`);
|
|
4500
|
+
}
|
|
3660
4501
|
if (current.kind === "queued" && next.kind === "queued" &&
|
|
3661
4502
|
areTelegramQueueAdmissionReceiptsEqual(current, next)) return current;
|
|
3662
4503
|
throw new TelegramUpdateAdmissionOutcomeError(
|
|
@@ -4650,13 +5491,22 @@ export function createTelegramUpdateAdmissionHandle<
|
|
|
4650
5491
|
let nextExecutionGeneration = 0;
|
|
4651
5492
|
return async (update, ctx, signal) => {
|
|
4652
5493
|
const generation = ++nextExecutionGeneration;
|
|
5494
|
+
let suspended = false;
|
|
5495
|
+
let routingClaims = 0;
|
|
5496
|
+
let abandoned: TelegramUpdateJournalPendingAbandonmentResult | undefined;
|
|
5497
|
+
let routingLifetime: TelegramUpdateJournalRoutingInput | undefined;
|
|
5498
|
+
let routingSourceIds: readonly number[] | undefined;
|
|
5499
|
+
let routingClockUnknown = false;
|
|
5500
|
+
let routingSelectionAttempted = false;
|
|
5501
|
+
let routingSelectionConfirmed = false;
|
|
4653
5502
|
const execution: TelegramUpdateExecutionFence = {
|
|
4654
5503
|
generation,
|
|
4655
5504
|
updateId: update.update_id,
|
|
4656
5505
|
signal,
|
|
4657
|
-
isCurrent: () => !signal.aborted
|
|
5506
|
+
isCurrent: () => !signal.aborted && !suspended && ((!routingLifetime && !routingClockUnknown) || immediate || settlementReported ||
|
|
5507
|
+
deps.isRoutingInputCurrent?.({ updateId: update.update_id, signal }) !== false),
|
|
4658
5508
|
assertCurrent() {
|
|
4659
|
-
if (
|
|
5509
|
+
if (!execution.isCurrent()) {
|
|
4660
5510
|
throw signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
4661
5511
|
}
|
|
4662
5512
|
},
|
|
@@ -4666,9 +5516,18 @@ export function createTelegramUpdateAdmissionHandle<
|
|
|
4666
5516
|
execution.assertCurrent();
|
|
4667
5517
|
if (verdict === "consume") return { kind: "complete" };
|
|
4668
5518
|
let immediate = true;
|
|
5519
|
+
let settlementReported = false;
|
|
4669
5520
|
let outcome: TelegramUpdateAdmissionOutcome | undefined;
|
|
4670
5521
|
const boundUpdate = bindTelegramUpdateExecutionFence(
|
|
4671
5522
|
bindTelegramUpdateAdmissionSource(update, (next) => {
|
|
5523
|
+
if (!execution.isCurrent()) return;
|
|
5524
|
+
if (next.kind === "deferred" && next.routingReview) {
|
|
5525
|
+
if (!immediate || settlementReported || routingClaims > 0 || !deps.isHistoricalSource?.({ updateId: update.update_id, signal })) {
|
|
5526
|
+
throw new TelegramUpdateAdmissionOutcomeError("Historical review cannot override accepted or selected work.");
|
|
5527
|
+
}
|
|
5528
|
+
suspended = true;
|
|
5529
|
+
}
|
|
5530
|
+
if (next.kind !== "deferred") settlementReported = true;
|
|
4672
5531
|
if (immediate) {
|
|
4673
5532
|
outcome = mergeTelegramReportedAdmissionOutcome(
|
|
4674
5533
|
outcome,
|
|
@@ -4683,13 +5542,14 @@ export function createTelegramUpdateAdmissionHandle<
|
|
|
4683
5542
|
);
|
|
4684
5543
|
}
|
|
4685
5544
|
void Promise.resolve()
|
|
4686
|
-
.then(() =>
|
|
4687
|
-
|
|
5545
|
+
.then(() => {
|
|
5546
|
+
if (!execution.isCurrent()) return;
|
|
5547
|
+
return deps.onLateOutcome!(next, {
|
|
4688
5548
|
updateId: update.update_id,
|
|
4689
5549
|
ctx,
|
|
4690
5550
|
signal,
|
|
4691
|
-
})
|
|
4692
|
-
)
|
|
5551
|
+
});
|
|
5552
|
+
})
|
|
4693
5553
|
.catch((error) => {
|
|
4694
5554
|
try {
|
|
4695
5555
|
deps.onLateOutcomeError?.(error, update.update_id);
|
|
@@ -4697,13 +5557,64 @@ export function createTelegramUpdateAdmissionHandle<
|
|
|
4697
5557
|
// Diagnostic sinks must not create an unhandled late Promise.
|
|
4698
5558
|
}
|
|
4699
5559
|
});
|
|
4700
|
-
})
|
|
5560
|
+
}, { abandon: (authority) => {
|
|
5561
|
+
const isCurrent = () => !signal.aborted && routingClaims === 0 && !settlementReported && authority.isCurrent();
|
|
5562
|
+
if (immediate || outcome?.kind !== "deferred" || !deps.abandonDeferred || !isCurrent()) return undefined;
|
|
5563
|
+
if (abandoned) return { ...abandoned, duplicate: true };
|
|
5564
|
+
const wasSuspended = suspended;
|
|
5565
|
+
suspended = true;
|
|
5566
|
+
// Failed/unknown publication keeps dispatch suspended for exact cancellation retry.
|
|
5567
|
+
// An ineligible request that attempted no publication remains inert.
|
|
5568
|
+
const result = deps.abandonDeferred({ ...authority, updateId: update.update_id, signal, isCurrent });
|
|
5569
|
+
if (result) abandoned = result;
|
|
5570
|
+
else suspended = wasSuspended;
|
|
5571
|
+
return result;
|
|
5572
|
+
}, inspectAbandoning: request => execution.isCurrent()
|
|
5573
|
+
? deps.inspectAbandoning?.({ ...request, signal }) : undefined,
|
|
5574
|
+
inspectHistorical: request => execution.isCurrent() ? deps.inspectHistorical?.({ ...request, signal }) : undefined,
|
|
5575
|
+
inspectSource: () => execution.isCurrent() && !immediate && !settlementReported
|
|
5576
|
+
? deps.inspectDeferredSource?.({ updateId: update.update_id, signal }) : undefined,
|
|
5577
|
+
isHistorical: matchesOriginal => !signal.aborted && deps.isHistoricalSource?.({ updateId: update.update_id, signal,
|
|
5578
|
+
...(matchesOriginal ? { matchesOriginal } : {}) }) === true,
|
|
5579
|
+
isHistoricalReviewHeld: () => !signal.aborted && suspended && outcome?.kind === "deferred" && outcome.routingReview === true,
|
|
5580
|
+
supportsAbandonment: journalBindingKey => !signal.aborted && !settlementReported &&
|
|
5581
|
+
deps.supportsDeferredAbandonment?.({ updateId: update.update_id, signal, journalBindingKey }) === true,
|
|
5582
|
+
armRoutingInput: (operatorUserId, sourceUpdateIds) => {
|
|
5583
|
+
execution.assertCurrent();
|
|
5584
|
+
if (settlementReported || routingClaims || suspended) return undefined;
|
|
5585
|
+
if (routingClockUnknown) throw new TelegramUpdateAdmissionOutcomeError("Telegram routing input clock publication is unconfirmed.");
|
|
5586
|
+
const ids = [...sourceUpdateIds];
|
|
5587
|
+
if (routingSourceIds && !isDeepStrictEqual(ids, routingSourceIds)) throw new TelegramUpdateAdmissionOutcomeError("Telegram routing input source membership changed.");
|
|
5588
|
+
try { routingLifetime = deps.armRoutingInput?.({ updateId: update.update_id, signal, operatorUserId, sourceUpdateIds: ids }); }
|
|
5589
|
+
catch (error) { routingClockUnknown = true; throw error; }
|
|
5590
|
+
if (routingLifetime) routingSourceIds = ids;
|
|
5591
|
+
return routingLifetime ? { ...routingLifetime } : undefined;
|
|
5592
|
+
},
|
|
5593
|
+
acquireRouting: (select = false, sourceUpdateIds) => {
|
|
5594
|
+
execution.assertCurrent();
|
|
5595
|
+
if (select && routingClockUnknown) throw new TelegramUpdateAdmissionOutcomeError("Telegram routing input clock publication is unconfirmed; selection is protected.");
|
|
5596
|
+
if (select && routingLifetime && !routingSelectionConfirmed) {
|
|
5597
|
+
if (routingSelectionAttempted) throw new TelegramUpdateAdmissionOutcomeError("Telegram routing selection is unconfirmed; no replay is permitted.");
|
|
5598
|
+
if (!isDeepStrictEqual(sourceUpdateIds ?? [update.update_id], routingSourceIds)) throw new TelegramUpdateAdmissionOutcomeError("Telegram routing input selection changed its source membership.");
|
|
5599
|
+
routingSelectionAttempted = true;
|
|
5600
|
+
routingSelectionConfirmed = deps.selectRoutingInput?.({ updateId: update.update_id, signal,
|
|
5601
|
+
operatorUserId: routingLifetime.operatorUserId, sourceUpdateIds: routingSourceIds! }) === true;
|
|
5602
|
+
if (!routingSelectionConfirmed) throw new TelegramUpdateAdmissionOutcomeError("Telegram routing input choice is expired or unavailable.");
|
|
5603
|
+
}
|
|
5604
|
+
routingClaims += 1;
|
|
5605
|
+
let released = false;
|
|
5606
|
+
return () => {
|
|
5607
|
+
if (released) return;
|
|
5608
|
+
released = true;
|
|
5609
|
+
routingClaims -= 1;
|
|
5610
|
+
};
|
|
5611
|
+
} }),
|
|
4701
5612
|
execution,
|
|
4702
5613
|
);
|
|
4703
5614
|
try {
|
|
4704
5615
|
execution.assertCurrent();
|
|
4705
5616
|
await deps.defaultHandle(boundUpdate, ctx, execution);
|
|
4706
|
-
execution.assertCurrent();
|
|
5617
|
+
if (outcome?.kind !== "deferred" || !outcome.routingReview || signal.aborted) execution.assertCurrent();
|
|
4707
5618
|
} finally {
|
|
4708
5619
|
immediate = false;
|
|
4709
5620
|
}
|
|
@@ -4715,7 +5626,7 @@ export function createTelegramCustodiedUpdateAdmissionHandle<
|
|
|
4715
5626
|
TUpdate extends TelegramJournaledUpdate & TelegramUpdateFlow,
|
|
4716
5627
|
TContext,
|
|
4717
5628
|
>(deps: Omit<TelegramUpdateAdmissionHandleDeps<TUpdate, TContext>,
|
|
4718
|
-
"onLateOutcome" | "onLateOutcomeError"> & {
|
|
5629
|
+
"onLateOutcome" | "onLateOutcomeError" | "abandonDeferred" | "armRoutingInput" | "selectRoutingInput" | "isRoutingInputCurrent" | "supportsDeferredAbandonment" | "inspectAbandoning" | "inspectHistorical" | "inspectDeferredSource" | "isHistoricalSource"> & {
|
|
4719
5630
|
journal: TelegramCustodyExecutionJournal;
|
|
4720
5631
|
recipientBindingKey: string;
|
|
4721
5632
|
onLateOutcomeError(error: unknown, updateId: number): void;
|
|
@@ -4755,6 +5666,8 @@ const isQueueReceiptCompletionAcknowledged = (receipt: TelegramQueueAdmissionRec
|
|
|
4755
5666
|
|
|
4756
5667
|
export interface TelegramQueueAdmissionSettlementRuntime<TContext> {
|
|
4757
5668
|
isItemReady: (item: TelegramQueueAdmissionItemLike) => boolean;
|
|
5669
|
+
getQueueReceiptSettlementOwner?: (receipt: TelegramQueueAdmissionReceiptLike, ctx: TContext,
|
|
5670
|
+
reason: TelegramQueueReceiptCompletionReason) => TelegramUpdateJournalQueueOwner | undefined;
|
|
4758
5671
|
getQueueReceiptOwner: (
|
|
4759
5672
|
receipt: TelegramQueueAdmissionReceiptLike,
|
|
4760
5673
|
) => TelegramUpdateJournalQueueOwner | undefined;
|
|
@@ -4782,15 +5695,20 @@ export function createTelegramQueueAdmissionSettlementMuxRuntime<TContext>(
|
|
|
4782
5695
|
kind: "prompt" | "control" | "discard",
|
|
4783
5696
|
): boolean => {
|
|
4784
5697
|
const plan = new Map<TelegramQueueAdmissionSettlementRuntime<TContext>, TelegramQueueAdmissionReceiptLike[]>();
|
|
5698
|
+
const reason = kind === "prompt" ? "prompt-handoff" : kind === "control" ? "control-settlement" : "discard";
|
|
5699
|
+
const settlementOwner = (runtime: TelegramQueueAdmissionSettlementRuntime<TContext>, receipt: TelegramQueueAdmissionReceiptLike) =>
|
|
5700
|
+
runtime.getQueueReceiptSettlementOwner ? runtime.getQueueReceiptSettlementOwner(receipt, ctx, reason) :
|
|
5701
|
+
runtime.isItemReady({ admissionReceipts: [receipt] }) ? runtime.getQueueReceiptOwner(receipt) : undefined;
|
|
4785
5702
|
for (const receipt of items.flatMap(item => item.admissionReceipts ?? [])) {
|
|
4786
|
-
if (
|
|
4787
|
-
const candidates = runtimes.filter(runtime => runtime.
|
|
5703
|
+
if (isQueueReceiptCompletionAcknowledged(receipt)) continue;
|
|
5704
|
+
const candidates = runtimes.filter(runtime => runtime.getQueueReceiptSettlementOwner ? !!settlementOwner(runtime, receipt)
|
|
5705
|
+
: runtime.isItemReady({ admissionReceipts: [receipt] }));
|
|
4788
5706
|
const selected = candidates[0];
|
|
4789
5707
|
if (!selected) return false;
|
|
4790
5708
|
if (candidates.length > 1) {
|
|
4791
|
-
const owner = selected
|
|
5709
|
+
const owner = settlementOwner(selected, receipt);
|
|
4792
5710
|
if (!owner || candidates.some(runtime => {
|
|
4793
|
-
const candidate = runtime
|
|
5711
|
+
const candidate = settlementOwner(runtime, receipt);
|
|
4794
5712
|
return !candidate || !areTelegramUpdateJournalQueueOwnersEqual(owner, candidate);
|
|
4795
5713
|
})) return false;
|
|
4796
5714
|
}
|
|
@@ -4848,7 +5766,7 @@ export function createTelegramQueueAdmissionSettlementRuntime<TContext>(
|
|
|
4848
5766
|
const receipts: TelegramQueueAdmissionReceiptLike[] = [];
|
|
4849
5767
|
for (const item of items) {
|
|
4850
5768
|
for (const receipt of item.admissionReceipts ?? []) {
|
|
4851
|
-
if (
|
|
5769
|
+
if (!isQueueReceiptCompletionAcknowledged(receipt)) receipts.push(receipt);
|
|
4852
5770
|
}
|
|
4853
5771
|
}
|
|
4854
5772
|
return receipts.length === 0 || worker.completeQueueReceipts({ receipts, ctx, reason });
|
|
@@ -4859,6 +5777,7 @@ export function createTelegramQueueAdmissionSettlementRuntime<TContext>(
|
|
|
4859
5777
|
worker.isQueueReceiptCommitted,
|
|
4860
5778
|
),
|
|
4861
5779
|
getQueueReceiptOwner: worker.getQueueReceiptOwner,
|
|
5780
|
+
...(worker.getQueueReceiptSettlementOwner ? { getQueueReceiptSettlementOwner: worker.getQueueReceiptSettlementOwner } : {}),
|
|
4862
5781
|
onPromptHandedOff: (item, ctx) =>
|
|
4863
5782
|
complete([item], ctx, "prompt-handoff"),
|
|
4864
5783
|
onControlSettled: (item, ctx) =>
|
|
@@ -5246,6 +6165,7 @@ export interface TelegramQueueHandoffReconciliationRuntimeAssemblyDeps<
|
|
|
5246
6165
|
listFollowers: () => readonly TelegramBusFollowerView[];
|
|
5247
6166
|
createRecipientJournalResolver: (
|
|
5248
6167
|
profileKey: string,
|
|
6168
|
+
sessionId: string,
|
|
5249
6169
|
) => (() => { recoveryKey: string } | undefined);
|
|
5250
6170
|
queueStore: {
|
|
5251
6171
|
getQueuedItems: () => TelegramQueueItem<TContext>[];
|
|
@@ -5291,8 +6211,8 @@ export function createTelegramQueueHandoffReconciliationRuntimeAssembly<
|
|
|
5291
6211
|
canHandoffWithLeader: deps.canHandoffWithLeader,
|
|
5292
6212
|
listFollowers: deps.listFollowers,
|
|
5293
6213
|
createRecipientJournalBindingKey(recipient) {
|
|
5294
|
-
if (!recipient.profileKey) return undefined;
|
|
5295
|
-
return deps.createRecipientJournalResolver(recipient.profileKey)()
|
|
6214
|
+
if (!recipient.profileKey || !recipient.sessionId) return undefined;
|
|
6215
|
+
return deps.createRecipientJournalResolver(recipient.profileKey, recipient.sessionId)()
|
|
5296
6216
|
?.recoveryKey;
|
|
5297
6217
|
},
|
|
5298
6218
|
getQueuedItems: deps.queueStore.getQueuedItems,
|
|
@@ -5502,6 +6422,8 @@ export interface TelegramUpdateAdmissionLifecycleRuntimeDeps<TContext> {
|
|
|
5502
6422
|
acquireSourceReference?: (
|
|
5503
6423
|
binding: TelegramUpdateAdmissionLifecycleJournalBinding,
|
|
5504
6424
|
) => () => void;
|
|
6425
|
+
/** Runs after the previous worker stopped and before the replacement worker exists; throwing refuses binding. */
|
|
6426
|
+
prepareBinding?: (binding: TelegramUpdateAdmissionLifecycleJournalBinding) => void;
|
|
5505
6427
|
recordRuntimeEvent?: TelegramUpdateWorkerRuntimeDeps<TContext>["recordRuntimeEvent"];
|
|
5506
6428
|
}
|
|
5507
6429
|
|
|
@@ -5558,8 +6480,8 @@ export interface TelegramUpdateAdmissionLifecycleRuntime<TContext>
|
|
|
5558
6480
|
|
|
5559
6481
|
export interface TelegramUpdateWorkerOwnerRuntime<TContext> {
|
|
5560
6482
|
getQueueOwnerIdentity: () => TelegramUpdateJournalQueueOwnerIdentity;
|
|
5561
|
-
onQueueReceiptCommitted: (receipt:
|
|
5562
|
-
onUpdateCompleted: (updateId: number, ctx: TContext) => void;
|
|
6483
|
+
onQueueReceiptCommitted: (receipt: TelegramQueueAdmissionReceiptLike, ctx: TContext) => void;
|
|
6484
|
+
onUpdateCompleted: (updateId: number, ctx: TContext, journalBindingKey?: string) => void;
|
|
5563
6485
|
}
|
|
5564
6486
|
|
|
5565
6487
|
export interface TelegramUpdateWorkerOwnerRuntimeDeps<TContext> {
|
|
@@ -5570,7 +6492,8 @@ export interface TelegramUpdateWorkerOwnerRuntimeDeps<TContext> {
|
|
|
5570
6492
|
isContextCurrent: (ctx: TContext) => boolean;
|
|
5571
6493
|
dispatchNext: (ctx: TContext) => void;
|
|
5572
6494
|
requestQueueHandoffReconciliation: (ctx: TContext) => void;
|
|
5573
|
-
|
|
6495
|
+
afterQueueReceiptCommitted?: (receipt: TelegramQueueAdmissionReceiptLike, ctx: TContext) => void;
|
|
6496
|
+
afterUpdateCompleted?: (updateId: number, ctx: TContext, journalBindingKey?: string) => void;
|
|
5574
6497
|
}
|
|
5575
6498
|
|
|
5576
6499
|
export function createTelegramUpdateWorkerOwnerRuntime<TContext>(
|
|
@@ -5585,15 +6508,16 @@ export function createTelegramUpdateWorkerOwnerRuntime<TContext>(
|
|
|
5585
6508
|
sessionGeneration: deps.getSessionGeneration(),
|
|
5586
6509
|
};
|
|
5587
6510
|
},
|
|
5588
|
-
onQueueReceiptCommitted(
|
|
6511
|
+
onQueueReceiptCommitted(receipt, ctx) {
|
|
5589
6512
|
if (!deps.isContextCurrent(ctx)) return;
|
|
6513
|
+
deps.afterQueueReceiptCommitted?.(receipt, ctx);
|
|
5590
6514
|
deps.dispatchNext(ctx);
|
|
5591
6515
|
deps.requestQueueHandoffReconciliation(ctx);
|
|
5592
6516
|
},
|
|
5593
|
-
onUpdateCompleted(updateId, ctx) {
|
|
6517
|
+
onUpdateCompleted(updateId, ctx, journalBindingKey) {
|
|
5594
6518
|
if (!deps.isContextCurrent(ctx)) return;
|
|
5595
6519
|
deps.dispatchNext(ctx);
|
|
5596
|
-
deps.afterUpdateCompleted?.(updateId);
|
|
6520
|
+
deps.afterUpdateCompleted?.(updateId, ctx, journalBindingKey);
|
|
5597
6521
|
},
|
|
5598
6522
|
};
|
|
5599
6523
|
}
|
|
@@ -5625,6 +6549,7 @@ export function createTelegramUpdateAdmissionRuntimeBinding<TContext>(deps: {
|
|
|
5625
6549
|
let follower: TelegramUpdateAdmissionLifecycleRuntime<TContext> | undefined;
|
|
5626
6550
|
let settlement: TelegramQueueAdmissionSettlementRuntime<TContext> | undefined;
|
|
5627
6551
|
let inputCustodyBus: TelegramInputCustodyBusBindingRuntime<TContext> | undefined;
|
|
6552
|
+
const getActive = () => deps.isFollowerRegistered() ? follower : leader;
|
|
5628
6553
|
return {
|
|
5629
6554
|
bind(input) {
|
|
5630
6555
|
leader = input.leader;
|
|
@@ -5637,7 +6562,7 @@ export function createTelegramUpdateAdmissionRuntimeBinding<TContext>(deps: {
|
|
|
5637
6562
|
},
|
|
5638
6563
|
getLeader: () => leader,
|
|
5639
6564
|
getFollower: () => follower,
|
|
5640
|
-
getActive
|
|
6565
|
+
getActive,
|
|
5641
6566
|
getSettlement: () => settlement,
|
|
5642
6567
|
getInputCustodyBus: () => inputCustodyBus,
|
|
5643
6568
|
getLifecycleForJournalBinding(journalBindingKey) {
|
|
@@ -5743,6 +6668,7 @@ export function createTelegramUpdateAdmissionLifecycleRuntime<TContext>(
|
|
|
5743
6668
|
binding.recoveryKey !== recoveryKey) {
|
|
5744
6669
|
throw new Error("Telegram update source binding changed during startup.");
|
|
5745
6670
|
}
|
|
6671
|
+
deps.prepareBinding?.(binding);
|
|
5746
6672
|
const release = deps.acquireSourceReference?.(binding);
|
|
5747
6673
|
const inputCustody = binding.journal.inputCustody;
|
|
5748
6674
|
try {
|
|
@@ -6043,6 +6969,8 @@ export interface TelegramUpdateAdmissionLifecycleAssemblyDeps<
|
|
|
6043
6969
|
isRegistered: () => boolean;
|
|
6044
6970
|
getGeneration: () => string | undefined;
|
|
6045
6971
|
prepareUpdateForExecution: (update: TUpdate) => TUpdate;
|
|
6972
|
+
/** Session succession before worker creation, under the exact generation-fenced binding. */
|
|
6973
|
+
prepareBinding?: (binding: TelegramUpdateAdmissionLifecycleJournalBinding & { hasAuthority?: () => boolean }) => void;
|
|
6046
6974
|
};
|
|
6047
6975
|
recordRuntimeEvent?: TelegramUpdateWorkerRuntimeDeps<TContext>["recordRuntimeEvent"];
|
|
6048
6976
|
}
|
|
@@ -6073,6 +7001,15 @@ export function createTelegramUpdateAdmissionWorkerRuntime<
|
|
|
6073
7001
|
const executeUpdate = createTelegramUpdateAdmissionHandle<TUpdate, TContext>({
|
|
6074
7002
|
defaultHandle: deps.defaultHandle,
|
|
6075
7003
|
registry: deps.registry,
|
|
7004
|
+
abandonDeferred: input => worker?.abandonDeferred?.(input),
|
|
7005
|
+
armRoutingInput: input => worker?.armRoutingInput?.(input),
|
|
7006
|
+
selectRoutingInput: input => worker?.selectRoutingInput?.(input) === true,
|
|
7007
|
+
isRoutingInputCurrent: input => worker?.isRoutingInputCurrent?.(input) !== false,
|
|
7008
|
+
supportsDeferredAbandonment: input => worker?.supportsDeferredAbandonment?.(input) === true,
|
|
7009
|
+
inspectAbandoning: input => worker?.inspectAbandoning?.(input),
|
|
7010
|
+
inspectHistorical: input => worker?.inspectHistorical?.(input),
|
|
7011
|
+
inspectDeferredSource: input => worker?.inspectDeferredSource?.(input),
|
|
7012
|
+
isHistoricalSource: input => worker?.isHistoricalSource?.(input) === true,
|
|
6076
7013
|
onLateOutcome(outcome, details) {
|
|
6077
7014
|
worker?.settleDeferred({
|
|
6078
7015
|
updateId: details.updateId,
|
|
@@ -6155,6 +7092,7 @@ export function createTelegramUpdateAdmissionLifecycleAssembly<
|
|
|
6155
7092
|
getQueueOwnerIdentity: deps.worker.getQueueOwnerIdentity,
|
|
6156
7093
|
...(deps.acquireSourceReference ? { acquireSourceReference: binding =>
|
|
6157
7094
|
deps.acquireSourceReference!("follower", binding) } : {}),
|
|
7095
|
+
...(deps.follower.prepareBinding ? { prepareBinding: deps.follower.prepareBinding } : {}),
|
|
6158
7096
|
resolveBinding() {
|
|
6159
7097
|
const generation = deps.follower.getGeneration();
|
|
6160
7098
|
if (!deps.follower.isRegistered() || !generation) return undefined;
|
|
@@ -6179,6 +7117,11 @@ export function createTelegramUpdateAdmissionLifecycleAssembly<
|
|
|
6179
7117
|
getJournalBindingKey: () => binding.recoveryKey,
|
|
6180
7118
|
getRecipientBindingKey: () => binding.recipientBindingKey,
|
|
6181
7119
|
hasAuthority: () => binding.hasAuthority?.() ?? false,
|
|
7120
|
+
// Recipient custody is not an orphaned direct-bot source.
|
|
7121
|
+
shouldReviewHistoricalInput: undefined,
|
|
7122
|
+
shouldHoldPendingInput: undefined,
|
|
7123
|
+
spendHistoricalInput: undefined,
|
|
7124
|
+
onHeldSourcesPrepared: undefined,
|
|
6182
7125
|
prepareUpdateForExecution: deps.follower.prepareUpdateForExecution,
|
|
6183
7126
|
});
|
|
6184
7127
|
},
|