@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
@@ -3,8 +3,10 @@
3
3
  * Zones: telegram inbound, orchestration, queue/menu/command composition
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
+ import { randomBytes } from "node:crypto";
6
7
  import { readFile } from "node:fs/promises";
7
8
  import { basename, dirname } from "node:path";
9
+ import { isDeepStrictEqual } from "node:util";
8
10
  import * as Bus from "./bus.js";
9
11
  import * as Commands from "./commands.js";
10
12
  import * as Media from "./media.js";
@@ -17,6 +19,7 @@ import * as TextGroups from "./text-groups.js";
17
19
  import * as ThreadNaming from "./thread-naming.js";
18
20
  import * as ThreadReconciler from "./thread-reconciler.js";
19
21
  import * as Turns from "./turns.js";
22
+ import * as WorkspaceIdentity from "./workspace-identity.js";
20
23
  function formatTelegramPromptPeer(peer) {
21
24
  if (!peer)
22
25
  return undefined;
@@ -63,6 +66,21 @@ export function resolveTelegramGuestPromptPeer(input) {
63
66
  }
64
67
  return undefined;
65
68
  }
69
+ /** Stable file scope of the remote Guest Mode peer: username, else numeric id; never the bot's own scope. */
70
+ export function resolveTelegramGuestFileScope(input) {
71
+ const candidates = input.chatType !== "private" ? [input.chat]
72
+ : [input.from, input.chat, input.guestBotCallerUser, input.guestBotCallerChat, input.replyFrom];
73
+ for (const candidate of candidates) {
74
+ if (!candidate || (input.chatType === "private" &&
75
+ (isTelegramPromptOwnerPeer(candidate, input.ownerUserId) || isTelegramPromptBotPeer(candidate))))
76
+ continue;
77
+ if (typeof candidate.username === "string" && candidate.username.length > 0)
78
+ return candidate.username;
79
+ if (typeof candidate.id === "number" && Number.isSafeInteger(candidate.id))
80
+ return String(Math.abs(candidate.id));
81
+ }
82
+ return "guest";
83
+ }
66
84
  function appendTelegramSourceAttachmentSection(text, from, files, outputs = []) {
67
85
  if (files.length === 0 && outputs.length === 0)
68
86
  return text;
@@ -117,7 +135,12 @@ function escapeHtml(text) {
117
135
  }
118
136
  const TELEGRAM_UNBOUND_REROUTE_CALLBACK_PREFIX = "reroute:";
119
137
  const TELEGRAM_UNBOUND_REROUTE_RESTORE_MENU_CALLBACK_PREFIX = "rerouterestore:";
138
+ const TELEGRAM_UNBOUND_REROUTE_MENU_CALLBACK_PREFIX = "reroutemenu:";
139
+ const TELEGRAM_UNBOUND_REROUTE_ROOT_CALLBACK_PREFIX = "rerouteroot:";
120
140
  const TELEGRAM_UNBOUND_REROUTE_NEW_SLOT_CALLBACK_PREFIX = "reroutenew:";
141
+ const TELEGRAM_UNBOUND_REROUTE_CANCEL_CALLBACK_PREFIX = "reroutecancel:";
142
+ const TELEGRAM_PENDING_CANCELLATION_REVIEW_PREFIX = "reroutecancel:review:";
143
+ const TELEGRAM_RETIRED_HISTORICAL_REVIEW_PREFIX = "reroutecancel:history:";
121
144
  const TELEGRAM_SLOT_CAPACITY_MESSAGE = "No Telegram instance slot is available. Automatic reclamation is disabled for safety.";
122
145
  function formatTelegramUnboundRerouteCallbackData(rerouteId, threadId) {
123
146
  return `${TELEGRAM_UNBOUND_REROUTE_CALLBACK_PREFIX}${rerouteId}:${threadId}`;
@@ -129,9 +152,9 @@ function formatTelegramUnboundRerouteNewSlotCallbackData(rerouteId, threadId) {
129
152
  return `${TELEGRAM_UNBOUND_REROUTE_NEW_SLOT_CALLBACK_PREFIX}${rerouteId}:${threadId}`;
130
153
  }
131
154
  function parseTelegramUnboundRerouteRestoreMenuCallbackData(data) {
132
- const match = data?.match(/^rerouterestore:([a-z0-9]+)$/);
133
- const rerouteId = match?.[1];
134
- return rerouteId ? { rerouteId } : undefined;
155
+ const match = data?.match(/^(rerouterestore|reroutemenu|rerouteroot):([a-z0-9]+)$/);
156
+ const rerouteId = match?.[2];
157
+ return rerouteId ? { rerouteId, restore: match?.[1] === "rerouterestore", root: match?.[1] === "rerouteroot" } : undefined;
135
158
  }
136
159
  function parseTelegramUnboundRerouteCallbackData(data) {
137
160
  const match = data?.match(/^(reroute|reroutenew):([a-z0-9]+):(\d+)$/);
@@ -145,17 +168,11 @@ function parseTelegramUnboundRerouteCallbackData(data) {
145
168
  function getTelegramThreadRecordLabel(record, getDisplayTitle) {
146
169
  return getDisplayTitle?.(record.target) ?? getRestoredThreadName(record, record.slot ?? "");
147
170
  }
148
- function getNextTelegramSlotPreference(slot) {
149
- if (!slot || !/^[A-Z]$/.test(slot))
150
- return undefined;
151
- const index = slot.charCodeAt(0) - "A".charCodeAt(0);
152
- return String.fromCharCode("A".charCodeAt(0) + ((index + 1) % 26));
153
- }
154
171
  function getRestoredThreadName(record, slot) {
155
172
  return record.threadName &&
156
- Threads.isTelegramTopicThreadNameValidForSlot(record.threadName, slot)
173
+ ThreadNaming.isTelegramTopicThreadNameValidForSlot(record.threadName, slot)
157
174
  ? record.threadName
158
- : (Threads.chooseTelegramThreadName({ slot }) ?? "Pi");
175
+ : (ThreadNaming.chooseTelegramThreadName({ slot }) ?? "Pi");
159
176
  }
160
177
  function isTelegramLiveThreadTarget(record, liveTargets) {
161
178
  if (!liveTargets)
@@ -167,6 +184,15 @@ function getTelegramRoutableThreadRecords(records, liveTargets) {
167
184
  return records.filter((record) => record.status === "active" &&
168
185
  isTelegramLiveThreadTarget(record, liveTargets));
169
186
  }
187
+ function formatTelegramTemporaryThreadChooserText(command) {
188
+ return [
189
+ "<b>🧵 Choose target thread:</b>",
190
+ "",
191
+ command ? `You used <code>/${escapeHtml(command)}</code> from the <b>All</b> tab.` : "Choose where to send your message.",
192
+ "Unaccepted routing expires after 60 minutes; disposable tabs are removed when clear. Accepted work and restored Threads are kept.",
193
+ "Select the Pi thread that should handle it, or restore a Pi into this tab:",
194
+ ].join("\n");
195
+ }
170
196
  function formatTelegramAllTabMenuChooserText(command) {
171
197
  return [
172
198
  "<b>🧵 Choose target thread:</b>",
@@ -179,25 +205,19 @@ function formatTelegramAllTabMenuChooserText(command) {
179
205
  function buildTelegramUnboundRerouteChooserMarkup(rerouteId, records, options) {
180
206
  const activeRecords = records.filter((record) => record.status === "active");
181
207
  const canRestoreAnyLiveThread = options.canRestore && activeRecords.length > 0;
182
- const rows = activeRecords.map((record) => [
183
- {
184
- text: `↪️ ${getTelegramThreadRecordLabel(record, options.getDisplayTitle)}`,
185
- callback_data: formatTelegramUnboundRerouteCallbackData(rerouteId, record.target.threadId),
186
- },
187
- ]);
188
- return {
189
- inline_keyboard: canRestoreAnyLiveThread
190
- ? [
191
- ...rows,
192
- [
193
- {
194
- text: "🔁 Replace/restore thread…",
195
- callback_data: formatTelegramUnboundRerouteRestoreMenuCallbackData(rerouteId),
196
- },
197
- ],
198
- ]
199
- : rows,
200
- };
208
+ const rows = activeRecords.length
209
+ ? [[{ text: "↪️ Reroute…", callback_data: `${TELEGRAM_UNBOUND_REROUTE_MENU_CALLBACK_PREFIX}${rerouteId}` }]] : [];
210
+ if (canRestoreAnyLiveThread)
211
+ rows.push([{
212
+ text: "🔁 Restore…",
213
+ callback_data: formatTelegramUnboundRerouteRestoreMenuCallbackData(rerouteId),
214
+ }]);
215
+ if (options.canCancel)
216
+ rows.push([{
217
+ text: "⛔️ Cancel routing",
218
+ callback_data: `${TELEGRAM_UNBOUND_REROUTE_CANCEL_CALLBACK_PREFIX}${rerouteId}`,
219
+ }]);
220
+ return { inline_keyboard: rows };
201
221
  }
202
222
  function buildTelegramUnboundRerouteRestoreChooserMarkup(rerouteId, records, getDisplayTitle) {
203
223
  return {
@@ -231,20 +251,109 @@ function formatTelegramUnboundTopicGuidance() {
231
251
  function formatTelegramTargetKey(target) {
232
252
  return `${target.chatId}:${target.threadId ?? "all"}`;
233
253
  }
234
- function formatTelegramUnboundRerouteChooserText(_records, options = {}) {
235
- const rerouteText = [
236
- "<b>🧵 Choose target thread:</b>",
237
- "",
238
- "Your message is still in this Telegram thread.",
239
- "Select the Pi thread that should handle it:",
240
- ].join("\n");
241
- return options.includeGuidance === false
242
- ? rerouteText
243
- : [formatTelegramUnboundTopicGuidance(), "", rerouteText].join("\n");
244
- }
245
254
  import * as Threads from "./threads.js";
246
255
  import * as Updates from "./updates.js";
247
256
  import { getTelegramVoiceReplyMode } from "./voice.js";
257
+ /** One admitted Restore attempt through recipient readiness, never source dispatch or cleanup. */
258
+ export async function advanceTelegramWorkspaceRestore(input) {
259
+ const isCurrent = input.authority.isCurrent.bind(input.authority);
260
+ if (!isCurrent())
261
+ return undefined;
262
+ const request = structuredClone(input.request);
263
+ if (!Threads.isTelegramWorkspaceRestoreRequest(request))
264
+ return undefined;
265
+ const getRecipient = input.getRecipient;
266
+ const runRecipient = input.runRecipient;
267
+ const observedRecipient = getRecipient();
268
+ if (!Threads.isTelegramWorkspaceRestoreRecipient(observedRecipient) || observedRecipient.sessionId !== request.binding.sessionId)
269
+ return undefined;
270
+ const recipient = structuredClone(observedRecipient);
271
+ const executor = structuredClone(input.authority.executor);
272
+ const operatorUserId = input.authority.operatorUserId;
273
+ const current = () => isCurrent() && input.authority.operatorUserId === operatorUserId &&
274
+ isDeepStrictEqual(input.authority.executor, executor) && isDeepStrictEqual(getRecipient(), recipient);
275
+ const authority = { executor, operatorUserId, isCurrent: current };
276
+ const { restoreStore } = input;
277
+ const retained = () => {
278
+ if (!current())
279
+ return undefined;
280
+ const found = restoreStore.list().find(value => value.request.operationId === request.operationId);
281
+ return current() && found && isDeepStrictEqual(found.request, request) && found.operatorUserId === operatorUserId &&
282
+ isDeepStrictEqual(found.executor, executor) ? found : undefined;
283
+ };
284
+ let intent = retained();
285
+ const predecessor = intent ? undefined : restoreStore.list().find(value => value.request.operationId === request.operationId);
286
+ if (predecessor && current() && isDeepStrictEqual(predecessor.request, request) &&
287
+ predecessor.operatorUserId === operatorUserId && !isDeepStrictEqual(predecessor.executor, executor)) {
288
+ // Adoption transfers executor authority only. Issued grants stay issued, so a successor can merely inspect them.
289
+ try {
290
+ intent = restoreStore.adopt(predecessor, authority);
291
+ }
292
+ catch (error) {
293
+ intent = retained();
294
+ if (!intent)
295
+ throw error;
296
+ }
297
+ if (!current() || !intent)
298
+ return undefined;
299
+ intent = retained();
300
+ if (!intent)
301
+ return undefined;
302
+ }
303
+ if (input.inspectOnly && !intent)
304
+ return undefined;
305
+ if (!intent || intent.phase === "relocated") {
306
+ if ((!input.inspectOnly && recipient.instanceId !== request.owner.instanceId) ||
307
+ recipient.kind !== (request.owner.owner?.kind === "leader" ? "leader" : "follower"))
308
+ return undefined;
309
+ intent = await restoreStore.commit(request, authority);
310
+ if (!current() || !intent)
311
+ return undefined;
312
+ }
313
+ let mode = "inspect";
314
+ if (intent.phase === "relocated") {
315
+ try {
316
+ const issuance = restoreStore.issueRecipient(intent, recipient, authority);
317
+ if (issuance) {
318
+ intent = issuance.intent;
319
+ mode = input.inspectOnly ? "inspect" : "apply";
320
+ }
321
+ else
322
+ intent = retained();
323
+ }
324
+ catch (error) {
325
+ intent = retained();
326
+ if (intent?.phase !== "recipient-issued")
327
+ throw error;
328
+ // A retained issued phase is not a fresh grant, even when publication's reply was lost.
329
+ }
330
+ }
331
+ if (!current() || !intent || !["recipient-issued", "ready"].includes(intent.phase) ||
332
+ (mode === "apply" && !isDeepStrictEqual(intent.recipient, recipient)))
333
+ return undefined;
334
+ const expected = structuredClone(intent);
335
+ if (!isDeepStrictEqual(retained(), expected))
336
+ return undefined;
337
+ const observation = await runRecipient({ intent: structuredClone(expected), mode, isCurrent: current });
338
+ if (!current() || !isDeepStrictEqual(retained(), expected) || !observation || observation.ready !== true ||
339
+ observation.operationId !== request.operationId || !isDeepStrictEqual(observation.recipient, recipient) ||
340
+ !isDeepStrictEqual(observation.target, request.target) || observation.slot !== request.binding.slot)
341
+ return undefined;
342
+ if (expected.phase === "ready" && isDeepStrictEqual(expected.readyRecipient ?? expected.recipient, recipient))
343
+ return retained();
344
+ try {
345
+ intent = mode === "inspect" ? restoreStore.confirmInspectedReady(expected, recipient, authority) :
346
+ restoreStore.confirmReady(expected, recipient, authority);
347
+ }
348
+ catch (error) {
349
+ intent = retained();
350
+ if (intent?.phase !== "ready" || !isDeepStrictEqual(intent.readyRecipient ?? intent.recipient, recipient))
351
+ throw error;
352
+ }
353
+ const confirmed = retained();
354
+ return current() && confirmed?.phase === "ready" &&
355
+ isDeepStrictEqual(confirmed.readyRecipient ?? confirmed.recipient, recipient) ? confirmed : undefined;
356
+ }
248
357
  async function deleteReservedTelegramTopicThroughReconciler(deps, target, messageId) {
249
358
  if (!deps.threadStore)
250
359
  return false;
@@ -280,6 +389,8 @@ async function deleteReservedTelegramTopicThroughReconciler(deps, target, messag
280
389
  return plan.actions.some((action) => action.kind === "close-delete-reserved-topic");
281
390
  }
282
391
  export const TELEGRAM_ALL_TAB_COMMAND_MAX_AGE_MS = 60 * 60_000;
392
+ /** One user-facing answer for expired or previous-process routing controls. */
393
+ export const TELEGRAM_ROUTING_CHOICE_EXPIRED = "⌛ Routing choice expired.";
283
394
  export function isTelegramAllTabCommandExpired(message, nowMs = Date.now()) {
284
395
  return message.message_thread_id === undefined &&
285
396
  typeof message.date === "number" && Number.isFinite(message.date) &&
@@ -319,6 +430,7 @@ export function createTelegramInboundBusProjectionRuntime(deps) {
319
430
  const TELEGRAM_OWNED_CALLBACK_PREFIXES = [
320
431
  "allmenu:",
321
432
  TELEGRAM_UNBOUND_REROUTE_CALLBACK_PREFIX,
433
+ TELEGRAM_UNBOUND_REROUTE_CANCEL_CALLBACK_PREFIX,
322
434
  "compact:",
323
435
  "menu:",
324
436
  "model:",
@@ -335,8 +447,10 @@ function isTelegramOwnedCallbackData(data) {
335
447
  }
336
448
  export function createTelegramInboundRouteRuntime(deps) {
337
449
  const pendingUnboundReroutes = new Map();
450
+ const implicitThreadCreations = new Map();
338
451
  const guidedUnboundTopicKeys = new Set();
339
452
  let nextUnboundRerouteId = 0;
453
+ let cancellationReview;
340
454
  const requestDispatchNextQueuedTelegramTurn = (ctx) => {
341
455
  deps.dispatchNextQueuedTelegramTurn(ctx);
342
456
  if (deps.requestDeferredDispatchNextQueuedTelegramTurn &&
@@ -368,7 +482,7 @@ export function createTelegramInboundRouteRuntime(deps) {
368
482
  continue;
369
483
  }
370
484
  return record.threadName &&
371
- Threads.isTelegramTopicThreadNameValidForSlot(record.threadName, record.slot)
485
+ ThreadNaming.isTelegramTopicThreadNameValidForSlot(record.threadName, record.slot)
372
486
  ? record.threadName
373
487
  : getRestoredThreadName(record, record.slot ?? "");
374
488
  }
@@ -449,33 +563,30 @@ export function createTelegramInboundRouteRuntime(deps) {
449
563
  };
450
564
  schedule();
451
565
  };
452
- const prunePendingUnboundReroutes = () => {
453
- const nowMs = Date.now();
566
+ const prunePendingCommandReroutes = () => {
567
+ // Deferred prompts and selected routes with cleanup work must retain their control.
568
+ // Age is not settlement; the chooser capacity limit still bounds retained entries.
454
569
  for (const [id, entry] of pendingUnboundReroutes) {
455
570
  if (entry.dispatchKind === "command" && entry.sourceTarget.threadId === undefined) {
456
571
  expirePendingCommand(id, entry);
457
572
  }
458
- else if (nowMs - entry.createdAtMs > 30 * 60_000) {
459
- removePendingReroute(id);
460
- }
461
573
  }
462
574
  };
463
- const storePendingUnboundReroute = (messages, dispatchKind = "prompt") => {
464
- prunePendingUnboundReroutes();
575
+ const storePendingUnboundReroute = (messages, dispatchKind = "prompt", presentationTarget) => {
576
+ prunePendingCommandReroutes();
465
577
  if (pendingUnboundReroutes.size >= 100) {
466
578
  throw new Error("Telegram route chooser capacity reached; source remains retryable.");
467
579
  }
468
580
  nextUnboundRerouteId += 1;
469
581
  const id = nextUnboundRerouteId.toString(36);
470
582
  pendingUnboundReroutes.set(id, {
471
- sourceTarget: {
583
+ sourceTarget: presentationTarget ? { ...presentationTarget } : {
472
584
  chatId: messages[0].chat.id,
473
585
  ...(typeof messages[0].message_thread_id === "number"
474
586
  ? { threadId: messages[0].message_thread_id }
475
587
  : {}),
476
588
  },
477
589
  messages,
478
- createdAtMs: Date.now(),
479
590
  dispatchKind,
480
591
  });
481
592
  const pending = pendingUnboundReroutes.get(id);
@@ -487,6 +598,19 @@ export function createTelegramInboundRouteRuntime(deps) {
487
598
  if (!pending)
488
599
  return;
489
600
  pending.chooserMessageId = messageId;
601
+ if (Number.isSafeInteger(messageId) && messageId > 0) {
602
+ const owner = deps.configStore.getAllowedUserId();
603
+ if (owner !== undefined && pending.messages.every(source => source.from?.id === owner && !source.from.is_bot && source.chat.type === "private")) {
604
+ try {
605
+ const lifetime = Updates.armTelegramRoutingInputs(pending.messages, owner);
606
+ if (lifetime)
607
+ pending.routingOperatorUserId = lifetime.operatorUserId;
608
+ }
609
+ catch (error) {
610
+ deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-clock" });
611
+ }
612
+ }
613
+ }
490
614
  if (messageId === undefined || pending.dispatchKind !== "command" ||
491
615
  pending.sourceTarget.threadId !== undefined || pending.selectionAttempted)
492
616
  return;
@@ -587,15 +711,48 @@ export function createTelegramInboundRouteRuntime(deps) {
587
711
  reply_to_message: undefined,
588
712
  }));
589
713
  };
590
- const applyThreadCleanupPlan = async (plan, assertExecutionCurrent) => {
714
+ /** Whether a chooser is still pending in this Thread, ignoring the ones the caller itself owns. */
715
+ const hasPendingRerouteForTarget = (target, own) => [...pendingUnboundReroutes.values()].some(pending => pending.sourceTarget.chatId === target.chatId &&
716
+ pending.sourceTarget.threadId === target.threadId && !own?.(pending));
717
+ const isOwnTemporaryChooser = (entry) => (pending) => pending.temporaryThread?.token === entry.token && isDeepStrictEqual(pending.temporaryThread.source, entry.source);
718
+ const hasOtherTemporaryThreadReroute = (target, own) => hasPendingRerouteForTarget(target, isOwnTemporaryChooser(own));
719
+ const applyThreadCleanupPlan = async (plan, assertExecutionCurrent, restoreCleanup, temporaryCleanup) => {
591
720
  assertExecutionCurrent?.();
721
+ if ((restoreCleanup && restoreCleanup.routing?.cleanup !== "issued") ||
722
+ (temporaryCleanup && !temporaryCleanup.cleanupIssued))
723
+ return false;
724
+ let protectedTarget = false, temporaryDeleteConfirmed = false, temporaryTransportUncertain = false;
592
725
  deps.recordThreadReconciliationPlan?.(plan);
593
726
  const result = await ThreadReconciler.applyThreadReconciliationPlan(plan, {
727
+ skipCloseBeforeDelete: (temporaryCleanup?.target?.chatId ?? 0) > 0,
594
728
  isCleanupTargetProtected(target) {
595
729
  assertExecutionCurrent?.();
596
- return isRerouteTargetProtected(target);
730
+ // A tab's first source is not authority over sibling choosers, including unsettled cleanup-only work.
731
+ const siblingProtected = !!temporaryCleanup && hasOtherTemporaryThreadReroute(target, temporaryCleanup);
732
+ const lostTemporaryGrant = !!temporaryCleanup && !deps.getWorkspaceRestoreStore?.()?.listTemporaryThreads()
733
+ .some(entry => isDeepStrictEqual(entry, temporaryCleanup));
734
+ const protectedNow = temporaryTransportUncertain || lostTemporaryGrant || siblingProtected || isRerouteTargetProtected(target, restoreCleanup, temporaryCleanup);
735
+ protectedTarget ||= protectedNow;
736
+ return protectedNow;
597
737
  },
598
- callApi: deps.callApi,
738
+ callApi: temporaryCleanup && deps.callApi
739
+ ? async (method, body) => {
740
+ try {
741
+ assertExecutionCurrent?.();
742
+ const result = await deps.callApi(method, body, { maxAttempts: 1, retrySafety: "non-idempotent" });
743
+ assertExecutionCurrent?.();
744
+ if (result !== true)
745
+ throw new Error("Temporary Thread cleanup lacks a positive API acknowledgement.");
746
+ if (method === "deleteForumTopic")
747
+ temporaryDeleteConfirmed = true;
748
+ return result;
749
+ }
750
+ catch (error) {
751
+ temporaryTransportUncertain = true;
752
+ throw error;
753
+ }
754
+ }
755
+ : deps.callApi,
599
756
  markStaleByTarget: (staleTarget, syncStatus, lastSyncError) => deps.threadStore?.markStaleByTarget(staleTarget, syncStatus, lastSyncError) ?? false,
600
757
  persist: () => deps.threadStore?.persist() ?? Promise.resolve(),
601
758
  removePendingProvisionById: (id) => deps.threadStore?.removePendingProvision(id) ?? false,
@@ -603,14 +760,132 @@ export function createTelegramInboundRouteRuntime(deps) {
603
760
  recordRuntimeEvent: deps.recordRuntimeEvent,
604
761
  });
605
762
  assertExecutionCurrent?.();
606
- return (result.incompleteActions?.length ?? 0) === 0;
763
+ return !protectedTarget && (!temporaryCleanup || temporaryDeleteConfirmed) && (result.incompleteActions?.length ?? 0) === 0;
764
+ };
765
+ const findCreatedTemporaryThread = (target) => {
766
+ try {
767
+ return deps.getWorkspaceRestoreStore?.()?.listTemporaryThreads().find(value => value.phase === "created" && isDeepStrictEqual(value.target, target));
768
+ }
769
+ catch {
770
+ return undefined;
771
+ }
772
+ };
773
+ /**
774
+ * One fenced capture of leader epoch, operator, journal binding and context for a temporary-Thread effect. `isCurrent`
775
+ * re-reads every captured fact (plus an optional extra fence) at each boundary, so a delayed effect keeps the identity it
776
+ * was scheduled under; `adopt` re-keys an entry to this executor before a write.
777
+ */
778
+ const captureTemporaryThreadAuthority = (ctx, extra) => {
779
+ const store = deps.getWorkspaceRestoreStore?.();
780
+ const instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.();
781
+ const operatorUserId = deps.configStore.getAllowedUserId(), journalBindingKey = deps.getAdmissionJournalBinding?.();
782
+ const generation = deps.getSessionGeneration?.(), scope = deps.getAdmissionScope?.();
783
+ if (!store || !instanceId || epoch === undefined || operatorUserId === undefined || !journalBindingKey)
784
+ return undefined;
785
+ const isCurrent = () => deps.isContextActive?.(ctx) === true && deps.getCurrentInstanceId?.() === instanceId &&
786
+ deps.getSessionGeneration?.() === generation && deps.getAdmissionScope?.() === scope &&
787
+ deps.getCurrentLeaderEpoch?.() === epoch && deps.configStore.getAllowedUserId() === operatorUserId &&
788
+ deps.getAdmissionJournalBinding?.() === journalBindingKey && (extra?.() ?? true);
789
+ const authority = { executor: { instanceId, leaderEpoch: String(epoch) }, operatorUserId, isCurrent };
790
+ return { store, operatorUserId, journalBindingKey, epoch, authority, isCurrent,
791
+ adopt: entry => isDeepStrictEqual(entry.executor, authority.executor) ? entry : store.adoptTemporaryThread(entry, authority) };
607
792
  };
608
- const isRerouteTargetProtected = (target) => {
793
+ const isTemporaryTabTarget = (target) => !!target && findCreatedTemporaryThread(target) !== undefined;
794
+ /** Restore may choose one input only when every other known group is cancelled or a live unassigned chooser that can be retained. */
795
+ const areTemporaryThreadSiblingsAccountedForRestore = (entry, from) => {
796
+ const target = entry.target;
797
+ if (!target)
798
+ return false;
799
+ const group = { journalBindingKey: entry.source.journalBindingKey, updateIds: Updates.collectTelegramAdmissionSourceUpdateIds(from.messages) };
800
+ const inputs = Threads.getTelegramTemporaryThreadInputs(entry), resolved = [...(entry.cancelledInputs ?? []), ...(entry.completedInputs ?? [])];
801
+ if (!inputs.some(input => isDeepStrictEqual(input, group)) || resolved.some(input => isDeepStrictEqual(input, group)))
802
+ return false;
803
+ const others = [...pendingUnboundReroutes.values()].filter(pending => pending !== from &&
804
+ pending.sourceTarget.chatId === target.chatId && pending.sourceTarget.threadId === target.threadId);
805
+ const groupOf = (pending) => ({ journalBindingKey: entry.source.journalBindingKey,
806
+ updateIds: Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages) });
807
+ if (!others.every(pending => !!pending.abandonment && !pending.abandonment.result && !pending.destinationSelected &&
808
+ !pending.selectionAttempted && !pending.dispatching && !pending.foreignForwardIssued && !pending.foreignRetry && !pending.cleanup && !pending.finalizeMessage &&
809
+ !pending.workspaceRestore && !entry.forwardedInputs?.some(input => isDeepStrictEqual(input, groupOf(pending))) &&
810
+ inputs.some(input => isDeepStrictEqual(input, groupOf(pending)))))
811
+ return false;
812
+ return inputs.every(input => isDeepStrictEqual(input, group) || resolved.some(other => isDeepStrictEqual(other, input)) ||
813
+ others.some(pending => isDeepStrictEqual(groupOf(pending), input)));
814
+ };
815
+ const isRerouteTargetProtected = (target, ownRestore, ownTemporary, restoreFrom) => {
609
816
  const matches = (candidate) => candidate.chatId === target.chatId && candidate.threadId === target.threadId;
610
- return (deps.getLiveThreadTargets?.() ?? []).some(matches) ||
611
- (deps.threadStore?.list() ?? []).some((record) => matches(record.target)) ||
612
- (deps.threadStore?.listReservations() ?? []).some((record) => matches(record.target)) ||
613
- (deps.threadStore?.listPendingProvisions() ?? []).some((record) => !!record.target && matches(record.target));
817
+ const activeTarget = deps.activeTurnRuntime.getTarget();
818
+ if ((activeTarget && matches(activeTarget)) ||
819
+ deps.telegramQueueStore.getQueuedItems().some(item => item.target && matches(item.target)) ||
820
+ (deps.getLiveThreadTargets?.() ?? []).some(matches) ||
821
+ (deps.threadStore?.list() ?? []).some(record => matches(record.target)) ||
822
+ (deps.threadStore?.listReservations() ?? []).some(record => matches(record.target)) ||
823
+ (deps.threadStore?.listPendingProvisions() ?? []).some(record => record.target && matches(record.target)))
824
+ return true;
825
+ const store = deps.getWorkspaceRestoreStore?.();
826
+ if (deps.getWorkspaceRestoreStore && !store)
827
+ return true;
828
+ let temporary;
829
+ try {
830
+ temporary = store?.listTemporaryThreads() ?? [];
831
+ }
832
+ catch {
833
+ return true;
834
+ }
835
+ // Only the source's own Forward/Cancel may remove its retained tab; adoption may have changed its executor.
836
+ const ownTab = (entry) => !!ownTemporary && entry.token === ownTemporary.token &&
837
+ isDeepStrictEqual(entry.source, ownTemporary.source) && isDeepStrictEqual(entry.target, ownTemporary.target);
838
+ if (temporary.some(entry => !!entry.target && matches(entry.target) &&
839
+ (!ownTab(entry) || (Threads.getTelegramTemporaryThreadInputs(entry).length > 1 &&
840
+ !Threads.isTelegramTemporaryThreadFullyResolved(entry) &&
841
+ !(restoreFrom && areTemporaryThreadSiblingsAccountedForRestore(entry, restoreFrom))))))
842
+ return true;
843
+ // Whole-tab cleanup after cancellation also needs a fresh census of retained arrivals no membership has seen.
844
+ if (!restoreFrom && temporary.some(entry => ownTab(entry) && !!(entry.cancelledInputs?.length || entry.completedInputs?.length))) {
845
+ try {
846
+ const required = [...new Set([deps.getAdmissionJournalBinding?.(), ownTemporary.source.journalBindingKey,
847
+ ...Threads.getTelegramTemporaryThreadInputs(ownTemporary).map(input => input.journalBindingKey)].filter((key) => !!key))];
848
+ if (deps.inspectTemporaryThreadSources?.({ chatId: target.chatId, threadId: target.threadId }, required)?.length !== 0)
849
+ return true;
850
+ }
851
+ catch {
852
+ return true;
853
+ }
854
+ }
855
+ const retained = store?.list() ?? [];
856
+ // Exempt only the exact operation's old target. Cleanup itself also requires its issued grant.
857
+ if (ownRestore && (!matches(ownRestore.request.binding.target) ||
858
+ !retained.some(intent => isDeepStrictEqual(intent, ownRestore))))
859
+ return true;
860
+ if (retained.some(intent => (matches(intent.request.binding.target) || matches(intent.request.target)) &&
861
+ !(ownRestore && isDeepStrictEqual(intent, ownRestore) && matches(intent.request.binding.target))))
862
+ return true;
863
+ if (!ownRestore)
864
+ return false;
865
+ let acceptedWorkClear = false;
866
+ try {
867
+ deps.threadStore?.withWorkspaceRestoreSnapshot(ownRestore, snapshot => {
868
+ const bindings = snapshot.workspaceBindings?.filter(binding => binding.bindingKey === ownRestore.request.binding.bindingKey) ?? [];
869
+ if (bindings.length !== 1 || !deps.captureWorkspaceExternalProtection)
870
+ return undefined;
871
+ const binding = bindings[0];
872
+ // Relocation does not erase predecessor references or prove where their work executes.
873
+ const journalBindingKeys = [...new Set([
874
+ ...(ownRestore.request.binding.journalBindingKeys ?? []), ...(binding.journalBindingKeys ?? []),
875
+ ])];
876
+ const journalSources = [...new Map([
877
+ ...(ownRestore.request.binding.journalSources ?? []), ...(binding.journalSources ?? []),
878
+ ].map(source => [JSON.stringify([source.sessionId, source.recipientBindingKey]), source])).values()];
879
+ acceptedWorkClear = deps.captureWorkspaceExternalProtection({ ...binding, target: ownRestore.request.binding.target,
880
+ journalBindingKeys, journalSources }, { requireBindingProvenance: true }).acceptedWork === "clear";
881
+ return undefined;
882
+ });
883
+ }
884
+ catch (error) {
885
+ acceptedWorkClear = false;
886
+ deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-protection" });
887
+ }
888
+ return !acceptedWorkClear;
614
889
  };
615
890
  const dismissRerouteChooserMessage = async (query, assertExecutionCurrent) => {
616
891
  const chatId = query.message?.chat?.id;
@@ -637,7 +912,7 @@ export function createTelegramInboundRouteRuntime(deps) {
637
912
  return false;
638
913
  }
639
914
  };
640
- const closeReroutedUnboundTopic = async (target, messageId, assertExecutionCurrent) => {
915
+ const closeReroutedUnboundTopic = async (target, messageId, assertExecutionCurrent, ownTemporary) => {
641
916
  if (!target || !deps.threadStore)
642
917
  return true;
643
918
  const nowMs = Date.now();
@@ -660,9 +935,11 @@ export function createTelegramInboundRouteRuntime(deps) {
660
935
  },
661
936
  ],
662
937
  });
663
- return applyThreadCleanupPlan(plan, assertExecutionCurrent);
938
+ // A temporary grant licenses this tab only, not unrelated cleanup emitted by the general planner.
939
+ const scoped = ownTemporary ? { ...plan, actions: plan.actions.filter(action => action.kind === "close-delete-unbound-topic" && isDeepStrictEqual(action.target, target)) } : plan;
940
+ return applyThreadCleanupPlan(scoped, assertExecutionCurrent, undefined, ownTemporary);
664
941
  };
665
- const closePreviousLeaderThread = async (target, assertExecutionCurrent) => {
942
+ const closePreviousLeaderThread = async (target, assertExecutionCurrent, restoreCleanup) => {
666
943
  if (!target || !deps.threadStore)
667
944
  return true;
668
945
  const currentLeaderEpoch = deps.getCurrentLeaderEpoch?.();
@@ -678,9 +955,9 @@ export function createTelegramInboundRouteRuntime(deps) {
678
955
  : {}),
679
956
  },
680
957
  ],
681
- }, assertExecutionCurrent);
958
+ }, assertExecutionCurrent, restoreCleanup);
682
959
  };
683
- const closeReplacedFollowerThread = async (target, instanceId, assertExecutionCurrent) => {
960
+ const closeReplacedFollowerThread = async (target, instanceId, assertExecutionCurrent, restoreCleanup) => {
684
961
  if (!target || !deps.threadStore)
685
962
  return true;
686
963
  const currentLeaderEpoch = deps.getCurrentLeaderEpoch?.();
@@ -696,11 +973,807 @@ export function createTelegramInboundRouteRuntime(deps) {
696
973
  : {}),
697
974
  },
698
975
  ],
699
- }, assertExecutionCurrent);
976
+ }, assertExecutionCurrent, restoreCleanup);
700
977
  };
978
+ // Worker ACK observers outlive callback admission. Re-enter the existing Workspace gate;
979
+ // never await this continuation while holding the dispatch invocation's gate.
980
+ const restoreSettlementTasks = new Set();
981
+ /**
982
+ * After a Restore into a temporary tab reached positive terminal settlement, every still-unassigned sibling there is retained
983
+ * privately and cancelled without Pi delivery. Missing proof, authority or abandonment leaves that sibling pending and the
984
+ * tab's temporary entry protected; selected, queued, running or unknown work is never touched.
985
+ */
986
+ const disposeTemporaryThreadSiblingsAfterRestore = async (intent, ctx, restoreCurrent) => {
987
+ const target = intent.request.target, cap = captureTemporaryThreadAuthority(ctx, restoreCurrent);
988
+ const entry = findCreatedTemporaryThread(target);
989
+ if (!cap || !entry || !entry.target || !cap.isCurrent())
990
+ return;
991
+ const operatorUserId = intent.operatorUserId, journalBindingKey = intent.request.source.journalBindingKey;
992
+ if (cap.operatorUserId !== operatorUserId || cap.journalBindingKey !== journalBindingKey ||
993
+ entry.operatorUserId !== operatorUserId || entry.source.journalBindingKey !== journalBindingKey)
994
+ return;
995
+ const group = { journalBindingKey, updateIds: [...intent.request.source.updateIds] };
996
+ if (!Threads.getTelegramTemporaryThreadInputs(entry).some(input => isDeepStrictEqual(input, group)))
997
+ return;
998
+ for (const [id, pending] of [...pendingUnboundReroutes]) {
999
+ const cancellation = pending.abandonment, source = pending.messages[0];
1000
+ if (pending.sourceTarget.chatId !== target.chatId || pending.sourceTarget.threadId !== target.threadId ||
1001
+ pending.workspaceRestore?.operationId === intent.request.operationId || !cancellation || !source || cancellation.result ||
1002
+ cancellation.running)
1003
+ continue;
1004
+ const current = () => cap.isCurrent() &&
1005
+ pendingUnboundReroutes.get(id) === pending && pending.abandonment === cancellation &&
1006
+ !pending.destinationSelected && !pending.selectionAttempted && !pending.dispatching && !pending.foreignForwardIssued && !pending.foreignRetry &&
1007
+ !pending.cleanup && !pending.finalizeMessage && !pending.workspaceRestore &&
1008
+ Updates.getTelegramUpdateExecutionFence(source)?.signal.aborted === false &&
1009
+ cancellation.ownerUserId === operatorUserId && cancellation.journalBindingKey === journalBindingKey;
1010
+ if (!current())
1011
+ continue;
1012
+ cancellation.running = true;
1013
+ try {
1014
+ const result = Updates.abandonTelegramDeferredUpdate(source, { operatorAuthorityId: `telegram-owner:${operatorUserId}`, isCurrent: current });
1015
+ if (!result)
1016
+ continue;
1017
+ cancellation.attempted = true;
1018
+ cancellation.result = result;
1019
+ if (!recordCancelledTemporaryThreadInput(pending, ctx, current))
1020
+ continue;
1021
+ await retireCancellationChooser(id, pending, current);
1022
+ }
1023
+ catch (error) {
1024
+ deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-restore-sibling", rerouteId: id });
1025
+ }
1026
+ finally {
1027
+ cancellation.running = false;
1028
+ }
1029
+ }
1030
+ // The entry is released only after every sibling is gone and the Restore group's completion is the single uncancelled input.
1031
+ if (hasPendingRerouteForTarget(target, pending => pending.workspaceRestore?.operationId === intent.request.operationId) ||
1032
+ !cap.isCurrent())
1033
+ return;
1034
+ const fresh = findCreatedTemporaryThread(target), owned = fresh && cap.adopt(fresh);
1035
+ if (owned && cap.isCurrent())
1036
+ cap.store.retireTemporaryThread(owned, cap.authority, group);
1037
+ };
1038
+ const observeRestoreSettlement = (signal, ctx) => {
1039
+ if (!deps.hasWorkspaceRestoreAuthority?.() || !deps.runWorkspaceOperation || !deps.getWorkspaceRestoreStore ||
1040
+ !deps.getSessionGeneration || deps.isContextActive?.(ctx) === false)
1041
+ return;
1042
+ const evidence = signal.kind === "source" ? structuredClone(signal.evidence) : undefined;
1043
+ const follower = signal.kind === "recipient" ? structuredClone(signal.follower) : undefined;
1044
+ const recipientCurrent = () => {
1045
+ if (signal.kind !== "recipient" || !follower)
1046
+ return true;
1047
+ const live = deps.workspaceRestoreRecipient?.followerRegistry.get(follower.instanceId);
1048
+ return signal.isCurrent() && !!live && Bus.hasTelegramBusCapability(live.protocol, Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE) &&
1049
+ isDeepStrictEqual({ ...live, lastHeartbeatMs: undefined, connectedAtMs: undefined, threadName: undefined }, { ...follower, lastHeartbeatMs: undefined, connectedAtMs: undefined, threadName: undefined });
1050
+ };
1051
+ const recipientMatches = (intent) => {
1052
+ if (!follower)
1053
+ return true;
1054
+ const ready = intent.readyRecipient ?? intent.recipient;
1055
+ return ready?.kind === "follower" && ready.instanceId === follower.instanceId && ready.sessionId === follower.sessionId &&
1056
+ ready.generation === follower.registrationGeneration && !!follower.cwd &&
1057
+ intent.request.binding.cwd === WorkspaceIdentity.normalizeTelegramWorkspacePath(follower.cwd) &&
1058
+ intent.request.binding.slot === follower.slot && isDeepStrictEqual(intent.request.target, follower.target);
1059
+ };
1060
+ // A same-session successor already registered on the relocated target may prove an issued grant by inspection only.
1061
+ const inspectableSuccessor = (intent) => !!follower?.registrationGeneration && !!follower.cwd && (intent.phase === "relocated" || intent.phase === "recipient-issued") &&
1062
+ intent.request.owner.owner?.kind === "manual-follower" && follower.sessionId === intent.request.binding.sessionId &&
1063
+ intent.request.binding.cwd === WorkspaceIdentity.normalizeTelegramWorkspacePath(follower.cwd) &&
1064
+ intent.request.binding.slot === follower.slot && isDeepStrictEqual(intent.request.target, follower.target);
1065
+ const instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.();
1066
+ const operatorUserId = deps.configStore.getAllowedUserId();
1067
+ const generation = deps.getSessionGeneration(), scope = deps.getAdmissionScope?.();
1068
+ const sourceBinding = deps.getAdmissionJournalBinding?.();
1069
+ if (!instanceId || epoch === undefined || operatorUserId === undefined || !scope ||
1070
+ !sourceBinding || (evidence && evidence.journalBindingKey !== sourceBinding) || !recipientCurrent())
1071
+ return;
1072
+ // This same-session leader already runs on the relocated target; it may only inspect an issued leader grant.
1073
+ const leaderSessionId = deps.workspaceRestoreRecipient?.getSessionId(ctx);
1074
+ const leaderCwd = deps.workspaceRestoreRecipient?.getCwd(ctx);
1075
+ const leaderAt = (request) => {
1076
+ const ports = deps.workspaceRestoreRecipient;
1077
+ const cwd = ports?.getCwd(ctx), local = ports?.getLeaderIdentity();
1078
+ return !!cwd && cwd === leaderCwd && ports?.getSessionId(ctx) === leaderSessionId &&
1079
+ leaderSessionId === request.binding.sessionId && WorkspaceIdentity.normalizeTelegramWorkspacePath(cwd) === request.binding.cwd &&
1080
+ !!local && local.slot === request.binding.slot && isDeepStrictEqual(local.target, request.target);
1081
+ };
1082
+ const observeLeaderRestore = (intent, recipient, isCurrent) => {
1083
+ const threads = deps.threadStore;
1084
+ if (!threads || !isCurrent() || !leaderAt(intent.request))
1085
+ return undefined;
1086
+ const { request } = intent;
1087
+ let observation;
1088
+ threads.withWorkspaceRestoreSnapshot(intent, snapshot => {
1089
+ if (!isCurrent() || !leaderAt(request))
1090
+ return;
1091
+ const bindings = (snapshot.workspaceBindings ?? []).filter(value => value.bindingKey === request.binding.bindingKey);
1092
+ const live = bindings[0];
1093
+ const owners = snapshot.threads.filter(value => value.status === "active" &&
1094
+ (value.slot === request.binding.slot || isDeepStrictEqual(value.target, request.target)));
1095
+ if (bindings.length !== 1 || live?.sessionId !== request.binding.sessionId || live.cwd !== request.binding.cwd ||
1096
+ live.slot !== request.binding.slot || live.inactiveSinceMs !== undefined || !isDeepStrictEqual(live.target, request.target) ||
1097
+ owners.length !== 1 || owners[0]?.owner?.kind !== "leader" || owners[0].instanceId !== recipient.instanceId ||
1098
+ owners[0].owner.instanceId !== recipient.instanceId || !owners[0].owner.cwd ||
1099
+ WorkspaceIdentity.normalizeTelegramWorkspacePath(owners[0].owner.cwd) !== request.binding.cwd ||
1100
+ owners[0].profileKey !== request.owner.profileKey || owners[0].slot !== request.binding.slot || !isDeepStrictEqual(owners[0].target, request.target))
1101
+ return;
1102
+ observation = { operationId: request.operationId, recipient, target: request.target, slot: request.binding.slot, ready: true };
1103
+ });
1104
+ return isCurrent() && leaderAt(request) ? observation : undefined;
1105
+ };
1106
+ // Same-process lost replies stay with their chooser; this path only serves a restarted leader process.
1107
+ const inspectableLeader = (intent) => !!evidence &&
1108
+ intent.request.owner.owner?.kind === "leader" && leaderAt(intent.request) &&
1109
+ (intent.phase === "relocated" ? intent.request.owner.instanceId !== instanceId
1110
+ : intent.phase === "recipient-issued" && intent.recipient?.kind === "leader" && intent.recipient.instanceId !== instanceId);
1111
+ const current = () => deps.hasWorkspaceRestoreAuthority?.() === true && deps.isContextActive?.(ctx) !== false &&
1112
+ deps.getSessionGeneration?.() === generation && deps.getAdmissionScope?.() === scope &&
1113
+ deps.getAdmissionJournalBinding?.() === sourceBinding && deps.getCurrentInstanceId?.() === instanceId &&
1114
+ deps.getCurrentLeaderEpoch?.() === epoch && deps.configStore.getAllowedUserId() === operatorUserId && recipientCurrent();
1115
+ const recoveryRecipient = (intent) => {
1116
+ const ready = intent.readyRecipient ?? intent.recipient;
1117
+ if (!deps.inspectRestoreSourceCompletion || !deps.workspaceRestoreRecipient || intent.phase !== "ready" ||
1118
+ !intent.routing || intent.routing.cleanup !== undefined || intent.operatorUserId !== operatorUserId ||
1119
+ !intent.routing.acceptances?.some(value => value.kind === "forwarded"))
1120
+ return undefined;
1121
+ const live = deps.workspaceRestoreRecipient.followerRegistry.get(follower?.instanceId ?? ready?.instanceId ?? "");
1122
+ return ready?.kind === "follower" && live?.registrationGeneration && live.cwd &&
1123
+ Bus.hasTelegramBusCapability(live.protocol, Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE) &&
1124
+ Bus.hasTelegramBusCapability(live.protocol, Bus.TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION) &&
1125
+ live.sessionId === intent.request.binding.sessionId && WorkspaceIdentity.normalizeTelegramWorkspacePath(live.cwd) === intent.request.binding.cwd &&
1126
+ live.slot === intent.request.binding.slot && isDeepStrictEqual(live.target, intent.request.target) ? live : undefined;
1127
+ };
1128
+ const recoveryLeader = (intent) => {
1129
+ const ready = intent.readyRecipient ?? intent.recipient;
1130
+ if (evidence?.kind !== "completed" || !deps.inspectRestoreSourceCompletion || !deps.threadStore ||
1131
+ intent.phase !== "ready" || !intent.routing || intent.routing.cleanup !== undefined || intent.operatorUserId !== operatorUserId ||
1132
+ intent.request.owner.owner?.kind !== "leader" || ready?.kind !== "leader" || ready.sessionId !== intent.request.binding.sessionId ||
1133
+ !leaderAt(intent.request) ||
1134
+ !intent.routing.acceptances?.some(value => value.kind === "completed" || value.kind === "queued"))
1135
+ return undefined;
1136
+ return { kind: "leader", instanceId, sessionId: intent.request.binding.sessionId, generation: String(generation) };
1137
+ };
1138
+ let selected;
1139
+ try {
1140
+ selected = deps.getWorkspaceRestoreStore()?.list().filter(intent => {
1141
+ if (intent.request.source.journalBindingKey !== sourceBinding)
1142
+ return false;
1143
+ if (evidence && intent.request.source.updateIds.some(id => evidence.updateIds.includes(id)))
1144
+ return true;
1145
+ if (((evidence?.kind === "completed" || follower) && recoveryRecipient(intent)) || recoveryLeader(intent))
1146
+ return true;
1147
+ if (follower && deps.workspaceRestoreRecipient && inspectableSuccessor(intent))
1148
+ return true;
1149
+ if (deps.threadStore && inspectableLeader(intent))
1150
+ return true;
1151
+ // Completion rechecks an unsent Restore whose originals may all have been abandoned before its retirement published.
1152
+ if (evidence?.kind === "completed" && deps.inspectRestoreSourceAbandonment && intent.phase === "ready" &&
1153
+ intent.routing === undefined)
1154
+ return true;
1155
+ // Hints only wake fully settled, unissued cleanup. They never settle another source.
1156
+ return (evidence?.kind === "completed" || !!follower) && intent.phase === "ready" && intent.routing?.cleanup === undefined &&
1157
+ recipientMatches(intent) && intent.request.source.updateIds.every(id => intent.routing?.settlements.some(value => value.kind !== "queued" && value.updateIds.includes(id)));
1158
+ }).map(intent => intent.request.operationId) ?? [];
1159
+ }
1160
+ catch (error) {
1161
+ deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-settlement" });
1162
+ return;
1163
+ }
1164
+ if (!selected.length || !current())
1165
+ return;
1166
+ const task = Promise.resolve().then(() => deps.runWorkspaceOperation({ operationId: `restore-settlement-${randomBytes(16).toString("hex")}`,
1167
+ operationKind: "workspace.restore-settlement", scopes: [{ kind: "profile" }] }, async () => {
1168
+ if (!current())
1169
+ return;
1170
+ const store = deps.getWorkspaceRestoreStore();
1171
+ if (!store)
1172
+ return;
1173
+ const authority = {
1174
+ executor: { instanceId, leaderEpoch: String(epoch) }, operatorUserId, isCurrent: current
1175
+ };
1176
+ const assertCurrent = () => { if (!current())
1177
+ throw new Error("Workspace Restore settlement authority ended."); };
1178
+ // Exact owner abandonment of every original is terminal for an unsent Restore; no cleanup or delivery follows.
1179
+ const abandoned = (value) => {
1180
+ if (evidence?.kind !== "completed" || !value.request.source.updateIds.length)
1181
+ return false;
1182
+ return value.request.source.updateIds.every(updateId => {
1183
+ const proof = deps.inspectRestoreSourceAbandonment?.(updateId, sourceBinding);
1184
+ assertCurrent();
1185
+ return proof?.journalBindingKey === sourceBinding && proof.updateId === updateId &&
1186
+ proof.operatorAuthorityId === `telegram-owner:${operatorUserId}`;
1187
+ });
1188
+ };
1189
+ for (const operationId of selected) {
1190
+ assertCurrent();
1191
+ let intent = store.list().find(value => value.request.operationId === operationId);
1192
+ let settlementCurrent = current;
1193
+ let settlementCanonicalCurrent;
1194
+ let retired = false;
1195
+ if (follower && intent && inspectableSuccessor(intent) && intent.operatorUserId === operatorUserId) {
1196
+ const ports = deps.workspaceRestoreRecipient;
1197
+ const successor = { kind: "follower", instanceId: follower.instanceId,
1198
+ sessionId: follower.sessionId, generation: follower.registrationGeneration };
1199
+ const { request } = intent;
1200
+ // Adoption plus inspect never consumes the original apply grant, delivers input or issues cleanup.
1201
+ await advanceTelegramWorkspaceRestore({ request, authority, restoreStore: store, inspectOnly: true,
1202
+ getRecipient: () => current() ? successor : undefined,
1203
+ async runRecipient(action) {
1204
+ if (action.mode !== "inspect" || !action.isCurrent())
1205
+ return undefined;
1206
+ return ports.runFollower({ operationId: request.operationId, instanceId: successor.instanceId,
1207
+ sessionId: successor.sessionId, slot: request.binding.slot, target: request.target,
1208
+ oldTarget: request.binding.target, mode: "inspect", isCurrent: action.isCurrent });
1209
+ } });
1210
+ continue;
1211
+ }
1212
+ if (intent && inspectableLeader(intent) && intent.operatorUserId === operatorUserId) {
1213
+ const threads = deps.threadStore;
1214
+ const leader = { kind: "leader", instanceId,
1215
+ sessionId: intent.request.binding.sessionId, generation: String(generation) };
1216
+ const { request } = intent;
1217
+ await threads.load();
1218
+ assertCurrent();
1219
+ await advanceTelegramWorkspaceRestore({ request, authority, restoreStore: store, inspectOnly: true,
1220
+ getRecipient: () => current() && leaderAt(request) ? leader : undefined,
1221
+ async runRecipient(action) {
1222
+ if (action.mode !== "inspect")
1223
+ return undefined;
1224
+ return observeLeaderRestore(action.intent, leader, action.isCurrent);
1225
+ } });
1226
+ continue;
1227
+ }
1228
+ if (intent?.phase === "ready" && intent.routing === undefined && intent.operatorUserId === operatorUserId &&
1229
+ intent.request.source.journalBindingKey === sourceBinding && abandoned(intent)) {
1230
+ const owned = isDeepStrictEqual(intent.executor, authority.executor) ? intent : store.adopt(intent, authority);
1231
+ assertCurrent();
1232
+ if (owned)
1233
+ store.retireAbandoned(owned, owned.request.source.updateIds, authority);
1234
+ continue;
1235
+ }
1236
+ const liveRecipient = intent && recoveryRecipient(intent), localRecipient = intent && recoveryLeader(intent);
1237
+ if (intent && (liveRecipient || localRecipient) && intent.request.source.journalBindingKey === sourceBinding) {
1238
+ const expected = intent, ports = deps.workspaceRestoreRecipient;
1239
+ const recoveryCurrent = () => current() && (localRecipient ? leaderAt(expected.request) && isDeepStrictEqual(recoveryLeader(expected), localRecipient) : isDeepStrictEqual({ ...recoveryRecipient(expected), lastHeartbeatMs: undefined, connectedAtMs: undefined, threadName: undefined }, { ...liveRecipient, lastHeartbeatMs: undefined, connectedAtMs: undefined, threadName: undefined }));
1240
+ // Storage authority callbacks check live fences only; canonical reads cannot re-enter their transaction lock.
1241
+ const recoveryAuthority = { ...authority, isCurrent: recoveryCurrent };
1242
+ settlementCurrent = recoveryCurrent;
1243
+ if (localRecipient) {
1244
+ settlementCanonicalCurrent = () => !!intent && !!observeLeaderRestore(intent, localRecipient, recoveryCurrent);
1245
+ if (!settlementCanonicalCurrent())
1246
+ continue; // A local target alone cannot authorize executor adoption.
1247
+ }
1248
+ const inspect = (acceptance, operation) => {
1249
+ const completion = { journalBindingKey: sourceBinding, updateId: acceptance.updateId,
1250
+ sourceSha256: acceptance.sourceSha256,
1251
+ completionSha256: Threads.getTelegramWorkspaceRestoreSourceCompletionSha256(operation, acceptance) };
1252
+ const observed = deps.inspectRestoreSourceCompletion({ ...completion });
1253
+ if (!recoveryCurrent())
1254
+ throw new Error("Workspace Restore completion observation authority ended.");
1255
+ return isDeepStrictEqual(observed, completion);
1256
+ };
1257
+ // Inspect proof before adopting: missing/foreign ACKs cannot even re-key the retained operation.
1258
+ const accepted = expected.routing.acceptances.filter(value => (localRecipient ? value.kind === "completed" || value.kind === "queued" : value.kind === "forwarded") && inspect(value, expected));
1259
+ if (accepted.length) {
1260
+ const successor = localRecipient ?? { kind: "follower", instanceId: liveRecipient.instanceId,
1261
+ sessionId: liveRecipient.sessionId, generation: liveRecipient.registrationGeneration };
1262
+ const { request } = expected;
1263
+ const inspected = await advanceTelegramWorkspaceRestore({ request, authority: recoveryAuthority, restoreStore: store, inspectOnly: true,
1264
+ getRecipient: () => recoveryCurrent() ? successor : undefined,
1265
+ async runRecipient(action) {
1266
+ if (action.mode !== "inspect" || !action.isCurrent())
1267
+ return undefined;
1268
+ if (localRecipient)
1269
+ return observeLeaderRestore(action.intent, successor, action.isCurrent);
1270
+ return ports.runFollower({ operationId: request.operationId, instanceId: successor.instanceId,
1271
+ sessionId: successor.sessionId, slot: request.binding.slot, target: request.target,
1272
+ oldTarget: request.binding.target, mode: "inspect", isCurrent: action.isCurrent });
1273
+ } });
1274
+ if (!inspected || !recoveryCurrent())
1275
+ continue;
1276
+ intent = inspected;
1277
+ if (settlementCanonicalCurrent && !settlementCanonicalCurrent())
1278
+ continue;
1279
+ // The await cannot lend earlier readback; confirm each immutable proof again before publication.
1280
+ const settledIds = new Set(intent.routing.settlements.filter(value => value.kind !== "queued").flatMap(value => value.updateIds));
1281
+ const recovered = new Map();
1282
+ for (const acceptance of accepted.filter(value => !settledIds.has(value.updateId) && inspect(value, intent))) {
1283
+ const key = acceptance.kind === "queued" ? JSON.stringify([acceptance.receiptId, acceptance.queueKind]) : "completed";
1284
+ const settlement = recovered.get(key) ?? { journalBindingKey: sourceBinding, updateIds: [],
1285
+ ...(acceptance.kind === "queued" ? { kind: "queue-completed", receiptId: acceptance.receiptId, queueKind: acceptance.queueKind }
1286
+ : { kind: "completed" }) };
1287
+ settlement.updateIds.push(acceptance.updateId);
1288
+ recovered.set(key, settlement);
1289
+ }
1290
+ for (const settlement of recovered.values()) {
1291
+ intent = store.recordSourceSettlement(intent, settlement, recoveryAuthority);
1292
+ if (!intent)
1293
+ break;
1294
+ }
1295
+ if (!intent)
1296
+ continue;
1297
+ }
1298
+ }
1299
+ // Source hints carry no registry fence; cleanup must keep the acknowledged recipient exact across awaits.
1300
+ const readyRecipientCurrent = () => {
1301
+ const ready = intent?.readyRecipient ?? intent?.recipient;
1302
+ if (intent?.phase !== "ready" || ready?.kind !== "follower")
1303
+ return true;
1304
+ const live = deps.workspaceRestoreRecipient?.followerRegistry.get(ready.instanceId);
1305
+ return !!live?.cwd && live.registrationGeneration === ready.generation && live.sessionId === ready.sessionId &&
1306
+ WorkspaceIdentity.normalizeTelegramWorkspacePath(live.cwd) === intent.request.binding.cwd &&
1307
+ live.slot === intent.request.binding.slot && isDeepStrictEqual(live.target, intent.request.target) &&
1308
+ Bus.hasTelegramBusCapability(live.protocol, Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE) &&
1309
+ Bus.hasTelegramBusCapability(live.protocol, Bus.TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION);
1310
+ };
1311
+ const settlementAuthority = { ...authority, isCurrent: function () { return settlementCurrent() && readyRecipientCurrent(); } };
1312
+ const assertSettlementCurrent = () => {
1313
+ if (!settlementAuthority.isCurrent() || (!retired && settlementCanonicalCurrent && !settlementCanonicalCurrent())) {
1314
+ throw new Error("Workspace Restore settlement authority ended.");
1315
+ }
1316
+ };
1317
+ assertSettlementCurrent();
1318
+ if (!intent || intent.phase !== "ready" || !intent.routing || !isDeepStrictEqual(intent.executor, authority.executor) ||
1319
+ intent.operatorUserId !== operatorUserId || intent.request.source.journalBindingKey !== sourceBinding)
1320
+ continue;
1321
+ const settled = new Set(intent.routing.settlements.flatMap(value => value.updateIds));
1322
+ if (follower && (intent.routing.cleanup !== undefined || !recipientMatches(intent) ||
1323
+ !intent.request.source.updateIds.every(id => settled.has(id))))
1324
+ continue;
1325
+ const updateIds = evidence?.updateIds.filter(id => intent.request.source.updateIds.includes(id) && !settled.has(id)) ?? [];
1326
+ if (evidence && updateIds.length) {
1327
+ intent = store.recordSourceSettlement(intent, { ...evidence, updateIds }, settlementAuthority);
1328
+ if (!intent)
1329
+ continue;
1330
+ }
1331
+ if (intent.routing?.cleanup === undefined) {
1332
+ if (!intent.request.source.updateIds.every(id => intent.routing.settlements.some(value => value.kind !== "queued" && value.updateIds.includes(id))))
1333
+ continue;
1334
+ // Positive settlement of every selected source is the success proof; old-thread cleanup may stay protected indefinitely.
1335
+ await disposeTemporaryThreadSiblingsAfterRestore(intent, ctx, settlementAuthority.isCurrent);
1336
+ assertSettlementCurrent();
1337
+ if (isRerouteTargetProtected(intent.request.binding.target, intent))
1338
+ continue;
1339
+ const grant = store.issueCleanup(intent, settlementAuthority);
1340
+ if (!grant)
1341
+ continue;
1342
+ intent = grant.intent;
1343
+ const oldTarget = intent.request.binding.target;
1344
+ const completed = intent.request.owner.owner?.kind === "leader"
1345
+ ? await closePreviousLeaderThread(oldTarget, assertSettlementCurrent, intent)
1346
+ : await closeReplacedFollowerThread(oldTarget, intent.request.owner.instanceId, assertSettlementCurrent, intent);
1347
+ assertSettlementCurrent();
1348
+ if (!completed)
1349
+ continue; // Issued uncertainty stays protected, never downgraded or retried.
1350
+ intent = store.recordCleanup(intent, { kind: "completed", target: oldTarget }, settlementAuthority);
1351
+ if (!intent)
1352
+ continue;
1353
+ }
1354
+ if (!store.retire(intent, settlementAuthority))
1355
+ continue;
1356
+ retired = true;
1357
+ for (const [id, pending] of pendingUnboundReroutes) {
1358
+ if (pending.workspaceRestore?.operationId !== operationId)
1359
+ continue;
1360
+ pending.finalizeMessage = "Message routed.";
1361
+ if (!deps.editInteractiveMessage || pending.chooserMessageId === undefined)
1362
+ continue;
1363
+ assertSettlementCurrent();
1364
+ await deps.editInteractiveMessage(pending.sourceTarget.chatId, pending.chooserMessageId, "<b>✅ Message routed.</b>", "html", { inline_keyboard: [] });
1365
+ assertSettlementCurrent();
1366
+ if (pendingUnboundReroutes.get(id) === pending)
1367
+ removePendingReroute(id);
1368
+ }
1369
+ }
1370
+ })).catch(error => { deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-settlement" }); });
1371
+ restoreSettlementTasks.add(task);
1372
+ void task.finally(() => restoreSettlementTasks.delete(task));
1373
+ return task;
1374
+ };
1375
+ const beforeQueueReceiptPublished = async (receipt, queueOwner, ctx, isWorkerCurrent) => {
1376
+ const store = deps.getWorkspaceRestoreStore?.();
1377
+ if (!store)
1378
+ return;
1379
+ const selected = store.list().filter(intent => intent.request.source.journalBindingKey === receipt.journalBindingKey &&
1380
+ intent.request.source.updateIds.some(id => receipt.sourceUpdateIds.includes(id))).map(intent => intent.request.operationId);
1381
+ if (!selected.length)
1382
+ return;
1383
+ const instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.();
1384
+ const generation = deps.getSessionGeneration?.(), scope = deps.getAdmissionScope?.(), operatorUserId = deps.configStore.getAllowedUserId();
1385
+ const binding = receipt.journalBindingKey, ports = deps.workspaceRestoreRecipient, threads = deps.threadStore;
1386
+ if (!instanceId || epoch === undefined || generation === undefined || !scope || !binding || operatorUserId === undefined ||
1387
+ !ports || !threads || !deps.runWorkspaceOperation || !deps.inspectRestoreQueuedReceipt) {
1388
+ throw new Error("Workspace Restore queued acceptance authority is unavailable.");
1389
+ }
1390
+ const expectedReceipt = { queueKind: receipt.queueKind, receiptId: receipt.receiptId,
1391
+ sourceUpdateIds: [...receipt.sourceUpdateIds], queueOwner: { ...queueOwner } };
1392
+ const current = () => isWorkerCurrent() && deps.hasWorkspaceRestoreAuthority?.() === true &&
1393
+ deps.isContextActive?.(ctx) !== false && deps.getSessionGeneration?.() === generation && deps.getAdmissionScope?.() === scope &&
1394
+ deps.getAdmissionJournalBinding?.() === binding && deps.getCurrentInstanceId?.() === instanceId &&
1395
+ deps.getCurrentLeaderEpoch?.() === epoch && deps.configStore.getAllowedUserId() === operatorUserId &&
1396
+ queueOwner.instanceId === instanceId && queueOwner.sessionGeneration === generation;
1397
+ const assertCurrent = () => { if (!current())
1398
+ throw new Error("Workspace Restore queued acceptance authority changed."); };
1399
+ assertCurrent();
1400
+ const recipientGuards = [];
1401
+ const sourceCompletions = [];
1402
+ await deps.runWorkspaceOperation({ operationId: `restore-queue-acceptance-${randomBytes(16).toString("hex")}`,
1403
+ operationKind: "workspace.restore-queue-acceptance", scopes: [{ kind: "profile" }] }, async () => {
1404
+ assertCurrent();
1405
+ const active = deps.getWorkspaceRestoreStore?.();
1406
+ if (!active)
1407
+ throw new Error("Workspace Restore queued acceptance storage disappeared.");
1408
+ const proof = deps.inspectRestoreQueuedReceipt({ ...structuredClone(expectedReceipt), journalBindingKey: binding });
1409
+ assertCurrent();
1410
+ if (!proof || !isDeepStrictEqual(proof.receipt, expectedReceipt) || !/^[a-f0-9]{64}$/u.test(proof.queueOwnerSha256) ||
1411
+ proof.sources.length !== receipt.sourceUpdateIds.length || proof.sources.some((source, index) => source.updateId !== receipt.sourceUpdateIds[index] || !/^[a-f0-9]{64}$/u.test(source.sourceSha256))) {
1412
+ throw new Error("Workspace Restore queued receipt proof is unavailable.");
1413
+ }
1414
+ for (const operationId of selected) {
1415
+ let intent = active.list().find(value => value.request.operationId === operationId);
1416
+ const recipient = intent?.readyRecipient ?? intent?.recipient;
1417
+ if (!intent || intent.phase !== "ready" || !intent.routing || intent.operatorUserId !== operatorUserId ||
1418
+ !isDeepStrictEqual(intent.executor, { instanceId, leaderEpoch: String(epoch) }) ||
1419
+ intent.request.source.journalBindingKey !== binding || recipient?.kind !== "leader" || recipient.instanceId !== instanceId ||
1420
+ recipient.generation !== String(generation) || recipient.sessionId !== intent.request.binding.sessionId) {
1421
+ throw new Error("Workspace Restore queued recipient proof changed.");
1422
+ }
1423
+ const request = intent.request;
1424
+ const liveRecipientCurrent = () => {
1425
+ const local = ports.getLeaderIdentity(), cwd = ports.getCwd(ctx);
1426
+ return current() && !!local && local.slot === request.binding.slot && isDeepStrictEqual(local.target, request.target) &&
1427
+ ports.getSessionId(ctx) === recipient.sessionId && !!cwd && WorkspaceIdentity.normalizeTelegramWorkspacePath(cwd) === request.binding.cwd;
1428
+ };
1429
+ const recipientCurrent = () => {
1430
+ if (!liveRecipientCurrent())
1431
+ return false;
1432
+ let committed = false;
1433
+ threads.withWorkspaceRestoreSnapshot(intent, snapshot => {
1434
+ const live = snapshot.workspaceBindings?.filter(value => value.bindingKey === request.binding.bindingKey) ?? [];
1435
+ const owners = snapshot.threads.filter(value => value.status === "active" &&
1436
+ (value.slot === request.binding.slot || isDeepStrictEqual(value.target, request.target)));
1437
+ const local = ports.getLeaderIdentity(), cwd = ports.getCwd(ctx);
1438
+ committed = live.length === 1 && live[0]?.sessionId === recipient.sessionId && live[0].cwd === request.binding.cwd &&
1439
+ live[0].slot === request.binding.slot && live[0].inactiveSinceMs === undefined && isDeepStrictEqual(live[0].target, request.target) &&
1440
+ owners.length === 1 && owners[0]?.owner?.kind === "leader" && owners[0].instanceId === instanceId &&
1441
+ owners[0].owner.instanceId === instanceId && !!owners[0].owner.cwd &&
1442
+ WorkspaceIdentity.normalizeTelegramWorkspacePath(owners[0].owner.cwd) === request.binding.cwd && owners[0].profileKey === request.owner.profileKey &&
1443
+ owners[0].slot === request.binding.slot && isDeepStrictEqual(owners[0].target, request.target) &&
1444
+ !!local && local.slot === request.binding.slot && isDeepStrictEqual(local.target, request.target) &&
1445
+ ports.getSessionId(ctx) === recipient.sessionId && !!cwd && WorkspaceIdentity.normalizeTelegramWorkspacePath(cwd) === request.binding.cwd;
1446
+ });
1447
+ return committed && liveRecipientCurrent();
1448
+ };
1449
+ recipientGuards.push(recipientCurrent);
1450
+ // Storage owns the canonical CAS; its in-transaction authority callback cannot reacquire the snapshot lock.
1451
+ const authority = { executor: { instanceId, leaderEpoch: String(epoch) }, operatorUserId, isCurrent: liveRecipientCurrent };
1452
+ for (const source of proof.sources.filter(value => request.source.updateIds.includes(value.updateId))) {
1453
+ if (!recipientCurrent())
1454
+ throw new Error("Workspace Restore queued recipient authority changed.");
1455
+ const evidence = { ...source, journalBindingKey: binding, recipient,
1456
+ kind: "queued", receiptId: receipt.receiptId, queueKind: receipt.queueKind, queueOwnerSha256: proof.queueOwnerSha256 };
1457
+ let accepted, failure;
1458
+ try {
1459
+ accepted = active.recordSourceAcceptance(intent, evidence, authority);
1460
+ }
1461
+ catch (error) {
1462
+ failure = error;
1463
+ }
1464
+ if (!accepted)
1465
+ accepted = active.list().find(value => value.request.operationId === operationId &&
1466
+ isDeepStrictEqual(value.request, request) && isDeepStrictEqual(value.executor, authority.executor) && value.operatorUserId === operatorUserId &&
1467
+ value.routing?.acceptances?.some(retained => isDeepStrictEqual(retained, evidence)));
1468
+ if (!accepted || !isDeepStrictEqual(accepted.request, request) || !isDeepStrictEqual(accepted.executor, authority.executor) ||
1469
+ accepted.operatorUserId !== operatorUserId || !accepted.routing?.acceptances?.some(value => isDeepStrictEqual(value, evidence))) {
1470
+ throw failure ?? new Error("Workspace Restore queued acceptance was not published.");
1471
+ }
1472
+ intent = accepted;
1473
+ sourceCompletions.push({ ...source, journalBindingKey: binding,
1474
+ completionSha256: Threads.getTelegramWorkspaceRestoreSourceCompletionSha256(accepted, evidence) });
1475
+ if (!recipientCurrent())
1476
+ throw new Error("Workspace Restore queued recipient authority changed.");
1477
+ }
1478
+ }
1479
+ const observed = deps.inspectRestoreQueuedReceipt({ ...structuredClone(expectedReceipt), journalBindingKey: binding });
1480
+ assertCurrent();
1481
+ if (!isDeepStrictEqual(observed, proof))
1482
+ throw new Error("Workspace Restore queued receipt changed after publication.");
1483
+ });
1484
+ assertCurrent();
1485
+ if (recipientGuards.some(guard => !guard()))
1486
+ throw new Error("Workspace Restore queued recipient changed after admission.");
1487
+ return sourceCompletions.sort((a, b) => a.updateId - b.updateId);
1488
+ };
1489
+ const onQueueReceiptCompleted = (receipt, ctx) => {
1490
+ // A hint re-enters admission and rereads scoped proof; it never supplies completed command evidence for a queued source.
1491
+ if (!receipt.journalBindingKey)
1492
+ return;
1493
+ observeRestoreSettlement({ kind: "source", evidence: { journalBindingKey: receipt.journalBindingKey,
1494
+ updateIds: [...receipt.sourceUpdateIds], kind: "completed" } }, ctx);
1495
+ for (const updateId of receipt.sourceUpdateIds)
1496
+ retireCompletedTemporaryThread(updateId, ctx, receipt.journalBindingKey);
1497
+ };
1498
+ const onQueueReceiptCommitted = (receipt, ctx) => {
1499
+ if (receipt.journalBindingKey)
1500
+ observeRestoreSettlement({ kind: "source", evidence: { journalBindingKey: receipt.journalBindingKey,
1501
+ updateIds: [...receipt.sourceUpdateIds], kind: "queued", receiptId: receipt.receiptId, queueKind: receipt.queueKind } }, ctx);
1502
+ };
1503
+ // Worker-confirmed source completion is the only evidence for a Forwarded input. It records a durable group fact and
1504
+ // starts the same delayed, fully rechecked cleanup as the last Cancel; deletion itself never happens here.
1505
+ const completedTemporaryInputIds = new Map();
1506
+ const retireCompletedTemporaryThread = (updateId, ctx, journalBindingKey) => {
1507
+ const cap = captureTemporaryThreadAuthority(ctx);
1508
+ if (!cap || !deps.runWorkspaceOperation || cap.journalBindingKey !== journalBindingKey)
1509
+ return;
1510
+ const store = cap.store;
1511
+ const matches = (entry) => entry.source.journalBindingKey === journalBindingKey && entry.source.updateId === updateId;
1512
+ const inGroup = (entry) => Threads.getTelegramTemporaryThreadInputs(entry).some(input => input.journalBindingKey === journalBindingKey && input.updateIds.includes(updateId));
1513
+ let restoreOwned = false;
1514
+ try {
1515
+ if (!store.listTemporaryThreads().some(entry => matches(entry) || inGroup(entry)))
1516
+ return;
1517
+ restoreOwned = store.list().some(intent => intent.request.source.journalBindingKey === journalBindingKey &&
1518
+ intent.request.source.updateIds.includes(updateId));
1519
+ }
1520
+ catch (error) {
1521
+ deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-retire" });
1522
+ return;
1523
+ }
1524
+ const task = Promise.resolve().then(() => deps.runWorkspaceOperation({ operationId: `temporary-thread-retire-${randomBytes(16).toString("hex")}`,
1525
+ operationKind: "workspace.temporary-thread", scopes: [{ kind: "profile" }] }, async () => {
1526
+ if (!cap.isCurrent())
1527
+ return;
1528
+ let entry = store.listTemporaryThreads().find(restoreOwned ? matches : inGroup);
1529
+ if (!entry)
1530
+ return;
1531
+ if (restoreOwned) {
1532
+ // A Restore keeps its own lifecycle: only its source entry may be released, never by Forward bookkeeping.
1533
+ if (entry.target && hasOtherTemporaryThreadReroute(entry.target, entry))
1534
+ return;
1535
+ entry = cap.adopt(entry);
1536
+ if (entry && cap.isCurrent())
1537
+ store.retireTemporaryThread(entry, cap.authority);
1538
+ return;
1539
+ }
1540
+ const group = Threads.getTelegramTemporaryThreadInputs(entry).find(input => input.journalBindingKey === journalBindingKey && input.updateIds.includes(updateId));
1541
+ const target = entry.target;
1542
+ if (!group || entry.phase !== "created" || !target)
1543
+ return;
1544
+ if (entry.cancelledInputs?.some(input => isDeepStrictEqual(input, group)) ||
1545
+ entry.completedInputs?.some(input => isDeepStrictEqual(input, group)))
1546
+ return;
1547
+ // Process-local accumulation only: a restart forgets partial groups and the tab stays protected.
1548
+ const seen = completedTemporaryInputIds.get(entry.token) ?? new Set();
1549
+ seen.add(updateId);
1550
+ completedTemporaryInputIds.set(entry.token, seen);
1551
+ if (!group.updateIds.every(id => seen.has(id)))
1552
+ return;
1553
+ entry = cap.adopt(entry);
1554
+ if (!entry || !cap.isCurrent() || !store.recordTemporaryThreadInputCompletion(entry, group, cap.authority) || !cap.isCurrent())
1555
+ return;
1556
+ scheduleTemporaryThreadCleanup(target, ctx);
1557
+ return true;
1558
+ })).then(published => {
1559
+ // A sibling may complete after Restore already settled. Reinspect only after its terminal fact publishes and admission releases.
1560
+ if (published && cap.isCurrent())
1561
+ observeRestoreSettlement({ kind: "source",
1562
+ evidence: { journalBindingKey, updateIds: [updateId], kind: "completed" } }, ctx);
1563
+ }).catch(error => { deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-retire" }); });
1564
+ restoreSettlementTasks.add(task);
1565
+ void task.finally(() => restoreSettlementTasks.delete(task));
1566
+ };
1567
+ const onUpdateCompleted = (updateId, ctx, journalBindingKey) => {
1568
+ if (!journalBindingKey)
1569
+ return;
1570
+ observeRestoreSettlement({ kind: "source", evidence: { journalBindingKey, updateIds: [updateId], kind: "completed" } }, ctx);
1571
+ retireCompletedTemporaryThread(updateId, ctx, journalBindingKey);
1572
+ };
1573
+ const restoreWorkspace = deps.workspaceRestoreRecipient ? async (input) => {
1574
+ const ports = deps.workspaceRestoreRecipient;
1575
+ const threads = deps.threadStore;
1576
+ if (!threads || !deps.getWorkspaceRestoreStore || !deps.getSessionGeneration ||
1577
+ !deps.hasWorkspaceRestoreAuthority?.() || !input.isCurrent() || input.target.threadId === undefined)
1578
+ return "protected";
1579
+ const instanceId = deps.getCurrentInstanceId?.(), epoch = deps.getCurrentLeaderEpoch?.();
1580
+ const operatorUserId = deps.configStore.getAllowedUserId(), scope = deps.getAdmissionScope?.();
1581
+ const journalBindingKey = deps.getAdmissionJournalBinding?.(), generation = deps.getSessionGeneration();
1582
+ const sourceIds = Updates.collectTelegramAdmissionSourceUpdateIds(input.messages);
1583
+ if (!instanceId || epoch === undefined || operatorUserId === undefined || !scope || !journalBindingKey || !sourceIds.length)
1584
+ return "protected";
1585
+ const current = () => input.isCurrent() && deps.hasWorkspaceRestoreAuthority?.() === true &&
1586
+ deps.isContextActive?.(input.ctx) !== false && deps.getSessionGeneration?.() === generation &&
1587
+ deps.getCurrentInstanceId?.() === instanceId && deps.getCurrentLeaderEpoch?.() === epoch &&
1588
+ deps.getAdmissionScope?.() === scope && deps.getAdmissionJournalBinding?.() === journalBindingKey &&
1589
+ deps.configStore.getAllowedUserId() === operatorUserId;
1590
+ await threads.load();
1591
+ if (!current())
1592
+ return "protected";
1593
+ const store = deps.getWorkspaceRestoreStore();
1594
+ if (!store)
1595
+ return "protected";
1596
+ const retained = store.list().find(value => value.request.operationId === input.operationId);
1597
+ const bindings = threads.listWorkspaceBindings().filter(value => isDeepStrictEqual(value.target, input.record.target));
1598
+ const binding = retained?.request.binding ?? (bindings.length === 1 ? bindings[0] : undefined);
1599
+ if (!binding || !binding.sessionId || !binding.slot)
1600
+ return "protected";
1601
+ const { sessionId, slot } = binding;
1602
+ const request = { operationId: input.operationId,
1603
+ binding, owner: structuredClone(input.record), target: { chatId: input.target.chatId, threadId: input.target.threadId },
1604
+ source: { journalBindingKey, updateIds: sourceIds } };
1605
+ if (retained && !isDeepStrictEqual(retained.request, request))
1606
+ return "protected";
1607
+ const getRecipient = (snapshot) => {
1608
+ if (!current())
1609
+ return undefined;
1610
+ const liveBindings = (snapshot ? snapshot.workspaceBindings ?? [] : threads.listWorkspaceBindings())
1611
+ .filter(value => value.bindingKey === binding.bindingKey);
1612
+ const live = liveBindings[0];
1613
+ if (liveBindings.length !== 1 || live?.sessionId !== binding.sessionId || live.cwd !== binding.cwd || live.slot !== binding.slot ||
1614
+ live.inactiveSinceMs !== undefined || (!isDeepStrictEqual(live.target, binding.target) && !isDeepStrictEqual(live.target, request.target)))
1615
+ return undefined;
1616
+ const records = (snapshot ? snapshot.threads : threads.list()).filter(value => value.status === "active" &&
1617
+ (value.slot === binding.slot || isDeepStrictEqual(value.target, live.target)));
1618
+ const record = records[0];
1619
+ if (records.length !== 1 || record?.slot !== binding.slot || !isDeepStrictEqual(record.target, live.target))
1620
+ return undefined;
1621
+ if (record.owner?.kind === "leader" && record.instanceId === instanceId) {
1622
+ const cwd = ports.getCwd(input.ctx);
1623
+ if (ports.getSessionId(input.ctx) !== sessionId || !cwd || WorkspaceIdentity.normalizeTelegramWorkspacePath(cwd) !== binding.cwd ||
1624
+ !deps.setCurrentLeaderIdentity)
1625
+ return undefined;
1626
+ return { kind: "leader", instanceId, sessionId, generation: String(generation) };
1627
+ }
1628
+ if (record.owner?.kind !== "manual-follower" || !record.instanceId)
1629
+ return undefined;
1630
+ const follower = ports.followerRegistry.get(record.instanceId);
1631
+ if (!follower?.registrationGeneration || !follower.cwd || follower.sessionId !== sessionId ||
1632
+ WorkspaceIdentity.normalizeTelegramWorkspacePath(follower.cwd) !== binding.cwd || follower.slot !== slot ||
1633
+ !Bus.hasTelegramBusCapability(follower.protocol, Bus.TELEGRAM_BUS_CAPABILITY_WORKSPACE_RESTORE))
1634
+ return undefined;
1635
+ return { kind: "follower", instanceId: follower.instanceId, sessionId, generation: follower.registrationGeneration };
1636
+ };
1637
+ const hasCommittedTarget = () => threads.listWorkspaceBindings().some(value => value.bindingKey === binding.bindingKey && isDeepStrictEqual(value.target, request.target));
1638
+ const authority = { executor: { instanceId, leaderEpoch: String(epoch) }, operatorUserId, isCurrent: current };
1639
+ const ready = await advanceTelegramWorkspaceRestore({ request, authority, restoreStore: store, getRecipient,
1640
+ async runRecipient(action) {
1641
+ const recipient = getRecipient();
1642
+ if (!recipient || !action.isCurrent() || !hasCommittedTarget())
1643
+ return undefined;
1644
+ if (recipient.kind === "follower") {
1645
+ const follower = ports.followerRegistry.get(recipient.instanceId);
1646
+ const observation = await ports.runFollower({ operationId: request.operationId, instanceId: recipient.instanceId,
1647
+ sessionId: recipient.sessionId, slot: binding.slot, target: request.target, oldTarget: binding.target,
1648
+ mode: action.mode, isCurrent: action.isCurrent });
1649
+ if (!action.isCurrent() || !hasCommittedTarget())
1650
+ return undefined;
1651
+ if (!follower || !observation?.ready)
1652
+ return observation;
1653
+ if (observation.operationId !== request.operationId || !isDeepStrictEqual(observation.recipient, recipient) ||
1654
+ !isDeepStrictEqual(observation.target, request.target) || observation.slot !== binding.slot)
1655
+ return undefined;
1656
+ const actual = ports.followerRegistry.get(recipient.instanceId);
1657
+ if (!actual || actual.registrationGeneration !== follower.registrationGeneration || actual.sessionId !== follower.sessionId ||
1658
+ actual.cwd !== follower.cwd || actual.slot !== follower.slot || actual.profileKey !== follower.profileKey ||
1659
+ actual.busSocketPath !== follower.busSocketPath || !isDeepStrictEqual(actual.target, follower.target) ||
1660
+ !isDeepStrictEqual(actual.protocol, follower.protocol))
1661
+ return undefined;
1662
+ threads.commitWorkspaceRestoreRegistration({ target: request.target, bindingKey: binding.bindingKey, slot: binding.slot }, () => {
1663
+ if (!action.isCurrent())
1664
+ throw new Error("Workspace Restore recipient authority changed.");
1665
+ ports.followerRegistry.register({ ...actual, target: request.target, connectedAtMs: actual.connectedAtMs });
1666
+ });
1667
+ return observation;
1668
+ }
1669
+ let observation;
1670
+ threads.withWorkspaceRestoreSnapshot(action.intent, snapshot => {
1671
+ if (!action.isCurrent() || !isDeepStrictEqual(getRecipient(snapshot), recipient))
1672
+ return;
1673
+ const live = snapshot.workspaceBindings?.find(value => value.bindingKey === binding.bindingKey);
1674
+ if (!live || !isDeepStrictEqual(live.target, request.target))
1675
+ return;
1676
+ const local = ports.getLeaderIdentity();
1677
+ if (!local || local.slot !== binding.slot || (!isDeepStrictEqual(local.target, binding.target) &&
1678
+ !isDeepStrictEqual(local.target, request.target)))
1679
+ return;
1680
+ if (action.mode === "apply" && !isDeepStrictEqual(local.target, request.target)) {
1681
+ if (action.intent.phase !== "recipient-issued" || !action.isCurrent())
1682
+ return;
1683
+ deps.setCurrentLeaderIdentity({ target: request.target, slot: binding.slot, threadName: request.owner.threadName });
1684
+ }
1685
+ if (!action.isCurrent() || !isDeepStrictEqual(getRecipient(snapshot), recipient))
1686
+ return;
1687
+ const observed = ports.getLeaderIdentity();
1688
+ if (!observed || observed.slot !== binding.slot)
1689
+ return;
1690
+ observation = { operationId: request.operationId, recipient, target: observed.target,
1691
+ slot: binding.slot, ready: isDeepStrictEqual(observed.target, request.target) };
1692
+ });
1693
+ return observation;
1694
+ } }).catch(error => {
1695
+ deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-recipient" });
1696
+ return undefined;
1697
+ });
1698
+ if (!ready || !current() || ready.routing)
1699
+ return "protected";
1700
+ const recipient = ready.readyRecipient ?? ready.recipient;
1701
+ const recipientCurrent = () => {
1702
+ if (!current() || !recipient || !hasCommittedTarget() || !isDeepStrictEqual(getRecipient(), recipient))
1703
+ return false;
1704
+ const observed = recipient.kind === "leader" ? ports.getLeaderIdentity() : ports.followerRegistry.get(recipient.instanceId);
1705
+ return observed?.slot === slot && isDeepStrictEqual(observed.target, request.target);
1706
+ };
1707
+ if (!recipient || !recipientCurrent())
1708
+ return "protected";
1709
+ if (deps.callApi) {
1710
+ try {
1711
+ await deps.callApi("editForumTopic", { chat_id: request.target.chatId, message_thread_id: request.target.threadId,
1712
+ name: deps.getDisplayTitle?.(request.target) ?? ThreadNaming.getTelegramTopicTitleForThreadName(getRestoredThreadName(request.owner, slot), slot) });
1713
+ }
1714
+ catch (error) {
1715
+ deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-title" });
1716
+ }
1717
+ }
1718
+ if (!recipientCurrent() || !store.issueRouting(ready, { ...authority, isCurrent: recipientCurrent }))
1719
+ return "protected";
1720
+ const recordAcceptance = (message, delivery) => {
1721
+ if (!recipientCurrent() || recipient.kind !== (delivery ? "follower" : "leader"))
1722
+ throw new Error("Workspace Restore acceptance authority changed.");
1723
+ const source = Updates.inspectTelegramDeferredSource(message);
1724
+ const follower = ports.followerRegistry.get(recipient.instanceId);
1725
+ const ownership = deps.getTargetOwnership?.(request.target);
1726
+ if (!source || source.journalBindingKey !== journalBindingKey || !request.source.updateIds.includes(source.updateId) ||
1727
+ delivery && (!follower || ownership?.instanceId !== recipient.instanceId || ownership.ownerGeneration !== recipient.generation ||
1728
+ !ownership.recipientBindingKey || delivery.sourceUpdateId !== source.updateId ||
1729
+ delivery.recipientBindingKey !== ownership.recipientBindingKey || delivery.deliveryId !== Bus.createTelegramBusFollowerDeliveryIdentity({
1730
+ kind: "leader.forwardMessage", recipientBindingKey: ownership.recipientBindingKey, sourceUpdateId: source.updateId
1731
+ }).deliveryId) ||
1732
+ !recipientCurrent())
1733
+ throw new Error("Workspace Restore acceptance source or delivery changed.");
1734
+ const evidence = { ...source, recipient,
1735
+ ...(delivery ? { kind: "forwarded", deliveryId: delivery.deliveryId, recipientBindingKey: delivery.recipientBindingKey }
1736
+ : { kind: "completed" }) };
1737
+ const expected = store.list().find(value => value.request.operationId === request.operationId);
1738
+ if (!expected || !isDeepStrictEqual(expected.request, request) || !expected.routing ||
1739
+ !recipientCurrent())
1740
+ throw new Error("Workspace Restore dispatch proof is unavailable.");
1741
+ let accepted;
1742
+ let failure;
1743
+ const acceptanceAuthority = { ...authority, isCurrent: recipientCurrent };
1744
+ try {
1745
+ accepted = store.recordSourceAcceptance(expected, evidence, acceptanceAuthority);
1746
+ }
1747
+ catch (error) {
1748
+ failure = { error };
1749
+ }
1750
+ if (!recipientCurrent())
1751
+ throw new Error("Workspace Restore acceptance authority changed.");
1752
+ // A lost rename reply observes only the exact full retained proof; it never resends the accepted message.
1753
+ if (!accepted) {
1754
+ const observed = store.list().find(value => value.request.operationId === request.operationId);
1755
+ if (observed && isDeepStrictEqual(observed.request, request) && isDeepStrictEqual(observed.executor, authority.executor) &&
1756
+ observed.operatorUserId === operatorUserId && observed.routing?.acceptances?.some(value => isDeepStrictEqual(value, evidence)))
1757
+ accepted = observed;
1758
+ }
1759
+ if (!accepted || !isDeepStrictEqual(accepted.request, request) || !isDeepStrictEqual(accepted.executor, authority.executor) ||
1760
+ accepted.operatorUserId !== operatorUserId || !accepted.routing?.acceptances?.some(value => isDeepStrictEqual(value, evidence)) ||
1761
+ !recipientCurrent())
1762
+ throw failure?.error ?? new Error("Workspace Restore acceptance was not published.");
1763
+ return { ...source, completionSha256: Threads.getTelegramWorkspaceRestoreSourceCompletionSha256(accepted, evidence) };
1764
+ };
1765
+ try {
1766
+ await input.dispatch(recipient, recipientCurrent, recordAcceptance, message => recordAcceptance(message));
1767
+ }
1768
+ catch (error) {
1769
+ deps.recordRuntimeEvent?.("telegram", error, { phase: "workspace-restore-dispatch" });
1770
+ }
1771
+ // Positive worker ACKs own settlement and cleanup under a subsequent admission.
1772
+ return "protected";
1773
+ } : undefined;
701
1774
  const retryPendingRerouteCleanup = async (cleanup, assertExecutionCurrent) => {
702
1775
  if (cleanup.kind === "unbound") {
703
- return closeReroutedUnboundTopic(cleanup.target, cleanup.messageId, assertExecutionCurrent);
1776
+ return closeReroutedUnboundTopic(cleanup.target, cleanup.messageId, assertExecutionCurrent, cleanup.temporaryThread);
704
1777
  }
705
1778
  if (cleanup.kind === "previous-leader") {
706
1779
  return closePreviousLeaderThread(cleanup.target, assertExecutionCurrent);
@@ -711,60 +1784,774 @@ export function createTelegramInboundRouteRuntime(deps) {
711
1784
  const dispatchPendingRerouteMessages = async (pending, messages, ctx) => {
712
1785
  if (pending.dispatchKind === "command" && dispatchReroutedCommandMessages) {
713
1786
  await dispatchReroutedCommandMessages(messages, ctx);
714
- if (pending.sourceTarget.threadId === undefined) {
715
- for (const message of messages)
716
- Updates.reportTelegramUpdateCompleted(message);
1787
+ // Every command chooser deferred its exact original, whether the tab came from Telegram or the bridge.
1788
+ // A queued command retains its separately reported receipt; this cannot settle accepted queue custody.
1789
+ for (const message of messages)
1790
+ Updates.reportTelegramUpdateCompleted(message);
1791
+ return;
1792
+ }
1793
+ await promptEnqueue(messages, ctx);
1794
+ };
1795
+ const finalizePendingReroute = async (rerouteId, pending, query, successMessage, assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query)) => {
1796
+ assertExecutionCurrent();
1797
+ const dismissed = await dismissRerouteChooserMessage(query, assertExecutionCurrent);
1798
+ assertExecutionCurrent();
1799
+ if (dismissed) {
1800
+ removePendingReroute(rerouteId);
1801
+ await deps.answerCallbackQuery(query.id, successMessage);
1802
+ return;
1803
+ }
1804
+ pending.finalizeMessage = successMessage;
1805
+ await deps.answerCallbackQuery(query.id, `${successMessage} Chooser cleanup is still pending. Try again.`);
1806
+ };
1807
+ const isTemporaryReroute = (pending) => !!pending.temporaryThread || pending.temporaryMembership === true || isTemporaryTabTarget(pending.sourceTarget);
1808
+ const isTemporaryThreadForwardIssued = (pending) => {
1809
+ try {
1810
+ const entry = pending.temporaryThread ?? findCreatedTemporaryThread(pending.sourceTarget);
1811
+ const store = deps.getWorkspaceRestoreStore?.();
1812
+ if ((entry || pending.temporaryMembership) && !store)
1813
+ return true;
1814
+ const stored = entry && store?.listTemporaryThreads().find(value => value.token === entry.token);
1815
+ const updateIds = Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages);
1816
+ return !!stored?.forwardedInputs?.some(input => input.updateIds.some(id => updateIds.includes(id)));
1817
+ }
1818
+ catch {
1819
+ return true;
1820
+ }
1821
+ };
1822
+ /** Publishes the durable one-time Forward fact for this chooser's exact group; false means nothing may be sent. */
1823
+ const issueTemporaryThreadForward = (pending, ctx) => {
1824
+ const entry = pending.temporaryThread ?? findCreatedTemporaryThread(pending.sourceTarget);
1825
+ const cap = captureTemporaryThreadAuthority(ctx);
1826
+ if (!entry || !cap)
1827
+ return false;
1828
+ const group = { journalBindingKey: entry.source.journalBindingKey, updateIds: Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages) };
1829
+ try {
1830
+ const current = cap.store.listTemporaryThreads().find(value => value.token === entry.token);
1831
+ const adopted = current && cap.isCurrent() ? cap.adopt(current) : undefined;
1832
+ return !!adopted && cap.isCurrent() && !!cap.store.recordTemporaryThreadForwardIssued(adopted, group, cap.authority) && cap.isCurrent();
1833
+ }
1834
+ catch (error) {
1835
+ deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-forward-issue" });
1836
+ return false;
1837
+ }
1838
+ };
1839
+ const forwardPendingRerouteMessages = async (pending, instanceId, threadId, ctx, assertExecutionCurrent, recordForwardAcceptance) => {
1840
+ assertExecutionCurrent?.();
1841
+ const forwardMessage = deps.foreignOwnedUpdateForwarder?.forwardMessage;
1842
+ if (!forwardMessage || pending.foreignForwardIssued)
1843
+ return false;
1844
+ const target = { ...pending.sourceTarget, threadId };
1845
+ const liveOwnership = deps.getTargetOwnership?.(target);
1846
+ if (!liveOwnership || liveOwnership.instanceId !== instanceId ||
1847
+ !liveOwnership.ownerGeneration || !liveOwnership.recipientBindingKey)
1848
+ return false;
1849
+ const ownership = structuredClone(liveOwnership);
1850
+ const messages = cloneTelegramMessagesForThread(pending.messages, threadId);
1851
+ // Issuance is durable before the RPC: an unacknowledged temporary-tab Forward never becomes another dispatch grant,
1852
+ // even after restart. Any refusal or publication failure sends nothing and keeps the input held.
1853
+ if (!recordForwardAcceptance && isTemporaryReroute(pending) && !issueTemporaryThreadForward(pending, ctx)) {
1854
+ if (isTemporaryThreadForwardIssued(pending))
1855
+ pending.foreignForwardIssued = true;
1856
+ return false;
1857
+ }
1858
+ if (!recordForwardAcceptance && isTemporaryReroute(pending))
1859
+ pending.foreignForwardIssued = true;
1860
+ const outcomes = await Promise.allSettled(messages.map((message) => forwardMessage({
1861
+ message,
1862
+ ownership,
1863
+ ctx,
1864
+ })));
1865
+ assertExecutionCurrent?.();
1866
+ if (!isDeepStrictEqual(deps.getTargetOwnership?.(target), ownership))
1867
+ return false;
1868
+ pending.messages = pending.messages.filter((_, index) => {
1869
+ const outcome = outcomes[index];
1870
+ if (outcome?.status === "fulfilled" &&
1871
+ outcome.value.status === "accepted") {
1872
+ // Restore publishes acceptance before reporting completion; cleanup still waits for journal disposition.
1873
+ const expectedSource = recordForwardAcceptance?.(pending.messages[index], outcome.value.delivery);
1874
+ assertExecutionCurrent?.();
1875
+ Updates.reportTelegramUpdateCompleted(pending.messages[index], expectedSource);
1876
+ return false;
1877
+ }
1878
+ if (outcome?.status === "rejected") {
1879
+ deps.recordRuntimeEvent?.("bus", outcome.reason, {
1880
+ phase: "reroute-foreign-forward",
1881
+ instanceId,
1882
+ threadId,
1883
+ messageIndex: index,
1884
+ });
1885
+ }
1886
+ return true;
1887
+ });
1888
+ if (pending.messages.length === 0)
1889
+ pending.foreignForwardIssued = false;
1890
+ return pending.messages.length === 0;
1891
+ };
1892
+ const temporaryCleanupTimers = new Map();
1893
+ const cancelTemporaryThreadCleanup = (token) => {
1894
+ const scheduled = temporaryCleanupTimers.get(token);
1895
+ if (!scheduled)
1896
+ return;
1897
+ clearTimeout(scheduled.timer);
1898
+ temporaryCleanupTimers.delete(token);
1899
+ scheduled.settle();
1900
+ };
1901
+ /**
1902
+ * One cleanup attempt after the quiet period. Cancellation facts are only a precondition: fresh authority, proof,
1903
+ * absence of chooser and journal custody, and the ordinary protection checks must all hold again. The attempt is
1904
+ * durably issued once; a skipped or unknown removal keeps the entry protecting the tab across restart.
1905
+ */
1906
+ const runTemporaryThreadCleanup = async (token, cap, reconcileExpiry) => {
1907
+ const store = cap.store;
1908
+ if ((!deps.inspectRestoreSourceAbandonment && !deps.inspectRoutingInputGroupExpiry) || !deps.runWorkspaceOperation || !deps.callApi || !cap.isCurrent())
1909
+ return;
1910
+ await deps.runWorkspaceOperation({ operationId: `temporary-thread-cleanup-${randomBytes(16).toString("hex")}`,
1911
+ operationKind: "workspace.temporary-thread", scopes: [{ kind: "profile" }] }, async () => {
1912
+ if (!cap.isCurrent())
1913
+ return;
1914
+ let entry = store.listTemporaryThreads().find(value => value.token === token && value.phase === "created" && !!value.target);
1915
+ const target = entry?.target;
1916
+ if (!entry || !target || entry.cleanupIssued || entry.operatorUserId !== cap.operatorUserId ||
1917
+ entry.source.journalBindingKey !== cap.journalBindingKey)
1918
+ return;
1919
+ const owner = `telegram-owner:${cap.operatorUserId}`;
1920
+ if (reconcileExpiry && deps.inspectRoutingInputGroupExpiry) {
1921
+ for (const group of Threads.getTelegramTemporaryThreadInputs(entry)) {
1922
+ if ([...(entry.cancelledInputs ?? []), ...(entry.completedInputs ?? [])].some(value => isDeepStrictEqual(value, group)))
1923
+ continue;
1924
+ const inspect = (id) => deps.inspectRoutingInputGroupExpiry(group)?.find(value => value.updateId === id);
1925
+ if (!group.updateIds.every(id => inspect(id)?.operatorAuthorityId === owner))
1926
+ continue;
1927
+ entry = cap.adopt(entry);
1928
+ if (!entry || !cap.isCurrent())
1929
+ return;
1930
+ let recorded, failure;
1931
+ try {
1932
+ recorded = store.recordTemporaryThreadInputExpiry(entry, group, cap.authority, inspect);
1933
+ }
1934
+ catch (error) {
1935
+ failure = error;
1936
+ }
1937
+ if (!cap.isCurrent())
1938
+ return;
1939
+ const retained = store.listTemporaryThreads().find(value => value.token === token);
1940
+ // A lost publication ACK may already have recorded the group or retired a bound tab's temporary frame.
1941
+ if (!retained)
1942
+ return;
1943
+ if (!recorded && !retained.cancelledInputs?.some(value => isDeepStrictEqual(value, group)))
1944
+ throw failure ?? new Error("Chooser expiry metadata was not confirmed.");
1945
+ entry = retained;
1946
+ }
1947
+ }
1948
+ if (!Threads.isTelegramTemporaryThreadFullyResolved(entry) || hasPendingRerouteForTarget(target))
1949
+ return;
1950
+ const cancelled = entry.cancelledInputs ?? [];
1951
+ const cancelledCurrent = () => cancelled.every(input => {
1952
+ const expiry = deps.inspectRoutingInputGroupExpiry?.(input);
1953
+ return input.updateIds.every(updateId => {
1954
+ const evidence = deps.inspectRestoreSourceAbandonment?.(updateId, input.journalBindingKey) ?? expiry?.find(value => value.updateId === updateId);
1955
+ return evidence?.journalBindingKey === input.journalBindingKey && evidence.updateId === updateId && evidence.operatorAuthorityId === owner;
1956
+ });
1957
+ });
1958
+ if (!cancelledCurrent())
1959
+ return;
1960
+ entry = cap.adopt(entry);
1961
+ if (!entry || !cap.isCurrent() || isRerouteTargetProtected(target, undefined, entry))
1962
+ return;
1963
+ const issued = store.issueTemporaryThreadCleanup(entry, cap.authority);
1964
+ if (!issued || !cap.isCurrent())
1965
+ return;
1966
+ entry = issued.entry;
1967
+ const issuedEntry = entry;
1968
+ const assertCurrent = () => {
1969
+ if (!cap.isCurrent() || !cancelledCurrent() || !store.isTemporaryThreadCleanupCurrent(issuedEntry, cap.authority))
1970
+ throw new Error("Temporary Thread cleanup authority or exact grant evidence changed.");
1971
+ };
1972
+ if (!await closeReroutedUnboundTopic(target, undefined, assertCurrent, entry) || !cap.isCurrent())
1973
+ return;
1974
+ store.retireTemporaryThread(entry, cap.authority);
1975
+ });
1976
+ };
1977
+ // Reuse the one timer per tab for body-free metadata retries. Future retry timers do not join settlement waits;
1978
+ // only their running attempt does. No retry is licensed once a destructive grant may have been issued.
1979
+ const queueTemporaryThreadCleanup = (token, cap, delay, reconcileExpiry, retry = false) => {
1980
+ cancelTemporaryThreadCleanup(token);
1981
+ let settle = () => undefined;
1982
+ const task = new Promise(resolve => { settle = resolve; });
1983
+ const timer = setTimeout(() => {
1984
+ if (temporaryCleanupTimers.get(token)?.timer !== timer)
1985
+ return;
1986
+ temporaryCleanupTimers.delete(token);
1987
+ if (retry)
1988
+ restoreSettlementTasks.add(task);
1989
+ runTemporaryThreadCleanup(token, cap, reconcileExpiry)
1990
+ .catch(error => {
1991
+ deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup" });
1992
+ if (!reconcileExpiry || !cap.isCurrent())
1993
+ return;
1994
+ try {
1995
+ const retained = cap.store.listTemporaryThreads().find(value => value.token === token);
1996
+ if (retained && !retained.cleanupIssued && cap.isCurrent())
1997
+ queueTemporaryThreadCleanup(token, cap, 60_000, true, true);
1998
+ }
1999
+ catch { /* Unknown grant state never licenses a retry. */ }
2000
+ })
2001
+ .finally(settle);
2002
+ }, delay);
2003
+ timer.unref?.();
2004
+ temporaryCleanupTimers.set(token, { timer, settle });
2005
+ if (!retry)
2006
+ restoreSettlementTasks.add(task);
2007
+ void task.finally(() => restoreSettlementTasks.delete(task));
2008
+ };
2009
+ /** Starts the quiet period for resolved groups or body-free expiry reconciliation; fresh input/authority revokes it. */
2010
+ const scheduleTemporaryThreadCleanup = (target, ctx, reconcileExpiry = false) => {
2011
+ const cap = captureTemporaryThreadAuthority(ctx);
2012
+ if (!cap || !deps.runWorkspaceOperation || target.threadId === undefined || hasPendingRerouteForTarget(target))
2013
+ return;
2014
+ let entry;
2015
+ try {
2016
+ entry = cap.store.listTemporaryThreads().find(value => value.phase === "created" && isDeepStrictEqual(value.target, target));
2017
+ }
2018
+ catch (error) {
2019
+ deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup-schedule" });
2020
+ return;
2021
+ }
2022
+ if (!entry || entry.cleanupIssued || entry.operatorUserId !== cap.operatorUserId || entry.source.journalBindingKey !== cap.journalBindingKey)
2023
+ return;
2024
+ if (!reconcileExpiry && !Threads.isTelegramTemporaryThreadFullyResolved(entry)) {
2025
+ try {
2026
+ reconcileExpiry = Threads.getTelegramTemporaryThreadInputs(entry).some(group => !!deps.inspectRoutingInputGroupExpiry?.(group));
2027
+ }
2028
+ catch (error) {
2029
+ deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup-schedule" });
2030
+ return;
2031
+ }
2032
+ }
2033
+ if (!reconcileExpiry && !Threads.isTelegramTemporaryThreadFullyResolved(entry))
2034
+ return;
2035
+ const requested = deps.temporaryThreadCleanupDelayMs;
2036
+ const delay = typeof requested === "number" && Number.isFinite(requested) ? Math.max(0, requested) : 1000;
2037
+ queueTemporaryThreadCleanup(entry.token, cap, delay, reconcileExpiry);
2038
+ };
2039
+ const recordCancelledTemporaryThreadInput = (pending, ctx, isCurrent) => {
2040
+ const store = deps.getWorkspaceRestoreStore?.(), target = pending.sourceTarget;
2041
+ if (!store || target.threadId === undefined)
2042
+ return !pending.temporaryThread;
2043
+ let entry = store.listTemporaryThreads().find(value => value.phase === "created" && isDeepStrictEqual(value.target, target));
2044
+ if (!entry)
2045
+ return !pending.temporaryThread;
2046
+ const cap = captureTemporaryThreadAuthority(ctx, isCurrent), inspect = deps.inspectRestoreSourceAbandonment;
2047
+ if (!cap || !inspect || cap.operatorUserId !== entry.operatorUserId || cap.journalBindingKey !== entry.source.journalBindingKey)
2048
+ return false;
2049
+ const { operatorUserId, journalBindingKey } = cap, current = cap.isCurrent;
2050
+ const input = { journalBindingKey, updateIds: Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages) };
2051
+ if (!current())
2052
+ return false;
2053
+ entry = cap.adopt(entry);
2054
+ if (!entry || !current())
2055
+ return false;
2056
+ const recorded = store.recordTemporaryThreadInputCancellation(entry, input, cap.authority, updateId => inspect(updateId, journalBindingKey));
2057
+ if (!recorded || !current() || !recorded.cancelledInputs?.some(value => isDeepStrictEqual(value, input)))
2058
+ return false;
2059
+ const observed = store.listTemporaryThreads().find(value => isDeepStrictEqual(value, recorded));
2060
+ return !!observed && input.updateIds.every(updateId => {
2061
+ const evidence = inspect(updateId, journalBindingKey);
2062
+ return evidence?.journalBindingKey === journalBindingKey && evidence.updateId === updateId &&
2063
+ evidence.operatorAuthorityId === `telegram-owner:${operatorUserId}`;
2064
+ }) && current();
2065
+ };
2066
+ const retireCancellationChooser = async (id, pending, isCurrent) => {
2067
+ if (!pending.abandonment?.result || !isCurrent() || !deps.editInteractiveMessage || pending.chooserMessageId === undefined)
2068
+ return false;
2069
+ await deps.editInteractiveMessage(pending.sourceTarget.chatId, pending.chooserMessageId, "<b>⛔️ Routing cancelled.</b>", "html", { inline_keyboard: [] });
2070
+ if (!isCurrent() || pendingUnboundReroutes.get(id) !== pending)
2071
+ return false;
2072
+ removePendingReroute(id);
2073
+ return true;
2074
+ };
2075
+ const isReviewText = (message) => {
2076
+ if (!message || message.chat?.type !== "private" || !Number.isSafeInteger(message.chat.id) ||
2077
+ !Number.isSafeInteger(message.from?.id) || message.from?.is_bot !== false ||
2078
+ !Number.isSafeInteger(message.message_id) || message.message_id <= 0 ||
2079
+ !Number.isSafeInteger(message.message_thread_id) || message.message_thread_id <= 0 ||
2080
+ typeof message.text !== "string" || !message.text.trim() || message.text.trim().startsWith("/"))
2081
+ return false;
2082
+ const fields = message;
2083
+ 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);
2084
+ };
2085
+ const reviewMessage = (entry) => {
2086
+ const message = entry.update.message;
2087
+ if (entry.state !== "pending" || entry.preApprovalExcluded || entry.inputClaim || entry.inputProvenance ||
2088
+ entry.queueOwner || entry.queueReceiptId || entry.queueHandoff || entry.failure ||
2089
+ Object.keys(entry.update).some(key => key !== "update_id" && key !== "message") ||
2090
+ !isReviewText(message) || Reflect.has(message, "pi_telegram_source_update_id"))
2091
+ return undefined;
2092
+ return message;
2093
+ };
2094
+ const needsHistoricalReview = (message) => {
2095
+ if (!deps.threadStore)
2096
+ throw new Error("Historical routing requires a current Thread snapshot.");
2097
+ if (deps.threadStore.getBotState().threadMode === "disabled")
2098
+ return true;
2099
+ return !getTelegramRoutableThreadRecords(deps.threadStore.list(), deps.getLiveThreadTargets?.()).some(record => record.target.chatId === message.chat.id && record.target.threadId === message.message_thread_id);
2100
+ };
2101
+ /** New-world restart: forget previous-instance Restore/temporary state, then one silent delete per positively disposable tab. */
2102
+ const forgetPreviousWorld = async (input, captureTransport) => {
2103
+ const result = { forgotten: 0, deleted: 0 };
2104
+ const { ctx, signal, journalBindingKey, isCurrent: preparedCurrent } = input;
2105
+ const transport = captureTransport ? captureTransport(ctx) : () => true;
2106
+ const cap = transport && captureTemporaryThreadAuthority(ctx, () => !signal.aborted && preparedCurrent() && transport() &&
2107
+ deps.hasWorkspaceRestoreAuthority?.() === true);
2108
+ const threads = deps.threadStore;
2109
+ if (!cap?.isCurrent() || cap.journalBindingKey !== journalBindingKey || !threads || !deps.runWorkspaceOperation)
2110
+ return result;
2111
+ try {
2112
+ await deps.runWorkspaceOperation({ operationId: `new-world-${randomBytes(16).toString("hex")}`,
2113
+ operationKind: "workspace.forget-previous-world", scopes: [{ kind: "profile" }] }, async () => {
2114
+ if (!cap.isCurrent())
2115
+ return;
2116
+ await threads.load();
2117
+ if (!cap.isCurrent())
2118
+ return;
2119
+ const self = cap.authority.executor.instanceId;
2120
+ const entries = cap.store.listTemporaryThreads();
2121
+ const preserved = entries.filter(entry => entry.operatorUserId === cap.operatorUserId && entry.phase === "created" &&
2122
+ cap.store.inspectTemporaryThreadTarget(entry, cap.authority)?.kind === "temporary" &&
2123
+ Threads.getTelegramTemporaryThreadInputs(entry).some(source => source.journalBindingKey === journalBindingKey &&
2124
+ source.updateIds.some(id => input.routingSourceIds?.includes(id)))).map(entry => entry.token);
2125
+ const disposable = entries.flatMap(entry => entry.operatorUserId === cap.operatorUserId && !preserved.includes(entry.token) &&
2126
+ entry.executor.instanceId !== self && entry.phase === "created" && entry.target &&
2127
+ cap.store.inspectTemporaryThreadTarget(entry, cap.authority)?.kind === "temporary" ? [entry] : []);
2128
+ const forgotten = cap.store.forgetPreviousWorld(cap.authority, preserved);
2129
+ if (!forgotten)
2130
+ return;
2131
+ result.forgotten = forgotten.operations.length + forgotten.temporaryThreads.length;
2132
+ for (const entry of disposable) {
2133
+ const target = entry.target;
2134
+ if (!cap.isCurrent() || !deps.callApi)
2135
+ break;
2136
+ await threads.load();
2137
+ const same = (value) => value?.chatId === target.chatId && value.threadId === target.threadId;
2138
+ if (!cap.isCurrent() || threads.listWorkspaceBindings().some(value => same(value.target)) ||
2139
+ threads.list().some(record => record.status === "active" && same(record.target)) ||
2140
+ cap.store.listTemporaryThreads().some(entry => same(entry.target)) ||
2141
+ cap.store.list().some(({ request }) => same(request.target) || same(request.binding.target)))
2142
+ continue;
2143
+ try {
2144
+ const required = [...new Set([cap.journalBindingKey, entry.source.journalBindingKey,
2145
+ ...Threads.getTelegramTemporaryThreadInputs(entry).map(input => input.journalBindingKey)])];
2146
+ if (deps.inspectTemporaryThreadSources?.(target, required)?.length !== 0 || !cap.isCurrent())
2147
+ continue;
2148
+ // Exactly one attempt; the entry is already forgotten, so failure or silence leaves the tab without retry.
2149
+ if (await deps.callApi("deleteForumTopic", { chat_id: target.chatId, message_thread_id: target.threadId }, { maxAttempts: 1, retrySafety: "non-idempotent" }) === true)
2150
+ result.deleted++;
2151
+ }
2152
+ catch (error) {
2153
+ deps.recordRuntimeEvent?.("routing", error, { phase: "new-world-tab-delete" });
2154
+ }
2155
+ }
2156
+ // Same-instance session replacement may have lost a metadata-only timer after the source was already spent.
2157
+ // Reconstruct from retained tab/source proof under fresh authority, never from a prompt body or an issued delete.
2158
+ for (const entry of cap.store.listTemporaryThreads()) {
2159
+ if (!cap.isCurrent())
2160
+ break;
2161
+ if (entry.phase !== "created" || !entry.target || entry.cleanupIssued || entry.operatorUserId !== cap.operatorUserId ||
2162
+ entry.source.journalBindingKey !== journalBindingKey)
2163
+ continue;
2164
+ if (Threads.getTelegramTemporaryThreadInputs(entry).some(group => !!deps.inspectRoutingInputGroupExpiry?.(group)))
2165
+ scheduleTemporaryThreadCleanup(entry.target, ctx, true);
2166
+ }
2167
+ });
2168
+ }
2169
+ catch (error) {
2170
+ deps.recordRuntimeEvent?.("routing", error, { phase: "new-world-forget" });
2171
+ }
2172
+ return result;
2173
+ };
2174
+ /** Historical temporary sources await the cold policy; warm bound/unknown sources never fall into ordinary dispatch. */
2175
+ const isRecordedTemporaryThreadInputHeld = (entry, ctx, signal, historical = false) => {
2176
+ const store = deps.getWorkspaceRestoreStore?.(), binding = deps.getAdmissionJournalBinding?.();
2177
+ if (!store || !binding)
2178
+ return false;
2179
+ const members = store.listTemporaryThreads().filter(temporary => Threads.getTelegramTemporaryThreadInputs(temporary).some(input => input.journalBindingKey === binding && input.updateIds.includes(entry.updateId)));
2180
+ const cap = captureTemporaryThreadAuthority(ctx, () => !signal.aborted);
2181
+ return members.some(temporary => {
2182
+ if (!cap?.isCurrent())
2183
+ return true;
2184
+ const observation = store.inspectTemporaryThreadTarget(temporary, cap.authority);
2185
+ return historical || !observation || observation.kind !== "temporary";
2186
+ });
2187
+ };
2188
+ const shouldHoldPendingInput = async (entry, ctx, signal) => {
2189
+ const binding = deps.getAdmissionJournalBinding?.();
2190
+ const current = () => !signal.aborted && deps.isContextActive?.(ctx) === true && deps.getAdmissionJournalBinding?.() === binding;
2191
+ if (!current())
2192
+ throw new Error("Temporary Thread source generation ended.");
2193
+ const held = isRecordedTemporaryThreadInputHeld(entry, ctx, signal);
2194
+ if (!current())
2195
+ throw new Error("Temporary Thread source authority changed.");
2196
+ return held;
2197
+ };
2198
+ const shouldReviewHistoricalInput = async (entry, ctx, signal) => {
2199
+ if (signal.aborted)
2200
+ throw new Error("Historical routing generation ended.");
2201
+ const message = entry.update.message;
2202
+ const unsupported = !reviewMessage(entry);
2203
+ const retainable = unsupported && entry.state === "pending" && !entry.preApprovalExcluded && !entry.inputClaim && !entry.inputProvenance &&
2204
+ !entry.queueOwner && !entry.queueReceiptId && !entry.queueHandoff && !entry.failure &&
2205
+ Object.keys(entry.update).every(key => key === "update_id" || key === "message") &&
2206
+ message?.chat?.type === "private" && Number.isSafeInteger(message.chat.id) &&
2207
+ Number.isSafeInteger(message.from?.id) && message.from?.is_bot === false &&
2208
+ Number.isSafeInteger(message.message_id) && message.message_id > 0 &&
2209
+ Number.isSafeInteger(message.message_thread_id) && message.message_thread_id > 0 &&
2210
+ !Reflect.has(message, "pi_telegram_source_update_id") && !Reflect.has(message, "business_connection_id");
2211
+ const temporary = isRecordedTemporaryThreadInputHeld(entry, ctx, signal, true);
2212
+ if (!retainable && temporary)
2213
+ return true;
2214
+ if (!retainable && unsupported)
2215
+ return false;
2216
+ if (signal.aborted)
2217
+ throw new Error("Historical routing generation ended.");
2218
+ const epoch = deps.getCurrentLeaderEpoch?.();
2219
+ const binding = deps.getAdmissionJournalBinding?.();
2220
+ const current = () => !signal.aborted && deps.isContextActive?.(ctx) === true &&
2221
+ epoch !== undefined && deps.getCurrentLeaderEpoch?.() === epoch && !!binding && deps.getAdmissionJournalBinding?.() === binding;
2222
+ if (!current() || !deps.threadStore)
2223
+ throw new Error("Historical routing authority is unavailable.");
2224
+ await deps.threadStore.load();
2225
+ if (!current())
2226
+ throw new Error("Historical routing authority changed.");
2227
+ // Raw unsupported startup originals stay protected even when a bound target outlives forgotten Restore membership.
2228
+ // Holding protects evidence, not sender authorization; owner-bearing sources keep their existing paths.
2229
+ return retainable ? "retain" : needsHistoricalReview(message);
2230
+ };
2231
+ const recoveryPreview = (source, owner, chatId, binding) => {
2232
+ const entry = source.original;
2233
+ const message = reviewMessage(entry);
2234
+ if (!message || message.chat.id !== chatId || message.from.id !== owner)
2235
+ return undefined;
2236
+ for (const pending of pendingUnboundReroutes.values()) {
2237
+ if (pending.abandonment?.journalBindingKey === binding &&
2238
+ Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(entry.updateId) &&
2239
+ // Revoked carriers describe a past attempt, not a live selection veto.
2240
+ // Current claim/entry authority still comes from the worker and journal CAS.
2241
+ pending.messages.some(value => Updates.getTelegramUpdateExecutionFence(value)?.signal.aborted !== true) &&
2242
+ (pending.selectionAttempted || pending.destinationSelected || pending.dispatching || pending.foreignForwardIssued || pending.foreignRetry || pending.cleanup ||
2243
+ pending.abandonment.running))
2244
+ return undefined;
2245
+ }
2246
+ const text = Array.from(message.text.replace(/\s+/gu, " ").trim());
2247
+ return escapeHtml(text.slice(0, 80).join("")) + (text.length > 80 ? "…" : "");
2248
+ };
2249
+ const expireRoutingInput = async (source, ctx, signal) => {
2250
+ const operator = deps.configStore.getAllowedUserId(), epoch = deps.getCurrentLeaderEpoch?.();
2251
+ const message = source.original.update.message;
2252
+ const runtimeCurrent = () => !signal.aborted && operator !== undefined && epoch !== undefined &&
2253
+ deps.isContextActive?.(ctx) === true && deps.configStore.getAllowedUserId() === operator &&
2254
+ deps.getCurrentLeaderEpoch?.() === epoch && deps.getAdmissionJournalBinding?.() === source.journalBindingKey &&
2255
+ source.original.routingInput?.operatorUserId === operator && message?.from?.id === operator && message.from.is_bot !== true && message.chat?.type === "private";
2256
+ const current = () => runtimeCurrent() && source.isCurrent();
2257
+ if (!current() || !deps.runWorkspaceOperation)
2258
+ return;
2259
+ await deps.runWorkspaceOperation({ operationId: `routing-expiry-${randomBytes(16).toString("hex")}`,
2260
+ operationKind: "workspace.expire-unbound-routing", scopes: [{ kind: "profile" }] }, async () => {
2261
+ if (!current())
2262
+ return;
2263
+ const restores = deps.getWorkspaceRestoreStore?.();
2264
+ if (deps.getWorkspaceRestoreStore && !restores)
2265
+ return;
2266
+ const temporary = restores?.listTemporaryThreads().find(entry => Threads.getTelegramTemporaryThreadInputs(entry).some(input => input.journalBindingKey === source.journalBindingKey && input.updateIds.includes(source.original.updateId)));
2267
+ const result = source.expire();
2268
+ if (!result)
2269
+ return;
2270
+ // Expiry revokes the donor carrier; old controls and late ACKs cannot issue another delivery. No body is archived.
2271
+ for (const [id, pending] of pendingUnboundReroutes) {
2272
+ if (!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId))
2273
+ continue;
2274
+ removePendingReroute(id);
2275
+ if (signal.aborted || deps.isContextActive?.(ctx) !== true || deps.configStore.getAllowedUserId() !== operator ||
2276
+ deps.getCurrentLeaderEpoch?.() !== epoch || deps.getAdmissionJournalBinding?.() !== source.journalBindingKey)
2277
+ continue;
2278
+ if (deps.editInteractiveMessage && pending.chooserMessageId !== undefined) {
2279
+ try {
2280
+ await deps.editInteractiveMessage(pending.sourceTarget.chatId, pending.chooserMessageId, `<b>${TELEGRAM_ROUTING_CHOICE_EXPIRED}</b>`, "html", { inline_keyboard: [] });
2281
+ }
2282
+ catch (error) {
2283
+ deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-expiry-view" });
2284
+ }
2285
+ }
717
2286
  }
718
- return;
719
- }
720
- await promptEnqueue(messages, ctx);
2287
+ // The scheduled stage captures only context/epoch and a tab token. It reconstructs disposition from body-free proof,
2288
+ // never closes over this source or asks the worker to retain a terminal prompt while metadata publication fails.
2289
+ if (temporary?.target && runtimeCurrent())
2290
+ scheduleTemporaryThreadCleanup(temporary.target, ctx, true);
2291
+ });
721
2292
  };
722
- const finalizePendingReroute = async (rerouteId, pending, query, successMessage, assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query)) => {
723
- assertExecutionCurrent();
724
- const dismissed = await dismissRerouteChooserMessage(query, assertExecutionCurrent);
725
- assertExecutionCurrent();
726
- if (dismissed) {
727
- removePendingReroute(rerouteId);
728
- await deps.answerCallbackQuery(query.id, successMessage);
2293
+ const renderCancellationReview = async (view, isCurrent) => {
2294
+ if (!isCurrent())
729
2295
  return;
2296
+ const data = (action) => `${TELEGRAM_PENDING_CANCELLATION_REVIEW_PREFIX}${view.id}:${action}`;
2297
+ const rows = [[{ text: "⬆️ Main menu", callback_data: "menu:back" }]];
2298
+ const lines = ["<b>☑️ Review pending cancellations:</b>", "",
2299
+ "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."];
2300
+ if (!view.sources.length)
2301
+ lines.push("", "No supported pending cancellations on this page.");
2302
+ for (const [index, source] of view.sources.entries()) {
2303
+ lines.push("", `<b>${index + 1}. ${source.result ? "⛔️ Routing cancelled." : "Protected input"}</b>`, source.preview);
2304
+ if (!source.result)
2305
+ rows.push([{ text: `❌ Finish cancellation ${index + 1}`, callback_data: data(`retry:${index}`) }]);
730
2306
  }
731
- pending.finalizeMessage = successMessage;
732
- await deps.answerCallbackQuery(query.id, `${successMessage} Chooser cleanup is still pending. Try again.`);
2307
+ if (view.unsupported)
2308
+ lines.push("", "Other protected inputs are not available in this view and remain untouched.");
2309
+ if (view.nextAfterUpdateId !== undefined)
2310
+ rows.push([{ text: "🟣 More", callback_data: data("more") }]);
2311
+ rows.push([{ text: "🔄 Refresh", callback_data: data("refresh") }]);
2312
+ await deps.editInteractiveMessage(view.target.chatId, view.messageId, lines.join("\n"), "html", { inline_keyboard: rows });
733
2313
  };
734
- const forwardPendingRerouteMessages = async (pending, instanceId, threadId, ctx, assertExecutionCurrent) => {
735
- assertExecutionCurrent?.();
736
- const forwardMessage = deps.foreignOwnedUpdateForwarder?.forwardMessage;
737
- if (!forwardMessage)
2314
+ const handleCancellationReview = async (query, ctx) => {
2315
+ const matches = (view) => query.message?.message_id === view.messageId &&
2316
+ query.message.chat.id === view.target.chatId &&
2317
+ (query.message.message_thread_id === undefined || query.message.message_thread_id === view.target.threadId);
2318
+ if (query.data?.startsWith(TELEGRAM_RETIRED_HISTORICAL_REVIEW_PREFIX)) {
2319
+ await deps.answerCallbackQuery(query.id, "🚫 This control is no longer available.");
2320
+ return true;
2321
+ }
2322
+ const ownsReview = query.data?.startsWith(TELEGRAM_PENDING_CANCELLATION_REVIEW_PREFIX) === true;
2323
+ const prefix = TELEGRAM_PENDING_CANCELLATION_REVIEW_PREFIX;
2324
+ if (!ownsReview && cancellationReview && matches(cancellationReview)) {
2325
+ if (cancellationReview.running && cancellationReview.isCurrent()) {
2326
+ await deps.answerCallbackQuery(query.id, "⏳ Cancellation is in progress.");
2327
+ return true;
2328
+ }
2329
+ cancellationReview = undefined;
738
2330
  return false;
739
- const messages = cloneTelegramMessagesForThread(pending.messages, threadId);
740
- const outcomes = await Promise.allSettled(messages.map((message) => forwardMessage({
741
- message,
742
- ownership: { instanceId },
743
- ctx,
744
- })));
745
- assertExecutionCurrent?.();
746
- pending.messages = pending.messages.filter((_, index) => {
747
- const outcome = outcomes[index];
748
- if (outcome?.status === "fulfilled" &&
749
- outcome.value.status === "accepted") {
750
- if (pending.dispatchKind === "command" && pending.sourceTarget.threadId === undefined) {
751
- Updates.reportTelegramUpdateCompleted(pending.messages[index]);
2331
+ }
2332
+ if (!ownsReview)
2333
+ return false;
2334
+ const unavailable = async () => { await deps.answerCallbackQuery(query.id, "🚫 Cancellation review expired or unavailable. Open Status again."); return true; };
2335
+ const execution = Updates.getTelegramUpdateExecutionFence(query);
2336
+ const chatId = query.message?.chat.id;
2337
+ const messageId = query.message?.message_id;
2338
+ if (execution?.isCurrent() !== true || deps.isContextActive?.(ctx) !== true ||
2339
+ !deps.editInteractiveMessage || !deps.runWorkspaceOperation || query.message?.chat.type !== "private" ||
2340
+ typeof chatId !== "number" || !Number.isSafeInteger(chatId) ||
2341
+ typeof messageId !== "number" || !Number.isSafeInteger(messageId) || messageId <= 0)
2342
+ return unavailable();
2343
+ const owner = deps.configStore.getAllowedUserId();
2344
+ const journalBindingKey = deps.getAdmissionJournalBinding?.();
2345
+ const epoch = deps.getCurrentLeaderEpoch?.();
2346
+ if (owner === undefined || query.from.id !== owner || query.from.is_bot || !journalBindingKey || epoch === undefined)
2347
+ return unavailable();
2348
+ const current = () => execution.isCurrent() && deps.isContextActive?.(ctx) === true &&
2349
+ deps.configStore.getAllowedUserId() === owner && deps.getAdmissionJournalBinding?.() === journalBindingKey &&
2350
+ deps.getCurrentLeaderEpoch?.() === epoch;
2351
+ const parsed = query.data.slice(prefix.length).match(/^([a-z0-9]+):(retry:(\d+)|refresh|more)$/);
2352
+ const prior = cancellationReview;
2353
+ const open = query.data === `${prefix}open`;
2354
+ if (!open && (!parsed || !prior || parsed[1] !== prior.id || !matches(prior) || !prior.isCurrent()))
2355
+ return unavailable();
2356
+ if (prior?.running && prior.isCurrent()) {
2357
+ await deps.answerCallbackQuery(query.id, "⏳ Cancellation is in progress.");
2358
+ return true;
2359
+ }
2360
+ let view = prior;
2361
+ let selected;
2362
+ try {
2363
+ if (open || parsed?.[2] === "refresh" || parsed?.[2] === "more") {
2364
+ if (parsed?.[2] === "more" && prior?.nextAfterUpdateId === undefined)
2365
+ return unavailable();
2366
+ const next = { id: randomBytes(16).toString("hex"),
2367
+ target: { chatId, ...(query.message.message_thread_id !== undefined ? { threadId: query.message.message_thread_id } : {}) },
2368
+ messageId, ownerUserId: owner, journalBindingKey, sources: [], unsupported: false,
2369
+ isCurrent: () => cancellationReview === next && current() };
2370
+ cancellationReview = view = next;
2371
+ const page = Updates.inspectTelegramAbandoningUpdates(query, { journalBindingKey, isCurrent: next.isCurrent,
2372
+ ...(parsed?.[2] === "more" ? { afterUpdateId: prior.nextAfterUpdateId } : {}) });
2373
+ if (!page) {
2374
+ if (cancellationReview === next)
2375
+ cancellationReview = undefined;
2376
+ return unavailable();
752
2377
  }
753
- return false;
2378
+ for (const source of page.sources) {
2379
+ const preview = recoveryPreview(source, owner, next.target.chatId, journalBindingKey);
2380
+ if (preview === undefined) {
2381
+ next.unsupported = true;
2382
+ continue;
2383
+ }
2384
+ if (next.sources.length === 5) {
2385
+ next.nextAfterUpdateId = next.sources.at(-1).original.updateId;
2386
+ break;
2387
+ }
2388
+ next.sources.push({ ...source, preview });
2389
+ }
2390
+ next.nextAfterUpdateId ??= page.nextAfterUpdateId;
2391
+ next.running = true;
2392
+ await renderCancellationReview(next, next.isCurrent);
2393
+ if (next.isCurrent())
2394
+ await deps.answerCallbackQuery(query.id);
754
2395
  }
755
- if (outcome?.status === "rejected") {
756
- deps.recordRuntimeEvent?.("bus", outcome.reason, {
757
- phase: "reroute-foreign-forward",
758
- instanceId,
759
- threadId,
760
- messageIndex: index,
2396
+ else {
2397
+ if (!view || !parsed?.[3] || !parsed[2].startsWith("retry:"))
2398
+ return unavailable();
2399
+ selected = view.sources[Number(parsed[3])];
2400
+ if (!selected)
2401
+ return unavailable();
2402
+ const activeView = view;
2403
+ const source = selected;
2404
+ const isCurrent = () => current() && activeView.isCurrent() &&
2405
+ recoveryPreview(source, owner, activeView.target.chatId, journalBindingKey) !== undefined;
2406
+ view.running = true;
2407
+ await deps.runWorkspaceOperation({ operationId: `workspace-cancellation-recovery:${query.id}`,
2408
+ operationKind: "workspace.recover-unbound-cancellation", scopes: [{ kind: "profile" }] }, async () => {
2409
+ if (!isCurrent())
2410
+ return;
2411
+ source.result ??= source.retry({ operatorAuthorityId: `telegram-owner:${owner}`, isCurrent });
2412
+ if (!source.result) {
2413
+ await unavailable();
2414
+ return;
2415
+ }
2416
+ for (const [id, pending] of pendingUnboundReroutes) {
2417
+ if (pending.abandonment?.ownerUserId !== owner || pending.abandonment.journalBindingKey !== journalBindingKey ||
2418
+ !Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId))
2419
+ continue;
2420
+ pending.abandonment.result = source.result;
2421
+ pending.abandonment.attempted = true;
2422
+ try {
2423
+ await retireCancellationChooser(id, pending, isCurrent);
2424
+ }
2425
+ catch (error) {
2426
+ deps.recordRuntimeEvent?.("routing", error, { phase: "recovered-chooser-cleanup" });
2427
+ }
2428
+ }
2429
+ await renderCancellationReview(activeView, isCurrent);
2430
+ if (isCurrent())
2431
+ await deps.answerCallbackQuery(query.id, "⛔️ Routing cancelled.");
761
2432
  });
762
2433
  }
2434
+ }
2435
+ catch (error) {
2436
+ deps.recordRuntimeEvent?.("routing", error, { phase: "unbound-cancellation-recovery" });
2437
+ if (current())
2438
+ await deps.answerCallbackQuery(query.id, selected?.result
2439
+ ? "⚠️ Routing cancelled; view update failed. Retry the same button."
2440
+ : "⚠️ Recovery incomplete; inputs stay protected. Check /telegram-status --debug.");
2441
+ }
2442
+ finally {
2443
+ if (view)
2444
+ view.running = false;
2445
+ }
2446
+ return true;
2447
+ };
2448
+ const handleUnboundRerouteCancelCallback = async (query, ctx) => {
2449
+ if (!query.data?.startsWith(TELEGRAM_UNBOUND_REROUTE_CANCEL_CALLBACK_PREFIX))
2450
+ return false;
2451
+ const rerouteId = query.data.match(/^reroutecancel:([a-z0-9]+)$/)?.[1];
2452
+ const pending = rerouteId ? pendingUnboundReroutes.get(rerouteId) : undefined;
2453
+ const cancellation = pending?.abandonment;
2454
+ const source = pending?.messages[0];
2455
+ const execution = Updates.getTelegramUpdateExecutionFence(query);
2456
+ const sourceExecution = Updates.getTelegramUpdateExecutionFence(source);
2457
+ const isAuthorityCurrent = () => !!cancellation && execution?.isCurrent() === true &&
2458
+ sourceExecution?.signal.aborted === false && deps.isContextActive?.(ctx) === true &&
2459
+ query.from.id === cancellation.ownerUserId && !query.from.is_bot &&
2460
+ deps.configStore.getAllowedUserId() === cancellation.ownerUserId &&
2461
+ deps.getAdmissionJournalBinding?.() === cancellation.journalBindingKey &&
2462
+ deps.getCurrentLeaderEpoch?.() === cancellation.leaderEpoch;
2463
+ const isCurrent = () => isAuthorityCurrent() && !!pending && !!cancellation && !!source &&
2464
+ pendingUnboundReroutes.get(rerouteId) === pending && pending.abandonment === cancellation &&
2465
+ matchesRerouteChooser(pending, query) && query.message?.chat.type === "private" &&
2466
+ source.from?.id === cancellation.ownerUserId &&
2467
+ pending.messages.length === 1 &&
2468
+ !pending.destinationSelected && !pending.selectionAttempted && !pending.dispatching &&
2469
+ !pending.foreignForwardIssued && !pending.foreignRetry && !pending.cleanup && !pending.finalizeMessage;
2470
+ if (!isCurrent() || !deps.runWorkspaceOperation || !deps.editInteractiveMessage) {
2471
+ // A chooser from a previous process (new world) or an expired one simply reads as expired.
2472
+ await deps.answerCallbackQuery(query.id, pending ? "🚫 Message route is unavailable for cancellation." : TELEGRAM_ROUTING_CHOICE_EXPIRED);
763
2473
  return true;
764
- });
765
- return pending.messages.length === 0;
2474
+ }
2475
+ if (cancellation.running) {
2476
+ await deps.answerCallbackQuery(query.id, "⏳ Cancellation is already in progress.");
2477
+ return true;
2478
+ }
2479
+ cancellation.running = true;
2480
+ try {
2481
+ await deps.runWorkspaceOperation({ operationId: `workspace-reroute-cancel:${query.id}`,
2482
+ operationKind: "workspace.cancel-unbound-routing", scopes: [{ kind: "profile" }] }, async () => {
2483
+ if (!isCurrent())
2484
+ return;
2485
+ if (!cancellation.result) {
2486
+ const wasAttempted = cancellation.attempted;
2487
+ cancellation.attempted = true;
2488
+ const result = Updates.abandonTelegramDeferredUpdate(source, {
2489
+ operatorAuthorityId: `telegram-owner:${cancellation.ownerUserId}`, isCurrent,
2490
+ });
2491
+ if (!result) {
2492
+ cancellation.attempted = wasAttempted;
2493
+ await deps.answerCallbackQuery(query.id, "🚫 Message routing cannot be cancelled now.");
2494
+ return;
2495
+ }
2496
+ cancellation.result = result;
2497
+ }
2498
+ if (!isCurrent())
2499
+ return;
2500
+ if (!recordCancelledTemporaryThreadInput(pending, ctx, isCurrent)) {
2501
+ if (isAuthorityCurrent())
2502
+ await deps.answerCallbackQuery(query.id, "⚠️ Routing cancelled; Thread cleanup remains protected. Retry Cancel routing.");
2503
+ return;
2504
+ }
2505
+ if (await retireCancellationChooser(rerouteId, pending, isCurrent) && isAuthorityCurrent()) {
2506
+ scheduleTemporaryThreadCleanup(pending.sourceTarget, ctx);
2507
+ await deps.answerCallbackQuery(query.id, "⛔️ Routing cancelled.");
2508
+ }
2509
+ });
2510
+ }
2511
+ catch (error) {
2512
+ deps.recordRuntimeEvent?.("routing", error, { phase: "unbound-cancellation", rerouteId });
2513
+ if (isCurrent())
2514
+ await deps.answerCallbackQuery(query.id, cancellation.result ? "⚠️ Routing cancelled; chooser update failed. Retry Cancel routing." :
2515
+ "⚠️ Cancellation incomplete. Retry Cancel routing.");
2516
+ }
2517
+ finally {
2518
+ cancellation.running = false;
2519
+ }
2520
+ return true;
2521
+ };
2522
+ const withPendingRerouteSelection = async (rerouteId, query, operation) => {
2523
+ Updates.assertTelegramUpdateExecutionCurrent(query);
2524
+ const pending = pendingUnboundReroutes.get(rerouteId);
2525
+ if (pending?.foreignForwardIssued && matchesRerouteChooser(pending, query)) {
2526
+ await deps.answerCallbackQuery(query.id, "🚫 Forward is pending; this button will not resend uncertain input.");
2527
+ return true;
2528
+ }
2529
+ if (pending && matchesRerouteChooser(pending, query) &&
2530
+ (pending.abandonment?.attempted || pending.abandonment?.running || pending.messages.some(message => Updates.getTelegramUpdateExecutionFence(message)?.isCurrent() === false))) {
2531
+ await deps.answerCallbackQuery(query.id, TELEGRAM_ROUTING_CHOICE_EXPIRED);
2532
+ return true;
2533
+ }
2534
+ const releases = [];
2535
+ try {
2536
+ if (pending && matchesRerouteChooser(pending, query)) {
2537
+ try {
2538
+ for (const message of pending.messages)
2539
+ releases.push(Updates.acquireTelegramUpdateRouting(message));
2540
+ }
2541
+ catch (error) {
2542
+ deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-selection" });
2543
+ await deps.answerCallbackQuery(query.id, "🚫 Message route expired or unavailable.");
2544
+ return true;
2545
+ }
2546
+ }
2547
+ return await operation();
2548
+ }
2549
+ finally {
2550
+ for (const release of releases)
2551
+ release();
2552
+ }
766
2553
  };
767
- const handleUnboundRerouteRestoreMenuCallback = async (query, _ctx) => {
2554
+ const executeUnboundRerouteRestoreMenuCallback = async (query, _ctx) => {
768
2555
  const parsed = parseTelegramUnboundRerouteRestoreMenuCallbackData(query.data);
769
2556
  if (!parsed)
770
2557
  return false;
@@ -778,28 +2565,47 @@ export function createTelegramInboundRouteRuntime(deps) {
778
2565
  !deps.threadStore ||
779
2566
  !pending ||
780
2567
  !matchesRerouteChooser(pending, query) ||
2568
+ (parsed.root && pending.rootChooserText === undefined) ||
781
2569
  expirePendingCommand(parsed.rerouteId, pending)) {
782
- await deps.answerCallbackQuery(query.id, "Message route expired.");
2570
+ await deps.answerCallbackQuery(query.id, TELEGRAM_ROUTING_CHOICE_EXPIRED);
783
2571
  return true;
784
2572
  }
785
- if (pending.sourceTarget.threadId === undefined) {
2573
+ if (parsed.restore && pending.sourceTarget.threadId === undefined) {
786
2574
  await deps.answerCallbackQuery(query.id, "Restore needs a destination thread. Send a plain message in a new Telegram thread first.");
787
2575
  return true;
788
2576
  }
789
2577
  await deps.threadStore.load();
790
2578
  assertExecutionCurrent();
791
2579
  const activeRecords = getTelegramRoutableThreadRecords(deps.threadStore.list(), deps.getLiveThreadTargets?.());
792
- const replyMarkup = buildTelegramUnboundRerouteRestoreChooserMarkup(parsed.rerouteId, activeRecords, deps.getDisplayTitle);
2580
+ const replyMarkup = parsed.root
2581
+ ? buildTelegramUnboundRerouteChooserMarkup(parsed.rerouteId, activeRecords, {
2582
+ canRestore: pending.sourceTarget.threadId !== undefined, canCancel: !!pending.abandonment,
2583
+ getDisplayTitle: deps.getDisplayTitle,
2584
+ })
2585
+ : parsed.restore
2586
+ ? buildTelegramUnboundRerouteRestoreChooserMarkup(parsed.rerouteId, activeRecords, deps.getDisplayTitle)
2587
+ : { inline_keyboard: activeRecords.map(record => [{
2588
+ text: `↪️ ${getTelegramThreadRecordLabel(record, deps.getDisplayTitle)}`,
2589
+ callback_data: formatTelegramUnboundRerouteCallbackData(parsed.rerouteId, record.target.threadId),
2590
+ }]) };
2591
+ const chooserText = parsed.root
2592
+ ? pending.rootChooserText
2593
+ : parsed.restore ? formatTelegramUnboundRerouteRestoreChooserText()
2594
+ : "<b>🧵 Reroute:</b>\n\nChoose the thread that should handle this input:";
2595
+ if (!parsed.root)
2596
+ replyMarkup.inline_keyboard.unshift([{
2597
+ text: "⬆️ Back", callback_data: `${TELEGRAM_UNBOUND_REROUTE_ROOT_CALLBACK_PREFIX}${parsed.rerouteId}`,
2598
+ }]);
793
2599
  if (deps.editInteractiveMessage) {
794
- await deps.editInteractiveMessage(chatId, messageId, formatTelegramUnboundRerouteRestoreChooserText(), "html", replyMarkup);
2600
+ await deps.editInteractiveMessage(chatId, messageId, chooserText, "html", replyMarkup);
795
2601
  }
796
2602
  else if (deps.sendInteractiveMessage) {
797
- const chooserId = await deps.sendInteractiveMessage(chatId, formatTelegramUnboundRerouteRestoreChooserText(), "html", replyMarkup, { target: pending.sourceTarget, replyToMessageId: messageId });
2603
+ const chooserId = await deps.sendInteractiveMessage(chatId, chooserText, "html", replyMarkup, { target: pending.sourceTarget, replyToMessageId: messageId });
798
2604
  assertExecutionCurrent();
799
2605
  rememberRerouteChooser(parsed.rerouteId, chooserId);
800
2606
  }
801
2607
  assertExecutionCurrent();
802
- await deps.answerCallbackQuery(query.id, "Choose instance to restore.");
2608
+ await deps.answerCallbackQuery(query.id, parsed.root ? "Choose routing mode." : parsed.restore ? "Choose instance to restore." : "Choose target thread.");
803
2609
  return true;
804
2610
  };
805
2611
  const executeUnboundRerouteCallbackOperation = async (query, ctx) => {
@@ -812,25 +2618,31 @@ export function createTelegramInboundRouteRuntime(deps) {
812
2618
  const pending = pendingUnboundReroutes.get(parsed.rerouteId);
813
2619
  if (typeof chatId !== "number" || !deps.threadStore || !pending ||
814
2620
  !matchesRerouteChooser(pending, query) || expirePendingCommand(parsed.rerouteId, pending)) {
815
- await deps.answerCallbackQuery(query.id, "Message route expired.");
2621
+ await deps.answerCallbackQuery(query.id, TELEGRAM_ROUTING_CHOICE_EXPIRED);
816
2622
  return true;
817
2623
  }
818
2624
  await deps.threadStore.load();
819
2625
  assertExecutionCurrent();
820
2626
  if (pendingUnboundReroutes.get(parsed.rerouteId) !== pending ||
821
2627
  expirePendingCommand(parsed.rerouteId, pending)) {
822
- await deps.answerCallbackQuery(query.id, "Message route expired.");
2628
+ await deps.answerCallbackQuery(query.id, TELEGRAM_ROUTING_CHOICE_EXPIRED);
823
2629
  return true;
824
2630
  }
825
2631
  if (pending.finalizeMessage) {
826
2632
  await finalizePendingReroute(parsed.rerouteId, pending, query, pending.finalizeMessage, assertExecutionCurrent);
827
2633
  return true;
828
2634
  }
2635
+ if (pending.workspaceRestore && (!restoreWorkspace || !parsed.useNewSlot ||
2636
+ pending.workspaceRestore.record.target.threadId !== parsed.threadId)) {
2637
+ await deps.answerCallbackQuery(query.id, "🚫 Restore is already selected. Use the original Restore choice.");
2638
+ return true;
2639
+ }
829
2640
  if (pending.foreignRetry) {
830
2641
  const retry = pending.foreignRetry;
831
2642
  const allForwarded = await forwardPendingRerouteMessages(pending, retry.instanceId, retry.threadId, ctx, assertExecutionCurrent);
832
2643
  if (!allForwarded) {
833
- await deps.answerCallbackQuery(query.id, "Target thread is unavailable; retrying will send only remaining messages.");
2644
+ await deps.answerCallbackQuery(query.id, pending.foreignForwardIssued ? "🚫 Forward is pending; this button will not resend uncertain input." :
2645
+ "Target thread is unavailable; retrying will send only remaining messages.");
834
2646
  return true;
835
2647
  }
836
2648
  pending.foreignRetry = undefined;
@@ -847,7 +2659,11 @@ export function createTelegramInboundRouteRuntime(deps) {
847
2659
  await finalizePendingReroute(parsed.rerouteId, pending, query, "Thread cleanup completed.", assertExecutionCurrent);
848
2660
  return true;
849
2661
  }
850
- const record = getTelegramRoutableThreadRecords(deps.threadStore.list(), deps.getLiveThreadTargets?.()).find((candidate) => candidate.target.chatId === chatId &&
2662
+ if (!pending.workspaceRestore && isTemporaryReroute(pending) && isTemporaryThreadForwardIssued(pending)) {
2663
+ await deps.answerCallbackQuery(query.id, "🚫 Forward is pending; this button will not resend uncertain input.");
2664
+ return true;
2665
+ }
2666
+ const record = pending.workspaceRestore?.record ?? getTelegramRoutableThreadRecords(deps.threadStore.list(), deps.getLiveThreadTargets?.()).find((candidate) => candidate.target.chatId === chatId &&
851
2667
  candidate.target.threadId === parsed.threadId);
852
2668
  if (!record) {
853
2669
  await deps.answerCallbackQuery(query.id, "Thread is not active yet.");
@@ -867,113 +2683,120 @@ export function createTelegramInboundRouteRuntime(deps) {
867
2683
  await deps.answerCallbackQuery(query.id, "🚫 Selected thread is already the destination.");
868
2684
  return true;
869
2685
  }
870
- if (parsed.useNewSlot && sourceTarget && isRerouteTargetProtected(sourceTarget)) {
2686
+ // A source's own temporary tab is its Restore destination; only other protection refuses it.
2687
+ if (parsed.useNewSlot && sourceTarget && !pending.workspaceRestore &&
2688
+ isRerouteTargetProtected(sourceTarget, undefined, pending.temporaryThread ?? findCreatedTemporaryThread(sourceTarget), pending)) {
871
2689
  await deps.answerCallbackQuery(query.id, "🚫 Thread restore source is already owned.");
872
2690
  return true;
873
2691
  }
874
- pending.destinationSelected = true;
875
- pending.selectionAttempted = true;
876
- pending.pauseExpiry?.();
2692
+ // Restore has one controller. Without it or its negotiated authority, refuse before selection so the chooser keeps
2693
+ // its other routes and Cancel.
2694
+ if (parsed.useNewSlot && sourceTarget && !pending.workspaceRestore &&
2695
+ (!restoreWorkspace || deps.hasWorkspaceRestoreAuthority?.() !== true)) {
2696
+ await deps.answerCallbackQuery(query.id, "🚫 Thread restore is unavailable right now. The message stays pending; choose another route or cancel.");
2697
+ return true;
2698
+ }
877
2699
  const currentInstanceId = deps.getCurrentInstanceId?.();
878
2700
  const leaderProfileKey = getLeaderTopicProfileKey(ctx, currentInstanceId);
879
2701
  const isCurrentLeaderRecord = isCurrentLeaderTopicRecord(record, leaderProfileKey, currentInstanceId);
880
- if (parsed.useNewSlot && !isCurrentLeaderRecord) {
881
- if (!record.slot || !/^[A-Z]$/u.test(record.slot)) {
882
- await deps.answerCallbackQuery(query.id, "Follower thread slot authority is unavailable.");
883
- return true;
884
- }
885
- if (!sourceTarget ||
886
- !deps.replaceFollowerThreadTarget ||
887
- !deps.foreignOwnedUpdateForwarder?.forwardMessage) {
888
- await deps.answerCallbackQuery(query.id, "Follower thread restore is not available yet.");
889
- return true;
890
- }
891
- assertExecutionCurrent();
892
- const replaced = await deps.replaceFollowerThreadTarget({
893
- record,
894
- target: sourceTarget,
895
- oldTarget: record.target,
896
- });
897
- assertExecutionCurrent();
898
- if (!replaced) {
899
- await deps.answerCallbackQuery(query.id, "Follower thread is unavailable.");
900
- return true;
2702
+ if (!parsed.useNewSlot && record.instanceId && record.instanceId !== currentInstanceId && !isCurrentLeaderRecord &&
2703
+ !deps.foreignOwnedUpdateForwarder?.forwardMessage) {
2704
+ if (pending.routingOperatorUserId === undefined) {
2705
+ pending.destinationSelected = true;
2706
+ pending.selectionAttempted = true;
2707
+ pending.pauseExpiry?.();
901
2708
  }
902
- const nowMs = Date.now();
903
- const slot = record.slot;
904
- const threadName = getRestoredThreadName(record, slot);
905
- assertExecutionCurrent();
906
- deps.threadStore.markStaleByTarget(record.target, "deleted", "Follower thread was replaced by restore source.");
907
- deps.threadStore.upsert({
908
- ...record,
909
- target: sourceTarget,
910
- status: "active",
911
- syncStatus: "open",
912
- updatedAtMs: nowMs,
913
- threadName,
914
- lastSyncObservedAtMs: nowMs,
915
- lastReconcileAction: "follower-thread-restore",
916
- rerouteConfirmedAtMs: nowMs,
917
- });
918
- await deps.threadStore.persist();
919
- assertExecutionCurrent();
920
- if (deps.callApi) {
2709
+ await deps.answerCallbackQuery(query.id, "Open that thread and resend the message there.");
2710
+ return true;
2711
+ }
2712
+ if (pending.routingOperatorUserId !== undefined && (pending.routingOperatorUserId !== query.from.id ||
2713
+ pending.routingOperatorUserId !== deps.configStore.getAllowedUserId())) {
2714
+ await deps.answerCallbackQuery(query.id, "🚫 Message route authority changed.");
2715
+ return true;
2716
+ }
2717
+ // Freeze only a validated actual destination, inside the same admission as its effect. Menu browsing,
2718
+ // unavailable recipients and refused Restore capability never turn a waiting source into a selected grant.
2719
+ try {
2720
+ const release = Updates.acquireTelegramUpdateRouting(pending.messages[0], true, Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages));
2721
+ release();
2722
+ }
2723
+ catch (error) {
2724
+ deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-selection" });
2725
+ await deps.answerCallbackQuery(query.id, "🚫 Message route expired or unavailable.");
2726
+ return true;
2727
+ }
2728
+ pending.destinationSelected = true;
2729
+ pending.selectionAttempted = true;
2730
+ pending.pauseExpiry?.();
2731
+ if (parsed.useNewSlot && sourceTarget && restoreWorkspace) {
2732
+ const selection = pending.workspaceRestore ??= { operationId: `restore-${randomBytes(16).toString("hex")}`,
2733
+ record: structuredClone(record), messages: [...pending.messages] };
2734
+ let active = true;
2735
+ const ownerUserId = deps.configStore.getAllowedUserId();
2736
+ const leaderEpoch = deps.getCurrentLeaderEpoch?.();
2737
+ const admissionScope = deps.getAdmissionScope?.();
2738
+ const journalBinding = deps.getAdmissionJournalBinding?.();
2739
+ const selectionCurrent = () => {
921
2740
  try {
922
2741
  assertExecutionCurrent();
923
- await deps.callApi("editForumTopic", {
924
- chat_id: sourceTarget.chatId,
925
- message_thread_id: sourceTarget.threadId,
926
- name: deps.getDisplayTitle?.(sourceTarget) ??
927
- Threads.getTelegramTopicTitleForThreadName(threadName, slot),
928
- });
929
- assertExecutionCurrent();
930
2742
  }
931
- catch (renameError) {
932
- deps.recordRuntimeEvent?.("telegram", renameError, {
933
- phase: "follower-topic-reroute-restore-rename",
934
- chatId: sourceTarget.chatId,
935
- threadId: sourceTarget.threadId,
936
- slot: record.slot,
937
- });
2743
+ catch {
2744
+ return false;
938
2745
  }
939
- }
940
- const cleanup = {
941
- kind: "replaced-follower",
942
- target: record.target,
943
- instanceId: record.instanceId,
2746
+ return ownerUserId === query.from.id && deps.configStore.getAllowedUserId() === ownerUserId &&
2747
+ deps.getCurrentLeaderEpoch?.() === leaderEpoch && deps.getAdmissionScope?.() === admissionScope &&
2748
+ deps.getAdmissionJournalBinding?.() === journalBinding && deps.isContextActive?.(ctx) !== false &&
2749
+ pendingUnboundReroutes.get(parsed.rerouteId) === pending && pending.workspaceRestore === selection;
944
2750
  };
945
- const allForwarded = await forwardPendingRerouteMessages(pending, record.instanceId, sourceTarget.threadId, ctx, assertExecutionCurrent);
946
- if (!allForwarded) {
947
- pending.foreignRetry = {
948
- instanceId: record.instanceId,
949
- threadId: sourceTarget.threadId,
950
- cleanup,
951
- };
952
- await deps.answerCallbackQuery(query.id, "Thread restored; retrying will send only remaining messages before old-thread cleanup.");
953
- return true;
2751
+ const current = () => active && selectionCurrent();
2752
+ const assertRestoreCurrent = () => {
2753
+ assertExecutionCurrent();
2754
+ if (!current())
2755
+ throw new Error("Stale Workspace Restore callback.");
2756
+ };
2757
+ try {
2758
+ await restoreWorkspace({ operationId: selection.operationId, record: structuredClone(selection.record),
2759
+ target: { ...sourceTarget }, messages: [...selection.messages], ctx, isCurrent: current,
2760
+ async dispatch(recipient, isRecipientCurrent, recordForwardAcceptance, recordLocalAcceptance) {
2761
+ const assertRecipientCurrent = () => {
2762
+ assertRestoreCurrent();
2763
+ if (!isRecipientCurrent())
2764
+ throw new Error("Workspace Restore recipient authority changed.");
2765
+ };
2766
+ assertRecipientCurrent();
2767
+ if (recipient.kind === "leader") {
2768
+ if (recipient.instanceId !== currentInstanceId)
2769
+ return false;
2770
+ const routed = cloneTelegramMessagesForThread(pending.messages, sourceTarget.threadId);
2771
+ const messages = pending.dispatchKind === "command" ? routed.map((message, index) => Updates.bindTelegramUpdateCompletionAcceptance(message, () => recordLocalAcceptance(pending.messages[index]))) : routed;
2772
+ await dispatchPendingRerouteMessages(pending, messages, ctx);
2773
+ assertRecipientCurrent();
2774
+ return true;
2775
+ }
2776
+ return forwardPendingRerouteMessages(pending, recipient.instanceId, sourceTarget.threadId, ctx, assertRecipientCurrent, recordForwardAcceptance);
2777
+ },
2778
+ });
954
2779
  }
955
- const cleanupComplete = await retryPendingRerouteCleanup(cleanup, assertExecutionCurrent);
956
- if (!cleanupComplete) {
957
- pending.cleanup = cleanup;
958
- await deps.answerCallbackQuery(query.id, "Thread restored, but old-thread cleanup is still pending. Try again.");
959
- return true;
2780
+ finally {
2781
+ active = false;
960
2782
  }
961
- await finalizePendingReroute(parsed.rerouteId, pending, query, "Message routed.");
2783
+ assertExecutionCurrent();
2784
+ if (!selectionCurrent())
2785
+ return true;
2786
+ await deps.answerCallbackQuery(query.id, "🚫 Restore is pending; this button will not resend uncertain input.");
962
2787
  return true;
963
2788
  }
964
2789
  if (record.instanceId &&
965
2790
  record.instanceId !== currentInstanceId &&
966
2791
  !isCurrentLeaderRecord) {
967
- if (!deps.foreignOwnedUpdateForwarder?.forwardMessage) {
968
- await deps.answerCallbackQuery(query.id, "Open that thread and resend the message there.");
969
- return true;
970
- }
971
2792
  const allForwarded = await forwardPendingRerouteMessages(pending, record.instanceId, parsed.threadId, ctx, assertExecutionCurrent);
972
2793
  if (!allForwarded) {
973
- await deps.answerCallbackQuery(query.id, "Target thread is unavailable; retrying will send only remaining messages.");
2794
+ await deps.answerCallbackQuery(query.id, pending.foreignForwardIssued ? "🚫 Forward is pending; this button will not resend uncertain input." :
2795
+ "Target thread is unavailable; retrying will send only remaining messages.");
974
2796
  return true;
975
2797
  }
976
- const cleanupComplete = await closeReroutedUnboundTopic(sourceTarget, sourceMessageId, assertExecutionCurrent);
2798
+ // A temporary tab is removed only after every input in it resolves, never by one Forward.
2799
+ const cleanupComplete = isTemporaryTabTarget(sourceTarget) || await closeReroutedUnboundTopic(sourceTarget, sourceMessageId, assertExecutionCurrent, pending.temporaryThread);
977
2800
  if (!cleanupComplete && sourceTarget) {
978
2801
  pending.cleanup = {
979
2802
  kind: "unbound",
@@ -981,88 +2804,25 @@ export function createTelegramInboundRouteRuntime(deps) {
981
2804
  ...(typeof sourceMessageId === "number"
982
2805
  ? { messageId: sourceMessageId }
983
2806
  : {}),
2807
+ ...(pending.temporaryThread ? { temporaryThread: pending.temporaryThread } : {}),
984
2808
  };
985
2809
  await deps.answerCallbackQuery(query.id, "Message routed, but thread cleanup is still pending. Try again.");
986
2810
  return true;
987
2811
  }
988
2812
  await finalizePendingReroute(parsed.rerouteId, pending, query, "Message routed.");
2813
+ if (sourceTarget && isTemporaryTabTarget(sourceTarget))
2814
+ scheduleTemporaryThreadCleanup(sourceTarget, ctx);
989
2815
  return true;
990
2816
  }
991
- if (sourceTarget &&
992
- isCurrentLeaderRecord &&
993
- parsed.useNewSlot &&
994
- (record.target.chatId !== sourceTarget.chatId ||
995
- record.target.threadId !== sourceTarget.threadId)) {
996
- const slot = deps.threadStore.allocateSlot(leaderProfileKey ?? record.profileKey, getNextTelegramSlotPreference(record.slot), undefined, { excludeCurrentRecord: true });
997
- if (!slot) {
998
- await deps.answerCallbackQuery(query.id, TELEGRAM_SLOT_CAPACITY_MESSAGE);
999
- return true;
1000
- }
1001
- deps.threadStore.markStaleByTarget(record.target, "deleted", "Current leader thread was replaced by a new-slot reroute source.");
1002
- const nowMs = Date.now();
1003
- const threadName = getRestoredThreadName(record, slot);
1004
- deps.threadStore.upsert({
1005
- ...record,
1006
- target: sourceTarget,
1007
- status: "active",
1008
- updatedAtMs: nowMs,
1009
- threadName,
1010
- instanceId: currentInstanceId,
1011
- slot,
1012
- lastReconcileAction: "reroute-new-slot",
1013
- rerouteConfirmedAtMs: nowMs,
1014
- });
1015
- await deps.threadStore.persist();
1016
- assertExecutionCurrent();
1017
- deps.setCurrentLeaderIdentity?.({
1018
- target: sourceTarget,
1019
- slot,
1020
- threadName,
1021
- });
1022
- if (deps.callApi) {
1023
- try {
1024
- assertExecutionCurrent();
1025
- await deps.callApi("editForumTopic", {
1026
- chat_id: sourceTarget.chatId,
1027
- message_thread_id: sourceTarget.threadId,
1028
- name: deps.getDisplayTitle?.(sourceTarget) ??
1029
- Threads.getTelegramTopicTitleForThreadName(threadName, slot),
1030
- });
1031
- assertExecutionCurrent();
1032
- }
1033
- catch (renameError) {
1034
- deps.recordRuntimeEvent?.("telegram", renameError, {
1035
- phase: "leader-topic-reroute-reclaim-rename",
1036
- chatId: sourceTarget.chatId,
1037
- threadId: sourceTarget.threadId,
1038
- slot,
1039
- });
1040
- }
1041
- }
1042
- deps.recordRuntimeEvent?.("bus", "Bus leader reclaimed reroute source thread", {
1043
- phase: "leader-topic-reroute-reclaim",
1044
- chatId: sourceTarget.chatId,
1045
- threadId: sourceTarget.threadId,
1046
- staleThreadId: record.target.threadId,
1047
- slot,
1048
- });
1049
- await dispatchPendingRerouteMessages(pending, cloneTelegramMessagesForThread(pending.messages, sourceTarget.threadId), ctx);
1050
- pending.messages = [];
1051
- const cleanupComplete = await closePreviousLeaderThread(record.target, assertExecutionCurrent);
1052
- if (!cleanupComplete) {
1053
- pending.cleanup = {
1054
- kind: "previous-leader",
1055
- target: record.target,
1056
- };
1057
- await deps.answerCallbackQuery(query.id, "Thread restored, but old-thread cleanup is still pending. Try again.");
1058
- return true;
1059
- }
1060
- await finalizePendingReroute(parsed.rerouteId, pending, query, "Message routed.");
2817
+ // Local handlers and queue admission consume the same one-time group grant as follower forwarding.
2818
+ if (isTemporaryReroute(pending) && !issueTemporaryThreadForward(pending, ctx)) {
2819
+ await deps.answerCallbackQuery(query.id, "🚫 Forward is pending; this button will not resend uncertain input.");
1061
2820
  return true;
1062
2821
  }
1063
2822
  await dispatchPendingRerouteMessages(pending, reroutedMessages, ctx);
1064
2823
  pending.messages = [];
1065
- const cleanupComplete = await closeReroutedUnboundTopic(sourceTarget, sourceMessageId, assertExecutionCurrent);
2824
+ // A temporary tab is removed only after every input in it resolves, never by one Forward.
2825
+ const cleanupComplete = isTemporaryTabTarget(sourceTarget) || await closeReroutedUnboundTopic(sourceTarget, sourceMessageId, assertExecutionCurrent, pending.temporaryThread);
1066
2826
  if (!cleanupComplete && sourceTarget) {
1067
2827
  pending.cleanup = {
1068
2828
  kind: "unbound",
@@ -1070,29 +2830,35 @@ export function createTelegramInboundRouteRuntime(deps) {
1070
2830
  ...(typeof sourceMessageId === "number"
1071
2831
  ? { messageId: sourceMessageId }
1072
2832
  : {}),
2833
+ ...(pending.temporaryThread ? { temporaryThread: pending.temporaryThread } : {}),
1073
2834
  };
1074
2835
  await deps.answerCallbackQuery(query.id, "Message routed, but thread cleanup is still pending. Try again.");
1075
2836
  return true;
1076
2837
  }
1077
2838
  await finalizePendingReroute(parsed.rerouteId, pending, query, "Message routed.");
2839
+ if (sourceTarget && isTemporaryTabTarget(sourceTarget))
2840
+ scheduleTemporaryThreadCleanup(sourceTarget, ctx);
1078
2841
  return true;
1079
2842
  };
1080
- const executeUnboundRerouteCallback = (query, ctx) => {
1081
- if (!parseTelegramUnboundRerouteCallbackData(query.data) ||
1082
- !deps.runWorkspaceOperation) {
1083
- return executeUnboundRerouteCallbackOperation(query, ctx);
1084
- }
1085
- return deps.runWorkspaceOperation({
1086
- operationId: `workspace-reroute:${query.id}`,
1087
- operationKind: "workspace.route-unbound-thread",
1088
- scopes: [{ kind: "profile" }],
1089
- }, () => executeUnboundRerouteCallbackOperation(query, ctx));
2843
+ const executeUnboundRerouteCallback = async (query, ctx) => {
2844
+ const parsed = parseTelegramUnboundRerouteCallbackData(query.data);
2845
+ if (!parsed)
2846
+ return false;
2847
+ return withPendingRerouteSelection(parsed.rerouteId, query, () => {
2848
+ if (!deps.runWorkspaceOperation)
2849
+ return executeUnboundRerouteCallbackOperation(query, ctx);
2850
+ return deps.runWorkspaceOperation({
2851
+ operationId: `workspace-reroute:${query.id}`,
2852
+ operationKind: "workspace.route-unbound-thread",
2853
+ scopes: [{ kind: "profile" }],
2854
+ }, () => executeUnboundRerouteCallbackOperation(query, ctx));
2855
+ });
1090
2856
  };
1091
2857
  const handleUnboundRerouteCallback = async (query, ctx) => {
1092
2858
  const parsed = parseTelegramUnboundRerouteCallbackData(query.data);
1093
2859
  const pending = parsed && pendingUnboundReroutes.get(parsed.rerouteId);
1094
2860
  if (!parsed || !pending || pending.dispatchKind !== "command" ||
1095
- pending.sourceTarget.threadId !== undefined || !matchesRerouteChooser(pending, query)) {
2861
+ (pending.sourceTarget.threadId !== undefined && !pending.temporaryThread) || !matchesRerouteChooser(pending, query)) {
1096
2862
  return executeUnboundRerouteCallback(query, ctx);
1097
2863
  }
1098
2864
  if (pending.dispatching) {
@@ -1116,7 +2882,12 @@ export function createTelegramInboundRouteRuntime(deps) {
1116
2882
  const callbackHandler = async (query, ctx) => {
1117
2883
  const assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query);
1118
2884
  assertExecutionCurrent();
1119
- if (await handleUnboundRerouteRestoreMenuCallback(query, ctx))
2885
+ if (await handleCancellationReview(query, ctx))
2886
+ return;
2887
+ if (await handleUnboundRerouteCancelCallback(query, ctx))
2888
+ return;
2889
+ const restore = parseTelegramUnboundRerouteRestoreMenuCallbackData(query.data);
2890
+ if (restore && await withPendingRerouteSelection(restore.rerouteId, query, () => executeUnboundRerouteRestoreMenuCallback(query, ctx)))
1120
2891
  return;
1121
2892
  if (await handleUnboundRerouteCallback(query, ctx))
1122
2893
  return;
@@ -1514,7 +3285,59 @@ export function createTelegramInboundRouteRuntime(deps) {
1514
3285
  reportQueueAdmission(messages, turn.admissionReceipts ?? []);
1515
3286
  });
1516
3287
  };
1517
- const sendUnboundRerouteChooserNow = async (messages, _ctx, reportDeferred = true) => {
3288
+ const recordUnboundTemporaryThreadInput = async (messages, ctx) => {
3289
+ const message = messages[0], target = message && Updates.getTelegramMessageTarget(message);
3290
+ const store = deps.getWorkspaceRestoreStore?.();
3291
+ if (!target?.threadId || !store)
3292
+ return false;
3293
+ let found = store.listTemporaryThreads().find(entry => entry.phase === "created" && entry.target &&
3294
+ entry.target.chatId === target.chatId && entry.target.threadId === target.threadId);
3295
+ const key = formatTelegramTargetKey(target), implicit = implicitThreadCreations.get(key);
3296
+ if (!found && !implicit)
3297
+ return false;
3298
+ const updateIds = Updates.collectTelegramAdmissionSourceUpdateIds(messages);
3299
+ const operatorUserId = deps.configStore.getAllowedUserId();
3300
+ const cap = captureTemporaryThreadAuthority(ctx, () => messages.every(source => Updates.getTelegramUpdateExecutionFence(source)?.isCurrent() === true &&
3301
+ source.chat.type === "private" && source.chat.id === target.chatId && source.message_thread_id === target.threadId &&
3302
+ source.from?.id === operatorUserId && source.from?.is_bot === false));
3303
+ if (!found && implicit) {
3304
+ if (updateIds.length !== 1) {
3305
+ implicitThreadCreations.delete(key);
3306
+ return false;
3307
+ }
3308
+ if (!deps.runWorkspaceOperation || !cap || implicit.ctx !== ctx || implicit.operatorUserId !== cap.operatorUserId ||
3309
+ implicit.journalBindingKey !== cap.journalBindingKey || !isDeepStrictEqual(implicit.executor, cap.authority.executor) ||
3310
+ implicit.generation !== deps.getSessionGeneration?.() || implicit.scope !== deps.getAdmissionScope?.() ||
3311
+ !updateIds.length || updateIds.some(id => id <= implicit.updateId) || !cap.isCurrent()) {
3312
+ implicitThreadCreations.delete(key);
3313
+ return false;
3314
+ }
3315
+ found = store.registerImplicitTemporaryThread({ journalBindingKey: cap.journalBindingKey, updateIds }, { chatId: target.chatId, threadId: target.threadId }, randomBytes(16).toString("hex"), {
3316
+ ...cap.authority, isCurrent: () => cap.isCurrent() && implicitThreadCreations.get(key) === implicit,
3317
+ });
3318
+ if (!found || !cap.isCurrent())
3319
+ throw new Error("Implicit Telegram Thread registration was not acknowledged; source remains protected.");
3320
+ implicitThreadCreations.delete(key);
3321
+ }
3322
+ if (!found)
3323
+ return false;
3324
+ cancelTemporaryThreadCleanup(found.token);
3325
+ if (!deps.runWorkspaceOperation || !cap || cap.operatorUserId !== found.operatorUserId ||
3326
+ cap.journalBindingKey !== found.source.journalBindingKey || !updateIds.length || !cap.isCurrent()) {
3327
+ throw new Error("Temporary Thread input membership requires exact current source authority.");
3328
+ }
3329
+ // The caller already holds the profile Workspace admission (the non-reentrant gate): no nested operation here.
3330
+ let entry = store.listTemporaryThreads().find(value => value.token === found.token &&
3331
+ isDeepStrictEqual(value.source, found.source) && isDeepStrictEqual(value.target, target));
3332
+ if (!entry)
3333
+ throw new Error("Temporary Thread input target changed before membership publication.");
3334
+ entry = cap.adopt(entry);
3335
+ if (!entry || !cap.isCurrent() || !store.recordTemporaryThreadInput(entry, { journalBindingKey: cap.journalBindingKey, updateIds }, cap.authority) || !cap.isCurrent()) {
3336
+ throw new Error("Temporary Thread input membership was not acknowledged; the source remains protected.");
3337
+ }
3338
+ return true;
3339
+ };
3340
+ const sendUnboundRerouteChooserNow = async (messages, ctx, reportDeferred = true) => {
1518
3341
  const message = messages[0];
1519
3342
  if (!message || !deps.threadStore)
1520
3343
  return;
@@ -1540,16 +3363,46 @@ export function createTelegramInboundRouteRuntime(deps) {
1540
3363
  .join("\n\n"), { parseMode: "HTML", target: sourceTarget });
1541
3364
  return;
1542
3365
  }
1543
- const rerouteId = storePendingUnboundReroute(messages);
3366
+ const command = messages.length === 1 ? getKnownTelegramAllTabCommand(Media.extractFirstTelegramMessageText(messages).trim()) : undefined;
3367
+ const rerouteId = storePendingUnboundReroute(messages, command ? "command" : "prompt");
1544
3368
  if (reportDeferred) {
1545
3369
  for (const source of messages) {
1546
3370
  Updates.reportTelegramUpdateDeferred(source);
1547
3371
  }
1548
3372
  }
1549
- const text = formatTelegramUnboundRerouteChooserText(activeRecords, {
1550
- includeGuidance,
1551
- });
1552
- const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(rerouteId, activeRecords, { canRestore: sourceTarget !== undefined, getDisplayTitle: deps.getDisplayTitle });
3373
+ const inTemporaryThread = await recordUnboundTemporaryThreadInput(messages, ctx);
3374
+ const pending = pendingUnboundReroutes.get(rerouteId);
3375
+ if (inTemporaryThread)
3376
+ pending.temporaryMembership = true;
3377
+ if (messages.length === 1 && sourceTarget &&
3378
+ message.chat.type === "private" && typeof message.text === "string" && message.text.trim() &&
3379
+ deps.runWorkspaceOperation && deps.editInteractiveMessage &&
3380
+ deps.getAdmissionJournalBinding && deps.getCurrentLeaderEpoch &&
3381
+ Updates.getTelegramUpdateExecutionFence(message)?.isCurrent() === true && deps.isContextActive?.(ctx) === true) {
3382
+ const ownerUserId = deps.configStore.getAllowedUserId();
3383
+ const journalBindingKey = deps.getAdmissionJournalBinding();
3384
+ if (ownerUserId !== undefined && message.from?.id === ownerUserId && journalBindingKey &&
3385
+ Updates.supportsTelegramDeferredAbandonment(message, journalBindingKey)) {
3386
+ const leaderEpoch = deps.getCurrentLeaderEpoch();
3387
+ if (leaderEpoch !== undefined)
3388
+ pending.abandonment = { ownerUserId, journalBindingKey, leaderEpoch };
3389
+ }
3390
+ }
3391
+ if (command && sourceTarget && !inTemporaryThread && includeGuidance && deps.callApi) {
3392
+ // Telegram can create a private tab before delivering an All command not listed in the bot menu.
3393
+ // Naming that unbound tab is not creation evidence or authority to delete it.
3394
+ Updates.createTelegramUpdateExecutionFenceGuard(message)();
3395
+ try {
3396
+ await deps.callApi("editForumTopic", { chat_id: sourceTarget.chatId, message_thread_id: sourceTarget.threadId, name: `/${command.name}` });
3397
+ }
3398
+ catch (error) {
3399
+ deps.recordRuntimeEvent?.("routing", error, { phase: "unbound-command-title" });
3400
+ }
3401
+ Updates.createTelegramUpdateExecutionFenceGuard(message)();
3402
+ }
3403
+ const text = formatTelegramTemporaryThreadChooserText(command?.name);
3404
+ pending.rootChooserText = text;
3405
+ const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(rerouteId, activeRecords, { canRestore: sourceTarget !== undefined, canCancel: !!pending.abandonment, getDisplayTitle: deps.getDisplayTitle });
1553
3406
  if (deps.sendInteractiveMessage) {
1554
3407
  const chooserId = await deps.sendInteractiveMessage(message.chat.id, text, "html", replyMarkup, sourceTarget
1555
3408
  ? { target: sourceTarget, replyToMessageId: message.message_id }
@@ -1575,7 +3428,13 @@ export function createTelegramInboundRouteRuntime(deps) {
1575
3428
  const messages = [...(existing?.messages ?? []), message];
1576
3429
  const timer = setTimeout(() => {
1577
3430
  pendingUnboundRerouteMediaGroups.delete(groupKey);
1578
- void sendUnboundRerouteChooserNow(messages, ctx, false);
3431
+ // A media group's timer runs outside any request, so it takes the same profile admission as a single message.
3432
+ const send = () => sendUnboundRerouteChooserNow(messages, ctx, false);
3433
+ const task = deps.runWorkspaceOperation
3434
+ ? deps.runWorkspaceOperation({ operationId: `workspace-unbound-group:${groupKey}:${randomBytes(8).toString("hex")}`,
3435
+ operationKind: "workspace.route-unbound-thread", scopes: [{ kind: "profile" }] }, send)
3436
+ : send();
3437
+ void task.catch(error => { deps.recordRuntimeEvent?.("routing", error, { phase: "unbound-media-group-chooser" }); });
1579
3438
  }, 1200);
1580
3439
  timer.unref?.();
1581
3440
  pendingUnboundRerouteMediaGroups.set(groupKey, { messages, timer });
@@ -1594,6 +3453,84 @@ export function createTelegramInboundRouteRuntime(deps) {
1594
3453
  }
1595
3454
  return undefined;
1596
3455
  };
3456
+ /**
3457
+ * An All input gets one source-bound tab. The original stays deferred in All until explicit routing,
3458
+ * and an existing entry is reused on replay, so restart never creates a second tab or retries an unknown one.
3459
+ */
3460
+ const sendAllTabTemporaryInputChooser = async (command, commandText, message, ctx) => {
3461
+ const [updateId] = Updates.collectTelegramAdmissionSourceUpdateIds([message]);
3462
+ const execution = Updates.getTelegramUpdateExecutionFence(message);
3463
+ const cap = captureTemporaryThreadAuthority(ctx, () => execution?.isCurrent() === true);
3464
+ if (!cap || !deps.runWorkspaceOperation || !deps.callApi || updateId === undefined ||
3465
+ message.chat.type !== "private" || message.chat.id !== cap.operatorUserId || message.from?.id !== cap.operatorUserId ||
3466
+ !cap.isCurrent())
3467
+ return false;
3468
+ const { store, operatorUserId, journalBindingKey, epoch, authority } = cap, current = cap.isCurrent;
3469
+ const source = { journalBindingKey, updateId };
3470
+ const find = () => store.listTemporaryThreads().find(entry => entry.source.journalBindingKey === journalBindingKey && entry.source.updateId === updateId);
3471
+ let entry;
3472
+ await deps.runWorkspaceOperation({ operationId: `temporary-thread-${randomBytes(16).toString("hex")}`,
3473
+ operationKind: "workspace.temporary-thread", scopes: [{ kind: "profile" }] }, async () => {
3474
+ if (!current())
3475
+ return;
3476
+ let found = find();
3477
+ found = found && cap.adopt(found);
3478
+ if (found) {
3479
+ entry = found;
3480
+ return;
3481
+ }
3482
+ const reservation = store.reserveTemporaryThread(source, randomBytes(16).toString("hex"), authority);
3483
+ entry = reservation?.entry;
3484
+ if (!reservation?.reserved || !current())
3485
+ return;
3486
+ let created;
3487
+ try {
3488
+ // The single creation attempt; any failure leaves `creating` as an unknown outcome, never a retry.
3489
+ created = await deps.callApi("createForumTopic", { chat_id: operatorUserId, name: command ? `/${command.name}` : "New chat" });
3490
+ }
3491
+ catch (error) {
3492
+ deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-create" });
3493
+ return;
3494
+ }
3495
+ const threadId = created?.message_thread_id;
3496
+ if (!current() || typeof threadId !== "number" || !Number.isSafeInteger(threadId) || threadId <= 0)
3497
+ return;
3498
+ entry = store.acknowledgeTemporaryThread(reservation.entry, { chatId: operatorUserId, threadId }, authority) ?? entry;
3499
+ });
3500
+ if (!current() || !entry)
3501
+ throw new Error("Temporary Thread authority changed; the input remains retryable.");
3502
+ const commandMessage = Updates.carryTelegramUpdateExecutionFence(message, {
3503
+ ...message, text: commandText, caption: undefined
3504
+ });
3505
+ if (entry.phase !== "created" || !entry.target) {
3506
+ Updates.reportTelegramUpdateDeferred(commandMessage);
3507
+ await deps.sendTextReply(message.chat.id, message.message_id, "<b>⚠️ The routing tab for this command could not be confirmed.</b> It stays held and was not sent to Pi.", { parseMode: "HTML" });
3508
+ return true;
3509
+ }
3510
+ const target = entry.target;
3511
+ const records = getTelegramRoutableThreadRecords(deps.threadStore?.list() ?? [], deps.getLiveThreadTargets?.());
3512
+ const rerouteId = storePendingUnboundReroute([commandMessage], command ? "command" : "prompt", target);
3513
+ const pending = pendingUnboundReroutes.get(rerouteId);
3514
+ pending.temporaryThread = entry;
3515
+ Updates.reportTelegramUpdateDeferred(commandMessage);
3516
+ // Cancel is offered only when the exact source can still be abandoned; the tab removal needs that retention first.
3517
+ if (deps.editInteractiveMessage && Updates.supportsTelegramDeferredAbandonment(commandMessage, journalBindingKey)) {
3518
+ pending.abandonment = { ownerUserId: operatorUserId, journalBindingKey, leaderEpoch: epoch };
3519
+ }
3520
+ pending.rootChooserText = formatTelegramTemporaryThreadChooserText(command?.name);
3521
+ try {
3522
+ if (!deps.sendInteractiveMessage)
3523
+ throw new Error("Temporary Thread chooser publication is unavailable.");
3524
+ const chooserId = await deps.sendInteractiveMessage(target.chatId, pending.rootChooserText, "html", buildTelegramUnboundRerouteChooserMarkup(rerouteId, records, { canRestore: true, canCancel: !!pending.abandonment,
3525
+ getDisplayTitle: deps.getDisplayTitle }), { target });
3526
+ rememberRerouteChooser(rerouteId, chooserId);
3527
+ }
3528
+ catch (error) {
3529
+ removePendingReroute(rerouteId);
3530
+ throw error;
3531
+ }
3532
+ return true;
3533
+ };
1597
3534
  const sendAllTabCommandChooser = async (command, commandText, message, options = {}) => {
1598
3535
  if (!deps.threadStore)
1599
3536
  return false;
@@ -1609,6 +3546,7 @@ export function createTelegramInboundRouteRuntime(deps) {
1609
3546
  const rerouteId = storePendingUnboundReroute([commandMessage], "command");
1610
3547
  Updates.reportTelegramUpdateDeferred(commandMessage);
1611
3548
  const text = formatTelegramAllTabMenuChooserText(command.name);
3549
+ pendingUnboundReroutes.get(rerouteId).rootChooserText = text;
1612
3550
  const replyMarkup = buildTelegramUnboundRerouteChooserMarkup(rerouteId, activeRecords, { canRestore: typeof message.message_thread_id === "number", getDisplayTitle: deps.getDisplayTitle });
1613
3551
  let chooserId;
1614
3552
  try {
@@ -1802,11 +3740,26 @@ export function createTelegramInboundRouteRuntime(deps) {
1802
3740
  assertExecutionCurrent();
1803
3741
  await deps.handleTelegramTopicLifecycleUpdate?.(lifecycle, ctx);
1804
3742
  assertExecutionCurrent();
3743
+ const key = formatTelegramTargetKey(lifecycle.target);
1805
3744
  if (lifecycle.kind !== "created" || !deps.threadStore) {
3745
+ implicitThreadCreations.delete(key);
1806
3746
  return;
1807
3747
  }
1808
3748
  await deps.threadStore.load();
1809
3749
  assertExecutionCurrent();
3750
+ const message = lifecycle.message, created = message.forum_topic_created;
3751
+ const [updateId] = Updates.collectTelegramAdmissionSourceUpdateIds([message]);
3752
+ const cap = captureTemporaryThreadAuthority(ctx, () => Updates.getTelegramUpdateExecutionFence(message)?.isCurrent() === true);
3753
+ if (created && typeof created === "object" && "is_name_implicit" in created && created.is_name_implicit === true &&
3754
+ cap && deps.runWorkspaceOperation && updateId !== undefined && !Updates.isTelegramHistoricalInput(message) &&
3755
+ message.chat.type === "private" && message.chat.id === cap.operatorUserId && message.from?.id === cap.operatorUserId &&
3756
+ message.from.is_bot === false && cap.isCurrent() && implicitThreadCreations.size < 100) {
3757
+ implicitThreadCreations.set(key, { ctx, updateId, operatorUserId: cap.operatorUserId,
3758
+ journalBindingKey: cap.journalBindingKey, executor: structuredClone(cap.authority.executor),
3759
+ generation: deps.getSessionGeneration?.(), scope: deps.getAdmissionScope?.() });
3760
+ }
3761
+ else
3762
+ implicitThreadCreations.delete(key);
1810
3763
  };
1811
3764
  // Answer the guest query immediately so the agent-end edit can replace the
1812
3765
  // early ACK once the turn settles. The ACK is the first placeholder frame and
@@ -1836,7 +3789,8 @@ export function createTelegramInboundRouteRuntime(deps) {
1836
3789
  }
1837
3790
  assertExecutionCurrent();
1838
3791
  }
1839
- const text = guestMessage.text ?? "";
3792
+ // Media messages carry the user's text as a caption.
3793
+ const text = guestMessage.text ?? guestMessage.caption ?? "";
1840
3794
  const gm = guestMessage;
1841
3795
  // Build telegram prefix with guest context
1842
3796
  const chatRaw = gm.chat;
@@ -1878,8 +3832,12 @@ export function createTelegramInboundRouteRuntime(deps) {
1878
3832
  : "";
1879
3833
  // Download files, run inbound handlers
1880
3834
  const guestMsg = guestMessage;
3835
+ // Guest message IDs belong to the peer's chat; scope files to that peer so they cannot collide with bot-chat files.
3836
+ const guestScope = resolveTelegramGuestFileScope({ chatType, chat: chatRaw, from: fromRaw, replyFrom: replyFromRaw,
3837
+ guestBotCallerUser, guestBotCallerChat, ownerUserId });
3838
+ const downloadGuestFile = (fileId, fileName, source) => deps.downloadFile(fileId, fileName, source ? { ...source, scope: guestScope } : source);
1881
3839
  const replyFiles = guestMsg.reply_to_message
1882
- ? await Media.downloadTelegramMessageFiles([guestMsg.reply_to_message], { downloadFile: deps.downloadFile })
3840
+ ? await Media.downloadTelegramMessageFiles([guestMsg.reply_to_message], { downloadFile: downloadGuestFile })
1883
3841
  : [];
1884
3842
  assertExecutionCurrent();
1885
3843
  const processedReply = replyFiles.length > 0
@@ -1887,7 +3845,7 @@ export function createTelegramInboundRouteRuntime(deps) {
1887
3845
  : undefined;
1888
3846
  assertExecutionCurrent();
1889
3847
  const files = await Media.downloadTelegramMessageFiles([guestMsg], {
1890
- downloadFile: deps.downloadFile,
3848
+ downloadFile: downloadGuestFile,
1891
3849
  });
1892
3850
  assertExecutionCurrent();
1893
3851
  const processed = await deps.inboundHandlerRuntime.process(files, text, ctx);
@@ -1957,7 +3915,7 @@ export function createTelegramInboundRouteRuntime(deps) {
1957
3915
  deps.updateStatus(ctx);
1958
3916
  requestDispatchNextQueuedTelegramTurn(ctx);
1959
3917
  };
1960
- return Updates.createTelegramPairedUpdateRuntime({
3918
+ const runtime = Updates.createTelegramPairedUpdateRuntime({
1961
3919
  getAllowedUserId: deps.configStore.getAllowedUserId,
1962
3920
  getCurrentInstanceId: deps.getCurrentInstanceId,
1963
3921
  getMessageOwnership: deps.getMessageOwnership,
@@ -1999,8 +3957,14 @@ export function createTelegramInboundRouteRuntime(deps) {
1999
3957
  const records = deps.threadStore.list();
2000
3958
  const bindings = getTelegramRoutableThreadRecords(records, deps.getLiveThreadTargets?.());
2001
3959
  const command = getKnownTelegramAllTabCommand(text);
2002
- // Returning before deferral lets the admission worker terminally settle expired replay.
2003
- if (command && command.name !== "thread" && isTelegramAllTabCommandExpired(message))
3960
+ const [sourceUpdateId] = Updates.collectTelegramAdmissionSourceUpdateIds([message]);
3961
+ const presented = sourceUpdateId !== undefined && deps.getWorkspaceRestoreStore?.()?.listTemporaryThreads().some(entry => entry.source.updateId === sourceUpdateId && entry.source.journalBindingKey === deps.getAdmissionJournalBinding?.()) === true;
3962
+ // Returning before deferral lets the admission worker terminally settle expired replay; a presented tab is not expiry.
3963
+ if (command && command.name !== "thread" && !presented && isTelegramAllTabCommandExpired(message))
3964
+ return;
3965
+ if (command?.name !== "thread" && (bindings.length > 0 || presented) &&
3966
+ typeof message.text === "string" && message.text.trim() &&
3967
+ await sendAllTabTemporaryInputChooser(command, text, message, ctx))
2004
3968
  return;
2005
3969
  if (bindings.length > 0 && command && command.name !== "thread") {
2006
3970
  if (await sendAllTabCommandChooser(command, text, message, {
@@ -2057,6 +4021,12 @@ export function createTelegramInboundRouteRuntime(deps) {
2057
4021
  }
2058
4022
  await deps.threadStore.load();
2059
4023
  assertExecutionCurrent();
4024
+ if (Updates.isTelegramHistoricalInput(message, entry => reviewMessage(entry) !== undefined) && isReviewText(message) &&
4025
+ deps.getCurrentLeaderEpoch?.() !== undefined && needsHistoricalReview(message)) {
4026
+ if (!Updates.reportTelegramHistoricalRoutingReview(message))
4027
+ throw new Error("Historical routing hold is unavailable.");
4028
+ return;
4029
+ }
2060
4030
  if (deps.threadStore.getBotState().threadMode === "disabled") {
2061
4031
  await textDispatch.handleMessage(message, ctx);
2062
4032
  return;
@@ -2140,12 +4110,8 @@ export function createTelegramInboundRouteRuntime(deps) {
2140
4110
  }
2141
4111
  const command = getKnownTelegramAllTabCommand(text);
2142
4112
  if (command && hasAnyRoutableThread) {
2143
- if (await sendAllTabCommandChooser(command, text, message, {
2144
- target: { chatId: target.chatId, threadId: target.threadId },
2145
- replyToSource: true,
2146
- })) {
2147
- return;
2148
- }
4113
+ await sendUnboundRerouteChooser(message, ctx);
4114
+ return;
2149
4115
  }
2150
4116
  if (leaderProfileKey && deps.callApi) {
2151
4117
  const currentLeaderRecord = records.find((record) => {
@@ -2231,12 +4197,12 @@ export function createTelegramInboundRouteRuntime(deps) {
2231
4197
  return;
2232
4198
  }
2233
4199
  const identityThreadName = priorLeaderIdentity?.threadName &&
2234
- Threads.isTelegramTopicThreadNameValidForSlot(priorLeaderIdentity.threadName, slot)
4200
+ ThreadNaming.isTelegramTopicThreadNameValidForSlot(priorLeaderIdentity.threadName, slot)
2235
4201
  ? priorLeaderIdentity.threadName
2236
4202
  : undefined;
2237
4203
  const threadName = priorLeaderRecord?.threadName ??
2238
4204
  identityThreadName ??
2239
- Threads.chooseTelegramThreadName({ slot }) ??
4205
+ ThreadNaming.chooseTelegramThreadName({ slot }) ??
2240
4206
  "Pi";
2241
4207
  deps.threadStore.upsert({
2242
4208
  profileKey: leaderProfileKey,
@@ -2284,6 +4250,13 @@ export function createTelegramInboundRouteRuntime(deps) {
2284
4250
  }, operation);
2285
4251
  },
2286
4252
  });
4253
+ return { ...runtime, expireRoutingInput, shouldReviewHistoricalInput, shouldHoldPendingInput, forgetPreviousWorld, beforeQueueReceiptPublished, onQueueReceiptCommitted, onQueueReceiptCompleted, onUpdateCompleted,
4254
+ onWorkspaceRestoreRecipientObserved(follower, isCurrent, ctx) {
4255
+ if (ctx !== undefined)
4256
+ return observeRestoreSettlement({ kind: "recipient", follower, isCurrent }, ctx);
4257
+ return undefined;
4258
+ },
4259
+ async waitForRestoreSettlement() { await Promise.all([...restoreSettlementTasks]); } };
2287
4260
  }
2288
4261
  export function createTelegramAssistantOutputAuthorityRuntime(deps) {
2289
4262
  const getCurrentTarget = () => {