@llblab/pi-kit 0.27.5 → 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.
Files changed (194) hide show
  1. package/BACKLOG.md +3 -1
  2. package/CHANGELOG.md +9 -0
  3. package/README.md +4 -4
  4. package/node_modules/@llblab/pi-state-flow/AGENTS.md +1 -1
  5. package/node_modules/@llblab/pi-state-flow/BACKLOG.md +6 -2
  6. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +4 -1
  7. package/node_modules/@llblab/pi-state-flow/dist/lib/compaction.js +4 -3
  8. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +26 -13
  9. package/node_modules/@llblab/pi-state-flow/dist/package.json +1 -1
  10. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +3 -1
  11. package/node_modules/@llblab/pi-state-flow/docs/compatibility.md +2 -2
  12. package/node_modules/@llblab/pi-state-flow/docs/usage.md +2 -1
  13. package/node_modules/@llblab/pi-state-flow/lib/compaction.ts +4 -3
  14. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +25 -12
  15. package/node_modules/@llblab/pi-state-flow/package.json +1 -1
  16. package/node_modules/@llblab/pi-telegram/AGENTS.md +10 -8
  17. package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -9
  18. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +13 -0
  19. package/node_modules/@llblab/pi-telegram/LICENSE +21 -0
  20. package/node_modules/@llblab/pi-telegram/README.md +15 -5
  21. package/node_modules/@llblab/pi-telegram/dist/lib/activity-verbosity.d.ts +1 -5
  22. package/node_modules/@llblab/pi-telegram/dist/lib/activity-verbosity.js +5 -7
  23. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +5 -1
  24. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +6 -1
  25. package/node_modules/@llblab/pi-telegram/dist/lib/bus-api.js +4 -2
  26. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +54 -56
  27. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +206 -139
  28. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.d.ts +21 -2
  29. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +181 -25
  30. package/node_modules/@llblab/pi-telegram/dist/lib/bus-transport.d.ts +4 -1
  31. package/node_modules/@llblab/pi-telegram/dist/lib/bus-transport.js +28 -4
  32. package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +52 -56
  33. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +78 -246
  34. package/node_modules/@llblab/pi-telegram/dist/lib/channel-posts.d.ts +1 -3
  35. package/node_modules/@llblab/pi-telegram/dist/lib/channel-posts.js +23 -26
  36. package/node_modules/@llblab/pi-telegram/dist/lib/command-templates.d.ts +0 -5
  37. package/node_modules/@llblab/pi-telegram/dist/lib/command-templates.js +5 -5
  38. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +0 -23
  39. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +15 -15
  40. package/node_modules/@llblab/pi-telegram/dist/lib/config.d.ts +0 -17
  41. package/node_modules/@llblab/pi-telegram/dist/lib/config.js +12 -14
  42. package/node_modules/@llblab/pi-telegram/dist/lib/delivery.d.ts +0 -4
  43. package/node_modules/@llblab/pi-telegram/dist/lib/delivery.js +2 -5
  44. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +255 -61
  45. package/node_modules/@llblab/pi-telegram/dist/lib/generative-apps.d.ts +0 -19
  46. package/node_modules/@llblab/pi-telegram/dist/lib/inbound.d.ts +0 -2
  47. package/node_modules/@llblab/pi-telegram/dist/lib/inbound.js +2 -2
  48. package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +177 -16
  49. package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +1115 -249
  50. package/node_modules/@llblab/pi-telegram/dist/lib/keyboard.d.ts +0 -2
  51. package/node_modules/@llblab/pi-telegram/dist/lib/keyboard.js +2 -2
  52. package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.js +1 -1
  53. package/node_modules/@llblab/pi-telegram/dist/lib/locks.d.ts +100 -12
  54. package/node_modules/@llblab/pi-telegram/dist/lib/locks.js +380 -14
  55. package/node_modules/@llblab/pi-telegram/dist/lib/logging.d.ts +30 -12
  56. package/node_modules/@llblab/pi-telegram/dist/lib/logging.js +129 -72
  57. package/node_modules/@llblab/pi-telegram/dist/lib/media.d.ts +28 -5
  58. package/node_modules/@llblab/pi-telegram/dist/lib/media.js +26 -8
  59. package/node_modules/@llblab/pi-telegram/dist/lib/menu-model.d.ts +0 -11
  60. package/node_modules/@llblab/pi-telegram/dist/lib/menu-model.js +8 -8
  61. package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.d.ts +0 -18
  62. package/node_modules/@llblab/pi-telegram/dist/lib/menu-settings.js +18 -18
  63. package/node_modules/@llblab/pi-telegram/dist/lib/menu-status.d.ts +4 -4
  64. package/node_modules/@llblab/pi-telegram/dist/lib/menu-status.js +12 -7
  65. package/node_modules/@llblab/pi-telegram/dist/lib/menu-thinking.d.ts +1 -1
  66. package/node_modules/@llblab/pi-telegram/dist/lib/menu-thinking.js +1 -1
  67. package/node_modules/@llblab/pi-telegram/dist/lib/menu.d.ts +1 -0
  68. package/node_modules/@llblab/pi-telegram/dist/lib/menu.js +4 -3
  69. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.d.ts +0 -1
  70. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.js +1 -1
  71. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-buttons.js +1 -8
  72. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-markup.d.ts +1 -6
  73. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-markup.js +4 -21
  74. package/node_modules/@llblab/pi-telegram/dist/lib/outbound.d.ts +0 -8
  75. package/node_modules/@llblab/pi-telegram/dist/lib/outbound.js +5 -5
  76. package/node_modules/@llblab/pi-telegram/dist/lib/paths.d.ts +50 -7
  77. package/node_modules/@llblab/pi-telegram/dist/lib/paths.js +155 -17
  78. package/node_modules/@llblab/pi-telegram/dist/lib/pi.d.ts +1 -0
  79. package/node_modules/@llblab/pi-telegram/dist/lib/pi.js +2 -1
  80. package/node_modules/@llblab/pi-telegram/dist/lib/polling.d.ts +0 -5
  81. package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +5 -5
  82. package/node_modules/@llblab/pi-telegram/dist/lib/preview.d.ts +0 -3
  83. package/node_modules/@llblab/pi-telegram/dist/lib/preview.js +3 -3
  84. package/node_modules/@llblab/pi-telegram/dist/lib/prompt-templates.d.ts +0 -1
  85. package/node_modules/@llblab/pi-telegram/dist/lib/prompt-templates.js +1 -1
  86. package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +3 -15
  87. package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +69 -8
  88. package/node_modules/@llblab/pi-telegram/dist/lib/recovery.d.ts +29 -9
  89. package/node_modules/@llblab/pi-telegram/dist/lib/recovery.js +104 -33
  90. package/node_modules/@llblab/pi-telegram/dist/lib/replies.d.ts +0 -4
  91. package/node_modules/@llblab/pi-telegram/dist/lib/replies.js +1 -1
  92. package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +73 -8
  93. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +2206 -307
  94. package/node_modules/@llblab/pi-telegram/dist/lib/sections.d.ts +0 -2
  95. package/node_modules/@llblab/pi-telegram/dist/lib/sections.js +1 -1
  96. package/node_modules/@llblab/pi-telegram/dist/lib/status.d.ts +44 -9
  97. package/node_modules/@llblab/pi-telegram/dist/lib/status.js +149 -27
  98. package/node_modules/@llblab/pi-telegram/dist/lib/sync.d.ts +0 -5
  99. package/node_modules/@llblab/pi-telegram/dist/lib/sync.js +5 -4
  100. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +11 -3
  101. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +34 -23
  102. package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.d.ts +1 -0
  103. package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.js +18 -21
  104. package/node_modules/@llblab/pi-telegram/dist/lib/thread-naming.d.ts +32 -5
  105. package/node_modules/@llblab/pi-telegram/dist/lib/thread-naming.js +191 -7
  106. package/node_modules/@llblab/pi-telegram/dist/lib/thread-reconciler.d.ts +2 -0
  107. package/node_modules/@llblab/pi-telegram/dist/lib/thread-reconciler.js +1 -1
  108. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +291 -59
  109. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +1647 -369
  110. package/node_modules/@llblab/pi-telegram/dist/lib/turns.d.ts +0 -1
  111. package/node_modules/@llblab/pi-telegram/dist/lib/turns.js +1 -1
  112. package/node_modules/@llblab/pi-telegram/dist/lib/updates.d.ts +184 -28
  113. package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +1032 -114
  114. package/node_modules/@llblab/pi-telegram/dist/lib/wire.d.ts +12 -0
  115. package/node_modules/@llblab/pi-telegram/dist/lib/wire.js +21 -0
  116. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.d.ts +25 -17
  117. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.js +95 -20
  118. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-identity.d.ts +20 -0
  119. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-identity.js +103 -0
  120. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +43 -8
  121. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +148 -52
  122. package/node_modules/@llblab/pi-telegram/dist/package.json +6 -6
  123. package/node_modules/@llblab/pi-telegram/dist/skills/generated-control-surface/SKILL.md +3 -3
  124. package/node_modules/@llblab/pi-telegram/dist/skills/telegram-bridge/references/diagnosis.md +3 -3
  125. package/node_modules/@llblab/pi-telegram/docs/README.md +1 -1
  126. package/node_modules/@llblab/pi-telegram/docs/activity.md +1 -1
  127. package/node_modules/@llblab/pi-telegram/docs/architecture.md +152 -34
  128. package/node_modules/@llblab/pi-telegram/docs/callback-namespaces.md +1 -1
  129. package/node_modules/@llblab/pi-telegram/docs/delivery.md +1 -1
  130. package/node_modules/@llblab/pi-telegram/docs/generative-apps.md +2 -2
  131. package/node_modules/@llblab/pi-telegram/docs/inbound.md +1 -1
  132. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +125 -19
  133. package/node_modules/@llblab/pi-telegram/docs/public-api.md +3 -1
  134. package/node_modules/@llblab/pi-telegram/docs/ui-style.md +22 -8
  135. package/node_modules/@llblab/pi-telegram/docs/updates.md +1 -1
  136. package/node_modules/@llblab/pi-telegram/lib/activity-verbosity.ts +5 -9
  137. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +6 -0
  138. package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +5 -2
  139. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +243 -224
  140. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +178 -31
  141. package/node_modules/@llblab/pi-telegram/lib/bus-transport.ts +30 -8
  142. package/node_modules/@llblab/pi-telegram/lib/bus.ts +110 -334
  143. package/node_modules/@llblab/pi-telegram/lib/channel-posts.ts +29 -29
  144. package/node_modules/@llblab/pi-telegram/lib/command-templates.ts +5 -5
  145. package/node_modules/@llblab/pi-telegram/lib/commands.ts +15 -15
  146. package/node_modules/@llblab/pi-telegram/lib/config.ts +12 -17
  147. package/node_modules/@llblab/pi-telegram/lib/delivery.ts +2 -8
  148. package/node_modules/@llblab/pi-telegram/lib/extension.ts +257 -72
  149. package/node_modules/@llblab/pi-telegram/lib/generative-apps.ts +0 -22
  150. package/node_modules/@llblab/pi-telegram/lib/inbound.ts +2 -2
  151. package/node_modules/@llblab/pi-telegram/lib/journal.ts +1182 -326
  152. package/node_modules/@llblab/pi-telegram/lib/keyboard.ts +2 -2
  153. package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +1 -1
  154. package/node_modules/@llblab/pi-telegram/lib/locks.ts +402 -23
  155. package/node_modules/@llblab/pi-telegram/lib/logging.ts +151 -101
  156. package/node_modules/@llblab/pi-telegram/lib/media.ts +51 -9
  157. package/node_modules/@llblab/pi-telegram/lib/menu-model.ts +8 -8
  158. package/node_modules/@llblab/pi-telegram/lib/menu-settings.ts +18 -18
  159. package/node_modules/@llblab/pi-telegram/lib/menu-status.ts +11 -0
  160. package/node_modules/@llblab/pi-telegram/lib/menu-thinking.ts +2 -2
  161. package/node_modules/@llblab/pi-telegram/lib/menu.ts +5 -1
  162. package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +1 -1
  163. package/node_modules/@llblab/pi-telegram/lib/outbound-buttons.ts +1 -10
  164. package/node_modules/@llblab/pi-telegram/lib/outbound-markup.ts +5 -24
  165. package/node_modules/@llblab/pi-telegram/lib/outbound.ts +5 -5
  166. package/node_modules/@llblab/pi-telegram/lib/paths.ts +177 -17
  167. package/node_modules/@llblab/pi-telegram/lib/pi.ts +5 -2
  168. package/node_modules/@llblab/pi-telegram/lib/polling.ts +5 -5
  169. package/node_modules/@llblab/pi-telegram/lib/preview.ts +3 -3
  170. package/node_modules/@llblab/pi-telegram/lib/prompt-templates.ts +1 -1
  171. package/node_modules/@llblab/pi-telegram/lib/queue.ts +67 -9
  172. package/node_modules/@llblab/pi-telegram/lib/recovery.ts +104 -44
  173. package/node_modules/@llblab/pi-telegram/lib/replies.ts +1 -1
  174. package/node_modules/@llblab/pi-telegram/lib/routing.ts +1955 -375
  175. package/node_modules/@llblab/pi-telegram/lib/sections.ts +1 -1
  176. package/node_modules/@llblab/pi-telegram/lib/status.ts +156 -36
  177. package/node_modules/@llblab/pi-telegram/lib/sync.ts +4 -4
  178. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +48 -33
  179. package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +24 -21
  180. package/node_modules/@llblab/pi-telegram/lib/thread-naming.ts +267 -9
  181. package/node_modules/@llblab/pi-telegram/lib/thread-reconciler.ts +3 -1
  182. package/node_modules/@llblab/pi-telegram/lib/threads.ts +1697 -488
  183. package/node_modules/@llblab/pi-telegram/lib/turns.ts +1 -1
  184. package/node_modules/@llblab/pi-telegram/lib/updates.ts +1072 -151
  185. package/node_modules/@llblab/pi-telegram/lib/wire.ts +28 -0
  186. package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +97 -47
  187. package/node_modules/@llblab/pi-telegram/lib/workspace-identity.ts +147 -0
  188. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +188 -89
  189. package/node_modules/@llblab/pi-telegram/package.json +6 -6
  190. package/node_modules/@llblab/pi-telegram/scripts/audit-exports.mjs +100 -0
  191. package/node_modules/@llblab/pi-telegram/scripts/check-downgrade.mjs +80 -43
  192. package/node_modules/@llblab/pi-telegram/skills/generated-control-surface/SKILL.md +3 -3
  193. package/node_modules/@llblab/pi-telegram/skills/telegram-bridge/references/diagnosis.md +3 -3
  194. package/package.json +3 -3
@@ -4,12 +4,15 @@
4
4
  * Wires authorized updates into menus, commands, media grouping, and prompt queueing, and owns exact assistant-output target/route authority capture
5
5
  */
6
6
 
7
+ import { randomBytes } from "node:crypto";
7
8
  import { readFile } from "node:fs/promises";
8
9
  import { basename, dirname } from "node:path";
10
+ import { isDeepStrictEqual } from "node:util";
9
11
  import * as Bus from "./bus.ts";
10
12
  import * as Commands from "./commands.ts";
11
13
  import type { TelegramConfigStore } from "./config.ts";
12
14
  import type { TelegramInboundHandlerRuntime } from "./inbound.ts";
15
+ import type { TelegramUpdateJournalQueuedCompletion, TelegramUpdateJournalQueuedReceiptEvidence } from "./journal.ts";
13
16
  import * as Media from "./media.ts";
14
17
  import * as Menu from "./menu.ts";
15
18
  import * as Model from "./model.ts";
@@ -19,7 +22,7 @@ import * as Queue from "./queue.ts";
19
22
  import * as Replies from "./replies.ts";
20
23
  import type { TelegramBridgeRuntime } from "./runtime.ts";
21
24
  import type { TelegramSectionRegistry } from "./sections.ts";
22
- import type { TelegramInputRichMessage } from "./telegram-api.ts";
25
+ import type { TelegramApiCallOptions, TelegramInputRichMessage } from "./telegram-api.ts";
23
26
  import * as TextGroups from "./text-groups.ts";
24
27
  import * as ThreadNaming from "./thread-naming.ts";
25
28
  import * as ThreadReconciler from "./thread-reconciler.ts";
@@ -28,6 +31,8 @@ import type {
28
31
  TelegramTopicTargetRecord,
29
32
  } from "./threads.ts";
30
33
  import * as Turns from "./turns.ts";
34
+ import * as WorkspaceIdentity from "./workspace-identity.ts";
35
+ import type { createTelegramWorkspaceExternalProtectionCapture } from "./workspace-retirement.ts";
31
36
 
