@llblab/pi-telegram 0.51.6 → 0.52.1

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