@llblab/pi-telegram 0.52.1 → 0.53.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.
Files changed (204) hide show
  1. package/AGENTS.md +10 -8
  2. package/BACKLOG.md +1 -16
  3. package/CHANGELOG.md +17 -0
  4. package/README.md +8 -10
  5. package/api/commands.ts +3 -0
  6. package/dist/api/commands.d.ts +1 -1
  7. package/dist/lib/activity-verbosity.d.ts +1 -5
  8. package/dist/lib/activity-verbosity.js +15 -42
  9. package/dist/lib/activity.d.ts +9 -0
  10. package/dist/lib/activity.js +39 -10
  11. package/dist/lib/agent-messages.js +7 -3
  12. package/dist/lib/bindings.d.ts +43 -5
  13. package/dist/lib/bindings.js +163 -20
  14. package/dist/lib/bus-api.d.ts +12 -0
  15. package/dist/lib/bus-api.js +182 -79
  16. package/dist/lib/bus-follower.d.ts +111 -31
  17. package/dist/lib/bus-follower.js +1324 -359
  18. package/dist/lib/bus-leader.d.ts +41 -91
  19. package/dist/lib/bus-leader.js +969 -637
  20. package/dist/lib/bus-transport.js +16 -9
  21. package/dist/lib/bus.d.ts +215 -18
  22. package/dist/lib/bus.js +1048 -327
  23. package/dist/lib/channel-posts.d.ts +5 -0
  24. package/dist/lib/channel-posts.js +434 -261
  25. package/dist/lib/command-templates.d.ts +26 -5
  26. package/dist/lib/command-templates.js +92 -176
  27. package/dist/lib/commands.d.ts +168 -82
  28. package/dist/lib/commands.js +1409 -291
  29. package/dist/lib/config.d.ts +1 -8
  30. package/dist/lib/config.js +34 -76
  31. package/dist/lib/delivery.d.ts +2 -2
  32. package/dist/lib/delivery.js +16 -16
  33. package/dist/lib/extension.js +640 -171
  34. package/dist/lib/generative-app-worker.mjs +19 -7
  35. package/dist/lib/generative-apps.d.ts +0 -5
  36. package/dist/lib/generative-apps.js +61 -49
  37. package/dist/lib/inbound.d.ts +5 -0
  38. package/dist/lib/inbound.js +35 -59
  39. package/dist/lib/journal.d.ts +33 -3
  40. package/dist/lib/journal.js +2063 -782
  41. package/dist/lib/lifecycle.d.ts +2 -6
  42. package/dist/lib/lifecycle.js +75 -58
  43. package/dist/lib/locks.d.ts +34 -3
  44. package/dist/lib/locks.js +318 -119
  45. package/dist/lib/logging.js +50 -14
  46. package/dist/lib/media.d.ts +22 -1
  47. package/dist/lib/media.js +46 -28
  48. package/dist/lib/menu-model.d.ts +19 -5
  49. package/dist/lib/menu-model.js +53 -11
  50. package/dist/lib/menu-queue.d.ts +5 -4
  51. package/dist/lib/menu-queue.js +45 -63
  52. package/dist/lib/menu-settings.d.ts +5 -4
  53. package/dist/lib/menu-settings.js +114 -78
  54. package/dist/lib/menu-status.d.ts +1 -1
  55. package/dist/lib/menu-status.js +8 -25
  56. package/dist/lib/menu-thinking.d.ts +6 -1
  57. package/dist/lib/menu-thinking.js +14 -21
  58. package/dist/lib/menu.d.ts +8 -61
  59. package/dist/lib/menu.js +92 -164
  60. package/dist/lib/model.d.ts +1 -1
  61. package/dist/lib/model.js +2 -16
  62. package/dist/lib/outbound-attachments.d.ts +6 -27
  63. package/dist/lib/outbound-attachments.js +45 -38
  64. package/dist/lib/outbound-buttons.js +22 -16
  65. package/dist/lib/outbound-markup.d.ts +0 -2
  66. package/dist/lib/outbound-markup.js +51 -64
  67. package/dist/lib/outbound-voice.js +6 -3
  68. package/dist/lib/outbound.d.ts +6 -6
  69. package/dist/lib/outbound.js +61 -51
  70. package/dist/lib/ownership.js +6 -2
  71. package/dist/lib/paths.d.ts +5 -0
  72. package/dist/lib/paths.js +37 -21
  73. package/dist/lib/pi.d.ts +2 -0
  74. package/dist/lib/pi.js +8 -1
  75. package/dist/lib/polling.d.ts +23 -37
  76. package/dist/lib/polling.js +71 -52
  77. package/dist/lib/preview.js +39 -19
  78. package/dist/lib/process-identity.d.ts +24 -0
  79. package/dist/lib/process-identity.js +133 -0
  80. package/dist/lib/prompts.js +2 -7
  81. package/dist/lib/queue.d.ts +14 -16
  82. package/dist/lib/queue.js +249 -126
  83. package/dist/lib/recovery.d.ts +2 -83
  84. package/dist/lib/recovery.js +20 -274
  85. package/dist/lib/replies.d.ts +16 -24
  86. package/dist/lib/replies.js +99 -116
  87. package/dist/lib/routing.d.ts +120 -7
  88. package/dist/lib/routing.js +4667 -1152
  89. package/dist/lib/runtime.d.ts +1 -11
  90. package/dist/lib/sections.d.ts +4 -29
  91. package/dist/lib/sections.js +7 -20
  92. package/dist/lib/skills.js +4 -3
  93. package/dist/lib/status.js +86 -57
  94. package/dist/lib/sync.d.ts +6 -3
  95. package/dist/lib/sync.js +112 -45
  96. package/dist/lib/target.d.ts +4 -0
  97. package/dist/lib/target.js +18 -0
  98. package/dist/lib/telegram-api.d.ts +18 -13
  99. package/dist/lib/telegram-api.js +165 -53
  100. package/dist/lib/text-groups.js +4 -39
  101. package/dist/lib/thread-cleanup-manager.d.ts +6 -27
  102. package/dist/lib/thread-cleanup-manager.js +369 -186
  103. package/dist/lib/thread-display.d.ts +1 -2
  104. package/dist/lib/thread-display.js +58 -38
  105. package/dist/lib/thread-naming.d.ts +28 -8
  106. package/dist/lib/thread-naming.js +175 -66
  107. package/dist/lib/thread-reconciler.d.ts +42 -16
  108. package/dist/lib/thread-reconciler.js +90 -50
  109. package/dist/lib/threads.d.ts +82 -20
  110. package/dist/lib/threads.js +2247 -855
  111. package/dist/lib/time-injection.js +1 -2
  112. package/dist/lib/turns.js +1 -19
  113. package/dist/lib/updates.d.ts +169 -80
  114. package/dist/lib/updates.js +2418 -641
  115. package/dist/lib/voice.d.ts +7 -22
  116. package/dist/lib/voice.js +7 -22
  117. package/dist/lib/wire.js +1 -1
  118. package/dist/lib/workspace-admission.d.ts +3 -1
  119. package/dist/lib/workspace-admission.js +425 -211
  120. package/dist/lib/workspace-identity.js +8 -13
  121. package/dist/lib/workspace-retirement.d.ts +1 -1
  122. package/dist/lib/workspace-retirement.js +328 -140
  123. package/dist/lib/workspace-slots.js +8 -4
  124. package/dist/package.json +1 -1
  125. package/docs/architecture.md +36 -30
  126. package/docs/inbound.md +1 -1
  127. package/docs/multi-instance-bus.md +15 -3
  128. package/docs/outbound.md +1 -1
  129. package/docs/public-api.md +29 -1
  130. package/docs/ui-style.md +25 -8
  131. package/lib/activity-verbosity.ts +59 -82
  132. package/lib/activity.ts +146 -102
  133. package/lib/agent-messages.ts +12 -5
  134. package/lib/bindings.ts +389 -82
  135. package/lib/bus-api.ts +263 -102
  136. package/lib/bus-follower.ts +2440 -601
  137. package/lib/bus-leader.ts +2209 -1290
  138. package/lib/bus-transport.ts +46 -12
  139. package/lib/bus.ts +1673 -444
  140. package/lib/channel-posts.ts +848 -327
  141. package/lib/command-templates.ts +152 -265
  142. package/lib/commands.ts +2398 -555
  143. package/lib/config.ts +177 -170
  144. package/lib/delivery.ts +41 -29
  145. package/lib/extension.ts +1207 -433
  146. package/lib/generative-app-worker.mjs +19 -7
  147. package/lib/generative-apps.ts +324 -211
  148. package/lib/inbound.ts +95 -97
  149. package/lib/journal.ts +5311 -1763
  150. package/lib/keyboard.ts +1 -3
  151. package/lib/lifecycle.ts +128 -78
  152. package/lib/locks.ts +703 -212
  153. package/lib/logging.ts +147 -58
  154. package/lib/media.ts +82 -43
  155. package/lib/menu-model.ts +85 -18
  156. package/lib/menu-queue.ts +88 -129
  157. package/lib/menu-settings.ts +260 -155
  158. package/lib/menu-status.ts +20 -60
  159. package/lib/menu-thinking.ts +41 -56
  160. package/lib/menu.ts +112 -285
  161. package/lib/model.ts +8 -22
  162. package/lib/outbound-attachments.ts +156 -125
  163. package/lib/outbound-buttons.ts +76 -49
  164. package/lib/outbound-markup.ts +96 -94
  165. package/lib/outbound-voice.ts +33 -27
  166. package/lib/outbound.ts +125 -125
  167. package/lib/ownership.ts +19 -10
  168. package/lib/paths.ts +148 -49
  169. package/lib/pi.ts +35 -15
  170. package/lib/polling.ts +165 -149
  171. package/lib/preview.ts +93 -40
  172. package/lib/process-identity.ts +164 -0
  173. package/lib/prompts.ts +4 -11
  174. package/lib/queue.ts +511 -300
  175. package/lib/recovery.ts +50 -454
  176. package/lib/rendering.ts +1 -3
  177. package/lib/replies.ts +248 -209
  178. package/lib/routing.ts +8509 -2186
  179. package/lib/runtime.ts +1 -12
  180. package/lib/sections.ts +24 -89
  181. package/lib/setup.ts +1 -3
  182. package/lib/skills.ts +9 -4
  183. package/lib/status.ts +215 -110
  184. package/lib/sync.ts +488 -359
  185. package/lib/target.ts +22 -3
  186. package/lib/telegram-api.ts +322 -127
  187. package/lib/text-groups.ts +5 -39
  188. package/lib/thread-cleanup-manager.ts +833 -331
  189. package/lib/thread-display.ts +213 -97
  190. package/lib/thread-naming.ts +232 -87
  191. package/lib/thread-reconciler.ts +155 -72
  192. package/lib/threads.ts +5318 -1845
  193. package/lib/time-injection.ts +1 -4
  194. package/lib/turns.ts +15 -28
  195. package/lib/updates.ts +5184 -1612
  196. package/lib/voice.ts +7 -35
  197. package/lib/wire.ts +1 -1
  198. package/lib/workspace-admission.ts +996 -416
  199. package/lib/workspace-identity.ts +14 -17
  200. package/lib/workspace-retirement.ts +878 -376
  201. package/lib/workspace-slots.ts +22 -13
  202. package/package.json +1 -1
  203. package/scripts/check-downgrade.mjs +0 -472
  204. package/scripts/measure-workspace.mjs +0 -108
