@llblab/pi-telegram 0.52.0 → 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.
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> => {
@@ -1701,6 +1701,11 @@ export async function downloadTelegramFile(
1701
1701
  return targetPath;
1702
1702
  }
1703
1703
 
1704
+ /** Tooltip-like callback answers omit a terminal sentence period; ellipses and other punctuation stay literal. */
1705
+ export function formatTelegramCallbackAnswerText(text: string | undefined): string | undefined {
1706
+ return text?.replace(/(?<!\.)\.\s*$/u, "");
1707
+ }
1708
+
1704
1709
  export async function answerTelegramCallbackQuery(
1705
1710
  botToken: string | undefined,
1706
1711
  callbackQueryId: string,
package/lib/threads.ts CHANGED
@@ -332,7 +332,7 @@ export interface TelegramTemporaryThreadInput {
332
332
  journalBindingKey: string;
333
333
  updateIds: number[];
334
334
  }
335
- /** A strict journal observation of committed cancellation plus matching private retention. */
335
+ /** Journal-owned donor discard evidence: retained manual cancellation or body-free chooser expiry, never recipient cancellation. */
336
336
  export interface TelegramTemporaryThreadCancellationEvidence {
337
337
  journalBindingKey: string;
338
338
  updateId: number;
@@ -343,7 +343,7 @@ export interface TelegramTemporaryThreadEntry {
343
343
  source: { journalBindingKey: string; updateId: number };
344
344
  /** Append-only known source groups. Missing legacy metadata never proves that the creation source was alone. */
345
345
  inputs?: TelegramTemporaryThreadInput[];
346
- /** Whole known groups with positively observed cancellation; never proof that deletion may be issued. */
346
+ /** Whole known groups with positively observed donor cancellation or expiry; never recipient cancellation or deletion authority. */
347
347
  cancelledInputs?: TelegramTemporaryThreadInput[];
348
348
  /** Whole known groups whose Forward was positively completed by the journal owner; never deletion authority alone. */
349
349
  completedInputs?: TelegramTemporaryThreadInput[];
@@ -415,6 +415,10 @@ export interface TelegramWorkspaceRestore {
415
415
  recordTemporaryThreadInputCancellation(expected: TelegramTemporaryThreadEntry, input: TelegramTemporaryThreadInput,
416
416
  authority: TelegramWorkspaceRestoreAuthority,
417
417
  inspect: (updateId: number) => TelegramTemporaryThreadCancellationEvidence | undefined): TelegramTemporaryThreadEntry | undefined;
418
+ /** Body-free chooser expiry may terminate an uncertain donor Forward/Restore, never accepted recipient work or a bound target. */
419
+ recordTemporaryThreadInputExpiry(expected: TelegramTemporaryThreadEntry, input: TelegramTemporaryThreadInput,
420
+ authority: TelegramWorkspaceRestoreAuthority,
421
+ inspect: (updateId: number) => TelegramTemporaryThreadCancellationEvidence | undefined): TelegramTemporaryThreadEntry | undefined;
418
422
  /** Caller holds profile admission and proves fresh source/protection clearance; publication grants one attempt, never retry. */
419
423
  issueTemporaryThreadCleanup(expected: TelegramTemporaryThreadEntry, authority: TelegramWorkspaceRestoreAuthority):
420
424
  { issued: true; entry: TelegramTemporaryThreadEntry } | undefined;
@@ -423,8 +427,8 @@ export interface TelegramWorkspaceRestore {
423
427
  retireTemporaryThread(expected: TelegramTemporaryThreadEntry, authority: TelegramWorkspaceRestoreAuthority,
424
428
  completed?: TelegramTemporaryThreadInput): TelegramTemporaryThreadEntry | undefined;
425
429
  /** New-world restart: atomically forgets this operator's Restore intents and temporary entries from previous runtime instances.
426
- * Committed bindings stay; nothing is rolled back, replayed or deleted here. */
427
- forgetPreviousWorld(authority: TelegramWorkspaceRestoreAuthority):
430
+ * Caller may preserve exact unbound temporary tokens for clock-bearing sources. Committed bindings stay; nothing is rolled back, replayed or deleted here. */
431
+ forgetPreviousWorld(authority: TelegramWorkspaceRestoreAuthority, preserveTemporaryTokens?: readonly string[]):
428
432
  { operations: TelegramWorkspaceRestoreIntent[]; temporaryThreads: TelegramTemporaryThreadEntry[] } | undefined;
429
433
  }
430
434
  export interface TelegramWorkspaceRestoreOptions {
@@ -3595,6 +3599,40 @@ export function createTelegramTopicTargetStore(
3595
3599
  });
3596
3600
  return recorded && current() ? recorded : undefined;
3597
3601
  },
3602
+ recordTemporaryThreadInputExpiry(expected, inputValue, authority, inspect) {
3603
+ const input = structuredClone(inputValue);
3604
+ if (!isTemporaryThreadInput(input) || typeof inspect !== "function") return undefined;
3605
+ const operator = authority.operatorUserId, executor = structuredClone(authority.executor);
3606
+ const bound = classifyTemporaryTarget(read(), expected).kind === "bound";
3607
+ const current = () => authority.isCurrent() && authority.operatorUserId === operator &&
3608
+ isDeepStrictEqual(authority.executor, executor) && input.updateIds.every(updateId => {
3609
+ const evidence = inspect(updateId);
3610
+ return evidence?.journalBindingKey === input.journalBindingKey && evidence.updateId === updateId &&
3611
+ evidence.operatorAuthorityId === `telegram-owner:${operator}`;
3612
+ }) && authority.isCurrent();
3613
+ return advanceTemporary(expected, { ...authority, isCurrent: current }, (entry, file) => {
3614
+ if (entry.phase !== "created" || !getTelegramTemporaryThreadInputs(entry).some(value => isDeepStrictEqual(value, input)) ||
3615
+ entry.completedInputs?.some(value => isDeepStrictEqual(value, input))) return false;
3616
+ const cancelled = entry.cancelledInputs ?? [];
3617
+ if (cancelled.some(value => isDeepStrictEqual(value, input))) return "unchanged";
3618
+ const operations = file.operations.filter(({ request }) => request.source.journalBindingKey === input.journalBindingKey &&
3619
+ request.source.updateIds.some(id => input.updateIds.includes(id)));
3620
+ if (operations.some(({ request }) => !request.source.updateIds.every(id => input.updateIds.includes(id)))) return false;
3621
+ // Forget only expired donor intent; canonical binding, recipient queue and immutable settlement proofs are untouched.
3622
+ file.operations = file.operations.filter(value => !operations.includes(value));
3623
+ // Once bound, this is no longer a disposable tab. Forget its temporary frame, not the binding or other journal sources.
3624
+ if (bound) {
3625
+ file.temporaryThreads = file.temporaryThreads?.filter(value => value !== entry);
3626
+ if (!file.temporaryThreads?.length) delete file.temporaryThreads;
3627
+ return true;
3628
+ }
3629
+ const forwarded = entry.forwardedInputs?.filter(value => !isDeepStrictEqual(value, input));
3630
+ if (forwarded?.length) entry.forwardedInputs = forwarded;
3631
+ else delete entry.forwardedInputs;
3632
+ entry.cancelledInputs = [...cancelled, input];
3633
+ return true;
3634
+ });
3635
+ },
3598
3636
  recordTemporaryThreadForwardIssued(expected, inputValue, authority) {
3599
3637
  const input = structuredClone(inputValue);
3600
3638
  if (!isTemporaryThreadInput(input)) return undefined;
@@ -3644,7 +3682,7 @@ export function createTelegramTopicTargetStore(
3644
3682
  return entry;
3645
3683
  });
3646
3684
  },
3647
- forgetPreviousWorld(authority) {
3685
+ forgetPreviousWorld(authority, preserveTemporaryTokens = []) {
3648
3686
  if (!authority.isCurrent()) return undefined;
3649
3687
  const executor = structuredClone(authority.executor), operator = authority.operatorUserId;
3650
3688
  if (!isTelegramWorkspaceRestoreExecutor(executor) || !restoreInteger(operator)) return undefined;
@@ -3652,11 +3690,12 @@ export function createTelegramTopicTargetStore(
3652
3690
  const previous = (value: { operatorUserId: number; executor: TelegramWorkspaceRestoreExecutor }) =>
3653
3691
  value.operatorUserId === operator && value.executor.instanceId !== executor.instanceId;
3654
3692
  const before = storage.read(), file = structuredClone(before);
3655
- const operations = file.operations.filter(previous), temporaryThreads = (file.temporaryThreads ?? []).filter(previous);
3693
+ const forgetTemporary = (value: TelegramTemporaryThreadEntry) => previous(value) && !preserveTemporaryTokens.includes(value.token);
3694
+ const operations = file.operations.filter(previous), temporaryThreads = (file.temporaryThreads ?? []).filter(forgetTemporary);
3656
3695
  if (!current()) return undefined;
3657
3696
  if (!operations.length && !temporaryThreads.length) return { operations, temporaryThreads };
3658
3697
  file.operations = file.operations.filter(value => !previous(value));
3659
- const remaining = (file.temporaryThreads ?? []).filter(value => !previous(value));
3698
+ const remaining = (file.temporaryThreads ?? []).filter(value => !forgetTemporary(value));
3660
3699
  if (remaining.length) file.temporaryThreads = remaining;
3661
3700
  else delete file.temporaryThreads;
3662
3701
  if (file.revision === Number.MAX_SAFE_INTEGER) throw new Error("Workspace Restore revision exhausted.");
package/lib/updates.ts CHANGED
@@ -56,6 +56,7 @@ import {
56
56
  type TelegramUpdateJournalEntry,
57
57
  type TelegramUpdateJournalRoutingInput,
58
58
  type TelegramRoutingInputJournal,
59
+ type TelegramRoutingInputExpiryResult,
59
60
  type TelegramUpdateJournalQueueDiscardResult,
60
61
  type TelegramUpdateJournalQueueHandoffAcceptResult,
61
62
  type TelegramUpdateJournalQueueHandoffCancelResult,
@@ -495,6 +496,8 @@ export interface TelegramHeldSourcePreparation<TContext> {
495
496
  /** Exact prepared worker owner, context, key and authority; the consumer adds its own domain fences. */
496
497
  isCurrent: () => boolean;
497
498
  signal: AbortSignal;
499
+ /** Chooser-clock sources that must survive cold spending until their fixed expiry. */
500
+ routingSourceIds?: readonly number[];
498
501
  }
499
502
 
500
503
  export interface TelegramDeferredAbandonmentRecoveryPage {
@@ -1922,7 +1925,7 @@ export interface TelegramRoutingInputExpirySource {
1922
1925
  original: TelegramUpdateJournalEntry;
1923
1926
  journalBindingKey: string;
1924
1927
  isCurrent(): boolean;
1925
- expire(): TelegramUpdateJournalPendingAbandonmentResult | undefined;
1928
+ expire(): TelegramRoutingInputExpiryResult | undefined;
1926
1929
  }
1927
1930
 
1928
1931
  export interface TelegramUpdateWorkerJournalPort {
@@ -2035,7 +2038,7 @@ export interface TelegramUpdateWorkerRuntimeDeps<TContext> {
2035
2038
  getRecipientBindingKey?: () => string | undefined;
2036
2039
  /** True selects legacy historical review/spending; retain protects unsupported originals without disposition authority. */
2037
2040
  shouldReviewHistoricalInput?: (entry: TelegramUpdateJournalEntry, ctx: TContext, signal: AbortSignal) => boolean | "retain" | Promise<boolean | "retain">;
2038
- /** New-world restart: spend previous-process routing inputs (classified or armed) without delivery, copy or completion observers. Interrupted private abandonment keeps its own exact recovery. */
2041
+ /** New-world restart: spend unclocked classified routing input without delivery, copy or completion. Chooser clocks wait for expiry; interrupted private abandonment keeps exact recovery. */
2039
2042
  spendHistoricalInput?: boolean;
2040
2043
  /** Holds protected live or retry sources before execution; never cancels or disposes them. */
2041
2044
  shouldHoldPendingInput?: (entry: TelegramUpdateJournalEntry, ctx: TContext, signal: AbortSignal) => boolean | Promise<boolean>;
@@ -2090,6 +2093,7 @@ export interface TelegramUpdateWorkerRuntime<TContext> {
2090
2093
  }) => void;
2091
2094
  armRoutingInput?: (input: { updateId: number; signal: AbortSignal; operatorUserId: number; sourceUpdateIds: readonly number[] }) => TelegramUpdateJournalRoutingInput | undefined;
2092
2095
  selectRoutingInput?: (input: { updateId: number; signal: AbortSignal; operatorUserId: number; sourceUpdateIds: readonly number[] }) => boolean;
2096
+ isRoutingInputCurrent?: (input: { updateId: number; signal: AbortSignal }) => boolean;
2093
2097
  supportsDeferredAbandonment?: (input: { updateId: number; signal: AbortSignal; journalBindingKey: string }) => boolean;
2094
2098
  inspectAbandoning?: (input: TelegramDeferredAbandonmentRecoveryRequest & { signal: AbortSignal }) =>
2095
2099
  TelegramDeferredAbandonmentRecoveryPage | undefined;
@@ -3215,18 +3219,29 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
3215
3219
  if (!isTelegramUpdateJournalLegacyFamilyVersion(snapshot.version) || !expireRouting || !expireRoutingObserver) return false;
3216
3220
  let nextAtMs: number | undefined, changed = false;
3217
3221
  const binding = deps.getJournalBindingKey?.(), snapshotIds = new Set(snapshot.entries.map(entry => entry.updateId));
3218
- // Lost removal ACKs reconcile only the exact held original through the retained copy/tombstone.
3222
+ // Lost expiry ACKs reconcile the exact held original against its body-free discard tombstone.
3219
3223
  const candidates = [...snapshot.entries, ...[...deferredSources.values()]
3220
3224
  .filter(source => source.journalBindingKey === binding && !snapshotIds.has(source.entry.updateId))
3221
3225
  .map(source => source.entry)];
3222
3226
  for (const entry of candidates) {
3223
3227
  const lifetime = entry.routingInput;
3224
- if (claims.get(entry.updateId) === "retained" || (expectedOwner.startupUpdateIds?.has(entry.updateId) &&
3225
- !claims.has(entry.updateId) && deps.shouldReviewHistoricalInput)) continue;
3226
- if (!lifetime || lifetime.phase !== "waiting" || entry.state !== "pending" || !binding || unsettledExecutionsByUpdateId.get(entry.updateId)?.size) continue;
3228
+ if (!lifetime || entry.state !== "pending" || !binding || claims.get(entry.updateId) === "queued" ||
3229
+ unsettledExecutionsByUpdateId.get(entry.updateId)?.size) continue;
3230
+ // Classify the first startup snapshot before constructing any historical expiry carrier.
3231
+ if (expectedOwner.startupUpdateIds?.has(entry.updateId) && !claims.has(entry.updateId) && deps.shouldReviewHistoricalInput) {
3232
+ nextAtMs = Math.min(nextAtMs ?? Infinity, Math.max(getNowMs() + 1, lifetime.expiresAtMs));
3233
+ continue;
3234
+ }
3227
3235
  if (getNowMs() < lifetime.expiresAtMs) { nextAtMs = Math.min(nextAtMs ?? Infinity, lifetime.expiresAtMs); continue; }
3228
3236
  const existing = deferredSources.get(entry.updateId);
3229
- if (existing && !isDeepStrictEqual(existing.entry, entry)) continue;
3237
+ if (existing && !isDeepStrictEqual(existing.entry, entry)) {
3238
+ // Lost clock/selection ACKs may leave only this immutable metadata behind its exact source; reconciliation grants expiry, never another dispatch.
3239
+ const previous = existing.entry.routingInput;
3240
+ if (!isDeepStrictEqual({ ...existing.entry, routingInput: lifetime }, entry) ||
3241
+ (previous && (!isDeepStrictEqual({ ...previous, phase: lifetime.phase }, lifetime) ||
3242
+ (previous.phase === "selected" && lifetime.phase !== "selected")))) continue;
3243
+ existing.entry = structuredClone(entry);
3244
+ }
3230
3245
  const source = existing ?? { entry: structuredClone(entry), journalBindingKey: binding, historical: true as const };
3231
3246
  deferredSources.set(entry.updateId, source);
3232
3247
  if (!claims.has(entry.updateId)) claims.set(entry.updateId, "historical");
@@ -3234,7 +3249,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
3234
3249
  deps.isContextCurrent?.(expectedOwner.ctx) !== false && deps.getJournalBindingKey?.() === binding &&
3235
3250
  deps.hasAuthority(expectedOwner.ctx) && isQueueOwnerIdentityCurrent(expectedOwner) && deferredSources.get(entry.updateId) === source &&
3236
3251
  isDeepStrictEqual(source.entry, entry) && claims.get(entry.updateId) !== "queued";
3237
- let committed: TelegramUpdateJournalPendingAbandonmentResult | undefined;
3252
+ let committed: TelegramRoutingInputExpiryResult | undefined;
3238
3253
  try {
3239
3254
  if (current()) await expireRoutingObserver({ original: structuredClone(entry), journalBindingKey: binding, isCurrent: current,
3240
3255
  expire() {
@@ -3242,9 +3257,10 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
3242
3257
  if (committed) return { ...committed, duplicate: true };
3243
3258
  claims.set(entry.updateId, "abandoning");
3244
3259
  committed = expireRouting!({ entry: source.entry, journalBindingKey: binding, operatorUserId: lifetime.operatorUserId, isCurrent: current });
3260
+ // Source expiry is terminal before chooser/cleanup awaits; later failures must not retain a deferred/retry source.
3245
3261
  claims.delete(entry.updateId); deferredSources.delete(entry.updateId);
3246
3262
  state.journalEntryCount = committed.entryCount; state.journalSerializedBytes = committed.serializedBytes;
3247
- updateClaimCounts(); notifyStateChange(); changed = true;
3263
+ updateClaimCounts(); notifyStateChange(); changed ||= !committed.duplicate;
3248
3264
  return committed;
3249
3265
  } }, expectedOwner.ctx, expectedOwner.controller.signal);
3250
3266
  } catch (error) { recordRuntimeEvent(error, { phase: "routing-input-expiry", updateId: entry.updateId }); }
@@ -3445,7 +3461,7 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
3445
3461
  transition("deferred", entry.updateId);
3446
3462
  continue;
3447
3463
  }
3448
- if (review && historical && spendHistorical && classifyPending === deps.shouldReviewHistoricalInput) {
3464
+ if (review && historical && spendHistorical && !entry.routingInput && classifyPending === deps.shouldReviewHistoricalInput) {
3449
3465
  completedUpdateIds.push({ updateId: entry.updateId, spent: true });
3450
3466
  continue;
3451
3467
  }
@@ -3456,10 +3472,6 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
3456
3472
  }
3457
3473
  }
3458
3474
  if (entry.state === "pending" && entry.routingInput) {
3459
- if (historical && spendHistorical) {
3460
- completedUpdateIds.push({ updateId: entry.updateId, spent: true });
3461
- continue;
3462
- }
3463
3475
  claims.set(entry.updateId, "historical");
3464
3476
  transition("deferred", entry.updateId);
3465
3477
  continue;
@@ -3628,7 +3640,9 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
3628
3640
  expectedOwner.heldSourcesPreparedIssued = true;
3629
3641
  // Do not couple lifecycle/stop or the worker drain to a held controller/API reply.
3630
3642
  void Promise.resolve().then(() => isCurrent() ? onHeldSourcesPrepared({ ctx,
3631
- journalBindingKey: key, signal: expectedOwner.controller.signal, isCurrent }) : undefined).catch(error => {
3643
+ journalBindingKey: key, signal: expectedOwner.controller.signal, isCurrent,
3644
+ routingSourceIds: [...deferredSources].filter(([, source]) => source.journalBindingKey === key && !!source.entry.routingInput)
3645
+ .map(([id]) => id) }) : undefined).catch(error => {
3632
3646
  try { deps.recordRuntimeEvent?.("inbound-worker", error, { phase: "held-source-preparation" }); } catch { /* Diagnostic only. */ }
3633
3647
  });
3634
3648
  } catch (error) {
@@ -3784,6 +3798,10 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
3784
3798
  },
3785
3799
  armRoutingInput(input) { return mutateRoutingSource(input, false)?.lifetime; },
3786
3800
  selectRoutingInput(input) { return mutateRoutingSource(input, true)?.issued === true; },
3801
+ isRoutingInputCurrent(input) {
3802
+ return owner?.controller.signal === input.signal && !input.signal.aborted &&
3803
+ deferredSources.has(input.updateId) && claims.get(input.updateId) !== "abandoning";
3804
+ },
3787
3805
  inspectDeferredSource(input) {
3788
3806
  const expectedOwner = owner;
3789
3807
  const source = deferredSources.get(input.updateId);
@@ -3843,7 +3861,8 @@ export function createTelegramUpdateWorkerRuntime<TContext>(
3843
3861
  return;
3844
3862
  }
3845
3863
  const claim = claims.get(input.updateId);
3846
- if (claim === "abandoning" || claim === "historical" || claim === "retained") return;
3864
+ if (claim === "abandoning" || claim === "historical" || claim === "retained" ||
3865
+ (!claim && routingJournal?.inspectExpiry(input.updateId))) return;
3847
3866
  if (input.outcome.kind === "complete") {
3848
3867
  // Expiry may retire only a still-deferred source, never accepted queue work.
3849
3868
  if (claim !== "deferred") return;
@@ -4453,6 +4472,7 @@ export interface TelegramUpdateAdmissionHandleDeps<
4453
4472
  abandonDeferred?: TelegramUpdateWorkerRuntime<TContext>["abandonDeferred"];
4454
4473
  armRoutingInput?: TelegramUpdateWorkerRuntime<TContext>["armRoutingInput"];
4455
4474
  selectRoutingInput?: TelegramUpdateWorkerRuntime<TContext>["selectRoutingInput"];
4475
+ isRoutingInputCurrent?: TelegramUpdateWorkerRuntime<TContext>["isRoutingInputCurrent"];
4456
4476
  supportsDeferredAbandonment?: TelegramUpdateWorkerRuntime<TContext>["supportsDeferredAbandonment"];
4457
4477
  inspectAbandoning?: TelegramUpdateWorkerRuntime<TContext>["inspectAbandoning"];
4458
4478
  inspectHistorical?: TelegramUpdateWorkerRuntime<TContext>["inspectHistorical"];
@@ -5483,9 +5503,10 @@ export function createTelegramUpdateAdmissionHandle<
5483
5503
  generation,
5484
5504
  updateId: update.update_id,
5485
5505
  signal,
5486
- isCurrent: () => !signal.aborted && !suspended,
5506
+ isCurrent: () => !signal.aborted && !suspended && ((!routingLifetime && !routingClockUnknown) || immediate || settlementReported ||
5507
+ deps.isRoutingInputCurrent?.({ updateId: update.update_id, signal }) !== false),
5487
5508
  assertCurrent() {
5488
- if (signal.aborted || suspended) {
5509
+ if (!execution.isCurrent()) {
5489
5510
  throw signal.reason ?? new DOMException("Aborted", "AbortError");
5490
5511
  }
5491
5512
  },
@@ -5499,7 +5520,7 @@ export function createTelegramUpdateAdmissionHandle<
5499
5520
  let outcome: TelegramUpdateAdmissionOutcome | undefined;
5500
5521
  const boundUpdate = bindTelegramUpdateExecutionFence(
5501
5522
  bindTelegramUpdateAdmissionSource(update, (next) => {
5502
- if (suspended || signal.aborted) return;
5523
+ if (!execution.isCurrent()) return;
5503
5524
  if (next.kind === "deferred" && next.routingReview) {
5504
5525
  if (!immediate || settlementReported || routingClaims > 0 || !deps.isHistoricalSource?.({ updateId: update.update_id, signal })) {
5505
5526
  throw new TelegramUpdateAdmissionOutcomeError("Historical review cannot override accepted or selected work.");
@@ -5522,7 +5543,7 @@ export function createTelegramUpdateAdmissionHandle<
5522
5543
  }
5523
5544
  void Promise.resolve()
5524
5545
  .then(() => {
5525
- if (suspended || signal.aborted) return;
5546
+ if (!execution.isCurrent()) return;
5526
5547
  return deps.onLateOutcome!(next, {
5527
5548
  updateId: update.update_id,
5528
5549
  ctx,
@@ -5605,7 +5626,7 @@ export function createTelegramCustodiedUpdateAdmissionHandle<
5605
5626
  TUpdate extends TelegramJournaledUpdate & TelegramUpdateFlow,
5606
5627
  TContext,
5607
5628
  >(deps: Omit<TelegramUpdateAdmissionHandleDeps<TUpdate, TContext>,
5608
- "onLateOutcome" | "onLateOutcomeError" | "abandonDeferred" | "armRoutingInput" | "selectRoutingInput" | "supportsDeferredAbandonment" | "inspectAbandoning" | "inspectHistorical" | "inspectDeferredSource" | "isHistoricalSource"> & {
5629
+ "onLateOutcome" | "onLateOutcomeError" | "abandonDeferred" | "armRoutingInput" | "selectRoutingInput" | "isRoutingInputCurrent" | "supportsDeferredAbandonment" | "inspectAbandoning" | "inspectHistorical" | "inspectDeferredSource" | "isHistoricalSource"> & {
5609
5630
  journal: TelegramCustodyExecutionJournal;
5610
5631
  recipientBindingKey: string;
5611
5632
  onLateOutcomeError(error: unknown, updateId: number): void;
@@ -6983,6 +7004,7 @@ export function createTelegramUpdateAdmissionWorkerRuntime<
6983
7004
  abandonDeferred: input => worker?.abandonDeferred?.(input),
6984
7005
  armRoutingInput: input => worker?.armRoutingInput?.(input),
6985
7006
  selectRoutingInput: input => worker?.selectRoutingInput?.(input) === true,
7007
+ isRoutingInputCurrent: input => worker?.isRoutingInputCurrent?.(input) !== false,
6986
7008
  supportsDeferredAbandonment: input => worker?.supportsDeferredAbandonment?.(input) === true,
6987
7009
  inspectAbandoning: input => worker?.inspectAbandoning?.(input),
6988
7010
  inspectHistorical: input => worker?.inspectHistorical?.(input),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.52.0",
3
+ "version": "0.52.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"