@llblab/pi-telegram 0.52.0 → 0.52.2

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.
package/lib/locks.ts CHANGED
@@ -1489,6 +1489,8 @@ export interface TelegramLockedPollingRuntime<
1489
1489
  options?: TelegramLockedPollingStartOptions,
1490
1490
  ) => Promise<TelegramLockedPollingStartResult>;
1491
1491
  stop: () => Promise<string>;
1492
+ /** Capture one disconnect attempt before cleanup awaits; a newer start revokes it. */
1493
+ captureStop: () => { isCurrent: () => boolean; stop: () => Promise<string> };
1492
1494
  suspend: () => Promise<void>;
1493
1495
  isSuspended: () => boolean;
1494
1496
  /** Fence one owned polling generation; suspension, restart, conflict or lock loss revokes it. */
@@ -1582,6 +1584,7 @@ export function createTelegramLockedPollingRuntime<
1582
1584
  stopOwnershipWatcher();
1583
1585
  if (sessionAutoStartRun) {
1584
1586
  await sessionAutoStartRun;
1587
+ if (generation !== pollingGeneration) return;
1585
1588
  deps.stopFollowerRegistration?.();
1586
1589
  }
1587
1590
  if (ownershipStop) {
@@ -1687,6 +1690,20 @@ export function createTelegramLockedPollingRuntime<
1687
1690
  const formatStartBlockedMessage = (ctx: TContext): string =>
1688
1691
  deps.formatStartBlockedMessage?.(ctx) ??
1689
1692
  "Telegram polling is unavailable in this Pi run mode.";
1693
+ const stop = async (): Promise<string> => {
1694
+ const generation = pollingGeneration + 1;
1695
+ await suspendPolling();
1696
+ if (generation !== pollingGeneration) throw new Error("Telegram disconnect was superseded by a new connection.");
1697
+ const state = deps.lock.release();
1698
+ deps.onTransportAvailabilityChanged?.();
1699
+ if (state.kind === "active-elsewhere") {
1700
+ return `Telegram bridge is active in another Pi instance (${formatTelegramLockEntry(state.lock)}).`;
1701
+ }
1702
+ if (state.kind === "stale") {
1703
+ return `Removed stale Telegram bridge lock (${formatTelegramLockEntry(state.lock)}).`;
1704
+ }
1705
+ return "Telegram bridge disconnected.";
1706
+ };
1690
1707
  return {
1691
1708
  start: async (ctx, options = {}) => {
1692
1709
  if (!deps.hasBotToken()) {
@@ -1798,17 +1815,17 @@ export function createTelegramLockedPollingRuntime<
1798
1815
  const staleSuffix = acquired.replacedStale ? " Replaced stale lock." : "";
1799
1816
  return { ok: true, message: `Telegram bridge connected.${staleSuffix}` };
1800
1817
  },
1801
- stop: async () => {
1802
- await suspendPolling();
1803
- const state = deps.lock.release();
1804
- deps.onTransportAvailabilityChanged?.();
1805
- if (state.kind === "active-elsewhere") {
1806
- return `Telegram bridge is active in another Pi instance (${formatTelegramLockEntry(state.lock)}).`;
1807
- }
1808
- if (state.kind === "stale") {
1809
- return `Removed stale Telegram bridge lock (${formatTelegramLockEntry(state.lock)}).`;
1810
- }
1811
- return "Telegram bridge disconnected.";
1818
+ stop,
1819
+ captureStop() {
1820
+ const generation = pollingGeneration;
1821
+ const isCurrent = () => generation === pollingGeneration;
1822
+ return {
1823
+ isCurrent,
1824
+ async stop() {
1825
+ if (!isCurrent()) throw new Error("Telegram disconnect was superseded by a new connection.");
1826
+ return stop();
1827
+ },
1828
+ };
1812
1829
  },
1813
1830
  suspend: suspendPolling,
1814
1831
  captureTransportAuthority(ctx) {
package/lib/polling.ts CHANGED
@@ -25,7 +25,8 @@ export const TELEGRAM_GET_UPDATES_CONFLICT_STOP_LIMIT = 10;
25
25
  const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_LIMIT = 3;
26
26
  const TELEGRAM_GET_UPDATES_CONFLICT_FAST_RETRY_MS = 1_000;
27
27
  const TELEGRAM_GET_UPDATES_CONFLICT_SLOW_RETRY_MS = 3_000;
28
- const TELEGRAM_POLLING_RETRY_MS = 3_000;
28
+ const TELEGRAM_POLLING_RETRY_BASE_MS = 1_000;
29
+ const TELEGRAM_POLLING_RETRY_MAX_MS = 30_000;
29
30
  const TELEGRAM_GET_UPDATES_GRACE_MS = 10_000;
30
31
 
31
32
  // Standard Telegram DM polling does not expose ordinary message-deletion events.
@@ -79,6 +80,17 @@ export class TelegramPersistentGetUpdatesConflictError extends Error {
79
80
  }
80
81
  }
81
82
 
83
+ /** Exponential backoff for non-conflict polling/admission failures. */
84
+ export function getTelegramPollingRetryDelayMs(
85
+ consecutiveFailures: number,
86
+ ): number {
87
+ const exponent = Math.max(0, Math.min(consecutiveFailures - 1, 16));
88
+ return Math.min(
89
+ TELEGRAM_POLLING_RETRY_BASE_MS * 2 ** exponent,
90
+ TELEGRAM_POLLING_RETRY_MAX_MS,
91
+ );
92
+ }
93
+
82
94
  export class TelegramGetUpdatesTimeoutError extends Error {
83
95
  readonly timeoutMs: number;
84
96
 
@@ -1709,6 +1721,7 @@ export async function runTelegramPollLoop<
1709
1721
  >(deps: TelegramPollLoopDeps<TUpdate, TContext>): Promise<void> {
1710
1722
  if (!deps.config.botToken) return;
1711
1723
  let consecutiveGetUpdatesConflicts = 0;
1724
+ let consecutiveFailures = 0;
1712
1725
  const retryConflict = async () => {
1713
1726
  consecutiveGetUpdatesConflicts += 1;
1714
1727
  if (consecutiveGetUpdatesConflicts >= TELEGRAM_GET_UPDATES_CONFLICT_STOP_LIMIT) {
@@ -1791,6 +1804,7 @@ export async function runTelegramPollLoop<
1791
1804
  onPhaseChange: deps.onPhaseChange,
1792
1805
  recordRuntimeEvent: deps.recordRuntimeEvent,
1793
1806
  });
1807
+ consecutiveFailures = 0;
1794
1808
  currentUpdateId = undefined;
1795
1809
  } catch (error) {
1796
1810
  if (shouldStopTelegramPolling(deps.signal.aborted, error)) return;
@@ -1809,8 +1823,13 @@ export async function runTelegramPollLoop<
1809
1823
  : {}),
1810
1824
  });
1811
1825
  consecutiveGetUpdatesConflicts = 0;
1826
+ consecutiveFailures += 1;
1812
1827
  deps.onErrorStatus(getTelegramPollingErrorMessage(error));
1813
- await deps.sleep(TELEGRAM_POLLING_RETRY_MS, deps.signal);
1828
+ // Cap the pause, not retry count: outages must recover without manual reconnect.
1829
+ await deps.sleep(
1830
+ getTelegramPollingRetryDelayMs(consecutiveFailures),
1831
+ deps.signal,
1832
+ );
1814
1833
  if (deps.signal.aborted) return;
1815
1834
  deps.onStatusReset();
1816
1835
  }
package/lib/queue.ts CHANGED
@@ -1028,7 +1028,7 @@ export function applyTelegramQueuePromptReactionDisposition<
1028
1028
  let nextItems = items;
1029
1029
  for (const [index, item] of items.entries()) {
1030
1030
  if (
1031
- !isPendingTelegramTurn(item) ||
1031
+ !isPendingTelegramTurn(item) || item.queueLane === "control" ||
1032
1032
  !isTelegramQueueItemInMessageScope(item, scope) ||
1033
1033
  !item.sourceMessageIds.includes(messageId)
1034
1034
  ) {
@@ -2405,6 +2405,7 @@ export interface TelegramQueueMutationRuntimeDeps<
2405
2405
  TContext,
2406
2406
  > extends TelegramQueueStore<TContext>, TelegramRuntimeEventRecorderPort {
2407
2407
  ctx: TContext;
2408
+ hasPendingDispatch?: () => boolean;
2408
2409
  allocateLaneOrder?: () => number;
2409
2410
  onItemsDiscarded?: (
2410
2411
  items: readonly TelegramQueueItem<TContext>[],
@@ -2416,6 +2417,7 @@ export interface TelegramQueueMutationRuntimeDeps<
2416
2417
  export interface TelegramQueueMutationControllerDeps<
2417
2418
  TContext,
2418
2419
  > extends TelegramQueueStore<TContext>, TelegramRuntimeEventRecorderPort {
2420
+ hasPendingDispatch?: () => boolean;
2419
2421
  allocateLaneOrder?: () => number;
2420
2422
  onItemsDiscarded?: (
2421
2423
  items: readonly TelegramQueueItem<TContext>[],
@@ -2739,7 +2741,14 @@ function commitReorderedTelegramQueueItemsRuntime<TContext>(
2739
2741
  items: TelegramQueueItem<TContext>[],
2740
2742
  deps: TelegramQueueMutationRuntimeDeps<TContext>,
2741
2743
  ): void {
2742
- deps.setQueuedItems([...items].sort(compareTelegramQueueItems));
2744
+ const pendingHead = deps.hasPendingDispatch?.() ? deps.getQueuedItems()[0] : undefined;
2745
+ const ordered = [...items].sort(compareTelegramQueueItems);
2746
+ if (pendingHead) {
2747
+ const index = ordered.findIndex(item => item.queueOrder === pendingHead.queueOrder && item.kind === pendingHead.kind &&
2748
+ item.chatId === pendingHead.chatId && item.replyToMessageId === pendingHead.replyToMessageId);
2749
+ if (index > 0) ordered.unshift(ordered.splice(index, 1)[0]!);
2750
+ }
2751
+ deps.setQueuedItems(ordered);
2743
2752
  updateTelegramQueueStatusRuntime(deps);
2744
2753
  }
2745
2754
 
@@ -2817,9 +2826,23 @@ export function applyTelegramQueuePromptReactionDispositionRuntime<TContext>(
2817
2826
  scope?: TelegramQueueMessageScope,
2818
2827
  ): boolean {
2819
2828
  const queuedItems = deps.getQueuedItems();
2829
+ const suppression = disposition.kind === "reaction-transition" ? disposition.suppressionEmoji :
2830
+ disposition.kind === "suppressed" ? disposition.emoji : disposition.kind === "priority-suppressed" ? disposition.suppressionEmoji : undefined;
2831
+ // Source-addressed control-lane prompts are explicit continuations. Synthetic model-switch turns have no source IDs.
2832
+ // A Pi-owned dispatched head cannot be removed: agent_start must consume that exact item, not its successor.
2833
+ const cancelled = suppression && deps.hasPendingDispatch ? queuedItems.filter((item, index) =>
2834
+ isPendingTelegramTurn(item) && item.queueLane === "control" && isTelegramQueueItemInMessageScope(item, scope) &&
2835
+ item.sourceMessageIds.includes(messageId) && !(index === 0 && deps.hasPendingDispatch!())) : [];
2836
+ if (cancelled.length) {
2837
+ if (cancelled.some(item => (item.admissionReceipts?.length ?? 0) > 0) && !deps.onItemsDiscarded) return false;
2838
+ deps.onItemsDiscarded?.(cancelled, deps.ctx);
2839
+ deps.setQueuedItems(queuedItems.filter(item => !cancelled.includes(item)));
2840
+ updateTelegramQueueStatusRuntime(deps);
2841
+ return true;
2842
+ }
2820
2843
  const changesLane = queuedItems.some((item) => {
2821
2844
  if (
2822
- !isPendingTelegramTurn(item) ||
2845
+ !isPendingTelegramTurn(item) || item.queueLane === "control" ||
2823
2846
  !isTelegramQueueItemInMessageScope(item, scope) ||
2824
2847
  !item.sourceMessageIds.includes(messageId)
2825
2848
  ) {
package/lib/routing.ts CHANGED
@@ -289,6 +289,7 @@ function formatTelegramTemporaryThreadChooserText(command?: string): string {
289
289
  "<b>🧵 Choose target thread:</b>",
290
290
  "",
291
291
  command ? `You used <code>/${escapeHtml(command)}</code> from the <b>All</b> tab.` : "Choose where to send your message.",
292
+ "Unaccepted routing expires after 60 minutes; disposable tabs are removed when clear. Accepted work and restored Threads are kept.",
292
293
  "Select the Pi thread that should handle it, or restore a Pi into this tab:",
293
294
  ].join("\n");
294
295
  }
@@ -674,6 +675,7 @@ export interface TelegramInboundRouteRuntimeDeps<
674
675
  captureWorkspaceExternalProtection?: ReturnType<typeof createTelegramWorkspaceExternalProtectionCapture>;
675
676
  /** Strict committed abandonment plus retained original; shared by Restore and temporary-input cancellation. */
676
677
  inspectRestoreSourceAbandonment?: (updateId: number, journalBindingKey: string) => Threads.TelegramTemporaryThreadCancellationEvidence | undefined;
678
+ inspectRoutingInputGroupExpiry?: (input: Threads.TelegramTemporaryThreadInput) => readonly Threads.TelegramTemporaryThreadCancellationEvidence[] | undefined;
677
679
  /** Strict active-journal observation only; a hint or missing source never substitutes for this ACK. */
678
680
  inspectRestoreSourceCompletion?: (expected: Updates.TelegramDeferredSourceEvidence & { completionSha256: string }) =>
679
681
  (Updates.TelegramDeferredSourceEvidence & { completionSha256: string }) | undefined;
@@ -2363,22 +2365,45 @@ export function createTelegramInboundRouteRuntime<
2363
2365
  * absence of chooser and journal custody, and the ordinary protection checks must all hold again. The attempt is
2364
2366
  * durably issued once; a skipped or unknown removal keeps the entry protecting the tab across restart.
2365
2367
  */
2366
- const runTemporaryThreadCleanup = async (token: string, cap: TemporaryThreadAuthority): Promise<void> => {
2367
- const store = cap.store, inspect = deps.inspectRestoreSourceAbandonment;
2368
- if (!inspect || !deps.runWorkspaceOperation || !deps.callApi || !cap.isCurrent()) return;
2368
+ const runTemporaryThreadCleanup = async (token: string, cap: TemporaryThreadAuthority, reconcileExpiry: boolean): Promise<void> => {
2369
+ const store = cap.store;
2370
+ if ((!deps.inspectRestoreSourceAbandonment && !deps.inspectRoutingInputGroupExpiry) || !deps.runWorkspaceOperation || !deps.callApi || !cap.isCurrent()) return;
2369
2371
  await deps.runWorkspaceOperation({ operationId: `temporary-thread-cleanup-${randomBytes(16).toString("hex")}`,
2370
2372
  operationKind: "workspace.temporary-thread", scopes: [{ kind: "profile" }] }, async () => {
2371
2373
  if (!cap.isCurrent()) return;
2372
2374
  let entry = store.listTemporaryThreads().find(value => value.token === token && value.phase === "created" && !!value.target);
2373
2375
  const target = entry?.target;
2374
- if (!entry || !target || entry.cleanupIssued || !Threads.isTelegramTemporaryThreadFullyResolved(entry) || hasPendingRerouteForTarget(target) ||
2375
- entry.operatorUserId !== cap.operatorUserId || entry.source.journalBindingKey !== cap.journalBindingKey) return;
2376
- const owner = `telegram-owner:${cap.operatorUserId}`, cancelled = entry.cancelledInputs ?? [];
2377
- const cancelledCurrent = () => cancelled.every(input => input.updateIds.every(updateId => {
2378
- const evidence = inspect(updateId, input.journalBindingKey);
2379
- return evidence?.journalBindingKey === input.journalBindingKey && evidence.updateId === updateId &&
2380
- evidence.operatorAuthorityId === owner;
2381
- }));
2376
+ if (!entry || !target || entry.cleanupIssued || entry.operatorUserId !== cap.operatorUserId ||
2377
+ entry.source.journalBindingKey !== cap.journalBindingKey) return;
2378
+ const owner = `telegram-owner:${cap.operatorUserId}`;
2379
+ if (reconcileExpiry && deps.inspectRoutingInputGroupExpiry) {
2380
+ for (const group of Threads.getTelegramTemporaryThreadInputs(entry)) {
2381
+ if ([...(entry.cancelledInputs ?? []), ...(entry.completedInputs ?? [])].some(value => isDeepStrictEqual(value, group))) continue;
2382
+ const inspect = (id: number) => deps.inspectRoutingInputGroupExpiry!(group)?.find(value => value.updateId === id);
2383
+ if (!group.updateIds.every(id => inspect(id)?.operatorAuthorityId === owner)) continue;
2384
+ entry = cap.adopt(entry);
2385
+ if (!entry || !cap.isCurrent()) return;
2386
+ let recorded: Threads.TelegramTemporaryThreadEntry | undefined, failure: unknown;
2387
+ try { recorded = store.recordTemporaryThreadInputExpiry(entry, group, cap.authority, inspect); }
2388
+ catch (error) { failure = error; }
2389
+ if (!cap.isCurrent()) return;
2390
+ const retained = store.listTemporaryThreads().find(value => value.token === token);
2391
+ // A lost publication ACK may already have recorded the group or retired a bound tab's temporary frame.
2392
+ if (!retained) return;
2393
+ if (!recorded && !retained.cancelledInputs?.some(value => isDeepStrictEqual(value, group)))
2394
+ throw failure ?? new Error("Chooser expiry metadata was not confirmed.");
2395
+ entry = retained;
2396
+ }
2397
+ }
2398
+ if (!Threads.isTelegramTemporaryThreadFullyResolved(entry) || hasPendingRerouteForTarget(target)) return;
2399
+ const cancelled = entry.cancelledInputs ?? [];
2400
+ const cancelledCurrent = () => cancelled.every(input => {
2401
+ const expiry = deps.inspectRoutingInputGroupExpiry?.(input);
2402
+ return input.updateIds.every(updateId => {
2403
+ const evidence = deps.inspectRestoreSourceAbandonment?.(updateId, input.journalBindingKey) ?? expiry?.find(value => value.updateId === updateId);
2404
+ return evidence?.journalBindingKey === input.journalBindingKey && evidence.updateId === updateId && evidence.operatorAuthorityId === owner;
2405
+ });
2406
+ });
2382
2407
  if (!cancelledCurrent()) return;
2383
2408
  entry = cap.adopt(entry);
2384
2409
  if (!entry || !cap.isCurrent() || isRerouteTargetProtected(target, undefined, entry)) return;
@@ -2394,33 +2419,49 @@ export function createTelegramInboundRouteRuntime<
2394
2419
  store.retireTemporaryThread(entry, cap.authority);
2395
2420
  });
2396
2421
  };
2397
- /** Starts the quiet period only when every known group is resolved; a new input or authority change denies it. */
2398
- const scheduleTemporaryThreadCleanup = (target: Queue.TelegramQueueTarget, ctx: TContext): void => {
2399
- const cap = captureTemporaryThreadAuthority(ctx);
2400
- if (!cap || !deps.runWorkspaceOperation || target.threadId === undefined || hasPendingRerouteForTarget(target)) return;
2401
- let entry: Threads.TelegramTemporaryThreadEntry | undefined;
2402
- try { entry = cap.store.listTemporaryThreads().find(value => value.phase === "created" && isDeepStrictEqual(value.target, target)); }
2403
- catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup-schedule" }); return; }
2404
- if (!entry || entry.cleanupIssued || !Threads.isTelegramTemporaryThreadFullyResolved(entry) ||
2405
- entry.operatorUserId !== cap.operatorUserId || entry.source.journalBindingKey !== cap.journalBindingKey) return;
2406
- const token = entry.token;
2422
+ // Reuse the one timer per tab for body-free metadata retries. Future retry timers do not join settlement waits;
2423
+ // only their running attempt does. No retry is licensed once a destructive grant may have been issued.
2424
+ const queueTemporaryThreadCleanup = (token: string, cap: TemporaryThreadAuthority, delay: number, reconcileExpiry: boolean, retry = false): void => {
2407
2425
  cancelTemporaryThreadCleanup(token);
2408
- const requested = deps.temporaryThreadCleanupDelayMs;
2409
- const delay = typeof requested === "number" && Number.isFinite(requested) ? Math.max(0, requested) : 1000;
2410
2426
  let settle = (): void => undefined;
2411
2427
  const task = new Promise<void>(resolve => { settle = resolve; });
2412
2428
  const timer = setTimeout(() => {
2413
2429
  if (temporaryCleanupTimers.get(token)?.timer !== timer) return;
2414
2430
  temporaryCleanupTimers.delete(token);
2415
- runTemporaryThreadCleanup(token, cap)
2416
- .catch(error => { deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup" }); })
2431
+ if (retry) restoreSettlementTasks.add(task);
2432
+ runTemporaryThreadCleanup(token, cap, reconcileExpiry)
2433
+ .catch(error => {
2434
+ deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup" });
2435
+ if (!reconcileExpiry || !cap.isCurrent()) return;
2436
+ try {
2437
+ const retained = cap.store.listTemporaryThreads().find(value => value.token === token);
2438
+ if (retained && !retained.cleanupIssued && cap.isCurrent()) queueTemporaryThreadCleanup(token, cap, 60_000, true, true);
2439
+ } catch { /* Unknown grant state never licenses a retry. */ }
2440
+ })
2417
2441
  .finally(settle);
2418
2442
  }, delay);
2419
2443
  timer.unref?.();
2420
2444
  temporaryCleanupTimers.set(token, { timer, settle });
2421
- restoreSettlementTasks.add(task);
2445
+ if (!retry) restoreSettlementTasks.add(task);
2422
2446
  void task.finally(() => restoreSettlementTasks.delete(task));
2423
2447
  };
2448
+ /** Starts the quiet period for resolved groups or body-free expiry reconciliation; fresh input/authority revokes it. */
2449
+ const scheduleTemporaryThreadCleanup = (target: Queue.TelegramQueueTarget, ctx: TContext, reconcileExpiry = false): void => {
2450
+ const cap = captureTemporaryThreadAuthority(ctx);
2451
+ if (!cap || !deps.runWorkspaceOperation || target.threadId === undefined || hasPendingRerouteForTarget(target)) return;
2452
+ let entry: Threads.TelegramTemporaryThreadEntry | undefined;
2453
+ try { entry = cap.store.listTemporaryThreads().find(value => value.phase === "created" && isDeepStrictEqual(value.target, target)); }
2454
+ catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup-schedule" }); return; }
2455
+ if (!entry || entry.cleanupIssued || entry.operatorUserId !== cap.operatorUserId || entry.source.journalBindingKey !== cap.journalBindingKey) return;
2456
+ if (!reconcileExpiry && !Threads.isTelegramTemporaryThreadFullyResolved(entry)) {
2457
+ try { reconcileExpiry = Threads.getTelegramTemporaryThreadInputs(entry).some(group => !!deps.inspectRoutingInputGroupExpiry?.(group)); }
2458
+ catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup-schedule" }); return; }
2459
+ }
2460
+ if (!reconcileExpiry && !Threads.isTelegramTemporaryThreadFullyResolved(entry)) return;
2461
+ const requested = deps.temporaryThreadCleanupDelayMs;
2462
+ const delay = typeof requested === "number" && Number.isFinite(requested) ? Math.max(0, requested) : 1000;
2463
+ queueTemporaryThreadCleanup(entry.token, cap, delay, reconcileExpiry);
2464
+ };
2424
2465
  const recordCancelledTemporaryThreadInput = (pending: PendingUnboundReroute, ctx: TContext, isCurrent: () => boolean): boolean => {
2425
2466
  const store = deps.getWorkspaceRestoreStore?.(), target = pending.sourceTarget;
2426
2467
  if (!store || target.threadId === undefined) return !pending.temporaryThread;
@@ -2490,10 +2531,15 @@ export function createTelegramInboundRouteRuntime<
2490
2531
  await threads.load();
2491
2532
  if (!cap.isCurrent()) return;
2492
2533
  const self = cap.authority.executor.instanceId;
2493
- const disposable = cap.store.listTemporaryThreads().flatMap(entry => entry.operatorUserId === cap.operatorUserId &&
2534
+ const entries = cap.store.listTemporaryThreads();
2535
+ const preserved = entries.filter(entry => entry.operatorUserId === cap.operatorUserId && entry.phase === "created" &&
2536
+ cap.store.inspectTemporaryThreadTarget(entry, cap.authority)?.kind === "temporary" &&
2537
+ Threads.getTelegramTemporaryThreadInputs(entry).some(source => source.journalBindingKey === journalBindingKey &&
2538
+ source.updateIds.some(id => input.routingSourceIds?.includes(id)))).map(entry => entry.token);
2539
+ const disposable = entries.flatMap(entry => entry.operatorUserId === cap.operatorUserId && !preserved.includes(entry.token) &&
2494
2540
  entry.executor.instanceId !== self && entry.phase === "created" && entry.target &&
2495
2541
  cap.store.inspectTemporaryThreadTarget(entry, cap.authority)?.kind === "temporary" ? [entry] : []);
2496
- const forgotten = cap.store.forgetPreviousWorld(cap.authority);
2542
+ const forgotten = cap.store.forgetPreviousWorld(cap.authority, preserved);
2497
2543
  if (!forgotten) return;
2498
2544
  result.forgotten = forgotten.operations.length + forgotten.temporaryThreads.length;
2499
2545
  for (const entry of disposable) {
@@ -2514,6 +2560,15 @@ export function createTelegramInboundRouteRuntime<
2514
2560
  { maxAttempts: 1, retrySafety: "non-idempotent" }) === true) result.deleted++;
2515
2561
  } catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "new-world-tab-delete" }); }
2516
2562
  }
2563
+ // Same-instance session replacement may have lost a metadata-only timer after the source was already spent.
2564
+ // Reconstruct from retained tab/source proof under fresh authority, never from a prompt body or an issued delete.
2565
+ for (const entry of cap.store.listTemporaryThreads()) {
2566
+ if (!cap.isCurrent()) break;
2567
+ if (entry.phase !== "created" || !entry.target || entry.cleanupIssued || entry.operatorUserId !== cap.operatorUserId ||
2568
+ entry.source.journalBindingKey !== journalBindingKey) continue;
2569
+ if (Threads.getTelegramTemporaryThreadInputs(entry).some(group => !!deps.inspectRoutingInputGroupExpiry?.(group)))
2570
+ scheduleTemporaryThreadCleanup(entry.target, ctx, true);
2571
+ }
2517
2572
  });
2518
2573
  } catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "new-world-forget" }); }
2519
2574
  return result;
@@ -2585,25 +2640,22 @@ export function createTelegramInboundRouteRuntime<
2585
2640
  const expireRoutingInput = async (source: Updates.TelegramRoutingInputExpirySource, ctx: TContext, signal: AbortSignal): Promise<void> => {
2586
2641
  const operator = deps.configStore.getAllowedUserId(), epoch = deps.getCurrentLeaderEpoch?.();
2587
2642
  const message = source.original.update.message as Partial<TMessage> | undefined;
2588
- const current = () => !signal.aborted && source.isCurrent() && operator !== undefined && epoch !== undefined &&
2643
+ const runtimeCurrent = () => !signal.aborted && operator !== undefined && epoch !== undefined &&
2589
2644
  deps.isContextActive?.(ctx) === true && deps.configStore.getAllowedUserId() === operator &&
2590
2645
  deps.getCurrentLeaderEpoch?.() === epoch && deps.getAdmissionJournalBinding?.() === source.journalBindingKey &&
2591
2646
  source.original.routingInput?.operatorUserId === operator && message?.from?.id === operator && message.from.is_bot !== true && message.chat?.type === "private";
2647
+ const current = () => runtimeCurrent() && source.isCurrent();
2592
2648
  if (!current() || !deps.runWorkspaceOperation) return;
2593
2649
  await deps.runWorkspaceOperation({ operationId: `routing-expiry-${randomBytes(16).toString("hex")}`,
2594
2650
  operationKind: "workspace.expire-unbound-routing", scopes: [{ kind: "profile" }] }, async () => {
2595
2651
  if (!current()) return;
2596
2652
  const restores = deps.getWorkspaceRestoreStore?.();
2597
2653
  if (deps.getWorkspaceRestoreStore && !restores) return;
2598
- if (restores?.list().some(intent => intent.request.source.journalBindingKey === source.journalBindingKey &&
2599
- intent.request.source.updateIds.includes(source.original.updateId))) return;
2600
- for (const pending of pendingUnboundReroutes.values()) {
2601
- if (!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId)) continue;
2602
- if (pending.selectionAttempted || pending.destinationSelected || pending.dispatching || pending.foreignForwardIssued || pending.workspaceRestore || pending.foreignRetry || pending.cleanup || pending.abandonment?.running) return;
2603
- }
2654
+ const temporary = restores?.listTemporaryThreads().find(entry => Threads.getTelegramTemporaryThreadInputs(entry).some(input =>
2655
+ input.journalBindingKey === source.journalBindingKey && input.updateIds.includes(source.original.updateId)));
2604
2656
  const result = source.expire();
2605
2657
  if (!result) return;
2606
- // Retire local controls after the source ACK. Telegram UI failure cannot undo retention or trigger deletion.
2658
+ // Expiry revokes the donor carrier; old controls and late ACKs cannot issue another delivery. No body is archived.
2607
2659
  for (const [id, pending] of pendingUnboundReroutes) {
2608
2660
  if (!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId)) continue;
2609
2661
  removePendingReroute(id);
@@ -2615,6 +2667,9 @@ export function createTelegramInboundRouteRuntime<
2615
2667
  catch (error) { deps.recordRuntimeEvent?.("routing", error, { phase: "routing-input-expiry-view" }); }
2616
2668
  }
2617
2669
  }
2670
+ // The scheduled stage captures only context/epoch and a tab token. It reconstructs disposition from body-free proof,
2671
+ // never closes over this source or asks the worker to retain a terminal prompt while metadata publication fails.
2672
+ if (temporary?.target && runtimeCurrent()) scheduleTemporaryThreadCleanup(temporary.target, ctx, true);
2618
2673
  });
2619
2674
  };
2620
2675
  const renderCancellationReview = async (view: CancellationReview, isCurrent: () => boolean): Promise<void> => {
package/lib/sync.ts CHANGED
@@ -176,6 +176,7 @@ export interface TelegramManualThreadDisconnectDeps<TSyncState> {
176
176
  ) => void;
177
177
  runWorkspaceOperation: TelegramSyncWorkspaceOperationRunner;
178
178
  workspaceOperationKind?: string;
179
+ isDisconnectCurrent?: () => boolean;
179
180
  getNowMs?: () => number;
180
181
  }
181
182
 
@@ -270,15 +271,44 @@ export function createTelegramThreadDisconnectAssembly<
270
271
  >(
271
272
  deps: Omit<TelegramManualThreadDisconnectDeps<TSyncState>, "stopPolling"> & {
272
273
  stopPolling: () => Promise<string>;
274
+ captureStopPolling?: () => { isCurrent: () => boolean; stop: () => Promise<string> };
273
275
  suspendPolling: () => Promise<void>;
274
276
  },
275
277
  ): TelegramThreadDisconnectAssembly {
276
278
  return {
277
- disconnect: createTelegramManualThreadDisconnectHandler({
278
- ...deps,
279
- workspaceOperationKind: "workspace.disconnect-thread",
280
- stopPolling: deps.stopPolling,
281
- }),
279
+ async disconnect() {
280
+ const captured = deps.captureStopPolling?.();
281
+ const stop = captured?.stop ?? deps.stopPolling;
282
+ let stopAttempted = false;
283
+ const disconnect = createTelegramManualThreadDisconnectHandler({
284
+ ...deps,
285
+ workspaceOperationKind: "workspace.disconnect-thread",
286
+ isDisconnectCurrent: captured?.isCurrent ?? deps.isDisconnectCurrent,
287
+ stopPolling() {
288
+ stopAttempted = true;
289
+ return stop();
290
+ },
291
+ });
292
+ try {
293
+ return await disconnect();
294
+ } catch (error) {
295
+ // Never retry a stop whose teardown/release outcome may already have committed.
296
+ if (stopAttempted) {
297
+ deps.recordRuntimeEvent("connection", error, { phase: "disconnect-stop" });
298
+ throw error;
299
+ }
300
+ deps.recordRuntimeEvent("connection", error, { phase: "disconnect-cleanup" });
301
+ let stopped: string;
302
+ try {
303
+ stopped = await stop();
304
+ } catch (stopError) {
305
+ deps.recordRuntimeEvent("connection", stopError, { phase: "disconnect-stop" });
306
+ throw stopError;
307
+ }
308
+ const separator = /[.!?]$/u.test(stopped) ? " " : ". ";
309
+ return `${stopped}${separator}Thread cleanup was not confirmed. Check /telegram-status --debug.`;
310
+ }
311
+ },
282
312
  cleanupForSessionRestart:
283
313
  createTelegramSessionRestartThreadCleanupHandler({
284
314
  ...deps,
@@ -290,7 +320,11 @@ export function createTelegramThreadDisconnectAssembly<
290
320
  export function createTelegramManualThreadDisconnectHandler<
291
321
  TSyncState extends TelegramSyncState,
292
322
  >(deps: TelegramManualThreadDisconnectDeps<TSyncState>): () => Promise<string> {
323
+ const assertCurrent = () => {
324
+ if (deps.isDisconnectCurrent?.() === false) throw new Error("Telegram disconnect was superseded by a new connection.");
325
+ };
293
326
  const operation = async (): Promise<string> => {
327
+ assertCurrent();
294
328
  const currentRecord = deps.getCurrentThreadRecord();
295
329
  let cleanupPending = false;
296
330
  if (currentRecord?.target.threadId) {
@@ -302,6 +336,7 @@ export function createTelegramManualThreadDisconnectHandler<
302
336
  if (isManualFollower && !ownsLeader) {
303
337
  if (deps.disconnectFollowerThread) {
304
338
  const disconnected = await deps.disconnectFollowerThread();
339
+ assertCurrent();
305
340
  if (!disconnected) {
306
341
  throw new Error(
307
342
  "Telegram follower thread deletion requires a live leader registration.",
@@ -329,6 +364,7 @@ export function createTelegramManualThreadDisconnectHandler<
329
364
  const isCleanupTargetProtected = createTelegramCleanupTargetProtection(deps.topicTargetStore, departingRecord);
330
365
  deps.topicTargetStore.upsertPendingCleanup(intent);
331
366
  await deps.topicTargetStore.persist();
367
+ assertCurrent();
332
368
  const cleanupPlan = ThreadReconciler.planThreadReconciliation({
333
369
  nowMs: (deps.getNowMs ?? Date.now)(),
334
370
  currentLeaderEpoch: leaderEpoch,
@@ -340,9 +376,11 @@ export function createTelegramManualThreadDisconnectHandler<
340
376
  {
341
377
  isCleanupTargetProtected,
342
378
  callApi(method, body) {
379
+ assertCurrent();
343
380
  return deps.callApi(method, body);
344
381
  },
345
382
  markStaleByTarget(targetToMark, syncStatus, lastSyncError) {
383
+ assertCurrent();
346
384
  return deps.topicTargetStore.markStaleByTarget(
347
385
  targetToMark,
348
386
  syncStatus,
@@ -350,15 +388,18 @@ export function createTelegramManualThreadDisconnectHandler<
350
388
  );
351
389
  },
352
390
  removeCleanupIntentById(id) {
391
+ assertCurrent();
353
392
  return deps.topicTargetStore.removePendingCleanup(id);
354
393
  },
355
394
  persist() {
395
+ assertCurrent();
356
396
  return deps.topicTargetStore.persist();
357
397
  },
358
398
  getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
359
399
  recordRuntimeEvent: deps.recordRuntimeEvent,
360
400
  },
361
401
  );
402
+ assertCurrent();
362
403
  if (cleanupPlan.actions.some((action) => isCleanupTargetProtected(action.target, action))) {
363
404
  return "Thread disconnect superseded by a new binding.";
364
405
  }
@@ -378,6 +419,7 @@ export function createTelegramManualThreadDisconnectHandler<
378
419
  }) as TSyncState,
379
420
  );
380
421
  }
422
+ assertCurrent();
381
423
  const stopped = await deps.stopPolling();
382
424
  return cleanupPending
383
425
  ? `${stopped} Telegram thread cleanup remains pending for the next leader.`
@@ -38,6 +38,7 @@ function getTelegramApiTempDir(): string {
38
38
  return resolveTelegramAttachmentsDir();
39
39
  }
40
40
  const TELEGRAM_TEMP_FILE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
41
+ const TELEGRAM_DOWNLOAD_RENAME_MAX_ATTEMPTS = 6;
41
42
  const activeTelegramApiWorkspaceAdmissionOperationIds = new Set<string>();
42
43
  const TELEGRAM_INBOUND_FILE_MAX_BYTES = getTelegramInboundFileByteLimitFromEnv(
43
44
  process.env,
@@ -1106,6 +1107,25 @@ async function writeTelegramDownloadResponse(
1106
1107
  );
1107
1108
  }
1108
1109
 
1110
+ async function publishTelegramDownload(
1111
+ partPath: string,
1112
+ targetPath: string,
1113
+ signal?: AbortSignal,
1114
+ ): Promise<void> {
1115
+ for (let attempt = 0; ; attempt++) {
1116
+ throwIfTelegramApiCallAborted(signal);
1117
+ try {
1118
+ await rename(partPath, targetPath);
1119
+ return;
1120
+ } catch (error) {
1121
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
1122
+ // Windows may briefly deny replacement while another publisher/scanner holds the file.
1123
+ if (process.platform !== "win32" || (code !== "EPERM" && code !== "EACCES") || attempt + 1 >= TELEGRAM_DOWNLOAD_RENAME_MAX_ATTEMPTS) throw error;
1124
+ await sleepTelegramRetry(50 * 2 ** attempt, signal);
1125
+ }
1126
+ }
1127
+ }
1128
+
1109
1129
  async function removeTelegramPartialDownload(path: string): Promise<void> {
1110
1130
  try {
1111
1131
  await unlink(path);
@@ -1693,7 +1713,7 @@ export async function downloadTelegramFile(
1693
1713
  partPath,
1694
1714
  options?.maxFileSizeBytes,
1695
1715
  );
1696
- await rename(partPath, targetPath);
1716
+ await publishTelegramDownload(partPath, targetPath, options?.signal);
1697
1717
  } catch (error) {
1698
1718
  await removeTelegramPartialDownload(partPath);
1699
1719
  throw error;
@@ -1701,6 +1721,11 @@ export async function downloadTelegramFile(
1701
1721
  return targetPath;
1702
1722
  }
1703
1723
 
1724
+ /** Tooltip-like callback answers omit a terminal sentence period; ellipses and other punctuation stay literal. */
1725
+ export function formatTelegramCallbackAnswerText(text: string | undefined): string | undefined {
1726
+ return text?.replace(/(?<!\.)\.\s*$/u, "");
1727
+ }
1728
+
1704
1729
  export async function answerTelegramCallbackQuery(
1705
1730
  botToken: string | undefined,
1706
1731
  callbackQueryId: string,