@llblab/pi-kit 0.1.8 → 0.1.10

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 (44) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +2 -2
  3. package/node_modules/@llblab/pi-grow-loop/AGENTS.md +1 -1
  4. package/node_modules/@llblab/pi-grow-loop/CHANGELOG.md +6 -0
  5. package/node_modules/@llblab/pi-grow-loop/README.md +2 -1
  6. package/node_modules/@llblab/pi-grow-loop/index.ts +11 -2
  7. package/node_modules/@llblab/pi-grow-loop/package.json +1 -1
  8. package/node_modules/@llblab/pi-telegram/AGENTS.md +4 -4
  9. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +34 -0
  10. package/node_modules/@llblab/pi-telegram/README.md +3 -3
  11. package/node_modules/@llblab/pi-telegram/api/voice.ts +0 -1
  12. package/node_modules/@llblab/pi-telegram/docs/architecture.md +5 -5
  13. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +2 -2
  14. package/node_modules/@llblab/pi-telegram/docs/outbound.md +2 -3
  15. package/node_modules/@llblab/pi-telegram/docs/public-api.md +4 -13
  16. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +22 -8
  17. package/node_modules/@llblab/pi-telegram/docs/voice.md +9 -37
  18. package/node_modules/@llblab/pi-telegram/index.ts +20 -7
  19. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +49 -13
  20. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +127 -25
  21. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +17 -4
  22. package/node_modules/@llblab/pi-telegram/lib/bus.ts +56 -2
  23. package/node_modules/@llblab/pi-telegram/lib/command-templates.ts +65 -4
  24. package/node_modules/@llblab/pi-telegram/lib/commands.ts +122 -28
  25. package/node_modules/@llblab/pi-telegram/lib/config.ts +10 -7
  26. package/node_modules/@llblab/pi-telegram/lib/journal.ts +2 -9
  27. package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +20 -18
  28. package/node_modules/@llblab/pi-telegram/lib/locks.ts +6 -1
  29. package/node_modules/@llblab/pi-telegram/lib/menu-queue.ts +15 -4
  30. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +15 -11
  31. package/node_modules/@llblab/pi-telegram/lib/menu.ts +10 -7
  32. package/node_modules/@llblab/pi-telegram/lib/outbound-voice.ts +3 -18
  33. package/node_modules/@llblab/pi-telegram/lib/prompts.ts +2 -2
  34. package/node_modules/@llblab/pi-telegram/lib/queue.ts +49 -12
  35. package/node_modules/@llblab/pi-telegram/lib/routing.ts +5 -2
  36. package/node_modules/@llblab/pi-telegram/lib/status.ts +4 -2
  37. package/node_modules/@llblab/pi-telegram/lib/sync.ts +17 -0
  38. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +91 -45
  39. package/node_modules/@llblab/pi-telegram/lib/threads.ts +5 -0
  40. package/node_modules/@llblab/pi-telegram/lib/updates.ts +17 -9
  41. package/node_modules/@llblab/pi-telegram/lib/voice.ts +7 -31
  42. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  43. package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/SKILL.md +1 -1
  44. package/package.json +3 -3
@@ -43,12 +43,12 @@ Voice provider extensions can also register STT backends with `registerTelegramV
43
43
 
44
44
  ## Voice Reply Policy
45
45
 
46
- The bridge decides **when** to reply with voice from `voice.replyMode` in `TelegramConfig` (stored in `telegram.json`). Missing, invalid, `hidden`, and legacy `manual` values resolve to the silent `hidden` default.
46
+ The bridge decides **when** to reply with voice from `voice.replyMode` in `TelegramConfig` (stored in `telegram.json`). Missing and invalid values resolve to the `manual` default; the former `hidden` value remains a read-only compatibility alias for `manual`.
47
47
 
48
48
  ### Modes
49
49
 
50
- - **`hidden` (default):** no `voice.replyMode` is stored and no automatic voice context is added; explicit agent-authored `telegram_voice` actions still work.
51
- - **`mirror`:** voice/audio input activates automatic voice delivery. Text input follows `hidden` behavior.
50
+ - **`manual` (default):** no `voice.replyMode` is stored and no automatic voice context is added; explicit agent-authored `telegram_voice` actions still work.
51
+ - **`mirror`:** voice/audio input activates automatic voice delivery. Text input follows `manual` behavior.
52
52
  - **`always`:** every Telegram turn activates automatic voice delivery.
53
53
 
54
54
  **Warning:** In `always` mode, the bridge transparently intercepts ALL text replies and converts them to voice on success. Users will only receive voice messages when voice generation succeeds. If voice generation fails, the bridge falls back to sending the planned text reply.
@@ -70,7 +70,7 @@ A voice extension may combine three public seams:
70
70
 
71
71
  - `registerTelegramVoiceTranscriptionProvider()` for inbound STT fallback on voice/audio files
72
72
  - `registerTelegramVoiceSynthesisProvider()` for outbound TTS/synthesis fallback to Telegram voice messages
73
- - `registerTelegramSection()` for provider-specific Telegram UI such as voice, language, style, transcript, or provider on/off controls
73
+ - `registerTelegramSection()` for provider-specific Telegram UI such as voice, language, style, or provider on/off controls
74
74
 
75
75
  The reply policy itself remains a built-in pi-telegram setting (`voice.replyMode`) rather than a provider-owned menu.
76
76
 
@@ -81,10 +81,8 @@ Voice synthesis provider extensions register themselves through `registerTelegra
81
81
  - Text optimisation / speech-style rewriting