32
37
  interface TelegramPromptPeerView {
33
38
  id?: unknown;
@@ -106,6 +111,19 @@ export function resolveTelegramGuestPromptPeer(input: {
106
111
  return undefined;
107
112
  }
108
113
 
114
+ /** Stable file scope of the remote Guest Mode peer: username, else numeric id; never the bot's own scope. */
115
+ export function resolveTelegramGuestFileScope(input: Parameters<typeof resolveTelegramGuestPromptPeer>[0]): string {
116
+ const candidates = input.chatType !== "private" ? [input.chat]
117
+ : [input.from, input.chat, input.guestBotCallerUser, input.guestBotCallerChat, input.replyFrom];
118
+ for (const candidate of candidates) {
119
+ if (!candidate || (input.chatType === "private" &&
120
+ (isTelegramPromptOwnerPeer(candidate, input.ownerUserId) || isTelegramPromptBotPeer(candidate)))) continue;
121
+ if (typeof candidate.username === "string" && candidate.username.length > 0) return candidate.username;
122
+ if (typeof candidate.id === "number" && Number.isSafeInteger(candidate.id)) return String(Math.abs(candidate.id));
123
+ }
124
+ return "guest";
125
+ }
126
+
109
127
  function appendTelegramSourceAttachmentSection(
110
128
  text: string,
111
129
  from: string | undefined,
@@ -177,7 +195,12 @@ function escapeHtml(text: string): string {
177
195
 
178
196
  const TELEGRAM_UNBOUND_REROUTE_CALLBACK_PREFIX = "reroute:";
179
197
  const TELEGRAM_UNBOUND_REROUTE_RESTORE_MENU_CALLBACK_PREFIX = "rerouterestore:";
198
+ const TELEGRAM_UNBOUND_REROUTE_MENU_CALLBACK_PREFIX = "reroutemenu:";
199
+ const TELEGRAM_UNBOUND_REROUTE_ROOT_CALLBACK_PREFIX = "rerouteroot:";
180
200
  const TELEGRAM_UNBOUND_REROUTE_NEW_SLOT_CALLBACK_PREFIX = "reroutenew:";
201
+ const TELEGRAM_UNBOUND_REROUTE_CANCEL_CALLBACK_PREFIX = "reroutecancel:";
202
+ const TELEGRAM_PENDING_CANCELLATION_REVIEW_PREFIX = "reroutecancel:review:";
203
+ const TELEGRAM_RETIRED_HISTORICAL_REVIEW_PREFIX = "reroutecancel:history:";
181
204
  const TELEGRAM_SLOT_CAPACITY_MESSAGE =
182
205
  "No Telegram instance slot is available. Automatic reclamation is disabled for safety.";
183
206
 
@@ -203,10 +226,10 @@ function formatTelegramUnboundRerouteNewSlotCallbackData(
203
226
 
204
227
  function parseTelegramUnboundRerouteRestoreMenuCallbackData(
205
228
  data: string | undefined,
206
- ): { rerouteId: string } | undefined {
207
- const match = data?.match(/^rerouterestore:([a-z0-9]+)$/);
208
- const rerouteId = match?.[1];
209
- return rerouteId ? { rerouteId } : undefined;
229
+ ): { rerouteId: string; restore: boolean; root: boolean } | undefined {
230
+ const match = data?.match(/^(rerouterestore|reroutemenu|rerouteroot):([a-z0-9]+)$/);
231
+ const rerouteId = match?.[2];
232
+ return rerouteId ? { rerouteId, restore: match?.[1] === "rerouterestore", root: match?.[1] === "rerouteroot" } : undefined;
210
233
  }
211
234
 
212
235
  function parseTelegramUnboundRerouteCallbackData(
@@ -228,22 +251,14 @@ function getTelegramThreadRecordLabel(
228
251
  return getDisplayTitle?.(record.target) ?? getRestoredThreadName(record, record.slot ?? "");
229
252
  }
230
253
 
231
- function getNextTelegramSlotPreference(
232
- slot: string | undefined,
233
- ): string | undefined {
234
- if (!slot || !/^[A-Z]$/.test(slot)) return undefined;
235
- const index = slot.charCodeAt(0) - "A".charCodeAt(0);
236
- return String.fromCharCode("A".charCodeAt(0) + ((index + 1) % 26));
237
- }
238
-
239
254
  function getRestoredThreadName(
240
255
  record: Threads.TelegramTopicTargetRecord,
241
256
  slot: string,
242
257
  ): string {
243
258
  return record.threadName &&
244
- Threads.isTelegramTopicThreadNameValidForSlot(record.threadName, slot)
259
+ ThreadNaming.isTelegramTopicThreadNameValidForSlot(record.threadName, slot)
245
260
  ? record.threadName
246
- : (Threads.chooseTelegramThreadName({ slot }) ?? "Pi");
261
+ : (ThreadNaming.chooseTelegramThreadName({ slot }) ?? "Pi");
247
262
  }
248
263
 
249
264
  function isTelegramLiveThreadTarget(
@@ -269,6 +284,15 @@ function getTelegramRoutableThreadRecords(
269
284
  );
270
285
  }
271
286
 
287
+ function formatTelegramTemporaryThreadChooserText(command?: string): string {
288
+ return [
289
+ "<b>🧵 Choose target thread:</b>",
290
+ "",
291
+ command ? `You used <code>/${escapeHtml(command)}</code> from the <b>All</b> tab.` : "Choose where to send your message.",
292
+ "Select the Pi thread that should handle it, or restore a Pi into this tab:",
293
+ ].join("\n");
294
+ }
295
+
272
296
  function formatTelegramAllTabMenuChooserText(command: string): string {
273
297
  return [
274
298
  "<b>🧵 Choose target thread:</b>",
@@ -284,34 +308,23 @@ function buildTelegramUnboundRerouteChooserMarkup(
284
308
  records: readonly Threads.TelegramTopicTargetRecord[],
285
309
  options: {
286
310
  canRestore: boolean;
311
+ canCancel?: boolean;
287
312
  getDisplayTitle?: Threads.TelegramCurrentThreadAssembly["getDisplayTitle"];
288
313
  },
289
314
  ): Menu.TelegramReplyMarkup {
290
315
  const activeRecords = records.filter((record) => record.status === "active");
291
316
  const canRestoreAnyLiveThread = options.canRestore && activeRecords.length > 0;
292
- const rows = activeRecords.map((record) => [
293
- {
294
- text: `↪️ ${getTelegramThreadRecordLabel(record, options.getDisplayTitle)}`,
295
- callback_data: formatTelegramUnboundRerouteCallbackData(
296
- rerouteId,
297
- record.target.threadId,
298
- ),
299
- },
300
- ]);
301
- return {
302
- inline_keyboard: canRestoreAnyLiveThread
303
- ? [
304
- ...rows,
305
- [
306
- {
307
- text: "🔁 Replace/restore thread…",
308
- callback_data:
309
- formatTelegramUnboundRerouteRestoreMenuCallbackData(rerouteId),
310
- },
311
- ],
312
- ]
313
- : rows,
314
- };
317
+ const rows = activeRecords.length
318
+ ? [[{ text: "↪️ Reroute…", callback_data: `${TELEGRAM_UNBOUND_REROUTE_MENU_CALLBACK_PREFIX}${rerouteId}` }]] : [];
319
+ if (canRestoreAnyLiveThread) rows.push([{
320
+ text: "🔁 Restore…",
321
+ callback_data: formatTelegramUnboundRerouteRestoreMenuCallbackData(rerouteId),
322
+ }]);
323
+ if (options.canCancel) rows.push([{
324
+ text: "⛔️ Cancel routing",
325
+ callback_data: `${TELEGRAM_UNBOUND_REROUTE_CANCEL_CALLBACK_PREFIX}${rerouteId}`,
326
+ }]);
327
+ return { inline_keyboard: rows };
315
328
  }
316
329
 
317
330
  function buildTelegramUnboundRerouteRestoreChooserMarkup(
@@ -357,26 +370,102 @@ function formatTelegramTargetKey(target: Queue.TelegramQueueTarget): string {
357
370
  return `${target.chatId}:${target.threadId ?? "all"}`;
358
371
  }
359
372
 
360
- function formatTelegramUnboundRerouteChooserText(
361
- _records: readonly Threads.TelegramTopicTargetRecord[],
362
- options: { includeGuidance?: boolean } = {},
363
- ): string {
364
- const rerouteText = [
365
- "<b>🧵 Choose target thread:</b>",
366
- "",
367
- "Your message is still in this Telegram thread.",
368
- "Select the Pi thread that should handle it:",
369
- ].join("\n");
370
- return options.includeGuidance === false
371
- ? rerouteText
372
- : [formatTelegramUnboundTopicGuidance(), "", rerouteText].join("\n");
373
- }
374
-
375
373
  import * as Threads from "./threads.ts";
376
374
  import type { TelegramUser } from "./updates.ts";
377
375
  import * as Updates from "./updates.ts";
378
376
  import { getTelegramVoiceReplyMode } from "./voice.ts";
379
377
 
378
+ /** One admitted Restore attempt through recipient readiness, never source dispatch or cleanup. */
379
+ export async function advanceTelegramWorkspaceRestore(input: {
380
+ request: Threads.TelegramWorkspaceRestoreRequest;
381
+ authority: Threads.TelegramWorkspaceRestoreAuthority;
382
+ restoreStore: Threads.TelegramWorkspaceRestore;
383
+ getRecipient: () => Threads.TelegramWorkspaceRestoreRecipient | undefined;
384
+ /** Adapter proves current canonical ownership and authenticates observations; apply needs a fresh issuance. */
385
+ runRecipient: (input: { intent: Threads.TelegramWorkspaceRestoreIntent; mode: "apply" | "inspect"; isCurrent: () => boolean }) =>
386
+ Promise<{ operationId: string; recipient: Threads.TelegramWorkspaceRestoreRecipient;
387
+ target: Threads.TelegramWorkspaceRestoreRequest["target"]; slot: string; ready: boolean } | undefined>;
388
+ /**
389
+ * Recovery for a same-session successor already on the relocated target: it may receive the first grant, but only
390
+ * read-only inspection runs. It never commits a new relocation or applies a target.
391
+ */
392
+ inspectOnly?: true;
393
+ }): Promise<Threads.TelegramWorkspaceRestoreIntent | undefined> {
394
+ const isCurrent = input.authority.isCurrent.bind(input.authority);
395
+ if (!isCurrent()) return undefined;
396
+ const request = structuredClone(input.request);
397
+ if (!Threads.isTelegramWorkspaceRestoreRequest(request)) return undefined;
398
+ const getRecipient = input.getRecipient;
399
+ const runRecipient = input.runRecipient;
400
+ const observedRecipient = getRecipient();
401
+ if (!Threads.isTelegramWorkspaceRestoreRecipient(observedRecipient) || observedRecipient.sessionId !== request.binding.sessionId) return undefined;
402
+ const recipient = structuredClone(observedRecipient);
403
+ const executor = structuredClone(input.authority.executor);
404
+ const operatorUserId = input.authority.operatorUserId;
405
+ const current = (): boolean => isCurrent() && input.authority.operatorUserId === operatorUserId &&
406
+ isDeepStrictEqual(input.authority.executor, executor) && isDeepStrictEqual(getRecipient(), recipient);
407
+ const authority = { executor, operatorUserId, isCurrent: current };
408
+ const { restoreStore } = input;
409
+ const retained = (): Threads.TelegramWorkspaceRestoreIntent | undefined => {
410
+ if (!current()) return undefined;
411
+ const found = restoreStore.list().find(value => value.request.operationId === request.operationId);
412
+ return current() && found && isDeepStrictEqual(found.request, request) && found.operatorUserId === operatorUserId &&
413
+ isDeepStrictEqual(found.executor, executor) ? found : undefined;
414
+ };
415
+ let intent = retained();
416
+ const predecessor = intent ? undefined : restoreStore.list().find(value => value.request.operationId === request.operationId);
417
+ if (predecessor && current() && isDeepStrictEqual(predecessor.request, request) &&
418
+ predecessor.operatorUserId === operatorUserId && !isDeepStrictEqual(predecessor.executor, executor)) {
419
+ // Adoption transfers executor authority only. Issued grants stay issued, so a successor can merely inspect them.
420
+ try { intent = restoreStore.adopt(predecessor, authority); }
421
+ catch (error) {
422
+ intent = retained();
423
+ if (!intent) throw error;
424
+ }
425
+ if (!current() || !intent) return undefined;
426
+ intent = retained();
427
+ if (!intent) return undefined;
428
+ }
429
+ if (input.inspectOnly && !intent) return undefined;
430
+ if (!intent || intent.phase === "relocated") {
431
+ if ((!input.inspectOnly && recipient.instanceId !== request.owner.instanceId) ||
432
+ recipient.kind !== (request.owner.owner?.kind === "leader" ? "leader" : "follower")) return undefined;
433
+ intent = await restoreStore.commit(request, authority);
434
+ if (!current() || !intent) return undefined;
435
+ }
436
+ let mode: "apply" | "inspect" = "inspect";
437
+ if (intent.phase === "relocated") {
438
+ try {
439
+ const issuance = restoreStore.issueRecipient(intent, recipient, authority);
440
+ if (issuance) { intent = issuance.intent; mode = input.inspectOnly ? "inspect" : "apply"; }
441
+ else intent = retained();
442
+ } catch (error) {
443
+ intent = retained();
444
+ if (intent?.phase !== "recipient-issued") throw error;
445
+ // A retained issued phase is not a fresh grant, even when publication's reply was lost.
446
+ }
447
+ }
448
+ if (!current() || !intent || !["recipient-issued", "ready"].includes(intent.phase) ||
449
+ (mode === "apply" && !isDeepStrictEqual(intent.recipient, recipient))) return undefined;
450
+ const expected = structuredClone(intent);
451
+ if (!isDeepStrictEqual(retained(), expected)) return undefined;
452
+ const observation = await runRecipient({ intent: structuredClone(expected), mode, isCurrent: current });
453
+ if (!current() || !isDeepStrictEqual(retained(), expected) || !observation || observation.ready !== true ||
454
+ observation.operationId !== request.operationId || !isDeepStrictEqual(observation.recipient, recipient) ||
455
+ !isDeepStrictEqual(observation.target, request.target) || observation.slot !== request.binding.slot) return undefined;
456
+ if (expected.phase === "ready" && isDeepStrictEqual(expected.readyRecipient ?? expected.recipient, recipient)) return retained();
457
+ try {
458
+ intent = mode === "inspect" ? restoreStore.confirmInspectedReady(expected, recipient, authority) :
459
+ restoreStore.confirmReady(expected, recipient, authority);
460
+ } catch (error) {
461
+ intent = retained();
462
+ if (intent?.phase !== "ready" || !isDeepStrictEqual(intent.readyRecipient ?? intent.recipient, recipient)) throw error;
463
+ }
464
+ const confirmed = retained();
465
+ return current() && confirmed?.phase === "ready" &&
466
+ isDeepStrictEqual(confirmed.readyRecipient ?? confirmed.recipient, recipient) ? confirmed : undefined;
467
+ }
468
+
380
469
  async function deleteReservedTelegramTopicThroughReconciler(
381
470
  deps: {
382
471
  callApi?: <TResponse>(
@@ -447,6 +536,8 @@ async function deleteReservedTelegramTopicThroughReconciler(
447
536
  }
448
537
 
449
538
  export const TELEGRAM_ALL_TAB_COMMAND_MAX_AGE_MS = 60 * 60_000;
539
+ /** One user-facing answer for expired or previous-process routing controls. */
540
+ export const TELEGRAM_ROUTING_CHOICE_EXPIRED = "⌛ Routing choice expired.";
450
541
 
451
542
  export function isTelegramAllTabCommandExpired(
452
543
  message: { date?: number; message_thread_id?: number },
@@ -517,6 +608,17 @@ export function createTelegramInboundBusProjectionRuntime(deps: {
517
608
  };
518
609
  }
519
610
 
611
+ /** Fenced temporary-Thread authority; see `captureTemporaryThreadAuthority`. */
612
+ interface TemporaryThreadAuthority {
613
+ store: Threads.TelegramWorkspaceRestore;
614
+ operatorUserId: number;
615
+ journalBindingKey: string;
616
+ epoch: number | string;
617
+ authority: Threads.TelegramWorkspaceRestoreAuthority;
618
+ isCurrent: () => boolean;
619
+ adopt: (entry: Threads.TelegramTemporaryThreadEntry) => Threads.TelegramTemporaryThreadEntry | undefined;
620
+ }
621
+
520
622
  export interface TelegramInboundRouteRuntimeDeps<
521
623
  TMessage extends TelegramRoutedMessage,
522
624
  TCallbackQuery extends TelegramRoutedCallbackQuery,
@@ -530,6 +632,7 @@ export interface TelegramInboundRouteRuntimeDeps<
530
632
  callApi?: <TResponse>(
531
633
  method: string,
532
634
  body: Record<string, unknown>,
635
+ options?: TelegramApiCallOptions,
533
636
  ) => Promise<TResponse>;
534
637
  getCurrentInstanceId?: () => string | undefined;
535
638
  getAdmissionScope?: () => string | undefined;
@@ -567,11 +670,28 @@ export interface TelegramInboundRouteRuntimeDeps<
567
670
  TCallbackQuery,
568
671
  TMessage
569
672
  >;
570
- replaceFollowerThreadTarget?: (input: {
571
- record: Threads.TelegramTopicTargetRecord;
572
- target: Threads.TelegramTopicTargetRecord["target"];
573
- oldTarget: Threads.TelegramTopicTargetRecord["target"];
574
- }) => Promise<boolean>;
673
+ getWorkspaceRestoreStore?: () => Threads.TelegramWorkspaceRestore | undefined;
674
+ captureWorkspaceExternalProtection?: ReturnType<typeof createTelegramWorkspaceExternalProtectionCapture>;
675
+ /** Strict committed abandonment plus retained original; shared by Restore and temporary-input cancellation. */
676
+ inspectRestoreSourceAbandonment?: (updateId: number, journalBindingKey: string) => Threads.TelegramTemporaryThreadCancellationEvidence | undefined;
677
+ /** Strict active-journal observation only; a hint or missing source never substitutes for this ACK. */
678
+ inspectRestoreSourceCompletion?: (expected: Updates.TelegramDeferredSourceEvidence & { completionSha256: string }) =>
679
+ (Updates.TelegramDeferredSourceEvidence & { completionSha256: string }) | undefined;
680
+ inspectRestoreQueuedReceipt?: (expected: TelegramUpdateJournalQueuedCompletion & { journalBindingKey: string }) =>
681
+ TelegramUpdateJournalQueuedReceiptEvidence | undefined;
682
+ hasWorkspaceRestoreAuthority?: () => boolean;
683
+ /** Strict complete namespace plus exact current/historical references; only an empty result clears journal protection. */
684
+ inspectTemporaryThreadSources?: (target: Queue.TelegramQueueTarget, requiredJournalBindingKeys: readonly string[]) => readonly number[] | undefined;
685
+ /** Quiet period after the last cancelled input before one cleanup attempt; defaults to 1000 ms. */
686
+ temporaryThreadCleanupDelayMs?: number;
687
+ getSessionGeneration?: () => number;
688
+ workspaceRestoreRecipient?: {
689
+ getSessionId: (ctx: TContext) => string | undefined;
690
+ getCwd: (ctx: TContext) => string | undefined;
691
+ getLeaderIdentity: Threads.TelegramLeaderThreadStateRuntime["getIdentity"];
692
+ followerRegistry: Pick<Bus.TelegramBusFollowerRegistry, "get" | "register">;
693
+ runFollower: ReturnType<typeof Bus.createTelegramBusWorkspaceRestoreController>;
694
+ };
575
695
  bridgeRuntime: TelegramBridgeRuntime;
576
696
  activeTurnRuntime: Queue.TelegramActiveTurnStore;
577
697
  mediaGroupRuntime: Media.TelegramMediaGroupController<TMessage, TContext>;
@@ -720,6 +840,7 @@ export interface TelegramInboundRouteRuntimeDeps<
720
840
  const TELEGRAM_OWNED_CALLBACK_PREFIXES = [
721
841
  "allmenu:",
722
842
  TELEGRAM_UNBOUND_REROUTE_CALLBACK_PREFIX,
843
+ TELEGRAM_UNBOUND_REROUTE_CANCEL_CALLBACK_PREFIX,
723
844
  "compact:",
724
845
  "menu:",
725
846
  "model:",
@@ -755,12 +876,26 @@ export function createTelegramInboundRouteRuntime<
755
876
  TContext,
756
877
  TModel
757
878
  >,
758
- ): Updates.TelegramUpdateRuntimeController<TContext, TUpdate> {
879
+ ): Updates.TelegramUpdateRuntimeController<TContext, TUpdate> & {
880
+ expireRoutingInput: NonNullable<Updates.TelegramUpdateWorkerRuntimeDeps<TContext>["expireRoutingInput"]>;
881
+ shouldReviewHistoricalInput: NonNullable<Updates.TelegramUpdateWorkerRuntimeDeps<TContext>["shouldReviewHistoricalInput"]>;
882
+ shouldHoldPendingInput: NonNullable<Updates.TelegramUpdateWorkerRuntimeDeps<TContext>["shouldHoldPendingInput"]>;
883
+ forgetPreviousWorld(input: Updates.TelegramHeldSourcePreparation<TContext>,
884
+ captureTransport?: (ctx: TContext) => (() => boolean) | undefined): Promise<{ forgotten: number; deleted: number }>;
885
+ beforeQueueReceiptPublished: NonNullable<Updates.TelegramUpdateWorkerRuntimeDeps<TContext>["beforeQueueReceiptPublished"]>;
886
+ onQueueReceiptCommitted: NonNullable<Updates.TelegramUpdateWorkerRuntimeDeps<TContext>["onQueueReceiptCommitted"]>;
887
+ onQueueReceiptCompleted: NonNullable<Updates.TelegramUpdateWorkerRuntimeDeps<TContext>["onQueueReceiptCompleted"]>;
888
+ onUpdateCompleted: NonNullable<Updates.TelegramUpdateWorkerRuntimeDeps<TContext>["onUpdateCompleted"]>;
889
+ onWorkspaceRestoreRecipientObserved(follower: Bus.TelegramBusFollowerView, isCurrent: () => boolean,
890
+ ctx: TContext | undefined): Promise<void> | undefined;
891
+ waitForRestoreSettlement(): Promise<void>;
892
+ } {
759
893
  type PendingRerouteCleanup =
760
894
  | {
761
895
  kind: "unbound";
762
896
  target: { chatId: number; threadId: number };
763
897
  messageId?: number;
898
+ temporaryThread?: Threads.TelegramTemporaryThreadEntry;
764
899
  }
765
900
  | {
766
901
  kind: "previous-leader";
@@ -774,16 +909,32 @@ export function createTelegramInboundRouteRuntime<
774
909
  type PendingUnboundReroute = {
775
910
  sourceTarget: Queue.TelegramQueueTarget;
776
911
  chooserMessageId?: number;
912
+ rootChooserText?: string;
777
913
  messages: TMessage[];
778
- createdAtMs: number;
779
914
  dispatchKind: "prompt" | "command";
915
+ routingOperatorUserId?: number;
780
916
  expiresAtMs?: number;
781
917
  destinationSelected?: boolean;
782
918
  selectionAttempted?: boolean;
919
+ workspaceRestore?: { operationId: string; record: Threads.TelegramTopicTargetRecord; messages: TMessage[] };
920
+ /** The acknowledged tab presenting an All source; `sourceTarget` is this tab, not the original's location. */
921
+ temporaryThread?: Threads.TelegramTemporaryThreadEntry;
922
+ /** Acknowledged same-tab membership stays protective when its reader disappears; never a dispatch/cleanup grant. */
923
+ temporaryMembership?: true;
783
924
  dispatching?: boolean;
925
+ abandonment?: {
926
+ ownerUserId: number;
927
+ journalBindingKey: string;
928
+ leaderEpoch: number | string;
929
+ running?: boolean;
930
+ attempted?: boolean;
931
+ result?: NonNullable<ReturnType<typeof Updates.abandonTelegramDeferredUpdate>>;
932
+ };
784
933
  pauseExpiry?: () => void;
785
934
  stopExpiry?: () => void;
786
935
  cleanup?: PendingRerouteCleanup;
936
+ /** Process-local protection only; restart-safe Forward issuance remains a release gate. */
937
+ foreignForwardIssued?: boolean;
787
938
  foreignRetry?: {
788
939
  instanceId: string;
789
940
  threadId: number;
@@ -792,8 +943,17 @@ export function createTelegramInboundRouteRuntime<
792
943
  finalizeMessage?: string;
793
944
  };
794
945
  const pendingUnboundReroutes = new Map<string, PendingUnboundReroute>();
946
+ const implicitThreadCreations = new Map<string, { ctx: TContext; updateId: number; operatorUserId: number;
947
+ journalBindingKey: string; executor: Threads.TelegramWorkspaceRestoreExecutor; generation: number | undefined; scope: string | undefined }>();
795
948
  const guidedUnboundTopicKeys = new Set<string>();
796
949
  let nextUnboundRerouteId = 0;
950
+ type RecoverySource = Updates.TelegramDeferredAbandonmentRecoveryPage["sources"][number];
951
+ type CancellationReview = {
952
+ id: string; target: Queue.TelegramQueueTarget; messageId: number; ownerUserId: number; journalBindingKey: string;
953
+ isCurrent: () => boolean; running?: boolean; nextAfterUpdateId?: number; unsupported: boolean;
954
+ sources: Array<RecoverySource & { preview: string; result?: NonNullable<ReturnType<RecoverySource["retry"]>> }>;
955
+ };
956
+ let cancellationReview: CancellationReview | undefined;
797
957
  const requestDispatchNextQueuedTelegramTurn = (ctx: TContext): void => {
798
958
  deps.dispatchNextQueuedTelegramTurn(ctx);
799
959
  if (
@@ -833,7 +993,7 @@ export function createTelegramInboundRouteRuntime<
833
993
  continue;
834
994
  }
835
995
  return record.threadName &&
836
- Threads.isTelegramTopicThreadNameValidForSlot(
996
+ ThreadNaming.isTelegramTopicThreadNameValidForSlot(
837
997
  record.threadName,
838
998
  record.slot,
839
999
  )
@@ -915,35 +1075,34 @@ export function createTelegramInboundRouteRuntime<
915
1075
  };
916
1076
  schedule();
917
1077
  };
918
- const prunePendingUnboundReroutes = () => {
919
- const nowMs = Date.now();
1078
+ const prunePendingCommandReroutes = () => {
1079
+ // Deferred prompts and selected routes with cleanup work must retain their control.
1080
+ // Age is not settlement; the chooser capacity limit still bounds retained entries.
920
1081
  for (const [id, entry] of pendingUnboundReroutes) {
921
1082
  if (entry.dispatchKind === "command" && entry.sourceTarget.threadId === undefined) {
922
1083
  expirePendingCommand(id, entry);
923
- } else if (nowMs - entry.createdAtMs > 30 * 60_000) {
924
- removePendingReroute(id);
925
1084
  }
926
1085
  }
927
1086
  };
928
1087
  const storePendingUnboundReroute = (
929
1088
  messages: TMessage[],
930
1089
  dispatchKind: "prompt" | "command" = "prompt",
1090
+ presentationTarget?: { chatId: number; threadId: number },
931
1091
  ): string => {
932
- prunePendingUnboundReroutes();
1092
+ prunePendingCommandReroutes();
933
1093
  if (pendingUnboundReroutes.size >= 100) {
934
1094
  throw new Error("Telegram route chooser capacity reached; source remains retryable.");
935
1095
  }
936
1096
  nextUnboundRerouteId += 1;
937
1097
  const id = nextUnboundRerouteId.toString(36);
938
1098
  pendingUnboundReroutes.set(id, {
939
- sourceTarget: {
1099
+ sourceTarget: presentationTarget ? { ...presentationTarget } : {
940
1100
  chatId: messages[0]!.chat.id,
941
1101
  ...(typeof messages[0]!.message_thread_id === "number"
942
1102
  ? { threadId: messages[0]!.message_thread_id }
943
1103
  : {}),
944
1104
  },
945
1105
  messages,
946
- createdAtMs: Date.now(),
947
1106
  dispatchKind,
948
1107
  });
949
1108
  const pending = pendingUnboundReroutes.get(id)!;
@@ -954,6 +1113,15 @@ export function createTelegramInboundRouteRuntime<
954
1113
  const pending = pendingUnboundReroutes.get(id);
955
1114
  if (!pending) return;
956
1115
  pending.chooserMessageId = messageId;
1116
+ if (Number.isSafeInteger(messageId) && messageId! > 0) {
1117
+ const owner = deps.configStore.getAllowedUserId();
1118
+ if (owner !== undefined && pending.messages.every(source => source.from?.id === owner && !source.from.is_bot && source.chat.type === "private")) {
1119
+ try {
1120
+ const lifetime = Updates.armTelegramRoutingInputs(pending.messages, owner);
1121
+ if (lifetime) pending.routingOperatorUserId = lifetime.operatorUserId;
1122
+ } catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-clock" }); }
1123
+ }
1124
+ }
957
1125
  if (messageId === undefined || pending.dispatchKind !== "command" ||
958
1126
  pending.sourceTarget.threadId !== undefined || pending.selectionAttempted) return;
959
1127
  const source = pending.messages[0];
@@ -1083,18 +1251,49 @@ export function createTelegramInboundRouteRuntime<
1083
1251
  ),
1084
1252
  );
1085
1253
  };
1254
+ /** Whether a chooser is still pending in this Thread, ignoring the ones the caller itself owns. */
1255
+ const hasPendingRerouteForTarget = (target: Queue.TelegramQueueTarget, own?: (pending: PendingUnboundReroute) => boolean): boolean =>
1256
+ [...pendingUnboundReroutes.values()].some(pending => pending.sourceTarget.chatId === target.chatId &&
1257
+ pending.sourceTarget.threadId === target.threadId && !own?.(pending));
1258
+ const isOwnTemporaryChooser = (entry: Threads.TelegramTemporaryThreadEntry) => (pending: PendingUnboundReroute): boolean =>
1259
+ pending.temporaryThread?.token === entry.token && isDeepStrictEqual(pending.temporaryThread.source, entry.source);
1260
+ const hasOtherTemporaryThreadReroute = (target: Queue.TelegramQueueTarget, own: Threads.TelegramTemporaryThreadEntry): boolean =>
1261
+ hasPendingRerouteForTarget(target, isOwnTemporaryChooser(own));
1086
1262
  const applyThreadCleanupPlan = async (
1087
1263
  plan: ThreadReconciler.ThreadReconciliationPlan,
1088
1264
  assertExecutionCurrent?: () => void,
1265
+ restoreCleanup?: Threads.TelegramWorkspaceRestoreIntent,
1266
+ temporaryCleanup?: Threads.TelegramTemporaryThreadEntry,
1089
1267
  ): Promise<boolean> => {
1090
1268
  assertExecutionCurrent?.();
1269
+ if ((restoreCleanup && restoreCleanup.routing?.cleanup !== "issued") ||
1270
+ (temporaryCleanup && !temporaryCleanup.cleanupIssued)) return false;
1271
+ let protectedTarget = false, temporaryDeleteConfirmed = false, temporaryTransportUncertain = false;
1091
1272
  deps.recordThreadReconciliationPlan?.(plan);
1092
1273
  const result = await ThreadReconciler.applyThreadReconciliationPlan(plan, {
1274
+ skipCloseBeforeDelete: (temporaryCleanup?.target?.chatId ?? 0) > 0,
1093
1275
  isCleanupTargetProtected(target) {
1094
1276
  assertExecutionCurrent?.();
1095
- return isRerouteTargetProtected(target);
1277
+ // A tab's first source is not authority over sibling choosers, including unsettled cleanup-only work.
1278
+ const siblingProtected = !!temporaryCleanup && hasOtherTemporaryThreadReroute(target, temporaryCleanup);
1279
+ const lostTemporaryGrant = !!temporaryCleanup && !deps.getWorkspaceRestoreStore?.()?.listTemporaryThreads()
1280
+ .some(entry => isDeepStrictEqual(entry, temporaryCleanup));
1281
+ const protectedNow = temporaryTransportUncertain || lostTemporaryGrant || siblingProtected || isRerouteTargetProtected(target, restoreCleanup, temporaryCleanup);
1282
+ protectedTarget ||= protectedNow;
1283
+ return protectedNow;
1096
1284
  },
1097
- callApi: deps.callApi,
1285
+ callApi: temporaryCleanup && deps.callApi
1286
+ ? async <TResponse>(method: string, body: Record<string, unknown>): Promise<TResponse> => {
1287
+ try {
1288
+ assertExecutionCurrent?.();
1289
+ const result = await deps.callApi!<TResponse>(method, body, { maxAttempts: 1, retrySafety: "non-idempotent" });
1290
+ assertExecutionCurrent?.();
1291
+ if (result !== true) throw new Error("Temporary Thread cleanup lacks a positive API acknowledgement.");
1292
+ if (method === "deleteForumTopic") temporaryDeleteConfirmed = true;
1293
+ return result;
1294
+ } catch (error) { temporaryTransportUncertain = true; throw error; }
1295
+ }
1296
+ : deps.callApi,
1098
1297
  markStaleByTarget: (staleTarget, syncStatus, lastSyncError) =>
1099
1298
  deps.threadStore?.markStaleByTarget(
1100
1299
  staleTarget,
@@ -1108,15 +1307,111 @@ export function createTelegramInboundRouteRuntime<
1108
1307
  recordRuntimeEvent: deps.recordRuntimeEvent,
1109
1308
  });
1110
1309
  assertExecutionCurrent?.();
1111
- return (result.incompleteActions?.length ?? 0) === 0;
1310
+ return !protectedTarget && (!temporaryCleanup || temporaryDeleteConfirmed) && (result.incompleteActions?.length ?? 0) === 0;
1311
+ };
1312
+ const findCreatedTemporaryThread = (target: Queue.TelegramQueueTarget): Threads.TelegramTemporaryThreadEntry | undefined => {
1313
+ try { return deps.getWorkspaceRestoreStore?.()?.listTemporaryThreads().find(value => value.phase === "created" && isDeepStrictEqual(value.target, target)); }
1314
+ catch { return undefined; }
1112
1315
  };
1113
- const isRerouteTargetProtected = (target: Queue.TelegramQueueTarget): boolean => {
1316
+ /**
1317
+ * One fenced capture of leader epoch, operator, journal binding and context for a temporary-Thread effect. `isCurrent`
1318
+ * re-reads every captured fact (plus an optional extra fence) at each boundary, so a delayed effect keeps the identity it
1319
+ * was scheduled under; `adopt` re-keys an entry to this executor before a write.
1320
+ */
1321
+ const captureTemporaryThreadAuthority = (ctx: TContext, extra?: () => boolean): TemporaryThreadAuthority | undefined => {
1322
+ const store = deps.getWorkspaceRestoreStore?.();
1323
+ const instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.();
1324
+ const operatorUserId = deps.configStore.getAllowedUserId(), journalBindingKey = deps.getAdmissionJournalBinding?.();
1325
+ const generation = deps.getSessionGeneration?.(), scope = deps.getAdmissionScope?.();
1326
+ if (!store || !instanceId || epoch === undefined || operatorUserId === undefined || !journalBindingKey) return undefined;
1327
+ const isCurrent = (): boolean => deps.isContextActive?.(ctx) === true && deps.getCurrentInstanceId?.() === instanceId &&
1328
+ deps.getSessionGeneration?.() === generation && deps.getAdmissionScope?.() === scope &&
1329
+ deps.getCurrentLeaderEpoch?.() === epoch && deps.configStore.getAllowedUserId() === operatorUserId &&
1330
+ deps.getAdmissionJournalBinding?.() === journalBindingKey && (extra?.() ?? true);
1331
+ const authority = { executor: { instanceId, leaderEpoch: String(epoch) }, operatorUserId, isCurrent };
1332
+ return { store, operatorUserId, journalBindingKey, epoch, authority, isCurrent,
1333
+ adopt: entry => isDeepStrictEqual(entry.executor, authority.executor) ? entry : store.adoptTemporaryThread(entry, authority) };
1334
+ };
1335
+ const isTemporaryTabTarget = (target: Queue.TelegramQueueTarget | undefined): boolean =>
1336
+ !!target && findCreatedTemporaryThread(target) !== undefined;
1337
+ /** Restore may choose one input only when every other known group is cancelled or a live unassigned chooser that can be retained. */
1338
+ const areTemporaryThreadSiblingsAccountedForRestore = (entry: Threads.TelegramTemporaryThreadEntry, from: PendingUnboundReroute): boolean => {
1339
+ const target = entry.target;
1340
+ if (!target) return false;
1341
+ const group = { journalBindingKey: entry.source.journalBindingKey, updateIds: Updates.collectTelegramAdmissionSourceUpdateIds(from.messages) };
1342
+ const inputs = Threads.getTelegramTemporaryThreadInputs(entry), resolved = [...(entry.cancelledInputs ?? []), ...(entry.completedInputs ?? [])];
1343
+ if (!inputs.some(input => isDeepStrictEqual(input, group)) || resolved.some(input => isDeepStrictEqual(input, group))) return false;
1344
+ const others = [...pendingUnboundReroutes.values()].filter(pending => pending !== from &&
1345
+ pending.sourceTarget.chatId === target.chatId && pending.sourceTarget.threadId === target.threadId);
1346
+ const groupOf = (pending: PendingUnboundReroute) => ({ journalBindingKey: entry.source.journalBindingKey,
1347
+ updateIds: Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages) });
1348
+ if (!others.every(pending => !!pending.abandonment && !pending.abandonment.result && !pending.destinationSelected &&
1349
+ !pending.selectionAttempted && !pending.dispatching && !pending.foreignForwardIssued && !pending.foreignRetry && !pending.cleanup && !pending.finalizeMessage &&
1350
+ !pending.workspaceRestore && !entry.forwardedInputs?.some(input => isDeepStrictEqual(input, groupOf(pending))) &&
1351
+ inputs.some(input => isDeepStrictEqual(input, groupOf(pending))))) return false;
1352
+ return inputs.every(input => isDeepStrictEqual(input, group) || resolved.some(other => isDeepStrictEqual(other, input)) ||
1353
+ others.some(pending => isDeepStrictEqual(groupOf(pending), input)));
1354
+ };
1355
+ const isRerouteTargetProtected = (target: Queue.TelegramQueueTarget, ownRestore?: Threads.TelegramWorkspaceRestoreIntent,
1356
+ ownTemporary?: Threads.TelegramTemporaryThreadEntry, restoreFrom?: PendingUnboundReroute): boolean => {
1114
1357
  const matches = (candidate: Queue.TelegramQueueTarget): boolean =>
1115
1358
  candidate.chatId === target.chatId && candidate.threadId === target.threadId;
1116
- return (deps.getLiveThreadTargets?.() ?? []).some(matches) ||
1117
- (deps.threadStore?.list() ?? []).some((record) => matches(record.target)) ||
1118
- (deps.threadStore?.listReservations() ?? []).some((record) => matches(record.target)) ||
1119
- (deps.threadStore?.listPendingProvisions() ?? []).some((record) => !!record.target && matches(record.target));
1359
+ const activeTarget = deps.activeTurnRuntime.getTarget();
1360
+ if ((activeTarget && matches(activeTarget)) ||
1361
+ deps.telegramQueueStore.getQueuedItems().some(item => item.target && matches(item.target)) ||
1362
+ (deps.getLiveThreadTargets?.() ?? []).some(matches) ||
1363
+ (deps.threadStore?.list() ?? []).some(record => matches(record.target)) ||
1364
+ (deps.threadStore?.listReservations() ?? []).some(record => matches(record.target)) ||
1365
+ (deps.threadStore?.listPendingProvisions() ?? []).some(record => record.target && matches(record.target))) return true;
1366
+ const store = deps.getWorkspaceRestoreStore?.();
1367
+ if (deps.getWorkspaceRestoreStore && !store) return true;
1368
+ let temporary: Threads.TelegramTemporaryThreadEntry[];
1369
+ try { temporary = store?.listTemporaryThreads() ?? []; } catch { return true; }
1370
+ // Only the source's own Forward/Cancel may remove its retained tab; adoption may have changed its executor.
1371
+ const ownTab = (entry: Threads.TelegramTemporaryThreadEntry): boolean => !!ownTemporary && entry.token === ownTemporary.token &&
1372
+ isDeepStrictEqual(entry.source, ownTemporary.source) && isDeepStrictEqual(entry.target, ownTemporary.target);
1373
+ if (temporary.some(entry => !!entry.target && matches(entry.target) &&
1374
+ (!ownTab(entry) || (Threads.getTelegramTemporaryThreadInputs(entry).length > 1 &&
1375
+ !Threads.isTelegramTemporaryThreadFullyResolved(entry) &&
1376
+ !(restoreFrom && areTemporaryThreadSiblingsAccountedForRestore(entry, restoreFrom)))))) return true;
1377
+ // Whole-tab cleanup after cancellation also needs a fresh census of retained arrivals no membership has seen.
1378
+ if (!restoreFrom && temporary.some(entry => ownTab(entry) && !!(entry.cancelledInputs?.length || entry.completedInputs?.length))) {
1379
+ try {
1380
+ const required = [...new Set([deps.getAdmissionJournalBinding?.(), ownTemporary!.source.journalBindingKey,
1381
+ ...Threads.getTelegramTemporaryThreadInputs(ownTemporary!).map(input => input.journalBindingKey)].filter((key): key is string => !!key))];
1382
+ if (deps.inspectTemporaryThreadSources?.({ chatId: target.chatId, threadId: target.threadId! }, required)?.length !== 0) return true;
1383
+ } catch { return true; }
1384
+ }
1385
+ const retained = store?.list() ?? [];
1386
+ // Exempt only the exact operation's old target. Cleanup itself also requires its issued grant.
1387
+ if (ownRestore && (!matches(ownRestore.request.binding.target) ||
1388
+ !retained.some(intent => isDeepStrictEqual(intent, ownRestore)))) return true;
1389
+ if (retained.some(intent => (matches(intent.request.binding.target) || matches(intent.request.target)) &&
1390
+ !(ownRestore && isDeepStrictEqual(intent, ownRestore) && matches(intent.request.binding.target)))) return true;
1391
+ if (!ownRestore) return false;
1392
+ let acceptedWorkClear = false;
1393
+ try {
1394
+ deps.threadStore?.withWorkspaceRestoreSnapshot(ownRestore, snapshot => {
1395
+ const bindings = snapshot.workspaceBindings?.filter(binding => binding.bindingKey === ownRestore.request.binding.bindingKey) ?? [];
1396
+ if (bindings.length !== 1 || !deps.captureWorkspaceExternalProtection) return undefined;
1397
+ const binding = bindings[0]!;
1398
+ // Relocation does not erase predecessor references or prove where their work executes.
1399
+ const journalBindingKeys = [...new Set([
1400
+ ...(ownRestore.request.binding.journalBindingKeys ?? []), ...(binding.journalBindingKeys ?? []),
1401
+ ])];
1402
+ const journalSources = [...new Map([
1403
+ ...(ownRestore.request.binding.journalSources ?? []), ...(binding.journalSources ?? []),
1404
+ ].map(source => [JSON.stringify([source.sessionId, source.recipientBindingKey]), source])).values()];
1405
+ acceptedWorkClear = deps.captureWorkspaceExternalProtection({ ...binding, target: ownRestore.request.binding.target,
1406
+ journalBindingKeys, journalSources },
1407
+ { requireBindingProvenance: true }).acceptedWork === "clear";
1408
+ return undefined;
1409
+ });
1410
+ } catch (error) {
1411
+ acceptedWorkClear = false;
1412
+ deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-protection" });
1413
+ }
1414
+ return !acceptedWorkClear;
1120
1415
  };
1121
1416
  const dismissRerouteChooserMessage = async (
1122
1417
  query: TCallbackQuery,
@@ -1151,6 +1446,7 @@ export function createTelegramInboundRouteRuntime<
1151
1446
  target: { chatId: number; threadId: number } | undefined,
1152
1447
  messageId: number | undefined,
1153
1448
  assertExecutionCurrent?: () => void,
1449
+ ownTemporary?: Threads.TelegramTemporaryThreadEntry,
1154
1450
  ): Promise<boolean> => {
1155
1451
  if (!target || !deps.threadStore) return true;
1156
1452
  const nowMs = Date.now();
@@ -1173,11 +1469,15 @@ export function createTelegramInboundRouteRuntime<
1173
1469
  },
1174
1470
  ],
1175
1471
  });
1176
- return applyThreadCleanupPlan(plan, assertExecutionCurrent);
1472
+ // A temporary grant licenses this tab only, not unrelated cleanup emitted by the general planner.
1473
+ const scoped = ownTemporary ? { ...plan, actions: plan.actions.filter(action =>
1474
+ action.kind === "close-delete-unbound-topic" && isDeepStrictEqual(action.target, target)) } : plan;
1475
+ return applyThreadCleanupPlan(scoped, assertExecutionCurrent, undefined, ownTemporary);
1177
1476
  };
1178
1477
  const closePreviousLeaderThread = async (
1179
1478
  target: { chatId: number; threadId: number } | undefined,
1180
1479
  assertExecutionCurrent?: () => void,
1480
+ restoreCleanup?: Threads.TelegramWorkspaceRestoreIntent,
1181
1481
  ): Promise<boolean> => {
1182
1482
  if (!target || !deps.threadStore) return true;
1183
1483
  const currentLeaderEpoch = deps.getCurrentLeaderEpoch?.();
@@ -1193,12 +1493,13 @@ export function createTelegramInboundRouteRuntime<
1193
1493
  : {}),
1194
1494
  },
1195
1495
  ],
1196
- }, assertExecutionCurrent);
1496
+ }, assertExecutionCurrent, restoreCleanup);
1197
1497
  };
1198
1498
  const closeReplacedFollowerThread = async (
1199
1499
  target: { chatId: number; threadId: number } | undefined,
1200
1500
  instanceId: string | undefined,
1201
1501
  assertExecutionCurrent?: () => void,
1502
+ restoreCleanup?: Threads.TelegramWorkspaceRestoreIntent,
1202
1503
  ): Promise<boolean> => {
1203
1504
  if (!target || !deps.threadStore) return true;
1204
1505
  const currentLeaderEpoch = deps.getCurrentLeaderEpoch?.();
@@ -1214,8 +1515,692 @@ export function createTelegramInboundRouteRuntime<
1214
1515
  : {}),
1215
1516
  },
1216
1517
  ],
1217
- }, assertExecutionCurrent);
1518
+ }, assertExecutionCurrent, restoreCleanup);
1519
+ };
1520
+ // Worker ACK observers outlive callback admission. Re-enter the existing Workspace gate;
1521
+ // never await this continuation while holding the dispatch invocation's gate.
1522
+ const restoreSettlementTasks = new Set<Promise<void>>();
1523
+ /**
1524
+ * After a Restore into a temporary tab reached positive terminal settlement, every still-unassigned sibling there is retained
1525
+ * privately and cancelled without Pi delivery. Missing proof, authority or abandonment leaves that sibling pending and the
1526
+ * tab's temporary entry protected; selected, queued, running or unknown work is never touched.
1527
+ */
1528
+ const disposeTemporaryThreadSiblingsAfterRestore = async (intent: Threads.TelegramWorkspaceRestoreIntent, ctx: TContext,
1529
+ restoreCurrent: () => boolean): Promise<void> => {
1530
+ const target = intent.request.target, cap = captureTemporaryThreadAuthority(ctx, restoreCurrent);
1531
+ const entry = findCreatedTemporaryThread(target);
1532
+ if (!cap || !entry || !entry.target || !cap.isCurrent()) return;
1533
+ const operatorUserId = intent.operatorUserId, journalBindingKey = intent.request.source.journalBindingKey;
1534
+ if (cap.operatorUserId !== operatorUserId || cap.journalBindingKey !== journalBindingKey ||
1535
+ entry.operatorUserId !== operatorUserId || entry.source.journalBindingKey !== journalBindingKey) return;
1536
+ const group = { journalBindingKey, updateIds: [...intent.request.source.updateIds] };
1537
+ if (!Threads.getTelegramTemporaryThreadInputs(entry).some(input => isDeepStrictEqual(input, group))) return;
1538
+ for (const [id, pending] of [...pendingUnboundReroutes]) {
1539
+ const cancellation = pending.abandonment, source = pending.messages[0];
1540
+ if (pending.sourceTarget.chatId !== target.chatId || pending.sourceTarget.threadId !== target.threadId ||
1541
+ pending.workspaceRestore?.operationId === intent.request.operationId || !cancellation || !source || cancellation.result ||
1542
+ cancellation.running) continue;
1543
+ const current = (): boolean => cap.isCurrent() &&
1544
+ pendingUnboundReroutes.get(id) === pending && pending.abandonment === cancellation &&
1545
+ !pending.destinationSelected && !pending.selectionAttempted && !pending.dispatching && !pending.foreignForwardIssued && !pending.foreignRetry &&
1546
+ !pending.cleanup && !pending.finalizeMessage && !pending.workspaceRestore &&
1547
+ Updates.getTelegramUpdateExecutionFence(source)?.signal.aborted === false &&
1548
+ cancellation.ownerUserId === operatorUserId && cancellation.journalBindingKey === journalBindingKey;
1549
+ if (!current()) continue;
1550
+ cancellation.running = true;
1551
+ try {
1552
+ const result = Updates.abandonTelegramDeferredUpdate(source, { operatorAuthorityId: `telegram-owner:${operatorUserId}`, isCurrent: current });
1553
+ if (!result) continue;
1554
+ cancellation.attempted = true;
1555
+ cancellation.result = result;
1556
+ if (!recordCancelledTemporaryThreadInput(pending, ctx, current)) continue;
1557
+ await retireCancellationChooser(id, pending, current);
1558
+ } catch (error) {
1559
+ deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-restore-sibling", rerouteId: id });
1560
+ } finally { cancellation.running = false; }
1561
+ }
1562
+ // The entry is released only after every sibling is gone and the Restore group's completion is the single uncancelled input.
1563
+ if (hasPendingRerouteForTarget(target, pending => pending.workspaceRestore?.operationId === intent.request.operationId) ||
1564
+ !cap.isCurrent()) return;
1565
+ const fresh = findCreatedTemporaryThread(target), owned = fresh && cap.adopt(fresh);
1566
+ if (owned && cap.isCurrent()) cap.store.retireTemporaryThread(owned, cap.authority, group);
1567
+ };
1568
+ const observeRestoreSettlement = (signal:
1569
+ | { kind: "source"; evidence: Threads.TelegramWorkspaceRestoreSourceSettlement }
1570
+ | { kind: "recipient"; follower: Bus.TelegramBusFollowerView; isCurrent: () => boolean },
1571
+ ctx: TContext): Promise<void> | undefined => {
1572
+ if (!deps.hasWorkspaceRestoreAuthority?.() || !deps.runWorkspaceOperation || !deps.getWorkspaceRestoreStore ||
1573
+ !deps.getSessionGeneration || deps.isContextActive?.(ctx) === false) return;
1574
+ const evidence = signal.kind === "source" ? structuredClone(signal.evidence) : undefined;
1575
+ const follower = signal.kind === "recipient" ? structuredClone(signal.follower) : undefined;
1576
+ const recipientCurrent = (): boolean => {
1577
+ if (signal.kind !== "recipient" || !follower) return true;
1578
+ const live = deps.workspaceRestoreRecipient?.followerRegistry.get(follower.instanceId);
1579
+ return signal.isCurrent() && !!live && Bus.hasTelegramBusCapability(live.protocol, Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE) &&
1580
+ isDeepStrictEqual({ ...live, lastHeartbeatMs: undefined, connectedAtMs: undefined, threadName: undefined },
1581
+ { ...follower, lastHeartbeatMs: undefined, connectedAtMs: undefined, threadName: undefined });
1582
+ };
1583
+ const recipientMatches = (intent: Threads.TelegramWorkspaceRestoreIntent): boolean => {
1584
+ if (!follower) return true;
1585
+ const ready = intent.readyRecipient ?? intent.recipient;
1586
+ return ready?.kind === "follower" && ready.instanceId === follower.instanceId && ready.sessionId === follower.sessionId &&
1587
+ ready.generation === follower.registrationGeneration && !!follower.cwd &&
1588
+ intent.request.binding.cwd === WorkspaceIdentity.normalizeTelegramWorkspacePath(follower.cwd) &&
1589
+ intent.request.binding.slot === follower.slot && isDeepStrictEqual(intent.request.target, follower.target);
1590
+ };
1591
+ // A same-session successor already registered on the relocated target may prove an issued grant by inspection only.
1592
+ const inspectableSuccessor = (intent: Threads.TelegramWorkspaceRestoreIntent): boolean =>
1593
+ !!follower?.registrationGeneration && !!follower.cwd && (intent.phase === "relocated" || intent.phase === "recipient-issued") &&
1594
+ intent.request.owner.owner?.kind === "manual-follower" && follower.sessionId === intent.request.binding.sessionId &&
1595
+ intent.request.binding.cwd === WorkspaceIdentity.normalizeTelegramWorkspacePath(follower.cwd) &&
1596
+ intent.request.binding.slot === follower.slot && isDeepStrictEqual(intent.request.target, follower.target);
1597
+ const instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.();
1598
+ const operatorUserId = deps.configStore.getAllowedUserId();
1599
+ const generation = deps.getSessionGeneration(), scope = deps.getAdmissionScope?.();
1600
+ const sourceBinding = deps.getAdmissionJournalBinding?.();
1601
+ if (!instanceId || epoch === undefined || operatorUserId === undefined || !scope ||
1602
+ !sourceBinding || (evidence && evidence.journalBindingKey !== sourceBinding) || !recipientCurrent()) return;
1603
+ // This same-session leader already runs on the relocated target; it may only inspect an issued leader grant.
1604
+ const leaderSessionId = deps.workspaceRestoreRecipient?.getSessionId(ctx);
1605
+ const leaderCwd = deps.workspaceRestoreRecipient?.getCwd(ctx);
1606
+ const leaderAt = (request: Threads.TelegramWorkspaceRestoreRequest): boolean => {
1607
+ const ports = deps.workspaceRestoreRecipient;
1608
+ const cwd = ports?.getCwd(ctx), local = ports?.getLeaderIdentity();
1609
+ return !!cwd && cwd === leaderCwd && ports?.getSessionId(ctx) === leaderSessionId &&
1610
+ leaderSessionId === request.binding.sessionId && WorkspaceIdentity.normalizeTelegramWorkspacePath(cwd) === request.binding.cwd &&
1611
+ !!local && local.slot === request.binding.slot && isDeepStrictEqual(local.target, request.target);
1612
+ };
1613
+ const observeLeaderRestore = (intent: Threads.TelegramWorkspaceRestoreIntent, recipient: Threads.TelegramWorkspaceRestoreRecipient,
1614
+ isCurrent: () => boolean): Awaited<ReturnType<Parameters<typeof advanceTelegramWorkspaceRestore>[0]["runRecipient"]>> => {
1615
+ const threads = deps.threadStore;
1616
+ if (!threads || !isCurrent() || !leaderAt(intent.request)) return undefined;
1617
+ const { request } = intent;
1618
+ let observation: Awaited<ReturnType<Parameters<typeof advanceTelegramWorkspaceRestore>[0]["runRecipient"]>>;
1619
+ threads.withWorkspaceRestoreSnapshot(intent, snapshot => {
1620
+ if (!isCurrent() || !leaderAt(request)) return;
1621
+ const bindings = (snapshot.workspaceBindings ?? []).filter(value => value.bindingKey === request.binding.bindingKey);
1622
+ const live = bindings[0];
1623
+ const owners = snapshot.threads.filter(value => value.status === "active" &&
1624
+ (value.slot === request.binding.slot || isDeepStrictEqual(value.target, request.target)));
1625
+ if (bindings.length !== 1 || live?.sessionId !== request.binding.sessionId || live.cwd !== request.binding.cwd ||
1626
+ live.slot !== request.binding.slot || live.inactiveSinceMs !== undefined || !isDeepStrictEqual(live.target, request.target) ||
1627
+ owners.length !== 1 || owners[0]?.owner?.kind !== "leader" || owners[0].instanceId !== recipient.instanceId ||
1628
+ owners[0].owner.instanceId !== recipient.instanceId || !owners[0].owner.cwd ||
1629
+ WorkspaceIdentity.normalizeTelegramWorkspacePath(owners[0].owner.cwd) !== request.binding.cwd ||
1630
+ owners[0].profileKey !== request.owner.profileKey || owners[0].slot !== request.binding.slot || !isDeepStrictEqual(owners[0].target, request.target)) return;
1631
+ observation = { operationId: request.operationId, recipient, target: request.target, slot: request.binding.slot!, ready: true };
1632
+ });
1633
+ return isCurrent() && leaderAt(request) ? observation : undefined;
1634
+ };
1635
+ // Same-process lost replies stay with their chooser; this path only serves a restarted leader process.
1636
+ const inspectableLeader = (intent: Threads.TelegramWorkspaceRestoreIntent): boolean => !!evidence &&
1637
+ intent.request.owner.owner?.kind === "leader" && leaderAt(intent.request) &&
1638
+ (intent.phase === "relocated" ? intent.request.owner.instanceId !== instanceId
1639
+ : intent.phase === "recipient-issued" && intent.recipient?.kind === "leader" && intent.recipient.instanceId !== instanceId);
1640
+ const current = (): boolean => deps.hasWorkspaceRestoreAuthority?.() === true && deps.isContextActive?.(ctx) !== false &&
1641
+ deps.getSessionGeneration?.() === generation && deps.getAdmissionScope?.() === scope &&
1642
+ deps.getAdmissionJournalBinding?.() === sourceBinding && deps.getCurrentInstanceId?.() === instanceId &&
1643
+ deps.getCurrentLeaderEpoch?.() === epoch && deps.configStore.getAllowedUserId() === operatorUserId && recipientCurrent();
1644
+ const recoveryRecipient = (intent: Threads.TelegramWorkspaceRestoreIntent): Bus.TelegramBusFollowerView | undefined => {
1645
+ const ready = intent.readyRecipient ?? intent.recipient;
1646
+ if (!deps.inspectRestoreSourceCompletion || !deps.workspaceRestoreRecipient || intent.phase !== "ready" ||
1647
+ !intent.routing || intent.routing.cleanup !== undefined || intent.operatorUserId !== operatorUserId ||
1648
+ !intent.routing.acceptances?.some(value => value.kind === "forwarded")) return undefined;
1649
+ const live = deps.workspaceRestoreRecipient.followerRegistry.get(follower?.instanceId ?? ready?.instanceId ?? "");
1650
+ return ready?.kind === "follower" && live?.registrationGeneration && live.cwd &&
1651
+ Bus.hasTelegramBusCapability(live.protocol, Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE) &&
1652
+ Bus.hasTelegramBusCapability(live.protocol, Bus.TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION) &&
1653
+ live.sessionId === intent.request.binding.sessionId && WorkspaceIdentity.normalizeTelegramWorkspacePath(live.cwd) === intent.request.binding.cwd &&
1654
+ live.slot === intent.request.binding.slot && isDeepStrictEqual(live.target, intent.request.target) ? live : undefined;
1655
+ };
1656
+ const recoveryLeader = (intent: Threads.TelegramWorkspaceRestoreIntent): Threads.TelegramWorkspaceRestoreRecipient | undefined => {
1657
+ const ready = intent.readyRecipient ?? intent.recipient;
1658
+ if (evidence?.kind !== "completed" || !deps.inspectRestoreSourceCompletion || !deps.threadStore ||
1659
+ intent.phase !== "ready" || !intent.routing || intent.routing.cleanup !== undefined || intent.operatorUserId !== operatorUserId ||
1660
+ intent.request.owner.owner?.kind !== "leader" || ready?.kind !== "leader" || ready.sessionId !== intent.request.binding.sessionId ||
1661
+ !leaderAt(intent.request) ||
1662
+ !intent.routing.acceptances?.some(value => value.kind === "completed" || value.kind === "queued")) return undefined;
1663
+ return { kind: "leader", instanceId, sessionId: intent.request.binding.sessionId!, generation: String(generation) };
1664
+ };
1665
+ let selected: string[];
1666
+ try {
1667
+ selected = deps.getWorkspaceRestoreStore()?.list().filter(intent => {
1668
+ if (intent.request.source.journalBindingKey !== sourceBinding) return false;
1669
+ if (evidence && intent.request.source.updateIds.some(id => evidence.updateIds.includes(id))) return true;
1670
+ if (((evidence?.kind === "completed" || follower) && recoveryRecipient(intent)) || recoveryLeader(intent)) return true;
1671
+ if (follower && deps.workspaceRestoreRecipient && inspectableSuccessor(intent)) return true;
1672
+ if (deps.threadStore && inspectableLeader(intent)) return true;
1673
+ // Completion rechecks an unsent Restore whose originals may all have been abandoned before its retirement published.
1674
+ if (evidence?.kind === "completed" && deps.inspectRestoreSourceAbandonment && intent.phase === "ready" &&
1675
+ intent.routing === undefined) return true;
1676
+ // Hints only wake fully settled, unissued cleanup. They never settle another source.
1677
+ return (evidence?.kind === "completed" || !!follower) && intent.phase === "ready" && intent.routing?.cleanup === undefined &&
1678
+ recipientMatches(intent) && intent.request.source.updateIds.every(id =>
1679
+ intent.routing?.settlements.some(value => value.kind !== "queued" && value.updateIds.includes(id)));
1680
+ }).map(intent => intent.request.operationId) ?? [];
1681
+ } catch (error) {
1682
+ deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-settlement" });
1683
+ return;
1684
+ }
1685
+ if (!selected.length || !current()) return;
1686
+ const task = Promise.resolve().then(() => deps.runWorkspaceOperation!({ operationId: `restore-settlement-${randomBytes(16).toString("hex")}`,
1687
+ operationKind: "workspace.restore-settlement", scopes: [{ kind: "profile" }] }, async () => {
1688
+ if (!current()) return;
1689
+ const store = deps.getWorkspaceRestoreStore!();
1690
+ if (!store) return;
1691
+ const authority: Threads.TelegramWorkspaceRestoreAuthority = {
1692
+ executor: { instanceId, leaderEpoch: String(epoch) }, operatorUserId, isCurrent: current };
1693
+ const assertCurrent = (): void => { if (!current()) throw new Error("Workspace Restore settlement authority ended."); };
1694
+ // Exact owner abandonment of every original is terminal for an unsent Restore; no cleanup or delivery follows.
1695
+ const abandoned = (value: Threads.TelegramWorkspaceRestoreIntent): boolean => {
1696
+ if (evidence?.kind !== "completed" || !value.request.source.updateIds.length) return false;
1697
+ return value.request.source.updateIds.every(updateId => {
1698
+ const proof = deps.inspectRestoreSourceAbandonment?.(updateId, sourceBinding);
1699
+ assertCurrent();
1700
+ return proof?.journalBindingKey === sourceBinding && proof.updateId === updateId &&
1701
+ proof.operatorAuthorityId === `telegram-owner:${operatorUserId}`;
1702
+ });
1703
+ };
1704
+ for (const operationId of selected) {
1705
+ assertCurrent();
1706
+ let intent = store.list().find(value => value.request.operationId === operationId);
1707
+ let settlementCurrent = current;
1708
+ let settlementCanonicalCurrent: (() => boolean) | undefined;
1709
+ let retired = false;
1710
+ if (follower && intent && inspectableSuccessor(intent) && intent.operatorUserId === operatorUserId) {
1711
+ const ports = deps.workspaceRestoreRecipient!;
1712
+ const successor: Threads.TelegramWorkspaceRestoreRecipient = { kind: "follower", instanceId: follower.instanceId,
1713
+ sessionId: follower.sessionId!, generation: follower.registrationGeneration! };
1714
+ const { request } = intent;
1715
+ // Adoption plus inspect never consumes the original apply grant, delivers input or issues cleanup.
1716
+ await advanceTelegramWorkspaceRestore({ request, authority, restoreStore: store, inspectOnly: true,
1717
+ getRecipient: () => current() ? successor : undefined,
1718
+ async runRecipient(action) {
1719
+ if (action.mode !== "inspect" || !action.isCurrent()) return undefined;
1720
+ return ports.runFollower({ operationId: request.operationId, instanceId: successor.instanceId,
1721
+ sessionId: successor.sessionId, slot: request.binding.slot!, target: request.target,
1722
+ oldTarget: request.binding.target, mode: "inspect", isCurrent: action.isCurrent });
1723
+ } });
1724
+ continue;
1725
+ }
1726
+ if (intent && inspectableLeader(intent) && intent.operatorUserId === operatorUserId) {
1727
+ const threads = deps.threadStore!;
1728
+ const leader: Threads.TelegramWorkspaceRestoreRecipient = { kind: "leader", instanceId,
1729
+ sessionId: intent.request.binding.sessionId!, generation: String(generation) };
1730
+ const { request } = intent;
1731
+ await threads.load();
1732
+ assertCurrent();
1733
+ await advanceTelegramWorkspaceRestore({ request, authority, restoreStore: store, inspectOnly: true,
1734
+ getRecipient: () => current() && leaderAt(request) ? leader : undefined,
1735
+ async runRecipient(action) {
1736
+ if (action.mode !== "inspect") return undefined;
1737
+ return observeLeaderRestore(action.intent, leader, action.isCurrent);
1738
+ } });
1739
+ continue;
1740
+ }
1741
+ if (intent?.phase === "ready" && intent.routing === undefined && intent.operatorUserId === operatorUserId &&
1742
+ intent.request.source.journalBindingKey === sourceBinding && abandoned(intent)) {
1743
+ const owned = isDeepStrictEqual(intent.executor, authority.executor) ? intent : store.adopt(intent, authority);
1744
+ assertCurrent();
1745
+ if (owned) store.retireAbandoned(owned, owned.request.source.updateIds, authority);
1746
+ continue;
1747
+ }
1748
+ const liveRecipient = intent && recoveryRecipient(intent), localRecipient = intent && recoveryLeader(intent);
1749
+ if (intent && (liveRecipient || localRecipient) && intent.request.source.journalBindingKey === sourceBinding) {
1750
+ const expected = intent, ports = deps.workspaceRestoreRecipient!;
1751
+ const recoveryCurrent = (): boolean => current() && (localRecipient ? leaderAt(expected.request) && isDeepStrictEqual(
1752
+ recoveryLeader(expected), localRecipient) : isDeepStrictEqual(
1753
+ { ...recoveryRecipient(expected), lastHeartbeatMs: undefined, connectedAtMs: undefined, threadName: undefined },
1754
+ { ...liveRecipient, lastHeartbeatMs: undefined, connectedAtMs: undefined, threadName: undefined }));
1755
+ // Storage authority callbacks check live fences only; canonical reads cannot re-enter their transaction lock.
1756
+ const recoveryAuthority = { ...authority, isCurrent: recoveryCurrent };
1757
+ settlementCurrent = recoveryCurrent;
1758
+ if (localRecipient) {
1759
+ settlementCanonicalCurrent = () => !!intent && !!observeLeaderRestore(intent, localRecipient, recoveryCurrent);
1760
+ if (!settlementCanonicalCurrent()) continue; // A local target alone cannot authorize executor adoption.
1761
+ }
1762
+ const inspect = (acceptance: Threads.TelegramWorkspaceRestoreSourceAcceptance,
1763
+ operation: Threads.TelegramWorkspaceRestoreIntent): boolean => {
1764
+ const completion = { journalBindingKey: sourceBinding, updateId: acceptance.updateId,
1765
+ sourceSha256: acceptance.sourceSha256,
1766
+ completionSha256: Threads.getTelegramWorkspaceRestoreSourceCompletionSha256(operation, acceptance) };
1767
+ const observed = deps.inspectRestoreSourceCompletion!({ ...completion });
1768
+ if (!recoveryCurrent()) throw new Error("Workspace Restore completion observation authority ended.");
1769
+ return isDeepStrictEqual(observed, completion);
1770
+ };
1771
+ // Inspect proof before adopting: missing/foreign ACKs cannot even re-key the retained operation.
1772
+ const accepted = expected.routing!.acceptances!.filter(value =>
1773
+ (localRecipient ? value.kind === "completed" || value.kind === "queued" : value.kind === "forwarded") && inspect(value, expected));
1774
+ if (accepted.length) {
1775
+ const successor: Threads.TelegramWorkspaceRestoreRecipient = localRecipient ?? { kind: "follower", instanceId: liveRecipient!.instanceId,
1776
+ sessionId: liveRecipient!.sessionId!, generation: liveRecipient!.registrationGeneration! };
1777
+ const { request } = expected;
1778
+ const inspected = await advanceTelegramWorkspaceRestore({ request, authority: recoveryAuthority, restoreStore: store, inspectOnly: true,
1779
+ getRecipient: () => recoveryCurrent() ? successor : undefined,
1780
+ async runRecipient(action) {
1781
+ if (action.mode !== "inspect" || !action.isCurrent()) return undefined;
1782
+ if (localRecipient) return observeLeaderRestore(action.intent, successor, action.isCurrent);
1783
+ return ports.runFollower({ operationId: request.operationId, instanceId: successor.instanceId,
1784
+ sessionId: successor.sessionId, slot: request.binding.slot!, target: request.target,
1785
+ oldTarget: request.binding.target, mode: "inspect", isCurrent: action.isCurrent });
1786
+ } });
1787
+ if (!inspected || !recoveryCurrent()) continue;
1788
+ intent = inspected;
1789
+ if (settlementCanonicalCurrent && !settlementCanonicalCurrent()) continue;
1790
+ // The await cannot lend earlier readback; confirm each immutable proof again before publication.
1791
+ const settledIds = new Set(intent.routing!.settlements.filter(value => value.kind !== "queued").flatMap(value => value.updateIds));
1792
+ const recovered = new Map<string, Threads.TelegramWorkspaceRestoreSourceSettlement>();
1793
+ for (const acceptance of accepted.filter(value => !settledIds.has(value.updateId) && inspect(value, intent!))) {
1794
+ const key = acceptance.kind === "queued" ? JSON.stringify([acceptance.receiptId, acceptance.queueKind]) : "completed";
1795
+ const settlement = recovered.get(key) ?? { journalBindingKey: sourceBinding, updateIds: [],
1796
+ ...(acceptance.kind === "queued" ? { kind: "queue-completed" as const, receiptId: acceptance.receiptId, queueKind: acceptance.queueKind }
1797
+ : { kind: "completed" as const }) };
1798
+ settlement.updateIds.push(acceptance.updateId);
1799
+ recovered.set(key, settlement);
1800
+ }
1801
+ for (const settlement of recovered.values()) {
1802
+ intent = store.recordSourceSettlement(intent!, settlement, recoveryAuthority);
1803
+ if (!intent) break;
1804
+ }
1805
+ if (!intent) continue;
1806
+ }
1807
+ }
1808
+ // Source hints carry no registry fence; cleanup must keep the acknowledged recipient exact across awaits.
1809
+ const readyRecipientCurrent = (): boolean => {
1810
+ const ready = intent?.readyRecipient ?? intent?.recipient;
1811
+ if (intent?.phase !== "ready" || ready?.kind !== "follower") return true;
1812
+ const live = deps.workspaceRestoreRecipient?.followerRegistry.get(ready.instanceId);
1813
+ return !!live?.cwd && live.registrationGeneration === ready.generation && live.sessionId === ready.sessionId &&
1814
+ WorkspaceIdentity.normalizeTelegramWorkspacePath(live.cwd) === intent.request.binding.cwd &&
1815
+ live.slot === intent.request.binding.slot && isDeepStrictEqual(live.target, intent.request.target) &&
1816
+ Bus.hasTelegramBusCapability(live.protocol, Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE) &&
1817
+ Bus.hasTelegramBusCapability(live.protocol, Bus.TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION);
1818
+ };
1819
+ const settlementAuthority = { ...authority, isCurrent: function () { return settlementCurrent() && readyRecipientCurrent(); } };
1820
+ const assertSettlementCurrent = (): void => {
1821
+ if (!settlementAuthority.isCurrent() || (!retired && settlementCanonicalCurrent && !settlementCanonicalCurrent())) {
1822
+ throw new Error("Workspace Restore settlement authority ended.");
1823
+ }
1824
+ };
1825
+ assertSettlementCurrent();
1826
+ if (!intent || intent.phase !== "ready" || !intent.routing || !isDeepStrictEqual(intent.executor, authority.executor) ||
1827
+ intent.operatorUserId !== operatorUserId || intent.request.source.journalBindingKey !== sourceBinding) continue;
1828
+ const settled = new Set(intent.routing.settlements.flatMap(value => value.updateIds));
1829
+ if (follower && (intent.routing.cleanup !== undefined || !recipientMatches(intent) ||
1830
+ !intent.request.source.updateIds.every(id => settled.has(id)))) continue;
1831
+ const updateIds = evidence?.updateIds.filter(id => intent!.request.source.updateIds.includes(id) && !settled.has(id)) ?? [];
1832
+ if (evidence && updateIds.length) {
1833
+ intent = store.recordSourceSettlement(intent, { ...evidence, updateIds }, settlementAuthority);
1834
+ if (!intent) continue;
1835
+ }
1836
+ if (intent.routing?.cleanup === undefined) {
1837
+ if (!intent.request.source.updateIds.every(id => intent!.routing!.settlements.some(value => value.kind !== "queued" && value.updateIds.includes(id)))) continue;
1838
+ // Positive settlement of every selected source is the success proof; old-thread cleanup may stay protected indefinitely.
1839
+ await disposeTemporaryThreadSiblingsAfterRestore(intent, ctx, settlementAuthority.isCurrent);
1840
+ assertSettlementCurrent();
1841
+ if (isRerouteTargetProtected(intent.request.binding.target, intent)) continue;
1842
+ const grant = store.issueCleanup(intent, settlementAuthority);
1843
+ if (!grant) continue;
1844
+ intent = grant.intent;
1845
+ const oldTarget = intent.request.binding.target;
1846
+ const completed = intent.request.owner.owner?.kind === "leader"
1847
+ ? await closePreviousLeaderThread(oldTarget, assertSettlementCurrent, intent)
1848
+ : await closeReplacedFollowerThread(oldTarget, intent.request.owner.instanceId, assertSettlementCurrent, intent);
1849
+ assertSettlementCurrent();
1850
+ if (!completed) continue; // Issued uncertainty stays protected, never downgraded or retried.
1851
+ intent = store.recordCleanup(intent, { kind: "completed", target: oldTarget }, settlementAuthority);
1852
+ if (!intent) continue;
1853
+ }
1854
+ if (!store.retire(intent, settlementAuthority)) continue;
1855
+ retired = true;
1856
+ for (const [id, pending] of pendingUnboundReroutes) {
1857
+ if (pending.workspaceRestore?.operationId !== operationId) continue;
1858
+ pending.finalizeMessage = "Message routed.";
1859
+ if (!deps.editInteractiveMessage || pending.chooserMessageId === undefined) continue;
1860
+ assertSettlementCurrent();
1861
+ await deps.editInteractiveMessage(pending.sourceTarget.chatId, pending.chooserMessageId,
1862
+ "<b>✅ Message routed.</b>", "html", { inline_keyboard: [] });
1863
+ assertSettlementCurrent();
1864
+ if (pendingUnboundReroutes.get(id) === pending) removePendingReroute(id);
1865
+ }
1866
+ }
1867
+ })).catch(error => { deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-settlement" }); });
1868
+ restoreSettlementTasks.add(task);
1869
+ void task.finally(() => restoreSettlementTasks.delete(task));
1870
+ return task;
1871
+ };
1872
+ const beforeQueueReceiptPublished: NonNullable<Updates.TelegramUpdateWorkerRuntimeDeps<TContext>["beforeQueueReceiptPublished"]> =
1873
+ async (receipt, queueOwner, ctx, isWorkerCurrent) => {
1874
+ const store = deps.getWorkspaceRestoreStore?.();
1875
+ if (!store) return;
1876
+ const selected = store.list().filter(intent => intent.request.source.journalBindingKey === receipt.journalBindingKey &&
1877
+ intent.request.source.updateIds.some(id => receipt.sourceUpdateIds.includes(id))).map(intent => intent.request.operationId);
1878
+ if (!selected.length) return;
1879
+ const instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.();
1880
+ const generation = deps.getSessionGeneration?.(), scope = deps.getAdmissionScope?.(), operatorUserId = deps.configStore.getAllowedUserId();
1881
+ const binding = receipt.journalBindingKey, ports = deps.workspaceRestoreRecipient, threads = deps.threadStore;
1882
+ if (!instanceId || epoch === undefined || generation === undefined || !scope || !binding || operatorUserId === undefined ||
1883
+ !ports || !threads || !deps.runWorkspaceOperation || !deps.inspectRestoreQueuedReceipt) {
1884
+ throw new Error("Workspace Restore queued acceptance authority is unavailable.");
1885
+ }
1886
+ const expectedReceipt: TelegramUpdateJournalQueuedCompletion = { queueKind: receipt.queueKind, receiptId: receipt.receiptId,
1887
+ sourceUpdateIds: [...receipt.sourceUpdateIds], queueOwner: { ...queueOwner } };
1888
+ const current = (): boolean => isWorkerCurrent() && deps.hasWorkspaceRestoreAuthority?.() === true &&
1889
+ deps.isContextActive?.(ctx) !== false && deps.getSessionGeneration?.() === generation && deps.getAdmissionScope?.() === scope &&
1890
+ deps.getAdmissionJournalBinding?.() === binding && deps.getCurrentInstanceId?.() === instanceId &&
1891
+ deps.getCurrentLeaderEpoch?.() === epoch && deps.configStore.getAllowedUserId() === operatorUserId &&
1892
+ queueOwner.instanceId === instanceId && queueOwner.sessionGeneration === generation;
1893
+ const assertCurrent = (): void => { if (!current()) throw new Error("Workspace Restore queued acceptance authority changed."); };
1894
+ assertCurrent();
1895
+ const recipientGuards: Array<() => boolean> = [];
1896
+ const sourceCompletions: Updates.TelegramQueueSourceCompletion[] = [];
1897
+ await deps.runWorkspaceOperation({ operationId: `restore-queue-acceptance-${randomBytes(16).toString("hex")}`,
1898
+ operationKind: "workspace.restore-queue-acceptance", scopes: [{ kind: "profile" }] }, async () => {
1899
+ assertCurrent();
1900
+ const active = deps.getWorkspaceRestoreStore?.();
1901
+ if (!active) throw new Error("Workspace Restore queued acceptance storage disappeared.");
1902
+ const proof = deps.inspectRestoreQueuedReceipt!({ ...structuredClone(expectedReceipt), journalBindingKey: binding });
1903
+ assertCurrent();
1904
+ if (!proof || !isDeepStrictEqual(proof.receipt, expectedReceipt) || !/^[a-f0-9]{64}$/u.test(proof.queueOwnerSha256) ||
1905
+ proof.sources.length !== receipt.sourceUpdateIds.length || proof.sources.some((source, index) =>
1906
+ source.updateId !== receipt.sourceUpdateIds[index] || !/^[a-f0-9]{64}$/u.test(source.sourceSha256))) {
1907
+ throw new Error("Workspace Restore queued receipt proof is unavailable.");
1908
+ }
1909
+ for (const operationId of selected) {
1910
+ let intent = active.list().find(value => value.request.operationId === operationId);
1911
+ const recipient = intent?.readyRecipient ?? intent?.recipient;
1912
+ if (!intent || intent.phase !== "ready" || !intent.routing || intent.operatorUserId !== operatorUserId ||
1913
+ !isDeepStrictEqual(intent.executor, { instanceId, leaderEpoch: String(epoch) }) ||
1914
+ intent.request.source.journalBindingKey !== binding || recipient?.kind !== "leader" || recipient.instanceId !== instanceId ||
1915
+ recipient.generation !== String(generation) || recipient.sessionId !== intent.request.binding.sessionId) {
1916
+ throw new Error("Workspace Restore queued recipient proof changed.");
1917
+ }
1918
+ const request = intent.request;
1919
+ const liveRecipientCurrent = (): boolean => {
1920
+ const local = ports.getLeaderIdentity(), cwd = ports.getCwd(ctx);
1921
+ return current() && !!local && local.slot === request.binding.slot && isDeepStrictEqual(local.target, request.target) &&
1922
+ ports.getSessionId(ctx) === recipient.sessionId && !!cwd && WorkspaceIdentity.normalizeTelegramWorkspacePath(cwd) === request.binding.cwd;
1923
+ };
1924
+ const recipientCurrent = (): boolean => {
1925
+ if (!liveRecipientCurrent()) return false;
1926
+ let committed = false;
1927
+ threads.withWorkspaceRestoreSnapshot(intent!, snapshot => {
1928
+ const live = snapshot.workspaceBindings?.filter(value => value.bindingKey === request.binding.bindingKey) ?? [];
1929
+ const owners = snapshot.threads.filter(value => value.status === "active" &&
1930
+ (value.slot === request.binding.slot || isDeepStrictEqual(value.target, request.target)));
1931
+ const local = ports.getLeaderIdentity(), cwd = ports.getCwd(ctx);
1932
+ committed = live.length === 1 && live[0]?.sessionId === recipient.sessionId && live[0].cwd === request.binding.cwd &&
1933
+ live[0].slot === request.binding.slot && live[0].inactiveSinceMs === undefined && isDeepStrictEqual(live[0].target, request.target) &&
1934
+ owners.length === 1 && owners[0]?.owner?.kind === "leader" && owners[0].instanceId === instanceId &&
1935
+ owners[0].owner.instanceId === instanceId && !!owners[0].owner.cwd &&
1936
+ WorkspaceIdentity.normalizeTelegramWorkspacePath(owners[0].owner.cwd) === request.binding.cwd && owners[0].profileKey === request.owner.profileKey &&
1937
+ owners[0].slot === request.binding.slot && isDeepStrictEqual(owners[0].target, request.target) &&
1938
+ !!local && local.slot === request.binding.slot && isDeepStrictEqual(local.target, request.target) &&
1939
+ ports.getSessionId(ctx) === recipient.sessionId && !!cwd && WorkspaceIdentity.normalizeTelegramWorkspacePath(cwd) === request.binding.cwd;
1940
+ });
1941
+ return committed && liveRecipientCurrent();
1942
+ };
1943
+ recipientGuards.push(recipientCurrent);
1944
+ // Storage owns the canonical CAS; its in-transaction authority callback cannot reacquire the snapshot lock.
1945
+ const authority: Threads.TelegramWorkspaceRestoreAuthority = { executor: { instanceId, leaderEpoch: String(epoch) }, operatorUserId, isCurrent: liveRecipientCurrent };
1946
+ for (const source of proof.sources.filter(value => request.source.updateIds.includes(value.updateId))) {
1947
+ if (!recipientCurrent()) throw new Error("Workspace Restore queued recipient authority changed.");
1948
+ const evidence: Threads.TelegramWorkspaceRestoreSourceAcceptance = { ...source, journalBindingKey: binding, recipient,
1949
+ kind: "queued", receiptId: receipt.receiptId, queueKind: receipt.queueKind, queueOwnerSha256: proof.queueOwnerSha256 };
1950
+ let accepted: Threads.TelegramWorkspaceRestoreIntent | undefined, failure: unknown;
1951
+ try { accepted = active.recordSourceAcceptance(intent, evidence, authority); } catch (error) { failure = error; }
1952
+ if (!accepted) accepted = active.list().find(value => value.request.operationId === operationId &&
1953
+ isDeepStrictEqual(value.request, request) && isDeepStrictEqual(value.executor, authority.executor) && value.operatorUserId === operatorUserId &&
1954
+ value.routing?.acceptances?.some(retained => isDeepStrictEqual(retained, evidence)));
1955
+ if (!accepted || !isDeepStrictEqual(accepted.request, request) || !isDeepStrictEqual(accepted.executor, authority.executor) ||
1956
+ accepted.operatorUserId !== operatorUserId || !accepted.routing?.acceptances?.some(value => isDeepStrictEqual(value, evidence))) {
1957
+ throw failure ?? new Error("Workspace Restore queued acceptance was not published.");
1958
+ }
1959
+ intent = accepted;
1960
+ sourceCompletions.push({ ...source, journalBindingKey: binding,
1961
+ completionSha256: Threads.getTelegramWorkspaceRestoreSourceCompletionSha256(accepted, evidence) });
1962
+ if (!recipientCurrent()) throw new Error("Workspace Restore queued recipient authority changed.");
1963
+ }
1964
+ }
1965
+ const observed = deps.inspectRestoreQueuedReceipt!({ ...structuredClone(expectedReceipt), journalBindingKey: binding });
1966
+ assertCurrent();
1967
+ if (!isDeepStrictEqual(observed, proof)) throw new Error("Workspace Restore queued receipt changed after publication.");
1968
+ });
1969
+ assertCurrent();
1970
+ if (recipientGuards.some(guard => !guard())) throw new Error("Workspace Restore queued recipient changed after admission.");
1971
+ return sourceCompletions.sort((a, b) => a.updateId - b.updateId);
1972
+ };
1973
+ const onQueueReceiptCompleted = (receipt: Updates.TelegramQueueAdmissionReceiptLike, ctx: TContext): void => {
1974
+ // A hint re-enters admission and rereads scoped proof; it never supplies completed command evidence for a queued source.
1975
+ if (!receipt.journalBindingKey) return;
1976
+ observeRestoreSettlement({ kind: "source", evidence: { journalBindingKey: receipt.journalBindingKey,
1977
+ updateIds: [...receipt.sourceUpdateIds], kind: "completed" } }, ctx);
1978
+ for (const updateId of receipt.sourceUpdateIds) retireCompletedTemporaryThread(updateId, ctx, receipt.journalBindingKey);
1218
1979
  };
1980
+ const onQueueReceiptCommitted = (receipt: Updates.TelegramQueueAdmissionReceiptLike, ctx: TContext): void => {
1981
+ if (receipt.journalBindingKey) observeRestoreSettlement({ kind: "source", evidence: { journalBindingKey: receipt.journalBindingKey,
1982
+ updateIds: [...receipt.sourceUpdateIds], kind: "queued", receiptId: receipt.receiptId, queueKind: receipt.queueKind } }, ctx);
1983
+ };
1984
+ // Worker-confirmed source completion is the only evidence for a Forwarded input. It records a durable group fact and
1985
+ // starts the same delayed, fully rechecked cleanup as the last Cancel; deletion itself never happens here.
1986
+ const completedTemporaryInputIds = new Map<string, Set<number>>();
1987
+ const retireCompletedTemporaryThread = (updateId: number, ctx: TContext, journalBindingKey: string): void => {
1988
+ const cap = captureTemporaryThreadAuthority(ctx);
1989
+ if (!cap || !deps.runWorkspaceOperation || cap.journalBindingKey !== journalBindingKey) return;
1990
+ const store = cap.store;
1991
+ const matches = (entry: Threads.TelegramTemporaryThreadEntry) =>
1992
+ entry.source.journalBindingKey === journalBindingKey && entry.source.updateId === updateId;
1993
+ const inGroup = (entry: Threads.TelegramTemporaryThreadEntry) => Threads.getTelegramTemporaryThreadInputs(entry).some(input =>
1994
+ input.journalBindingKey === journalBindingKey && input.updateIds.includes(updateId));
1995
+ let restoreOwned = false;
1996
+ try {
1997
+ if (!store.listTemporaryThreads().some(entry => matches(entry) || inGroup(entry))) return;
1998
+ restoreOwned = store.list().some(intent => intent.request.source.journalBindingKey === journalBindingKey &&
1999
+ intent.request.source.updateIds.includes(updateId));
2000
+ } catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-retire" }); return; }
2001
+ const task = Promise.resolve().then(() => deps.runWorkspaceOperation!({ operationId: `temporary-thread-retire-${randomBytes(16).toString("hex")}`,
2002
+ operationKind: "workspace.temporary-thread", scopes: [{ kind: "profile" }] }, async () => {
2003
+ if (!cap.isCurrent()) return;
2004
+ let entry = store.listTemporaryThreads().find(restoreOwned ? matches : inGroup);
2005
+ if (!entry) return;
2006
+ if (restoreOwned) {
2007
+ // A Restore keeps its own lifecycle: only its source entry may be released, never by Forward bookkeeping.
2008
+ if (entry.target && hasOtherTemporaryThreadReroute(entry.target, entry)) return;
2009
+ entry = cap.adopt(entry);
2010
+ if (entry && cap.isCurrent()) store.retireTemporaryThread(entry, cap.authority);
2011
+ return;
2012
+ }
2013
+ const group = Threads.getTelegramTemporaryThreadInputs(entry).find(input =>
2014
+ input.journalBindingKey === journalBindingKey && input.updateIds.includes(updateId));
2015
+ const target = entry.target;
2016
+ if (!group || entry.phase !== "created" || !target) return;
2017
+ if (entry.cancelledInputs?.some(input => isDeepStrictEqual(input, group)) ||
2018
+ entry.completedInputs?.some(input => isDeepStrictEqual(input, group))) return;
2019
+ // Process-local accumulation only: a restart forgets partial groups and the tab stays protected.
2020
+ const seen = completedTemporaryInputIds.get(entry.token) ?? new Set<number>();
2021
+ seen.add(updateId);
2022
+ completedTemporaryInputIds.set(entry.token, seen);
2023
+ if (!group.updateIds.every(id => seen.has(id))) return;
2024
+ entry = cap.adopt(entry);
2025
+ if (!entry || !cap.isCurrent() || !store.recordTemporaryThreadInputCompletion(entry, group, cap.authority) || !cap.isCurrent()) return;
2026
+ scheduleTemporaryThreadCleanup(target, ctx);
2027
+ return true;
2028
+ })).then(published => {
2029
+ // A sibling may complete after Restore already settled. Reinspect only after its terminal fact publishes and admission releases.
2030
+ if (published && cap.isCurrent()) observeRestoreSettlement({ kind: "source",
2031
+ evidence: { journalBindingKey, updateIds: [updateId], kind: "completed" } }, ctx);
2032
+ }).catch(error => { deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-retire" }); });
2033
+ restoreSettlementTasks.add(task);
2034
+ void task.finally(() => restoreSettlementTasks.delete(task));
2035
+ };
2036
+ const onUpdateCompleted = (updateId: number, ctx: TContext, journalBindingKey?: string): void => {
2037
+ if (!journalBindingKey) return;
2038
+ observeRestoreSettlement({ kind: "source", evidence: { journalBindingKey, updateIds: [updateId], kind: "completed" } }, ctx);
2039
+ retireCompletedTemporaryThread(updateId, ctx, journalBindingKey);
2040
+ };
2041
+ const restoreWorkspace = deps.workspaceRestoreRecipient ? async (input: {
2042
+ operationId: string; record: Threads.TelegramTopicTargetRecord;
2043
+ target: Threads.TelegramTopicTargetRecord["target"]; messages: readonly TMessage[];
2044
+ ctx: TContext; isCurrent: () => boolean;
2045
+ dispatch: (recipient: { kind: "leader" | "follower"; instanceId: string }, isRecipientCurrent: () => boolean,
2046
+ recordForwardAcceptance: (message: TMessage, delivery: Bus.TelegramBusFollowerDeliveryIdentity) => Updates.TelegramDeferredSourceEvidence,
2047
+ recordLocalAcceptance: (message: TMessage) => Updates.TelegramDeferredSourceEvidence) => Promise<boolean>;
2048
+ }) => {
2049
+ const ports = deps.workspaceRestoreRecipient!;
2050
+ const threads = deps.threadStore;
2051
+ if (!threads || !deps.getWorkspaceRestoreStore || !deps.getSessionGeneration ||
2052
+ !deps.hasWorkspaceRestoreAuthority?.() || !input.isCurrent() || input.target.threadId === undefined) return "protected";
2053
+ const instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.();
2054
+ const operatorUserId = deps.configStore.getAllowedUserId(), scope = deps.getAdmissionScope?.();
2055
+ const journalBindingKey = deps.getAdmissionJournalBinding?.(), generation = deps.getSessionGeneration();
2056
+ const sourceIds = Updates.collectTelegramAdmissionSourceUpdateIds(input.messages);
2057
+ if (!instanceId || epoch === undefined || operatorUserId === undefined || !scope || !journalBindingKey || !sourceIds.length) return "protected";
2058
+ const current = (): boolean => input.isCurrent() && deps.hasWorkspaceRestoreAuthority?.() === true &&
2059
+ deps.isContextActive?.(input.ctx) !== false && deps.getSessionGeneration?.() === generation &&
2060
+ deps.getCurrentInstanceId?.() === instanceId && deps.getCurrentLeaderEpoch?.() === epoch &&
2061
+ deps.getAdmissionScope?.() === scope && deps.getAdmissionJournalBinding?.() === journalBindingKey &&
2062
+ deps.configStore.getAllowedUserId() === operatorUserId;
2063
+ await threads.load();
2064
+ if (!current()) return "protected";
2065
+ const store = deps.getWorkspaceRestoreStore();
2066
+ if (!store) return "protected";
2067
+ const retained = store.list().find(value => value.request.operationId === input.operationId);
2068
+ const bindings = threads.listWorkspaceBindings().filter(value => isDeepStrictEqual(value.target, input.record.target));
2069
+ const binding = retained?.request.binding ?? (bindings.length === 1 ? bindings[0] : undefined);
2070
+ if (!binding || !binding.sessionId || !binding.slot) return "protected";
2071
+ const { sessionId, slot } = binding;
2072
+ const request: Threads.TelegramWorkspaceRestoreRequest = { operationId: input.operationId,
2073
+ binding, owner: structuredClone(input.record), target: { chatId: input.target.chatId, threadId: input.target.threadId },
2074
+ source: { journalBindingKey, updateIds: sourceIds } };
2075
+ if (retained && !isDeepStrictEqual(retained.request, request)) return "protected";
2076
+ const getRecipient = (snapshot?: Readonly<Pick<Threads.TelegramTopicTargetFile, "threads" | "workspaceBindings">>): Threads.TelegramWorkspaceRestoreRecipient | undefined => {
2077
+ if (!current()) return undefined;
2078
+ const liveBindings = (snapshot ? snapshot.workspaceBindings ?? [] : threads.listWorkspaceBindings())
2079
+ .filter(value => value.bindingKey === binding.bindingKey);
2080
+ const live = liveBindings[0];
2081
+ if (liveBindings.length !== 1 || live?.sessionId !== binding.sessionId || live.cwd !== binding.cwd || live.slot !== binding.slot ||
2082
+ live.inactiveSinceMs !== undefined || (!isDeepStrictEqual(live.target, binding.target) && !isDeepStrictEqual(live.target, request.target))) return undefined;
2083
+ const records = (snapshot ? snapshot.threads : threads.list()).filter(value => value.status === "active" &&
2084
+ (value.slot === binding.slot || isDeepStrictEqual(value.target, live.target)));
2085
+ const record = records[0];
2086
+ if (records.length !== 1 || record?.slot !== binding.slot || !isDeepStrictEqual(record.target, live.target)) return undefined;
2087
+ if (record.owner?.kind === "leader" && record.instanceId === instanceId) {
2088
+ const cwd = ports.getCwd(input.ctx);
2089
+ if (ports.getSessionId(input.ctx) !== sessionId || !cwd || WorkspaceIdentity.normalizeTelegramWorkspacePath(cwd) !== binding.cwd ||
2090
+ !deps.setCurrentLeaderIdentity) return undefined;
2091
+ return { kind: "leader", instanceId, sessionId, generation: String(generation) };
2092
+ }
2093
+ if (record.owner?.kind !== "manual-follower" || !record.instanceId) return undefined;
2094
+ const follower = ports.followerRegistry.get(record.instanceId);
2095
+ if (!follower?.registrationGeneration || !follower.cwd || follower.sessionId !== sessionId ||
2096
+ WorkspaceIdentity.normalizeTelegramWorkspacePath(follower.cwd) !== binding.cwd || follower.slot !== slot ||
2097
+ !Bus.hasTelegramBusCapability(follower.protocol, Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE)) return undefined;
2098
+ return { kind: "follower", instanceId: follower.instanceId, sessionId, generation: follower.registrationGeneration };
2099
+ };
2100
+ const hasCommittedTarget = (): boolean => threads.listWorkspaceBindings().some(value =>
2101
+ value.bindingKey === binding.bindingKey && isDeepStrictEqual(value.target, request.target));
2102
+ const authority = { executor: { instanceId, leaderEpoch: String(epoch) }, operatorUserId, isCurrent: current };
2103
+ const ready = await advanceTelegramWorkspaceRestore({ request, authority, restoreStore: store, getRecipient,
2104
+ async runRecipient(action) {
2105
+ const recipient = getRecipient();
2106
+ if (!recipient || !action.isCurrent() || !hasCommittedTarget()) return undefined;
2107
+ if (recipient.kind === "follower") {
2108
+ const follower = ports.followerRegistry.get(recipient.instanceId);
2109
+ const observation = await ports.runFollower({ operationId: request.operationId, instanceId: recipient.instanceId,
2110
+ sessionId: recipient.sessionId, slot: binding.slot!, target: request.target, oldTarget: binding.target,
2111
+ mode: action.mode, isCurrent: action.isCurrent });
2112
+ if (!action.isCurrent() || !hasCommittedTarget()) return undefined;
2113
+ if (!follower || !observation?.ready) return observation;
2114
+ if (observation.operationId !== request.operationId || !isDeepStrictEqual(observation.recipient, recipient) ||
2115
+ !isDeepStrictEqual(observation.target, request.target) || observation.slot !== binding.slot) return undefined;
2116
+ const actual = ports.followerRegistry.get(recipient.instanceId);
2117
+ if (!actual || actual.registrationGeneration !== follower.registrationGeneration || actual.sessionId !== follower.sessionId ||
2118
+ actual.cwd !== follower.cwd || actual.slot !== follower.slot || actual.profileKey !== follower.profileKey ||
2119
+ actual.busSocketPath !== follower.busSocketPath || !isDeepStrictEqual(actual.target, follower.target) ||
2120
+ !isDeepStrictEqual(actual.protocol, follower.protocol)) return undefined;
2121
+ threads.commitWorkspaceRestoreRegistration({ target: request.target, bindingKey: binding.bindingKey, slot: binding.slot }, () => {
2122
+ if (!action.isCurrent()) throw new Error("Workspace Restore recipient authority changed.");
2123
+ ports.followerRegistry.register({ ...actual, target: request.target, connectedAtMs: actual.connectedAtMs });
2124
+ });
2125
+ return observation;
2126
+ }
2127
+ let observation: Awaited<ReturnType<Parameters<typeof advanceTelegramWorkspaceRestore>[0]["runRecipient"]>>;
2128
+ threads.withWorkspaceRestoreSnapshot(action.intent, snapshot => {
2129
+ if (!action.isCurrent() || !isDeepStrictEqual(getRecipient(snapshot), recipient)) return;
2130
+ const live = snapshot.workspaceBindings?.find(value => value.bindingKey === binding.bindingKey);
2131
+ if (!live || !isDeepStrictEqual(live.target, request.target)) return;
2132
+ const local = ports.getLeaderIdentity();
2133
+ if (!local || local.slot !== binding.slot || (!isDeepStrictEqual(local.target, binding.target) &&
2134
+ !isDeepStrictEqual(local.target, request.target))) return;
2135
+ if (action.mode === "apply" && !isDeepStrictEqual(local.target, request.target)) {
2136
+ if (action.intent.phase !== "recipient-issued" || !action.isCurrent()) return;
2137
+ deps.setCurrentLeaderIdentity!({ target: request.target, slot: binding.slot, threadName: request.owner.threadName });
2138
+ }
2139
+ if (!action.isCurrent() || !isDeepStrictEqual(getRecipient(snapshot), recipient)) return;
2140
+ const observed = ports.getLeaderIdentity();
2141
+ if (!observed || observed.slot !== binding.slot) return;
2142
+ observation = { operationId: request.operationId, recipient, target: observed.target as typeof request.target,
2143
+ slot: binding.slot!, ready: isDeepStrictEqual(observed.target, request.target) };
2144
+ });
2145
+ return observation;
2146
+ } }).catch(error => {
2147
+ deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-recipient" });
2148
+ return undefined;
2149
+ });
2150
+ if (!ready || !current() || ready.routing) return "protected";
2151
+ const recipient = ready.readyRecipient ?? ready.recipient;
2152
+ const recipientCurrent = (): boolean => {
2153
+ if (!current() || !recipient || !hasCommittedTarget() || !isDeepStrictEqual(getRecipient(), recipient)) return false;
2154
+ const observed = recipient.kind === "leader" ? ports.getLeaderIdentity() : ports.followerRegistry.get(recipient.instanceId);
2155
+ return observed?.slot === slot && isDeepStrictEqual(observed.target, request.target);
2156
+ };
2157
+ if (!recipient || !recipientCurrent()) return "protected";
2158
+ if (deps.callApi) {
2159
+ try {
2160
+ await deps.callApi("editForumTopic", { chat_id: request.target.chatId, message_thread_id: request.target.threadId,
2161
+ name: deps.getDisplayTitle?.(request.target) ?? ThreadNaming.getTelegramTopicTitleForThreadName(getRestoredThreadName(request.owner, slot), slot) });
2162
+ } catch (error) { deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-title" }); }
2163
+ }
2164
+ if (!recipientCurrent() || !store.issueRouting(ready, { ...authority, isCurrent: recipientCurrent })) return "protected";
2165
+ const recordAcceptance = (message: TMessage, delivery?: Bus.TelegramBusFollowerDeliveryIdentity): Updates.TelegramDeferredSourceEvidence => {
2166
+ if (!recipientCurrent() || recipient.kind !== (delivery ? "follower" : "leader")) throw new Error("Workspace Restore acceptance authority changed.");
2167
+ const source = Updates.inspectTelegramDeferredSource(message);
2168
+ const follower = ports.followerRegistry.get(recipient.instanceId);
2169
+ const ownership = deps.getTargetOwnership?.(request.target);
2170
+ if (!source || source.journalBindingKey !== journalBindingKey || !request.source.updateIds.includes(source.updateId) ||
2171
+ delivery && (!follower || ownership?.instanceId !== recipient.instanceId || ownership.ownerGeneration !== recipient.generation ||
2172
+ !ownership.recipientBindingKey || delivery.sourceUpdateId !== source.updateId ||
2173
+ delivery.recipientBindingKey !== ownership.recipientBindingKey || delivery.deliveryId !== Bus.createTelegramBusFollowerDeliveryIdentity({
2174
+ kind: "leader.forwardMessage", recipientBindingKey: ownership.recipientBindingKey, sourceUpdateId: source.updateId }).deliveryId) ||
2175
+ !recipientCurrent()) throw new Error("Workspace Restore acceptance source or delivery changed.");
2176
+ const evidence: Threads.TelegramWorkspaceRestoreSourceAcceptance = { ...source, recipient,
2177
+ ...(delivery ? { kind: "forwarded", deliveryId: delivery.deliveryId, recipientBindingKey: delivery.recipientBindingKey }
2178
+ : { kind: "completed" }) };
2179
+ const expected = store.list().find(value => value.request.operationId === request.operationId);
2180
+ if (!expected || !isDeepStrictEqual(expected.request, request) || !expected.routing ||
2181
+ !recipientCurrent()) throw new Error("Workspace Restore dispatch proof is unavailable.");
2182
+ let accepted: Threads.TelegramWorkspaceRestoreIntent | undefined;
2183
+ let failure: { error: unknown } | undefined;
2184
+ const acceptanceAuthority = { ...authority, isCurrent: recipientCurrent };
2185
+ try { accepted = store.recordSourceAcceptance(expected, evidence, acceptanceAuthority); }
2186
+ catch (error) { failure = { error }; }
2187
+ if (!recipientCurrent()) throw new Error("Workspace Restore acceptance authority changed.");
2188
+ // A lost rename reply observes only the exact full retained proof; it never resends the accepted message.
2189
+ if (!accepted) {
2190
+ const observed = store.list().find(value => value.request.operationId === request.operationId);
2191
+ if (observed && isDeepStrictEqual(observed.request, request) && isDeepStrictEqual(observed.executor, authority.executor) &&
2192
+ observed.operatorUserId === operatorUserId && observed.routing?.acceptances?.some(value => isDeepStrictEqual(value, evidence))) accepted = observed;
2193
+ }
2194
+ if (!accepted || !isDeepStrictEqual(accepted.request, request) || !isDeepStrictEqual(accepted.executor, authority.executor) ||
2195
+ accepted.operatorUserId !== operatorUserId || !accepted.routing?.acceptances?.some(value => isDeepStrictEqual(value, evidence)) ||
2196
+ !recipientCurrent()) throw failure?.error ?? new Error("Workspace Restore acceptance was not published.");
2197
+ return { ...source, completionSha256: Threads.getTelegramWorkspaceRestoreSourceCompletionSha256(accepted, evidence) };
2198
+ };
2199
+ try { await input.dispatch(recipient, recipientCurrent, recordAcceptance, message => recordAcceptance(message)); }
2200
+ catch (error) { deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-dispatch" }); }
2201
+ // Positive worker ACKs own settlement and cleanup under a subsequent admission.
2202
+ return "protected";
2203
+ } : undefined;
1219
2204
  const retryPendingRerouteCleanup = async (
1220
2205
  cleanup: PendingRerouteCleanup,
1221
2206
  assertExecutionCurrent?: () => void,
@@ -1225,6 +2210,7 @@ export function createTelegramInboundRouteRuntime<
1225
2210
  cleanup.target,
1226
2211
  cleanup.messageId,
1227
2212
  assertExecutionCurrent,
2213
+ cleanup.temporaryThread,
1228
2214
  );
1229
2215
  }
1230
2216
  if (cleanup.kind === "previous-leader") {
@@ -1240,15 +2226,15 @@ export function createTelegramInboundRouteRuntime<
1240
2226
  | ((messages: TMessage[], ctx: TContext) => Promise<void>)
1241
2227
  | undefined;
1242
2228
  const dispatchPendingRerouteMessages = async (
1243
- pending: Pick<PendingUnboundReroute, "dispatchKind" | "sourceTarget">,
2229
+ pending: Pick<PendingUnboundReroute, "dispatchKind" | "sourceTarget" | "temporaryThread">,
1244
2230
  messages: TMessage[],
1245
2231
  ctx: TContext,
1246
2232
  ): Promise<void> => {
1247
2233
  if (pending.dispatchKind === "command" && dispatchReroutedCommandMessages) {
1248
2234
  await dispatchReroutedCommandMessages(messages, ctx);
1249
- if (pending.sourceTarget.threadId === undefined) {
1250
- for (const message of messages) Updates.reportTelegramUpdateCompleted(message);
1251
- }
2235
+ // Every command chooser deferred its exact original, whether the tab came from Telegram or the bridge.
2236
+ // A queued command retains its separately reported receipt; this cannot settle accepted queue custody.
2237
+ for (const message of messages) Updates.reportTelegramUpdateCompleted(message);
1252
2238
  return;
1253
2239
  }
1254
2240
  await promptEnqueue(messages, ctx);
@@ -1277,36 +2263,78 @@ export function createTelegramInboundRouteRuntime<
1277
2263
  `${successMessage} Chooser cleanup is still pending. Try again.`,
1278
2264
  );
1279
2265
  };
2266
+ const isTemporaryReroute = (pending: PendingUnboundReroute): boolean =>
2267
+ !!pending.temporaryThread || pending.temporaryMembership === true || isTemporaryTabTarget(pending.sourceTarget);
2268
+ const isTemporaryThreadForwardIssued = (pending: PendingUnboundReroute): boolean => {
2269
+ try {
2270
+ const entry = pending.temporaryThread ?? findCreatedTemporaryThread(pending.sourceTarget);
2271
+ const store = deps.getWorkspaceRestoreStore?.();
2272
+ if ((entry || pending.temporaryMembership) && !store) return true;
2273
+ const stored = entry && store?.listTemporaryThreads().find(value => value.token === entry.token);
2274
+ const updateIds = Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages);
2275
+ return !!stored?.forwardedInputs?.some(input => input.updateIds.some(id => updateIds.includes(id)));
2276
+ } catch { return true; }
2277
+ };
2278
+ /** Publishes the durable one-time Forward fact for this chooser's exact group; false means nothing may be sent. */
2279
+ const issueTemporaryThreadForward = (pending: PendingUnboundReroute, ctx: TContext): boolean => {
2280
+ const entry = pending.temporaryThread ?? findCreatedTemporaryThread(pending.sourceTarget);
2281
+ const cap = captureTemporaryThreadAuthority(ctx);
2282
+ if (!entry || !cap) return false;
2283
+ const group = { journalBindingKey: entry.source.journalBindingKey, updateIds: Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages) };
2284
+ try {
2285
+ const current = cap.store.listTemporaryThreads().find(value => value.token === entry.token);
2286
+ const adopted = current && cap.isCurrent() ? cap.adopt(current) : undefined;
2287
+ return !!adopted && cap.isCurrent() && !!cap.store.recordTemporaryThreadForwardIssued(adopted, group, cap.authority) && cap.isCurrent();
2288
+ } catch (error) {
2289
+ deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-forward-issue" });
2290
+ return false;
2291
+ }
2292
+ };
1280
2293
  const forwardPendingRerouteMessages = async (
1281
2294
  pending: PendingUnboundReroute,
1282
2295
  instanceId: string,
1283
2296
  threadId: number,
1284
2297
  ctx: TContext,
1285
2298
  assertExecutionCurrent?: () => void,
2299
+ recordForwardAcceptance?: (message: TMessage, delivery: Bus.TelegramBusFollowerDeliveryIdentity) => Updates.TelegramDeferredSourceEvidence,
1286
2300
  ): Promise<boolean> => {
1287
2301
  assertExecutionCurrent?.();
1288
2302
  const forwardMessage = deps.foreignOwnedUpdateForwarder?.forwardMessage;
1289
- if (!forwardMessage) return false;
2303
+ if (!forwardMessage || pending.foreignForwardIssued) return false;
2304
+ const target = { ...pending.sourceTarget, threadId };
2305
+ const liveOwnership = deps.getTargetOwnership?.(target);
2306
+ if (!liveOwnership || liveOwnership.instanceId !== instanceId ||
2307
+ !liveOwnership.ownerGeneration || !liveOwnership.recipientBindingKey) return false;
2308
+ const ownership = structuredClone(liveOwnership);
1290
2309
  const messages = cloneTelegramMessagesForThread(pending.messages, threadId);
2310
+ // Issuance is durable before the RPC: an unacknowledged temporary-tab Forward never becomes another dispatch grant,
2311
+ // even after restart. Any refusal or publication failure sends nothing and keeps the input held.
2312
+ if (!recordForwardAcceptance && isTemporaryReroute(pending) && !issueTemporaryThreadForward(pending, ctx)) {
2313
+ if (isTemporaryThreadForwardIssued(pending)) pending.foreignForwardIssued = true;
2314
+ return false;
2315
+ }
2316
+ if (!recordForwardAcceptance && isTemporaryReroute(pending)) pending.foreignForwardIssued = true;
1291
2317
  const outcomes = await Promise.allSettled(
1292
2318
  messages.map((message) =>
1293
2319
  forwardMessage({
1294
2320
  message,
1295
- ownership: { instanceId },
2321
+ ownership,
1296
2322
  ctx,
1297
2323
  }),
1298
2324
  ),
1299
2325
  );
1300
2326
  assertExecutionCurrent?.();
2327
+ if (!isDeepStrictEqual(deps.getTargetOwnership?.(target), ownership)) return false;
1301
2328
  pending.messages = pending.messages.filter((_, index) => {
1302
2329
  const outcome = outcomes[index];
1303
2330
  if (
1304
2331
  outcome?.status === "fulfilled" &&
1305
2332
  outcome.value.status === "accepted"
1306
2333
  ) {
1307
- if (pending.dispatchKind === "command" && pending.sourceTarget.threadId === undefined) {
1308
- Updates.reportTelegramUpdateCompleted(pending.messages[index]);
1309
- }
2334
+ // Restore publishes acceptance before reporting completion; cleanup still waits for journal disposition.
2335
+ const expectedSource = recordForwardAcceptance?.(pending.messages[index], outcome.value.delivery);
2336
+ assertExecutionCurrent?.();
2337
+ Updates.reportTelegramUpdateCompleted(pending.messages[index], expectedSource);
1310
2338
  return false;
1311
2339
  }
1312
2340
  if (outcome?.status === "rejected") {
@@ -1319,9 +2347,487 @@ export function createTelegramInboundRouteRuntime<
1319
2347
  }
1320
2348
  return true;
1321
2349
  });
2350
+ if (pending.messages.length === 0) pending.foreignForwardIssued = false;
1322
2351
  return pending.messages.length === 0;
1323
2352
  };
1324
- const handleUnboundRerouteRestoreMenuCallback = async (
2353
+ const temporaryCleanupTimers = new Map<string, { timer: ReturnType<typeof setTimeout>; settle: () => void }>();
2354
+ const cancelTemporaryThreadCleanup = (token: string): void => {
2355
+ const scheduled = temporaryCleanupTimers.get(token);
2356
+ if (!scheduled) return;
2357
+ clearTimeout(scheduled.timer);
2358
+ temporaryCleanupTimers.delete(token);
2359
+ scheduled.settle();
2360
+ };
2361
+ /**
2362
+ * One cleanup attempt after the quiet period. Cancellation facts are only a precondition: fresh authority, proof,
2363
+ * absence of chooser and journal custody, and the ordinary protection checks must all hold again. The attempt is
2364
+ * durably issued once; a skipped or unknown removal keeps the entry protecting the tab across restart.
2365
+ */
2366
+ const runTemporaryThreadCleanup = async (token: string, cap: TemporaryThreadAuthority): Promise<void> => {
2367
+ const store = cap.store, inspect = deps.inspectRestoreSourceAbandonment;
2368
+ if (!inspect || !deps.runWorkspaceOperation || !deps.callApi || !cap.isCurrent()) return;
2369
+ await deps.runWorkspaceOperation({ operationId: `temporary-thread-cleanup-${randomBytes(16).toString("hex")}`,
2370
+ operationKind: "workspace.temporary-thread", scopes: [{ kind: "profile" }] }, async () => {
2371
+ if (!cap.isCurrent()) return;
2372
+ let entry = store.listTemporaryThreads().find(value => value.token === token && value.phase === "created" && !!value.target);
2373
+ const target = entry?.target;
2374
+ if (!entry || !target || entry.cleanupIssued || !Threads.isTelegramTemporaryThreadFullyResolved(entry) || hasPendingRerouteForTarget(target) ||
2375
+ entry.operatorUserId !== cap.operatorUserId || entry.source.journalBindingKey !== cap.journalBindingKey) return;
2376
+ const owner = `telegram-owner:${cap.operatorUserId}`, cancelled = entry.cancelledInputs ?? [];
2377
+ const cancelledCurrent = () => cancelled.every(input => input.updateIds.every(updateId => {
2378
+ const evidence = inspect(updateId, input.journalBindingKey);
2379
+ return evidence?.journalBindingKey === input.journalBindingKey && evidence.updateId === updateId &&
2380
+ evidence.operatorAuthorityId === owner;
2381
+ }));
2382
+ if (!cancelledCurrent()) return;
2383
+ entry = cap.adopt(entry);
2384
+ if (!entry || !cap.isCurrent() || isRerouteTargetProtected(target, undefined, entry)) return;
2385
+ const issued = store.issueTemporaryThreadCleanup(entry, cap.authority);
2386
+ if (!issued || !cap.isCurrent()) return;
2387
+ entry = issued.entry;
2388
+ const issuedEntry = entry;
2389
+ const assertCurrent = (): void => {
2390
+ if (!cap.isCurrent() || !cancelledCurrent() || !store.isTemporaryThreadCleanupCurrent(issuedEntry, cap.authority))
2391
+ throw new Error("Temporary Thread cleanup authority or exact grant evidence changed.");
2392
+ };
2393
+ if (!await closeReroutedUnboundTopic(target, undefined, assertCurrent, entry) || !cap.isCurrent()) return;
2394
+ store.retireTemporaryThread(entry, cap.authority);
2395
+ });
2396
+ };
2397
+ /** Starts the quiet period only when every known group is resolved; a new input or authority change denies it. */
2398
+ const scheduleTemporaryThreadCleanup = (target: Queue.TelegramQueueTarget, ctx: TContext): void => {
2399
+ const cap = captureTemporaryThreadAuthority(ctx);
2400
+ if (!cap || !deps.runWorkspaceOperation || target.threadId === undefined || hasPendingRerouteForTarget(target)) return;
2401
+ let entry: Threads.TelegramTemporaryThreadEntry | undefined;
2402
+ try { entry = cap.store.listTemporaryThreads().find(value => value.phase === "created" && isDeepStrictEqual(value.target, target)); }
2403
+ catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup-schedule" }); return; }
2404
+ if (!entry || entry.cleanupIssued || !Threads.isTelegramTemporaryThreadFullyResolved(entry) ||
2405
+ entry.operatorUserId !== cap.operatorUserId || entry.source.journalBindingKey !== cap.journalBindingKey) return;
2406
+ const token = entry.token;
2407
+ cancelTemporaryThreadCleanup(token);
2408
+ const requested = deps.temporaryThreadCleanupDelayMs;
2409
+ const delay = typeof requested === "number" && Number.isFinite(requested) ? Math.max(0, requested) : 1000;
2410
+ let settle = (): void => undefined;
2411
+ const task = new Promise<void>(resolve => { settle = resolve; });
2412
+ const timer = setTimeout(() => {
2413
+ if (temporaryCleanupTimers.get(token)?.timer !== timer) return;
2414
+ temporaryCleanupTimers.delete(token);
2415
+ runTemporaryThreadCleanup(token, cap)
2416
+ .catch(error => { deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup" }); })
2417
+ .finally(settle);
2418
+ }, delay);
2419
+ timer.unref?.();
2420
+ temporaryCleanupTimers.set(token, { timer, settle });
2421
+ restoreSettlementTasks.add(task);
2422
+ void task.finally(() => restoreSettlementTasks.delete(task));
2423
+ };
2424
+ const recordCancelledTemporaryThreadInput = (pending: PendingUnboundReroute, ctx: TContext, isCurrent: () => boolean): boolean => {
2425
+ const store = deps.getWorkspaceRestoreStore?.(), target = pending.sourceTarget;
2426
+ if (!store || target.threadId === undefined) return !pending.temporaryThread;
2427
+ let entry = store.listTemporaryThreads().find(value => value.phase === "created" && isDeepStrictEqual(value.target, target));
2428
+ if (!entry) return !pending.temporaryThread;
2429
+ const cap = captureTemporaryThreadAuthority(ctx, isCurrent), inspect = deps.inspectRestoreSourceAbandonment;
2430
+ if (!cap || !inspect || cap.operatorUserId !== entry.operatorUserId || cap.journalBindingKey !== entry.source.journalBindingKey) return false;
2431
+ const { operatorUserId, journalBindingKey } = cap, current = cap.isCurrent;
2432
+ const input = { journalBindingKey, updateIds: Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages) };
2433
+ if (!current()) return false;
2434
+ entry = cap.adopt(entry);
2435
+ if (!entry || !current()) return false;
2436
+ const recorded = store.recordTemporaryThreadInputCancellation(entry, input, cap.authority, updateId => inspect(updateId, journalBindingKey));
2437
+ if (!recorded || !current() || !recorded.cancelledInputs?.some(value => isDeepStrictEqual(value, input))) return false;
2438
+ const observed = store.listTemporaryThreads().find(value => isDeepStrictEqual(value, recorded));
2439
+ return !!observed && input.updateIds.every(updateId => {
2440
+ const evidence = inspect(updateId, journalBindingKey);
2441
+ return evidence?.journalBindingKey === journalBindingKey && evidence.updateId === updateId &&
2442
+ evidence.operatorAuthorityId === `telegram-owner:${operatorUserId}`;
2443
+ }) && current();
2444
+ };
2445
+ const retireCancellationChooser = async (id: string, pending: PendingUnboundReroute, isCurrent: () => boolean): Promise<boolean> => {
2446
+ if (!pending.abandonment?.result || !isCurrent() || !deps.editInteractiveMessage || pending.chooserMessageId === undefined) return false;
2447
+ await deps.editInteractiveMessage(pending.sourceTarget.chatId, pending.chooserMessageId,
2448
+ "<b>⛔️ Routing cancelled.</b>", "html", { inline_keyboard: [] });
2449
+ if (!isCurrent() || pendingUnboundReroutes.get(id) !== pending) return false;
2450
+ removePendingReroute(id);
2451
+ return true;
2452
+ };
2453
+ const isReviewText = (message: Partial<TMessage> | undefined): boolean => {
2454
+ if (!message || message.chat?.type !== "private" || !Number.isSafeInteger(message.chat.id) ||
2455
+ !Number.isSafeInteger(message.from?.id) || message.from?.is_bot !== false ||
2456
+ !Number.isSafeInteger(message.message_id) || message.message_id! <= 0 ||
2457
+ !Number.isSafeInteger(message.message_thread_id) || message.message_thread_id! <= 0 ||
2458
+ typeof message.text !== "string" || !message.text.trim() || message.text.trim().startsWith("/")) return false;
2459
+ const fields = message as Record<string, unknown>;
2460
+ return ["media_group_id", "business_connection_id", "photo", "video", "audio", "voice", "document", "animation", "sticker", "contact", "location", "venue", "poll", "dice", "story"].every(key => fields[key] === undefined);
2461
+ };
2462
+ const reviewMessage = (entry: RecoverySource["original"]): Partial<TMessage> | undefined => {
2463
+ const message = entry.update.message as Partial<TMessage> | undefined;
2464
+ if (entry.state !== "pending" || entry.preApprovalExcluded || entry.inputClaim || entry.inputProvenance ||
2465
+ entry.queueOwner || entry.queueReceiptId || entry.queueHandoff || entry.failure ||
2466
+ Object.keys(entry.update).some(key => key !== "update_id" && key !== "message") ||
2467
+ !isReviewText(message) || Reflect.has(message!, "pi_telegram_source_update_id")) return undefined;
2468
+ return message;
2469
+ };
2470
+ const needsHistoricalReview = (message: Partial<TMessage>): boolean => {
2471
+ if (!deps.threadStore) throw new Error("Historical routing requires a current Thread snapshot.");
2472
+ if (deps.threadStore.getBotState().threadMode === "disabled") return true;
2473
+ return !getTelegramRoutableThreadRecords(deps.threadStore.list(), deps.getLiveThreadTargets?.()).some(record =>
2474
+ record.target.chatId === message.chat!.id && record.target.threadId === message.message_thread_id);
2475
+ };
2476
+ /** New-world restart: forget previous-instance Restore/temporary state, then one silent delete per positively disposable tab. */
2477
+ const forgetPreviousWorld = async (input: Updates.TelegramHeldSourcePreparation<TContext>,
2478
+ captureTransport?: (ctx: TContext) => (() => boolean) | undefined): Promise<{ forgotten: number; deleted: number }> => {
2479
+ const result = { forgotten: 0, deleted: 0 };
2480
+ const { ctx, signal, journalBindingKey, isCurrent: preparedCurrent } = input;
2481
+ const transport = captureTransport ? captureTransport(ctx) : () => true;
2482
+ const cap = transport && captureTemporaryThreadAuthority(ctx, () => !signal.aborted && preparedCurrent() && transport() &&
2483
+ deps.hasWorkspaceRestoreAuthority?.() === true);
2484
+ const threads = deps.threadStore;
2485
+ if (!cap?.isCurrent() || cap.journalBindingKey !== journalBindingKey || !threads || !deps.runWorkspaceOperation) return result;
2486
+ try {
2487
+ await deps.runWorkspaceOperation({ operationId: `new-world-${randomBytes(16).toString("hex")}`,
2488
+ operationKind: "workspace.forget-previous-world", scopes: [{ kind: "profile" }] }, async () => {
2489
+ if (!cap.isCurrent()) return;
2490
+ await threads.load();
2491
+ if (!cap.isCurrent()) return;
2492
+ const self = cap.authority.executor.instanceId;
2493
+ const disposable = cap.store.listTemporaryThreads().flatMap(entry => entry.operatorUserId === cap.operatorUserId &&
2494
+ entry.executor.instanceId !== self && entry.phase === "created" && entry.target &&
2495
+ cap.store.inspectTemporaryThreadTarget(entry, cap.authority)?.kind === "temporary" ? [entry] : []);
2496
+ const forgotten = cap.store.forgetPreviousWorld(cap.authority);
2497
+ if (!forgotten) return;
2498
+ result.forgotten = forgotten.operations.length + forgotten.temporaryThreads.length;
2499
+ for (const entry of disposable) {
2500
+ const target = entry.target!;
2501
+ if (!cap.isCurrent() || !deps.callApi) break;
2502
+ await threads.load();
2503
+ const same = (value: { chatId: number; threadId?: number } | undefined) => value?.chatId === target.chatId && value.threadId === target.threadId;
2504
+ if (!cap.isCurrent() || threads.listWorkspaceBindings().some(value => same(value.target)) ||
2505
+ threads.list().some(record => record.status === "active" && same(record.target)) ||
2506
+ cap.store.listTemporaryThreads().some(entry => same(entry.target)) ||
2507
+ cap.store.list().some(({ request }) => same(request.target) || same(request.binding.target))) continue;
2508
+ try {
2509
+ const required = [...new Set([cap.journalBindingKey, entry.source.journalBindingKey,
2510
+ ...Threads.getTelegramTemporaryThreadInputs(entry).map(input => input.journalBindingKey)])];
2511
+ if (deps.inspectTemporaryThreadSources?.(target, required)?.length !== 0 || !cap.isCurrent()) continue;
2512
+ // Exactly one attempt; the entry is already forgotten, so failure or silence leaves the tab without retry.
2513
+ if (await deps.callApi<boolean>("deleteForumTopic", { chat_id: target.chatId, message_thread_id: target.threadId },
2514
+ { maxAttempts: 1, retrySafety: "non-idempotent" }) === true) result.deleted++;
2515
+ } catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "new-world-tab-delete" }); }
2516
+ }
2517
+ });
2518
+ } catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "new-world-forget" }); }
2519
+ return result;
2520
+ };
2521
+ /** Historical temporary sources await the cold policy; warm bound/unknown sources never fall into ordinary dispatch. */
2522
+ const isRecordedTemporaryThreadInputHeld = (entry: RecoverySource["original"], ctx: TContext, signal: AbortSignal, historical = false): boolean => {
2523
+ const store = deps.getWorkspaceRestoreStore?.(), binding = deps.getAdmissionJournalBinding?.();
2524
+ if (!store || !binding) return false;
2525
+ const members = store.listTemporaryThreads().filter(temporary =>
2526
+ Threads.getTelegramTemporaryThreadInputs(temporary).some(input => input.journalBindingKey === binding && input.updateIds.includes(entry.updateId)));
2527
+ const cap = captureTemporaryThreadAuthority(ctx, () => !signal.aborted);
2528
+ return members.some(temporary => {
2529
+ if (!cap?.isCurrent()) return true;
2530
+ const observation = store.inspectTemporaryThreadTarget(temporary, cap.authority);
2531
+ return historical || !observation || observation.kind !== "temporary";
2532
+ });
2533
+ };
2534
+ const shouldHoldPendingInput = async (entry: RecoverySource["original"], ctx: TContext, signal: AbortSignal): Promise<boolean> => {
2535
+ const binding = deps.getAdmissionJournalBinding?.();
2536
+ const current = () => !signal.aborted && deps.isContextActive?.(ctx) === true && deps.getAdmissionJournalBinding?.() === binding;
2537
+ if (!current()) throw new Error("Temporary Thread source generation ended.");
2538
+ const held = isRecordedTemporaryThreadInputHeld(entry, ctx, signal);
2539
+ if (!current()) throw new Error("Temporary Thread source authority changed.");
2540
+ return held;
2541
+ };
2542
+ const shouldReviewHistoricalInput = async (entry: RecoverySource["original"], ctx: TContext, signal: AbortSignal): Promise<boolean | "retain"> => {
2543
+ if (signal.aborted) throw new Error("Historical routing generation ended.");
2544
+ const message = entry.update.message as Partial<TMessage> | undefined;
2545
+ const unsupported = !reviewMessage(entry);
2546
+ const retainable = unsupported && entry.state === "pending" && !entry.preApprovalExcluded && !entry.inputClaim && !entry.inputProvenance &&
2547
+ !entry.queueOwner && !entry.queueReceiptId && !entry.queueHandoff && !entry.failure &&
2548
+ Object.keys(entry.update).every(key => key === "update_id" || key === "message") &&
2549
+ message?.chat?.type === "private" && Number.isSafeInteger(message.chat.id) &&
2550
+ Number.isSafeInteger(message.from?.id) && message.from?.is_bot === false &&
2551
+ Number.isSafeInteger(message.message_id) && message.message_id! > 0 &&
2552
+ Number.isSafeInteger(message.message_thread_id) && message.message_thread_id! > 0 &&
2553
+ !Reflect.has(message, "pi_telegram_source_update_id") && !Reflect.has(message, "business_connection_id");
2554
+ const temporary = isRecordedTemporaryThreadInputHeld(entry, ctx, signal, true);
2555
+ if (!retainable && temporary) return true;
2556
+ if (!retainable && unsupported) return false;
2557
+ if (signal.aborted) throw new Error("Historical routing generation ended.");
2558
+ const epoch = deps.getCurrentLeaderEpoch?.();
2559
+ const binding = deps.getAdmissionJournalBinding?.();
2560
+ const current = () => !signal.aborted && deps.isContextActive?.(ctx) === true &&
2561
+ epoch !== undefined && deps.getCurrentLeaderEpoch?.() === epoch && !!binding && deps.getAdmissionJournalBinding?.() === binding;
2562
+ if (!current() || !deps.threadStore) throw new Error("Historical routing authority is unavailable.");
2563
+ await deps.threadStore.load();
2564
+ if (!current()) throw new Error("Historical routing authority changed.");
2565
+ // Raw unsupported startup originals stay protected even when a bound target outlives forgotten Restore membership.
2566
+ // Holding protects evidence, not sender authorization; owner-bearing sources keep their existing paths.
2567
+ return retainable ? "retain" : needsHistoricalReview(message!);
2568
+ };
2569
+ const recoveryPreview = (source: RecoverySource, owner: number, chatId: number, binding: string): string | undefined => {
2570
+ const entry = source.original;
2571
+ const message = reviewMessage(entry);
2572
+ if (!message || message.chat!.id !== chatId || message.from!.id !== owner) return undefined;
2573
+ for (const pending of pendingUnboundReroutes.values()) {
2574
+ if (pending.abandonment?.journalBindingKey === binding &&
2575
+ Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(entry.updateId) &&
2576
+ // Revoked carriers describe a past attempt, not a live selection veto.
2577
+ // Current claim/entry authority still comes from the worker and journal CAS.
2578
+ pending.messages.some(value => Updates.getTelegramUpdateExecutionFence(value)?.signal.aborted !== true) &&
2579
+ (pending.selectionAttempted || pending.destinationSelected || pending.dispatching || pending.foreignForwardIssued || pending.foreignRetry || pending.cleanup ||
2580
+ pending.abandonment.running)) return undefined;
2581
+ }
2582
+ const text = Array.from(message.text!.replace(/\s+/gu, " ").trim());
2583
+ return escapeHtml(text.slice(0, 80).join("")) + (text.length > 80 ? "…" : "");
2584
+ };
2585
+ const expireRoutingInput = async (source: Updates.TelegramRoutingInputExpirySource, ctx: TContext, signal: AbortSignal): Promise<void> => {
2586
+ const operator = deps.configStore.getAllowedUserId(), epoch = deps.getCurrentLeaderEpoch?.();
2587
+ const message = source.original.update.message as Partial<TMessage> | undefined;
2588
+ const current = () => !signal.aborted && source.isCurrent() && operator !== undefined && epoch !== undefined &&
2589
+ deps.isContextActive?.(ctx) === true && deps.configStore.getAllowedUserId() === operator &&
2590
+ deps.getCurrentLeaderEpoch?.() === epoch && deps.getAdmissionJournalBinding?.() === source.journalBindingKey &&
2591
+ source.original.routingInput?.operatorUserId === operator && message?.from?.id === operator && message.from.is_bot !== true && message.chat?.type === "private";
2592
+ if (!current() || !deps.runWorkspaceOperation) return;
2593
+ await deps.runWorkspaceOperation({ operationId: `routing-expiry-${randomBytes(16).toString("hex")}`,
2594
+ operationKind: "workspace.expire-unbound-routing", scopes: [{ kind: "profile" }] }, async () => {
2595
+ if (!current()) return;
2596
+ const restores = deps.getWorkspaceRestoreStore?.();
2597
+ if (deps.getWorkspaceRestoreStore && !restores) return;
2598
+ if (restores?.list().some(intent => intent.request.source.journalBindingKey === source.journalBindingKey &&
2599
+ intent.request.source.updateIds.includes(source.original.updateId))) return;
2600
+ for (const pending of pendingUnboundReroutes.values()) {
2601
+ if (!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId)) continue;
2602
+ if (pending.selectionAttempted || pending.destinationSelected || pending.dispatching || pending.foreignForwardIssued || pending.workspaceRestore || pending.foreignRetry || pending.cleanup || pending.abandonment?.running) return;
2603
+ }
2604
+ const result = source.expire();
2605
+ if (!result) return;
2606
+ // Retire local controls after the source ACK. Telegram UI failure cannot undo retention or trigger deletion.
2607
+ for (const [id, pending] of pendingUnboundReroutes) {
2608
+ if (!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId)) continue;
2609
+ removePendingReroute(id);
2610
+ if (signal.aborted || deps.isContextActive?.(ctx) !== true || deps.configStore.getAllowedUserId() !== operator ||
2611
+ deps.getCurrentLeaderEpoch?.() !== epoch || deps.getAdmissionJournalBinding?.() !== source.journalBindingKey) continue;
2612
+ if (deps.editInteractiveMessage && pending.chooserMessageId !== undefined) {
2613
+ try { await deps.editInteractiveMessage(pending.sourceTarget.chatId, pending.chooserMessageId,
2614
+ `<b>${TELEGRAM_ROUTING_CHOICE_EXPIRED}</b>`, "html", { inline_keyboard: [] }); }
2615
+ catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-expiry-view" }); }
2616
+ }
2617
+ }
2618
+ });
2619
+ };
2620
+ const renderCancellationReview = async (view: CancellationReview, isCurrent: () => boolean): Promise<void> => {
2621
+ if (!isCurrent()) return;
2622
+ const data = (action: string) => `${TELEGRAM_PENDING_CANCELLATION_REVIEW_PREFIX}${view.id}:${action}`;
2623
+ const rows: Menu.TelegramReplyMarkup["inline_keyboard"] = [[{ text: "⬆️ Main menu", callback_data: "menu:back" }]];
2624
+ const lines = ["<b>☑️ Review pending cancellations:</b>", "",
2625
+ "Finish stopping routing retries to retain the original privately. No new delivery is started; previously accepted work may continue. No Telegram message or Thread is deleted."];
2626
+ if (!view.sources.length) lines.push("", "No supported pending cancellations on this page.");
2627
+ for (const [index, source] of view.sources.entries()) {
2628
+ lines.push("", `<b>${index + 1}. ${source.result ? "⛔️ Routing cancelled." : "Protected input"}</b>`, source.preview);
2629
+ if (!source.result) rows.push([{ text: `❌ Finish cancellation ${index + 1}`, callback_data: data(`retry:${index}`) }]);
2630
+ }
2631
+ if (view.unsupported) lines.push("", "Other protected inputs are not available in this view and remain untouched.");
2632
+ if (view.nextAfterUpdateId !== undefined) rows.push([{ text: "🟣 More", callback_data: data("more") }]);
2633
+ rows.push([{ text: "🔄 Refresh", callback_data: data("refresh") }]);
2634
+ await deps.editInteractiveMessage!(view.target.chatId, view.messageId, lines.join("\n"), "html", { inline_keyboard: rows });
2635
+ };
2636
+ const handleCancellationReview = async (query: TCallbackQuery, ctx: TContext): Promise<boolean> => {
2637
+ const matches = (view: CancellationReview) => query.message?.message_id === view.messageId &&
2638
+ query.message.chat.id === view.target.chatId &&
2639
+ (query.message.message_thread_id === undefined || query.message.message_thread_id === view.target.threadId);
2640
+ if (query.data?.startsWith(TELEGRAM_RETIRED_HISTORICAL_REVIEW_PREFIX)) {
2641
+ await deps.answerCallbackQuery(query.id, "🚫 This control is no longer available.");
2642
+ return true;
2643
+ }
2644
+ const ownsReview = query.data?.startsWith(TELEGRAM_PENDING_CANCELLATION_REVIEW_PREFIX) === true;
2645
+ const prefix = TELEGRAM_PENDING_CANCELLATION_REVIEW_PREFIX;
2646
+ if (!ownsReview && cancellationReview && matches(cancellationReview)) {
2647
+ if (cancellationReview.running && cancellationReview.isCurrent()) {
2648
+ await deps.answerCallbackQuery(query.id, "⏳ Cancellation is in progress.");
2649
+ return true;
2650
+ }
2651
+ cancellationReview = undefined;
2652
+ return false;
2653
+ }
2654
+ if (!ownsReview) return false;
2655
+ const unavailable = async () => { await deps.answerCallbackQuery(query.id, "🚫 Cancellation review expired or unavailable. Open Status again."); return true; };
2656
+ const execution = Updates.getTelegramUpdateExecutionFence(query);
2657
+ const chatId = query.message?.chat.id;
2658
+ const messageId = query.message?.message_id;
2659
+ if (execution?.isCurrent() !== true || deps.isContextActive?.(ctx) !== true ||
2660
+ !deps.editInteractiveMessage || !deps.runWorkspaceOperation || query.message?.chat.type !== "private" ||
2661
+ typeof chatId !== "number" || !Number.isSafeInteger(chatId) ||
2662
+ typeof messageId !== "number" || !Number.isSafeInteger(messageId) || messageId <= 0) return unavailable();
2663
+ const owner = deps.configStore.getAllowedUserId();
2664
+ const journalBindingKey = deps.getAdmissionJournalBinding?.();
2665
+ const epoch = deps.getCurrentLeaderEpoch?.();
2666
+ if (owner === undefined || query.from.id !== owner || query.from.is_bot || !journalBindingKey || epoch === undefined) return unavailable();
2667
+ const current = () => execution.isCurrent() && deps.isContextActive?.(ctx) === true &&
2668
+ deps.configStore.getAllowedUserId() === owner && deps.getAdmissionJournalBinding?.() === journalBindingKey &&
2669
+ deps.getCurrentLeaderEpoch?.() === epoch;
2670
+ const parsed = query.data!.slice(prefix.length).match(/^([a-z0-9]+):(retry:(\d+)|refresh|more)$/);
2671
+ const prior = cancellationReview;
2672
+ const open = query.data === `${prefix}open`;
2673
+ if (!open && (!parsed || !prior || parsed[1] !== prior.id || !matches(prior) || !prior.isCurrent())) return unavailable();
2674
+ if (prior?.running && prior.isCurrent()) {
2675
+ await deps.answerCallbackQuery(query.id, "⏳ Cancellation is in progress.");
2676
+ return true;
2677
+ }
2678
+ let view = prior;
2679
+ let selected: CancellationReview["sources"][number] | undefined;
2680
+ try {
2681
+ if (open || parsed?.[2] === "refresh" || parsed?.[2] === "more") {
2682
+ if (parsed?.[2] === "more" && prior?.nextAfterUpdateId === undefined) return unavailable();
2683
+ const next: CancellationReview = { id: randomBytes(16).toString("hex"),
2684
+ target: { chatId, ...(query.message.message_thread_id !== undefined ? { threadId: query.message.message_thread_id } : {}) },
2685
+ messageId, ownerUserId: owner, journalBindingKey, sources: [], unsupported: false,
2686
+ isCurrent: () => cancellationReview === next && current() };
2687
+ cancellationReview = view = next;
2688
+ const page = Updates.inspectTelegramAbandoningUpdates(query, { journalBindingKey, isCurrent: next.isCurrent,
2689
+ ...(parsed?.[2] === "more" ? { afterUpdateId: prior!.nextAfterUpdateId } : {}) });
2690
+ if (!page) { if (cancellationReview === next) cancellationReview = undefined; return unavailable(); }
2691
+ for (const source of page.sources) {
2692
+ const preview = recoveryPreview(source, owner, next.target.chatId, journalBindingKey);
2693
+ if (preview === undefined) { next.unsupported = true; continue; }
2694
+ if (next.sources.length === 5) { next.nextAfterUpdateId = next.sources.at(-1)!.original.updateId; break; }
2695
+ next.sources.push({ ...source, preview });
2696
+ }
2697
+ next.nextAfterUpdateId ??= page.nextAfterUpdateId;
2698
+ next.running = true;
2699
+ await renderCancellationReview(next, next.isCurrent);
2700
+ if (next.isCurrent()) await deps.answerCallbackQuery(query.id);
2701
+ } else {
2702
+ if (!view || !parsed?.[3] || !parsed[2].startsWith("retry:")) return unavailable();
2703
+ selected = view.sources[Number(parsed[3])];
2704
+ if (!selected) return unavailable();
2705
+ const activeView = view;
2706
+ const source = selected;
2707
+ const isCurrent = () => current() && activeView.isCurrent() &&
2708
+ recoveryPreview(source, owner, activeView.target.chatId, journalBindingKey) !== undefined;
2709
+ view.running = true;
2710
+ await deps.runWorkspaceOperation({ operationId: `workspace-cancellation-recovery:${query.id}`,
2711
+ operationKind: "workspace.recover-unbound-cancellation", scopes: [{ kind: "profile" }] }, async () => {
2712
+ if (!isCurrent()) return;
2713
+ source.result ??= source.retry({ operatorAuthorityId: `telegram-owner:${owner}`, isCurrent });
2714
+ if (!source.result) { await unavailable(); return; }
2715
+ for (const [id, pending] of pendingUnboundReroutes) {
2716
+ if (pending.abandonment?.ownerUserId !== owner || pending.abandonment.journalBindingKey !== journalBindingKey ||
2717
+ !Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId)) continue;
2718
+ pending.abandonment.result = source.result;
2719
+ pending.abandonment.attempted = true;
2720
+ try { await retireCancellationChooser(id, pending, isCurrent); }
2721
+ catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "recovered-chooser-cleanup" }); }
2722
+ }
2723
+ await renderCancellationReview(activeView, isCurrent);
2724
+ if (isCurrent()) await deps.answerCallbackQuery(query.id, "⛔️ Routing cancelled.");
2725
+ });
2726
+ }
2727
+ } catch (error) {
2728
+ deps.recordRuntimeEvent?.("routing", error, { phase: "unbound-cancellation-recovery" });
2729
+ if (current()) await deps.answerCallbackQuery(query.id, selected?.result
2730
+ ? "⚠️ Routing cancelled; view update failed. Retry the same button."
2731
+ : "⚠️ Recovery incomplete; inputs stay protected. Check /telegram-status --debug.");
2732
+ } finally { if (view) view.running = false; }
2733
+ return true;
2734
+ };
2735
+ const handleUnboundRerouteCancelCallback = async (query: TCallbackQuery, ctx: TContext): Promise<boolean> => {
2736
+ if (!query.data?.startsWith(TELEGRAM_UNBOUND_REROUTE_CANCEL_CALLBACK_PREFIX)) return false;
2737
+ const rerouteId = query.data.match(/^reroutecancel:([a-z0-9]+)$/)?.[1];
2738
+ const pending = rerouteId ? pendingUnboundReroutes.get(rerouteId) : undefined;
2739
+ const cancellation = pending?.abandonment;
2740
+ const source = pending?.messages[0];
2741
+ const execution = Updates.getTelegramUpdateExecutionFence(query);
2742
+ const sourceExecution = Updates.getTelegramUpdateExecutionFence(source);
2743
+ const isAuthorityCurrent = () => !!cancellation && execution?.isCurrent() === true &&
2744
+ sourceExecution?.signal.aborted === false && deps.isContextActive?.(ctx) === true &&
2745
+ query.from.id === cancellation.ownerUserId && !query.from.is_bot &&
2746
+ deps.configStore.getAllowedUserId() === cancellation.ownerUserId &&
2747
+ deps.getAdmissionJournalBinding?.() === cancellation.journalBindingKey &&
2748
+ deps.getCurrentLeaderEpoch?.() === cancellation.leaderEpoch;
2749
+ const isCurrent = () => isAuthorityCurrent() && !!pending && !!cancellation && !!source &&
2750
+ pendingUnboundReroutes.get(rerouteId!) === pending && pending.abandonment === cancellation &&
2751
+ matchesRerouteChooser(pending, query) && query.message?.chat.type === "private" &&
2752
+ source.from?.id === cancellation.ownerUserId &&
2753
+ pending.messages.length === 1 &&
2754
+ !pending.destinationSelected && !pending.selectionAttempted && !pending.dispatching &&
2755
+ !pending.foreignForwardIssued && !pending.foreignRetry && !pending.cleanup && !pending.finalizeMessage;
2756
+ if (!isCurrent() || !deps.runWorkspaceOperation || !deps.editInteractiveMessage) {
2757
+ // A chooser from a previous process (new world) or an expired one simply reads as expired.
2758
+ await deps.answerCallbackQuery(query.id, pending ? "🚫 Message route is unavailable for cancellation." : TELEGRAM_ROUTING_CHOICE_EXPIRED);
2759
+ return true;
2760
+ }
2761
+ if (cancellation!.running) {
2762
+ await deps.answerCallbackQuery(query.id, "⏳ Cancellation is already in progress.");
2763
+ return true;
2764
+ }
2765
+ cancellation!.running = true;
2766
+ try {
2767
+ await deps.runWorkspaceOperation({ operationId: `workspace-reroute-cancel:${query.id}`,
2768
+ operationKind: "workspace.cancel-unbound-routing", scopes: [{ kind: "profile" }] }, async () => {
2769
+ if (!isCurrent()) return;
2770
+ if (!cancellation!.result) {
2771
+ const wasAttempted = cancellation!.attempted;
2772
+ cancellation!.attempted = true;
2773
+ const result = Updates.abandonTelegramDeferredUpdate(source, {
2774
+ operatorAuthorityId: `telegram-owner:${cancellation!.ownerUserId}`, isCurrent,
2775
+ });
2776
+ if (!result) {
2777
+ cancellation!.attempted = wasAttempted;
2778
+ await deps.answerCallbackQuery(query.id, "🚫 Message routing cannot be cancelled now.");
2779
+ return;
2780
+ }
2781
+ cancellation!.result = result;
2782
+ }
2783
+ if (!isCurrent()) return;
2784
+ if (!recordCancelledTemporaryThreadInput(pending!, ctx, isCurrent)) {
2785
+ if (isAuthorityCurrent()) await deps.answerCallbackQuery(query.id, "⚠️ Routing cancelled; Thread cleanup remains protected. Retry Cancel routing.");
2786
+ return;
2787
+ }
2788
+ if (await retireCancellationChooser(rerouteId!, pending!, isCurrent) && isAuthorityCurrent()) {
2789
+ scheduleTemporaryThreadCleanup(pending!.sourceTarget, ctx);
2790
+ await deps.answerCallbackQuery(query.id, "⛔️ Routing cancelled.");
2791
+ }
2792
+ });
2793
+ } catch (error) {
2794
+ deps.recordRuntimeEvent?.("routing", error, { phase: "unbound-cancellation", rerouteId });
2795
+ if (isCurrent()) await deps.answerCallbackQuery(query.id,
2796
+ cancellation!.result ? "⚠️ Routing cancelled; chooser update failed. Retry Cancel routing." :
2797
+ "⚠️ Cancellation incomplete. Retry Cancel routing.");
2798
+ } finally { cancellation!.running = false; }
2799
+ return true;
2800
+ };
2801
+ const withPendingRerouteSelection = async (
2802
+ rerouteId: string, query: TCallbackQuery, operation: () => Promise<boolean>,
2803
+ ): Promise<boolean> => {
2804
+ Updates.assertTelegramUpdateExecutionCurrent(query);
2805
+ const pending = pendingUnboundReroutes.get(rerouteId);
2806
+ if (pending?.foreignForwardIssued && matchesRerouteChooser(pending, query)) {
2807
+ await deps.answerCallbackQuery(query.id, "🚫 Forward is pending; this button will not resend uncertain input.");
2808
+ return true;
2809
+ }
2810
+ if (pending && matchesRerouteChooser(pending, query) &&
2811
+ (pending.abandonment?.attempted || pending.abandonment?.running || pending.messages.some(message =>
2812
+ Updates.getTelegramUpdateExecutionFence(message)?.isCurrent() === false))) {
2813
+ await deps.answerCallbackQuery(query.id, TELEGRAM_ROUTING_CHOICE_EXPIRED);
2814
+ return true;
2815
+ }
2816
+ const releases: Array<() => void> = [];
2817
+ try {
2818
+ if (pending && matchesRerouteChooser(pending, query)) {
2819
+ try {
2820
+ for (const message of pending.messages) releases.push(Updates.acquireTelegramUpdateRouting(message));
2821
+ } catch (error) {
2822
+ deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-selection" });
2823
+ await deps.answerCallbackQuery(query.id, "🚫 Message route expired or unavailable.");
2824
+ return true;
2825
+ }
2826
+ }
2827
+ return await operation();
2828
+ } finally { for (const release of releases) release(); }
2829
+ };
2830
+ const executeUnboundRerouteRestoreMenuCallback = async (
1325
2831
  query: TCallbackQuery,
1326
2832
  _ctx: TContext,
1327
2833
  ): Promise<boolean> => {
@@ -1340,12 +2846,13 @@ export function createTelegramInboundRouteRuntime<
1340
2846
  !deps.threadStore ||
1341
2847
  !pending ||
1342
2848
  !matchesRerouteChooser(pending, query) ||
2849
+ (parsed.root && pending.rootChooserText === undefined) ||
1343
2850
  expirePendingCommand(parsed.rerouteId, pending)
1344
2851
  ) {
1345
- await deps.answerCallbackQuery(query.id, "Message route expired.");
2852
+ await deps.answerCallbackQuery(query.id, TELEGRAM_ROUTING_CHOICE_EXPIRED);
1346
2853
  return true;
1347
2854
  }
