@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.
@@ -189,6 +189,7 @@ function formatTelegramTemporaryThreadChooserText(command) {
189
189
  "<b>🧵 Choose target thread:</b>",
190
190
  "",
191
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.",
192
193
  "Select the Pi thread that should handle it, or restore a Pi into this tab:",
193
194
  ].join("\n");
194
195
  }
@@ -1902,9 +1903,9 @@ export function createTelegramInboundRouteRuntime(deps) {
1902
1903
  * absence of chooser and journal custody, and the ordinary protection checks must all hold again. The attempt is
1903
1904
  * durably issued once; a skipped or unknown removal keeps the entry protecting the tab across restart.
1904
1905
  */
1905
- const runTemporaryThreadCleanup = async (token, cap) => {
1906
- const store = cap.store, inspect = deps.inspectRestoreSourceAbandonment;
1907
- if (!inspect || !deps.runWorkspaceOperation || !deps.callApi || !cap.isCurrent())
1906
+ const runTemporaryThreadCleanup = async (token, cap, reconcileExpiry) => {
1907
+ const store = cap.store;
1908
+ if ((!deps.inspectRestoreSourceAbandonment && !deps.inspectRoutingInputGroupExpiry) || !deps.runWorkspaceOperation || !deps.callApi || !cap.isCurrent())
1908
1909
  return;
1909
1910
  await deps.runWorkspaceOperation({ operationId: `temporary-thread-cleanup-${randomBytes(16).toString("hex")}`,
1910
1911
  operationKind: "workspace.temporary-thread", scopes: [{ kind: "profile" }] }, async () => {
@@ -1912,15 +1913,48 @@ export function createTelegramInboundRouteRuntime(deps) {
1912
1913
  return;
1913
1914
  let entry = store.listTemporaryThreads().find(value => value.token === token && value.phase === "created" && !!value.target);
1914
1915
  const target = entry?.target;
1915
- if (!entry || !target || entry.cleanupIssued || !Threads.isTelegramTemporaryThreadFullyResolved(entry) || hasPendingRerouteForTarget(target) ||
1916
- entry.operatorUserId !== cap.operatorUserId || entry.source.journalBindingKey !== cap.journalBindingKey)
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))
1917
1949
  return;
1918
- const owner = `telegram-owner:${cap.operatorUserId}`, cancelled = entry.cancelledInputs ?? [];
1919
- const cancelledCurrent = () => cancelled.every(input => input.updateIds.every(updateId => {
1920
- const evidence = inspect(updateId, input.journalBindingKey);
1921
- return evidence?.journalBindingKey === input.journalBindingKey && evidence.updateId === updateId &&
1922
- evidence.operatorAuthorityId === owner;
1923
- }));
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
+ });
1924
1958
  if (!cancelledCurrent())
1925
1959
  return;
1926
1960
  entry = cap.adopt(entry);
@@ -1940,8 +1974,40 @@ export function createTelegramInboundRouteRuntime(deps) {
1940
1974
  store.retireTemporaryThread(entry, cap.authority);
1941
1975
  });
1942
1976
  };
1943
- /** Starts the quiet period only when every known group is resolved; a new input or authority change denies it. */
1944
- const scheduleTemporaryThreadCleanup = (target, ctx) => {
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) => {
1945
2011
  const cap = captureTemporaryThreadAuthority(ctx);
1946
2012
  if (!cap || !deps.runWorkspaceOperation || target.threadId === undefined || hasPendingRerouteForTarget(target))
1947
2013
  return;
@@ -1953,27 +2019,22 @@ export function createTelegramInboundRouteRuntime(deps) {
1953
2019
  deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup-schedule" });
1954
2020
  return;
1955
2021
  }
1956
- if (!entry || entry.cleanupIssued || !Threads.isTelegramTemporaryThreadFullyResolved(entry) ||
1957
- entry.operatorUserId !== cap.operatorUserId || entry.source.journalBindingKey !== cap.journalBindingKey)
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))
1958
2034
  return;
1959
- const token = entry.token;
1960
- cancelTemporaryThreadCleanup(token);
1961
2035
  const requested = deps.temporaryThreadCleanupDelayMs;
1962
2036
  const delay = typeof requested === "number" && Number.isFinite(requested) ? Math.max(0, requested) : 1000;