82
82
  - Adding speech tags (when desired)
83
83
  - Running TTS + ffmpeg conversion to OGG/Opus
84
- - Deciding whether to return `transcriptText` at all based on the bridge-owned `voice.sendTranscript` preference when the provider has access to the current Telegram config
85
- - `transcriptText` (when returned) is attached by the bridge as the voice message **caption** only. Separate transcript messages are no longer sent.
86
84
 
87
- The bridge shows a `record_voice` action while delivering and sends the final audio with Telegram `sendVoice`. When a provider returns `transcriptText`, the bridge attaches it as the voice caption.
85
+ The bridge shows a `record_voice` action while delivering and sends the final audio with Telegram `sendVoice`.
88
86
 
89
87
  Providers can implement `getVoicePromptContribution(view)` to inject voice-specific instructions into voice-tagged prompts (for example: "Reply only with the spoken text"). The bridge appends the first non-empty provider contribution when `mirror` or `always` mode tags the turn.
90
88
 
@@ -95,7 +93,6 @@ The provider receives the raw agent text plus optional `{ lang?, rate? }`.
95
93
  It must return one of:
96
94
 
97
95
  - `string` — path to a ready `.ogg` or `.opus` file
98
- - `{ audioPath: string, transcriptText?: string }` — `audioPath` must be OGG/Opus. When `transcriptText` is present it is attached as the voice message **caption**. Providers should treat pi-telegram's `voice.sendTranscript` as the bridge-owned transcript preference instead of inventing a second reply-policy UI.
99
96
  - `undefined` — skip this text block
100
97
 
101
98
  **Important:** Providers are fully responsible for producing a clean, TTS-optimised native voice file. The bridge may also run configured outbound voice command templates for users who prefer process-boundary handlers instead of provider extensions.
@@ -106,7 +103,7 @@ Registration returns a disposer function for cleanup. Stable provider registrati
106
103
 
107
104
  ## Outbound Voice Handlers
108
105
 
109
- Users can also configure `outboundHandlers` with `type: "voice"` in `telegram.json`. This is the command-template path for TTS without a provider extension. Reply modes (`hidden`, `mirror`, `always`) affect these handlers the same way they affect providers: explicit `telegram_voice` blocks and automatic mirror/always interception both produce a voice reply plan, then delivery tries configured outbound voice handlers first and registered synthesis providers as progressive fallbacks.
106
+ Users can also configure `outboundHandlers` with `type: "voice"` in `telegram.json`. This is the command-template path for TTS without a provider extension. Reply modes (`manual`, `mirror`, `always`) affect these handlers the same way they affect providers: explicit `telegram_voice` blocks and automatic mirror/always interception both produce a voice reply plan, then delivery tries configured outbound voice handlers first and registered synthesis providers as progressive fallbacks.
110
107
 
111
108
  Voice handlers receive the text on stdin in composed pipelines and can use `{text}`, `{lang}`, `{rate}`, `{mp3}`, and `{ogg}` placeholders. Set `output` to `"ogg"` or another placeholder name when the template writes to a known path:
112
109
 