1348
- if (pending.sourceTarget.threadId === undefined) {
2855
+ if (parsed.restore && pending.sourceTarget.threadId === undefined) {
1349
2856
  await deps.answerCallbackQuery(query.id, "Restore needs a destination thread. Send a plain message in a new Telegram thread first.");
1350
2857
  return true;
1351
2858
  }
@@ -1355,23 +2862,36 @@ export function createTelegramInboundRouteRuntime<
1355
2862
  deps.threadStore.list(),
1356
2863
  deps.getLiveThreadTargets?.(),
1357
2864
  );
1358
- const replyMarkup = buildTelegramUnboundRerouteRestoreChooserMarkup(
1359
- parsed.rerouteId,
1360
- activeRecords,
1361
- deps.getDisplayTitle,
1362
- );
2865
+ const replyMarkup = parsed.root
2866
+ ? buildTelegramUnboundRerouteChooserMarkup(parsed.rerouteId, activeRecords, {
2867
+ canRestore: pending.sourceTarget.threadId !== undefined, canCancel: !!pending.abandonment,
2868
+ getDisplayTitle: deps.getDisplayTitle,
2869
+ })
2870
+ : parsed.restore
2871
+ ? buildTelegramUnboundRerouteRestoreChooserMarkup(parsed.rerouteId, activeRecords, deps.getDisplayTitle)
2872
+ : { inline_keyboard: activeRecords.map(record => [{
2873
+ text: `↪️ ${getTelegramThreadRecordLabel(record, deps.getDisplayTitle)}`,
2874
+ callback_data: formatTelegramUnboundRerouteCallbackData(parsed.rerouteId, record.target.threadId),
2875
+ }]) };
2876
+ const chooserText = parsed.root
2877
+ ? pending.rootChooserText!
2878
+ : parsed.restore ? formatTelegramUnboundRerouteRestoreChooserText()
2879
+ : "<b>🧵 Reroute:</b>\n\nChoose the thread that should handle this input:";
2880
+ if (!parsed.root) replyMarkup.inline_keyboard.unshift([{
2881
+ text: "⬆️ Back", callback_data: `${TELEGRAM_UNBOUND_REROUTE_ROOT_CALLBACK_PREFIX}${parsed.rerouteId}`,
2882
+ }]);
1363
2883
  if (deps.editInteractiveMessage) {
1364
2884
  await deps.editInteractiveMessage(
1365
2885
  chatId,
1366
2886
  messageId,
1367
- formatTelegramUnboundRerouteRestoreChooserText(),
2887
+ chooserText,
1368
2888
  "html",
1369
2889
  replyMarkup,
1370
2890
  );
1371
2891
  } else if (deps.sendInteractiveMessage) {
1372
2892
  const chooserId = await deps.sendInteractiveMessage(
1373
2893
  chatId,
1374
- formatTelegramUnboundRerouteRestoreChooserText(),
2894
+ chooserText,
1375
2895
  "html",
1376
2896
  replyMarkup,
1377
2897
  { target: pending.sourceTarget, replyToMessageId: messageId },
@@ -1380,7 +2900,7 @@ export function createTelegramInboundRouteRuntime<
1380
2900
  rememberRerouteChooser(parsed.rerouteId, chooserId);
1381
2901
  }
1382
2902
  assertExecutionCurrent();
1383
- await deps.answerCallbackQuery(query.id, "Choose instance to restore.");
2903
+ await deps.answerCallbackQuery(query.id, parsed.root ? "Choose routing mode." : parsed.restore ? "Choose instance to restore." : "Choose target thread.");
1384
2904
  return true;
1385
2905
  };