1963
- let settle = () => undefined;
1964
- const task = new Promise(resolve => { settle = resolve; });
1965
- const timer = setTimeout(() => {
1966
- if (temporaryCleanupTimers.get(token)?.timer !== timer)
1967
- return;
1968
- temporaryCleanupTimers.delete(token);
1969
- runTemporaryThreadCleanup(token, cap)
1970
- .catch(error => { deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup" }); })
1971
- .finally(settle);
1972
- }, delay);
1973
- timer.unref?.();
1974
- temporaryCleanupTimers.set(token, { timer, settle });
1975
- restoreSettlementTasks.add(task);
1976
- void task.finally(() => restoreSettlementTasks.delete(task));
2037
+ queueTemporaryThreadCleanup(entry.token, cap, delay, reconcileExpiry);
1977
2038
  };
1978
2039
  const recordCancelledTemporaryThreadInput = (pending, ctx, isCurrent) => {
1979
2040
  const store = deps.getWorkspaceRestoreStore?.(), target = pending.sourceTarget;
@@ -2056,10 +2117,15 @@ export function createTelegramInboundRouteRuntime(deps) {
2056
2117
  if (!cap.isCurrent())
2057
2118
  return;
2058
2119
  const self = cap.authority.executor.instanceId;
2059
- const disposable = cap.store.listTemporaryThreads().flatMap(entry => entry.operatorUserId === cap.operatorUserId &&
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) &&
2060
2126
  entry.executor.instanceId !== self && entry.phase === "created" && entry.target &&
2061
2127
  cap.store.inspectTemporaryThreadTarget(entry, cap.authority)?.kind === "temporary" ? [entry] : []);
2062
- const forgotten = cap.store.forgetPreviousWorld(cap.authority);
2128
+ const forgotten = cap.store.forgetPreviousWorld(cap.authority, preserved);
2063
2129
  if (!forgotten)
2064
2130
  return;
2065
2131
  result.forgotten = forgotten.operations.length + forgotten.temporaryThreads.length;
@@ -2087,6 +2153,17 @@ export function createTelegramInboundRouteRuntime(deps) {
2087
2153
  deps.recordRuntimeEvent?.("routing", error, { phase: "new-world-tab-delete" });
2088
2154
  }
2089
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
+ }
2090
2167
  });
2091
2168
  }
2092
2169
  catch (error) {
@@ -2172,10 +2249,11 @@ export function createTelegramInboundRouteRuntime(deps) {
2172
2249
  const expireRoutingInput = async (source, ctx, signal) => {
2173
2250
  const operator = deps.configStore.getAllowedUserId(), epoch = deps.getCurrentLeaderEpoch?.();
2174
2251
  const message = source.original.update.message;
2175
- const current = () => !signal.aborted && source.isCurrent() && operator !== undefined && epoch !== undefined &&
2252
+ const runtimeCurrent = () => !signal.aborted && operator !== undefined && epoch !== undefined &&
2176
2253
  deps.isContextActive?.(ctx) === true && deps.configStore.getAllowedUserId() === operator &&
2177
2254
  deps.getCurrentLeaderEpoch?.() === epoch && deps.getAdmissionJournalBinding?.() === source.journalBindingKey &&
2178
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();
2179
2257
  if (!current() || !deps.runWorkspaceOperation)
2180
2258
  return;
2181
2259
  await deps.runWorkspaceOperation({ operationId: `routing-expiry-${randomBytes(16).toString("hex")}`,
@@ -2185,19 +2263,11 @@ export function createTelegramInboundRouteRuntime(deps) {
2185
2263
  const restores = deps.getWorkspaceRestoreStore?.();
2186
2264
  if (deps.getWorkspaceRestoreStore && !restores)
2187
2265
  return;
2188
- if (restores?.list().some(intent => intent.request.source.journalBindingKey === source.journalBindingKey &&
2189
- intent.request.source.updateIds.includes(source.original.updateId)))
2190
- return;
2191
- for (const pending of pendingUnboundReroutes.values()) {
2192
- if (!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId))
2193
- continue;
2194
- if (pending.selectionAttempted || pending.destinationSelected || pending.dispatching || pending.foreignForwardIssued || pending.workspaceRestore || pending.foreignRetry || pending.cleanup || pending.abandonment?.running)
2195
- return;
2196
- }
2266
+ const temporary = restores?.listTemporaryThreads().find(entry => Threads.getTelegramTemporaryThreadInputs(entry).some(input => input.journalBindingKey === source.journalBindingKey && input.updateIds.includes(source.original.updateId)));
2197
2267
  const result = source.expire();
2198
2268
  if (!result)
2199
2269
  return;
2200
- // Retire local controls after the source ACK. Telegram UI failure cannot undo retention or trigger deletion.
2270
+ // Expiry revokes the donor carrier; old controls and late ACKs cannot issue another delivery. No body is archived.
2201
2271
  for (const [id, pending] of pendingUnboundReroutes) {
2202
2272
  if (!Updates.collectTelegramAdmissionSourceUpdateIds(pending.messages).includes(source.original.updateId))
2203
2273
  continue;
@@ -2214,6 +2284,10 @@ export function createTelegramInboundRouteRuntime(deps) {
2214
2284
  }
2215
2285
  }
2216
2286
  }
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);
2217
2291
  });
2218
2292
  };