@@ -128,31 +125,6 @@ Voice handlers receive the text on stdin in composed pipelines and can use `{tex
128
125
 
129
126
  Priority for outbound voice delivery is: configured `outboundHandlers` with `type: "voice"` in their `telegram.json` order, then programmatic `voice` outbound handlers, then registered voice synthesis providers. Provider extensions are the zero-config tail of the same pipeline: they handle voice when no explicit configured handler succeeds, but they do not override operator-configured handlers. If multiple providers are registered, only one handles a given voice reply: the first provider that returns a valid `.ogg`/`.opus` artifact wins. Providers that return `undefined` explicitly pass to the next provider; providers that throw or return invalid output are recorded and the next fallback is tried.
130
127
 
131
- ### Provider with transcript caption (controlled by user toggle)
132
-
133
- When the user's "Send Transcript" toggle is ON, return the clean spoken text as `transcriptText`. The bridge attaches it as the caption on the voice message. When the toggle is OFF, return only the audio path (no `transcriptText`).
134
-
135
- ```typescript
136
- import {
137
- getTelegramVoiceSendTranscript,
138
- registerTelegramVoiceSynthesisProvider,
139
- } from "@llblab/pi-telegram/voice";
140
-
141
- registerTelegramVoiceSynthesisProvider(
142
- async (text, options) => {
143
- const rewritten = rewriteWithSpeechTags(text);
144
- const audioPath = await myTTS(rewritten, { language: options?.lang });
145
- const sendTranscript = getTelegramVoiceSendTranscript(
146
- getCurrentTelegramConfigView(),
147
- );
148
- return sendTranscript ? { audioPath, transcriptText: text } : { audioPath };
149
- },
150
- { id: "my-voice-provider/tts" },
151
- );
152
- ```
153
-
154
- `getCurrentTelegramConfigView()` represents whatever current `TelegramConfig` view your extension already owns or receives; pi-telegram does not require providers to read config directly. The bridge never sends a separate transcript message. Caption-only is the "ON" behavior.
155
-
156
128
  ### Surfacing provider diagnostics
157
129
 
158
130
  Voice provider extensions can record runtime events that appear in `/telegram-status` alongside pi-telegram's own events:
@@ -170,13 +142,13 @@ recordTelegramRuntimeEvent("voice-provider", new Error("TTS failed"), {
170
142
 
171
143
  ## Voice Extension Section
172
144
 
173
- Voice provider extensions can register a Voice Extension Section (settings UI) via `registerTelegramSection`. The section can expose provider-specific controls such as TTS voice, language, speech style, transcript behavior, or STT/TTS enablement. Reply mode is a core pi-telegram setting and belongs in the built-in Settings menu.
145
+ Voice provider extensions can register a Voice Extension Section (settings UI) via `registerTelegramSection`. The section can expose provider-specific controls such as TTS voice, language, speech style, or STT/TTS enablement. Reply mode is a core pi-telegram setting and belongs in the built-in Settings menu.
174
146
 
175
147
  **Note on resume:** Because the previous automatic persistent re-registration system has been removed, extensions are responsible for re-registering their Voice Extension Section on `session_start` if they want the menu to survive a `pi resume`. See `registerTelegramSection` from `@llblab/pi-telegram/sections`.
176
148
 
177
149
  ## Prompt Guidance
178
150
 
179
- The bridge keeps voice prompt context compact, effective, and policy-owned. `hidden` and text-originated `mirror` turns add no voice line. Voice/audio-originated `mirror` turns and every `always` turn add exactly `[voice] delivery: automatic voice`, describing the current delivery environment without exposing the underlying mode matrix or an instruction list. The marker is appended after `[outputs]` when handler output exists, otherwise after `[attachments]`. Voice inputs also appear in `[attachments]` with their downloaded file names, MIME data, and handler output, so agents can infer concrete voice-file context from attachment metadata.
151
+ The bridge keeps voice prompt context compact, effective, and policy-owned. `manual` and text-originated `mirror` turns add no voice line. Voice/audio-originated `mirror` turns and every `always` turn add exactly `[voice] delivery: automatic voice`, describing the current delivery environment without exposing the underlying mode matrix or an instruction list. The marker is appended after `[outputs]` when handler output exists, otherwise after `[attachments]`. Voice inputs also appear in `[attachments]` with their downloaded file names, MIME data, and handler output, so agents can infer concrete voice-file context from attachment metadata.
180
152
 
181
153
  Voice synthesis providers can supply prompt guidance through `getVoicePromptContribution(view)`, but provider text should stay optional and provider-specific. Reply-mode context belongs to pi-telegram.
182
154
 
@@ -224,4 +196,4 @@ The bridge reads `voice.replyMode` from the config when building a turn.
224
196
 
225
197
  ### Provider config
226
198
 
227
- Provider-specific settings (voice ID, language, speech style, transcript behavior, STT/TTS enablement) are owned by the voice provider extension. Reply mode is owned by pi-telegram's `voice.replyMode` and configured from the built-in pi-telegram Settings menu, not duplicated in provider UIs.
199
+ Provider-specific settings (voice ID, language, speech style, STT/TTS enablement) are owned by the voice provider extension. Reply mode is owned by pi-telegram's `voice.replyMode` and configured from the built-in pi-telegram Settings menu, not duplicated in provider UIs.
@@ -359,6 +359,7 @@ export default function (pi: Pi.ExtensionAPI) {
359
359
  getActiveToolExecutions: lifecycle.getActiveToolExecutions,
360
360
  hasPendingModelSwitch: pendingModelSwitchStore.has,
361
361
  getQueuedItems: telegramQueueStore.getQueuedItems,
362
+ getQueuedItemCount: Queue.countExecutableTelegramQueueItems,
362
363
  formatQueuedStatus: Queue.formatQueuedTelegramItemsStatus,
363
364
  getRecentRuntimeEvents: runtimeEvents.getEvents,
364
365
  getRuntimeLockState: lockRuntime.getStatusLabel,
@@ -415,6 +416,8 @@ export default function (pi: Pi.ExtensionAPI) {
415
416
  },
416
417
  getRegistrationGeneration:
417
418
  telegramBusFollowerRegistrationState.getGeneration,
419
+ waitForRegistrationGeneration:
420
+ telegramBusFollowerRegistrationState.waitForGeneration,
418
421
  getForwardCommentBatchPosition:
419
422
  textGroupRuntime.getPreparedForwardingPosition,
420
423
  recordRuntimeEvent,
@@ -841,6 +844,16 @@ export default function (pi: Pi.ExtensionAPI) {
841
844
  });
842
845
  },
843
846
  );
847
+ const staleTopicApiErrorRecoveryDeps = {
848
+ topicTargetStore: threadStore,
849
+ getSyncState: telegramSyncStateRuntime.getState,
850
+ setSyncState: telegramSyncStateRuntime.setState,
851
+ recordEvent: recordRuntimeEvent,
852
+ };
853
+ const recoverStaleTelegramTopicApiError =
854
+ Sync.createTelegramStaleTopicApiErrorRecoveryRuntime(
855
+ staleTopicApiErrorRecoveryDeps,
856
+ );
844
857
  const {
845
858
  owner: updateWorkerOwnerRuntime,
846
859
  leader: updateAdmissionLifecycleRuntime,
@@ -864,6 +877,12 @@ export default function (pi: Pi.ExtensionAPI) {
864
877
  worker: {
865
878
  defaultHandle: inboundRouteRuntime.handleUpdate,
866
879
  onStateChange: runtimeDiagnostics.scheduleSnapshotPersist,
880
+ settleTerminalExecutionFailure(error) {
881
+ return Sync.settleStaleTelegramTopicExecutionFailure(
882
+ error,
883
+ staleTopicApiErrorRecoveryDeps,
884
+ );
885
+ },
867
886
  },
868
887
  leader: {
869
888
  resolveBinding: resolveTelegramUpdateJournalBinding,
@@ -1053,13 +1072,6 @@ export default function (pi: Pi.ExtensionAPI) {
1053
1072
  onPollingStateChange: runtimeDiagnostics.scheduleSnapshotPersist,
1054
1073
  recordRuntimeEvent,
1055
1074
  });
1056
- const recoverStaleTelegramTopicApiError =
1057
- Sync.createTelegramStaleTopicApiErrorRecoveryRuntime({
1058
- topicTargetStore: threadStore,
1059
- getSyncState: telegramSyncStateRuntime.getState,
1060
- setSyncState: telegramSyncStateRuntime.setState,
1061
- recordEvent: recordRuntimeEvent,
1062
- });
1063
1075
  const authorizeFollowerApiCall = Bus.createTelegramFollowerApiCallAuthorizer({
1064
1076
  isMessageOwned: messageOwnershipRuntime.isOwnedByFollower,
1065
1077
  });
@@ -1232,6 +1244,7 @@ export default function (pi: Pi.ExtensionAPI) {
1232
1244
  prepareTempDir,
1233
1245
  updateStatus,
1234
1246
  unbindDeferredDispatchContext: deferredQueueDispatchRuntime.unbind,
1247
+ discardQueuedItems: queueMutationRuntime.clear,
1235
1248
  clearModelMenuState: modelMenuRuntime.clear,
1236
1249
  getActiveTurnChatId: activeTurnRuntime.getChatId,
1237
1250
  getActiveTurnTarget: activeTurnRuntime.getTarget,
@@ -95,11 +95,28 @@ export function createTelegramQueueBindingRuntime<TContext>(deps: {
95
95
  sendUserMessage: Queue.TelegramQueueDispatchRuntimeDeps<TContext>["sendUserMessage"];
96
96
  recordRuntimeEvent?: TelegramRuntimeEventRecorder;
97
97
  }): TelegramQueueBindingRuntime<TContext> {
98
+ const settleDiscardedItems = (
99
+ items: readonly Queue.TelegramQueueItem<TContext>[],
100
+ ctx: TContext,
101
+ ): boolean => {
102
+ const durableItems = items.filter(
103
+ (item) => (item.admissionReceipts?.length ?? 0) > 0,
104
+ );
105
+ if (durableItems.length === 0) return true;
106
+ const settlement = deps.admission.getSettlement();
107
+ if (!settlement) return false;
108
+ settlement.onItemsDiscarded(durableItems, ctx);
109
+ return durableItems.every((item) => !settlement.isItemReady(item));
110
+ };
98
111
  const mutation = Queue.createTelegramQueueMutationController({
99
112
  ...deps.store,
100
113
  allocateLaneOrder: deps.queue.allocateItemOrder,
101
114
  onItemsDiscarded(items, ctx) {
102
- deps.admission.getSettlement()?.onItemsDiscarded(items, ctx);
115
+ if (!settleDiscardedItems(items, ctx)) {
116
+ throw new Error(
117
+ "Telegram queue items could not be discarded durably.",
118
+ );
119
+ }
103
120
  },
104
121
  updateStatus: deps.updateStatus,
105
122
  recordRuntimeEvent: deps.recordRuntimeEvent,
@@ -136,6 +153,9 @@ export function createTelegramQueueBindingRuntime<TContext>(deps: {
136
153
  onControlSettled(item, ctx) {
137
154
  deps.admission.getSettlement()?.onControlSettled(item, ctx);
138
155
  },
156
+ onPromptSkipped(item, ctx) {
157
+ return settleDiscardedItems([item], ctx);
158
+ },
139
159
  updateStatus: deps.updateStatus,
140
160
  sendTextReply: deps.sendTextReply,
141
161
  recordRuntimeEvent: deps.recordRuntimeEvent,
@@ -909,6 +929,25 @@ export function registerTelegramLifecycleRuntimeHooks({
909
929
  { replyToPrompt: false },
910
930
  );
911
931
  };
932
+ let activeTurnDeliveryTail = Promise.resolve();
933
+ const scheduleActiveTurnDelivery = (task: () => Promise<void>): void => {
934
+ const previous = activeTurnDeliveryTail;
935
+ activeTurnDeliveryTail = (async () => {
936
+ await previous;
937
+ await new Promise<void>((resolve) => {
938
+ const timer = setTimeout(resolve, 0);
939
+ timer.unref?.();
940
+ });
941
+ await task();
942
+ })().catch((error) => {
943
+ recordRuntimeEvent("delivery", error, {
944
+ phase: "agent-end-background-delivery",
945
+ });
946
+ });
947
+ };
948
+ const waitForActiveTurnDelivery = async (): Promise<void> => {
949
+ await activeTurnDeliveryTail;
950
+ };
912
951
  const agentLifecycleHooks = Queue.createTelegramAgentLifecycleHooks<
913
952
  Queue.PendingTelegramTurn,
914
953
  Pi.ExtensionContext,
@@ -950,16 +989,7 @@ export function registerTelegramLifecycleRuntimeHooks({
950
989
  dispatchNextQueuedTelegramTurn,
951
990
  requestDeferredDispatchNextQueuedTelegramTurn:
952
991
  deferredQueueDispatchRuntime.request,
953
- scheduleActiveTurnDelivery(task) {
954
- const timer = setTimeout(() => {
955
- void task().catch((error) => {
956
- recordRuntimeEvent("delivery", error, {
957
- phase: "agent-end-background-delivery",
958
- });
959
- });
960
- }, 0);
961
- timer.unref?.();
962
- },
992
+ scheduleActiveTurnDelivery,
963
993
  clearPreview: previewRuntime.clear,
964
994
  setPreviewPendingText: previewRuntime.setPendingText,
965
995
  finalizeMarkdownPreview,
@@ -1079,7 +1109,11 @@ export function registerTelegramLifecycleRuntimeHooks({
1079
1109
  activityRuntime.onCompactionStart(Pi.getSessionCompactionReason(event));
1080
1110
  compactionObserver.onSessionBeforeCompact(event, ctx);
1081
1111
  if (shouldNotify) {
1082
- await sendCompactionNotice(Commands.TELEGRAM_COMPACTION_STARTED_TEXT);
1112
+ await waitForActiveTurnDelivery();
1113
+ if (!isSessionContextActive(ctx)) return;
1114
+ await sendCompactionNotice(
1115
+ Commands.TELEGRAM_COMPACTION_STARTED_MARKDOWN,
1116
+ );
1083
1117
  }
1084
1118
  },
1085
1119
  async onSessionCompact(event, ctx) {
@@ -1088,7 +1122,9 @@ export function registerTelegramLifecycleRuntimeHooks({
1088
1122
  compactionObserver.onSessionCompact(event, ctx);
1089
1123
  if (observedAutomaticCompaction) {
1090
1124
  observedAutomaticCompaction = false;
1091
- await sendCompactionNotice(Commands.TELEGRAM_COMPACTION_COMPLETED_TEXT);
1125
+ await sendCompactionNotice(
1126
+ Commands.TELEGRAM_COMPACTION_COMPLETED_MARKDOWN,
1127
+ );
1092
1128
  }
1093
1129
  },
1094
1130
  async onAgentStart(event, ctx) {
@@ -20,6 +20,7 @@ import type { TelegramTarget } from "./target.ts";
20
20
  import {
21
21
  isTelegramApiMethodRetrySafe,
22
22
  TelegramApiCommitUnknownError,
23
+ TelegramApiStaleTargetError,
23
24
  } from "./telegram-api.ts";
24
25
  import {
25
26
  createTelegramBusFollowerDeliveryIdentity,
@@ -47,6 +48,7 @@ import {
47
48
  export const TELEGRAM_BUS_FOLLOWER_PROMOTION_GRACE_MS = 2_500;
48
49
  export const TELEGRAM_FOLLOWER_SESSION_HANDOFF_TTL_MS = 30_000;
49
50
  export const TELEGRAM_BUS_FOLLOWER_CLIENT_TIMEOUT_MS = 30_000;
51
+ export const TELEGRAM_BUS_FOLLOWER_REGISTRATION_WAIT_MS = 30_000;
50
52
  export const TELEGRAM_BUS_FOLLOWER_REGISTRATION_RETRY_ATTEMPTS =
51
53
  TELEGRAM_BUS_REGISTRATION_RETRY.attempts;
52
54
  export const TELEGRAM_BUS_FOLLOWER_REGISTRATION_RETRY_DELAY_MS =
@@ -167,6 +169,9 @@ export interface TelegramBusFollowerRegistrationState {
167
169
  getSlot: () => string | undefined;
168
170
  getThreadName: () => string | undefined;
169
171
  getGeneration: () => string | undefined;
172
+ beginRecovery: () => number;
173
+ cancelRecovery: () => void;
174
+ waitForGeneration: (timeoutMs?: number) => Promise<string | undefined>;
170
175
  getLeaderProtocol: () => TelegramBusProtocolIdentity | undefined;
171
176
  getEligibleElectionSlots: () => readonly string[];
172
177
  setEligibleElectionSlots: (slots: readonly string[]) => void;
@@ -214,6 +219,9 @@ export interface TelegramBusFollowerClientRuntimeDeps<TMessage = unknown> {
214
219
  getApiAuthSecret?: () => string | undefined;
215
220
  getForwardingAuthSecret?: () => string | undefined;
216
221
  getRegistrationGeneration: () => string | undefined;
222
+ waitForRegistrationGeneration?: (
223
+ timeoutMs?: number,
224
+ ) => Promise<string | undefined>;
217
225
  getForwardCommentBatchPosition?: (
218
226
  message: TMessage,
219
227
  ) => "comment" | "forward" | undefined;
@@ -231,6 +239,9 @@ export interface TelegramBusFollowerApiCallerDeps {
231
239
  createRequestId: () => string;
232
240
  getAuthSecret?: () => string | undefined;
233
241
  getRegistrationGeneration: () => string | undefined;
242
+ waitForRegistrationGeneration?: (
243
+ timeoutMs?: number,
244
+ ) => Promise<string | undefined>;
234
245
  getNowMs?: () => number;
235
246
  timeoutMs?: number;
236
247
  }
@@ -417,6 +428,7 @@ export interface TelegramBusFollowerHeartbeatRecoveryHandlerDeps<TContext> {
417
428
  | "getSlot"
418
429
  | "getThreadName"
419
430
  | "getEligibleElectionSlots"
431
+ | "beginRecovery"
420
432
  | "setRegistered"
421
433
  >;
422
434
  getRegistrationRuntime: () => TelegramBusFollowerRegistrationRuntime<TContext>;
@@ -638,6 +650,7 @@ export function createTelegramBusFollowerClientRuntime<
638
650
  socketPath: deps.socketPath,
639
651
  createRequestId,
640
652
  timeoutMs,
653
+ waitForRegistrationGeneration: deps.waitForRegistrationGeneration,
641
654
  };
642
655
  return {
643
656
  createRequestId,
@@ -695,14 +708,14 @@ export function createTelegramBusFollowerQueueHandoffClient(
695
708
  const timeoutMs =
696
709
  deps.timeoutMs ?? TELEGRAM_BUS_FOLLOWER_CLIENT_TIMEOUT_MS;
697
710
  return async (input) => {
698
- const registrationGeneration = deps.getRegistrationGeneration();
699
- if (!registrationGeneration) {
700
- throw new Error("Telegram bus follower is not registered.");
701
- }
711
+ const registration = await resolveTelegramBusFollowerRegistration(
712
+ deps,
713
+ timeoutMs,
714
+ );
702
715
  const socketPath = resolveTelegramBusSocketPath(deps.socketPath);
703
716
  const response = await sendTelegramBusLocalEnvelope({
704
717
  socketPath,
705
- timeoutMs,
718
+ timeoutMs: registration.remainingTimeoutMs,
706
719
  retry: getTelegramBusTransportRetryPolicy({
707
720
  endpoint: socketPath,
708
721
  operation: "operation",
@@ -712,7 +725,7 @@ export function createTelegramBusFollowerQueueHandoffClient(
712
725
  requestId: deps.createRequestId(),
713
726
  auth: deps.getAuthSecret?.(),
714
727
  instanceId: deps.instanceId,
715
- registrationGeneration,
728
+ registrationGeneration: registration.generation,
716
729
  ...input,
717
730
  sentAtMs: getNowMs(),
718
731
  },
@@ -770,11 +783,12 @@ export function createTelegramBusAgentMessageClient(
770
783
  envelope:
771
784
  | Extract<TelegramBusEnvelope, { kind: "follower.resolveAgentTarget" }>
772
785
  | Extract<TelegramBusEnvelope, { kind: "follower.routeAgentMessage" }>,
786
+ requestTimeoutMs = timeoutMs,
773
787
  ): Promise<unknown> => {
774
788
  const socketPath = resolveTelegramBusSocketPath(deps.socketPath);
775
789
  const response = await sendTelegramBusLocalEnvelope({
776
790
  socketPath,
777
- timeoutMs,
791
+ timeoutMs: requestTimeoutMs,
778
792
  retry: getTelegramBusTransportRetryPolicy({
779
793
  endpoint: socketPath,
780
794
  operation: "operation",
@@ -788,26 +802,30 @@ export function createTelegramBusAgentMessageClient(
788
802
  : "Telegram bus agent message did not return an acknowledgement.",
789
803
  );
790
804
  };
791
- const registrationFields = () => {
792
- const registrationGeneration = deps.getRegistrationGeneration();
793
- if (!registrationGeneration) {
794
- throw new Error("Telegram bus follower is not registered.");
795
- }
805
+ const registrationFields = async () => {
806
+ const registration = await resolveTelegramBusFollowerRegistration(
807
+ deps,
808
+ timeoutMs,
809
+ );
796
810
  return {
797
- auth: deps.getAuthSecret?.(),
798
- instanceId: deps.instanceId,
799
- registrationGeneration,
811
+ fields: {
812
+ auth: deps.getAuthSecret?.(),
813
+ instanceId: deps.instanceId,
814
+ registrationGeneration: registration.generation,
815
+ },
816
+ remainingTimeoutMs: registration.remainingTimeoutMs,
800
817
  };
801
818
  };
802
819
  return {
803
820
  async resolveTarget(selector) {
821
+ const registration = await registrationFields();
804
822
  const result = await request({
805
823
  kind: "follower.resolveAgentTarget",
806
824
  requestId: deps.createRequestId(),
807
- ...registrationFields(),
825
+ ...registration.fields,
808
826
  selector,
809
827
  sentAtMs: getNowMs(),
810
- });
828
+ }, registration.remainingTimeoutMs);
811
829
  if (!result || typeof result !== "object" || Array.isArray(result)) {
812
830
  throw new Error("Telegram bus returned an invalid agent target.");
813
831
  }
@@ -821,13 +839,14 @@ export function createTelegramBusAgentMessageClient(
821
839
  return { chatId: target.chatId, threadId: target.threadId };
822
840
  },
823
841
  async routeMessage(message) {
842
+ const registration = await registrationFields();
824
843
  await request({
825
844
  kind: "follower.routeAgentMessage",
826
845
  requestId: deps.createRequestId(),
827
- ...registrationFields(),
846
+ ...registration.fields,
828
847
  message,
829
848
  sentAtMs: getNowMs(),
830
- });
849
+ }, registration.remainingTimeoutMs);
831
850
  },
832
851
  };
833
852
  }
@@ -839,16 +858,16 @@ export function createTelegramBusFollowerApiCaller(
839
858
  const timeoutMs =
840
859
  deps.timeoutMs ?? TELEGRAM_BUS_FOLLOWER_CLIENT_TIMEOUT_MS;
841
860
  return async (method, args) => {
861
+ const registration = await resolveTelegramBusFollowerRegistration(
862
+ deps,
863
+ timeoutMs,
864
+ );
842
865
  const socketPath = resolveTelegramBusSocketPath(deps.socketPath);
843
- const registrationGeneration = deps.getRegistrationGeneration();
844
- if (!registrationGeneration) {
845
- throw new Error("Telegram bus follower is not registered.");
846
- }
847
866
  let response: TelegramBusEnvelope | undefined;
848
867
  try {
849
868
  response = await sendTelegramBusLocalEnvelope({
850
869
  socketPath,
851
- timeoutMs,
870
+ timeoutMs: registration.remainingTimeoutMs,
852
871
  retry: getTelegramBusTransportRetryPolicy({
853
872
  endpoint: socketPath,
854
873
  operation: "operation",
@@ -858,7 +877,7 @@ export function createTelegramBusFollowerApiCaller(
858
877
  requestId: deps.createRequestId(),
859
878
  auth: deps.getAuthSecret?.(),
860
879
  instanceId: deps.instanceId,
861
- registrationGeneration,
880
+ registrationGeneration: registration.generation,
862
881
  method,
863
882
  args,
864
883
  sentAtMs: getNowMs(),
@@ -880,6 +899,20 @@ export function createTelegramBusFollowerApiCaller(
880
899
  response?.kind === "bus.ack"
881
900
  ? response.message
882
901
  : "Telegram bus API call did not return an acknowledgement.";
902
+ if (
903
+ response?.kind === "bus.ack" &&
904
+ response.error?.code === "stale-target" &&
905
+ response.error.chatId !== undefined &&
906
+ response.error.threadId !== undefined
907
+ ) {
908
+ throw new TelegramApiStaleTargetError(
909
+ message ?? "Telegram thread target is stale.",
910
+ {
911
+ chatId: response.error.chatId,
912
+ threadId: response.error.threadId,
913
+ },
914
+ );
915
+ }
883
916
  if (
884
917
  response?.kind === "bus.ack" &&
885
918
  response.error?.code === "commit-unknown"
@@ -893,6 +926,29 @@ export function createTelegramBusFollowerApiCaller(
893
926
  };
894
927
  }
895
928
 
929
+ async function resolveTelegramBusFollowerRegistration(
930
+ deps: Pick<
931
+ TelegramBusFollowerApiCallerDeps,
932
+ | "getRegistrationGeneration"
933
+ | "waitForRegistrationGeneration"
934
+ | "getNowMs"
935
+ >,
936
+ timeoutMs: number,
937
+ ): Promise<{ generation: string; remainingTimeoutMs: number }> {
938
+ const current = deps.getRegistrationGeneration();
939
+ if (current) return { generation: current, remainingTimeoutMs: timeoutMs };
940
+ const getNowMs = deps.getNowMs ?? Date.now;
941
+ const startedAtMs = getNowMs();
942
+ const restored = await deps.waitForRegistrationGeneration?.(
943
+ timeoutMs,
944
+ );
945
+ const remainingTimeoutMs = Math.max(0, timeoutMs - (getNowMs() - startedAtMs));
946
+ if (restored && remainingTimeoutMs > 0) {
947
+ return { generation: restored, remainingTimeoutMs };
948
+ }
949
+ throw new Error("Telegram bus follower is not registered.");
950
+ }
951
+
896
952
  function isTelegramStaleContextError(error: unknown): boolean {
897
953
  return (
898
954
  error instanceof Error &&
@@ -1046,12 +1102,52 @@ export function createTelegramBusFollowerRegistrationState(
1046
1102
  let generation: string | undefined;
1047
1103
  let leaderProtocol: TelegramBusProtocolIdentity | undefined;
1048
1104
  let eligibleElectionSlots: string[] = [];
1105
+ let recoveryEpoch = 0;
1106
+ let activeRecoveryEpoch: number | undefined;
1107
+ const generationWaiters = new Set<{
1108
+ epoch: number;
1109
+ settle: (value: string | undefined) => void;
1110
+ }>();
1111
+ const settleGenerationWaiters = (
1112
+ value: string | undefined,
1113
+ epoch?: number,
1114
+ ) => {
1115
+ for (const waiter of [...generationWaiters]) {
1116
+ if (epoch === undefined || waiter.epoch === epoch) waiter.settle(value);
1117
+ }
1118
+ };
1049
1119
  return {
1050
1120
  isRegistered: () => registered,
1051
1121
  getTarget: () => (target ? { ...target } : undefined),
1052
1122
  getSlot: () => slot,
1053
1123
  getThreadName: () => threadName,
1054
1124
  getGeneration: () => generation,
1125
+ beginRecovery: () => {
1126
+ if (activeRecoveryEpoch !== undefined) return activeRecoveryEpoch;
1127
+ activeRecoveryEpoch = ++recoveryEpoch;
1128
+ return activeRecoveryEpoch;
1129
+ },
1130
+ cancelRecovery: () => {
1131
+ const epoch = activeRecoveryEpoch;
1132
+ activeRecoveryEpoch = undefined;
1133
+ if (epoch !== undefined) settleGenerationWaiters(undefined, epoch);
1134
+ },
1135
+ waitForGeneration: (timeoutMs = TELEGRAM_BUS_FOLLOWER_REGISTRATION_WAIT_MS) => {
1136
+ if (generation) return Promise.resolve(generation);
1137
+ const epoch = activeRecoveryEpoch;
1138
+ if (epoch === undefined) return Promise.resolve(undefined);
1139
+ return new Promise((resolve) => {
1140
+ let timer: NodeJS.Timeout | undefined;
1141
+ const settle = (value: string | undefined) => {
1142
+ generationWaiters.delete(waiter);
1143
+ if (timer) clearTimeout(timer);
1144
+ resolve(value);
1145
+ };
1146
+ const waiter = { epoch, settle };
1147
+ generationWaiters.add(waiter);
1148
+ timer = setTimeout(() => settle(undefined), Math.max(0, timeoutMs));
1149
+ });
1150
+ },
1055
1151
  getLeaderProtocol: () =>
1056
1152
  leaderProtocol
1057
1153
  ? { ...leaderProtocol, capabilities: [...leaderProtocol.capabilities] }
@@ -1077,6 +1173,10 @@ export function createTelegramBusFollowerRegistrationState(
1077
1173
  }
1078
1174
  : undefined;
1079
1175
  if (availabilityChanged) options.onAvailabilityChanged?.();
1176
+ if (generation) {
1177
+ activeRecoveryEpoch = undefined;
1178
+ settleGenerationWaiters(generation);
1179
+ }
1080
1180
  },
1081
1181
  };
1082
1182
  }
@@ -1253,6 +1353,7 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
1253
1353
  ): Promise<void> => {
1254
1354
  if (promotionPending) return;
1255
1355
  promotionPending = true;
1356
+ deps.registrationState.beginRecovery();
1256
1357
  try {
1257
1358
  const initialBinding = carriedBinding ?? snapshotBinding();
1258
1359
  const state = deps.getLeaderState();
@@ -1366,6 +1467,7 @@ export function createTelegramBusFollowerRegistrationRuntime<
1366
1467
  heartbeatPromise = undefined;
1367
1468
  heartbeatPromiseGeneration = undefined;
1368
1469
  deps.setActiveAuthSecret?.(undefined);
1470
+ deps.registrationState?.cancelRecovery();
1369
1471
  deps.registrationState?.setRegistered(false);
1370
1472
  lastKnownTarget = undefined;
1371
1473
  lastKnownSlot = undefined;
@@ -8,6 +8,7 @@
8
8
  import * as Sync from "./sync.ts";
9
9
  import * as ThreadReconciler from "./thread-reconciler.ts";
10
10
  import {
11
+ getTelegramApiErrorRequestTarget,
11
12
  isTelegramApiCommitUnknownError,
12
13
  type TelegramApiCallOptions,
13
14
  } from "./telegram-api.ts";
@@ -1756,6 +1757,10 @@ async function handleFollowerApiCall(
1756
1757
  result,
1757
1758
  };
1758
1759
  } catch (error) {
1760
+ const staleTarget =
1761
+ Threads.isTelegramTopicTargetStaleError(error)
1762
+ ? getTelegramApiErrorRequestTarget(error)
1763
+ : undefined;
1759
1764
  return {
1760
1765
  kind: "bus.ack",
1761
1766
  requestId: envelope.requestId,
@@ -1764,14 +1769,22 @@ async function handleFollowerApiCall(
1764
1769
  error instanceof Error
1765
1770
  ? error.message
1766
1771
  : "Telegram bus API call failed.",
1767
- ...(isTelegramApiCommitUnknownError(error)
1772
+ ...(staleTarget
1768
1773
  ? {
1769
1774
  error: {
1770
- code: "commit-unknown" as const,
1771
- method: error.method,
1775
+ code: "stale-target" as const,
1776
+ chatId: staleTarget.chatId,
1777
+ threadId: staleTarget.threadId,
1772
1778
  },
1773
1779
  }
1774
- : {}),
1780
+ : isTelegramApiCommitUnknownError(error)
1781
+ ? {
1782
+ error: {
1783
+ code: "commit-unknown" as const,
1784
+ method: error.method,
1785
+ },
1786
+ }
1787
+ : {}),
1775
1788
  };
1776
1789
  }
1777
1790
  }