@llblab/pi-telegram 0.27.12 → 0.28.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/updates.ts CHANGED
@@ -1,15 +1,56 @@
1
1
  /**
2
2
  * Telegram updates domain helpers
3
3
  * Zones: telegram inbound, authorization, routing plans
4
- * Owns update extraction, authorization, classification, execution planning, runtime execution, and the public update-handler registry
4
+ * Owns update extraction, authorization, execution planning, generation-fenced journal draining, and the public update-handler registry
5
5
  */
6
6
 
7
+ import { randomUUID } from "node:crypto";
8
+
7
9
  import {
8
10
  createTelegramPrivateTarget,
9
11
  createTelegramThreadTarget,
10
12
  type TelegramTarget,
11
13
  } from "./target.ts";
14
+ import type {
15
+ TelegramBusEnvelope,
16
+ TelegramBusFollowerView,
17
+ TelegramBusForeignUpdateSettlement,
18
+ TelegramProcessLiveness,
19
+ } from "./bus.ts";
12
20
  import type { TelegramMessageOwnershipStore } from "./ownership.ts";
21
+ import {
22
+ TELEGRAM_UPDATE_JOURNAL_FAILURE_CLASS_MAX_LENGTH,
23
+ TELEGRAM_UPDATE_JOURNAL_FAILURE_SUMMARY_MAX_LENGTH,
24
+ TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH,
25
+ areTelegramUpdateJournalQueueOwnersEqual,
26
+ getTelegramUpdateJournalBindingPath,
27
+ isTelegramUpdateJournalQueueOwnerProcess,
28
+ parseTelegramUpdateJournalQueueOwner,
29
+ type TelegramJournaledUpdate,
30
+ type TelegramUpdateJournalDeadQueueOwnerRecoveryResult,
31
+ type TelegramUpdateJournalOperatorDispositionInput,
32
+ type TelegramUpdateJournalOperatorDispositionResult,
33
+ type TelegramUpdateJournalQueueDiscardResult,
34
+ type TelegramUpdateJournalQueueHandoffAcceptResult,
35
+ type TelegramUpdateJournalQueueHandoffCancelResult,
36
+ type TelegramUpdateJournalQueueHandoffInput,
37
+ type TelegramUpdateJournalQueueHandoffOfferResult,
38
+ type TelegramUpdateJournalQueueOwner,
39
+ type TelegramUpdateJournalQueueOwnerIdentity,
40
+ } from "./journal.ts";
41
+ import {
42
+ areTelegramQueueAdmissionReceiptsEqual,
43
+ createTelegramQueueHandoff,
44
+ removeTelegramQueueItemByReceipt,
45
+ type PendingTelegramControlItem,
46
+ type TelegramControlQueueHandoffPayload,
47
+ type TelegramQueueAdmissionReceipt,
48
+ type TelegramQueueHandoffPayload,
49
+ type TelegramQueueHandoffStageResult,
50
+ type TelegramQueueReactionDisposition,
51
+ type TelegramQueueHandoffStagingRuntime,
52
+ type TelegramQueueItem,
53
+ } from "./queue.ts";
13
54
  import {
14
55
  createTelegramUserPairingRuntime,
15
56
  getTelegramAuthorizationState,
@@ -67,38 +108,49 @@ export function normalizeTelegramReactionEmoji(emoji: string): string {
67
108
  export function collectTelegramReactionEmojis(
68
109
  reactions: TelegramReactionType[],
69
110
  ): Set<string> {
70
- return new Set(
71
- reactions
72
- .filter(
73
- (reaction): reaction is TelegramReactionTypeEmoji =>
74
- reaction.type === "emoji",
75
- )
76
- .map((reaction) => normalizeTelegramReactionEmoji(reaction.emoji)),
77
- );
111
+ const emojis = new Set<string>();
112
+ for (const reaction of reactions) {
113
+ if (reaction.type === "emoji") {
114
+ const emojiReaction = reaction as TelegramReactionTypeEmoji;
115
+ emojis.add(normalizeTelegramReactionEmoji(emojiReaction.emoji));
116
+ }
117
+ }
118
+ return emojis;
78
119
  }
79
120
 
80
- function hasAnyTelegramReactionEmoji(
121
+ function getTelegramReactionEmoji(
81
122
  emojis: Set<string>,
82
123
  candidates: readonly string[],
83
- ): boolean {
84
- return candidates.some((emoji) => emojis.has(emoji));
124
+ ): string | undefined {
125
+ return candidates.find((emoji) => emojis.has(emoji));
85
126
  }
86
127
 
87
- function getAddedTelegramReactionEmoji(
88
- oldEmojis: Set<string>,
89
- newEmojis: Set<string>,
90
- candidates: readonly string[],
91
- ): string | undefined {
92
- return candidates.find(
93
- (emoji) => !oldEmojis.has(emoji) && newEmojis.has(emoji),
128
+ export function getTelegramQueueReactionDisposition(
129
+ reactions: TelegramReactionType[],
130
+ ): TelegramQueueReactionDisposition {
131
+ const emojis = collectTelegramReactionEmojis(reactions);
132
+ const suppressionEmoji = getTelegramReactionEmoji(
133
+ emojis,
134
+ TELEGRAM_REMOVAL_REACTION_EMOJIS,
94
135
  );
136
+ if (suppressionEmoji) return { kind: "suppressed", emoji: suppressionEmoji };
137
+ const priorityEmoji = getTelegramReactionEmoji(
138
+ emojis,
139
+ TELEGRAM_PRIORITY_REACTION_EMOJIS,
140
+ );
141
+ if (priorityEmoji) return { kind: "priority", emoji: priorityEmoji };
142
+ return { kind: "default" };
95
143
  }
96
- function hasAddedTelegramReactionEmoji(
97
- oldEmojis: Set<string>,
98
- newEmojis: Set<string>,
99
- candidates: readonly string[],
144
+
145
+ function areTelegramQueueReactionDispositionsEqual(
146
+ left: TelegramQueueReactionDisposition,
147
+ right: TelegramQueueReactionDisposition,
100
148
  ): boolean {
101
- return !!getAddedTelegramReactionEmoji(oldEmojis, newEmojis, candidates);
149
+ return (
150
+ left.kind === right.kind &&
151
+ (left.kind === "default" ||
152
+ (right.kind !== "default" && left.emoji === right.emoji))
153
+ );
102
154
  }
103
155
 
104
156
  export function extractDeletedTelegramMessageIds(
@@ -251,6 +303,7 @@ export function getAuthorizedTelegramGuestMessage(
251
303
  export interface TelegramMessageOwnershipView {
252
304
  instanceId: string;
253
305
  ownerGeneration?: string;
306
+ recipientBindingKey?: string;
254
307
  }
255
308
 
256
309
  export type TelegramMessageOwnershipLookup = (
@@ -261,6 +314,7 @@ export type TelegramMessageOwnershipLookup = (
261
314
  export interface TelegramTargetOwnershipView {
262
315
  instanceId: string;
263
316
  ownerGeneration?: string;
317
+ recipientBindingKey?: string;
264
318
  }
265
319
 
266
320
  export type TelegramTargetOwnershipLookup = (
@@ -278,22 +332,77 @@ export interface TelegramForeignOwnedUpdateForwarder<
278
332
  query: TCallbackQuery;
279
333
  ownership: TelegramMessageOwnershipView;
280
334
  ctx: TContext;
281
- }) => Promise<boolean> | boolean;
335
+ }) =>
336
+ | Promise<TelegramBusForeignUpdateSettlement>
337
+ | TelegramBusForeignUpdateSettlement;
282
338
  forwardReaction?: (input: {
283
339
  reactionUpdate: TReactionUpdate;
284
340
  ownership: TelegramMessageOwnershipView;
285
341
  ctx: TContext;
286
- }) => Promise<boolean> | boolean;
342
+ }) =>
343
+ | Promise<TelegramBusForeignUpdateSettlement>
344
+ | TelegramBusForeignUpdateSettlement;
287
345
  forwardMessage?: (input: {
288
346
  message: TMessage;
289
347
  ownership: TelegramTargetOwnershipView;
290
348
  ctx: TContext;
291
- }) => Promise<boolean> | boolean;
349
+ }) =>
350
+ | Promise<TelegramBusForeignUpdateSettlement>
351
+ | TelegramBusForeignUpdateSettlement;
292
352
  forwardEditedMessage?: (input: {
293
353
  message: TMessage;
294
354
  ownership: TelegramTargetOwnershipView;
295
355
  ctx: TContext;
296
- }) => Promise<boolean> | boolean;
356
+ }) =>
357
+ | Promise<TelegramBusForeignUpdateSettlement>
358
+ | TelegramBusForeignUpdateSettlement;
359
+ }
360
+
361
+ type TelegramForeignUpdateSettlementFailure =
362
+ | Exclude<TelegramBusForeignUpdateSettlement, { status: "accepted" }>
363
+ | {
364
+ status: "terminal-rejected";
365
+ failureClass: "forwarder-unavailable";
366
+ message: string;
367
+ sourceUpdateId?: number;
368
+ };
369
+
370
+ export class TelegramForeignUpdateSettlementError extends Error {
371
+ readonly settlement: TelegramForeignUpdateSettlementFailure;
372
+
373
+ constructor(
374
+ operation: string,
375
+ settlement: TelegramForeignUpdateSettlementFailure,
376
+ ) {
377
+ super(
378
+ `Telegram ${operation} forwarding did not settle: ${settlement.failureClass}.`,
379
+ );
380
+ this.name = "TelegramForeignUpdateSettlementError";
381
+ this.settlement = settlement;
382
+ }
383
+ }
384
+
385
+ function rejectTelegramForeignUpdateSettlement(
386
+ settlement: TelegramBusForeignUpdateSettlement | undefined,
387
+ operation: string,
388
+ source: unknown,
389
+ ): never {
390
+ const sourceUpdateId =
391
+ source && typeof source === "object"
392
+ ? Reflect.get(source, "pi_telegram_source_update_id")
393
+ : undefined;
394
+ const failure: TelegramForeignUpdateSettlementFailure =
395
+ settlement && settlement.status !== "accepted"
396
+ ? settlement
397
+ : {
398
+ status: "terminal-rejected",
399
+ failureClass: "forwarder-unavailable",
400
+ message: `Telegram ${operation} forwarding is unavailable.`,
401
+ ...(Number.isSafeInteger(sourceUpdateId) && sourceUpdateId >= 0
402
+ ? { sourceUpdateId }
403
+ : {}),
404
+ };
405
+ throw new TelegramForeignUpdateSettlementError(operation, failure);
297
406
  }
298
407
 
299
408
  export interface TelegramMessageReactionUpdated {
@@ -314,6 +423,191 @@ export interface TelegramUpdateFlow
314
423
  [TELEGRAM_INTERNAL_AGENT_MESSAGE]?: true;
315
424
  }
316
425
 
426
+ export type TelegramUpdateAdmissionOutcome =
427
+ | { kind: "complete" }
428
+ | { kind: "deferred" }
429
+ | {
430
+ kind: "queued";
431
+ queueKind: "prompt" | "control";
432
+ receiptId: string;
433
+ sourceUpdateIds: readonly number[];
434
+ };
435
+
436
+ type TelegramQueuedUpdateAdmissionOutcome = Extract<
437
+ TelegramUpdateAdmissionOutcome,
438
+ { kind: "queued" }
439
+ >;
440
+
441
+ const TELEGRAM_UPDATE_ADMISSION_BINDING = Symbol(
442
+ "telegram.update-admission.binding",
443
+ );
444
+
445
+ interface TelegramUpdateAdmissionBinding {
446
+ sourceUpdateId: number;
447
+ report: (
448
+ outcome: Extract<
449
+ TelegramUpdateAdmissionOutcome,
450
+ { kind: "deferred" | "queued" }
451
+ >,
452
+ ) => void;
453
+ }
454
+
455
+ export type TelegramQueueAdmissionReceiptLike = TelegramQueueAdmissionReceipt;
456
+
457
+ function bindTelegramUpdateAdmissionCarrier<TValue>(
458
+ value: TValue | undefined,
459
+ binding: TelegramUpdateAdmissionBinding,
460
+ ): TValue | undefined {
461
+ if (!value || typeof value !== "object") return value;
462
+ return {
463
+ ...(value as Record<PropertyKey, unknown>),
464
+ pi_telegram_source_update_id: binding.sourceUpdateId,
465
+ [TELEGRAM_UPDATE_ADMISSION_BINDING]: binding,
466
+ } as TValue;
467
+ }
468
+
469
+ function getTelegramUpdateAdmissionBinding(
470
+ value: unknown,
471
+ ): TelegramUpdateAdmissionBinding | undefined {
472
+ if (!value || typeof value !== "object") return undefined;
473
+ const binding = Reflect.get(value, TELEGRAM_UPDATE_ADMISSION_BINDING) as
474
+ | TelegramUpdateAdmissionBinding
475
+ | undefined;
476
+ return binding &&
477
+ Number.isSafeInteger(binding.sourceUpdateId) &&
478
+ binding.sourceUpdateId >= 0 &&
479
+ typeof binding.report === "function"
480
+ ? binding
481
+ : undefined;
482
+ }
483
+
484
+ export function bindTelegramUpdateAdmissionSource<
485
+ TUpdate extends TelegramUpdateFlow & { update_id: number },
486
+ >(
487
+ update: TUpdate,
488
+ report: TelegramUpdateAdmissionBinding["report"],
489
+ ): TUpdate {
490
+ if (!Number.isSafeInteger(update.update_id) || update.update_id < 0) {
491
+ throw new TelegramUpdateAdmissionOutcomeError(
492
+ "Telegram update admission requires a safe update_id.",
493
+ );
494
+ }
495
+ const binding: TelegramUpdateAdmissionBinding = {
496
+ sourceUpdateId: update.update_id,
497
+ report,
498
+ };
499
+ const callbackQuery = update.callback_query
500
+ ? bindTelegramUpdateAdmissionCarrier(
501
+ {
502
+ ...update.callback_query,
503
+ message: bindTelegramUpdateAdmissionCarrier(
504
+ update.callback_query.message,
505
+ binding,
506
+ ),
507
+ },
508
+ binding,
509
+ )
510
+ : undefined;
511
+ return {
512
+ ...update,
513
+ ...(update.message
514
+ ? { message: bindTelegramUpdateAdmissionCarrier(update.message, binding) }
515
+ : {}),
516
+ ...(update.edited_message
517
+ ? {
518
+ edited_message: bindTelegramUpdateAdmissionCarrier(
519
+ update.edited_message,
520
+ binding,
521
+ ),
522
+ }
523
+ : {}),
524
+ ...(callbackQuery ? { callback_query: callbackQuery } : {}),
525
+ ...(update.guest_message
526
+ ? {
527
+ guest_message: bindTelegramUpdateAdmissionCarrier(
528
+ update.guest_message,
529
+ binding,
530
+ ),
531
+ }
532
+ : {}),
533
+ ...(update.message_reaction
534
+ ? {
535
+ message_reaction: bindTelegramUpdateAdmissionCarrier(
536
+ update.message_reaction,
537
+ binding,
538
+ ),
539
+ }
540
+ : {}),
541
+ } as TUpdate;
542
+ }
543
+
544
+ export function collectTelegramAdmissionSourceUpdateIds(
545
+ values: readonly unknown[],
546
+ ): number[] {
547
+ const sourceUpdateIds = new Set<number>();
548
+ for (const value of values) {
549
+ const binding = getTelegramUpdateAdmissionBinding(value);
550
+ if (binding) sourceUpdateIds.add(binding.sourceUpdateId);
551
+ }
552
+ return [...sourceUpdateIds].sort((left, right) => left - right);
553
+ }
554
+
555
+ export function reportTelegramUpdateDeferred(value: unknown): boolean {
556
+ const binding = getTelegramUpdateAdmissionBinding(value);
557
+ if (!binding) return false;
558
+ binding.report({ kind: "deferred" });
559
+ return true;
560
+ }
561
+
562
+ export function reportTelegramQueueAdmission(
563
+ values: readonly unknown[],
564
+ receipts: readonly TelegramQueueAdmissionReceiptLike[],
565
+ ): boolean {
566
+ const bindings = new Map<number, TelegramUpdateAdmissionBinding>();
567
+ for (const value of values) {
568
+ const binding = getTelegramUpdateAdmissionBinding(value);
569
+ if (!binding) continue;
570
+ const existing = bindings.get(binding.sourceUpdateId);
571
+ if (existing && existing !== binding) {
572
+ throw new TelegramUpdateAdmissionOutcomeError(
573
+ `Telegram update ${binding.sourceUpdateId} has conflicting admission bindings.`,
574
+ );
575
+ }
576
+ bindings.set(binding.sourceUpdateId, binding);
577
+ }
578
+ if (bindings.size === 0) return false;
579
+ const receiptsByUpdateId = new Map<
580
+ number,
581
+ { receipt: TelegramQueueAdmissionReceiptLike; count: number }
582
+ >();
583
+ for (const receipt of receipts) {
584
+ for (const sourceUpdateId of new Set(receipt.sourceUpdateIds)) {
585
+ const existing = receiptsByUpdateId.get(sourceUpdateId);
586
+ if (existing) existing.count += 1;
587
+ else receiptsByUpdateId.set(sourceUpdateId, { receipt, count: 1 });
588
+ }
589
+ }
590
+ const reports = [...bindings].map(([sourceUpdateId, binding]) => {
591
+ const match = receiptsByUpdateId.get(sourceUpdateId);
592
+ if (!match || match.count !== 1) {
593
+ throw new TelegramUpdateAdmissionOutcomeError(
594
+ `Telegram update ${sourceUpdateId} requires one exact queue receipt.`,
595
+ );
596
+ }
597
+ return {
598
+ binding,
599
+ outcome: {
600
+ kind: "queued" as const,
601
+ queueKind: match.receipt.queueKind,
602
+ receiptId: match.receipt.receiptId,
603
+ sourceUpdateIds: [...match.receipt.sourceUpdateIds],
604
+ },
605
+ };
606
+ });
607
+ for (const report of reports) report.binding.report(report.outcome);
608
+ return true;
609
+ }
610
+
317
611
  export type TelegramUpdateFlowAction<
318
612
  TReactionUpdate extends TelegramMessageReactionUpdated =
319
613
  TelegramMessageReactionUpdated,
@@ -563,6 +857,7 @@ export interface TelegramUpdateRuntimeDeps<
563
857
  TMessage extends TelegramUpdateMessage = TelegramUpdateMessage,
564
858
  > {
565
859
  ctx: TContext;
860
+ execution?: TelegramUpdateExecutionFence;
566
861
  getCurrentInstanceId?: () => string | undefined;
567
862
  getMessageOwnership?: TelegramMessageOwnershipLookup;
568
863
  getTargetOwnership?: TelegramTargetOwnershipLookup;
@@ -586,7 +881,11 @@ export interface TelegramUpdateRuntimeDeps<
586
881
  lifecycle: TelegramTopicLifecycleUpdate<TMessage>,
587
882
  ctx: TContext,
588
883
  ) => Promise<void> | void;