1386
2906
  const executeUnboundRerouteCallbackOperation = async (
@@ -1396,14 +2916,14 @@ export function createTelegramInboundRouteRuntime<
1396
2916
  const pending = pendingUnboundReroutes.get(parsed.rerouteId);
1397
2917
  if (typeof chatId !== "number" || !deps.threadStore || !pending ||
1398
2918
  !matchesRerouteChooser(pending, query) || expirePendingCommand(parsed.rerouteId, pending)) {
1399
- await deps.answerCallbackQuery(query.id, "Message route expired.");
2919
+ await deps.answerCallbackQuery(query.id, TELEGRAM_ROUTING_CHOICE_EXPIRED);
1400
2920
  return true;
1401
2921
  }
1402
2922
  await deps.threadStore.load();
1403
2923
  assertExecutionCurrent();
1404
2924
  if (pendingUnboundReroutes.get(parsed.rerouteId) !== pending ||
1405
2925
  expirePendingCommand(parsed.rerouteId, pending)) {
1406
- await deps.answerCallbackQuery(query.id, "Message route expired.");
2926
+ await deps.answerCallbackQuery(query.id, TELEGRAM_ROUTING_CHOICE_EXPIRED);
1407
2927
  return true;
1408
2928
  }
1409
2929
  if (pending.finalizeMessage) {
@@ -1416,6 +2936,11 @@ export function createTelegramInboundRouteRuntime<
1416
2936
  );
1417
2937
  return true;
1418
2938
  }