package/lib/polling.ts CHANGED
@@ -25,7 +25,8 @@ export const TELEGRAM_GET_UPDATES_CONFLICT_STOP_LIMIT = 10;
25
25
  const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_LIMIT = 3;
26
26
  const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_MS = 1_000;
27
27
  const TELEGRAM_GET_UPDATES_CONFLICT_SLOW_RETRY_MS = 3_000;
28
- const TELEGRAM_POLLING_RETRY_MS = 3_000;
28
+ const TELEGRAM_POLLING_RETRY_BASE_MS = 1_000;
29
+ const TELEGRAM_POLLING_RETRY_MAX_MS = 30_000;
29
30
  const TELEGRAM_GET_UPDATES_GRACE_MS = 10_000;
30
31
 
31
32
  // Standard Telegram DM polling does not expose ordinary message-deletion events.
@@ -73,12 +74,25 @@ export function getLatestTelegramUpdateId(
73
74
  export class TelegramPersistentGetUpdatesConflictError extends Error {
74
75
  readonly count: number;
75
76
  constructor(count: number) {
76
- super(`Telegram polling stopped after ${count} consecutive getUpdates conflicts.`);
77
+ super(
78
+ `Telegram polling stopped after ${count} consecutive getUpdates conflicts.`,
79
+ );
77
80
  this.name = "TelegramPersistentGetUpdatesConflictError";
78
81
  this.count = count;
79
82
  }
80
83
  }
81
84
 
85
+ /** Exponential backoff for non-conflict polling/admission failures. */
86
+ export function getTelegramPollingRetryDelayMs(
87
+ consecutiveFailures: number,
88
+ ): number {
89
+ const exponent = Math.max(0, Math.min(consecutiveFailures - 1, 16));
90
+ return Math.min(
91
+ TELEGRAM_POLLING_RETRY_BASE_MS * 2 ** exponent,
92
+ TELEGRAM_POLLING_RETRY_MAX_MS,
93
+ );
94
+ }
95
+
82
96
  export class TelegramGetUpdatesTimeoutError extends Error {
83
97
  readonly timeoutMs: number;
84
98
 
@@ -122,22 +136,13 @@ export interface TelegramPollingStartState {
122
136
  }
123
137
 
124
138
  export type TelegramPollingWorkPhase =
125
- | "long-poll"
126
- | "persisting-journal"
127
- | "persisting-offset"
128
- | "retrying";
139
+ "long-poll" | "persisting-journal" | "persisting-offset" | "retrying";
129
140
 
130
141
  export type TelegramPollingPhase =
131
- | "stopped"
132
- | "starting"
133
- | TelegramPollingWorkPhase;
142
+ "stopped" | "starting" | TelegramPollingWorkPhase;
134
143
 
135
144
  export type TelegramPollingStopReason =
136
- | "not-started"
137
- | "requested"
138
- | "completed"
139
- | "failed"
140
- | "persistent-conflict";
145
+ "not-started" | "requested" | "completed" | "failed" | "persistent-conflict";
141
146
 
142
147
  export interface TelegramPollingStateSnapshot {
143
148
  phase: TelegramPollingPhase;
@@ -150,8 +155,7 @@ export interface TelegramPollingStateSnapshot {
150
155
  stopReason?: TelegramPollingStopReason;
151
156
  }
152
157
 
153
- export interface TelegramPollingControllerState
154
- extends TelegramPollingStateSnapshot {
158
+ export interface TelegramPollingControllerState extends TelegramPollingStateSnapshot {
155
159
  pollingPromise?: Promise<void>;
156
160
  pollingController?: AbortController;
157
161
  }
@@ -173,8 +177,7 @@ function getTelegramPollingStateSnapshot(
173
177
  startedAtMs: state.startedAtMs,
174
178
  stoppedAtMs: state.stoppedAtMs,
175
179
  lastSuccessfulResponseAtMs: state.lastSuccessfulResponseAtMs,
176
- lastSuccessfulResponseUpdateCount:
177
- state.lastSuccessfulResponseUpdateCount,
180
+ lastSuccessfulResponseUpdateCount: state.lastSuccessfulResponseUpdateCount,
178
181
  stopReason: state.stopReason,
179
182
  };
180
183
  }
@@ -251,7 +254,8 @@ export function createTelegramPollingAdmissionRuntime<TContext>(deps: {
251
254
  async start(ctx) {
252
255
  if (!(deps.canStart?.(ctx) ?? true)) return;
253
256
  const expectedGeneration = ++generation;
254
- const isCurrent = () => expectedGeneration === generation && (deps.canStart?.(ctx) ?? true);
257
+ const isCurrent = () =>
258
+ expectedGeneration === generation && (deps.canStart?.(ctx) ?? true);
255
259
  if (!isCurrent()) return;
256
260
  await deps.prepareStart?.();
257
261
  if (!isCurrent()) return;
@@ -321,7 +325,10 @@ export function createTelegramDurablePollingRuntimeAssembly<
321
325
  if (batch.length > 0) deps.prepareUpdateBatch(batch);
322
326
  } catch (error) {
323
327
  try {
324
- deps.recordRuntimeEvent?.("polling", error, { phase: "batch-preparation", updateCount: updates.length });
328
+ deps.recordRuntimeEvent?.("polling", error, {
329
+ phase: "batch-preparation",
330
+ updateCount: updates.length,
331
+ });
325
332
  } catch {
326
333
  // Already-published input must still reach the worker if diagnostics fail.
327
334
  }
@@ -580,8 +587,13 @@ export function startTelegramPollingRuntime<TContext>(
580
587
  if (ownsController) deps.setPollingController(undefined);
581
588
  if (!ownsPromise && !ownsController) return;
582
589
  deps.onPollingStopped?.(
583
- controller.signal.aborted ? "requested" : persistentConflict
584
- ? "persistent-conflict" : failed ? "failed" : "completed",
590
+ controller.signal.aborted
591
+ ? "requested"
592
+ : persistentConflict
593
+ ? "persistent-conflict"
594
+ : failed
595
+ ? "failed"
596
+ : "completed",
585
597
  );
586
598
  // Detach the inner promise before outer teardown calls polling.stop().
587
599
  if (persistentConflict && !controller.signal.aborted) {
@@ -591,11 +603,14 @@ export function startTelegramPollingRuntime<TContext>(
591
603
  } else {
592
604
  deps.stopTypingLoop();
593
605
  deps.recordRuntimeEvent?.("polling", persistentConflict, {
594
- phase: "persistent-conflict", count: persistentConflict.count,
606
+ phase: "persistent-conflict",
607
+ count: persistentConflict.count,
595
608
  });
596
609
  }
597
610
  } catch (error) {
598
- deps.recordRuntimeEvent?.("polling", error, { phase: "conflict-stand-down" });
611
+ deps.recordRuntimeEvent?.("polling", error, {
612
+ phase: "conflict-stand-down",
613
+ });
599
614
  }
600
615
  if (deps.getPollingController() || deps.getPollingPromise()) return;
601
616
  }
@@ -719,7 +734,9 @@ export interface TelegramThreadTargetObservationBinding<TContext> {
719
734
  set(handler: TelegramThreadTargetObservationHandler<TContext>): void;
720
735
  }
721
736
 
722
- export function createTelegramThreadTargetObservationBinding<TContext>(): TelegramThreadTargetObservationBinding<TContext> {
737
+ export function createTelegramThreadTargetObservationBinding<
738
+ TContext,
739
+ >(): TelegramThreadTargetObservationBinding<TContext> {
723
740
  let handler: TelegramThreadTargetObservationHandler<TContext> | undefined;
724
741
  return {
725
742
  async handle(ctx) {
@@ -748,16 +765,8 @@ export interface TelegramThreadAwarePollingPorts<TContext, TOwner> {
748
765
  stopFollowerRegistration: () => void;
749
766
  }
750
767
 
751
- export interface TelegramThreadAwarePollingDeps<
752
- TContext,
753
- TOwner,
754
- > extends TelegramStartupThreadCapabilityProbeDeps {
755
- lifecycle?: TelegramThreadCapabilityLifecycle;
756
- isBusRuntimeEnabled: () => boolean;
757
- isTopicModeUnavailableError: (error: unknown) => boolean;
758
- getPollingStartedWithTelegramBus: () => boolean;
759
- setPollingStartedWithTelegramBus: (started: boolean) => void;
760
- setForceFreshLeaderThreadOnNextStart: (forceFresh: boolean) => void;
768
+ /** Classic/leader polling and follower registration controls that thread-aware polling switches between. */
769
+ export interface TelegramThreadAwarePollingControls<TContext, TOwner> {
761
770
  startClassicPolling: (ctx: TContext) => MaybePromise<void>;
762
771
  stopClassicPolling: () => Promise<void>;
763
772
  startBusLeaderPolling: (ctx: TContext) => Promise<void>;
@@ -774,35 +783,29 @@ export interface TelegramThreadAwarePollingDeps<
774
783
  ) => Promise<boolean | undefined>;
775
784
  hasRememberedWorkspaceBinding?: (ctx: TContext) => boolean;
776
785
  stopFollowerRegistration: () => void;
786
+ isBusRuntimeEnabled: () => boolean;
787
+ isTopicModeUnavailableError: (error: unknown) => boolean;
777
788
  }
778
789
 
779
- export interface TelegramThreadCapabilityOrchestrationDeps<
780
- TContext,
781
- TOwner,
782
- > extends TelegramThreadCapabilityReaderDeps {
790
+ export interface TelegramThreadAwarePollingDeps<TContext, TOwner>
791
+ extends
792
+ TelegramStartupThreadCapabilityProbeDeps,
793
+ TelegramThreadAwarePollingControls<TContext, TOwner> {
794
+ lifecycle?: TelegramThreadCapabilityLifecycle;
795
+ getPollingStartedWithTelegramBus: () => boolean;
796
+ setPollingStartedWithTelegramBus: (started: boolean) => void;
797
+ setForceFreshLeaderThreadOnNextStart: (forceFresh: boolean) => void;
798
+ }
799
+
800
+ export interface TelegramThreadCapabilityOrchestrationDeps<TContext, TOwner>
801
+ extends
802
+ TelegramThreadCapabilityReaderDeps,
803
+ TelegramThreadAwarePollingControls<TContext, TOwner> {
783
804
  state: TelegramThreadCapabilityStateRuntime;
784
805
  topicTargetStore: TelegramThreadCapabilityStore;
785
- isBusRuntimeEnabled: () => boolean;
786
806
  ownsLock: (ctx: TContext) => boolean;
787
807
  isFollowerRegistered?: () => boolean;
788
- startClassicPolling: (ctx: TContext) => MaybePromise<void>;
789
- stopClassicPolling: () => Promise<void>;
790
- startBusLeaderPolling: (ctx: TContext) => Promise<void>;
791
- stopBusLeaderPolling: () => Promise<void>;
792
- startLeaderHealth: () => void;
793
- stopLeaderHealth: () => void;
794
- registerFollowerWithLeader: (
795
- ctx: TContext,
796
- owner: TOwner,
797
- ) => Promise<boolean | undefined>;
798
- restoreFollowerWithLeader?: (
799
- ctx: TContext,
800
- owner: TOwner,
801
- ) => Promise<boolean | undefined>;
802
- hasRememberedWorkspaceBinding?: (ctx: TContext) => boolean;
803
808
  suspendLiveThreadTarget?: () => void;
804
- stopFollowerRegistration: () => void;
805
- isTopicModeUnavailableError: (error: unknown) => boolean;
806
809
  updateStatus: (ctx: TContext) => void;
807
810
  recordEvent: (
808
811
  category: string,
@@ -852,7 +855,9 @@ export function createTelegramThreadCapabilityOrchestration<TContext, TOwner>(
852
855
  const expected = generation;
853
856
  return () => expected === generation;
854
857
  },
855
- invalidate() { generation++; },
858
+ invalidate() {
859
+ generation++;
860
+ },
856
861
  };
857
862
  const capabilityDeps: TelegramThreadCapabilityRuntimeDeps<TContext> = {
858
863
  lifecycle,
@@ -975,6 +980,32 @@ function hasTelegramThreadCapabilityBindings(
975
980
  );
976
981
  }
977
982
 
983
+ /** Falls back to classic polling, recording a failed restore durably; false once the attempt is stale. */
984
+ async function restoreTelegramClassicPolling<TContext>(
985
+ ctx: TContext,
986
+ phase: string,
987
+ deps: TelegramThreadCapabilityRuntimeDeps<TContext>,
988
+ isCurrent: () => boolean,
989
+ ): Promise<boolean> {
990
+ try {
991
+ await deps.startClassicPolling(ctx);
992
+ return isCurrent();
993
+ } catch (classicError) {
994
+ if (!isCurrent()) return false;
995
+ deps.topicTargetStore.setBotState({
996
+ threadMode: "disabled",
997
+ updatedAtMs: (deps.getNowMs ?? Date.now)(),
998
+ lastReconcileAction: `${phase}-classic-restore-failed`,
999
+ });
1000
+ await deps.topicTargetStore.persist();
1001
+ if (!isCurrent()) return false;
1002
+ deps.recordEvent("bus", classicError, {
1003
+ phase: `${phase}-classic-restore`,
1004
+ });
1005
+ return true;
1006
+ }
1007
+ }
1008
+
978
1009
  export async function applyTelegramThreadCapability<TContext>(
979
1010
  ctx: TContext,
980
1011
  threadModeEnabled: boolean,
@@ -1017,22 +1048,8 @@ export async function applyTelegramThreadCapability<TContext>(
1017
1048
  if (!isCurrent()) return;
1018
1049
  deps.setPollingStartedWithTelegramBus(false);
1019
1050
  if (hadLiveThreadTransport) deps.suspendLiveThreadTarget?.();
1020
- try {
1021
- await deps.startClassicPolling(ctx);
1022
- if (!isCurrent()) return;
1023
- } catch (classicError) {
1024
- if (!isCurrent()) return;
1025
- deps.topicTargetStore.setBotState({
1026
- threadMode: "disabled",
1027
- updatedAtMs: (deps.getNowMs ?? Date.now)(),
1028
- lastReconcileAction: `${phase}-classic-restore-failed`,
1029
- });
1030
- await deps.topicTargetStore.persist();
1031
- if (!isCurrent()) return;
1032
- deps.recordEvent("bus", classicError, {
1033
- phase: `${phase}-classic-restore`,
1034
- });
1035
- }
1051
+ if (!(await restoreTelegramClassicPolling(ctx, phase, deps, isCurrent)))
1052
+ return;
1036
1053
  }
1037
1054
  deps.updateStatus(ctx);
1038
1055
  return;
@@ -1069,22 +1086,8 @@ export async function applyTelegramThreadCapability<TContext>(
1069
1086
  if (!isCurrent()) return;
1070
1087
  deps.setTopicModeUnavailable(true);
1071
1088
  }
1072
- try {
1073
- await deps.startClassicPolling(ctx);
1074
- if (!isCurrent()) return;
1075
- } catch (classicError) {
1076
- if (!isCurrent()) return;
1077
- deps.topicTargetStore.setBotState({
1078
- threadMode: "disabled",
1079
- updatedAtMs: (deps.getNowMs ?? Date.now)(),
1080
- lastReconcileAction: `${phase}-classic-restore-failed`,
1081
- });
1082
- await deps.topicTargetStore.persist();
1083
- if (!isCurrent()) return;
1084
- deps.recordEvent("bus", classicError, {
1085
- phase: `${phase}-classic-restore`,
1086
- });
1087
- }
1089
+ if (!(await restoreTelegramClassicPolling(ctx, phase, deps, isCurrent)))
1090
+ return;
1088
1091
  deps.updateStatus(ctx);
1089
1092
  if (threadModeUnavailable) return;
1090
1093
  throw error;
@@ -1104,12 +1107,16 @@ export function createTelegramThreadAwarePollingPorts<TContext, TOwner>(
1104
1107
  const expectedGeneration = ++generation;
1105
1108
  deps.lifecycle?.invalidate();
1106
1109
  const isLifecycleCurrent = deps.lifecycle?.capture() ?? (() => true);
1107
- const isCurrent = () => expectedGeneration === generation && isLifecycleCurrent();
1110
+ const isCurrent = () =>
1111
+ expectedGeneration === generation && isLifecycleCurrent();
1108
1112
  await deps.topicTargetStore.load();
1109
1113
  if (!isCurrent()) return;
1110
1114
  let startupThreadCapability: boolean | undefined;
1111
1115
  try {
1112
- startupThreadCapability = await probeTelegramStartupThreadCapability(deps, isCurrent);
1116
+ startupThreadCapability = await probeTelegramStartupThreadCapability(
1117
+ deps,
1118
+ isCurrent,
1119
+ );
1113
1120
  } catch (error) {
1114
1121
  if (!isCurrent()) return;
1115
1122
  deps.recordEvent("bus", error, { phase: "startup-thread-mode-probe" });
@@ -1156,7 +1163,8 @@ export function createTelegramThreadAwarePollingPorts<TContext, TOwner>(
1156
1163
  deps.stopLeaderHealth();
1157
1164
  if (deps.getPollingStartedWithTelegramBus()) {
1158
1165
  await deps.stopBusLeaderPolling();
1159
- if (expectedGeneration === generation) deps.setPollingStartedWithTelegramBus(false);
1166
+ if (expectedGeneration === generation)
1167
+ deps.setPollingStartedWithTelegramBus(false);
1160
1168
  return;
1161
1169
  }
1162
1170
  await deps.stopClassicPolling();
@@ -1181,13 +1189,25 @@ export function createTelegramThreadAwarePollingPorts<TContext, TOwner>(
1181
1189
  owner: TOwner,
1182
1190
  ): Promise<boolean | undefined> => {
1183
1191
  if (!(await refreshFollowerState())) {
1184
- deps.recordEvent("bus", "Telegram follower auto-connect skipped: Threaded Mode is not enabled.",
1185
- { phase: "follower-auto-connect-skip", reason: "thread-mode-unavailable" });
1192
+ deps.recordEvent(
1193
+ "bus",
1194
+ "Telegram follower auto-connect skipped: Threaded Mode is not enabled.",
1195
+ {
1196
+ phase: "follower-auto-connect-skip",
1197
+ reason: "thread-mode-unavailable",
1198
+ },
1199
+ );
1186
1200
  return undefined;
1187
1201
  }
1188
1202
  if (!deps.hasRememberedWorkspaceBinding?.(ctx)) {
1189
- deps.recordEvent("bus", "Telegram follower auto-connect skipped: no binding for this session.",
1190
- { phase: "follower-auto-connect-skip", reason: "session-binding-unavailable" });
1203
+ deps.recordEvent(
1204
+ "bus",
1205
+ "Telegram follower auto-connect skipped: no binding for this session.",
1206
+ {
1207
+ phase: "follower-auto-connect-skip",
1208
+ reason: "session-binding-unavailable",
1209
+ },
1210
+ );
1191
1211
  return undefined;
1192
1212
  }
1193
1213
  return deps.restoreFollowerWithLeader?.(ctx, owner);
@@ -1258,7 +1278,8 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
1258
1278
  }
1259
1279
  const expectedGeneration = generation;
1260
1280
  const isLifecycleCurrent = deps.lifecycle?.capture() ?? (() => true);
1261
- const isCurrent = (): boolean => generation === expectedGeneration && isLifecycleCurrent();
1281
+ const isCurrent = (): boolean =>
1282
+ generation === expectedGeneration && isLifecycleCurrent();
1262
1283
  let tracked: Promise<void>;
1263
1284
  tracked = readTelegramThreadCapability(deps)
1264
1285
  .then(async (threadModeEnabled) => {
@@ -1351,7 +1372,11 @@ export function createTelegramThreadCapabilityMonitor<TContext>(
1351
1372
  try {
1352
1373
  check(ctx);
1353
1374
  } catch (error) {
1354
- try { stop(); } catch { /* Timer shutdown must not escape the callback. */ }
1375
+ try {
1376
+ stop();
1377
+ } catch {
1378
+ /* Timer shutdown must not escape the callback. */
1379
+ }
1355
1380
  try {
1356
1381
  deps.recordEvent("bus", error, { phase: "capability-monitor" });
1357
1382
  } catch {
@@ -1475,13 +1500,10 @@ export async function admitTelegramPollingUpdateBatch<
1475
1500
  return { updateCount: deps.updates.length, latestUpdateId };
1476
1501
  }
1477
1502
 
1478
- export interface TelegramPollLoopDeps<
1503
+ /** Bot API, journal and progress ports shared by one poll loop and its restartable runner. */
1504
+ export interface TelegramPollLoopTransportPorts<
1479
1505
  TUpdate extends TelegramUpdate,
1480
- TContext = unknown,
1481
1506
  > extends TelegramRuntimeEventRecorderPort {
1482
- ctx: TContext;
1483
- signal: AbortSignal;
1484
- config: TelegramPollingConfig;
1485
1507
  deleteWebhook: (signal: AbortSignal) => Promise<unknown>;
1486
1508
  getUpdates: (
1487
1509
  body: Record<string, unknown>,
@@ -1496,9 +1518,6 @@ export interface TelegramPollLoopDeps<
1496
1518
  getAcceptedThroughUpdateId?: () => number | undefined;
1497
1519
  getJournalEntryCount: () => number;
1498
1520
  signalUpdateWorker: () => void;
1499
- onErrorStatus: (message: string) => void;
1500
- onStatusReset: () => void;
1501
- sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
1502
1521
  onPhaseChange?: (
1503
1522
  phase: TelegramPollingWorkPhase,
1504
1523
  currentUpdateId?: number,
@@ -1506,32 +1525,25 @@ export interface TelegramPollLoopDeps<
1506
1525
  onSuccessfulResponse?: (updateCount: number) => void;
1507
1526
  }
1508
1527
 
1528
+ export interface TelegramPollLoopDeps<
1529
+ TUpdate extends TelegramUpdate,
1530
+ TContext = unknown,
1531
+ > extends TelegramPollLoopTransportPorts<TUpdate> {
1532
+ ctx: TContext;
1533
+ signal: AbortSignal;
1534
+ config: TelegramPollingConfig;
1535
+ onErrorStatus: (message: string) => void;
1536
+ onStatusReset: () => void;
1537
+ sleep: (ms: number, signal?: AbortSignal) => Promise<void>;
1538
+ }
1539
+
1509
1540
  export interface TelegramPollLoopRunnerDeps<
1510
1541
  TUpdate extends TelegramUpdate,
1511
1542
  TContext = unknown,
1512
- > extends TelegramRuntimeEventRecorderPort {
1543
+ > extends TelegramPollLoopTransportPorts<TUpdate> {
1513
1544
  getConfig: () => TelegramPollingConfig;
1514
- deleteWebhook: (signal: AbortSignal) => Promise<unknown>;
1515
- getUpdates: (
1516
- body: Record<string, unknown>,
1517
- signal: AbortSignal,
1518
- ) => Promise<TUpdate[]>;
1519
- getUpdatesRequestBudgetMs?: (body: Record<string, unknown>) => number;
1520
- persistConfig: (config: TelegramPollingConfig) => Promise<void>;
1521
- appendUpdateBatch: (
1522
- updates: readonly TUpdate[],
1523
- acceptedThroughUpdateId?: number,
1524
- ) => MaybePromise<unknown>;
1525
- getAcceptedThroughUpdateId?: () => number | undefined;
1526
- getJournalEntryCount: () => number;
1527
- signalUpdateWorker: () => void;
1528
1545
  updateStatus: (ctx: TContext, message?: string) => void;
1529
1546
  sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
1530
- onPhaseChange?: (
1531
- phase: TelegramPollingWorkPhase,
1532
- currentUpdateId?: number,
1533
- ) => void;
1534
- onSuccessfulResponse?: (updateCount: number) => void;
1535
1547
  }
1536
1548
 
1537
1549
  export function sleepTelegramPollingRetry(
@@ -1629,10 +1641,7 @@ function reportTelegramPollingPhase(
1629
1641
  function reportTelegramPollingResponse<
1630
1642
  TUpdate extends TelegramUpdate,
1631
1643
  TContext,
1632
- >(
1633
- deps: TelegramPollLoopDeps<TUpdate, TContext>,
1634
- updateCount: number,
1635
- ): void {
1644
+ >(deps: TelegramPollLoopDeps<TUpdate, TContext>, updateCount: number): void {
1636
1645
  try {
1637
1646
  deps.onSuccessfulResponse?.(updateCount);
1638
1647
  } catch (error) {
@@ -1709,13 +1718,19 @@ export async function runTelegramPollLoop<
1709
1718
  >(deps: TelegramPollLoopDeps<TUpdate, TContext>): Promise<void> {
1710
1719
  if (!deps.config.botToken) return;
1711
1720
  let consecutiveGetUpdatesConflicts = 0;
1721
+ let consecutiveFailures = 0;
1712
1722
  const retryConflict = async () => {
1713
1723
  consecutiveGetUpdatesConflicts += 1;
1714
- if (consecutiveGetUpdatesConflicts >= TELEGRAM_GET_UPDATES_CONFLICT_STOP_LIMIT) {
1715
- throw new TelegramPersistentGetUpdatesConflictError(consecutiveGetUpdatesConflicts);
1724
+ if (
1725
+ consecutiveGetUpdatesConflicts >= TELEGRAM_GET_UPDATES_CONFLICT_STOP_LIMIT
1726
+ ) {
1727
+ throw new TelegramPersistentGetUpdatesConflictError(
1728
+ consecutiveGetUpdatesConflicts,
1729
+ );
1716
1730
  }
1717
1731
  await deps.sleep(
1718
- consecutiveGetUpdatesConflicts < TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_LIMIT
1732
+ consecutiveGetUpdatesConflicts <
1733
+ TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_LIMIT
1719
1734
  ? TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_MS
1720
1735
  : TELEGRAM_GET_UPDATES_CONFLICT_SLOW_RETRY_MS,
1721
1736
  deps.signal,
@@ -1734,9 +1749,7 @@ export async function runTelegramPollLoop<
1734
1749
  "Telegram polling cursor is missing while the durable update journal is non-empty.",
1735
1750
  );
1736
1751
  }
1737
- if (
1738
- deps.getAcceptedThroughUpdateId?.() === undefined
1739
- ) {
1752
+ if (deps.getAcceptedThroughUpdateId?.() === undefined) {
1740
1753
  try {
1741
1754
  const request = buildTelegramInitialSyncRequest();
1742
1755
  reportTelegramPollingPhase(deps, "long-poll");
@@ -1744,11 +1757,7 @@ export async function runTelegramPollLoop<
1744
1757
  reportTelegramPollingResponse(deps, updates.length);
1745
1758
  const lastUpdateId = getLatestTelegramUpdateId(updates);
1746
1759
  if (lastUpdateId !== undefined) {
1747
- reportTelegramPollingPhase(
1748
- deps,
1749
- "persisting-offset",
1750
- lastUpdateId,
1751
- );
1760
+ reportTelegramPollingPhase(deps, "persisting-offset", lastUpdateId);
1752
1761
  await deps.appendUpdateBatch([], lastUpdateId);
1753
1762
  deps.recordRuntimeEvent?.(
1754
1763
  "polling",
@@ -1761,12 +1770,13 @@ export async function runTelegramPollLoop<
1761
1770
  reportTelegramPollingPhase(deps, "retrying");
1762
1771
  if (isTelegramGetUpdatesConflictError(error)) {
1763
1772
  await retryConflict();
1764
- } else deps.recordRuntimeEvent?.("polling", error, {
1765
- phase: "initial-sync",
1766
- ...(error instanceof TelegramGetUpdatesTimeoutError
1767
- ? { timeoutMs: error.timeoutMs }
1768
- : {}),
1769
- });
1773
+ } else
1774
+ deps.recordRuntimeEvent?.("polling", error, {
1775
+ phase: "initial-sync",
1776
+ ...(error instanceof TelegramGetUpdatesTimeoutError
1777
+ ? { timeoutMs: error.timeoutMs }
1778
+ : {}),
1779
+ });
1770
1780
  }
1771
1781
  }
1772
1782
  let currentUpdateId: number | undefined;
@@ -1791,6 +1801,7 @@ export async function runTelegramPollLoop<
1791
1801
  onPhaseChange: deps.onPhaseChange,
1792
1802
  recordRuntimeEvent: deps.recordRuntimeEvent,
1793
1803
  });
1804
+ consecutiveFailures = 0;
1794
1805
  currentUpdateId = undefined;
1795
1806
  } catch (error) {
1796
1807
  if (shouldStopTelegramPolling(deps.signal.aborted, error)) return;
@@ -1809,8 +1820,13 @@ export async function runTelegramPollLoop<
1809
1820
  : {}),
1810
1821
  });
1811
1822
  consecutiveGetUpdatesConflicts = 0;
1823
+ consecutiveFailures += 1;
1812
1824
  deps.onErrorStatus(getTelegramPollingErrorMessage(error));
1813
- await deps.sleep(TELEGRAM_POLLING_RETRY_MS, deps.signal);
1825
+ // Cap the pause, not retry count: outages must recover without manual reconnect.
1826
+ await deps.sleep(
1827
+ getTelegramPollingRetryDelayMs(consecutiveFailures),
1828
+ deps.signal,
1829
+ );
1814
1830
  if (deps.signal.aborted) return;
1815
1831
  deps.onStatusReset();
1816
1832
  }