589
- pairTelegramUserIfNeeded: (userId: number, ctx: TContext) => Promise<boolean>;
884
+ pairTelegramUserIfNeeded: (
885
+ userId: number,
886
+ ctx: TContext,
887
+ assertExecutionCurrent?: () => void,
888
+ ) => Promise<boolean>;
590
889
  answerCallbackQuery: (
591
890
  callbackQueryId: string,
592
891
  text?: string,
@@ -638,23 +937,24 @@ export interface TelegramUpdateRuntimeControllerDeps<
638
937
  TMessage
639
938
  >;
640
939
  removePendingMediaGroupMessages: (messageIds: number[]) => void;
940
+ flushPendingMediaGroupMessage?: (messageId: number) => Promise<boolean>;
941
+ flushPendingTextGroupMessage?: (messageId: number) => Promise<boolean>;
641
942
  removeQueuedTelegramTurnsByMessageIds: (
642
943
  messageIds: number[],
643
944
  ctx: TContext,
644
945
  scope?: { chatId?: number; threadId?: number },
645
946
  ) => number;
646
- clearQueuedTelegramTurnPriorityByMessageId: (
947
+ applyQueuedTelegramTurnReactionByMessageId: (
647
948
  messageId: number,
949
+ disposition: TelegramQueueReactionDisposition,
648
950
  ctx: TContext,
649
951
  scope?: { chatId?: number; threadId?: number },
650
952
  ) => boolean;
651
- prioritizeQueuedTelegramTurnByMessageId: (
652
- messageId: number,
953
+ pairTelegramUserIfNeeded: (
954
+ userId: number,
653
955
  ctx: TContext,
654
- priorityEmoji?: string,
655
- scope?: { chatId?: number; threadId?: number },
656
- ) => boolean;
657
- pairTelegramUserIfNeeded: (userId: number, ctx: TContext) => Promise<boolean>;
956
+ assertExecutionCurrent?: () => void,
957
+ ) => Promise<boolean>;
658
958
  answerCallbackQuery: (
659
959
  callbackQueryId: string,
660
960
  text?: string,
@@ -701,7 +1001,11 @@ export interface TelegramUpdateRuntimeController<
701
1001
  reactionUpdate: NonNullable<TUpdate["message_reaction"]>,
702
1002
  ctx: TContext,
703
1003
  ) => Promise<void>;
704
- handleUpdate: (update: TUpdate, ctx: TContext) => Promise<void>;
1004
+ handleUpdate: (
1005
+ update: TUpdate,
1006
+ ctx: TContext,
1007
+ execution?: TelegramUpdateExecutionFence,
1008
+ ) => Promise<void>;
705
1009
  }
706
1010
 
707
1011
  function getTelegramCallbackQueryId(
@@ -850,18 +1154,19 @@ export function createTelegramPairedUpdateRuntime<
850
1154
  handleTelegramTopicLifecycleUpdate: deps.handleTelegramTopicLifecycleUpdate,
851
1155
  foreignOwnedUpdateForwarder: deps.foreignOwnedUpdateForwarder,
852
1156
  removePendingMediaGroupMessages: deps.removePendingMediaGroupMessages,
1157
+ flushPendingMediaGroupMessage: deps.flushPendingMediaGroupMessage,
1158
+ flushPendingTextGroupMessage: deps.flushPendingTextGroupMessage,
853
1159
  removeQueuedTelegramTurnsByMessageIds:
854
1160
  deps.removeQueuedTelegramTurnsByMessageIds,
855
- clearQueuedTelegramTurnPriorityByMessageId:
856
- deps.clearQueuedTelegramTurnPriorityByMessageId,
857
- prioritizeQueuedTelegramTurnByMessageId:
858
- deps.prioritizeQueuedTelegramTurnByMessageId,
859
- pairTelegramUserIfNeeded: createTelegramUserPairingRuntime({
860
- getAllowedUserId: deps.getAllowedUserId,
861
- setAllowedUserId: deps.setAllowedUserId,
862
- persistConfig: deps.persistConfig,
863
- updateStatus: deps.updateStatus,
864
- }).pairIfNeeded,
1161
+ applyQueuedTelegramTurnReactionByMessageId:
1162
+ deps.applyQueuedTelegramTurnReactionByMessageId,
1163
+ pairTelegramUserIfNeeded: (userId, ctx, assertExecutionCurrent) =>
1164
+ createTelegramUserPairingRuntime({
1165
+ getAllowedUserId: deps.getAllowedUserId,
1166
+ setAllowedUserId: deps.setAllowedUserId,
1167
+ persistConfig: deps.persistConfig,
1168
+ updateStatus: deps.updateStatus,
1169
+ }).pairIfNeeded(userId, ctx, assertExecutionCurrent),
865
1170
  answerCallbackQuery: deps.answerCallbackQuery,
866
1171
  answerGuestQuery: deps.answerGuestQuery,
867
1172
  handleAuthorizedTelegramCallbackQuery:
@@ -893,23 +1198,24 @@ export function createTelegramUpdateRuntime<
893
1198
  await handleAuthorizedTelegramReactionUpdate(reactionUpdate, {
894
1199
  allowedUserId: deps.getAllowedUserId(),
895
1200
  ctx,
896
- removePendingMediaGroupMessages: deps.removePendingMediaGroupMessages,
897
- removeQueuedTelegramTurnsByMessageIds:
898
- deps.removeQueuedTelegramTurnsByMessageIds,
1201
+ flushPendingMediaGroupMessage: deps.flushPendingMediaGroupMessage,
1202
+ flushPendingTextGroupMessage: deps.flushPendingTextGroupMessage,
899
1203
  getCurrentInstanceId: deps.getCurrentInstanceId,
900
1204
  getMessageOwnership: deps.getMessageOwnership,
901
1205
  foreignOwnedUpdateForwarder: deps.foreignOwnedUpdateForwarder,
902
- clearQueuedTelegramTurnPriorityByMessageId:
903
- deps.clearQueuedTelegramTurnPriorityByMessageId,
904
- prioritizeQueuedTelegramTurnByMessageId:
905
- deps.prioritizeQueuedTelegramTurnByMessageId,
1206
+ assertExecutionCurrent: createTelegramUpdateExecutionFenceGuard(
1207
+ reactionUpdate,
1208
+ ),
1209
+ applyQueuedTelegramTurnReactionByMessageId:
1210
+ deps.applyQueuedTelegramTurnReactionByMessageId,
906
1211
  });
907
1212
  };
908
1213
  return {
909
1214
  handleAuthorizedReactionUpdate,
910
- handleUpdate: (update, ctx) =>
1215
+ handleUpdate: (update, ctx, execution) =>
911
1216
  executeTelegramUpdate(update, deps.getAllowedUserId(), {
912
1217
  ctx,
1218
+ execution,
913
1219
  getCurrentInstanceId: deps.getCurrentInstanceId,
914
1220
  getMessageOwnership: deps.getMessageOwnership,
915
1221
  getTargetOwnership: deps.getTargetOwnership,
@@ -944,21 +1250,13 @@ export interface AuthorizedTelegramReactionUpdateDeps<TContext> {
944
1250
  getCurrentInstanceId?: () => string | undefined;
945
1251
  getMessageOwnership?: TelegramMessageOwnershipLookup;
946
1252
  foreignOwnedUpdateForwarder?: TelegramForeignOwnedUpdateForwarder<TContext>;
947
- removePendingMediaGroupMessages: (messageIds: number[]) => void;
948
- removeQueuedTelegramTurnsByMessageIds: (
949
- messageIds: number[],
950
- ctx: TContext,
951
- scope?: { chatId?: number; threadId?: number },
952
- ) => number;
953
- clearQueuedTelegramTurnPriorityByMessageId: (
954
- messageId: number,
955
- ctx: TContext,
956
- scope?: { chatId?: number; threadId?: number },
957
- ) => boolean;
958
- prioritizeQueuedTelegramTurnByMessageId: (
1253
+ assertExecutionCurrent?: () => void;
1254
+ flushPendingMediaGroupMessage?: (messageId: number) => Promise<boolean>;
1255
+ flushPendingTextGroupMessage?: (messageId: number) => Promise<boolean>;
1256
+ applyQueuedTelegramTurnReactionByMessageId: (
959
1257
  messageId: number,
1258
+ disposition: TelegramQueueReactionDisposition,
960
1259
  ctx: TContext,
961
- priorityEmoji?: string,
962
1260
  scope?: { chatId?: number; threadId?: number },
963
1261
  ) => boolean;
964
1262
  }
@@ -972,11 +1270,21 @@ export async function handleAuthorizedTelegramReactionUpdate<TContext>(
972
1270
  deps,
973
1271
  );
974
1272
  if (foreignOwnership) {
975
- await deps.foreignOwnedUpdateForwarder?.forwardReaction?.({
976
- reactionUpdate,
977
- ownership: foreignOwnership,
978
- ctx: deps.ctx,
979
- });
1273
+ deps.assertExecutionCurrent?.();
1274
+ const settlement =
1275
+ await deps.foreignOwnedUpdateForwarder?.forwardReaction?.({
1276
+ reactionUpdate,
1277
+ ownership: foreignOwnership,
1278
+ ctx: deps.ctx,
1279
+ });
1280
+ deps.assertExecutionCurrent?.();
1281
+ if (settlement?.status !== "accepted") {
1282
+ rejectTelegramForeignUpdateSettlement(
1283
+ settlement,
1284
+ "reaction",
1285
+ reactionUpdate,
1286
+ );
1287
+ }
980
1288
  return;
981
1289
  }
982
1290
  const reactionUser = reactionUpdate.user;
@@ -991,48 +1299,26 @@ export async function handleAuthorizedTelegramReactionUpdate<TContext>(
991
1299
  typeof reactionUpdate.chat.id === "number"
992
1300
  ? { chatId: reactionUpdate.chat.id }
993
1301
  : undefined;
994
- const oldEmojis = collectTelegramReactionEmojis(reactionUpdate.old_reaction);
995
- const newEmojis = collectTelegramReactionEmojis(reactionUpdate.new_reaction);
1302
+ const oldDisposition = getTelegramQueueReactionDisposition(
1303
+ reactionUpdate.old_reaction,
1304
+ );
1305
+ const newDisposition = getTelegramQueueReactionDisposition(
1306
+ reactionUpdate.new_reaction,
1307
+ );
996
1308
  if (
997
- hasAddedTelegramReactionEmoji(
998
- oldEmojis,
999
- newEmojis,
1000
- TELEGRAM_REMOVAL_REACTION_EMOJIS,
1001
- )
1309
+ areTelegramQueueReactionDispositionsEqual(oldDisposition, newDisposition)
1002
1310
  ) {
1003
- deps.removePendingMediaGroupMessages([reactionUpdate.message_id]);
1004
- deps.removeQueuedTelegramTurnsByMessageIds(
1005
- [reactionUpdate.message_id],
1006
- deps.ctx,
1007
- reactionScope,
1008
- );
1009
1311
  return;
1010
1312
  }
1011
- const hadPriorityReaction = hasAnyTelegramReactionEmoji(
1012
- oldEmojis,
1013
- TELEGRAM_PRIORITY_REACTION_EMOJIS,
1014
- );
1015
- const hasPriorityReaction = hasAnyTelegramReactionEmoji(
1016
- newEmojis,
1017
- TELEGRAM_PRIORITY_REACTION_EMOJIS,
1018
- );
1019
- if (hadPriorityReaction && !hasPriorityReaction) {
1020
- deps.clearQueuedTelegramTurnPriorityByMessageId(
1021
- reactionUpdate.message_id,
1022
- deps.ctx,
1023
- reactionScope,
1024
- );
1025
- }
1026
- const addedPriorityEmoji = getAddedTelegramReactionEmoji(
1027
- oldEmojis,
1028
- newEmojis,
1029
- TELEGRAM_PRIORITY_REACTION_EMOJIS,
1030
- );
1031
- if (!addedPriorityEmoji) return;
1032
- deps.prioritizeQueuedTelegramTurnByMessageId(
1313
+ deps.assertExecutionCurrent?.();
1314
+ await deps.flushPendingMediaGroupMessage?.(reactionUpdate.message_id);
1315
+ deps.assertExecutionCurrent?.();
1316
+ await deps.flushPendingTextGroupMessage?.(reactionUpdate.message_id);
1317
+ deps.assertExecutionCurrent?.();
1318
+ deps.applyQueuedTelegramTurnReactionByMessageId(
1033
1319
  reactionUpdate.message_id,
1320
+ newDisposition,
1034
1321
  deps.ctx,
1035
- addedPriorityEmoji,
1036
1322
  reactionScope,
1037
1323
  );
1038
1324
  }
@@ -1061,20 +1347,26 @@ export async function executeTelegramUpdatePlan<
1061
1347
  >,
1062
1348
  ): Promise<void> {
1063
1349
  try {
1350
+ const assertExecutionCurrent = (): void =>
1351
+ deps.execution?.assertCurrent();
1064
1352
  if (plan.kind === "ignore") return;
1065
1353
  if (plan.kind === "deleted") {
1354
+ assertExecutionCurrent();
1066
1355
  deps.removePendingMediaGroupMessages(plan.messageIds);
1067
1356
  deps.removeQueuedTelegramTurnsByMessageIds(plan.messageIds, deps.ctx);
1068
1357
  return;
1069
1358
  }
1070
1359
  if (plan.kind === "reaction") {
1360
+ assertExecutionCurrent();
1071
1361
  await deps.handleAuthorizedTelegramReactionUpdate(
1072
1362
  plan.reactionUpdate,
1073
1363
  deps.ctx,
1074
1364
  );
1365
+ assertExecutionCurrent();
1075
1366
  return;
1076
1367
  }
1077
1368
  if (plan.kind === "topic-lifecycle") {
1369
+ assertExecutionCurrent();
1078
1370
  await deps.handleTelegramTopicLifecycleUpdate?.(plan.lifecycle, deps.ctx);
1079
1371
  return;
1080
1372
  }
@@ -1084,29 +1376,46 @@ export async function executeTelegramUpdatePlan<
1084
1376
  deps,
1085
1377
  );
1086
1378
  if (foreignOwnership) {
1087
- const forwarded =
1088
- (await deps.foreignOwnedUpdateForwarder?.forwardCallback?.({
1379
+ assertExecutionCurrent();
1380
+ const settlement =
1381
+ await deps.foreignOwnedUpdateForwarder?.forwardCallback?.({
1089
1382
  query: plan.query,
1090
1383
  ownership: foreignOwnership,
1091
1384
  ctx: deps.ctx,
1092
- })) ?? false;
1093
- if (!forwarded) {
1385
+ });
1386
+ if (settlement?.status !== "accepted") {
1094
1387
  const callbackQueryId = getTelegramCallbackQueryId(plan.query);
1095
- if (callbackQueryId) {
1096
- await deps.answerCallbackQuery(
1097
- callbackQueryId,
1098
- "This Telegram message belongs to another Pi instance.",
1388
+ try {
1389
+ if (callbackQueryId) {
1390
+ assertExecutionCurrent();
1391
+ await deps.answerCallbackQuery(
1392
+ callbackQueryId,
1393
+ "This Telegram message belongs to another Pi instance.",
1394
+ );
1395
+ }
1396
+ } finally {
1397
+ rejectTelegramForeignUpdateSettlement(
1398
+ settlement,
1399
+ "callback",
1400
+ plan.query,
1099
1401
  );
1100
1402
  }
1101
1403
  }
1404
+ assertExecutionCurrent();
1102
1405
  return;
1103
1406
  }
1104
1407
  if (plan.shouldPair) {
1105
- await deps.pairTelegramUserIfNeeded(plan.query.from.id, deps.ctx);
1408
+ assertExecutionCurrent();
1409
+ await deps.pairTelegramUserIfNeeded(
1410
+ plan.query.from.id,
1411
+ deps.ctx,
1412
+ assertExecutionCurrent,
1413
+ );
1106
1414
  }
1107
1415
  if (plan.shouldDeny) {
1108
1416
  const callbackQueryId = getTelegramCallbackQueryId(plan.query);
1109
1417
  if (callbackQueryId) {
1418
+ assertExecutionCurrent();
1110
1419
  await deps.answerCallbackQuery(
1111
1420
  callbackQueryId,
1112
1421
  "This bot is not authorized for your account.",
@@ -1114,11 +1423,14 @@ export async function executeTelegramUpdatePlan<
1114
1423
  }
1115
1424
  return;
1116
1425
  }
1426
+ assertExecutionCurrent();
1117
1427
  await deps.handleAuthorizedTelegramCallbackQuery(plan.query, deps.ctx);
1428
+ assertExecutionCurrent();
1118
1429
  return;
1119
1430
  }
1120
1431
  if (plan.kind === "guest") {
1121
1432
  if (plan.shouldDeny) {
1433
+ assertExecutionCurrent();
1122
1434
  await deps.answerGuestQuery(
1123
1435
  plan.guestMessage.guest_query_id,
1124
1436
  "🚫 Access denied.",
@@ -1126,10 +1438,12 @@ export async function executeTelegramUpdatePlan<
1126
1438
  return;
1127
1439
  }
1128
1440
  if (deps.handleAuthorizedTelegramGuestMessage) {
1441
+ assertExecutionCurrent();
1129
1442
  await deps.handleAuthorizedTelegramGuestMessage(
1130
1443
  plan.guestMessage,
1131
1444
  deps.ctx,
1132
1445
  );
1446
+ assertExecutionCurrent();
1133
1447
  }
1134
1448
  return;
1135
1449
  }
@@ -1138,19 +1452,27 @@ export async function executeTelegramUpdatePlan<
1138
1452
  deps,
1139
1453
  );
1140
1454
  if (foreignMessageOwnership) {
1141
- if (plan.kind === "edited-message") {
1142
- await deps.foreignOwnedUpdateForwarder?.forwardEditedMessage?.({
1143
- message: plan.message,
1144
- ownership: foreignMessageOwnership,
1145
- ctx: deps.ctx,
1146
- });
1147
- } else {
1148
- await deps.foreignOwnedUpdateForwarder?.forwardMessage?.({
1149
- message: plan.message,
1150
- ownership: foreignMessageOwnership,
1151
- ctx: deps.ctx,
1152
- });
1455
+ assertExecutionCurrent();
1456
+ const settlement =
1457
+ plan.kind === "edited-message"
1458
+ ? await deps.foreignOwnedUpdateForwarder?.forwardEditedMessage?.({
1459
+ message: plan.message,
1460
+ ownership: foreignMessageOwnership,
1461
+ ctx: deps.ctx,
1462
+ })
1463
+ : await deps.foreignOwnedUpdateForwarder?.forwardMessage?.({
1464
+ message: plan.message,
1465
+ ownership: foreignMessageOwnership,
1466
+ ctx: deps.ctx,
1467
+ });
1468
+ if (settlement?.status !== "accepted") {
1469
+ rejectTelegramForeignUpdateSettlement(
1470
+ settlement,
1471
+ plan.kind,
1472
+ plan.message,
1473
+ );
1153
1474
  }
1475
+ assertExecutionCurrent();
1154
1476
  return;
1155
1477
  }
1156
1478
  const messageTarget = getTelegramMessageTarget(plan.message);
@@ -1160,6 +1482,7 @@ export async function executeTelegramUpdatePlan<
1160
1482
  );
1161
1483
  if (foreignTargetOwnership) {
1162
1484
  if (typeof plan.message.message_id === "number") {
1485
+ assertExecutionCurrent();
1163
1486
  deps.recordMessageOwnership?.({
1164
1487
  chatId: messageTarget!.chatId,
1165
1488
  messageId: plan.message.message_id,
@@ -1167,19 +1490,27 @@ export async function executeTelegramUpdatePlan<
1167
1490
  instanceId: foreignTargetOwnership.instanceId,
1168
1491
  });
1169
1492
  }
1170
- if (plan.kind === "edited-message") {
1171
- await deps.foreignOwnedUpdateForwarder?.forwardEditedMessage?.({
1172
- message: plan.message,
1173
- ownership: foreignTargetOwnership,
1174
- ctx: deps.ctx,
1175
- });
1176
- } else {
1177
- await deps.foreignOwnedUpdateForwarder?.forwardMessage?.({
1178
- message: plan.message,
1179
- ownership: foreignTargetOwnership,
1180
- ctx: deps.ctx,
1181
- });
1493
+ assertExecutionCurrent();
1494
+ const settlement =
1495
+ plan.kind === "edited-message"
1496
+ ? await deps.foreignOwnedUpdateForwarder?.forwardEditedMessage?.({
1497
+ message: plan.message,
1498
+ ownership: foreignTargetOwnership,
1499
+ ctx: deps.ctx,
1500
+ })
1501
+ : await deps.foreignOwnedUpdateForwarder?.forwardMessage?.({
1502
+ message: plan.message,
1503
+ ownership: foreignTargetOwnership,
1504
+ ctx: deps.ctx,
1505
+ });
1506
+ if (settlement?.status !== "accepted") {
1507
+ rejectTelegramForeignUpdateSettlement(
1508
+ settlement,
1509
+ plan.kind,
1510
+ plan.message,
1511
+ );
1182
1512
  }
1513
+ assertExecutionCurrent();
1183
1514
  return;
1184
1515
  }
1185
1516
  if (
@@ -1187,11 +1518,18 @@ export async function executeTelegramUpdatePlan<
1187
1518
  messageTarget?.threadId != null &&
1188
1519
  deps.handleUnboundTelegramTopicMessage
1189
1520
  ) {
1521
+ assertExecutionCurrent();
1190
1522
  await deps.handleUnboundTelegramTopicMessage(plan.message, deps.ctx);
1523
+ assertExecutionCurrent();
1191
1524
  return;
1192
1525
  }
1526
+ if (plan.shouldPair) assertExecutionCurrent();
1193
1527
  const pairedNow = plan.shouldPair
1194
- ? await deps.pairTelegramUserIfNeeded(plan.message.from.id, deps.ctx)
1528
+ ? await deps.pairTelegramUserIfNeeded(
1529
+ plan.message.from.id,
1530
+ deps.ctx,
1531
+ assertExecutionCurrent,
1532
+ )
1195
1533
  : false;
1196
1534
  const replyTarget = getTelegramMessageReplyTarget(plan.message);
1197
1535
  if (
@@ -1200,15 +1538,18 @@ export async function executeTelegramUpdatePlan<
1200
1538
  plan.shouldNotifyPaired &&
1201
1539
  replyTarget
1202
1540
  ) {
1541
+ assertExecutionCurrent();
1203
1542
  await deps.sendTextReply(
1204
1543
  replyTarget.chatId,
1205
1544
  replyTarget.messageId,
1206
1545
  "Telegram bridge paired with this account.",
1207
1546
  { target: replyTarget },
1208
1547
  );
1548
+ assertExecutionCurrent();
1209
1549
  }
1210
1550
  if (plan.shouldDeny) {
1211
1551
  if (replyTarget) {
1552
+ assertExecutionCurrent();
1212
1553
  await deps.sendTextReply(
1213
1554
  replyTarget.chatId,
1214
1555
  replyTarget.messageId,
@@ -1219,111 +1560,1816 @@ export async function executeTelegramUpdatePlan<
1219
1560
  return;
1220
1561
  }
1221
1562
  if (plan.kind === "edited-message") {
1563
+ assertExecutionCurrent();
1222
1564
  await deps.handleAuthorizedTelegramEditedMessage(plan.message, deps.ctx);
1565
+ assertExecutionCurrent();
1223
1566
  return;
1224
1567
  }
1568
+ assertExecutionCurrent();
1225
1569
  await deps.handleAuthorizedTelegramMessage(plan.message, deps.ctx);
1570
+ assertExecutionCurrent();
1226
1571
  } catch (error) {
1227
1572
  if (!isTelegramStaleContextError(error)) throw error;
1228
1573
  }
1229
1574
  }
1230
1575
 
1231
- // --- Public update handler registry ---
1576
+ // --- Durable update worker ---
1232
1577
 
1233
- /**
1234
- * Verdict returned by a public Telegram update handler.
1235
- *
1236
- * - `"consume"` — the handler processed this update; pi-telegram skips default routing.
1237
- * - `"pass"` (or `void`/`undefined`) — pi-telegram routes the update normally.
1238
- */
1239
- export type TelegramUpdateHandlerVerdict = "consume" | "pass";
1578
+ export const TELEGRAM_UPDATE_RETRY_BASE_DELAY_MS = 1_000;
1579
+ export const TELEGRAM_UPDATE_RETRY_MAX_DELAY_MS = 60_000;
1580
+ export const TELEGRAM_UPDATE_WORKER_BATCH_SIZE = 64;
1240
1581
 
1241
- export type TelegramUpdateHandler = (
1242
- update: unknown,
1243
- ) =>
1244
- | TelegramUpdateHandlerVerdict
1245
- | void
1246
- | Promise<TelegramUpdateHandlerVerdict | void>;
1582
+ export type TelegramUpdateWorkerPhase =
1583
+ | "stopped"
1584
+ | "idle"
1585
+ | "executing"
1586
+ | "retry-wait"
1587
+ | "failed"
1588
+ | "deferred"
1589
+ | "queued"
1590
+ | "blocked";
1247
1591
 
1248
- export interface TelegramUpdateHandlerRegistry {
1249
- /** Schema version of this registry shape. */
1250
- readonly version: 1;
1251
- /**
1252
- * Register an update handler. Returns a disposer that removes it.
1253
- *
1254
- * Handlers are invoked in registration order on every Telegram update,
1255
- * before pi-telegram's own routing. The first handler that returns
1256
- * `"consume"` wins and stops the chain for that update.
1257
- */
1258
- add: (handler: TelegramUpdateHandler) => () => void;
1259
- /**
1260
- * Run all registered handlers against an update.
1261
- *
1262
- * Used by pi-telegram's polling runtime; extension consumers should call
1263
- * {@link registerTelegramUpdateHandler} or `add` instead of dispatching directly.
1264
- */
1265
- dispatch: (update: unknown) => Promise<TelegramUpdateHandlerVerdict>;
1592
+ export type TelegramUpdateWorkerBlockedReason =
1593
+ | "authority-lost"
1594
+ | "authority-check"
1595
+ | "journal-read"
1596
+ | "journal-write"
1597
+ | "execution"
1598
+ | "prior-generation-executing"
1599
+ | "invalid-outcome";
1600
+
1601
+ export interface TelegramUpdateWorkerStateSnapshot {
1602
+ phase: TelegramUpdateWorkerPhase;
1603
+ generation: number;
1604
+ phaseStartedAtMs?: number;
1605
+ currentUpdateId?: number;
1606
+ blockedReason?: TelegramUpdateWorkerBlockedReason;
1607
+ journalEntryCount: number;
1608
+ journalSerializedBytes: number;
1609
+ oldestAdmittedAtMs?: number;
1610
+ deferredClaimCount: number;
1611
+ queuedClaimCount: number;
1612
+ foreignQueuedCount: number;
1613
+ foreignQueuedOwner?: TelegramUpdateJournalQueueOwner;
1614
+ foreignQueuedOwnerLiveness?: TelegramProcessLiveness;
1615
+ retryWaitCount: number;
1616
+ failedCount: number;
1617
+ nextRetryUpdateId?: number;
1618
+ nextRetryAtMs?: number;
1619
+ nextRetryAttemptCount?: number;
1620
+ nextRetryFailureClass?: string;
1621
+ failedUpdateId?: number;
1622
+ failedFailureId?: string;
1623
+ failedAttemptCount?: number;
1624
+ failedClass?: string;
1625
+ failedSummary?: string;
1626
+ terminalFailureAtMs?: number;
1627
+ unsettledExecutionCount: number;
1628
+ lastCompletedUpdateId?: number;
1629
+ lastCompletedAtMs?: number;
1630
+ lastFailureAtMs?: number;
1631
+ lastFailurePhase?: string;
1266
1632
  }
1267
1633
 
1268
- const UPDATE_HANDLER_REGISTRY_KEY = "__piTelegramUpdateHandlerRegistry__";
1634
+ export interface TelegramUpdateWorkerJournalSnapshot {
1635
+ entries: readonly {
1636
+ updateId: number;
1637
+ update: TelegramJournaledUpdate;
1638
+ admittedAtMs: number;
1639
+ state: "pending" | "retry-wait" | "queued" | "failed";
1640
+ queueKind?: "prompt" | "control";
1641
+ queueReceiptId?: string;
1642
+ queueOwner?: TelegramUpdateJournalQueueOwner;
1643
+ queueHandoff?: {
1644
+ handoffId: string;
1645
+ offeredAtMs: number;
1646
+ recipientOwner: TelegramUpdateJournalQueueOwnerIdentity;
1647
+ };
1648
+ failure?: {
1649
+ attemptCount: number;
1650
+ failedAtMs: number;
1651
+ failureClass: string;
1652
+ summary: string;
1653
+ };
1654
+ nextRetryAtMs?: number;
1655
+ terminalAtMs?: number;
1656
+ terminalReason?: string;
1657
+ terminalFailureId?: string;
1658
+ }[];
1659
+ serializedBytes: number;
1660
+ }
1269
1661
 
1270
- function isValidV1UpdateHandlerRegistry(
1271
- candidate: unknown,
1272
- ): candidate is TelegramUpdateHandlerRegistry {
1273
- if (!candidate || typeof candidate !== "object") return false;
1274
- const r = candidate as Partial<TelegramUpdateHandlerRegistry>;
1275
- return (
1276
- r.version === 1 &&
1277
- typeof r.add === "function" &&
1278
- typeof r.dispatch === "function"
1662
+ export interface TelegramUpdateWorkerJournalPort {
1663
+ read: () => TelegramUpdateWorkerJournalSnapshot;
1664
+ markQueued: (receipt: {
1665
+ queueKind: "prompt" | "control";
1666
+ receiptId: string;
1667
+ sourceUpdateIds: readonly number[];
1668
+ owner: TelegramUpdateJournalQueueOwnerIdentity;
1669
+ }) => {
1670
+ queuedUpdateIds: readonly number[];
1671
+ duplicateUpdateIds: readonly number[];
1672
+ queueOwner?: TelegramUpdateJournalQueueOwner;
1673
+ };
1674
+ completeQueued: (
1675
+ receipts: readonly {
1676
+ queueKind: "prompt" | "control";
1677
+ receiptId: string;
1678
+ sourceUpdateIds: readonly number[];
1679
+ queueOwner: TelegramUpdateJournalQueueOwner;
1680
+ }[],
1681
+ ) => {
1682
+ removedUpdateIds: readonly number[];
1683
+ };
1684
+ markExecutionFailure: (input: {
1685
+ updateId: number;
1686
+ expectedAttemptCount: number;
1687
+ failedAtMs: number;
1688
+ failureClass: string;
1689
+ summary: string;
1690
+ disposition: "retry-wait" | "failed";
1691
+ nextRetryAtMs?: number;
1692
+ terminalReason?: string;
1693
+ }) => {
1694
+ entry: TelegramUpdateWorkerJournalSnapshot["entries"][number];
1695
+ };
1696
+ removeCompleted: (updateIds: readonly number[]) => {
1697
+ removedUpdateIds: readonly number[];
1698
+ };
1699
+ }
1700
+
1701
+ export interface TelegramUpdateRetryPolicy {
1702
+ baseDelayMs: number;
1703
+ maxDelayMs: number;
1704
+ }
1705
+
1706
+ export interface TelegramUpdateExecutionFailureClassification {
1707
+ disposition: "retryable" | "terminal";
1708
+ failureClass: string;
1709
+ summary: string;
1710
+ }
1711
+
1712
+ export interface TelegramUpdateWorkerRuntimeDeps<TContext> {
1713
+ journal: TelegramUpdateWorkerJournalPort;
1714
+ executeUpdate: (
1715
+ update: TelegramJournaledUpdate,
1716
+ ctx: TContext,
1717
+ signal: AbortSignal,
1718
+ ) => Promise<TelegramUpdateAdmissionOutcome> | TelegramUpdateAdmissionOutcome;
1719
+ hasAuthority: (ctx: TContext) => boolean;
1720
+ getJournalBindingKey?: () => string | undefined;
1721
+ getQueueOwnerIdentity?: (
1722
+ ctx: TContext,
1723
+ ) => TelegramUpdateJournalQueueOwnerIdentity;
1724
+ isContextCurrent?: (ctx: TContext) => boolean;
1725
+ createAbortController?: () => AbortController;
1726
+ getNowMs?: () => number;
1727
+ retryPolicy?: Partial<TelegramUpdateRetryPolicy>;
1728
+ classifyExecutionFailure?: (
1729
+ error: unknown,
1730
+ ) => TelegramUpdateExecutionFailureClassification;
1731
+ scheduleRetry?: (callback: () => void, delayMs: number) => unknown;
1732
+ cancelRetry?: (handle: unknown) => void;
1733
+ batchSize?: number;
1734
+ yieldToEventLoop?: () => Promise<void>;
1735
+ onStateChange?: (state: TelegramUpdateWorkerStateSnapshot) => void;
1736
+ onQueueReceiptCommitted?: (
1737
+ receipt: TelegramQueueAdmissionReceiptLike,
1738
+ ctx: TContext,
1739
+ ) => void;
1740
+ onUpdateCompleted?: (updateId: number, ctx: TContext) => void;
1741
+ recordRuntimeEvent?: (
1742
+ category: string,
1743
+ error: unknown,
1744
+ details?: Record<string, unknown>,
1745
+ ) => void;
1746
+ }
1747
+
1748
+ export type TelegramQueueReceiptCompletionReason =
1749
+ | "prompt-handoff"
1750
+ | "control-settlement"
1751
+ | "discard";
1752
+
1753
+ export interface TelegramUpdateWorkerRuntime<TContext> {
1754
+ start: (ctx: TContext) => void;
1755
+ signal: () => void;
1756
+ settleDeferred: (input: {
1757
+ updateId: number;
1758
+ outcome: Extract<
1759
+ TelegramUpdateAdmissionOutcome,
1760
+ { kind: "deferred" | "queued" }
1761
+ >;
1762
+ signal: AbortSignal;
1763
+ }) => void;
1764
+ isQueueReceiptCommitted: (
1765
+ receipt: TelegramQueueAdmissionReceiptLike,
1766
+ ) => boolean;
1767
+ getQueueReceiptOwner: (
1768
+ receipt: TelegramQueueAdmissionReceiptLike,
1769
+ ) => TelegramUpdateJournalQueueOwner | undefined;
1770
+ completeQueueReceipts: (input: {
1771
+ receipts: readonly TelegramQueueAdmissionReceiptLike[];
1772
+ ctx: TContext;
1773
+ reason: TelegramQueueReceiptCompletionReason;
1774
+ }) => void;
1775
+ stop: () => Promise<void>;
1776
+ waitForDrain: () => Promise<void>;
1777
+ getState: () => TelegramUpdateWorkerStateSnapshot;
1778
+ }
1779
+
1780
+ export class TelegramUpdateAdmissionOutcomeError extends Error {
1781
+ constructor(message: string) {
1782
+ super(message);
1783
+ this.name = "TelegramUpdateAdmissionOutcomeError";
1784
+ }
1785
+ }
1786
+
1787
+ interface TelegramUpdateWorkerOwner<TContext> {
1788
+ generation: number;
1789
+ ctx: TContext;
1790
+ controller: AbortController;
1791
+ queueOwnerIdentity: TelegramUpdateJournalQueueOwnerIdentity;
1792
+ }
1793
+
1794
+ type TelegramUpdateWorkerClaim = "deferred" | "queued";
1795
+ type TelegramUpdateWorkerDrainResult = "idle" | "blocked" | "aborted";
1796
+ type TelegramUpdateWorkerExecutionSettlement =
1797
+ | { ok: true; outcome: TelegramUpdateAdmissionOutcome }
1798
+ | { ok: false; error: unknown };
1799
+
1800
+ const TELEGRAM_UPDATE_WORKER_EXECUTION_ABORTED = Symbol(
1801
+ "telegram.update-worker.execution-aborted",
1802
+ );
1803
+
1804
+ function getTelegramUpdateWorkerStateSnapshot(
1805
+ state: TelegramUpdateWorkerStateSnapshot,
1806
+ ): TelegramUpdateWorkerStateSnapshot {
1807
+ return { ...state };
1808
+ }
1809
+
1810
+ function isTelegramUpdateAdmissionRecord(
1811
+ value: unknown,
1812
+ ): value is Record<string, unknown> {
1813
+ return !!value && typeof value === "object" && !Array.isArray(value);
1814
+ }
1815
+
1816
+ function isTelegramUpdateAdmissionString(value: unknown): value is string {
1817
+ return typeof value === "string" && value.length > 0;
1818
+ }
1819
+
1820
+ function validateTelegramUpdateAdmissionOutcome(
1821
+ value: unknown,
1822
+ currentUpdateId: number,
1823
+ claimableUpdateIds: ReadonlySet<number>,
1824
+ ): TelegramUpdateAdmissionOutcome {
1825
+ if (!isTelegramUpdateAdmissionRecord(value)) {
1826
+ throw new TelegramUpdateAdmissionOutcomeError(
1827
+ `Telegram update ${currentUpdateId} returned no admission outcome.`,
1828
+ );
1829
+ }
1830
+ if (value.kind === "complete") return { kind: "complete" };
1831
+ if (value.kind === "deferred") return { kind: "deferred" };
1832
+ if (value.kind === "queued") {
1833
+ if (
1834
+ (value.queueKind !== "prompt" && value.queueKind !== "control") ||
1835
+ !isTelegramUpdateAdmissionString(value.receiptId) ||
1836
+ !Array.isArray(value.sourceUpdateIds) ||
1837
+ value.sourceUpdateIds.length === 0 ||
1838
+ !value.sourceUpdateIds.every(
1839
+ (updateId) =>
1840
+ Number.isSafeInteger(updateId) &&
1841
+ (updateId as number) >= 0 &&
1842
+ claimableUpdateIds.has(updateId as number),
1843
+ )
1844
+ ) {
1845
+ throw new TelegramUpdateAdmissionOutcomeError(
1846
+ `Telegram update ${currentUpdateId} returned an invalid queue receipt.`,
1847
+ );
1848
+ }
1849
+ const sourceUpdateIds = [...new Set(value.sourceUpdateIds as number[])];
1850
+ if (
1851
+ sourceUpdateIds.length !== value.sourceUpdateIds.length ||
1852
+ !sourceUpdateIds.includes(currentUpdateId)
1853
+ ) {
1854
+ throw new TelegramUpdateAdmissionOutcomeError(
1855
+ `Telegram update ${currentUpdateId} returned a mismatched queue receipt.`,
1856
+ );
1857
+ }
1858
+ return {
1859
+ kind: "queued",
1860
+ queueKind: value.queueKind,
1861
+ receiptId: value.receiptId,
1862
+ sourceUpdateIds,
1863
+ };
1864
+ }
1865
+ throw new TelegramUpdateAdmissionOutcomeError(
1866
+ `Telegram update ${currentUpdateId} returned an unknown admission outcome.`,
1279
1867
  );
1280
1868
  }
1281
1869
 
1282
- function getOrCreateUpdateHandlerRegistry(): TelegramUpdateHandlerRegistry {
1283
- const g = globalThis as Record<string, unknown>;
1284
- const existing = g[UPDATE_HANDLER_REGISTRY_KEY];
1285
- if (isValidV1UpdateHandlerRegistry(existing)) return existing;
1286
- const handlers = new Set<TelegramUpdateHandler>();
1287
- const registry: TelegramUpdateHandlerRegistry = {
1288
- version: 1,
1289
- add(handler) {
1290
- handlers.add(handler);
1291
- return () => handlers.delete(handler);
1292
- },
1293
- async dispatch(update) {
1294
- for (const handler of handlers) {
1295
- try {
1296
- const result = await handler(update);
1297
- if (result === "consume") return "consume";
1298
- } catch {
1299
- // Update handler errors must not break polling.
1300
- }
1301
- }
1302
- return "pass";
1303
- },
1870
+ function normalizeTelegramUpdateRetryPolicy(
1871
+ input: Partial<TelegramUpdateRetryPolicy> | undefined,
1872
+ ): TelegramUpdateRetryPolicy {
1873
+ const policy = {
1874
+ baseDelayMs: input?.baseDelayMs ?? TELEGRAM_UPDATE_RETRY_BASE_DELAY_MS,
1875
+ maxDelayMs: input?.maxDelayMs ?? TELEGRAM_UPDATE_RETRY_MAX_DELAY_MS,
1304
1876
  };
1305
- g[UPDATE_HANDLER_REGISTRY_KEY] = registry;
1306
- return registry;
1877
+ if (
1878
+ !Number.isSafeInteger(policy.baseDelayMs) ||
1879
+ policy.baseDelayMs <= 0 ||
1880
+ !Number.isSafeInteger(policy.maxDelayMs) ||
1881
+ policy.maxDelayMs < policy.baseDelayMs
1882
+ ) {
1883
+ throw new Error("Telegram update retry policy is invalid.");
1884
+ }
1885
+ return policy;
1307
1886
  }
1308
1887
 
1309
- /**
1310
- * Called by pi-telegram's own runtime to obtain the registry it dispatches
1311
- * through. Extension consumers should not call this; use
1312
- * {@link registerTelegramUpdateHandler} instead.
1313
- */
1314
- export function getTelegramUpdateHandlerRegistry(): TelegramUpdateHandlerRegistry {
1315
- return getOrCreateUpdateHandlerRegistry();
1888
+ function normalizeTelegramUpdateFailureClass(value: string): string {
1889
+ const normalized = value
1890
+ .trim()
1891
+ .replace(/[^A-Za-z0-9._:-]+/gu, "-")
1892
+ .replace(/^-+|-+$/gu, "")
1893
+ .slice(0, TELEGRAM_UPDATE_JOURNAL_FAILURE_CLASS_MAX_LENGTH);
1894
+ return normalized || "execution-error";
1316
1895
  }
1317
1896
 
1318
- export interface TelegramUpdateHandlerWrapDeps<TUpdate, TContext> {
1319
- defaultHandle: (update: TUpdate, ctx: TContext) => Promise<void>;
1320
- registry?: TelegramUpdateHandlerRegistry;
1897
+ function normalizeTelegramUpdateFailureSummary(value: string): string {
1898
+ const normalized = value
1899
+ .trim()
1900
+ .slice(0, TELEGRAM_UPDATE_JOURNAL_FAILURE_SUMMARY_MAX_LENGTH);
1901
+ return normalized || "Telegram update execution failed.";
1321
1902
  }
1322
1903
 
1323
- /**
1324
- * Wrap a default polling `handleUpdate` with the public update handler registry.
1325
- */
1326
- export function createTelegramUpdateHandle<TUpdate, TContext>(
1904
+ function classifyTelegramUpdateExecutionFailure(
1905
+ error: unknown,
1906
+ ): TelegramUpdateExecutionFailureClassification {
1907
+ if (error instanceof TelegramForeignUpdateSettlementError) {
1908
+ return {
1909
+ disposition:
1910
+ error.settlement.status === "retryable" ? "retryable" : "terminal",
1911
+ failureClass: normalizeTelegramUpdateFailureClass(
1912
+ error.settlement.failureClass,
1913
+ ),
1914
+ summary: normalizeTelegramUpdateFailureSummary(
1915
+ error.settlement.message,
1916
+ ),
1917
+ };
1918
+ }
1919
+ const errorName =
1920
+ error instanceof Error && error.name ? error.name : "UnknownError";
1921
+ return {
1922
+ disposition: "retryable",
1923
+ failureClass: normalizeTelegramUpdateFailureClass(
1924
+ `execution-${errorName}`,
1925
+ ),
1926
+ summary: normalizeTelegramUpdateFailureSummary(
1927
+ `${errorName}: Telegram update execution failed.`,
1928
+ ),
1929
+ };
1930
+ }
1931
+
1932
+ function getTelegramUpdateRetryDelayMs(
1933
+ attemptCount: number,
1934
+ policy: TelegramUpdateRetryPolicy,
1935
+ ): number {
1936
+ const multiplier = 2 ** Math.max(0, attemptCount - 1);
1937
+ return Math.min(policy.maxDelayMs, policy.baseDelayMs * multiplier);
1938
+ }
1939
+
1940
+ function scheduleTelegramUpdateRetry(
1941
+ callback: () => void,
1942
+ delayMs: number,
1943
+ ): ReturnType<typeof setTimeout> {
1944
+ const handle = setTimeout(callback, delayMs);
1945
+ handle.unref?.();
1946
+ return handle;
1947
+ }
1948
+
1949
+ function normalizeTelegramUpdateQueueOwnerIdentity(
1950
+ value: TelegramUpdateJournalQueueOwnerIdentity,
1951
+ ): TelegramUpdateJournalQueueOwnerIdentity {
1952
+ if (
1953
+ typeof value.instanceId !== "string" ||
1954
+ value.instanceId.length === 0 ||
1955
+ value.instanceId.length >
1956
+ TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH ||
1957
+ !Number.isSafeInteger(value.processId) ||
1958
+ value.processId <= 0 ||
1959
+ typeof value.processBirthId !== "string" ||
1960
+ value.processBirthId.length === 0 ||
1961
+ value.processBirthId.length >
1962
+ TELEGRAM_UPDATE_JOURNAL_QUEUE_OWNER_ID_MAX_LENGTH ||
1963
+ !Number.isSafeInteger(value.sessionGeneration) ||
1964
+ value.sessionGeneration <= 0
1965
+ ) {
1966
+ throw new Error("Telegram update queue owner identity is invalid.");
1967
+ }
1968
+ return { ...value };
1969
+ }
1970
+
1971
+ export function createTelegramUpdateWorkerRuntime<TContext>(
1972
+ deps: TelegramUpdateWorkerRuntimeDeps<TContext>,
1973
+ ): TelegramUpdateWorkerRuntime<TContext> {
1974
+ if (
1975
+ (deps.scheduleRetry === undefined) !==
1976
+ (deps.cancelRetry === undefined)
1977
+ ) {
1978
+ throw new Error(
1979
+ "Telegram update retry scheduling requires matching schedule and cancel ports.",
1980
+ );
1981
+ }
1982
+ const getNowMs = deps.getNowMs ?? Date.now;
1983
+ const batchSize = deps.batchSize ?? TELEGRAM_UPDATE_WORKER_BATCH_SIZE;
1984
+ if (!Number.isSafeInteger(batchSize) || batchSize <= 0) {
1985
+ throw new Error("Telegram update worker batch size must be positive.");
1986
+ }
1987
+ const yieldToEventLoop = deps.yieldToEventLoop ??
1988
+ (() => new Promise<void>((resolve) => setTimeout(resolve, 0)));
1989
+ const fallbackQueueOwnerInstanceId = `worker-${randomUUID()}`;
1990
+ const fallbackQueueOwnerProcessId = process.pid > 0 ? process.pid : 1;
1991
+ const createAbortController =
1992
+ deps.createAbortController ?? (() => new AbortController());
1993
+ const retryPolicy = normalizeTelegramUpdateRetryPolicy(deps.retryPolicy);
1994
+ const scheduleRetry = deps.scheduleRetry ?? scheduleTelegramUpdateRetry;
1995
+ const cancelRetry =
1996
+ deps.cancelRetry ??
1997
+ ((handle: unknown) =>
1998
+ clearTimeout(handle as ReturnType<typeof setTimeout>));
1999
+ const resolveQueueOwnerIdentity = (
2000
+ ctx: TContext,
2001
+ generation: number,
2002
+ ): TelegramUpdateJournalQueueOwnerIdentity =>
2003
+ normalizeTelegramUpdateQueueOwnerIdentity(
2004
+ deps.getQueueOwnerIdentity?.(ctx) ?? {
2005
+ instanceId: fallbackQueueOwnerInstanceId,
2006
+ processId: fallbackQueueOwnerProcessId,
2007
+ processBirthId: `${fallbackQueueOwnerProcessId}:${fallbackQueueOwnerInstanceId}`,
2008
+ sessionGeneration: generation,
2009
+ },
2010
+ );
2011
+ const state: TelegramUpdateWorkerStateSnapshot = {
2012
+ phase: "stopped",
2013
+ generation: 0,
2014
+ journalEntryCount: 0,
2015
+ journalSerializedBytes: 0,
2016
+ deferredClaimCount: 0,
2017
+ queuedClaimCount: 0,
2018
+ foreignQueuedCount: 0,
2019
+ retryWaitCount: 0,
2020
+ failedCount: 0,
2021
+ unsettledExecutionCount: 0,
2022
+ };
2023
+ const claims = new Map<number, TelegramUpdateWorkerClaim>();
2024
+ const unsettledExecutionsByUpdateId = new Map<
2025
+ number,
2026
+ Set<Promise<TelegramUpdateWorkerExecutionSettlement>>
2027
+ >();
2028
+ const committedQueueReceipts = new Map<
2029
+ string,
2030
+ {
2031
+ receipt: TelegramQueueAdmissionReceiptLike;
2032
+ queueOwner: TelegramUpdateJournalQueueOwner;
2033
+ }
2034
+ >();
2035
+ const unsettledExecutions =
2036
+ new Set<Promise<TelegramUpdateWorkerExecutionSettlement>>();
2037
+ let owner: TelegramUpdateWorkerOwner<TContext> | undefined;
2038
+ let drainPromise: Promise<void> | undefined;
2039
+ let pendingSignal = false;
2040
+ let blocked = false;
2041
+ let nextGeneration = 0;
2042
+ let retryTimer: unknown;
2043
+ let retryTimerAtMs: number | undefined;
2044
+ let retryTimerToken: object | undefined;
2045
+ let launchDrain: () => void = () => {};
2046
+
2047
+ const recordRuntimeEvent = (
2048
+ error: unknown,
2049
+ details: Record<string, unknown>,
2050
+ ): void => {
2051
+ try {
2052
+ deps.recordRuntimeEvent?.("inbound-worker", error, details);
2053
+ } catch {
2054
+ // Diagnostics cannot own or terminate worker progress.
2055
+ }
2056
+ };
2057
+
2058
+ const notifyStateChange = (): void => {
2059
+ try {
2060
+ deps.onStateChange?.(getTelegramUpdateWorkerStateSnapshot(state));
2061
+ } catch (error) {
2062
+ recordRuntimeEvent(error, { phase: "state-observer" });
2063
+ }
2064
+ };
2065
+
2066
+ const updateClaimCounts = (): void => {
2067
+ let deferredClaimCount = 0;
2068
+ let queuedClaimCount = 0;
2069
+ for (const claim of claims.values()) {
2070
+ if (claim === "queued") queuedClaimCount += 1;
2071
+ else deferredClaimCount += 1;
2072
+ }
2073
+ state.deferredClaimCount = deferredClaimCount;
2074
+ state.queuedClaimCount = queuedClaimCount;
2075
+ };
2076
+
2077
+ const releaseDeferredClaims = (updateIds: readonly number[]): void => {
2078
+ let changed = false;
2079
+ for (const updateId of updateIds) {
2080
+ if (claims.get(updateId) !== "deferred") continue;
2081
+ claims.delete(updateId);
2082
+ changed = true;
2083
+ }
2084
+ if (!changed) return;
2085
+ updateClaimCounts();
2086
+ notifyStateChange();
2087
+ };
2088
+
2089
+ const transition = (
2090
+ phase: TelegramUpdateWorkerPhase,
2091
+ currentUpdateId?: number,
2092
+ blockedReason?: TelegramUpdateWorkerBlockedReason,
2093
+ ): void => {
2094
+ state.phase = phase;
2095
+ state.phaseStartedAtMs = getNowMs();
2096
+ state.currentUpdateId = currentUpdateId;
2097
+ state.blockedReason = phase === "blocked" ? blockedReason : undefined;
2098
+ state.unsettledExecutionCount = unsettledExecutions.size;
2099
+ updateClaimCounts();
2100
+ notifyStateChange();
2101
+ };
2102
+
2103
+ const blockWithFailure = (
2104
+ blockedReason: Exclude<TelegramUpdateWorkerBlockedReason, "authority-lost">,
2105
+ failurePhase: string,
2106
+ error: unknown,
2107
+ currentUpdateId?: number,
2108
+ ): "blocked" => {
2109
+ blocked = true;
2110
+ state.lastFailureAtMs = getNowMs();
2111
+ state.lastFailurePhase = failurePhase;
2112
+ recordRuntimeEvent(error, {
2113
+ phase: failurePhase,
2114
+ generation: owner?.generation,
2115
+ ...(currentUpdateId !== undefined ? { updateId: currentUpdateId } : {}),
2116
+ });
2117
+ transition("blocked", currentUpdateId, blockedReason);
2118
+ return "blocked";
2119
+ };
2120
+
2121
+ const normalizeQueueReceipt = (
2122
+ receipt: TelegramQueueAdmissionReceiptLike,
2123
+ ): TelegramQueueAdmissionReceiptLike => ({
2124
+ queueKind: receipt.queueKind,
2125
+ receiptId: receipt.receiptId,
2126
+ sourceUpdateIds: [...receipt.sourceUpdateIds].sort(
2127
+ (left, right) => left - right,
2128
+ ),
2129
+ ...(receipt.journalBindingKey
2130
+ ? { journalBindingKey: receipt.journalBindingKey }
2131
+ : {}),
2132
+ });
2133
+ const bindQueueReceiptToJournal = (
2134
+ receipt: TelegramQueueAdmissionReceiptLike,
2135
+ ): TelegramQueueAdmissionReceiptLike => ({
2136
+ ...normalizeQueueReceipt(receipt),
2137
+ ...(deps.getJournalBindingKey?.()
2138
+ ? { journalBindingKey: deps.getJournalBindingKey() }
2139
+ : {}),
2140
+ });
2141
+
2142
+ const publishCommittedQueueReceipt = (
2143
+ receipt: TelegramQueueAdmissionReceiptLike,
2144
+ queueOwner: TelegramUpdateJournalQueueOwner,
2145
+ ctx: TContext,
2146
+ ): boolean => {
2147
+ const normalized = bindQueueReceiptToJournal(receipt);
2148
+ const existing = committedQueueReceipts.get(receipt.receiptId);
2149
+ if (existing) {
2150
+ if (
2151
+ !areTelegramQueueAdmissionReceiptsEqual(
2152
+ existing.receipt,
2153
+ normalized,
2154
+ ) ||
2155
+ !areTelegramUpdateJournalQueueOwnersEqual(
2156
+ existing.queueOwner,
2157
+ queueOwner,
2158
+ )
2159
+ ) {
2160
+ throw new TelegramUpdateAdmissionOutcomeError(
2161
+ `Telegram queue receipt ${receipt.receiptId} has conflicting committed authority.`,
2162
+ );
2163
+ }
2164
+ return false;
2165
+ }
2166
+ committedQueueReceipts.set(receipt.receiptId, {
2167
+ receipt: normalized,
2168
+ queueOwner: { ...queueOwner },
2169
+ });
2170
+ try {
2171
+ deps.onQueueReceiptCommitted?.(normalized, ctx);
2172
+ } catch (error) {
2173
+ recordRuntimeEvent(error, {
2174
+ phase: "queue-receipt-observer",
2175
+ receiptId: normalized.receiptId,
2176
+ });
2177
+ }
2178
+ return true;
2179
+ };
2180
+
2181
+ const clearRetryTimer = (): void => {
2182
+ if (retryTimer !== undefined) cancelRetry(retryTimer);
2183
+ retryTimer = undefined;
2184
+ retryTimerAtMs = undefined;
2185
+ retryTimerToken = undefined;
2186
+ };
2187
+
2188
+ const scheduleNextRetry = (
2189
+ nextRetryAtMs: number | undefined,
2190
+ expectedOwner: TelegramUpdateWorkerOwner<TContext>,
2191
+ ): void => {
2192
+ if (nextRetryAtMs === undefined) {
2193
+ clearRetryTimer();
2194
+ return;
2195
+ }
2196
+ if (retryTimer !== undefined && retryTimerAtMs === nextRetryAtMs) {
2197
+ return;
2198
+ }
2199
+ clearRetryTimer();
2200
+ const token = {};
2201
+ retryTimerAtMs = nextRetryAtMs;
2202
+ retryTimerToken = token;
2203
+ retryTimer = scheduleRetry(() => {
2204
+ if (retryTimerToken !== token) return;
2205
+ retryTimer = undefined;
2206
+ retryTimerAtMs = undefined;
2207
+ retryTimerToken = undefined;
2208
+ if (
2209
+ owner !== expectedOwner ||
2210
+ expectedOwner.controller.signal.aborted
2211
+ ) {
2212
+ return;
2213
+ }
2214
+ pendingSignal = true;
2215
+ launchDrain();
2216
+ }, Math.max(0, nextRetryAtMs - getNowMs()));
2217
+ };
2218
+
2219
+ const refreshJournalState = (
2220
+ snapshot: TelegramUpdateWorkerJournalSnapshot,
2221
+ expectedOwner: TelegramUpdateWorkerOwner<TContext>,
2222
+ ): number | undefined => {
2223
+ const availableUpdateIds = new Set<number>();
2224
+ const queuedReceiptEntries = new Map<
2225
+ string,
2226
+ {
2227
+ queueKind: "prompt" | "control";
2228
+ sourceUpdateIds: number[];
2229
+ queueOwner: TelegramUpdateJournalQueueOwner;
2230
+ }
2231
+ >();
2232
+ const locallyOwnedQueuedUpdateIds = new Set<number>();
2233
+ let foreignQueuedCount = 0;
2234
+ let foreignQueuedOwner: TelegramUpdateJournalQueueOwner | undefined;
2235
+ let oldestAdmittedAtMs: number | undefined;
2236
+ let retryWaitCount = 0;
2237
+ let scheduledRetryAtMs: number | undefined;
2238
+ let nextRetry: TelegramUpdateWorkerJournalSnapshot["entries"][number] | undefined;
2239
+ let failedCount = 0;
2240
+ let latestFailure: TelegramUpdateWorkerJournalSnapshot["entries"][number] | undefined;
2241
+ for (const entry of snapshot.entries) {
2242
+ availableUpdateIds.add(entry.updateId);
2243
+ oldestAdmittedAtMs =
2244
+ oldestAdmittedAtMs === undefined
2245
+ ? entry.admittedAtMs
2246
+ : Math.min(oldestAdmittedAtMs, entry.admittedAtMs);
2247
+ if (entry.state === "retry-wait" && entry.nextRetryAtMs !== undefined) {
2248
+ scheduledRetryAtMs =
2249
+ scheduledRetryAtMs === undefined
2250
+ ? entry.nextRetryAtMs
2251
+ : Math.min(scheduledRetryAtMs, entry.nextRetryAtMs);
2252
+ }
2253
+ if (
2254
+ entry.state === "retry-wait" &&
2255
+ entry.failure !== undefined &&
2256
+ entry.nextRetryAtMs !== undefined
2257
+ ) {
2258
+ retryWaitCount += 1;
2259
+ if (
2260
+ !nextRetry ||
2261
+ nextRetry.nextRetryAtMs === undefined ||
2262
+ entry.nextRetryAtMs < nextRetry.nextRetryAtMs ||
2263
+ (entry.nextRetryAtMs === nextRetry.nextRetryAtMs &&
2264
+ entry.updateId < nextRetry.updateId)
2265
+ ) {
2266
+ nextRetry = entry;
2267
+ }
2268
+ }
2269
+ if (
2270
+ entry.state === "failed" &&
2271
+ entry.failure !== undefined &&
2272
+ entry.terminalAtMs !== undefined
2273
+ ) {
2274
+ failedCount += 1;
2275
+ if (
2276
+ !latestFailure ||
2277
+ latestFailure.terminalAtMs === undefined ||
2278
+ entry.terminalAtMs > latestFailure.terminalAtMs ||
2279
+ (entry.terminalAtMs === latestFailure.terminalAtMs &&
2280
+ entry.updateId > latestFailure.updateId)
2281
+ ) {
2282
+ latestFailure = entry;
2283
+ }
2284
+ }
2285
+ if (entry.state !== "queued") continue;
2286
+ if (!entry.queueKind || !entry.queueReceiptId) {
2287
+ throw new TelegramUpdateAdmissionOutcomeError(
2288
+ `Telegram queued update ${entry.updateId} has no receipt metadata.`,
2289
+ );
2290
+ }
2291
+ if (
2292
+ entry.queueHandoff ||
2293
+ !entry.queueOwner ||
2294
+ !isTelegramUpdateJournalQueueOwnerProcess(
2295
+ entry.queueOwner,
2296
+ expectedOwner.queueOwnerIdentity,
2297
+ )
2298
+ ) {
2299
+ foreignQueuedCount += 1;
2300
+ foreignQueuedOwner ??= entry.queueOwner;
2301
+ continue;
2302
+ }
2303
+ locallyOwnedQueuedUpdateIds.add(entry.updateId);
2304
+ claims.set(entry.updateId, "queued");
2305
+ const receipt = queuedReceiptEntries.get(entry.queueReceiptId);
2306
+ if (
2307
+ receipt &&
2308
+ (receipt.queueKind !== entry.queueKind ||
2309
+ !areTelegramUpdateJournalQueueOwnersEqual(
2310
+ receipt.queueOwner,
2311
+ entry.queueOwner,
2312
+ ))
2313
+ ) {
2314
+ throw new TelegramUpdateAdmissionOutcomeError(
2315
+ `Telegram queue receipt ${entry.queueReceiptId} has conflicting authority.`,
2316
+ );
2317
+ }
2318
+ if (receipt) receipt.sourceUpdateIds.push(entry.updateId);
2319
+ else {
2320
+ queuedReceiptEntries.set(entry.queueReceiptId, {
2321
+ queueKind: entry.queueKind,
2322
+ sourceUpdateIds: [entry.updateId],
2323
+ queueOwner: { ...entry.queueOwner },
2324
+ });
2325
+ }
2326
+ }
2327
+ for (const [updateId, claim] of claims) {
2328
+ if (
2329
+ !availableUpdateIds.has(updateId) ||
2330
+ (claim === "queued" && !locallyOwnedQueuedUpdateIds.has(updateId))
2331
+ ) {
2332
+ claims.delete(updateId);
2333
+ }
2334
+ }
2335
+ for (const [receiptId, receipt] of queuedReceiptEntries) {
2336
+ publishCommittedQueueReceipt(
2337
+ {
2338
+ queueKind: receipt.queueKind,
2339
+ receiptId,
2340
+ sourceUpdateIds: receipt.sourceUpdateIds,
2341
+ },
2342
+ receipt.queueOwner,
2343
+ expectedOwner.ctx,
2344
+ );
2345
+ }
2346
+ for (const [receiptId] of committedQueueReceipts) {
2347
+ if (!queuedReceiptEntries.has(receiptId)) {
2348
+ committedQueueReceipts.delete(receiptId);
2349
+ }
2350
+ }
2351
+ state.foreignQueuedCount = foreignQueuedCount;
2352
+ if (foreignQueuedOwner) state.foreignQueuedOwner = foreignQueuedOwner;
2353
+ else delete state.foreignQueuedOwner;
2354
+ state.journalEntryCount = snapshot.entries.length;
2355
+ state.journalSerializedBytes = snapshot.serializedBytes;
2356
+ state.oldestAdmittedAtMs = oldestAdmittedAtMs;
2357
+ state.retryWaitCount = retryWaitCount;
2358
+ state.nextRetryUpdateId = nextRetry?.updateId;
2359
+ state.nextRetryAtMs = nextRetry?.nextRetryAtMs;
2360
+ state.nextRetryAttemptCount = nextRetry?.failure?.attemptCount;
2361
+ state.nextRetryFailureClass = nextRetry?.failure?.failureClass;
2362
+ state.failedCount = failedCount;
2363
+ state.failedUpdateId = latestFailure?.updateId;
2364
+ state.failedFailureId = latestFailure?.terminalFailureId;
2365
+ state.failedAttemptCount = latestFailure?.failure?.attemptCount;
2366
+ state.failedClass = latestFailure?.failure?.failureClass;
2367
+ state.failedSummary = latestFailure?.failure?.summary;
2368
+ state.terminalFailureAtMs = latestFailure?.terminalAtMs;
2369
+ state.unsettledExecutionCount = unsettledExecutions.size;
2370
+ updateClaimCounts();
2371
+ return scheduledRetryAtMs;
2372
+ };
2373
+
2374
+ const checkAuthority = (
2375
+ expectedOwner: TelegramUpdateWorkerOwner<TContext>,
2376
+ currentUpdateId?: number,
2377
+ ):
2378
+ | Exclude<TelegramUpdateWorkerDrainResult, "idle">
2379
+ | undefined => {
2380
+ if (blocked) return "blocked";
2381
+ if (owner !== expectedOwner || expectedOwner.controller.signal.aborted) {
2382
+ return "aborted";
2383
+ }
2384
+ try {
2385
+ if (deps.hasAuthority(expectedOwner.ctx)) return undefined;
2386
+ } catch (error) {
2387
+ return blockWithFailure(
2388
+ "authority-check",
2389
+ "authority-check",
2390
+ error,
2391
+ currentUpdateId,
2392
+ );
2393
+ }
2394
+ transition("blocked", currentUpdateId, "authority-lost");
2395
+ return "blocked";
2396
+ };
2397
+
2398
+ const executeWithinOwner = async (
2399
+ expectedOwner: TelegramUpdateWorkerOwner<TContext>,
2400
+ update: TelegramJournaledUpdate,
2401
+ ): Promise<
2402
+ | TelegramUpdateWorkerExecutionSettlement
2403
+ | typeof TELEGRAM_UPDATE_WORKER_EXECUTION_ABORTED
2404
+ > => {
2405
+ if (expectedOwner.controller.signal.aborted) {
2406
+ return TELEGRAM_UPDATE_WORKER_EXECUTION_ABORTED;
2407
+ }
2408
+ const execution = Promise.resolve().then(() =>
2409
+ deps.executeUpdate(
2410
+ update,
2411
+ expectedOwner.ctx,
2412
+ expectedOwner.controller.signal,
2413
+ ),
2414
+ );
2415
+ const settlement: Promise<TelegramUpdateWorkerExecutionSettlement> =
2416
+ execution.then(
2417
+ (outcome) => ({ ok: true, outcome }),
2418
+ (error: unknown) => ({ ok: false, error }),
2419
+ );
2420
+ unsettledExecutions.add(settlement);
2421
+ const updateExecutions =
2422
+ unsettledExecutionsByUpdateId.get(update.update_id) ?? new Set();
2423
+ updateExecutions.add(settlement);
2424
+ unsettledExecutionsByUpdateId.set(update.update_id, updateExecutions);
2425
+ state.unsettledExecutionCount = unsettledExecutions.size;
2426
+ notifyStateChange();
2427
+ void settlement.then((result) => {
2428
+ unsettledExecutions.delete(settlement);
2429
+ const currentExecutions = unsettledExecutionsByUpdateId.get(
2430
+ update.update_id,
2431
+ );
2432
+ currentExecutions?.delete(settlement);
2433
+ if (currentExecutions?.size === 0) {
2434
+ unsettledExecutionsByUpdateId.delete(update.update_id);
2435
+ }
2436
+ state.unsettledExecutionCount = unsettledExecutions.size;
2437
+ if (owner !== expectedOwner || expectedOwner.controller.signal.aborted) {
2438
+ recordRuntimeEvent(
2439
+ result.ok
2440
+ ? "Superseded Telegram update execution settled successfully."
2441
+ : result.error,
2442
+ {
2443
+ phase: result.ok ? "late-execution-success" : "late-execution",
2444
+ generation: expectedOwner.generation,
2445
+ updateId: update.update_id,
2446
+ },
2447
+ );
2448
+ }
2449
+ notifyStateChange();
2450
+ });
2451
+ let removeAbortListener = (): void => {};
2452
+ const aborted = new Promise<typeof TELEGRAM_UPDATE_WORKER_EXECUTION_ABORTED>(
2453
+ (resolve) => {
2454
+ const onAbort = () => resolve(TELEGRAM_UPDATE_WORKER_EXECUTION_ABORTED);
2455
+ removeAbortListener = () =>
2456
+ expectedOwner.controller.signal.removeEventListener(
2457
+ "abort",
2458
+ onAbort,
2459
+ );
2460
+ expectedOwner.controller.signal.addEventListener("abort", onAbort, {
2461
+ once: true,
2462
+ });
2463
+ if (expectedOwner.controller.signal.aborted) onAbort();
2464
+ },
2465
+ );
2466
+ try {
2467
+ return await Promise.race([settlement, aborted]);
2468
+ } finally {
2469
+ removeAbortListener();
2470
+ }
2471
+ };
2472
+
2473
+ const commitQueuedOutcome = (
2474
+ expectedOwner: TelegramUpdateWorkerOwner<TContext>,
2475
+ currentUpdateId: number,
2476
+ outcome: TelegramQueuedUpdateAdmissionOutcome,
2477
+ ):
2478
+ | "committed"
2479
+ | "duplicate"
2480
+ | Exclude<TelegramUpdateWorkerDrainResult, "idle"> => {
2481
+ const normalized = normalizeQueueReceipt(outcome);
2482
+ const existing = committedQueueReceipts.get(normalized.receiptId);
2483
+ if (existing) {
2484
+ if (
2485
+ !areTelegramQueueAdmissionReceiptsEqual(
2486
+ existing.receipt,
2487
+ normalized,
2488
+ ) ||
2489
+ !isTelegramUpdateJournalQueueOwnerProcess(
2490
+ existing.queueOwner,
2491
+ expectedOwner.queueOwnerIdentity,
2492
+ )
2493
+ ) {
2494
+ return blockWithFailure(
2495
+ "invalid-outcome",
2496
+ "queue-receipt-conflict",
2497
+ new TelegramUpdateAdmissionOutcomeError(
2498
+ `Telegram queue receipt ${normalized.receiptId} conflicts with committed authority.`,
2499
+ ),
2500
+ currentUpdateId,
2501
+ );
2502
+ }
2503
+ return "duplicate";
2504
+ }
2505
+ const commitAuthority = checkAuthority(expectedOwner, currentUpdateId);
2506
+ if (commitAuthority) return commitAuthority;
2507
+ let queueOwner: TelegramUpdateJournalQueueOwner;
2508
+ try {
2509
+ const committed = deps.journal.markQueued({
2510
+ ...normalized,
2511
+ owner: expectedOwner.queueOwnerIdentity,
2512
+ });
2513
+ const committedUpdateIds = new Set([
2514
+ ...committed.queuedUpdateIds,
2515
+ ...committed.duplicateUpdateIds,
2516
+ ]);
2517
+ if (
2518
+ normalized.sourceUpdateIds.some(
2519
+ (updateId) => !committedUpdateIds.has(updateId),
2520
+ )
2521
+ ) {
2522
+ throw new Error(
2523
+ `Telegram queue receipt ${normalized.receiptId} did not commit every source update.`,
2524
+ );
2525
+ }
2526
+ if (
2527
+ !committed.queueOwner ||
2528
+ !isTelegramUpdateJournalQueueOwnerProcess(
2529
+ committed.queueOwner,
2530
+ expectedOwner.queueOwnerIdentity,
2531
+ )
2532
+ ) {
2533
+ throw new Error(
2534
+ `Telegram queue receipt ${normalized.receiptId} belongs to another live process.`,
2535
+ );
2536
+ }
2537
+ queueOwner = committed.queueOwner;
2538
+ } catch (error) {
2539
+ return blockWithFailure(
2540
+ "journal-write",
2541
+ "queue-receipt-commit",
2542
+ error,
2543
+ currentUpdateId,
2544
+ );
2545
+ }
2546
+ for (const sourceUpdateId of normalized.sourceUpdateIds) {
2547
+ claims.set(sourceUpdateId, "queued");
2548
+ }
2549
+ try {
2550
+ publishCommittedQueueReceipt(
2551
+ normalized,
2552
+ queueOwner,
2553
+ expectedOwner.ctx,
2554
+ );
2555
+ } catch (error) {
2556
+ return blockWithFailure(
2557
+ "invalid-outcome",
2558
+ "queue-receipt-publish",
2559
+ error,
2560
+ currentUpdateId,
2561
+ );
2562
+ }
2563
+ return "committed";
2564
+ };
2565
+
2566
+ const persistExecutionFailure = (
2567
+ expectedOwner: TelegramUpdateWorkerOwner<TContext>,
2568
+ entry: TelegramUpdateWorkerJournalSnapshot["entries"][number],
2569
+ error: unknown,
2570
+ ):
2571
+ | "retry-wait"
2572
+ | "failed"
2573
+ | Exclude<TelegramUpdateWorkerDrainResult, "idle"> => {
2574
+ const authorityResult = checkAuthority(expectedOwner, entry.updateId);
2575
+ if (authorityResult) return authorityResult;
2576
+ let rawClassification: TelegramUpdateExecutionFailureClassification;
2577
+ try {
2578
+ rawClassification = deps.classifyExecutionFailure
2579
+ ? deps.classifyExecutionFailure(error)
2580
+ : classifyTelegramUpdateExecutionFailure(error);
2581
+ if (
2582
+ (rawClassification.disposition !== "retryable" &&
2583
+ rawClassification.disposition !== "terminal") ||
2584
+ typeof rawClassification.failureClass !== "string" ||
2585
+ typeof rawClassification.summary !== "string"
2586
+ ) {
2587
+ throw new Error("Telegram update failure classifier returned invalid data.");
2588
+ }
2589
+ } catch (classificationError) {
2590
+ return blockWithFailure(
2591
+ "execution",
2592
+ "failure-classification",
2593
+ classificationError,
2594
+ entry.updateId,
2595
+ );
2596
+ }
2597
+ const failureClass = normalizeTelegramUpdateFailureClass(
2598
+ rawClassification.failureClass,
2599
+ );
2600
+ const summary = normalizeTelegramUpdateFailureSummary(
2601
+ rawClassification.summary,
2602
+ );
2603
+ const expectedAttemptCount = entry.failure?.attemptCount ?? 0;
2604
+ const attemptCount = expectedAttemptCount + 1;
2605
+ const failedAtMs = getNowMs();
2606
+ const disposition = "retry-wait" as const;
2607
+ const nextRetryAtMs =
2608
+ failedAtMs + getTelegramUpdateRetryDelayMs(attemptCount, retryPolicy);
2609
+ try {
2610
+ const result = deps.journal.markExecutionFailure({
2611
+ updateId: entry.updateId,
2612
+ expectedAttemptCount,
2613
+ failedAtMs,
2614
+ failureClass,
2615
+ summary,
2616
+ disposition,
2617
+ nextRetryAtMs,
2618
+ });
2619
+ if (result.entry.state !== disposition) {
2620
+ throw new Error(
2621
+ `Telegram update ${entry.updateId} failure disposition did not persist.`,
2622
+ );
2623
+ }
2624
+ } catch (journalError) {
2625
+ return blockWithFailure(
2626
+ "journal-write",
2627
+ "execution-failure-commit",
2628
+ journalError,
2629
+ entry.updateId,
2630
+ );
2631
+ }
2632
+ state.lastFailureAtMs = failedAtMs;
2633
+ state.lastFailurePhase = "execute";
2634
+ recordRuntimeEvent(error, {
2635
+ phase: "execute",
2636
+ generation: expectedOwner.generation,
2637
+ updateId: entry.updateId,
2638
+ failureClass,
2639
+ attemptCount,
2640
+ disposition,
2641
+ nextRetryAtMs,
2642
+ });
2643
+ transition(disposition, entry.updateId);
2644
+ return disposition;
2645
+ };
2646
+
2647
+ const commitCompletedBatch = (
2648
+ expectedOwner: TelegramUpdateWorkerOwner<TContext>,
2649
+ updateIds: readonly number[],
2650
+ ): Exclude<TelegramUpdateWorkerDrainResult, "idle"> | undefined => {
2651
+ if (updateIds.length === 0) return undefined;
2652
+ const commitAuthority = checkAuthority(
2653
+ expectedOwner,
2654
+ updateIds[updateIds.length - 1],
2655
+ );
2656
+ if (commitAuthority) return commitAuthority;
2657
+ try {
2658
+ const removed = deps.journal.removeCompleted(updateIds);
2659
+ const removedIds = new Set(removed.removedUpdateIds);
2660
+ if (updateIds.some((updateId) => !removedIds.has(updateId))) {
2661
+ throw new Error(
2662
+ "Telegram update batch changed before completion commit.",
2663
+ );
2664
+ }
2665
+ } catch (error) {
2666
+ return blockWithFailure(
2667
+ "journal-write",
2668
+ "completion-commit",
2669
+ error,
2670
+ updateIds[updateIds.length - 1],
2671
+ );
2672
+ }
2673
+ const completedAtMs = getNowMs();
2674
+ for (const updateId of updateIds) {
2675
+ claims.delete(updateId);
2676
+ state.lastCompletedUpdateId = updateId;
2677
+ state.lastCompletedAtMs = completedAtMs;
2678
+ try {
2679
+ deps.onUpdateCompleted?.(updateId, expectedOwner.ctx);
2680
+ } catch (error) {
2681
+ recordRuntimeEvent(error, {
2682
+ phase: "update-completion-observer",
2683
+ updateId,
2684
+ });
2685
+ }
2686
+ }
2687
+ return undefined;
2688
+ };
2689
+
2690
+ const drain = async (
2691
+ expectedOwner: TelegramUpdateWorkerOwner<TContext>,
2692
+ ): Promise<TelegramUpdateWorkerDrainResult> => {
2693
+ while (owner === expectedOwner && !expectedOwner.controller.signal.aborted) {
2694
+ const authorityResult = checkAuthority(expectedOwner);
2695
+ if (authorityResult) return authorityResult;
2696
+ let snapshot: TelegramUpdateWorkerJournalSnapshot;
2697
+ let scheduledRetryAtMs: number | undefined;
2698
+ try {
2699
+ snapshot = deps.journal.read();
2700
+ scheduledRetryAtMs = refreshJournalState(snapshot, expectedOwner);
2701
+ } catch (error) {
2702
+ return blockWithFailure("journal-read", "journal-read", error);
2703
+ }
2704
+ const nowMs = getNowMs();
2705
+ const entries: TelegramUpdateWorkerJournalSnapshot["entries"][number][] =
2706
+ [];
2707
+ let hasMoreEntries = false;
2708
+ for (const candidate of snapshot.entries) {
2709
+ if (
2710
+ !claims.has(candidate.updateId) &&
2711
+ (candidate.state === "pending" ||
2712
+ (candidate.state === "retry-wait" &&
2713
+ candidate.nextRetryAtMs !== undefined &&
2714
+ candidate.nextRetryAtMs <= nowMs))
2715
+ ) {
2716
+ if (entries.length === batchSize) {
2717
+ hasMoreEntries = true;
2718
+ break;
2719
+ }
2720
+ entries.push(candidate);
2721
+ }
2722
+ }
2723
+ if (entries.length === 0) {
2724
+ scheduleNextRetry(scheduledRetryAtMs, expectedOwner);
2725
+ transition("idle");
2726
+ return "idle";
2727
+ }
2728
+ const completedUpdateIds: number[] = [];
2729
+ let snapshotInvalidated = false;
2730
+ for (const entry of entries) {
2731
+ const priorExecutions = unsettledExecutionsByUpdateId.get(entry.updateId);
2732
+ if (priorExecutions?.size) {
2733
+ const completionResult = commitCompletedBatch(
2734
+ expectedOwner,
2735
+ completedUpdateIds,
2736
+ );
2737
+ if (completionResult) return completionResult;
2738
+ transition("blocked", entry.updateId, "prior-generation-executing");
2739
+ await Promise.allSettled([...priorExecutions]);
2740
+ if (
2741
+ owner !== expectedOwner ||
2742
+ expectedOwner.controller.signal.aborted
2743
+ ) {
2744
+ return "aborted";
2745
+ }
2746
+ snapshotInvalidated = true;
2747
+ break;
2748
+ }
2749
+ clearRetryTimer();
2750
+ transition("executing", entry.updateId);
2751
+ const execution = await executeWithinOwner(expectedOwner, entry.update);
2752
+ if (execution === TELEGRAM_UPDATE_WORKER_EXECUTION_ABORTED) {
2753
+ return "aborted";
2754
+ }
2755
+ if (!execution.ok) {
2756
+ const completionResult = commitCompletedBatch(
2757
+ expectedOwner,
2758
+ completedUpdateIds,
2759
+ );
2760
+ if (completionResult) return completionResult;
2761
+ const failureResult = persistExecutionFailure(
2762
+ expectedOwner,
2763
+ entry,
2764
+ execution.error,
2765
+ );
2766
+ if (failureResult === "blocked" || failureResult === "aborted") {
2767
+ return failureResult;
2768
+ }
2769
+ snapshotInvalidated = true;
2770
+ break;
2771
+ }
2772
+ const postExecutionAuthority = checkAuthority(
2773
+ expectedOwner,
2774
+ entry.updateId,
2775
+ );
2776
+ if (postExecutionAuthority) return postExecutionAuthority;
2777
+ const claimableUpdateIds = new Set<number>([
2778
+ entry.updateId,
2779
+ ...claims.keys(),
2780
+ ]);
2781
+ let outcome: TelegramUpdateAdmissionOutcome;
2782
+ try {
2783
+ outcome = validateTelegramUpdateAdmissionOutcome(
2784
+ execution.outcome,
2785
+ entry.updateId,
2786
+ claimableUpdateIds,
2787
+ );
2788
+ } catch (error) {
2789
+ return blockWithFailure(
2790
+ "invalid-outcome",
2791
+ "invalid-outcome",
2792
+ error,
2793
+ entry.updateId,
2794
+ );
2795
+ }
2796
+ if (outcome.kind === "deferred") {
2797
+ claims.set(entry.updateId, "deferred");
2798
+ transition("deferred", entry.updateId);
2799
+ continue;
2800
+ }
2801
+ if (outcome.kind === "queued") {
2802
+ const completionResult = commitCompletedBatch(
2803
+ expectedOwner,
2804
+ completedUpdateIds,
2805
+ );
2806
+ if (completionResult) return completionResult;
2807
+ const queuedResult = commitQueuedOutcome(
2808
+ expectedOwner,
2809
+ entry.updateId,
2810
+ outcome,
2811
+ );
2812
+ if (queuedResult === "blocked" || queuedResult === "aborted") {
2813
+ return queuedResult;
2814
+ }
2815
+ transition("queued", entry.updateId);
2816
+ snapshotInvalidated = true;
2817
+ break;
2818
+ }
2819
+ completedUpdateIds.push(entry.updateId);
2820
+ }
2821
+ const completionResult = commitCompletedBatch(
2822
+ expectedOwner,
2823
+ completedUpdateIds,
2824
+ );
2825
+ if (completionResult) return completionResult;
2826
+ if (!snapshotInvalidated && !hasMoreEntries) continue;
2827
+ await yieldToEventLoop();
2828
+ if (
2829
+ owner !== expectedOwner ||
2830
+ expectedOwner.controller.signal.aborted
2831
+ ) {
2832
+ return "aborted";
2833
+ }
2834
+ }
2835
+ return "aborted";
2836
+ };
2837
+
2838
+ launchDrain = (): void => {
2839
+ const expectedOwner = owner;
2840
+ if (!expectedOwner || drainPromise) return;
2841
+ const run = async (): Promise<void> => {
2842
+ while (
2843
+ pendingSignal &&
2844
+ owner === expectedOwner &&
2845
+ !expectedOwner.controller.signal.aborted
2846
+ ) {
2847
+ pendingSignal = false;
2848
+ const result = await drain(expectedOwner);
2849
+ if (result !== "idle") {
2850
+ pendingSignal = false;
2851
+ return;
2852
+ }
2853
+ }
2854
+ };
2855
+ const operation = run();
2856
+ let tracked: Promise<void>;
2857
+ const finish = (): void => {
2858
+ if (owner === expectedOwner && drainPromise === tracked) {
2859
+ drainPromise = undefined;
2860
+ if (pendingSignal && !expectedOwner.controller.signal.aborted) {
2861
+ launchDrain();
2862
+ }
2863
+ }
2864
+ };
2865
+ tracked = operation.then(
2866
+ () => finish(),
2867
+ (error: unknown) => {
2868
+ pendingSignal = false;
2869
+ if (owner === expectedOwner) {
2870
+ blockWithFailure("execution", "worker-loop", error);
2871
+ }
2872
+ finish();
2873
+ },
2874
+ );
2875
+ drainPromise = tracked;
2876
+ };
2877
+
2878
+ return {
2879
+ start(ctx) {
2880
+ if (owner) {
2881
+ if (!owner.controller.signal.aborted) {
2882
+ pendingSignal = true;
2883
+ launchDrain();
2884
+ }
2885
+ return;
2886
+ }
2887
+ clearRetryTimer();
2888
+ const nowMs = getNowMs();
2889
+ const generation = ++nextGeneration;
2890
+ const queueOwnerIdentity = resolveQueueOwnerIdentity(ctx, generation);
2891
+ owner = {
2892
+ generation,
2893
+ ctx,
2894
+ controller: createAbortController(),
2895
+ queueOwnerIdentity,
2896
+ };
2897
+ claims.clear();
2898
+ committedQueueReceipts.clear();
2899
+ blocked = false;
2900
+ pendingSignal = true;
2901
+ state.phase = "idle";
2902
+ state.generation = generation;
2903
+ state.phaseStartedAtMs = nowMs;
2904
+ state.currentUpdateId = undefined;
2905
+ state.blockedReason = undefined;
2906
+ state.journalEntryCount = 0;
2907
+ state.journalSerializedBytes = 0;
2908
+ state.oldestAdmittedAtMs = undefined;
2909
+ state.deferredClaimCount = 0;
2910
+ state.queuedClaimCount = 0;
2911
+ state.foreignQueuedCount = 0;
2912
+ delete state.foreignQueuedOwner;
2913
+ state.retryWaitCount = 0;
2914
+ state.failedCount = 0;
2915
+ state.nextRetryUpdateId = undefined;
2916
+ state.nextRetryAtMs = undefined;
2917
+ state.nextRetryAttemptCount = undefined;
2918
+ state.nextRetryFailureClass = undefined;
2919
+ state.failedUpdateId = undefined;
2920
+ state.failedFailureId = undefined;
2921
+ state.failedAttemptCount = undefined;
2922
+ state.failedClass = undefined;
2923
+ state.failedSummary = undefined;
2924
+ state.terminalFailureAtMs = undefined;
2925
+ state.unsettledExecutionCount = unsettledExecutions.size;
2926
+ state.lastCompletedUpdateId = undefined;
2927
+ state.lastCompletedAtMs = undefined;
2928
+ state.lastFailureAtMs = undefined;
2929
+ state.lastFailurePhase = undefined;
2930
+ notifyStateChange();
2931
+ launchDrain();
2932
+ },
2933
+ signal() {
2934
+ if (!owner || owner.controller.signal.aborted) return;
2935
+ blocked = false;
2936
+ pendingSignal = true;
2937
+ launchDrain();
2938
+ },
2939
+ settleDeferred(input) {
2940
+ const expectedOwner = owner;
2941
+ if (
2942
+ !expectedOwner ||
2943
+ expectedOwner.controller.signal !== input.signal ||
2944
+ input.signal.aborted
2945
+ ) {
2946
+ return;
2947
+ }
2948
+ const authorityResult = checkAuthority(expectedOwner, input.updateId);
2949
+ if (authorityResult) {
2950
+ if (authorityResult === "blocked") {
2951
+ releaseDeferredClaims(
2952
+ input.outcome.kind === "queued"
2953
+ ? input.outcome.sourceUpdateIds
2954
+ : [input.updateId],
2955
+ );
2956
+ }
2957
+ return;
2958
+ }
2959
+ const claim = claims.get(input.updateId);
2960
+ if (input.outcome.kind === "deferred") {
2961
+ if (claim === "queued") return;
2962
+ if (claim !== "deferred") {
2963
+ blockWithFailure(
2964
+ "invalid-outcome",
2965
+ "late-outcome-unclaimed",
2966
+ new TelegramUpdateAdmissionOutcomeError(
2967
+ `Telegram update ${input.updateId} reported a late deferred outcome without a live claim.`,
2968
+ ),
2969
+ input.updateId,
2970
+ );
2971
+ return;
2972
+ }
2973
+ transition("deferred", input.updateId);
2974
+ return;
2975
+ }
2976
+ let outcome: TelegramUpdateAdmissionOutcome;
2977
+ try {
2978
+ outcome = validateTelegramUpdateAdmissionOutcome(
2979
+ input.outcome,
2980
+ input.updateId,
2981
+ new Set([input.updateId, ...claims.keys()]),
2982
+ );
2983
+ } catch (error) {
2984
+ blockWithFailure(
2985
+ "invalid-outcome",
2986
+ "late-invalid-outcome",
2987
+ error,
2988
+ input.updateId,
2989
+ );
2990
+ releaseDeferredClaims(input.outcome.sourceUpdateIds);
2991
+ return;
2992
+ }
2993
+ if (outcome.kind !== "queued") {
2994
+ blockWithFailure(
2995
+ "invalid-outcome",
2996
+ "late-invalid-outcome",
2997
+ new TelegramUpdateAdmissionOutcomeError(
2998
+ `Telegram update ${input.updateId} reported a non-queue late outcome.`,
2999
+ ),
3000
+ input.updateId,
3001
+ );
3002
+ return;
3003
+ }
3004
+ if (claim !== "deferred" && claim !== "queued") {
3005
+ blockWithFailure(
3006
+ "invalid-outcome",
3007
+ "late-outcome-unclaimed",
3008
+ new TelegramUpdateAdmissionOutcomeError(
3009
+ `Telegram update ${input.updateId} reported a late queue outcome without a live claim.`,
3010
+ ),
3011
+ input.updateId,
3012
+ );
3013
+ releaseDeferredClaims(outcome.sourceUpdateIds);
3014
+ return;
3015
+ }
3016
+ const result = commitQueuedOutcome(
3017
+ expectedOwner,
3018
+ input.updateId,
3019
+ outcome,
3020
+ );
3021
+ if (result === "blocked") {
3022
+ releaseDeferredClaims(outcome.sourceUpdateIds);
3023
+ return;
3024
+ }
3025
+ if (result === "aborted") return;
3026
+ transition("queued", input.updateId);
3027
+ },
3028
+ isQueueReceiptCommitted(receipt) {
3029
+ const committed = committedQueueReceipts.get(receipt.receiptId);
3030
+ return (
3031
+ committed !== undefined &&
3032
+ areTelegramQueueAdmissionReceiptsEqual(
3033
+ committed.receipt,
3034
+ normalizeQueueReceipt(receipt),
3035
+ )
3036
+ );
3037
+ },
3038
+ getQueueReceiptOwner(receipt) {
3039
+ const committed = committedQueueReceipts.get(receipt.receiptId);
3040
+ return committed &&
3041
+ areTelegramQueueAdmissionReceiptsEqual(
3042
+ committed.receipt,
3043
+ normalizeQueueReceipt(receipt),
3044
+ )
3045
+ ? { ...committed.queueOwner }
3046
+ : undefined;
3047
+ },
3048
+ completeQueueReceipts(input) {
3049
+ const expectedOwner = owner;
3050
+ if (
3051
+ !expectedOwner ||
3052
+ expectedOwner.controller.signal.aborted ||
3053
+ !(deps.isContextCurrent?.(input.ctx) ?? expectedOwner.ctx === input.ctx)
3054
+ ) {
3055
+ return;
3056
+ }
3057
+ if (input.receipts.length === 0) return;
3058
+ const normalizedReceipts = input.receipts.map(normalizeQueueReceipt);
3059
+ const receiptIds = new Set<string>();
3060
+ const sourceUpdateIds = new Set<number>();
3061
+ const queuedCompletions: Array<{
3062
+ queueKind: "prompt" | "control";
3063
+ receiptId: string;
3064
+ sourceUpdateIds: readonly number[];
3065
+ queueOwner: TelegramUpdateJournalQueueOwner;
3066
+ }> = [];
3067
+ for (const receipt of normalizedReceipts) {
3068
+ const committed = committedQueueReceipts.get(receipt.receiptId);
3069
+ if (!committed) continue;
3070
+ if (
3071
+ receiptIds.has(receipt.receiptId) ||
3072
+ !areTelegramQueueAdmissionReceiptsEqual(
3073
+ committed.receipt,
3074
+ receipt,
3075
+ ) ||
3076
+ !isTelegramUpdateJournalQueueOwnerProcess(
3077
+ committed.queueOwner,
3078
+ expectedOwner.queueOwnerIdentity,
3079
+ ) ||
3080
+ receipt.sourceUpdateIds.some((updateId) =>
3081
+ sourceUpdateIds.has(updateId),
3082
+ )
3083
+ ) {
3084
+ blockWithFailure(
3085
+ "invalid-outcome",
3086
+ "queue-receipt-completion-invalid",
3087
+ new TelegramUpdateAdmissionOutcomeError(
3088
+ `Telegram ${input.reason} requested invalid queue receipt ${receipt.receiptId}.`,
3089
+ ),
3090
+ );
3091
+ return;
3092
+ }
3093
+ receiptIds.add(receipt.receiptId);
3094
+ queuedCompletions.push({
3095
+ ...receipt,
3096
+ queueOwner: { ...committed.queueOwner },
3097
+ });
3098
+ for (const updateId of receipt.sourceUpdateIds) {
3099
+ sourceUpdateIds.add(updateId);
3100
+ }
3101
+ }
3102
+ if (receiptIds.size === 0) return;
3103
+ let removedUpdateIds: readonly number[];
3104
+ try {
3105
+ removedUpdateIds = deps.journal.completeQueued(
3106
+ queuedCompletions,
3107
+ ).removedUpdateIds;
3108
+ } catch (error) {
3109
+ blockWithFailure(
3110
+ "journal-write",
3111
+ "queue-receipt-completion",
3112
+ error,
3113
+ );
3114
+ return;
3115
+ }
3116
+ const removed = new Set(removedUpdateIds);
3117
+ if (
3118
+ removed.size !== sourceUpdateIds.size ||
3119
+ [...sourceUpdateIds].some((updateId) => !removed.has(updateId))
3120
+ ) {
3121
+ blockWithFailure(
3122
+ "journal-write",
3123
+ "queue-receipt-completion",
3124
+ new Error(
3125
+ `Telegram ${input.reason} did not complete every receipt source.`,
3126
+ ),
3127
+ );
3128
+ return;
3129
+ }
3130
+ for (const updateId of sourceUpdateIds) claims.delete(updateId);
3131
+ for (const receiptId of receiptIds) {
3132
+ committedQueueReceipts.delete(receiptId);
3133
+ }
3134
+ const completedUpdateIds = [...sourceUpdateIds];
3135
+ state.lastCompletedUpdateId = Math.max(
3136
+ state.lastCompletedUpdateId ?? -1,
3137
+ ...completedUpdateIds,
3138
+ );
3139
+ state.lastCompletedAtMs = getNowMs();
3140
+ state.journalEntryCount = Math.max(
3141
+ 0,
3142
+ state.journalEntryCount - completedUpdateIds.length,
3143
+ );
3144
+ updateClaimCounts();
3145
+ notifyStateChange();
3146
+ pendingSignal = true;
3147
+ launchDrain();
3148
+ },
3149
+ async stop() {
3150
+ const expectedOwner = owner;
3151
+ if (!expectedOwner) return;
3152
+ pendingSignal = false;
3153
+ clearRetryTimer();
3154
+ expectedOwner.controller.abort();
3155
+ await drainPromise?.catch(() => undefined);
3156
+ if (owner !== expectedOwner) return;
3157
+ owner = undefined;
3158
+ drainPromise = undefined;
3159
+ claims.clear();
3160
+ committedQueueReceipts.clear();
3161
+ blocked = false;
3162
+ state.phase = "stopped";
3163
+ state.phaseStartedAtMs = getNowMs();
3164
+ state.currentUpdateId = undefined;
3165
+ state.blockedReason = undefined;
3166
+ state.deferredClaimCount = 0;
3167
+ state.queuedClaimCount = 0;
3168
+ state.foreignQueuedCount = 0;
3169
+ delete state.foreignQueuedOwner;
3170
+ state.retryWaitCount = 0;
3171
+ state.failedCount = 0;
3172
+ state.nextRetryUpdateId = undefined;
3173
+ state.nextRetryAtMs = undefined;
3174
+ state.nextRetryAttemptCount = undefined;
3175
+ state.nextRetryFailureClass = undefined;
3176
+ state.failedUpdateId = undefined;
3177
+ state.failedFailureId = undefined;
3178
+ state.failedAttemptCount = undefined;
3179
+ state.failedClass = undefined;
3180
+ state.failedSummary = undefined;
3181
+ state.terminalFailureAtMs = undefined;
3182
+ state.unsettledExecutionCount = unsettledExecutions.size;
3183
+ notifyStateChange();
3184
+ },
3185
+ waitForDrain() {
3186
+ return drainPromise ?? Promise.resolve();
3187
+ },
3188
+ getState() {
3189
+ return getTelegramUpdateWorkerStateSnapshot(state);
3190
+ },
3191
+ };
3192
+ }
3193
+
3194
+ // --- Public update handler registry ---
3195
+
3196
+ /**
3197
+ * Verdict returned by a public Telegram update handler.
3198
+ *
3199
+ * - `"consume"` — the handler processed this update; pi-telegram skips default routing.
3200
+ * - `"pass"` (or `void`/`undefined`) — pi-telegram routes the update normally.
3201
+ */
3202
+ export type TelegramUpdateHandlerVerdict = "consume" | "pass";
3203
+
3204
+ export interface TelegramUpdateExecutionFence {
3205
+ readonly generation: number;
3206
+ readonly updateId: number;
3207
+ readonly signal: AbortSignal;
3208
+ isCurrent: () => boolean;
3209
+ assertCurrent: () => void;
3210
+ }
3211
+
3212
+ const TELEGRAM_UPDATE_EXECUTION_FENCE = Symbol(
3213
+ "pi-telegram.update-execution-fence",
3214
+ );
3215
+
3216
+ type TelegramExecutionFencedUpdate = {
3217
+ [TELEGRAM_UPDATE_EXECUTION_FENCE]?: TelegramUpdateExecutionFence;
3218
+ };
3219
+
3220
+ export function getTelegramUpdateExecutionFence(
3221
+ update: unknown,
3222
+ ): TelegramUpdateExecutionFence | undefined {
3223
+ if (!update || typeof update !== "object") return undefined;
3224
+ return (update as TelegramExecutionFencedUpdate)[
3225
+ TELEGRAM_UPDATE_EXECUTION_FENCE
3226
+ ];
3227
+ }
3228
+
3229
+ function bindTelegramUpdateExecutionFenceCarrier<TValue>(
3230
+ value: TValue | undefined,
3231
+ execution: TelegramUpdateExecutionFence,
3232
+ ): TValue | undefined {
3233
+ if (!value || typeof value !== "object") return value;
3234
+ Object.defineProperty(value, TELEGRAM_UPDATE_EXECUTION_FENCE, {
3235
+ configurable: true,
3236
+ enumerable: false,
3237
+ value: execution,
3238
+ });
3239
+ return value;
3240
+ }
3241
+
3242
+ function bindTelegramUpdateExecutionFence<
3243
+ TUpdate extends TelegramUpdateFlow & object,
3244
+ >(
3245
+ update: TUpdate,
3246
+ execution: TelegramUpdateExecutionFence,
3247
+ ): TUpdate {
3248
+ bindTelegramUpdateExecutionFenceCarrier(update, execution);
3249
+ bindTelegramUpdateExecutionFenceCarrier(update.message, execution);
3250
+ bindTelegramUpdateExecutionFenceCarrier(update.edited_message, execution);
3251
+ bindTelegramUpdateExecutionFenceCarrier(update.callback_query, execution);
3252
+ bindTelegramUpdateExecutionFenceCarrier(
3253
+ update.callback_query?.message,
3254
+ execution,
3255
+ );
3256
+ bindTelegramUpdateExecutionFenceCarrier(update.guest_message, execution);
3257
+ bindTelegramUpdateExecutionFenceCarrier(update.message_reaction, execution);
3258
+ return update;
3259
+ }
3260
+
3261
+ export function assertTelegramUpdateExecutionCurrent(update: unknown): void {
3262
+ getTelegramUpdateExecutionFence(update)?.assertCurrent();
3263
+ }
3264
+
3265
+ export function createTelegramUpdateExecutionFenceGuard(
3266
+ update: unknown,
3267
+ ): () => void {
3268
+ const execution = getTelegramUpdateExecutionFence(update);
3269
+ return (): void => execution?.assertCurrent();
3270
+ }
3271
+
3272
+ export function carryTelegramUpdateExecutionFence<TTarget extends object>(
3273
+ source: unknown,
3274
+ target: TTarget,
3275
+ ): TTarget {
3276
+ const execution = getTelegramUpdateExecutionFence(source);
3277
+ return execution
3278
+ ? bindTelegramUpdateExecutionFenceCarrier(target, execution)!
3279
+ : target;
3280
+ }
3281
+
3282
+ export type TelegramUpdateHandler = (
3283
+ update: unknown,
3284
+ execution?: TelegramUpdateExecutionFence,
3285
+ ) =>
3286
+ | TelegramUpdateHandlerVerdict
3287
+ | void
3288
+ | Promise<TelegramUpdateHandlerVerdict | void>;
3289
+
3290
+ export interface TelegramUpdateHandlerRegistry {
3291
+ /** Schema version of this registry shape. */
3292
+ readonly version: 1;
3293
+ /**
3294
+ * Register an update handler. Returns a disposer that removes it.
3295
+ *
3296
+ * Handlers are invoked in registration order on every Telegram update,
3297
+ * before pi-telegram's own routing. The first handler that returns
3298
+ * `"consume"` wins and stops the chain for that update.
3299
+ */
3300
+ add: (handler: TelegramUpdateHandler) => () => void;
3301
+ /**
3302
+ * Run all registered handlers against an update.
3303
+ *
3304
+ * Used by pi-telegram's polling runtime; extension consumers should call
3305
+ * {@link registerTelegramUpdateHandler} or `add` instead of dispatching directly.
3306
+ */
3307
+ dispatch: (
3308
+ update: unknown,
3309
+ execution?: TelegramUpdateExecutionFence,
3310
+ ) => Promise<TelegramUpdateHandlerVerdict>;
3311
+ }
3312
+
3313
+ const UPDATE_HANDLER_REGISTRY_KEY = "__piTelegramUpdateHandlerRegistry__";
3314
+
3315
+ function isValidV1UpdateHandlerRegistry(
3316
+ candidate: unknown,
3317
+ ): candidate is TelegramUpdateHandlerRegistry {
3318
+ if (!candidate || typeof candidate !== "object") return false;
3319
+ const r = candidate as Partial<TelegramUpdateHandlerRegistry>;
3320
+ return (
3321
+ r.version === 1 &&
3322
+ typeof r.add === "function" &&
3323
+ typeof r.dispatch === "function"
3324
+ );
3325
+ }
3326
+
3327
+ function getOrCreateUpdateHandlerRegistry(): TelegramUpdateHandlerRegistry {
3328
+ const g = globalThis as Record<string, unknown>;
3329
+ const existing = g[UPDATE_HANDLER_REGISTRY_KEY];
3330
+ if (isValidV1UpdateHandlerRegistry(existing)) return existing;
3331
+ const handlers = new Set<TelegramUpdateHandler>();
3332
+ const registry: TelegramUpdateHandlerRegistry = {
3333
+ version: 1,
3334
+ add(handler) {
3335
+ handlers.add(handler);
3336
+ return () => handlers.delete(handler);
3337
+ },
3338
+ async dispatch(update, execution) {
3339
+ for (const handler of handlers) {
3340
+ execution?.assertCurrent();
3341
+ try {
3342
+ const result = await handler(update, execution);
3343
+ if (result === "consume") return "consume";
3344
+ } catch {
3345
+ // Update handler errors must not break polling.
3346
+ }
3347
+ }
3348
+ return "pass";
3349
+ },
3350
+ };
3351
+ g[UPDATE_HANDLER_REGISTRY_KEY] = registry;
3352
+ return registry;
3353
+ }
3354
+
3355
+ /**
3356
+ * Called by pi-telegram's own runtime to obtain the registry it dispatches
3357
+ * through. Extension consumers should not call this; use
3358
+ * {@link registerTelegramUpdateHandler} instead.
3359
+ */
3360
+ export function getTelegramUpdateHandlerRegistry(): TelegramUpdateHandlerRegistry {
3361
+ return getOrCreateUpdateHandlerRegistry();
3362
+ }
3363
+
3364
+ export interface TelegramUpdateHandlerWrapDeps<TUpdate, TContext> {
3365
+ defaultHandle: (update: TUpdate, ctx: TContext) => Promise<void>;
3366
+ registry?: TelegramUpdateHandlerRegistry;
3367
+ }
3368
+
3369
+ /**
3370
+ * Wrap a default polling `handleUpdate` with the public update handler registry.
3371
+ */
3372
+ export function createTelegramUpdateHandle<TUpdate, TContext>(
1327
3373
  deps: TelegramUpdateHandlerWrapDeps<TUpdate, TContext>,
1328
3374
  ): (update: TUpdate, ctx: TContext) => Promise<void> {
1329
3375
  const registry = deps.registry ?? getOrCreateUpdateHandlerRegistry();
@@ -1335,6 +3381,1508 @@ export function createTelegramUpdateHandle<TUpdate, TContext>(
1335
3381
  };
1336
3382
  }
1337
3383
 
3384
+ export interface TelegramUpdateAdmissionHandleDeps<
3385
+ TUpdate extends TelegramUpdateFlow & { update_id: number },
3386
+ TContext,
3387
+ > {
3388
+ defaultHandle: (
3389
+ update: TUpdate,
3390
+ ctx: TContext,
3391
+ execution?: TelegramUpdateExecutionFence,
3392
+ ) => Promise<void>;
3393
+ registry?: TelegramUpdateHandlerRegistry;
3394
+ onLateOutcome?: (
3395
+ outcome: Extract<
3396
+ TelegramUpdateAdmissionOutcome,
3397
+ { kind: "deferred" | "queued" }
3398
+ >,
3399
+ details: {
3400
+ updateId: number;
3401
+ ctx: TContext;
3402
+ signal: AbortSignal;
3403
+ },
3404
+ ) => void | Promise<void>;
3405
+ onLateOutcomeError?: (error: unknown, updateId: number) => void;
3406
+ }
3407
+
3408
+ function mergeTelegramReportedAdmissionOutcome(
3409
+ current:
3410
+ | Extract<
3411
+ TelegramUpdateAdmissionOutcome,
3412
+ { kind: "deferred" | "queued" }
3413
+ >
3414
+ | undefined,
3415
+ next: Extract<
3416
+ TelegramUpdateAdmissionOutcome,
3417
+ { kind: "deferred" | "queued" }
3418
+ >,
3419
+ updateId: number,
3420
+ ): Extract<
3421
+ TelegramUpdateAdmissionOutcome,
3422
+ { kind: "deferred" | "queued" }
3423
+ > {
3424
+ if (!current || current.kind === "deferred") return next;
3425
+ if (next.kind === "deferred") return current;
3426
+ if (areTelegramQueueAdmissionReceiptsEqual(current, next)) return current;
3427
+ throw new TelegramUpdateAdmissionOutcomeError(
3428
+ `Telegram update ${updateId} reported conflicting queue outcomes.`,
3429
+ );
3430
+ }
3431
+
3432
+ /**
3433
+ * Compose the stable public handler registry with source-bound semantic
3434
+ * admission. Production polling switches to this only with the journal worker.
3435
+ */
3436
+ export function createTelegramUpdateAdmissionHandle<
3437
+ TUpdate extends TelegramUpdateFlow & { update_id: number },
3438
+ TContext,
3439
+ >(
3440
+ deps: TelegramUpdateAdmissionHandleDeps<TUpdate, TContext>,
3441
+ ): (
3442
+ update: TUpdate,
3443
+ ctx: TContext,
3444
+ signal: AbortSignal,
3445
+ ) => Promise<TelegramUpdateAdmissionOutcome> {
3446
+ if (deps.onLateOutcome && !deps.onLateOutcomeError) {
3447
+ throw new Error(
3448
+ "Telegram late admission outcomes require a diagnostic error sink.",
3449
+ );
3450
+ }
3451
+ const registry = deps.registry ?? getOrCreateUpdateHandlerRegistry();
3452
+ let nextExecutionGeneration = 0;
3453
+ return async (update, ctx, signal) => {
3454
+ const generation = ++nextExecutionGeneration;
3455
+ const execution: TelegramUpdateExecutionFence = {
3456
+ generation,
3457
+ updateId: update.update_id,
3458
+ signal,
3459
+ isCurrent: () => !signal.aborted,
3460
+ assertCurrent() {
3461
+ if (signal.aborted) {
3462
+ throw signal.reason ?? new DOMException("Aborted", "AbortError");
3463
+ }
3464
+ },
3465
+ };
3466
+ execution.assertCurrent();
3467
+ const verdict = await registry.dispatch(update, execution);
3468
+ execution.assertCurrent();
3469
+ if (verdict === "consume") return { kind: "complete" };
3470
+ let immediate = true;
3471
+ let outcome:
3472
+ | Extract<
3473
+ TelegramUpdateAdmissionOutcome,
3474
+ { kind: "deferred" | "queued" }
3475
+ >
3476
+ | undefined;
3477
+ const boundUpdate = bindTelegramUpdateExecutionFence(
3478
+ bindTelegramUpdateAdmissionSource(update, (next) => {
3479
+ if (immediate) {
3480
+ outcome = mergeTelegramReportedAdmissionOutcome(
3481
+ outcome,
3482
+ next,
3483
+ update.update_id,
3484
+ );
3485
+ return;
3486
+ }
3487
+ if (!deps.onLateOutcome) {
3488
+ throw new TelegramUpdateAdmissionOutcomeError(
3489
+ `Telegram update ${update.update_id} reported a late outcome without an owner.`,
3490
+ );
3491
+ }
3492
+ void Promise.resolve()
3493
+ .then(() =>
3494
+ deps.onLateOutcome!(next, {
3495
+ updateId: update.update_id,
3496
+ ctx,
3497
+ signal,
3498
+ }),
3499
+ )
3500
+ .catch((error) => {
3501
+ try {
3502
+ deps.onLateOutcomeError?.(error, update.update_id);
3503
+ } catch {
3504
+ // Diagnostic sinks must not create an unhandled late Promise.
3505
+ }
3506
+ });
3507
+ }),
3508
+ execution,
3509
+ );
3510
+ try {
3511
+ execution.assertCurrent();
3512
+ await deps.defaultHandle(boundUpdate, ctx, execution);
3513
+ } finally {
3514
+ immediate = false;
3515
+ }
3516
+ return outcome ?? { kind: "complete" };
3517
+ };
3518
+ }
3519
+
3520
+ export interface TelegramQueueAdmissionItemLike {
3521
+ admissionReceipts?: readonly TelegramQueueAdmissionReceiptLike[];
3522
+ }
3523
+
3524
+ export interface TelegramQueueAdmissionSettlementRuntime<TContext> {
3525
+ isItemReady: (item: TelegramQueueAdmissionItemLike) => boolean;
3526
+ getQueueReceiptOwner: (
3527
+ receipt: TelegramQueueAdmissionReceiptLike,
3528
+ ) => TelegramUpdateJournalQueueOwner | undefined;
3529
+ onPromptHandedOff: (
3530
+ item: TelegramQueueAdmissionItemLike,
3531
+ ctx: TContext,
3532
+ ) => void;
3533
+ onControlSettled: (
3534
+ item: TelegramQueueAdmissionItemLike,
3535
+ ctx: TContext,
3536
+ ) => void;
3537
+ onItemsDiscarded: (
3538
+ items: readonly TelegramQueueAdmissionItemLike[],
3539
+ ctx: TContext,
3540
+ ) => void;
3541
+ }
3542
+
3543
+ export function createTelegramQueueAdmissionSettlementMuxRuntime<TContext>(
3544
+ runtimes: readonly TelegramQueueAdmissionSettlementRuntime<TContext>[],
3545
+ ): TelegramQueueAdmissionSettlementRuntime<TContext> {
3546
+ const settle = (
3547
+ operation: (
3548
+ runtime: TelegramQueueAdmissionSettlementRuntime<TContext>,
3549
+ ) => void,
3550
+ ): void => {
3551
+ for (const runtime of runtimes) operation(runtime);
3552
+ };
3553
+ return {
3554
+ isItemReady: (item) =>
3555
+ (item.admissionReceipts ?? []).every((receipt) =>
3556
+ runtimes.some((runtime) =>
3557
+ runtime.isItemReady({ admissionReceipts: [receipt] }),
3558
+ ),
3559
+ ),
3560
+ getQueueReceiptOwner(receipt) {
3561
+ let owner: TelegramUpdateJournalQueueOwner | undefined;
3562
+ for (const runtime of runtimes) {
3563
+ const candidate = runtime.getQueueReceiptOwner(receipt);
3564
+ if (!candidate) continue;
3565
+ if (
3566
+ owner &&
3567
+ !areTelegramUpdateJournalQueueOwnersEqual(owner, candidate)
3568
+ ) {
3569
+ throw new TelegramUpdateAdmissionOutcomeError(
3570
+ `Telegram queue receipt ${receipt.receiptId} has multiple live owners.`,
3571
+ );
3572
+ }
3573
+ owner = candidate;
3574
+ }
3575
+ return owner ? { ...owner } : undefined;
3576
+ },
3577
+ onPromptHandedOff: (item, ctx) =>
3578
+ settle((runtime) => runtime.onPromptHandedOff(item, ctx)),
3579
+ onControlSettled: (item, ctx) =>
3580
+ settle((runtime) => runtime.onControlSettled(item, ctx)),
3581
+ onItemsDiscarded: (items, ctx) =>
3582
+ settle((runtime) => runtime.onItemsDiscarded(items, ctx)),
3583
+ };
3584
+ }
3585
+
3586
+ export function createTelegramQueueAdmissionSettlementRuntime<TContext>(
3587
+ worker: TelegramUpdateWorkerRuntime<TContext>,
3588
+ ): TelegramQueueAdmissionSettlementRuntime<TContext> {
3589
+ const complete = (
3590
+ items: readonly TelegramQueueAdmissionItemLike[],
3591
+ ctx: TContext,
3592
+ reason: TelegramQueueReceiptCompletionReason,
3593
+ ): void => {
3594
+ const receipts: TelegramQueueAdmissionReceiptLike[] = [];
3595
+ for (const item of items) {
3596
+ if (item.admissionReceipts) receipts.push(...item.admissionReceipts);
3597
+ }
3598
+ worker.completeQueueReceipts({ receipts, ctx, reason });
3599
+ };
3600
+ return {
3601
+ isItemReady: (item) =>
3602
+ (item.admissionReceipts ?? []).every(
3603
+ worker.isQueueReceiptCommitted,
3604
+ ),
3605
+ getQueueReceiptOwner: worker.getQueueReceiptOwner,
3606
+ onPromptHandedOff: (item, ctx) =>
3607
+ complete([item], ctx, "prompt-handoff"),
3608
+ onControlSettled: (item, ctx) =>
3609
+ complete([item], ctx, "control-settlement"),
3610
+ onItemsDiscarded: (items, ctx) =>
3611
+ complete(items, ctx, "discard"),
3612
+ };
3613
+ }
3614
+
3615
+ export interface TelegramUpdateAdmissionLifecycleJournalBinding {
3616
+ runtimeKey: string;
3617
+ recoveryKey: string;
3618
+ journal: TelegramUpdateWorkerJournalPort & {
3619
+ appendBatch: (updates: readonly TelegramJournaledUpdate[]) => unknown;
3620
+ applyOperatorDisposition?: (
3621
+ input: TelegramUpdateJournalOperatorDispositionInput,
3622
+ ) => TelegramUpdateJournalOperatorDispositionResult;
3623
+ discardQueued?: (input: {
3624
+ queueKind: "prompt" | "control";
3625
+ receiptId: string;
3626
+ sourceUpdateIds: readonly number[];
3627
+ expectedOwner: TelegramUpdateJournalQueueOwner;
3628
+ }) => TelegramUpdateJournalQueueDiscardResult;
3629
+ offerQueuedHandoff?: (
3630
+ input: TelegramUpdateJournalQueueHandoffInput,
3631
+ ) => TelegramUpdateJournalQueueHandoffOfferResult;
3632
+ acceptQueuedHandoff?: (
3633
+ input: TelegramUpdateJournalQueueHandoffInput,
3634
+ ) => TelegramUpdateJournalQueueHandoffAcceptResult;
3635
+ cancelQueuedHandoff?: (
3636
+ input: TelegramUpdateJournalQueueHandoffInput,
3637
+ ) => TelegramUpdateJournalQueueHandoffCancelResult;
3638
+ recoverDeadQueueOwner?: (input: {
3639
+ queueKind: "prompt" | "control";
3640
+ receiptId: string;
3641
+ sourceUpdateIds: readonly number[];
3642
+ deadOwner: TelegramUpdateJournalQueueOwner;
3643
+ recoveryOwner: TelegramUpdateJournalQueueOwnerIdentity;
3644
+ }) => TelegramUpdateJournalDeadQueueOwnerRecoveryResult;
3645
+ };
3646
+ hasAuthority?: () => boolean;
3647
+ }
3648
+
3649
+ export interface TelegramQueueHandoffControlExecutionDeps<TContext> {
3650
+ isContextCurrent: (ctx: TContext) => boolean;
3651
+ showStatus: (
3652
+ chatId: number,
3653
+ replyToMessageId: number,
3654
+ ctx: TContext,
3655
+ threadId?: number,
3656
+ ) => Promise<void>;
3657
+ openModelMenu: (
3658
+ chatId: number,
3659
+ replyToMessageId: number,
3660
+ ctx: TContext,
3661
+ threadId?: number,
3662
+ ) => Promise<void>;
3663
+ }
3664
+
3665
+ export function createTelegramQueueHandoffControlExecutionFactory<TContext>(
3666
+ deps: TelegramQueueHandoffControlExecutionDeps<TContext>,
3667
+ ): (
3668
+ payload: TelegramControlQueueHandoffPayload,
3669
+ ) => PendingTelegramControlItem<TContext>["execute"] {
3670
+ return (payload) => async (ctx) => {
3671
+ if (!deps.isContextCurrent(ctx)) return;
3672
+ if (payload.controlType === "status") {
3673
+ await deps.showStatus(
3674
+ payload.chatId,
3675
+ payload.replyToMessageId,
3676
+ ctx,
3677
+ payload.target?.threadId,
3678
+ );
3679
+ return;
3680
+ }
3681
+ await deps.openModelMenu(
3682
+ payload.chatId,
3683
+ payload.replyToMessageId,
3684
+ ctx,
3685
+ payload.target?.threadId,
3686
+ );
3687
+ };
3688
+ }
3689
+
3690
+ export interface TelegramQueueHandoffCoordinatorInput<TContext> {
3691
+ item: TelegramQueueItem<TContext>;
3692
+ expectedOwner: TelegramUpdateJournalQueueOwner;
3693
+ recipientOwner: TelegramUpdateJournalQueueOwnerIdentity;
3694
+ handoffToken: string;
3695
+ stageRemote: (input: {
3696
+ handoffToken: string;
3697
+ expectedOwner: TelegramUpdateJournalQueueOwner;
3698
+ recipientOwner: TelegramUpdateJournalQueueOwnerIdentity;
3699
+ payload: TelegramQueueHandoffPayload;
3700
+ }) => Promise<TelegramQueueHandoffStageResult>;
3701
+ lifecycle: Pick<
3702
+ TelegramUpdateAdmissionLifecycleRuntime<TContext>,
3703
+ | "offerQueueReceiptHandoff"
3704
+ | "acceptQueueReceiptHandoff"
3705
+ | "cancelQueueReceiptHandoff"
3706
+ >;
3707
+ removeDonorItem: (receipt: TelegramQueueAdmissionReceipt) => boolean;
3708
+ }
3709
+
3710
+ export type TelegramQueueHandoffCoordinatorResult =
3711
+ | {
3712
+ status: "transferred";
3713
+ receipt: TelegramQueueAdmissionReceipt;
3714
+ queueOwner: TelegramUpdateJournalQueueOwner;
3715
+ }
3716
+ | {
3717
+ status: "retained";
3718
+ receipt: TelegramQueueAdmissionReceipt;
3719
+ error: unknown;
3720
+ cancelled: boolean;
3721
+ };
3722
+
3723
+ function assertTelegramQueueHandoffStageMatches(
3724
+ stage: TelegramQueueHandoffStageResult,
3725
+ receipt: TelegramQueueAdmissionReceipt,
3726
+ ): void {
3727
+ if (
3728
+ stage.status !== "staged" ||
3729
+ stage.receiptId !== receipt.receiptId ||
3730
+ stage.sourceUpdateIds.length !== receipt.sourceUpdateIds.length ||
3731
+ stage.sourceUpdateIds.some(
3732
+ (updateId, index) => updateId !== receipt.sourceUpdateIds[index],
3733
+ )
3734
+ ) {
3735
+ throw new Error(
3736
+ "Telegram queue handoff staging returned a mismatched receipt.",
3737
+ );
3738
+ }
3739
+ }
3740
+
3741
+ export async function coordinateTelegramQueueHandoff<TContext>(
3742
+ input: TelegramQueueHandoffCoordinatorInput<TContext>,
3743
+ ): Promise<TelegramQueueHandoffCoordinatorResult> {
3744
+ const handoff = createTelegramQueueHandoff({
3745
+ handoffToken: input.handoffToken,
3746
+ item: input.item,
3747
+ });
3748
+ const receipt = handoff.payload.admissionReceipts[0];
3749
+ if (!receipt || handoff.payload.admissionReceipts.length !== 1) {
3750
+ throw new Error(
3751
+ "Telegram queue handoff requires exactly one complete receipt.",
3752
+ );
3753
+ }
3754
+ const handoffInput: TelegramUpdateJournalQueueHandoffInput = {
3755
+ queueKind: receipt.queueKind,
3756
+ receiptId: receipt.receiptId,
3757
+ sourceUpdateIds: receipt.sourceUpdateIds,
3758
+ expectedOwner: input.expectedOwner,
3759
+ recipientOwner: input.recipientOwner,
3760
+ handoffToken: input.handoffToken,
3761
+ };
3762
+ input.lifecycle.offerQueueReceiptHandoff(handoffInput);
3763
+ let stage: TelegramQueueHandoffStageResult;
3764
+ try {
3765
+ stage = await input.stageRemote({
3766
+ handoffToken: input.handoffToken,
3767
+ expectedOwner: input.expectedOwner,
3768
+ recipientOwner: input.recipientOwner,
3769
+ payload: handoff.payload,
3770
+ });
3771
+ assertTelegramQueueHandoffStageMatches(stage, receipt);
3772
+ } catch (error) {
3773
+ let cancelled = false;
3774
+ try {
3775
+ input.lifecycle.cancelQueueReceiptHandoff(handoffInput);
3776
+ cancelled = true;
3777
+ } catch {
3778
+ return {
3779
+ status: "retained",
3780
+ receipt: { ...receipt, sourceUpdateIds: [...receipt.sourceUpdateIds] },
3781
+ error,
3782
+ cancelled: false,
3783
+ };
3784
+ }
3785
+ return {
3786
+ status: "retained",
3787
+ receipt: { ...receipt, sourceUpdateIds: [...receipt.sourceUpdateIds] },
3788
+ error,
3789
+ cancelled,
3790
+ };
3791
+ }
3792
+ let donorRemoved = false;
3793
+ try {
3794
+ donorRemoved = input.removeDonorItem(receipt);
3795
+ } catch (error) {
3796
+ throw new Error(
3797
+ `Telegram queue handoff donor removal failed after acceptance: ${error instanceof Error ? error.message : String(error)}`,
3798
+ );
3799
+ }
3800
+ if (!donorRemoved) {
3801
+ throw new Error(
3802
+ `Telegram queue handoff donor item ${receipt.receiptId} disappeared after acceptance.`,
3803
+ );
3804
+ }
3805
+ return {
3806
+ status: "transferred",
3807
+ receipt: { ...receipt, sourceUpdateIds: [...receipt.sourceUpdateIds] },
3808
+ queueOwner: { ...stage.queueOwner },
3809
+ };
3810
+ }
3811
+
3812
+ export interface TelegramQueueHandoffReconciliationBinding<TContext> {
3813
+ request: (ctx: TContext) => void;
3814
+ set: (reconcile: (ctx: TContext) => Promise<void>) => void;
3815
+ }
3816
+
3817
+ export function createTelegramQueueHandoffReconciliationBinding<TContext>(
3818
+ recordFailure?: (error: unknown) => void,
3819
+ ): TelegramQueueHandoffReconciliationBinding<TContext> {
3820
+ let reconcile: ((ctx: TContext) => Promise<void>) | undefined;
3821
+ return {
3822
+ request(ctx) {
3823
+ void reconcile?.(ctx).catch((error) => recordFailure?.(error));
3824
+ },
3825
+ set(next) {
3826
+ reconcile = next;
3827
+ },
3828
+ };
3829
+ }
3830
+
3831
+ export interface TelegramQueueHandoffRecipientRuntimeDeps<TContext> {
3832
+ staging: TelegramQueueHandoffStagingRuntime;
3833
+ getRecipientOwner: () => TelegramUpdateJournalQueueOwnerIdentity;
3834
+ getLifecycleForBinding: (
3835
+ journalBindingKey: string,
3836
+ ) => TelegramUpdateAdmissionLifecycleRuntime<TContext> | undefined;
3837
+ isTransportStampActive?: (
3838
+ stamp: TelegramQueueHandoffPayload["transportStamp"],
3839
+ ) => boolean;
3840
+ dispatchNext: (ctx: TContext) => void;
3841
+ }
3842
+
3843
+ export function createTelegramQueueHandoffRecipientRuntime<TContext>(
3844
+ deps: TelegramQueueHandoffRecipientRuntimeDeps<TContext>,
3845
+ ): (
3846
+ envelope: Extract<
3847
+ TelegramBusEnvelope,
3848
+ { kind: "leader.offerQueueHandoff" }
3849
+ >,
3850
+ ctx: TContext,
3851
+ ) => Promise<TelegramQueueHandoffStageResult> {
3852
+ return async (envelope, ctx) => {
3853
+ const stage = deps.staging.stage(envelope.payload);
3854
+ const receipt = envelope.payload.admissionReceipts[0];
3855
+ if (!receipt || envelope.payload.admissionReceipts.length !== 1) {
3856
+ deps.staging.cancel(
3857
+ receipt ?? {
3858
+ queueKind: envelope.payload.kind,
3859
+ receiptId: stage.receiptId,
3860
+ sourceUpdateIds: stage.sourceUpdateIds,
3861
+ },
3862
+ );
3863
+ throw new Error(
3864
+ "Telegram queue handoff requires exactly one complete receipt.",
3865
+ );
3866
+ }
3867
+ const journalBindingKey = receipt.journalBindingKey;
3868
+ if (!journalBindingKey) {
3869
+ deps.staging.cancel(receipt);
3870
+ throw new Error(
3871
+ "Telegram queue handoff receipt omitted its journal binding.",
3872
+ );
3873
+ }
3874
+ if (
3875
+ deps.isTransportStampActive &&
3876
+ !deps.isTransportStampActive(envelope.payload.transportStamp)
3877
+ ) {
3878
+ deps.staging.cancel(receipt);
3879
+ throw new Error(
3880
+ "Telegram queue handoff payload belongs to an inactive transport generation.",
3881
+ );
3882
+ }
3883
+ const lifecycle = deps.getLifecycleForBinding(journalBindingKey);
3884
+ if (!lifecycle) {
3885
+ deps.staging.cancel(receipt);
3886
+ throw new Error(
3887
+ "Telegram queue handoff journal binding is not active.",
3888
+ );
3889
+ }
3890
+ const donorOwner: TelegramUpdateJournalQueueOwner = {
3891
+ instanceId: envelope.donorInstanceId,
3892
+ processId: envelope.donorProcessId,
3893
+ processBirthId: envelope.donorProcessBirthId,
3894
+ sessionGeneration: envelope.donorSessionGeneration,
3895
+ acquisitionId: envelope.donorAcquisitionId,
3896
+ acquiredAtMs: envelope.donorAcquiredAtMs,
3897
+ };
3898
+ let accepted: TelegramUpdateJournalQueueHandoffAcceptResult;
3899
+ try {
3900
+ accepted = lifecycle.acceptQueueReceiptHandoff({
3901
+ queueKind: receipt.queueKind,
3902
+ receiptId: receipt.receiptId,
3903
+ sourceUpdateIds: receipt.sourceUpdateIds,
3904
+ expectedOwner: donorOwner,
3905
+ recipientOwner: deps.getRecipientOwner(),
3906
+ handoffToken: envelope.handoffToken,
3907
+ });
3908
+ await lifecycle.publishAcceptedQueueReceipt({
3909
+ receipt,
3910
+ queueOwner: accepted.queueOwner,
3911
+ ctx,
3912
+ });
3913
+ if (!deps.staging.accept(receipt)) {
3914
+ throw new Error(
3915
+ "Telegram queue handoff payload disappeared before readiness publication.",
3916
+ );
3917
+ }
3918
+ } catch (error) {
3919
+ deps.staging.cancel(receipt);
3920
+ throw error;
3921
+ }
3922
+ deps.dispatchNext(ctx);
3923
+ return { ...stage, queueOwner: { ...accepted.queueOwner } };
3924
+ };
3925
+ }
3926
+
3927
+ export interface TelegramQueueHandoffReconcilerDeps<TContext> {
3928
+ ownsDirect: () => boolean;
3929
+ isFollowerRegistered: () => boolean;
3930
+ isBusEnabled: () => boolean;
3931
+ canHandoffWithLeader?: () => boolean;
3932
+ listFollowers: () => readonly TelegramBusFollowerView[];
3933
+ createRecipientJournalBindingKey: (
3934
+ recipient: TelegramBusFollowerView,
3935
+ ) => string | undefined;
3936
+ getQueuedItems: () => readonly TelegramQueueItem<TContext>[];
3937
+ getReceiptOwner: (
3938
+ receipt: TelegramQueueAdmissionReceipt,
3939
+ ) => TelegramUpdateJournalQueueOwner | undefined;
3940
+ getLifecycleForReceipt: (
3941
+ receipt: TelegramQueueAdmissionReceipt,
3942
+ ) => TelegramUpdateAdmissionLifecycleRuntime<TContext> | undefined;
3943
+ createHandoffToken: () => string;
3944
+ createRequestId: () => string;
3945
+ donorInstanceId: string;
3946
+ authSecret?: string;
3947
+ stageThroughFollower: (input: {
3948
+ recipient: TelegramBusFollowerView;
3949
+ expectedOwner: TelegramUpdateJournalQueueOwner;
3950
+ handoffToken: string;
3951
+ payload: TelegramQueueHandoffPayload;
3952
+ }) => Promise<TelegramQueueHandoffStageResult>;
3953
+ routeThroughLeader: (input: {
3954
+ requestId: string;
3955
+ auth?: string;
3956
+ recipientInstanceId: string;
3957
+ recipientRegistrationGeneration: string;
3958
+ donorInstanceId: string;
3959
+ donorProcessId: number;
3960
+ donorProcessBirthId: string;
3961
+ donorSessionGeneration: number;
3962
+ donorAcquisitionId: string;
3963
+ donorAcquiredAtMs: number;
3964
+ handoffToken: string;
3965
+ payload: TelegramQueueHandoffPayload;
3966
+ sentAtMs: number;
3967
+ }) => Promise<TelegramBusEnvelope>;
3968
+ removeDonorItem: (
3969
+ receipt: TelegramQueueAdmissionReceipt,
3970
+ ctx: TContext,
3971
+ ) => boolean;
3972
+ recordFailure?: (
3973
+ error: unknown,
3974
+ details: Record<string, unknown>,
3975
+ ) => void;
3976
+ }
3977
+
3978
+ export interface TelegramQueueHandoffReconciliationRuntimeAssemblyDeps<
3979
+ TContext,
3980
+ > {
3981
+ ownsDirect: () => boolean;
3982
+ isFollowerRegistered: () => boolean;
3983
+ isBusEnabled: () => boolean;
3984
+ canHandoffWithLeader?: () => boolean;
3985
+ listFollowers: () => readonly TelegramBusFollowerView[];
3986
+ createRecipientJournalResolver: (
3987
+ profileKey: string,
3988
+ ) => (() => { recoveryKey: string } | undefined);
3989
+ queueStore: {
3990
+ getQueuedItems: () => TelegramQueueItem<TContext>[];
3991
+ setQueuedItems: (items: TelegramQueueItem<TContext>[]) => void;
3992
+ };
3993
+ admission: Pick<
3994
+ TelegramUpdateAdmissionRuntimeBinding<TContext>,
3995
+ "getSettlement" | "getLifecycleForJournalBinding"
3996
+ >;
3997
+ createHandoffToken: () => string;
3998
+ createRequestId: () => string;
3999
+ donorInstanceId: string;
4000
+ authSecret?: string;
4001
+ stageThroughFollower: (input: {
4002
+ recipientInstanceId: string;
4003
+ recipientRegistrationGeneration: string;
4004
+ donorProcessId: number;
4005
+ donorProcessBirthId: string;
4006
+ donorSessionGeneration: number;
4007
+ donorAcquisitionId: string;
4008
+ donorAcquiredAtMs: number;
4009
+ handoffToken: string;
4010
+ payload: TelegramQueueHandoffPayload;
4011
+ }) => Promise<TelegramQueueHandoffStageResult>;
4012
+ routeThroughLeader: TelegramQueueHandoffReconcilerDeps<TContext>["routeThroughLeader"];
4013
+ recordRuntimeEvent?: (
4014
+ category: string,
4015
+ error: unknown,
4016
+ details?: Record<string, unknown>,
4017
+ ) => void;
4018
+ }
4019
+
4020
+ /** Own queue-handoff projections over journals, admission, IPC, and live queue state. */
4021
+ export function createTelegramQueueHandoffReconciliationRuntimeAssembly<
4022
+ TContext,
4023
+ >(
4024
+ deps: TelegramQueueHandoffReconciliationRuntimeAssemblyDeps<TContext>,
4025
+ ): (ctx: TContext) => Promise<void> {
4026
+ return createTelegramQueueHandoffReconciler({
4027
+ ownsDirect: deps.ownsDirect,
4028
+ isFollowerRegistered: deps.isFollowerRegistered,
4029
+ isBusEnabled: deps.isBusEnabled,
4030
+ canHandoffWithLeader: deps.canHandoffWithLeader,
4031
+ listFollowers: deps.listFollowers,
4032
+ createRecipientJournalBindingKey(recipient) {
4033
+ if (!recipient.profileKey) return undefined;
4034
+ return deps.createRecipientJournalResolver(recipient.profileKey)()
4035
+ ?.recoveryKey;
4036
+ },
4037
+ getQueuedItems: deps.queueStore.getQueuedItems,
4038
+ getReceiptOwner(receipt) {
4039
+ return deps.admission.getSettlement()?.getQueueReceiptOwner(receipt);
4040
+ },
4041
+ getLifecycleForReceipt(receipt) {
4042
+ const bindingKey = receipt.journalBindingKey;
4043
+ return bindingKey
4044
+ ? deps.admission.getLifecycleForJournalBinding(bindingKey)
4045
+ : undefined;
4046
+ },
4047
+ createHandoffToken: deps.createHandoffToken,
4048
+ createRequestId: deps.createRequestId,
4049
+ donorInstanceId: deps.donorInstanceId,
4050
+ authSecret: deps.authSecret,
4051
+ stageThroughFollower(input) {
4052
+ const registrationGeneration = input.recipient.registrationGeneration;
4053
+ if (!registrationGeneration) {
4054
+ throw new Error(
4055
+ "Telegram queue handoff recipient registration generation is unavailable.",
4056
+ );
4057
+ }
4058
+ return deps.stageThroughFollower({
4059
+ recipientInstanceId: input.recipient.instanceId,
4060
+ recipientRegistrationGeneration: registrationGeneration,
4061
+ donorProcessId: input.expectedOwner.processId,
4062
+ donorProcessBirthId: input.expectedOwner.processBirthId,
4063
+ donorSessionGeneration: input.expectedOwner.sessionGeneration,
4064
+ donorAcquisitionId: input.expectedOwner.acquisitionId,
4065
+ donorAcquiredAtMs: input.expectedOwner.acquiredAtMs,
4066
+ handoffToken: input.handoffToken,
4067
+ payload: input.payload,
4068
+ });
4069
+ },
4070
+ routeThroughLeader: deps.routeThroughLeader,
4071
+ removeDonorItem(receipt) {
4072
+ return removeTelegramQueueItemByReceipt({
4073
+ receipt,
4074
+ store: deps.queueStore,
4075
+ });
4076
+ },
4077
+ recordFailure(error, details) {
4078
+ deps.recordRuntimeEvent?.("inbound-worker", error, details);
4079
+ },
4080
+ });
4081
+ }
4082
+
4083
+ export function createTelegramQueueHandoffReconciler<TContext>(
4084
+ deps: TelegramQueueHandoffReconcilerDeps<TContext>,
4085
+ ): (ctx: TContext) => Promise<void> {
4086
+ let operation: Promise<void> | undefined;
4087
+ const reconcile = async (ctx: TContext): Promise<void> => {
4088
+ const followerRegistered = deps.isFollowerRegistered();
4089
+ if (
4090
+ (!deps.ownsDirect() && !followerRegistered) ||
4091
+ !deps.isBusEnabled() ||
4092
+ (followerRegistered && deps.canHandoffWithLeader?.() === false)
4093
+ ) {
4094
+ return;
4095
+ }
4096
+ const followers = deps.listFollowers().filter(
4097
+ (follower) => follower.instanceId !== deps.donorInstanceId,
4098
+ );
4099
+ if (followers.length === 0) return;
4100
+ for (const item of [...deps.getQueuedItems()]) {
4101
+ const target = item.target;
4102
+ const recipient = target
4103
+ ? followers.find(
4104
+ (candidate) =>
4105
+ candidate.target?.chatId === target.chatId &&
4106
+ candidate.target?.threadId === target.threadId,
4107
+ )
4108
+ : undefined;
4109
+ if (!recipient) continue;
4110
+ const receipt = item.admissionReceipts?.[0];
4111
+ if (!receipt || item.admissionReceipts?.length !== 1) continue;
4112
+ if (
4113
+ !receipt.journalBindingKey ||
4114
+ !getTelegramUpdateJournalBindingPath(receipt.journalBindingKey)
4115
+ ) {
4116
+ continue;
4117
+ }
4118
+ const recipientJournalBindingKey =
4119
+ deps.createRecipientJournalBindingKey(recipient);
4120
+ if (!recipientJournalBindingKey) continue;
4121
+ const recipientItem = structuredClone(item);
4122
+ recipientItem.admissionReceipts = [
4123
+ { ...receipt, journalBindingKey: recipientJournalBindingKey },
4124
+ ];
4125
+ const expectedOwner = deps.getReceiptOwner(receipt);
4126
+ const lifecycle = deps.getLifecycleForReceipt(receipt);
4127
+ if (
4128
+ !expectedOwner ||
4129
+ !lifecycle ||
4130
+ !recipient.registrationGeneration ||
4131
+ !recipient.pid ||
4132
+ !recipient.processBirthId ||
4133
+ !recipient.sessionGeneration
4134
+ ) {
4135
+ continue;
4136
+ }
4137
+ const handoffToken = deps.createHandoffToken();
4138
+ const result = await coordinateTelegramQueueHandoff({
4139
+ item,
4140
+ expectedOwner,
4141
+ recipientOwner: {
4142
+ instanceId: recipient.instanceId,
4143
+ processId: recipient.pid,
4144
+ processBirthId: recipient.processBirthId,
4145
+ sessionGeneration: recipient.sessionGeneration,
4146
+ },
4147
+ handoffToken,
4148
+ lifecycle,
4149
+ stageRemote: async () => {
4150
+ const payload = createTelegramQueueHandoff({
4151
+ handoffToken,
4152
+ item: recipientItem,
4153
+ }).payload;
4154
+ if (followerRegistered) {
4155
+ return deps.stageThroughFollower({
4156
+ recipient,
4157
+ expectedOwner,
4158
+ handoffToken,
4159
+ payload,
4160
+ });
4161
+ }
4162
+ const response = await deps.routeThroughLeader({
4163
+ requestId: deps.createRequestId(),
4164
+ auth: deps.authSecret,
4165
+ recipientInstanceId: recipient.instanceId,
4166
+ recipientRegistrationGeneration:
4167
+ recipient.registrationGeneration!,
4168
+ donorInstanceId: deps.donorInstanceId,
4169
+ donorProcessId: expectedOwner.processId,
4170
+ donorProcessBirthId: expectedOwner.processBirthId,
4171
+ donorSessionGeneration: expectedOwner.sessionGeneration,
4172
+ donorAcquisitionId: expectedOwner.acquisitionId,
4173
+ donorAcquiredAtMs: expectedOwner.acquiredAtMs,
4174
+ handoffToken,
4175
+ payload,
4176
+ sentAtMs: Date.now(),
4177
+ });
4178
+ const queueOwner =
4179
+ response.kind === "bus.ack" &&
4180
+ response.result &&
4181
+ typeof response.result === "object"
4182
+ ? parseTelegramUpdateJournalQueueOwner(
4183
+ (response.result as Record<string, unknown>).queueOwner,
4184
+ )
4185
+ : undefined;
4186
+ if (
4187
+ response.kind !== "bus.ack" ||
4188
+ !response.ok ||
4189
+ !response.result ||
4190
+ typeof response.result !== "object" ||
4191
+ !queueOwner
4192
+ ) {
4193
+ throw new Error(
4194
+ response.kind === "bus.ack"
4195
+ ? response.message ?? "Telegram queue handoff was rejected."
4196
+ : "Telegram queue handoff returned no acknowledgement.",
4197
+ );
4198
+ }
4199
+ return {
4200
+ ...(response.result as Omit<TelegramQueueHandoffStageResult, "queueOwner">),
4201
+ queueOwner,
4202
+ };
4203
+ },
4204
+ removeDonorItem: (exactReceipt) =>
4205
+ deps.removeDonorItem(exactReceipt, ctx),
4206
+ });
4207
+ if (result.status === "retained") {
4208
+ deps.recordFailure?.(result.error, {
4209
+ phase: "queue-handoff-retained",
4210
+ receiptId: result.receipt.receiptId,
4211
+ recipientInstanceId: recipient.instanceId,
4212
+ cancelled: result.cancelled,
4213
+ });
4214
+ }
4215
+ }
4216
+ };
4217
+ return (ctx) => {
4218
+ if (operation) return operation;
4219
+ const current = reconcile(ctx).finally(() => {
4220
+ if (operation === current) operation = undefined;
4221
+ });
4222
+ operation = current;
4223
+ return current;
4224
+ };
4225
+ }
4226
+
4227
+ export interface TelegramQueueMutationDependencyItem {
4228
+ chatId: number;
4229
+ target?: { chatId: number };
4230
+ replyToMessageId: number;
4231
+ sourceMessageIds?: readonly number[];
4232
+ }
4233
+
4234
+ export interface TelegramUpdateAdmissionLifecycleRuntimeDeps<TContext> {
4235
+ resolveBinding: () =>
4236
+ | TelegramUpdateAdmissionLifecycleJournalBinding
4237
+ | undefined;
4238
+ getQueueOwnerIdentity?: (
4239
+ ctx: TContext,
4240
+ ) => TelegramUpdateJournalQueueOwnerIdentity;
4241
+ createWorker: (
4242
+ journal: TelegramUpdateWorkerJournalPort,
4243
+ binding: TelegramUpdateAdmissionLifecycleJournalBinding,
4244
+ ) => TelegramUpdateWorkerRuntime<TContext>;
4245
+ recordRuntimeEvent?: TelegramUpdateWorkerRuntimeDeps<TContext>["recordRuntimeEvent"];
4246
+ }
4247
+
4248
+ export interface TelegramUpdateAdmissionLifecycleRuntime<TContext>
4249
+ extends TelegramQueueAdmissionSettlementRuntime<TContext> {
4250
+ onSessionStart: (ctx: TContext) => Promise<void>;
4251
+ onSessionShutdown: () => Promise<void>;
4252
+ onTransportChanged: (ctx?: TContext) => Promise<void>;
4253
+ appendBatch: (updates: readonly TelegramJournaledUpdate[]) => unknown;
4254
+ discardQueueReceipt: (input: {
4255
+ queueKind: "prompt" | "control";
4256
+ receiptId: string;
4257
+ sourceUpdateIds: readonly number[];
4258
+ expectedOwner: TelegramUpdateJournalQueueOwner;
4259
+ }) => TelegramUpdateJournalQueueDiscardResult;
4260
+ recoverDeadQueueReceipt: (input: {
4261
+ queueKind: "prompt" | "control";
4262
+ receiptId: string;
4263
+ sourceUpdateIds: readonly number[];
4264
+ deadOwner: TelegramUpdateJournalQueueOwner;
4265
+ recoveryOwner: TelegramUpdateJournalQueueOwnerIdentity;
4266
+ }) => TelegramUpdateJournalDeadQueueOwnerRecoveryResult;
4267
+ offerQueueReceiptHandoff: (
4268
+ input: TelegramUpdateJournalQueueHandoffInput,
4269
+ ) => TelegramUpdateJournalQueueHandoffOfferResult;
4270
+ acceptQueueReceiptHandoff: (
4271
+ input: TelegramUpdateJournalQueueHandoffInput,
4272
+ ) => TelegramUpdateJournalQueueHandoffAcceptResult;
4273
+ cancelQueueReceiptHandoff: (
4274
+ input: TelegramUpdateJournalQueueHandoffInput,
4275
+ ) => TelegramUpdateJournalQueueHandoffCancelResult;
4276
+ publishAcceptedQueueReceipt: (input: {
4277
+ receipt: TelegramQueueAdmissionReceiptLike;
4278
+ queueOwner: TelegramUpdateJournalQueueOwner;
4279
+ ctx: TContext;
4280
+ }) => Promise<void>;
4281
+ getQueueReceiptOwner: (
4282
+ receipt: TelegramQueueAdmissionReceiptLike,
4283
+ ) => TelegramUpdateJournalQueueOwner | undefined;
4284
+ getJournalBindingKey: () => string | undefined;
4285
+ getJournalPath: () => string | undefined;
4286
+ ownsJournalBinding: (journalBindingKey: string) => boolean;
4287
+ getJournalEntryCount: () => number;
4288
+ getForeignQueueOwnerLiveness: () => TelegramProcessLiveness | undefined;
4289
+ hasPendingQueueMutationForItem: (
4290
+ item: TelegramQueueMutationDependencyItem,
4291
+ ) => boolean;
4292
+ signal: () => void;
4293
+ getState: () => TelegramUpdateWorkerStateSnapshot | undefined;
4294
+ }
4295
+
4296
+ export interface TelegramUpdateWorkerOwnerRuntime<TContext> {
4297
+ getQueueOwnerIdentity: () => TelegramUpdateJournalQueueOwnerIdentity;
4298
+ onQueueReceiptCommitted: (receipt: unknown, ctx: TContext) => void;
4299
+ onUpdateCompleted: (updateId: number, ctx: TContext) => void;
4300
+ }
4301
+
4302
+ export interface TelegramUpdateWorkerOwnerRuntimeDeps<TContext> {
4303
+ instanceId: string;
4304
+ processId: number;
4305
+ processBirthId: string;
4306
+ getSessionGeneration: () => number;
4307
+ isContextCurrent: (ctx: TContext) => boolean;
4308
+ dispatchNext: (ctx: TContext) => void;
4309
+ requestQueueHandoffReconciliation: (ctx: TContext) => void;
4310
+ }
4311
+
4312
+ export function createTelegramUpdateWorkerOwnerRuntime<TContext>(
4313
+ deps: TelegramUpdateWorkerOwnerRuntimeDeps<TContext>,
4314
+ ): TelegramUpdateWorkerOwnerRuntime<TContext> {
4315
+ return {
4316
+ getQueueOwnerIdentity() {
4317
+ return {
4318
+ instanceId: deps.instanceId,
4319
+ processId: deps.processId,
4320
+ processBirthId: deps.processBirthId,
4321
+ sessionGeneration: deps.getSessionGeneration(),
4322
+ };
4323
+ },
4324
+ onQueueReceiptCommitted(_receipt, ctx) {
4325
+ if (!deps.isContextCurrent(ctx)) return;
4326
+ deps.dispatchNext(ctx);
4327
+ deps.requestQueueHandoffReconciliation(ctx);
4328
+ },
4329
+ onUpdateCompleted(_updateId, ctx) {
4330
+ if (deps.isContextCurrent(ctx)) deps.dispatchNext(ctx);
4331
+ },
4332
+ };
4333
+ }
4334
+
4335
+ export interface TelegramUpdateAdmissionRuntimeBinding<TContext> {
4336
+ bind: (input: {
4337
+ leader: TelegramUpdateAdmissionLifecycleRuntime<TContext>;
4338
+ follower: TelegramUpdateAdmissionLifecycleRuntime<TContext>;
4339
+ }) => void;
4340
+ getLeader: () => TelegramUpdateAdmissionLifecycleRuntime<TContext> | undefined;
4341
+ getFollower: () => TelegramUpdateAdmissionLifecycleRuntime<TContext> | undefined;
4342
+ getActive: () => TelegramUpdateAdmissionLifecycleRuntime<TContext> | undefined;
4343
+ getSettlement: () => TelegramQueueAdmissionSettlementRuntime<TContext> | undefined;
4344
+ getLifecycleForJournalBinding: (
4345
+ journalBindingKey: string,
4346
+ ) => TelegramUpdateAdmissionLifecycleRuntime<TContext> | undefined;
4347
+ hasPendingQueueMutationForItem: (
4348
+ item: TelegramQueueMutationDependencyItem,
4349
+ ) => boolean;
4350
+ onSessionShutdown: () => Promise<void>;
4351
+ }
4352
+
4353
+ export function createTelegramUpdateAdmissionRuntimeBinding<TContext>(deps: {
4354
+ isFollowerRegistered: () => boolean;
4355
+ }): TelegramUpdateAdmissionRuntimeBinding<TContext> {
4356
+ let leader: TelegramUpdateAdmissionLifecycleRuntime<TContext> | undefined;
4357
+ let follower: TelegramUpdateAdmissionLifecycleRuntime<TContext> | undefined;
4358
+ let settlement: TelegramQueueAdmissionSettlementRuntime<TContext> | undefined;
4359
+ return {
4360
+ bind(input) {
4361
+ leader = input.leader;
4362
+ follower = input.follower;
4363
+ settlement = createTelegramQueueAdmissionSettlementMuxRuntime([
4364
+ leader,
4365
+ follower,
4366
+ ]);
4367
+ },
4368
+ getLeader: () => leader,
4369
+ getFollower: () => follower,
4370
+ getActive: () => (deps.isFollowerRegistered() ? follower : leader),
4371
+ getSettlement: () => settlement,
4372
+ getLifecycleForJournalBinding(journalBindingKey) {
4373
+ if (follower?.ownsJournalBinding(journalBindingKey)) return follower;
4374
+ return leader?.ownsJournalBinding(journalBindingKey) ? leader : undefined;
4375
+ },
4376
+ hasPendingQueueMutationForItem(item) {
4377
+ return Boolean(
4378
+ leader?.hasPendingQueueMutationForItem(item) ||
4379
+ follower?.hasPendingQueueMutationForItem(item),
4380
+ );
4381
+ },
4382
+ async onSessionShutdown() {
4383
+ await Promise.all([
4384
+ leader?.onSessionShutdown(),
4385
+ follower?.onSessionShutdown(),
4386
+ ]);
4387
+ },
4388
+ };
4389
+ }
4390
+
4391
+ function isTelegramReactionDependencyForQueueItem(
4392
+ update: TelegramJournaledUpdate,
4393
+ item: TelegramQueueMutationDependencyItem,
4394
+ ): boolean {
4395
+ const reaction = update.message_reaction;
4396
+ if (!isTelegramUpdateAdmissionRecord(reaction)) return false;
4397
+ const chat = reaction.chat;
4398
+ if (
4399
+ !isTelegramUpdateAdmissionRecord(chat) ||
4400
+ !Number.isSafeInteger(chat.id) ||
4401
+ !Number.isSafeInteger(reaction.message_id)
4402
+ ) {
4403
+ return false;
4404
+ }
4405
+ const itemMessageIds = new Set([
4406
+ item.replyToMessageId,
4407
+ ...(item.sourceMessageIds ?? []),
4408
+ ]);
4409
+ return (
4410
+ chat.id === (item.target?.chatId ?? item.chatId) &&
4411
+ itemMessageIds.has(reaction.message_id as number)
4412
+ );
4413
+ }
4414
+
4415
+ /** Own one worker per active transport identity without assuming queued-owner death. */
4416
+ export function createTelegramUpdateAdmissionLifecycleRuntime<TContext>(
4417
+ deps: TelegramUpdateAdmissionLifecycleRuntimeDeps<TContext>,
4418
+ ): TelegramUpdateAdmissionLifecycleRuntime<TContext> {
4419
+ let activeRuntimeKey: string | undefined;
4420
+ let journal: TelegramUpdateAdmissionLifecycleJournalBinding["journal"] | undefined;
4421
+ let worker: TelegramUpdateWorkerRuntime<TContext> | undefined;
4422
+ let settlement: TelegramQueueAdmissionSettlementRuntime<TContext> | undefined;
4423
+ let journalBindingKey: string | undefined;
4424
+ let operation: Promise<void> = Promise.resolve();
4425
+ let foreignQueueOwnerLiveness: TelegramProcessLiveness | undefined;
4426
+
4427
+ const stopCurrent = async (forget: boolean): Promise<void> => {
4428
+ await worker?.stop();
4429
+ if (!forget) return;
4430
+ journal = undefined;
4431
+ worker = undefined;
4432
+ settlement = undefined;
4433
+ journalBindingKey = undefined;
4434
+ activeRuntimeKey = undefined;
4435
+ foreignQueueOwnerLiveness = undefined;
4436
+ };
4437
+ const bind = async (ctx: TContext): Promise<void> => {
4438
+ const binding = deps.resolveBinding();
4439
+ if (!binding) {
4440
+ await stopCurrent(true);
4441
+ return;
4442
+ }
4443
+ if (!worker || activeRuntimeKey !== binding.runtimeKey) {
4444
+ await stopCurrent(true);
4445
+ journal = binding.journal;
4446
+ worker = deps.createWorker(binding.journal, binding);
4447
+ settlement = createTelegramQueueAdmissionSettlementRuntime(worker);
4448
+ journalBindingKey = binding.recoveryKey;
4449
+ activeRuntimeKey = binding.runtimeKey;
4450
+ }
4451
+ if (binding.journal.applyOperatorDisposition) {
4452
+ for (const entry of binding.journal.read().entries) {
4453
+ if (entry.state !== "failed" || !entry.terminalFailureId) continue;
4454
+ const result = binding.journal.applyOperatorDisposition({
4455
+ action: "retry",
4456
+ updateId: entry.updateId,
4457
+ failureId: entry.terminalFailureId,
4458
+ });
4459
+ if (result.duplicate) continue;
4460
+ try {
4461
+ deps.recordRuntimeEvent?.(
4462
+ "inbound-worker",
4463
+ "Resumed legacy terminal update under automatic retry policy.",
4464
+ {
4465
+ phase: "automatic-terminal-retry",
4466
+ updateId: entry.updateId,
4467
+ attemptCount: result.disposition.attemptCount,
4468
+ },
4469
+ );
4470
+ } catch {
4471
+ // Diagnostics cannot revoke the committed retry.
4472
+ }
4473
+ }
4474
+ }
4475
+ if (deps.getQueueOwnerIdentity && binding.journal.recoverDeadQueueOwner) {
4476
+ const recoveryOwner = deps.getQueueOwnerIdentity(ctx);
4477
+ const foreignReceipts = new Map<
4478
+ string,
4479
+ {
4480
+ queueKind: "prompt" | "control";
4481
+ sourceUpdateIds: number[];
4482
+ deadOwner: TelegramUpdateJournalQueueOwner;
4483
+ }
4484
+ >();
4485
+ for (const entry of binding.journal.read().entries) {
4486
+ if (
4487
+ entry.state !== "queued" ||
4488
+ !entry.queueKind ||
4489
+ !entry.queueReceiptId ||
4490
+ !entry.queueOwner ||
4491
+ isTelegramUpdateJournalQueueOwnerProcess(
4492
+ entry.queueOwner,
4493
+ recoveryOwner,
4494
+ )
4495
+ ) {
4496
+ continue;
4497
+ }
4498
+ const receipt = foreignReceipts.get(entry.queueReceiptId);
4499
+ if (receipt) receipt.sourceUpdateIds.push(entry.updateId);
4500
+ else {
4501
+ foreignReceipts.set(entry.queueReceiptId, {
4502
+ queueKind: entry.queueKind,
4503
+ sourceUpdateIds: [entry.updateId],
4504
+ deadOwner: { ...entry.queueOwner },
4505
+ });
4506
+ }
4507
+ }
4508
+ for (const [receiptId, receipt] of foreignReceipts) {
4509
+ const result = binding.journal.recoverDeadQueueOwner({
4510
+ queueKind: receipt.queueKind,
4511
+ receiptId,
4512
+ sourceUpdateIds: receipt.sourceUpdateIds,
4513
+ deadOwner: receipt.deadOwner,
4514
+ recoveryOwner,
4515
+ });
4516
+ foreignQueueOwnerLiveness =
4517
+ result.status === "recovered"
4518
+ ? "dead"
4519
+ : result.status === "owner-alive"
4520
+ ? "alive"
4521
+ : "unverifiable";
4522
+ if (result.status !== "recovered") continue;
4523
+ try {
4524
+ deps.recordRuntimeEvent?.(
4525
+ "inbound-worker",
4526
+ "Recovered queued authority from a confirmed-dead process.",
4527
+ {
4528
+ phase: "dead-queue-owner-recovery",
4529
+ receiptId,
4530
+ recoveredUpdateCount: result.recoveredUpdateIds.length,
4531
+ },
4532
+ );
4533
+ } catch {
4534
+ // Diagnostics cannot revoke the committed recovery.
4535
+ }
4536
+ }
4537
+ }
4538
+ worker.start(ctx);
4539
+ };
4540
+ const runExclusive = (task: () => Promise<void>): Promise<void> => {
4541
+ const next = operation.then(task, task);
4542
+ operation = next.catch(() => undefined);
4543
+ return next;
4544
+ };
4545
+ return {
4546
+ onSessionStart: (ctx) => runExclusive(() => bind(ctx)),
4547
+ onSessionShutdown: () => runExclusive(() => stopCurrent(false)),
4548
+ onTransportChanged: (ctx) =>
4549
+ runExclusive(async () => {
4550
+ await stopCurrent(true);
4551
+ if (ctx !== undefined) await bind(ctx);
4552
+ }),
4553
+ appendBatch(updates) {
4554
+ if (!journal || !worker) {
4555
+ throw new Error("Telegram update admission worker is not active.");
4556
+ }
4557
+ return journal.appendBatch(updates);
4558
+ },
4559
+ discardQueueReceipt(input) {
4560
+ if (!journal || !worker || !journal.discardQueued) {
4561
+ throw new Error(
4562
+ "Telegram update journal queue discard is not available.",
4563
+ );
4564
+ }
4565
+ const result = journal.discardQueued(input);
4566
+ worker.signal();
4567
+ return result;
4568
+ },
4569
+ recoverDeadQueueReceipt(input) {
4570
+ if (!journal || !worker || !journal.recoverDeadQueueOwner) {
4571
+ throw new Error(
4572
+ "Telegram update journal dead-owner recovery is not available.",
4573
+ );
4574
+ }
4575
+ const result = journal.recoverDeadQueueOwner(input);
4576
+ worker.signal();
4577
+ return result;
4578
+ },
4579
+ offerQueueReceiptHandoff(input) {
4580
+ if (!journal || !worker || !journal.offerQueuedHandoff) {
4581
+ throw new Error(
4582
+ "Telegram update journal queue handoff offer is not available.",
4583
+ );
4584
+ }
4585
+ const result = journal.offerQueuedHandoff(input);
4586
+ worker.signal();
4587
+ return result;
4588
+ },
4589
+ acceptQueueReceiptHandoff(input) {
4590
+ if (!journal || !worker || !journal.acceptQueuedHandoff) {
4591
+ throw new Error(
4592
+ "Telegram update journal queue handoff acceptance is not available.",
4593
+ );
4594
+ }
4595
+ return journal.acceptQueuedHandoff(input);
4596
+ },
4597
+ cancelQueueReceiptHandoff(input) {
4598
+ if (!journal || !worker || !journal.cancelQueuedHandoff) {
4599
+ throw new Error(
4600
+ "Telegram update journal queue handoff cancellation is not available.",
4601
+ );
4602
+ }
4603
+ const result = journal.cancelQueuedHandoff(input);
4604
+ worker.signal();
4605
+ return result;
4606
+ },
4607
+ async publishAcceptedQueueReceipt(input) {
4608
+ if (!worker || !journal) {
4609
+ throw new Error("Telegram update admission worker is not active.");
4610
+ }
4611
+ const entry = journal
4612
+ .read()
4613
+ .entries.find(
4614
+ (candidate) =>
4615
+ candidate.state === "queued" &&
4616
+ candidate.queueReceiptId === input.receipt.receiptId &&
4617
+ candidate.queueOwner?.acquisitionId ===
4618
+ input.queueOwner.acquisitionId,
4619
+ );
4620
+ if (!entry) {
4621
+ throw new Error(
4622
+ `Telegram queue handoff receipt ${input.receipt.receiptId} is not owned by this journal.`,
4623
+ );
4624
+ }
4625
+ worker.signal();
4626
+ await worker.waitForDrain();
4627
+ const currentOwner = worker.getQueueReceiptOwner(input.receipt);
4628
+ if (
4629
+ !currentOwner ||
4630
+ !areTelegramUpdateJournalQueueOwnersEqual(
4631
+ currentOwner,
4632
+ input.queueOwner,
4633
+ )
4634
+ ) {
4635
+ throw new Error(
4636
+ `Telegram queue handoff receipt ${input.receipt.receiptId} is not owned by this runtime.`,
4637
+ );
4638
+ }
4639
+ },
4640
+ getQueueReceiptOwner(receipt) {
4641
+ if (
4642
+ !journalBindingKey ||
4643
+ receipt.journalBindingKey !== journalBindingKey
4644
+ ) {
4645
+ return undefined;
4646
+ }
4647
+ return worker?.getQueueReceiptOwner(receipt);
4648
+ },
4649
+ getJournalBindingKey: () => journalBindingKey,
4650
+ getJournalPath: () =>
4651
+ journalBindingKey
4652
+ ? getTelegramUpdateJournalBindingPath(journalBindingKey)
4653
+ : undefined,
4654
+ ownsJournalBinding: (candidate) =>
4655
+ journalBindingKey !== undefined && journalBindingKey === candidate,
4656
+ getForeignQueueOwnerLiveness: () => foreignQueueOwnerLiveness,
4657
+ getJournalEntryCount() {
4658
+ if (!journal || !worker) {
4659
+ throw new Error("Telegram update admission worker is not active.");
4660
+ }
4661
+ return journal.read().entries.length;
4662
+ },
4663
+ hasPendingQueueMutationForItem(item) {
4664
+ return Boolean(
4665
+ journal &&
4666
+ worker &&
4667
+ journal
4668
+ .read()
4669
+ .entries.some(
4670
+ (entry) =>
4671
+ entry.state !== "queued" &&
4672
+ isTelegramReactionDependencyForQueueItem(
4673
+ entry.update,
4674
+ item,
4675
+ ),
4676
+ ),
4677
+ );
4678
+ },
4679
+ signal: () => worker?.signal(),
4680
+ getState: () => {
4681
+ const state = worker?.getState();
4682
+ if (!state) return undefined;
4683
+ return {
4684
+ ...state,
4685
+ ...(state.foreignQueuedOwner && foreignQueueOwnerLiveness
4686
+ ? { foreignQueuedOwnerLiveness: foreignQueueOwnerLiveness }
4687
+ : {}),
4688
+ };
4689
+ },
4690
+ isItemReady: (item) =>
4691
+ settlement?.isItemReady(item) ??
4692
+ (item.admissionReceipts?.length ?? 0) === 0,
4693
+ onPromptHandedOff: (item, ctx) =>
4694
+ settlement?.onPromptHandedOff(item, ctx),
4695
+ onControlSettled: (item, ctx) =>
4696
+ settlement?.onControlSettled(item, ctx),
4697
+ onItemsDiscarded: (items, ctx) =>
4698
+ settlement?.onItemsDiscarded(items, ctx),
4699
+ };
4700
+ }
4701
+
4702
+ export interface TelegramUpdateAdmissionLifecycleAssembly<TContext> {
4703
+ leader: TelegramUpdateAdmissionLifecycleRuntime<TContext>;
4704
+ follower: TelegramUpdateAdmissionLifecycleRuntime<TContext>;
4705
+ }
4706
+
4707
+ export interface TelegramUpdateAdmissionLifecycleAssemblyDeps<
4708
+ TUpdate extends TelegramJournaledUpdate & TelegramUpdateFlow,
4709
+ TContext,
4710
+ > {
4711
+ runtimeBinding: TelegramUpdateAdmissionRuntimeBinding<TContext>;
4712
+ worker: Omit<
4713
+ TelegramUpdateAdmissionWorkerRuntimeDeps<TUpdate, TContext>,
4714
+ | "journal"
4715
+ | "getJournalBindingKey"
4716
+ | "hasAuthority"
4717
+ | "prepareUpdateForExecution"
4718
+ >;
4719
+ leader: {
4720
+ resolveBinding: () =>
4721
+ | TelegramUpdateAdmissionLifecycleJournalBinding
4722
+ | undefined;
4723
+ hasAuthority: (ctx: TContext) => boolean;
4724
+ };
4725
+ follower: {
4726
+ resolveBinding: () =>
4727
+ | TelegramUpdateAdmissionLifecycleJournalBinding
4728
+ | undefined;
4729
+ isRegistered: () => boolean;
4730
+ getGeneration: () => string | undefined;
4731
+ prepareUpdateForExecution: (update: TUpdate) => TUpdate;
4732
+ };
4733
+ recordRuntimeEvent?: TelegramUpdateWorkerRuntimeDeps<TContext>["recordRuntimeEvent"];
4734
+ }
4735
+
4736
+ export type TelegramUpdateAdmissionWorkerRuntimeDeps<
4737
+ TUpdate extends TelegramJournaledUpdate & TelegramUpdateFlow,
4738
+ TContext,
4739
+ > = Omit<TelegramUpdateWorkerRuntimeDeps<TContext>, "executeUpdate"> & {
4740
+ defaultHandle: (
4741
+ update: TUpdate,
4742
+ ctx: TContext,
4743
+ execution?: TelegramUpdateExecutionFence,
4744
+ ) => Promise<void>;
4745
+ prepareUpdateForExecution?: (update: TUpdate) => TUpdate;
4746
+ registry?: TelegramUpdateHandlerRegistry;
4747
+ };
4748
+
4749
+ /** Compose source-bound routing and late grouped settlement under one worker. */
4750
+ export function createTelegramUpdateAdmissionWorkerRuntime<
4751
+ TUpdate extends TelegramJournaledUpdate & TelegramUpdateFlow,
4752
+ TContext,
4753
+ >(
4754
+ deps: TelegramUpdateAdmissionWorkerRuntimeDeps<TUpdate, TContext>,
4755
+ ): TelegramUpdateWorkerRuntime<TContext> {
4756
+ let worker: TelegramUpdateWorkerRuntime<TContext> | undefined;
4757
+ const executeUpdate = createTelegramUpdateAdmissionHandle<TUpdate, TContext>({
4758
+ defaultHandle: deps.defaultHandle,
4759
+ registry: deps.registry,
4760
+ onLateOutcome(outcome, details) {
4761
+ worker?.settleDeferred({
4762
+ updateId: details.updateId,
4763
+ outcome,
4764
+ signal: details.signal,
4765
+ });
4766
+ },
4767
+ onLateOutcomeError(error, updateId) {
4768
+ deps.recordRuntimeEvent?.("inbound-worker", error, {
4769
+ phase: "late-admission-handler",
4770
+ updateId,
4771
+ });
4772
+ },
4773
+ });
4774
+ worker = createTelegramUpdateWorkerRuntime({
4775
+ ...deps,
4776
+ executeUpdate: (update, ctx, signal) => {
4777
+ const typedUpdate = update as TUpdate;
4778
+ return executeUpdate(
4779
+ deps.prepareUpdateForExecution?.(typedUpdate) ?? typedUpdate,
4780
+ ctx,
4781
+ signal,
4782
+ );
4783
+ },
4784
+ });
4785
+ return worker;
4786
+ }
4787
+
4788
+ /** Own leader/follower journal lifecycle construction and generation fencing. */
4789
+ export function createTelegramUpdateAdmissionLifecycleAssembly<
4790
+ TUpdate extends TelegramJournaledUpdate & TelegramUpdateFlow,
4791
+ TContext,
4792
+ >(
4793
+ deps: TelegramUpdateAdmissionLifecycleAssemblyDeps<TUpdate, TContext>,
4794
+ ): TelegramUpdateAdmissionLifecycleAssembly<TContext> {
4795
+ const leader = createTelegramUpdateAdmissionLifecycleRuntime({
4796
+ resolveBinding: deps.leader.resolveBinding,
4797
+ getQueueOwnerIdentity: deps.worker.getQueueOwnerIdentity,
4798
+ createWorker(journal, binding) {
4799
+ return createTelegramUpdateAdmissionWorkerRuntime({
4800
+ ...deps.worker,
4801
+ journal,
4802
+ getJournalBindingKey: () => binding.recoveryKey,
4803
+ hasAuthority: deps.leader.hasAuthority,
4804
+ });
4805
+ },
4806
+ recordRuntimeEvent: deps.recordRuntimeEvent,
4807
+ });
4808
+ const follower = createTelegramUpdateAdmissionLifecycleRuntime({
4809
+ getQueueOwnerIdentity: deps.worker.getQueueOwnerIdentity,
4810
+ resolveBinding() {
4811
+ const generation = deps.follower.getGeneration();
4812
+ if (!deps.follower.isRegistered() || !generation) return undefined;
4813
+ const binding = deps.follower.resolveBinding();
4814
+ if (!binding) return undefined;
4815
+ return {
4816
+ ...binding,
4817
+ runtimeKey: `${binding.runtimeKey}\u0000${generation}`,
4818
+ hasAuthority() {
4819
+ return (
4820
+ deps.follower.isRegistered() &&
4821
+ deps.follower.getGeneration() === generation
4822
+ );
4823
+ },
4824
+ };
4825
+ },
4826
+ createWorker(journal, binding) {
4827
+ return createTelegramUpdateAdmissionWorkerRuntime({
4828
+ ...deps.worker,
4829
+ journal,
4830
+ getJournalBindingKey: () => binding.recoveryKey,
4831
+ hasAuthority: () => binding.hasAuthority?.() ?? false,
4832
+ prepareUpdateForExecution: deps.follower.prepareUpdateForExecution,
4833
+ });
4834
+ },
4835
+ recordRuntimeEvent: deps.recordRuntimeEvent,
4836
+ });
4837
+ deps.runtimeBinding.bind({ leader, follower });
4838
+ return { leader, follower };
4839
+ }
4840
+
4841
+ export interface TelegramUpdateAdmissionRuntimeAssembly<TContext>
4842
+ extends TelegramUpdateAdmissionLifecycleAssembly<TContext> {
4843
+ owner: TelegramUpdateWorkerOwnerRuntime<TContext>;
4844
+ }
4845
+
4846
+ export type TelegramUpdateAdmissionRuntimeAssemblyDeps<
4847
+ TUpdate extends TelegramJournaledUpdate & TelegramUpdateFlow,
4848
+ TContext,
4849
+ > = Omit<
4850
+ TelegramUpdateAdmissionLifecycleAssemblyDeps<TUpdate, TContext>,
4851
+ "worker" | "recordRuntimeEvent"
4852
+ > & {
4853
+ owner: TelegramUpdateWorkerOwnerRuntimeDeps<TContext>;
4854
+ worker: Omit<
4855
+ TelegramUpdateAdmissionLifecycleAssemblyDeps<TUpdate, TContext>["worker"],
4856
+ | keyof TelegramUpdateWorkerOwnerRuntime<TContext>
4857
+ | "isContextCurrent"
4858
+ | "recordRuntimeEvent"
4859
+ >;
4860
+ recordRuntimeEvent?: TelegramUpdateWorkerRuntimeDeps<TContext>["recordRuntimeEvent"];
4861
+ };
4862
+
4863
+ /** Own queue-owner projection and shared leader/follower worker composition. */
4864
+ export function createTelegramUpdateAdmissionRuntimeAssembly<
4865
+ TUpdate extends TelegramJournaledUpdate & TelegramUpdateFlow,
4866
+ TContext,
4867
+ >(
4868
+ deps: TelegramUpdateAdmissionRuntimeAssemblyDeps<TUpdate, TContext>,
4869
+ ): TelegramUpdateAdmissionRuntimeAssembly<TContext> {
4870
+ const owner = createTelegramUpdateWorkerOwnerRuntime(deps.owner);
4871
+ const lifecycle = createTelegramUpdateAdmissionLifecycleAssembly({
4872
+ runtimeBinding: deps.runtimeBinding,
4873
+ worker: {
4874
+ ...deps.worker,
4875
+ ...owner,
4876
+ isContextCurrent: deps.owner.isContextCurrent,
4877
+ recordRuntimeEvent: deps.recordRuntimeEvent,
4878
+ },
4879
+ leader: deps.leader,
4880
+ follower: deps.follower,
4881
+ recordRuntimeEvent: deps.recordRuntimeEvent,
4882
+ });
4883
+ return { owner, ...lifecycle };
4884
+ }
4885
+
1338
4886
  /**
1339
4887
  * Register a handler that runs before pi-telegram routes a Telegram update
1340
4888
  * through its built-in handlers.