2939
+ if (pending.workspaceRestore && (!restoreWorkspace || !parsed.useNewSlot ||
2940
+ pending.workspaceRestore.record.target.threadId !== parsed.threadId)) {
2941
+ await deps.answerCallbackQuery(query.id, "🚫 Restore is already selected. Use the original Restore choice.");
2942
+ return true;
2943
+ }
1419
2944
  if (pending.foreignRetry) {
1420
2945
  const retry = pending.foreignRetry;
1421
2946
  const allForwarded = await forwardPendingRerouteMessages(
@@ -1428,7 +2953,8 @@ export function createTelegramInboundRouteRuntime<
1428
2953
  if (!allForwarded) {
1429
2954
  await deps.answerCallbackQuery(
1430
2955
  query.id,
1431
- "Target thread is unavailable; retrying will send only remaining messages.",
2956
+ pending.foreignForwardIssued ? "🚫 Forward is pending; this button will not resend uncertain input." :
2957
+ "Target thread is unavailable; retrying will send only remaining messages.",
1432
2958
  );
1433
2959
  return true;
1434
2960
  }
@@ -1457,7 +2983,11 @@ export function createTelegramInboundRouteRuntime<
1457
2983
  );
1458
2984
  return true;
1459
2985
  }
1460
- const record = getTelegramRoutableThreadRecords(
2986
+ if (!pending.workspaceRestore && isTemporaryReroute(pending) && isTemporaryThreadForwardIssued(pending)) {
2987
+ await deps.answerCallbackQuery(query.id, "🚫 Forward is pending; this button will not resend uncertain input.");
2988
+ return true;
2989
+ }
2990
+ const record = pending.workspaceRestore?.record ?? getTelegramRoutableThreadRecords(
1461
2991
  deps.threadStore.list(),
1462
2992
  deps.getLiveThreadTargets?.(),
1463
2993
  ).find(
@@ -1487,137 +3017,92 @@ export function createTelegramInboundRouteRuntime<
1487
3017
  await deps.answerCallbackQuery(query.id, "🚫 Selected thread is already the destination.");
1488
3018
  return true;
1489
3019
  }
1490
- if (parsed.useNewSlot && sourceTarget && isRerouteTargetProtected(sourceTarget)) {
3020
+ // A source's own temporary tab is its Restore destination; only other protection refuses it.
3021
+ if (parsed.useNewSlot && sourceTarget && !pending.workspaceRestore &&
3022
+ isRerouteTargetProtected(sourceTarget, undefined, pending.temporaryThread ?? findCreatedTemporaryThread(sourceTarget), pending)) {
1491
3023
  await deps.answerCallbackQuery(query.id, "🚫 Thread restore source is already owned.");
1492
3024
  return true;
1493
3025
  }
1494
- pending.destinationSelected = true;
1495
- pending.selectionAttempted = true;
1496
- pending.pauseExpiry?.();
3026
+ // Restore has one controller. Without it or its negotiated authority, refuse before selection so the chooser keeps
3027
+ // its other routes and Cancel.
3028
+ if (parsed.useNewSlot && sourceTarget && !pending.workspaceRestore &&
3029
+ (!restoreWorkspace || deps.hasWorkspaceRestoreAuthority?.() !== true)) {
3030
+ await deps.answerCallbackQuery(query.id, "🚫 Thread restore is unavailable right now. The message stays pending; choose another route or cancel.");
3031
+ return true;
3032
+ }
1497
3033
  const currentInstanceId = deps.getCurrentInstanceId?.();
1498
3034
  const leaderProfileKey = getLeaderTopicProfileKey(ctx, currentInstanceId);
1499
- const isCurrentLeaderRecord = isCurrentLeaderTopicRecord(
1500
- record,
1501
- leaderProfileKey,
1502
- currentInstanceId,
1503
- );
1504
- if (parsed.useNewSlot && !isCurrentLeaderRecord) {
1505
- if (!record.slot || !/^[A-Z]$/u.test(record.slot)) {
1506
- await deps.answerCallbackQuery(
1507
- query.id,
1508
- "Follower thread slot authority is unavailable.",
1509
- );
1510
- return true;
1511
- }
1512
- if (
1513
- !sourceTarget ||
1514
- !deps.replaceFollowerThreadTarget ||
1515
- !deps.foreignOwnedUpdateForwarder?.forwardMessage
1516
- ) {
1517
- await deps.answerCallbackQuery(
1518
- query.id,
1519
- "Follower thread restore is not available yet.",
1520
- );
1521
- return true;
1522
- }
1523
- assertExecutionCurrent();
1524
- const replaced = await deps.replaceFollowerThreadTarget({
1525
- record,
1526
- target: sourceTarget,
1527
- oldTarget: record.target,
1528
- });
1529
- assertExecutionCurrent();
1530
- if (!replaced) {
1531
- await deps.answerCallbackQuery(
1532
- query.id,
1533
- "Follower thread is unavailable.",
1534
- );
1535
- return true;
1536
- }
1537
- const nowMs = Date.now();
1538
- const slot = record.slot;
1539
- const threadName = getRestoredThreadName(record, slot);
1540
- assertExecutionCurrent();
1541
- deps.threadStore.markStaleByTarget(
1542
- record.target,
1543
- "deleted",
1544
- "Follower thread was replaced by restore source.",
1545
- );
1546
- deps.threadStore.upsert({
1547
- ...record,
1548
- target: sourceTarget,
1549
- status: "active",
1550
- syncStatus: "open",
1551
- updatedAtMs: nowMs,
1552
- threadName,
1553
- lastSyncObservedAtMs: nowMs,
1554
- lastReconcileAction: "follower-thread-restore",
1555
- rerouteConfirmedAtMs: nowMs,
1556
- });
1557
- await deps.threadStore.persist();
1558
- assertExecutionCurrent();
1559
- if (deps.callApi) {
1560
- try {
1561
- assertExecutionCurrent();
1562
- await deps.callApi("editForumTopic", {
1563
- chat_id: sourceTarget.chatId,
1564
- message_thread_id: sourceTarget.threadId,
1565
- name:
1566
- deps.getDisplayTitle?.(sourceTarget) ??
1567
- Threads.getTelegramTopicTitleForThreadName(threadName, slot),
1568
- });
1569
- assertExecutionCurrent();
1570
- } catch (renameError) {
1571
- deps.recordRuntimeEvent?.("telegram", renameError, {
1572
- phase: "follower-topic-reroute-restore-rename",
1573
- chatId: sourceTarget.chatId,
1574
- threadId: sourceTarget.threadId,
1575
- slot: record.slot,
1576
- });
1577
- }
3035
+ const isCurrentLeaderRecord = isCurrentLeaderTopicRecord(record, leaderProfileKey, currentInstanceId);
3036
+ if (!parsed.useNewSlot && record.instanceId && record.instanceId !== currentInstanceId && !isCurrentLeaderRecord &&
3037
+ !deps.foreignOwnedUpdateForwarder?.forwardMessage) {
3038
+ if (pending.routingOperatorUserId === undefined) {
3039
+ pending.destinationSelected = true; pending.selectionAttempted = true; pending.pauseExpiry?.();
1578
3040
  }
1579
- const cleanup: PendingRerouteCleanup = {
1580
- kind: "replaced-follower",
1581
- target: record.target,
1582
- instanceId: record.instanceId,
3041
+ await deps.answerCallbackQuery(query.id, "Open that thread and resend the message there.");
3042
+ return true;
3043
+ }
3044
+ if (pending.routingOperatorUserId !== undefined && (pending.routingOperatorUserId !== query.from.id ||
3045
+ pending.routingOperatorUserId !== deps.configStore.getAllowedUserId())) {
3046
+ await deps.answerCallbackQuery(query.id, "🚫 Message route authority changed.");
3047
+ return true;
3048
+ }
3049
+ // Freeze only a validated actual destination, inside the same admission as its effect. Menu browsing,
3050
+ // unavailable recipients and refused Restore capability never turn a waiting source into a selected grant.
3051
+ try {
3052
+ const release = Updates.acquireTelegramUpdateRouting(pending.messages[0], true,
3053
+ Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages));
3054
+ release();
3055
+ } catch (error) {
3056
+ deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-selection" });
3057
+ await deps.answerCallbackQuery(query.id, "🚫 Message route expired or unavailable.");
3058
+ return true;
3059
+ }
3060
+ pending.destinationSelected = true;
3061
+ pending.selectionAttempted = true;
3062
+ pending.pauseExpiry?.();
3063
+ if (parsed.useNewSlot && sourceTarget && restoreWorkspace) {
3064
+ const selection = pending.workspaceRestore ??= { operationId: `restore-${randomBytes(16).toString("hex")}`,
3065
+ record: structuredClone(record), messages: [...pending.messages] };
3066
+ let active = true;
3067
+ const ownerUserId = deps.configStore.getAllowedUserId();
3068
+ const leaderEpoch = deps.getCurrentLeaderEpoch?.();
3069
+ const admissionScope = deps.getAdmissionScope?.();
3070
+ const journalBinding = deps.getAdmissionJournalBinding?.();
3071
+ const selectionCurrent = (): boolean => {
3072
+ try { assertExecutionCurrent(); } catch { return false; }
3073
+ return ownerUserId === query.from.id && deps.configStore.getAllowedUserId() === ownerUserId &&
3074
+ deps.getCurrentLeaderEpoch?.() === leaderEpoch && deps.getAdmissionScope?.() === admissionScope &&
3075
+ deps.getAdmissionJournalBinding?.() === journalBinding && deps.isContextActive?.(ctx) !== false &&
3076
+ pendingUnboundReroutes.get(parsed.rerouteId) === pending && pending.workspaceRestore === selection;
1583
3077
  };
1584
- const allForwarded = await forwardPendingRerouteMessages(
1585
- pending,
1586
- record.instanceId!,
1587
- sourceTarget.threadId,
1588
- ctx,
1589
- assertExecutionCurrent,
1590
- );
1591
- if (!allForwarded) {
1592
- pending.foreignRetry = {
1593
- instanceId: record.instanceId!,
1594
- threadId: sourceTarget.threadId,
1595
- cleanup,
1596
- };
1597
- await deps.answerCallbackQuery(
1598
- query.id,
1599
- "Thread restored; retrying will send only remaining messages before old-thread cleanup.",
1600
- );
1601
- return true;
1602
- }
1603
- const cleanupComplete = await retryPendingRerouteCleanup(
1604
- cleanup,
1605
- assertExecutionCurrent,
1606
- );
1607
- if (!cleanupComplete) {
1608
- pending.cleanup = cleanup;
1609
- await deps.answerCallbackQuery(
1610
- query.id,
1611
- "Thread restored, but old-thread cleanup is still pending. Try again.",
1612
- );
1613
- return true;
1614
- }
1615
- await finalizePendingReroute(
1616
- parsed.rerouteId,
1617
- pending,
1618
- query,
1619
- "Message routed.",
1620
- );
3078
+ const current = (): boolean => active && selectionCurrent();
3079
+ const assertRestoreCurrent = (): void => {
3080
+ assertExecutionCurrent();
3081
+ if (!current()) throw new Error("Stale Workspace Restore callback.");
3082
+ };
3083
+ try { await restoreWorkspace({ operationId: selection.operationId, record: structuredClone(selection.record),
3084
+ target: { ...sourceTarget }, messages: [...selection.messages], ctx, isCurrent: current,
3085
+ async dispatch(recipient, isRecipientCurrent, recordForwardAcceptance, recordLocalAcceptance) {
3086
+ const assertRecipientCurrent = (): void => {
3087
+ assertRestoreCurrent();
3088
+ if (!isRecipientCurrent()) throw new Error("Workspace Restore recipient authority changed.");
3089
+ };
3090
+ assertRecipientCurrent();
3091
+ if (recipient.kind === "leader") {
3092
+ if (recipient.instanceId !== currentInstanceId) return false;
3093
+ const routed = cloneTelegramMessagesForThread(pending.messages, sourceTarget.threadId);
3094
+ const messages = pending.dispatchKind === "command" ? routed.map((message, index) =>
3095
+ Updates.bindTelegramUpdateCompletionAcceptance(message, () => recordLocalAcceptance(pending.messages[index]!))) : routed;
3096
+ await dispatchPendingRerouteMessages(pending, messages, ctx);
3097
+ assertRecipientCurrent();
3098
+ return true;
3099
+ }
3100
+ return forwardPendingRerouteMessages(pending, recipient.instanceId, sourceTarget.threadId, ctx, assertRecipientCurrent, recordForwardAcceptance);
3101
+ },
3102
+ }); } finally { active = false; }
3103
+ assertExecutionCurrent();
3104
+ if (!selectionCurrent()) return true;
3105
+ await deps.answerCallbackQuery(query.id, "🚫 Restore is pending; this button will not resend uncertain input.");
1621
3106
  return true;
1622
3107
  }
1623
3108
  if (
@@ -1625,13 +3110,6 @@ export function createTelegramInboundRouteRuntime<
1625
3110
  record.instanceId !== currentInstanceId &&
1626
3111
  !isCurrentLeaderRecord
1627
3112
  ) {
1628
- if (!deps.foreignOwnedUpdateForwarder?.forwardMessage) {
1629
- await deps.answerCallbackQuery(
1630
- query.id,
1631
- "Open that thread and resend the message there.",
1632
- );
1633
- return true;
1634
- }
1635
3113
  const allForwarded = await forwardPendingRerouteMessages(
1636
3114
  pending,
1637
3115
  record.instanceId,
@@ -1642,14 +3120,17 @@ export function createTelegramInboundRouteRuntime<
1642
3120
  if (!allForwarded) {
1643
3121
  await deps.answerCallbackQuery(
1644
3122
  query.id,
1645
- "Target thread is unavailable; retrying will send only remaining messages.",
3123
+ pending.foreignForwardIssued ? "🚫 Forward is pending; this button will not resend uncertain input." :
3124
+ "Target thread is unavailable; retrying will send only remaining messages.",
1646
3125
  );
1647
3126
  return true;
1648
3127
  }
1649
- const cleanupComplete = await closeReroutedUnboundTopic(
3128
+ // A temporary tab is removed only after every input in it resolves, never by one Forward.
3129
+ const cleanupComplete = isTemporaryTabTarget(sourceTarget) || await closeReroutedUnboundTopic(
1650
3130
  sourceTarget,
1651
3131
  sourceMessageId,
1652
3132
  assertExecutionCurrent,
3133
+ pending.temporaryThread,
1653
3134
  );
1654
3135
  if (!cleanupComplete && sourceTarget) {
1655
3136
  pending.cleanup = {
@@ -1658,6 +3139,7 @@ export function createTelegramInboundRouteRuntime<
1658
3139
  ...(typeof sourceMessageId === "number"
1659
3140
  ? { messageId: sourceMessageId }
1660
3141
  : {}),
3142
+ ...(pending.temporaryThread ? { temporaryThread: pending.temporaryThread } : {}),
1661
3143
  };
1662
3144
  await deps.answerCallbackQuery(
1663
3145
  query.id,
@@ -1671,116 +3153,22 @@ export function createTelegramInboundRouteRuntime<
1671
3153
  query,
1672
3154
  "Message routed.",
1673
3155
  );
3156
+ if (sourceTarget && isTemporaryTabTarget(sourceTarget)) scheduleTemporaryThreadCleanup(sourceTarget, ctx);
1674
3157
  return true;
1675
3158
  }
1676
- if (
1677
- sourceTarget &&
1678
- isCurrentLeaderRecord &&
1679
- parsed.useNewSlot &&
1680
- (record.target.chatId !== sourceTarget.chatId ||
1681
- record.target.threadId !== sourceTarget.threadId)
1682
- ) {
1683
- const slot = deps.threadStore.allocateSlot(
1684
- leaderProfileKey ?? record.profileKey,
1685
- getNextTelegramSlotPreference(record.slot),
1686
- undefined,
1687
- { excludeCurrentRecord: true },
1688
- );
1689
- if (!slot) {
1690
- await deps.answerCallbackQuery(query.id, TELEGRAM_SLOT_CAPACITY_MESSAGE);
1691
- return true;
1692
- }
1693
- deps.threadStore.markStaleByTarget(
1694
- record.target,
1695
- "deleted",
1696
- "Current leader thread was replaced by a new-slot reroute source.",
1697
- );
1698
- const nowMs = Date.now();
1699
- const threadName = getRestoredThreadName(record, slot);
1700
- deps.threadStore.upsert({
1701
- ...record,
1702
- target: sourceTarget,
1703
- status: "active",
1704
- updatedAtMs: nowMs,
1705
- threadName,
1706
- instanceId: currentInstanceId,
1707
- slot,
1708
- lastReconcileAction: "reroute-new-slot",
1709
- rerouteConfirmedAtMs: nowMs,
1710
- });
1711
- await deps.threadStore.persist();
1712
- assertExecutionCurrent();
1713
- deps.setCurrentLeaderIdentity?.({
1714
- target: sourceTarget,
1715
- slot,
1716
- threadName,
1717
- });
1718
- if (deps.callApi) {
1719
- try {
1720
- assertExecutionCurrent();
1721
- await deps.callApi("editForumTopic", {
1722
- chat_id: sourceTarget.chatId,
1723
- message_thread_id: sourceTarget.threadId,
1724
- name:
1725
- deps.getDisplayTitle?.(sourceTarget) ??
1726
- Threads.getTelegramTopicTitleForThreadName(threadName, slot),
1727
- });
1728
- assertExecutionCurrent();
1729
- } catch (renameError) {
1730
- deps.recordRuntimeEvent?.("telegram", renameError, {
1731
- phase: "leader-topic-reroute-reclaim-rename",
1732
- chatId: sourceTarget.chatId,
1733
- threadId: sourceTarget.threadId,
1734
- slot,
1735
- });
1736
- }
1737
- }
1738
- deps.recordRuntimeEvent?.(
1739
- "bus",
1740
- "Bus leader reclaimed reroute source thread",
1741
- {
1742
- phase: "leader-topic-reroute-reclaim",
1743
- chatId: sourceTarget.chatId,
1744
- threadId: sourceTarget.threadId,
1745
- staleThreadId: record.target.threadId,
1746
- slot,
1747
- },
1748
- );
1749
- await dispatchPendingRerouteMessages(
1750
- pending,
1751
- cloneTelegramMessagesForThread(pending.messages, sourceTarget.threadId),
1752
- ctx,
1753
- );
1754
- pending.messages = [];
1755
- const cleanupComplete = await closePreviousLeaderThread(
1756
- record.target,
1757
- assertExecutionCurrent,
1758
- );
1759
- if (!cleanupComplete) {
1760
- pending.cleanup = {
1761
- kind: "previous-leader",
1762
- target: record.target,
1763
- };
1764
- await deps.answerCallbackQuery(
1765
- query.id,
1766
- "Thread restored, but old-thread cleanup is still pending. Try again.",
1767
- );
1768
- return true;
1769
- }
1770
- await finalizePendingReroute(
1771
- parsed.rerouteId,
1772
- pending,
1773
- query,
1774
- "Message routed.",
1775
- );
3159
+ // Local handlers and queue admission consume the same one-time group grant as follower forwarding.
3160
+ if (isTemporaryReroute(pending) && !issueTemporaryThreadForward(pending, ctx)) {
3161
+ await deps.answerCallbackQuery(query.id, "🚫 Forward is pending; this button will not resend uncertain input.");
1776
3162
  return true;
1777
3163
  }
1778
3164
  await dispatchPendingRerouteMessages(pending, reroutedMessages, ctx);
1779
3165
  pending.messages = [];
1780
- const cleanupComplete = await closeReroutedUnboundTopic(
3166
+ // A temporary tab is removed only after every input in it resolves, never by one Forward.
3167
+ const cleanupComplete = isTemporaryTabTarget(sourceTarget) || await closeReroutedUnboundTopic(
1781
3168
  sourceTarget,
1782
3169
  sourceMessageId,
1783
3170
  assertExecutionCurrent,
3171
+ pending.temporaryThread,
1784
3172
  );
1785
3173
  if (!cleanupComplete && sourceTarget) {
1786
3174
  pending.cleanup = {
@@ -1789,6 +3177,7 @@ export function createTelegramInboundRouteRuntime<
1789
3177
  ...(typeof sourceMessageId === "number"
1790
3178
  ? { messageId: sourceMessageId }
1791
3179
  : {}),
3180
+ ...(pending.temporaryThread ? { temporaryThread: pending.temporaryThread } : {}),
1792
3181
  };
1793
3182
  await deps.answerCallbackQuery(
1794
3183
  query.id,
@@ -1802,24 +3191,26 @@ export function createTelegramInboundRouteRuntime<
1802
3191
  query,
1803
3192
  "Message routed.",
1804
3193
  );
3194
+ if (sourceTarget && isTemporaryTabTarget(sourceTarget)) scheduleTemporaryThreadCleanup(sourceTarget, ctx);
1805
3195
  return true;
1806
3196
  };
1807
- const executeUnboundRerouteCallback = (
3197
+ const executeUnboundRerouteCallback = async (
1808
3198
  query: TCallbackQuery,
1809
3199
  ctx: TContext,
1810
3200
  ): Promise<boolean> => {
1811
- if (!parseTelegramUnboundRerouteCallbackData(query.data) ||
1812
- !deps.runWorkspaceOperation) {
1813
- return executeUnboundRerouteCallbackOperation(query, ctx);
1814
- }
1815
- return deps.runWorkspaceOperation(
1816
- {
1817
- operationId: `workspace-reroute:${query.id}`,
1818
- operationKind: "workspace.route-unbound-thread",
1819
- scopes: [{ kind: "profile" }],
1820
- },
1821
- () => executeUnboundRerouteCallbackOperation(query, ctx),
1822
- );
3201
+ const parsed = parseTelegramUnboundRerouteCallbackData(query.data);
3202
+ if (!parsed) return false;
3203
+ return withPendingRerouteSelection(parsed.rerouteId, query, () => {
3204
+ if (!deps.runWorkspaceOperation) return executeUnboundRerouteCallbackOperation(query, ctx);
3205
+ return deps.runWorkspaceOperation(
3206
+ {
3207
+ operationId: `workspace-reroute:${query.id}`,
3208
+ operationKind: "workspace.route-unbound-thread",
3209
+ scopes: [{ kind: "profile" }],
3210
+ },
3211
+ () => executeUnboundRerouteCallbackOperation(query, ctx),
3212
+ );
3213
+ });
1823
3214
  };
1824
3215
  const handleUnboundRerouteCallback = async (
1825
3216
  query: TCallbackQuery,
@@ -1828,7 +3219,7 @@ export function createTelegramInboundRouteRuntime<
1828
3219
  const parsed = parseTelegramUnboundRerouteCallbackData(query.data);
1829
3220
  const pending = parsed && pendingUnboundReroutes.get(parsed.rerouteId);
1830
3221
  if (!parsed || !pending || pending.dispatchKind !== "command" ||
1831
- pending.sourceTarget.threadId !== undefined || !matchesRerouteChooser(pending, query)) {
3222
+ (pending.sourceTarget.threadId !== undefined && !pending.temporaryThread) || !matchesRerouteChooser(pending, query)) {
1832
3223
  return executeUnboundRerouteCallback(query, ctx);
1833
3224
  }
1834
3225
  if (pending.dispatching) {
@@ -1855,7 +3246,11 @@ export function createTelegramInboundRouteRuntime<
1855
3246
  const assertExecutionCurrent =
1856
3247
  Updates.createTelegramUpdateExecutionFenceGuard(query);
1857
3248
  assertExecutionCurrent();
1858
- if (await handleUnboundRerouteRestoreMenuCallback(query, ctx)) return;
3249
+ if (await handleCancellationReview(query, ctx)) return;
3250
+ if (await handleUnboundRerouteCancelCallback(query, ctx)) return;
3251
+ const restore = parseTelegramUnboundRerouteRestoreMenuCallbackData(query.data);
3252
+ if (restore && await withPendingRerouteSelection(restore.rerouteId, query,
3253
+ () => executeUnboundRerouteRestoreMenuCallback(query, ctx))) return;
1859
3254
  if (await handleUnboundRerouteCallback(query, ctx)) return;
1860
3255
  if (deps.buttonActionStore) {
1861
3256
  const handled = await OutboundHandlers.handleTelegramButtonCallbackQuery(
@@ -2348,9 +3743,55 @@ export function createTelegramInboundRouteRuntime<
2348
3743
  reportQueueAdmission(messages, turn.admissionReceipts ?? []);
2349
3744
  });
2350
3745
  };
3746
+ const recordUnboundTemporaryThreadInput = async (messages: TMessage[], ctx: TContext): Promise<boolean> => {
3747
+ const message = messages[0], target = message && Updates.getTelegramMessageTarget(message);
3748
+ const store = deps.getWorkspaceRestoreStore?.();
3749
+ if (!target?.threadId || !store) return false;
3750
+ let found = store.listTemporaryThreads().find(entry => entry.phase === "created" && entry.target &&
3751
+ entry.target.chatId === target.chatId && entry.target.threadId === target.threadId);
3752
+ const key = formatTelegramTargetKey(target), implicit = implicitThreadCreations.get(key);
3753
+ if (!found && !implicit) return false;
3754
+ const updateIds = Updates.collectTelegramAdmissionSourceUpdateIds(messages);
3755
+ const operatorUserId = deps.configStore.getAllowedUserId();
3756
+ const cap = captureTemporaryThreadAuthority(ctx, () => messages.every(source =>
3757
+ Updates.getTelegramUpdateExecutionFence(source)?.isCurrent() === true &&
3758
+ source.chat.type === "private" && source.chat.id === target.chatId && source.message_thread_id === target.threadId &&
3759
+ source.from?.id === operatorUserId && source.from?.is_bot === false));
3760
+ if (!found && implicit) {
3761
+ if (updateIds.length !== 1) { implicitThreadCreations.delete(key); return false; }
3762
+ if (!deps.runWorkspaceOperation || !cap || implicit.ctx !== ctx || implicit.operatorUserId !== cap.operatorUserId ||
3763
+ implicit.journalBindingKey !== cap.journalBindingKey || !isDeepStrictEqual(implicit.executor, cap.authority.executor) ||
3764
+ implicit.generation !== deps.getSessionGeneration?.() || implicit.scope !== deps.getAdmissionScope?.() ||
3765
+ !updateIds.length || updateIds.some(id => id <= implicit.updateId) || !cap.isCurrent()) {
3766
+ implicitThreadCreations.delete(key);
3767
+ return false;
3768
+ }
3769
+ found = store.registerImplicitTemporaryThread({ journalBindingKey: cap.journalBindingKey, updateIds },
3770
+ { chatId: target.chatId, threadId: target.threadId }, randomBytes(16).toString("hex"), {
3771
+ ...cap.authority, isCurrent: () => cap.isCurrent() && implicitThreadCreations.get(key) === implicit,
3772
+ });
3773
+ if (!found || !cap.isCurrent()) throw new Error("Implicit Telegram Thread registration was not acknowledged; source remains protected.");
3774
+ implicitThreadCreations.delete(key);
3775
+ }
3776
+ if (!found) return false;
3777
+ cancelTemporaryThreadCleanup(found.token);
3778
+ if (!deps.runWorkspaceOperation || !cap || cap.operatorUserId !== found.operatorUserId ||
3779
+ cap.journalBindingKey !== found.source.journalBindingKey || !updateIds.length || !cap.isCurrent()) {
3780
+ throw new Error("Temporary Thread input membership requires exact current source authority.");
3781
+ }
3782
+ // The caller already holds the profile Workspace admission (the non-reentrant gate): no nested operation here.
3783
+ let entry = store.listTemporaryThreads().find(value => value.token === found.token &&
3784
+ isDeepStrictEqual(value.source, found.source) && isDeepStrictEqual(value.target, target));
3785
+ if (!entry) throw new Error("Temporary Thread input target changed before membership publication.");
3786
+ entry = cap.adopt(entry);
3787
+ if (!entry || !cap.isCurrent() || !store.recordTemporaryThreadInput(entry, { journalBindingKey: cap.journalBindingKey, updateIds }, cap.authority) || !cap.isCurrent()) {
3788
+ throw new Error("Temporary Thread input membership was not acknowledged; the source remains protected.");
3789
+ }
3790
+ return true;
3791
+ };
2351
3792
  const sendUnboundRerouteChooserNow = async (
2352
3793
  messages: TMessage[],
2353
- _ctx: TContext,
3794
+ ctx: TContext,
2354
3795
  reportDeferred = true,
2355
3796
  ): Promise<void> => {
2356
3797
  const message = messages[0];
@@ -2385,19 +3826,44 @@ export function createTelegramInboundRouteRuntime<
2385
3826
  );
2386
3827
  return;
2387
3828
  }
2388
- const rerouteId = storePendingUnboundReroute(messages);
3829
+ const command = messages.length === 1 ? getKnownTelegramAllTabCommand(Media.extractFirstTelegramMessageText(messages).trim()) : undefined;
3830
+ const rerouteId = storePendingUnboundReroute(messages, command ? "command" : "prompt");
2389
3831
  if (reportDeferred) {
2390
3832
  for (const source of messages) {
2391
3833
  Updates.reportTelegramUpdateDeferred(source);
2392
3834
  }
2393
3835
  }
2394
- const text = formatTelegramUnboundRerouteChooserText(activeRecords, {
2395
- includeGuidance,
2396
- });
3836
+ const inTemporaryThread = await recordUnboundTemporaryThreadInput(messages, ctx);
3837
+ const pending = pendingUnboundReroutes.get(rerouteId)!;
3838
+ if (inTemporaryThread) pending.temporaryMembership = true;
3839
+ if (messages.length === 1 && sourceTarget &&
3840
+ message.chat.type === "private" && typeof message.text === "string" && message.text.trim() &&
3841
+ deps.runWorkspaceOperation && deps.editInteractiveMessage &&
3842
+ deps.getAdmissionJournalBinding && deps.getCurrentLeaderEpoch &&
3843
+ Updates.getTelegramUpdateExecutionFence(message)?.isCurrent() === true && deps.isContextActive?.(ctx) === true) {
3844
+ const ownerUserId = deps.configStore.getAllowedUserId();
3845
+ const journalBindingKey = deps.getAdmissionJournalBinding();
3846
+ if (ownerUserId !== undefined && message.from?.id === ownerUserId && journalBindingKey &&
3847
+ Updates.supportsTelegramDeferredAbandonment(message, journalBindingKey)) {
3848
+ const leaderEpoch = deps.getCurrentLeaderEpoch();
3849
+ if (leaderEpoch !== undefined) pending.abandonment = { ownerUserId, journalBindingKey, leaderEpoch };
3850
+ }
3851
+ }
3852
+ if (command && sourceTarget && !inTemporaryThread && includeGuidance && deps.callApi) {
3853
+ // Telegram can create a private tab before delivering an All command not listed in the bot menu.
3854
+ // Naming that unbound tab is not creation evidence or authority to delete it.
3855
+ Updates.createTelegramUpdateExecutionFenceGuard(message)();
3856
+ try {
3857
+ await deps.callApi("editForumTopic", { chat_id: sourceTarget.chatId, message_thread_id: sourceTarget.threadId, name: `/${command.name}` });
3858
+ } catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "unbound-command-title" }); }
3859
+ Updates.createTelegramUpdateExecutionFenceGuard(message)();
3860
+ }
3861
+ const text = formatTelegramTemporaryThreadChooserText(command?.name);
3862
+ pending.rootChooserText = text;
2397
3863
  const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(
2398
3864
  rerouteId,
2399
3865
  activeRecords,
2400
- { canRestore: sourceTarget !== undefined, getDisplayTitle: deps.getDisplayTitle },
3866
+ { canRestore: sourceTarget !== undefined, canCancel: !!pending.abandonment, getDisplayTitle: deps.getDisplayTitle },
2401
3867
  );
2402
3868
  if (deps.sendInteractiveMessage) {
2403
3869
  const chooserId = await deps.sendInteractiveMessage(
@@ -2432,7 +3898,13 @@ export function createTelegramInboundRouteRuntime<
2432
3898
  const messages = [...(existing?.messages ?? []), message];
2433
3899
  const timer = setTimeout(() => {
2434
3900
  pendingUnboundRerouteMediaGroups.delete(groupKey);
2435
- void sendUnboundRerouteChooserNow(messages, ctx, false);
3901
+ // A media group's timer runs outside any request, so it takes the same profile admission as a single message.
3902
+ const send = (): Promise<void> => sendUnboundRerouteChooserNow(messages, ctx, false);
3903
+ const task = deps.runWorkspaceOperation
3904
+ ? deps.runWorkspaceOperation({ operationId: `workspace-unbound-group:${groupKey}:${randomBytes(8).toString("hex")}`,
3905
+ operationKind: "workspace.route-unbound-thread", scopes: [{ kind: "profile" }] }, send)
3906
+ : send();
3907
+ void task.catch(error => { deps.recordRuntimeEvent?.("routing", error, { phase: "unbound-media-group-chooser" }); });
2436
3908
  }, 1200);
2437
3909
  timer.unref?.();
2438
3910
  pendingUnboundRerouteMediaGroups.set(groupKey, { messages, timer });
@@ -2454,6 +3926,83 @@ export function createTelegramInboundRouteRuntime<
2454
3926
  }
2455
3927
  return undefined;
2456
3928
  };
3929
+ /**
3930
+ * An All input gets one source-bound tab. The original stays deferred in All until explicit routing,
3931
+ * and an existing entry is reused on replay, so restart never creates a second tab or retries an unknown one.
3932
+ */
3933
+ const sendAllTabTemporaryInputChooser = async (
3934
+ command: Commands.ParsedTelegramCommand | undefined,
3935
+ commandText: string,
3936
+ message: TMessage,
3937
+ ctx: TContext,
3938
+ ): Promise<boolean> => {
3939
+ const [updateId] = Updates.collectTelegramAdmissionSourceUpdateIds([message]);
3940
+ const execution = Updates.getTelegramUpdateExecutionFence(message);
3941
+ const cap = captureTemporaryThreadAuthority(ctx, () => execution?.isCurrent() === true);
3942
+ if (!cap || !deps.runWorkspaceOperation || !deps.callApi || updateId === undefined ||
3943
+ message.chat.type !== "private" || message.chat.id !== cap.operatorUserId || message.from?.id !== cap.operatorUserId ||
3944
+ !cap.isCurrent()) return false;
3945
+ const { store, operatorUserId, journalBindingKey, epoch, authority } = cap, current = cap.isCurrent;
3946
+ const source = { journalBindingKey, updateId };
3947
+ const find = () => store.listTemporaryThreads().find(entry =>
3948
+ entry.source.journalBindingKey === journalBindingKey && entry.source.updateId === updateId);
3949
+ let entry: Threads.TelegramTemporaryThreadEntry | undefined;
3950
+ await deps.runWorkspaceOperation({ operationId: `temporary-thread-${randomBytes(16).toString("hex")}`,
3951
+ operationKind: "workspace.temporary-thread", scopes: [{ kind: "profile" }] }, async () => {
3952
+ if (!current()) return;
3953
+ let found = find();
3954
+ found = found && cap.adopt(found);
3955
+ if (found) { entry = found; return; }
3956
+ const reservation = store.reserveTemporaryThread(source, randomBytes(16).toString("hex"), authority);
3957
+ entry = reservation?.entry;
3958
+ if (!reservation?.reserved || !current()) return;
3959
+ let created: { message_thread_id?: unknown } | undefined;
3960
+ try {
3961
+ // The single creation attempt; any failure leaves `creating` as an unknown outcome, never a retry.
3962
+ created = await deps.callApi!<{ message_thread_id?: unknown }>("createForumTopic",
3963
+ { chat_id: operatorUserId, name: command ? `/${command.name}` : "New chat" });
3964
+ } catch (error) {
3965
+ deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-create" });
3966
+ return;
3967
+ }
3968
+ const threadId = created?.message_thread_id;
3969
+ if (!current() || typeof threadId !== "number" || !Number.isSafeInteger(threadId) || threadId <= 0) return;
3970
+ entry = store.acknowledgeTemporaryThread(reservation.entry, { chatId: operatorUserId, threadId }, authority) ?? entry;
3971
+ });
3972
+ if (!current() || !entry) throw new Error("Temporary Thread authority changed; the input remains retryable.");
3973
+ const commandMessage = Updates.carryTelegramUpdateExecutionFence(message, {
3974
+ ...message, text: commandText, caption: undefined } as TMessage);
3975
+ if (entry.phase !== "created" || !entry.target) {
3976
+ Updates.reportTelegramUpdateDeferred(commandMessage);
3977
+ await deps.sendTextReply(message.chat.id, message.message_id,
3978
+ "<b>⚠️ The routing tab for this command could not be confirmed.</b> It stays held and was not sent to Pi.",
3979
+ { parseMode: "HTML" });
3980
+ return true;
3981
+ }
3982
+ const target = entry.target;
3983
+ const records = getTelegramRoutableThreadRecords(deps.threadStore?.list() ?? [], deps.getLiveThreadTargets?.());
3984
+ const rerouteId = storePendingUnboundReroute([commandMessage], command ? "command" : "prompt", target);
3985
+ const pending = pendingUnboundReroutes.get(rerouteId)!;
3986
+ pending.temporaryThread = entry;
3987
+ Updates.reportTelegramUpdateDeferred(commandMessage);
3988
+ // Cancel is offered only when the exact source can still be abandoned; the tab removal needs that retention first.
3989
+ if (deps.editInteractiveMessage && Updates.supportsTelegramDeferredAbandonment(commandMessage, journalBindingKey)) {
3990
+ pending.abandonment = { ownerUserId: operatorUserId, journalBindingKey, leaderEpoch: epoch };
3991
+ }
3992
+ pending.rootChooserText = formatTelegramTemporaryThreadChooserText(command?.name);
3993
+ try {
3994
+ if (!deps.sendInteractiveMessage) throw new Error("Temporary Thread chooser publication is unavailable.");
3995
+ const chooserId = await deps.sendInteractiveMessage(target.chatId, pending.rootChooserText, "html",
3996
+ buildTelegramUnboundRerouteChooserMarkup(rerouteId, records, { canRestore: true, canCancel: !!pending.abandonment,
3997
+ getDisplayTitle: deps.getDisplayTitle }),
3998
+ { target });
3999
+ rememberRerouteChooser(rerouteId, chooserId);
4000
+ } catch (error) {
4001
+ removePendingReroute(rerouteId);
4002
+ throw error;
4003
+ }
4004
+ return true;
4005
+ };
2457
4006
  const sendAllTabCommandChooser = async (
2458
4007
  command: Commands.ParsedTelegramCommand,
2459
4008
  commandText: string,
@@ -2478,6 +4027,7 @@ export function createTelegramInboundRouteRuntime<
2478
4027
  const rerouteId = storePendingUnboundReroute([commandMessage], "command");
2479
4028
  Updates.reportTelegramUpdateDeferred(commandMessage);
2480
4029
  const text = formatTelegramAllTabMenuChooserText(command.name);
4030
+ pendingUnboundReroutes.get(rerouteId)!.rootChooserText = text;
2481
4031
  const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(
2482
4032
  rerouteId,
2483
4033
  activeRecords,
@@ -2749,11 +4299,24 @@ export function createTelegramInboundRouteRuntime<
2749
4299
  assertExecutionCurrent();
2750
4300
  await deps.handleTelegramTopicLifecycleUpdate?.(lifecycle, ctx);
2751
4301
  assertExecutionCurrent();
4302
+ const key = formatTelegramTargetKey(lifecycle.target);
2752
4303
  if (lifecycle.kind !== "created" || !deps.threadStore) {
4304
+ implicitThreadCreations.delete(key);
2753
4305
  return;
2754
4306
  }
2755
4307
  await deps.threadStore.load();
2756
4308
  assertExecutionCurrent();
4309
+ const message = lifecycle.message, created = message.forum_topic_created;
4310
+ const [updateId] = Updates.collectTelegramAdmissionSourceUpdateIds([message]);
4311
+ const cap = captureTemporaryThreadAuthority(ctx, () => Updates.getTelegramUpdateExecutionFence(message)?.isCurrent() === true);
4312
+ if (created && typeof created === "object" && "is_name_implicit" in created && created.is_name_implicit === true &&
4313
+ cap && deps.runWorkspaceOperation && updateId !== undefined && !Updates.isTelegramHistoricalInput(message) &&
4314
+ message.chat.type === "private" && message.chat.id === cap.operatorUserId && message.from?.id === cap.operatorUserId &&
4315
+ message.from.is_bot === false && cap.isCurrent() && implicitThreadCreations.size < 100) {
4316
+ implicitThreadCreations.set(key, { ctx, updateId, operatorUserId: cap.operatorUserId,
4317
+ journalBindingKey: cap.journalBindingKey, executor: structuredClone(cap.authority.executor),
4318
+ generation: deps.getSessionGeneration?.(), scope: deps.getAdmissionScope?.() });
4319
+ } else implicitThreadCreations.delete(key);
2757
4320
  };
2758
4321
  // Answer the guest query immediately so the agent-end edit can replace the
2759
4322
  // early ACK once the turn settles. The ACK is the first placeholder frame and
@@ -2795,7 +4358,8 @@ export function createTelegramInboundRouteRuntime<
2795
4358
  }
2796
4359
  assertExecutionCurrent();
2797
4360
  }
2798
- const text = guestMessage.text ?? "";
4361
+ // Media messages carry the user's text as a caption.
4362
+ const text = guestMessage.text ?? guestMessage.caption ?? "";
2799
4363
  const gm = guestMessage as unknown as Record<string, unknown>;
2800
4364
  // Build telegram prefix with guest context
2801
4365
  const chatRaw = gm.chat as Record<string, unknown>;
@@ -2842,10 +4406,15 @@ export function createTelegramInboundRouteRuntime<
2842
4406
  : "";
2843
4407
  // Download files, run inbound handlers
2844
4408
  const guestMsg = guestMessage as unknown as Media.TelegramMediaMessage;
4409
+ // Guest message IDs belong to the peer's chat; scope files to that peer so they cannot collide with bot-chat files.
4410
+ const guestScope = resolveTelegramGuestFileScope({ chatType, chat: chatRaw, from: fromRaw, replyFrom: replyFromRaw,
4411
+ guestBotCallerUser, guestBotCallerChat, ownerUserId });
4412
+ const downloadGuestFile: typeof deps.downloadFile = (fileId, fileName, source) =>
4413
+ deps.downloadFile(fileId, fileName, source ? { ...source, scope: guestScope } : source);
2845
4414
  const replyFiles = guestMsg.reply_to_message
2846
4415
  ? await Media.downloadTelegramMessageFiles(
2847
4416
  [guestMsg.reply_to_message as Media.TelegramMediaMessage],
2848
- { downloadFile: deps.downloadFile },
4417
+ { downloadFile: downloadGuestFile },
2849
4418
  )
2850
4419
  : [];
2851
4420
  assertExecutionCurrent();
@@ -2855,7 +4424,7 @@ export function createTelegramInboundRouteRuntime<
2855
4424
  : undefined;
2856
4425
  assertExecutionCurrent();
2857
4426
  const files = await Media.downloadTelegramMessageFiles([guestMsg], {
2858
- downloadFile: deps.downloadFile,
4427
+ downloadFile: downloadGuestFile,
2859
4428
  });
2860
4429
  assertExecutionCurrent();
2861
4430
  const processed = await deps.inboundHandlerRuntime.process(
@@ -2941,7 +4510,7 @@ export function createTelegramInboundRouteRuntime<
2941
4510
  deps.updateStatus(ctx);
2942
4511
  requestDispatchNextQueuedTelegramTurn(ctx);
2943
4512
  };
2944
- return Updates.createTelegramPairedUpdateRuntime<TContext, TUpdate>({
4513
+ const runtime = Updates.createTelegramPairedUpdateRuntime<TContext, TUpdate>({
2945
4514
  getAllowedUserId: deps.configStore.getAllowedUserId,
2946
4515
  getCurrentInstanceId: deps.getCurrentInstanceId,
2947
4516
  getMessageOwnership: deps.getMessageOwnership,
@@ -2992,8 +4561,14 @@ export function createTelegramInboundRouteRuntime<
2992
4561
  deps.getLiveThreadTargets?.(),
2993
4562
  );
2994
4563
  const command = getKnownTelegramAllTabCommand(text);
2995
- // Returning before deferral lets the admission worker terminally settle expired replay.
2996
- if (command && command.name !== "thread" && isTelegramAllTabCommandExpired(message)) return;
4564
+ const [sourceUpdateId] = Updates.collectTelegramAdmissionSourceUpdateIds([message]);
4565
+ const presented = sourceUpdateId !== undefined && deps.getWorkspaceRestoreStore?.()?.listTemporaryThreads().some(entry =>
4566
+ entry.source.updateId === sourceUpdateId && entry.source.journalBindingKey === deps.getAdmissionJournalBinding?.()) === true;
4567
+ // Returning before deferral lets the admission worker terminally settle expired replay; a presented tab is not expiry.
4568
+ if (command && command.name !== "thread" && !presented && isTelegramAllTabCommandExpired(message)) return;
4569
+ if (command?.name !== "thread" && (bindings.length > 0 || presented) &&
4570
+ typeof message.text === "string" && message.text.trim() &&
4571
+ await sendAllTabTemporaryInputChooser(command, text, message as TMessage, ctx)) return;
2997
4572
  if (bindings.length > 0 && command && command.name !== "thread") {
2998
4573
  if (
2999
4574
  await sendAllTabCommandChooser(command, text, message as TMessage, {
@@ -3058,6 +4633,11 @@ export function createTelegramInboundRouteRuntime<
3058
4633
  }
3059
4634
  await deps.threadStore.load();
3060
4635
  assertExecutionCurrent();
4636
+ if (Updates.isTelegramHistoricalInput(message, entry => reviewMessage(entry) !== undefined) && isReviewText(message as TMessage) &&
4637
+ deps.getCurrentLeaderEpoch?.() !== undefined && needsHistoricalReview(message as TMessage)) {
4638
+ if (!Updates.reportTelegramHistoricalRoutingReview(message)) throw new Error("Historical routing hold is unavailable.");
4639
+ return;
4640
+ }
3061
4641
  if (deps.threadStore.getBotState().threadMode === "disabled") {
3062
4642
  await textDispatch.handleMessage(message as TMessage, ctx);
3063
4643
  return;
@@ -3181,14 +4761,8 @@ export function createTelegramInboundRouteRuntime<
3181
4761
  }
3182
4762
  const command = getKnownTelegramAllTabCommand(text);
3183
4763
  if (command && hasAnyRoutableThread) {
3184
- if (
3185
- await sendAllTabCommandChooser(command, text, message as TMessage, {
3186
- target: { chatId: target.chatId, threadId: target.threadId },
3187
- replyToSource: true,
3188
- })
3189
- ) {
3190
- return;
3191
- }
4764
+ await sendUnboundRerouteChooser(message as TMessage, ctx);
4765
+ return;
3192
4766
  }
3193
4767
  if (leaderProfileKey && deps.callApi) {
3194
4768
  const currentLeaderRecord = records.find((record) => {
@@ -3303,7 +4877,7 @@ export function createTelegramInboundRouteRuntime<
3303
4877
  }
3304
4878
  const identityThreadName =
3305
4879
  priorLeaderIdentity?.threadName &&
3306
- Threads.isTelegramTopicThreadNameValidForSlot(
4880
+ ThreadNaming.isTelegramTopicThreadNameValidForSlot(
3307
4881
  priorLeaderIdentity.threadName,
3308
4882
  slot,
3309
4883
  )
@@ -3312,7 +4886,7 @@ export function createTelegramInboundRouteRuntime<
3312
4886
  const threadName =
3313
4887
  priorLeaderRecord?.threadName ??
3314
4888
  identityThreadName ??
3315
- Threads.chooseTelegramThreadName({ slot }) ??
4889
+ ThreadNaming.chooseTelegramThreadName({ slot }) ??
3316
4890
  "Pi";
3317
4891
  deps.threadStore.upsert({
3318
4892
  profileKey: leaderProfileKey,
@@ -3367,6 +4941,12 @@ export function createTelegramInboundRouteRuntime<
3367
4941
  );
3368
4942
  },
3369
4943
  });
4944
+ return { ...runtime, expireRoutingInput, shouldReviewHistoricalInput, shouldHoldPendingInput, forgetPreviousWorld, beforeQueueReceiptPublished, onQueueReceiptCommitted, onQueueReceiptCompleted, onUpdateCompleted,
4945
+ onWorkspaceRestoreRecipientObserved(follower, isCurrent, ctx) {
4946
+ if (ctx !== undefined) return observeRestoreSettlement({ kind: "recipient", follower, isCurrent }, ctx);
4947
+ return undefined;
4948
+ },
4949
+ async waitForRestoreSettlement() { await Promise.all([...restoreSettlementTasks]); } };
3370
4950
  }
3371
4951
 
3372
4952
  // --- Assistant Output Delivery Authority ---