2219
2293
  const renderCancellationReview = async (view, isCurrent) => {
@@ -514,6 +514,8 @@ export declare function fetchTelegramBotIdentity(botToken: string, fetchImpl?: t
514
514
  */
515
515
  export declare function callTelegramMultipart<TResponse>(botToken: string | undefined, method: string, fields: Record<string, string>, fileField: string, filePath: string, fileName: string, options?: TelegramApiCallOptions): Promise<TResponse>;
516
516
  export declare function downloadTelegramFile(botToken: string | undefined, fileId: string, suggestedName: string, tempDir: string, options?: TelegramFileDownloadOptions): Promise<string>;
517
+ /** Tooltip-like callback answers omit a terminal sentence period; ellipses and other punctuation stay literal. */
518
+ export declare function formatTelegramCallbackAnswerText(text: string | undefined): string | undefined;
517
519
  export declare function answerTelegramCallbackQuery(botToken: string | undefined, callbackQueryId: string, text?: string, options?: TelegramAnswerCallbackQueryOptions): Promise<void>;
518
520
  export declare function createTelegramChatActionSender<TAction extends string>(sendChatAction: (chatId: number, action: TAction, options?: {
519
521
  message_thread_id?: number;
@@ -795,6 +795,10 @@ export async function downloadTelegramFile(botToken, fileId, suggestedName, temp
795
795
  }
796
796
  return targetPath;
797
797
  }
798
+ /** Tooltip-like callback answers omit a terminal sentence period; ellipses and other punctuation stay literal. */
799
+ export function formatTelegramCallbackAnswerText(text) {
800
+ return text?.replace(/(?<!\.)\.\s*$/u, "");
801
+ }
798
802
  export async function answerTelegramCallbackQuery(botToken, callbackQueryId, text, options = {}) {
799
803
  try {
800
804
  await callTelegram(botToken, "answerCallbackQuery", text
@@ -252,7 +252,7 @@ export interface TelegramTemporaryThreadInput {
252
252
  journalBindingKey: string;
253
253
  updateIds: number[];
254
254
  }
255
- /** A strict journal observation of committed cancellation plus matching private retention. */
255
+ /** Journal-owned donor discard evidence: retained manual cancellation or body-free chooser expiry, never recipient cancellation. */
256
256
  export interface TelegramTemporaryThreadCancellationEvidence {
257
257
  journalBindingKey: string;
258
258
  updateId: number;
@@ -266,7 +266,7 @@ export interface TelegramTemporaryThreadEntry {
266
266
  };
267
267
  /** Append-only known source groups. Missing legacy metadata never proves that the creation source was alone. */
268
268
  inputs?: TelegramTemporaryThreadInput[];
269
- /** Whole known groups with positively observed cancellation; never proof that deletion may be issued. */
269
+ /** Whole known groups with positively observed donor cancellation or expiry; never recipient cancellation or deletion authority. */
270
270
  cancelledInputs?: TelegramTemporaryThreadInput[];
271
271
  /** Whole known groups whose Forward was positively completed by the journal owner; never deletion authority alone. */
272
272
  completedInputs?: TelegramTemporaryThreadInput[];
@@ -361,6 +361,8 @@ export interface TelegramWorkspaceRestore {
361
361
  recordTemporaryThreadForwardIssued(expected: TelegramTemporaryThreadEntry, input: TelegramTemporaryThreadInput, authority: TelegramWorkspaceRestoreAuthority): TelegramTemporaryThreadEntry | undefined;
362
362
  recordTemporaryThreadInputCompletion(expected: TelegramTemporaryThreadEntry, input: TelegramTemporaryThreadInput, authority: TelegramWorkspaceRestoreAuthority): TelegramTemporaryThreadEntry | undefined;
363
363
  recordTemporaryThreadInputCancellation(expected: TelegramTemporaryThreadEntry, input: TelegramTemporaryThreadInput, authority: TelegramWorkspaceRestoreAuthority, inspect: (updateId: number) => TelegramTemporaryThreadCancellationEvidence | undefined): TelegramTemporaryThreadEntry | undefined;
364
+ /** Body-free chooser expiry may terminate an uncertain donor Forward/Restore, never accepted recipient work or a bound target. */
365
+ recordTemporaryThreadInputExpiry(expected: TelegramTemporaryThreadEntry, input: TelegramTemporaryThreadInput, authority: TelegramWorkspaceRestoreAuthority, inspect: (updateId: number) => TelegramTemporaryThreadCancellationEvidence | undefined): TelegramTemporaryThreadEntry | undefined;
364
366
  /** Caller holds profile admission and proves fresh source/protection clearance; publication grants one attempt, never retry. */
365
367
  issueTemporaryThreadCleanup(expected: TelegramTemporaryThreadEntry, authority: TelegramWorkspaceRestoreAuthority): {
366
368
  issued: true;
@@ -370,8 +372,8 @@ export interface TelegramWorkspaceRestore {
370
372
  /** `completed` names one newly completed group; every other known group must already be cancelled or completed. */
371
373
  retireTemporaryThread(expected: TelegramTemporaryThreadEntry, authority: TelegramWorkspaceRestoreAuthority, completed?: TelegramTemporaryThreadInput): TelegramTemporaryThreadEntry | undefined;
372
374
  /** New-world restart: atomically forgets this operator's Restore intents and temporary entries from previous runtime instances.
373
- * Committed bindings stay; nothing is rolled back, replayed or deleted here. */
374
- forgetPreviousWorld(authority: TelegramWorkspaceRestoreAuthority): {
375
+ * Caller may preserve exact unbound temporary tokens for clock-bearing sources. Committed bindings stay; nothing is rolled back, replayed or deleted here. */
376
+ forgetPreviousWorld(authority: TelegramWorkspaceRestoreAuthority, preserveTemporaryTokens?: readonly string[]): {
375
377
  operations: TelegramWorkspaceRestoreIntent[];
376
378
  temporaryThreads: TelegramTemporaryThreadEntry[];
377
379
  } | undefined;
@@ -2750,6 +2750,47 @@ export function createTelegramTopicTargetStore(options) {
2750
2750
  });
2751
2751
  return recorded && current() ? recorded : undefined;
2752
2752
  },
2753
+ recordTemporaryThreadInputExpiry(expected, inputValue, authority, inspect) {
2754
+ const input = structuredClone(inputValue);
2755
+ if (!isTemporaryThreadInput(input) || typeof inspect !== "function")
2756
+ return undefined;
2757
+ const operator = authority.operatorUserId, executor = structuredClone(authority.executor);
2758
+ const bound = classifyTemporaryTarget(read(), expected).kind === "bound";
2759
+ const current = () => authority.isCurrent() && authority.operatorUserId === operator &&
2760
+ isDeepStrictEqual(authority.executor, executor) && input.updateIds.every(updateId => {
2761
+ const evidence = inspect(updateId);
2762
+ return evidence?.journalBindingKey === input.journalBindingKey && evidence.updateId === updateId &&
2763
+ evidence.operatorAuthorityId === `telegram-owner:${operator}`;
2764
+ }) && authority.isCurrent();
2765
+ return advanceTemporary(expected, { ...authority, isCurrent: current }, (entry, file) => {
2766
+ if (entry.phase !== "created" || !getTelegramTemporaryThreadInputs(entry).some(value => isDeepStrictEqual(value, input)) ||
2767
+ entry.completedInputs?.some(value => isDeepStrictEqual(value, input)))
2768
+ return false;
2769
+ const cancelled = entry.cancelledInputs ?? [];
2770
+ if (cancelled.some(value => isDeepStrictEqual(value, input)))
2771
+ return "unchanged";
2772
+ const operations = file.operations.filter(({ request }) => request.source.journalBindingKey === input.journalBindingKey &&
2773
+ request.source.updateIds.some(id => input.updateIds.includes(id)));
2774
+ if (operations.some(({ request }) => !request.source.updateIds.every(id => input.updateIds.includes(id))))
2775
+ return false;
2776
+ // Forget only expired donor intent; canonical binding, recipient queue and immutable settlement proofs are untouched.
2777
+ file.operations = file.operations.filter(value => !operations.includes(value));
2778
+ // Once bound, this is no longer a disposable tab. Forget its temporary frame, not the binding or other journal sources.
2779
+ if (bound) {
2780
+ file.temporaryThreads = file.temporaryThreads?.filter(value => value !== entry);
2781
+ if (!file.temporaryThreads?.length)
2782
+ delete file.temporaryThreads;
2783
+ return true;
2784
+ }
2785
+ const forwarded = entry.forwardedInputs?.filter(value => !isDeepStrictEqual(value, input));
2786
+ if (forwarded?.length)
2787
+ entry.forwardedInputs = forwarded;
2788
+ else
2789
+ delete entry.forwardedInputs;
2790
+ entry.cancelledInputs = [...cancelled, input];
2791
+ return true;
2792
+ });
2793
+ },
2753
2794
  recordTemporaryThreadForwardIssued(expected, inputValue, authority) {
2754
2795
  const input = structuredClone(inputValue);
2755
2796
  if (!isTemporaryThreadInput(input))
@@ -2808,7 +2849,7 @@ export function createTelegramTopicTargetStore(options) {
2808
2849
  return entry;
2809
2850
  });
2810
2851
  },
2811
- forgetPreviousWorld(authority) {
2852
+ forgetPreviousWorld(authority, preserveTemporaryTokens = []) {
2812
2853
  if (!authority.isCurrent())
2813
2854
  return undefined;
2814
2855
  const executor = structuredClone(authority.executor), operator = authority.operatorUserId;
@@ -2817,13 +2858,14 @@ export function createTelegramTopicTargetStore(options) {
2817
2858
  const current = () => authority.isCurrent() && authority.operatorUserId === operator && isDeepStrictEqual(authority.executor, executor);
2818
2859
  const previous = (value) => value.operatorUserId === operator && value.executor.instanceId !== executor.instanceId;
2819
2860
  const before = storage.read(), file = structuredClone(before);
2820
- const operations = file.operations.filter(previous), temporaryThreads = (file.temporaryThreads ?? []).filter(previous);
2861
+ const forgetTemporary = (value) => previous(value) && !preserveTemporaryTokens.includes(value.token);
2862
+ const operations = file.operations.filter(previous), temporaryThreads = (file.temporaryThreads ?? []).filter(forgetTemporary);
2821
2863
  if (!current())
2822
2864
  return undefined;
2823
2865
  if (!operations.length && !temporaryThreads.length)
2824
2866
  return { operations, temporaryThreads };
2825
2867
  file.operations = file.operations.filter(value => !previous(value));
2826
- const remaining = (file.temporaryThreads ?? []).filter(value => !previous(value));
2868
+ const remaining = (file.temporaryThreads ?? []).filter(value => !forgetTemporary(value));
2827
2869
  if (remaining.length)
2828
2870
  file.temporaryThreads = remaining;
2829
2871
  else
@@ -6,7 +6,7 @@
6
6
  import { type TelegramTarget } from "./target.ts";
7
7
  import type { TelegramBusEnvelope, TelegramBusFollowerView, TelegramBusForwardOwnership, TelegramBusForeignUpdateSettlement, TelegramProcessLiveness } from "./bus.ts";
8
8
  import type { TelegramMessageOwnershipStore } from "./ownership.ts";
9
- import { TELEGRAM_UPDATE_JOURNAL_VERSION, TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION, TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION, type TelegramUpdateJournalEntryDigest, type TelegramUpdateJournalSourceCompletion, type TelegramUpdateJournalQueuedCompletion, type TelegramUpdateJournalQueuedReceiptEvidence, type TelegramInputJournalReceipt, type TelegramInputJournalSourceReference, type TelegramInputJournalStore, type TelegramJournaledUpdate, type TelegramUpdateJournalDeadQueueOwnerRecoveryResult, type TelegramUpdateJournalAppendResult, type TelegramUpdateJournalInputClaim, type TelegramUpdateJournalOperatorDispositionInput, type TelegramUpdateJournalOperatorDispositionResult, type TelegramUpdateJournalPendingAbandonmentInput, type TelegramUpdateJournalPendingAbandonmentResult, type TelegramUpdateJournalPendingRetentionEvidence, type TelegramUpdateJournalEntry, type TelegramUpdateJournalRoutingInput, type TelegramRoutingInputJournal, type TelegramUpdateJournalQueueDiscardResult, type TelegramUpdateJournalQueueHandoffAcceptResult, type TelegramUpdateJournalQueueHandoffCancelResult, type TelegramUpdateJournalQueueHandoffInput, type TelegramUpdateJournalQueueHandoffOfferResult, type TelegramUpdateJournalQueueOwner, type TelegramUpdateJournalQueueOwnerIdentity } from "./journal.ts";
9
+ import { TELEGRAM_UPDATE_JOURNAL_VERSION, TELEGRAM_UPDATE_JOURNAL_EXCLUSION_VERSION, TELEGRAM_UPDATE_JOURNAL_CUSTODY_VERSION, type TelegramUpdateJournalEntryDigest, type TelegramUpdateJournalSourceCompletion, type TelegramUpdateJournalQueuedCompletion, type TelegramUpdateJournalQueuedReceiptEvidence, type TelegramInputJournalReceipt, type TelegramInputJournalSourceReference, type TelegramInputJournalStore, type TelegramJournaledUpdate, type TelegramUpdateJournalDeadQueueOwnerRecoveryResult, type TelegramUpdateJournalAppendResult, type TelegramUpdateJournalInputClaim, type TelegramUpdateJournalOperatorDispositionInput, type TelegramUpdateJournalOperatorDispositionResult, type TelegramUpdateJournalPendingAbandonmentInput, type TelegramUpdateJournalPendingAbandonmentResult, type TelegramUpdateJournalPendingRetentionEvidence, type TelegramUpdateJournalEntry, type TelegramUpdateJournalRoutingInput, type TelegramRoutingInputJournal, type TelegramRoutingInputExpiryResult, type TelegramUpdateJournalQueueDiscardResult, type TelegramUpdateJournalQueueHandoffAcceptResult, type TelegramUpdateJournalQueueHandoffCancelResult, type TelegramUpdateJournalQueueHandoffInput, type TelegramUpdateJournalQueueHandoffOfferResult, type TelegramUpdateJournalQueueOwner, type TelegramUpdateJournalQueueOwnerIdentity } from "./journal.ts";
10
10
  import { type PendingTelegramControlItem, type TelegramControlQueueHandoffPayload, type TelegramQueueAdmissionReceipt, type TelegramQueueHandoffPayload, type TelegramQueueHandoffStageResult, type TelegramQueueReactionDisposition, type TelegramQueueHandoffStagingRuntime, type TelegramQueueItem } from "./queue.ts";
11
11
  import { type TelegramAuthorizationState, type TelegramUserPairingRuntimeDeps } from "./config.ts";
12
12
  export interface TelegramReactionTypeEmoji {
@@ -189,6 +189,8 @@ export interface TelegramHeldSourcePreparation<TContext> {
189
189
  /** Exact prepared worker owner, context, key and authority; the consumer adds its own domain fences. */
190
190
  isCurrent: () => boolean;
191
191
  signal: AbortSignal;
192
+ /** Chooser-clock sources that must survive cold spending until their fixed expiry. */
193
+ routingSourceIds?: readonly number[];
192
194
  }
193
195
  export interface TelegramDeferredAbandonmentRecoveryPage {
194
196
  sources: {
@@ -504,7 +506,7 @@ export interface TelegramRoutingInputExpirySource {
504
506
  original: TelegramUpdateJournalEntry;
505
507
  journalBindingKey: string;
506
508
  isCurrent(): boolean;
507
- expire(): TelegramUpdateJournalPendingAbandonmentResult | undefined;
509
+ expire(): TelegramRoutingInputExpiryResult | undefined;
508
510
  }
509
511
  export interface TelegramUpdateWorkerJournalPort {
510
512
  routingInputs?: TelegramRoutingInputJournal;
@@ -585,7 +587,7 @@ export interface TelegramUpdateWorkerRuntimeDeps<TContext> {
585
587
  getRecipientBindingKey?: () => string | undefined;
586
588
  /** True selects legacy historical review/spending; retain protects unsupported originals without disposition authority. */
587
589
  shouldReviewHistoricalInput?: (entry: TelegramUpdateJournalEntry, ctx: TContext, signal: AbortSignal) => boolean | "retain" | Promise<boolean | "retain">;
588
- /** 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. */
590
+ /** New-world restart: spend unclocked classified routing input without delivery, copy or completion. Chooser clocks wait for expiry; interrupted private abandonment keeps exact recovery. */
589
591
  spendHistoricalInput?: boolean;
590
592
  /** Holds protected live or retry sources before execution; never cancels or disposes them. */
591
593
  shouldHoldPendingInput?: (entry: TelegramUpdateJournalEntry, ctx: TContext, signal: AbortSignal) => boolean | Promise<boolean>;
@@ -633,6 +635,10 @@ export interface TelegramUpdateWorkerRuntime<TContext> {
633
635
  operatorUserId: number;
634
636
  sourceUpdateIds: readonly number[];
635
637
  }) => boolean;
638
+ isRoutingInputCurrent?: (input: {
639
+ updateId: number;
640
+ signal: AbortSignal;
641
+ }) => boolean;
636
642
  supportsDeferredAbandonment?: (input: {
637
643
  updateId: number;
638
644
  signal: AbortSignal;
@@ -744,6 +750,7 @@ export interface TelegramUpdateAdmissionHandleDeps<TUpdate extends TelegramUpdat
744
750
  abandonDeferred?: TelegramUpdateWorkerRuntime<TContext>["abandonDeferred"];
745
751
  armRoutingInput?: TelegramUpdateWorkerRuntime<TContext>["armRoutingInput"];
746
752
  selectRoutingInput?: TelegramUpdateWorkerRuntime<TContext>["selectRoutingInput"];
753
+ isRoutingInputCurrent?: TelegramUpdateWorkerRuntime<TContext>["isRoutingInputCurrent"];
747
754
  supportsDeferredAbandonment?: TelegramUpdateWorkerRuntime<TContext>["supportsDeferredAbandonment"];
748
755
  inspectAbandoning?: TelegramUpdateWorkerRuntime<TContext>["inspectAbandoning"];
749
756
  inspectHistorical?: TelegramUpdateWorkerRuntime<TContext>["inspectHistorical"];
@@ -1135,7 +1142,7 @@ export declare function executeTelegramCustodiedInput(input: {
1135
1142
  export declare function createTelegramUpdateAdmissionHandle<TUpdate extends TelegramUpdateFlow & {
1136
1143
  update_id: number;
1137
1144
  }, TContext>(deps: TelegramUpdateAdmissionHandleDeps<TUpdate, TContext>): (update: TUpdate, ctx: TContext, signal: AbortSignal) => Promise<TelegramUpdateAdmissionOutcome>;
1138
- export declare function createTelegramCustodiedUpdateAdmissionHandle<TUpdate extends TelegramJournaledUpdate & TelegramUpdateFlow, TContext>(deps: Omit<TelegramUpdateAdmissionHandleDeps<TUpdate, TContext>, "onLateOutcome" | "onLateOutcomeError" | "abandonDeferred" | "armRoutingInput" | "selectRoutingInput" | "supportsDeferredAbandonment" | "inspectAbandoning" | "inspectHistorical" | "inspectDeferredSource" | "isHistoricalSource"> & {
1145
+ export declare function createTelegramCustodiedUpdateAdmissionHandle<TUpdate extends TelegramJournaledUpdate & TelegramUpdateFlow, TContext>(deps: Omit<TelegramUpdateAdmissionHandleDeps<TUpdate, TContext>, "onLateOutcome" | "onLateOutcomeError" | "abandonDeferred" | "armRoutingInput" | "selectRoutingInput" | "isRoutingInputCurrent" | "supportsDeferredAbandonment" | "inspectAbandoning" | "inspectHistorical" | "inspectDeferredSource" | "isHistoricalSource"> & {
1139
1146
  journal: TelegramCustodyExecutionJournal;
1140
1147
  recipientBindingKey: string;
1141
1148
  onLateOutcomeError(error: unknown, updateId: number): void;
@@ -1701,24 +1701,34 @@ export function createTelegramUpdateWorkerRuntime(deps) {
1701
1701
  return false;
1702
1702
  let nextAtMs, changed = false;
1703
1703
  const binding = deps.getJournalBindingKey?.(), snapshotIds = new Set(snapshot.entries.map(entry => entry.updateId));
1704
- // Lost removal ACKs reconcile only the exact held original through the retained copy/tombstone.
1704
+ // Lost expiry ACKs reconcile the exact held original against its body-free discard tombstone.
1705
1705
  const candidates = [...snapshot.entries, ...[...deferredSources.values()]
1706
1706
  .filter(source => source.journalBindingKey === binding && !snapshotIds.has(source.entry.updateId))
1707
1707
  .map(source => source.entry)];
1708
1708
  for (const entry of candidates) {
1709
1709
  const lifetime = entry.routingInput;
1710
- if (claims.get(entry.updateId) === "retained" || (expectedOwner.startupUpdateIds?.has(entry.updateId) &&
1711
- !claims.has(entry.updateId) && deps.shouldReviewHistoricalInput))
1710
+ if (!lifetime || entry.state !== "pending" || !binding || claims.get(entry.updateId) === "queued" ||
1711
+ unsettledExecutionsByUpdateId.get(entry.updateId)?.size)
1712
1712
  continue;
1713
- if (!lifetime || lifetime.phase !== "waiting" || entry.state !== "pending" || !binding || unsettledExecutionsByUpdateId.get(entry.updateId)?.size)
1713
+ // Classify the first startup snapshot before constructing any historical expiry carrier.
1714
+ if (expectedOwner.startupUpdateIds?.has(entry.updateId) && !claims.has(entry.updateId) && deps.shouldReviewHistoricalInput) {
1715
+ nextAtMs = Math.min(nextAtMs ?? Infinity, Math.max(getNowMs() + 1, lifetime.expiresAtMs));
1714
1716
  continue;
1717
+ }
1715
1718
  if (getNowMs() < lifetime.expiresAtMs) {
1716
1719
  nextAtMs = Math.min(nextAtMs ?? Infinity, lifetime.expiresAtMs);
1717
1720
  continue;
1718
1721
  }
1719
1722
  const existing = deferredSources.get(entry.updateId);
1720
- if (existing && !isDeepStrictEqual(existing.entry, entry))
1721
- continue;
1723
+ if (existing && !isDeepStrictEqual(existing.entry, entry)) {
1724
+ // Lost clock/selection ACKs may leave only this immutable metadata behind its exact source; reconciliation grants expiry, never another dispatch.
1725
+ const previous = existing.entry.routingInput;
1726
+ if (!isDeepStrictEqual({ ...existing.entry, routingInput: lifetime }, entry) ||
1727
+ (previous && (!isDeepStrictEqual({ ...previous, phase: lifetime.phase }, lifetime) ||
1728
+ (previous.phase === "selected" && lifetime.phase !== "selected"))))
1729
+ continue;
1730
+ existing.entry = structuredClone(entry);
1731
+ }
1722
1732
  const source = existing ?? { entry: structuredClone(entry), journalBindingKey: binding, historical: true };
1723
1733
  deferredSources.set(entry.updateId, source);
1724
1734
  if (!claims.has(entry.updateId))
@@ -1738,13 +1748,14 @@ export function createTelegramUpdateWorkerRuntime(deps) {
1738
1748
  return { ...committed, duplicate: true };
1739
1749
  claims.set(entry.updateId, "abandoning");
1740
1750
  committed = expireRouting({ entry: source.entry, journalBindingKey: binding, operatorUserId: lifetime.operatorUserId, isCurrent: current });
1751
+ // Source expiry is terminal before chooser/cleanup awaits; later failures must not retain a deferred/retry source.
1741
1752
  claims.delete(entry.updateId);
1742
1753
  deferredSources.delete(entry.updateId);
1743
1754
  state.journalEntryCount = committed.entryCount;
1744
1755
  state.journalSerializedBytes = committed.serializedBytes;
1745
1756
  updateClaimCounts();
1746
1757
  notifyStateChange();
1747
- changed = true;
1758
+ changed ||= !committed.duplicate;
1748
1759
  return committed;
1749
1760
  } }, expectedOwner.ctx, expectedOwner.controller.signal);
1750
1761
  }
@@ -1964,7 +1975,7 @@ export function createTelegramUpdateWorkerRuntime(deps) {
1964
1975
  transition("deferred", entry.updateId);
1965
1976
  continue;
1966
1977
  }
1967
- if (review && historical && spendHistorical && classifyPending === deps.shouldReviewHistoricalInput) {
1978
+ if (review && historical && spendHistorical && !entry.routingInput && classifyPending === deps.shouldReviewHistoricalInput) {
1968
1979
  completedUpdateIds.push({ updateId: entry.updateId, spent: true });
1969
1980
  continue;
1970
1981
  }
@@ -1975,10 +1986,6 @@ export function createTelegramUpdateWorkerRuntime(deps) {
1975
1986
  }
1976
1987
  }
1977
1988
  if (entry.state === "pending" && entry.routingInput) {
1978
- if (historical && spendHistorical) {
1979
- completedUpdateIds.push({ updateId: entry.updateId, spent: true });
1980
- continue;
1981
- }
1982
1989
  claims.set(entry.updateId, "historical");
1983
1990
  transition("deferred", entry.updateId);
1984
1991
  continue;
@@ -2123,7 +2130,9 @@ export function createTelegramUpdateWorkerRuntime(deps) {
2123
2130
  expectedOwner.heldSourcesPreparedIssued = true;
2124
2131
  // Do not couple lifecycle/stop or the worker drain to a held controller/API reply.
2125
2132
  void Promise.resolve().then(() => isCurrent() ? onHeldSourcesPrepared({ ctx,
2126
- journalBindingKey: key, signal: expectedOwner.controller.signal, isCurrent }) : undefined).catch(error => {
2133
+ journalBindingKey: key, signal: expectedOwner.controller.signal, isCurrent,
2134
+ routingSourceIds: [...deferredSources].filter(([, source]) => source.journalBindingKey === key && !!source.entry.routingInput)
2135
+ .map(([id]) => id) }) : undefined).catch(error => {
2127
2136
  try {
2128
2137
  deps.recordRuntimeEvent?.("inbound-worker", error, { phase: "held-source-preparation" });
2129
2138
  }
@@ -2286,6 +2295,10 @@ export function createTelegramUpdateWorkerRuntime(deps) {
2286
2295
  },
2287
2296
  armRoutingInput(input) { return mutateRoutingSource(input, false)?.lifetime; },
2288
2297
  selectRoutingInput(input) { return mutateRoutingSource(input, true)?.issued === true; },
2298
+ isRoutingInputCurrent(input) {
2299
+ return owner?.controller.signal === input.signal && !input.signal.aborted &&
2300
+ deferredSources.has(input.updateId) && claims.get(input.updateId) !== "abandoning";
2301
+ },
2289
2302
  inspectDeferredSource(input) {
2290
2303
  const expectedOwner = owner;
2291
2304
  const source = deferredSources.get(input.updateId);
@@ -2346,7 +2359,8 @@ export function createTelegramUpdateWorkerRuntime(deps) {
2346
2359
  return;
2347
2360
  }
2348
2361
  const claim = claims.get(input.updateId);
2349
- if (claim === "abandoning" || claim === "historical" || claim === "retained")
2362
+ if (claim === "abandoning" || claim === "historical" || claim === "retained" ||
2363
+ (!claim && routingJournal?.inspectExpiry(input.updateId)))
2350
2364
  return;
2351
2365
  if (input.outcome.kind === "complete") {
2352
2366
  // Expiry may retire only a still-deferred source, never accepted queue work.
@@ -3551,9 +3565,10 @@ export function createTelegramUpdateAdmissionHandle(deps) {
3551
3565
  generation,
3552
3566
  updateId: update.update_id,
3553
3567
  signal,
3554
- isCurrent: () => !signal.aborted && !suspended,
3568
+ isCurrent: () => !signal.aborted && !suspended && ((!routingLifetime && !routingClockUnknown) || immediate || settlementReported ||
3569
+ deps.isRoutingInputCurrent?.({ updateId: update.update_id, signal }) !== false),
3555
3570
  assertCurrent() {
3556
- if (signal.aborted || suspended) {
3571
+ if (!execution.isCurrent()) {
3557
3572
  throw signal.reason ?? new DOMException("Aborted", "AbortError");
3558
3573
  }
3559
3574
  },
@@ -3567,7 +3582,7 @@ export function createTelegramUpdateAdmissionHandle(deps) {
3567
3582
  let settlementReported = false;
3568
3583
  let outcome;
3569
3584
  const boundUpdate = bindTelegramUpdateExecutionFence(bindTelegramUpdateAdmissionSource(update, (next) => {
3570
- if (suspended || signal.aborted)
3585
+ if (!execution.isCurrent())
3571
3586
  return;
3572
3587
  if (next.kind === "deferred" && next.routingReview) {
3573
3588
  if (!immediate || settlementReported || routingClaims > 0 || !deps.isHistoricalSource?.({ updateId: update.update_id, signal })) {
@@ -3586,7 +3601,7 @@ export function createTelegramUpdateAdmissionHandle(deps) {
3586
3601
  }
3587
3602
  void Promise.resolve()
3588
3603
  .then(() => {
3589
- if (suspended || signal.aborted)
3604
+ if (!execution.isCurrent())
3590
3605
  return;
3591
3606
  return deps.onLateOutcome(next, {
3592
3607
  updateId: update.update_id,
@@ -4487,6 +4502,7 @@ export function createTelegramUpdateAdmissionWorkerRuntime(deps) {
4487
4502
  abandonDeferred: input => worker?.abandonDeferred?.(input),
4488
4503
  armRoutingInput: input => worker?.armRoutingInput?.(input),
4489
4504
  selectRoutingInput: input => worker?.selectRoutingInput?.(input) === true,
4505
+ isRoutingInputCurrent: input => worker?.isRoutingInputCurrent?.(input) !== false,
4490
4506
  supportsDeferredAbandonment: input => worker?.supportsDeferredAbandonment?.(input) === true,
4491
4507
  inspectAbandoning: input => worker?.inspectAbandoning?.(input),
4492
4508
  inspectHistorical: input => worker?.inspectHistorical?.(input),
package/dist/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"