@llblab/pi-telegram 0.55.0 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/AGENTS.md +3 -2
  2. package/BACKLOG.md +4 -1
  3. package/CHANGELOG.md +14 -0
  4. package/README.md +27 -18
  5. package/dist/lib/bindings.d.ts +8 -4
  6. package/dist/lib/bindings.js +41 -1
  7. package/dist/lib/bus-follower.d.ts +1 -0
  8. package/dist/lib/bus-follower.js +5 -1
  9. package/dist/lib/bus-leader.js +9 -1
  10. package/dist/lib/bus.d.ts +5 -0
  11. package/dist/lib/bus.js +17 -6
  12. package/dist/lib/extension.js +14 -2
  13. package/dist/lib/lifecycle.d.ts +2 -1
  14. package/dist/lib/lifecycle.js +28 -20
  15. package/dist/lib/menu-queue.d.ts +1 -0
  16. package/dist/lib/menu-queue.js +102 -99
  17. package/dist/lib/pi.d.ts +3 -2
  18. package/dist/lib/pi.js +3 -0
  19. package/dist/lib/polling.js +3 -1
  20. package/dist/lib/queue.d.ts +57 -5
  21. package/dist/lib/queue.js +313 -25
  22. package/dist/lib/routing.d.ts +1 -1
  23. package/dist/lib/routing.js +261 -29
  24. package/dist/lib/status.d.ts +1 -2
  25. package/dist/lib/status.js +6 -7
  26. package/dist/lib/turns.d.ts +4 -0
  27. package/dist/lib/turns.js +6 -0
  28. package/dist/lib/updates.d.ts +10 -6
  29. package/dist/lib/updates.js +20 -4
  30. package/dist/package.json +1 -1
  31. package/docs/README.md +1 -1
  32. package/docs/architecture.md +17 -14
  33. package/docs/callback-namespaces.md +4 -0
  34. package/docs/multi-instance-bus.md +4 -2
  35. package/docs/ui-style.md +14 -10
  36. package/lib/bindings.ts +52 -3
  37. package/lib/bus-follower.ts +5 -0
  38. package/lib/bus-leader.ts +9 -0
  39. package/lib/bus.ts +20 -3
  40. package/lib/extension.ts +14 -1
  41. package/lib/lifecycle.ts +34 -28
  42. package/lib/menu-queue.ts +116 -162
  43. package/lib/pi.ts +8 -0
  44. package/lib/polling.ts +3 -1
  45. package/lib/queue.ts +328 -33
  46. package/lib/routing.ts +299 -29
  47. package/lib/status.ts +7 -7
  48. package/lib/turns.ts +7 -0
  49. package/lib/updates.ts +24 -10
  50. package/package.json +1 -1
package/lib/routing.ts CHANGED
@@ -1558,7 +1558,7 @@ export interface TelegramInboundRouteRuntimeDeps<
1558
1558
  requiredJournalBindingKeys: readonly string[],
1559
1559
  ownInputs?: readonly Threads.TelegramTemporaryThreadInput[],
1560
1560
  ) => readonly number[] | undefined;
1561
- /** Quiet period after the last cancelled input before one cleanup attempt; defaults to 1000 ms. */
1561
+ /** Quiet period after the last cancelled input before one cleanup attempt; defaults to 0 ms. */
1562
1562
  temporaryThreadCleanupDelayMs?: number;
1563
1563
  /** Live-rebind cleanup attempt pacing; the bounded window ends in `not-issued`, never a time-based deletion. */
1564
1564
  liveRebindCleanupSchedule?: {
@@ -1943,10 +1943,21 @@ export function createTelegramInboundRouteRuntime<
1943
1943
  temporaryThread?: Threads.TelegramTemporaryThreadEntry;
1944
1944
  /** Source copies left in All (a menu-picked command); deleted once, when the input is consumed. */
1945
1945
  allTabCopies?: number[];
1946
- /** The tab holds a forwarded copy of the threadless original, which then left All at once. */
1947
- allTabCopiesForwarded?: true;
1946
+ /** This live opening attempt did not remove its All originals; replay/revival cannot reconstruct that fact. */
1947
+ allTabSourceRetained?: true;
1948
+ /** Complete warm copy identity; journal originals stay unchanged even after their All messages leave. */
1949
+ allTabCopiesForwarded?: {
1950
+ target: { chatId: number; threadId: number };
1951
+ sourceMessageIds: readonly number[];
1952
+ messageIds: readonly number[];
1953
+ };
1948
1954
  /** Acknowledged same-tab membership stays protective when its reader disappears; never a dispatch/cleanup grant. */
1949
1955
  temporaryMembership?: true;
1956
+ /** Confirmed display copies only; the durable Forward fact remains the native one-shot authority. */
1957
+ rerouteCopies?: {
1958
+ target: { chatId: number; threadId: number };
1959
+ bySourceUpdateId: ReadonlyMap<number, number>;
1960
+ };
1950
1961
  /** Re-entrancy lock for one command route in flight; orthogonal to `phase`. */
1951
1962
  dispatching?: boolean;
1952
1963
  abandonment?: {
@@ -2415,15 +2426,22 @@ export function createTelegramInboundRouteRuntime<
2415
2426
  const cloneTelegramMessagesForThread = (
2416
2427
  messages: TMessage[],
2417
2428
  threadId: number,
2429
+ copies?: ReadonlyMap<number, number>,
2418
2430
  ): TMessage[] => {
2419
- return messages.map((message) =>
2420
- Updates.carryTelegramUpdateExecutionFence(message, {
2431
+ return messages.map((message) => {
2432
+ const ids = Updates.collectTelegramAdmissionSourceUpdateIds([message]);
2433
+ const copyId = copies && ids.length === 1 ? copies.get(ids[0]!) : undefined;
2434
+ if (copies && (!Number.isSafeInteger(copyId) || copyId! <= 0))
2435
+ throw new Error("Reroute destination copy identity is unavailable.");
2436
+ const routed = {
2421
2437
  ...message,
2422
- message_id: 0,
2438
+ message_id: copyId ?? 0,
2423
2439
  message_thread_id: threadId,
2424
2440
  reply_to_message: undefined,
2425
- } as TMessage),
2426
- );
2441
+ } as TMessage;
2442
+ return Updates.carryTelegramUpdateExecutionFence(message,
2443
+ copies ? Turns.withTelegramTurnReplyAnchor(routed) : routed);
2444
+ });
2427
2445
  };
2428
2446
  /** Whether a chooser is still pending in this Thread, ignoring the ones the caller itself owns. */
2429
2447
  const hasPendingRerouteForTarget = (
@@ -2819,9 +2837,11 @@ export function createTelegramInboundRouteRuntime<
2819
2837
  const deleteAllTabMessages = async (
2820
2838
  chatId: number,
2821
2839
  messageIds: readonly number[],
2840
+ isCurrent?: () => boolean,
2822
2841
  ): Promise<void> => {
2823
2842
  if (!deps.deleteMessage) return;
2824
2843
  for (const messageId of messageIds) {
2844
+ if (isCurrent?.() === false) return;
2825
2845
  try {
2826
2846
  await deps.deleteMessage(chatId, messageId);
2827
2847
  } catch (error) {
@@ -2834,10 +2854,14 @@ export function createTelegramInboundRouteRuntime<
2834
2854
  }
2835
2855
  };
2836
2856
  /** One-shot per chooser: the copies are taken before deletion, so a later consuming path finds none. */
2837
- const deleteAllTabCopies = (pending: PendingUnboundReroute): Promise<void> =>
2857
+ const deleteAllTabCopies = (
2858
+ pending: PendingUnboundReroute,
2859
+ isCurrent?: () => boolean,
2860
+ ): Promise<void> =>
2838
2861
  deleteAllTabMessages(
2839
2862
  pending.sourceTarget.chatId,
2840
2863
  pending.allTabCopies?.splice(0) ?? [],
2864
+ isCurrent,
2841
2865
  );
2842
2866
  /**
2843
2867
  * Restore keeps the tab as the Thread, so the input must stay visible there: the All original goes only when the tab
@@ -2846,7 +2870,11 @@ export function createTelegramInboundRouteRuntime<
2846
2870
  const releaseAllTabCopiesIntoSurvivingTab = (
2847
2871
  pending: PendingUnboundReroute,
2848
2872
  ): Promise<void> => {
2849
- if (pending.allTabCopiesForwarded) return deleteAllTabCopies(pending);
2873
+ if (
2874
+ pending.allTabCopiesForwarded &&
2875
+ isDeepStrictEqual(pending.allTabCopiesForwarded.target, pending.sourceTarget)
2876
+ )
2877
+ return deleteAllTabCopies(pending);
2850
2878
  pending.allTabCopies?.splice(0);
2851
2879
  return Promise.resolve();
2852
2880
  };
@@ -2862,26 +2890,38 @@ export function createTelegramInboundRouteRuntime<
2862
2890
  ): Promise<number | undefined> => {
2863
2891
  const messageIds = [...(pending.allTabCopies ?? [])].sort((a, b) => a - b);
2864
2892
  if (messageIds.length === 0 || !deps.callApi || !isCurrent()) return;
2893
+ const copyTarget = { ...target };
2865
2894
  const route = {
2866
- chat_id: target.chatId,
2867
- message_thread_id: target.threadId,
2895
+ chat_id: copyTarget.chatId,
2896
+ message_thread_id: copyTarget.threadId,
2868
2897
  from_chat_id: pending.sourceTarget.chatId,
2869
2898
  disable_notification: true,
2870
2899
  };
2900
+ const options = {
2901
+ maxAttempts: 1,
2902
+ retrySafety: "non-idempotent" as const,
2903
+ assertAuthority() {
2904
+ if (!isCurrent())
2905
+ throw new Error("Temporary Thread source forwarding authority changed.");
2906
+ },
2907
+ };
2871
2908
  try {
2872
2909
  // An album moves as one album; forwardMessages skips what it cannot forward, so a short reply is not a move.
2873
2910
  const copies =
2874
2911
  messageIds.length === 1
2875
2912
  ? [
2876
- await deps.callApi<{ message_id?: unknown }>("forwardMessage", {
2877
- ...route,
2878
- message_id: messageIds[0],
2879
- }),
2913
+ await deps.callApi<{ message_id?: unknown }>(
2914
+ "forwardMessage",
2915
+ { ...route, message_id: messageIds[0] },
2916
+ options,
2917
+ ),
2880
2918
  ]
2881
2919
  : await deps.callApi<Array<{ message_id?: unknown }>>(
2882
2920
  "forwardMessages",
2883
2921
  { ...route, message_ids: messageIds },
2922
+ options,
2884
2923
  );
2924
+ if (!isCurrent()) return;
2885
2925
  const copyIds = Array.isArray(copies)
2886
2926
  ? copies.map((copy) => copy?.message_id)
2887
2927
  : [];
@@ -2889,20 +2929,88 @@ export function createTelegramInboundRouteRuntime<
2889
2929
  copyIds.length !== messageIds.length ||
2890
2930
  !copyIds.every(
2891
2931
  (id): id is number =>
2892
- typeof id === "number" && Number.isSafeInteger(id),
2893
- )
2932
+ typeof id === "number" && Number.isSafeInteger(id) && id > 0,
2933
+ ) ||
2934
+ new Set(copyIds).size !== copyIds.length
2894
2935
  )
2895
2936
  return;
2896
- pending.allTabCopiesForwarded = true;
2897
- await deleteAllTabCopies(pending);
2898
- return copyIds[0];
2937
+ pending.allTabCopiesForwarded = {
2938
+ target: copyTarget,
2939
+ sourceMessageIds: messageIds,
2940
+ messageIds: copyIds,
2941
+ };
2942
+ await deleteAllTabCopies(pending, isCurrent);
2943
+ if (!isCurrent()) return;
2944
+ return pending.allTabCopiesForwarded.messageIds[0];
2899
2945
  } catch (error) {
2900
- deps.recordRuntimeEvent?.("routing", error, {
2901
- phase: "temporary-thread-source-forward",
2902
- });
2946
+ if (isCurrent())
2947
+ deps.recordRuntimeEvent?.("routing", error, {
2948
+ phase: "temporary-thread-source-forward",
2949
+ });
2903
2950
  return;
2904
2951
  }
2905
2952
  };
2953
+ /** Read original source addresses, not warm target rewrites; unknown copy provenance never grants a Reroute. */
2954
+ const resolvePendingRerouteForwardSource = (
2955
+ pending: PendingUnboundReroute,
2956
+ ): { chatId: number; sourceUpdateIds: number[]; sourceMessageIds: number[]; messageIds: number[] } | undefined => {
2957
+ try {
2958
+ const originals = pending.messages.map((message) => {
2959
+ const snapshot = Updates.inspectTelegramDeferredSourceSnapshot(message);
2960
+ const ids = Updates.collectTelegramAdmissionSourceUpdateIds([message]);
2961
+ const original = snapshot?.update.message as TMessage | undefined;
2962
+ if (
2963
+ !snapshot ||
2964
+ ids.length !== 1 ||
2965
+ snapshot.source.updateId !== ids[0] ||
2966
+ snapshot.update.update_id !== ids[0] ||
2967
+ Object.keys(snapshot.update).some((key) => key !== "update_id" && key !== "message") ||
2968
+ snapshot.source.journalBindingKey !== deps.getAdmissionJournalBinding?.() ||
2969
+ !original ||
2970
+ original.chat?.type !== "private" ||
2971
+ original.chat.id !== pending.sourceTarget.chatId ||
2972
+ original.from?.id !== deps.configStore.getAllowedUserId() ||
2973
+ original.from?.is_bot !== false ||
2974
+ !Number.isSafeInteger(original.message_id) ||
2975
+ original.message_id <= 0
2976
+ )
2977
+ return undefined;
2978
+ return { message: original, updateId: ids[0]! };
2979
+ });
2980
+ if (originals.length === 0 || originals.some((source) => !source)) return;
2981
+ originals.sort((a, b) => a!.message.message_id - b!.message.message_id);
2982
+ const sourceMessageIds = originals.map((source) => source!.message.message_id);
2983
+ const sourceUpdateIds = originals.map((source) => source!.updateId);
2984
+ if (
2985
+ new Set(sourceMessageIds).size !== sourceMessageIds.length ||
2986
+ new Set(sourceUpdateIds).size !== sourceUpdateIds.length
2987
+ )
2988
+ return;
2989
+ const copied = pending.allTabCopiesForwarded;
2990
+ if (copied) {
2991
+ if (
2992
+ !isDeepStrictEqual(copied.target, pending.sourceTarget) ||
2993
+ !isDeepStrictEqual(copied.sourceMessageIds, sourceMessageIds) ||
2994
+ copied.messageIds.length !== sourceMessageIds.length ||
2995
+ !copied.messageIds.every((id) => Number.isSafeInteger(id) && id > 0) ||
2996
+ new Set(copied.messageIds).size !== copied.messageIds.length
2997
+ )
2998
+ return;
2999
+ return { chatId: copied.target.chatId, sourceUpdateIds, sourceMessageIds, messageIds: [...copied.messageIds] };
3000
+ }
3001
+ const nativeOriginals = originals.every((source) =>
3002
+ typeof pending.sourceTarget.threadId === "number" &&
3003
+ source!.message.message_thread_id === pending.sourceTarget.threadId);
3004
+ const retainedAllOriginals = pending.allTabSourceRetained === true &&
3005
+ originals.every((source) => source!.message.message_thread_id === undefined) &&
3006
+ isDeepStrictEqual([...(pending.allTabCopies ?? [])].sort((a, b) => a - b), sourceMessageIds);
3007
+ if (nativeOriginals || retainedAllOriginals)
3008
+ return { chatId: pending.sourceTarget.chatId, sourceUpdateIds, sourceMessageIds, messageIds: [...sourceMessageIds] };
3009
+ } catch (error) {
3010
+ deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-source-proof" });
3011
+ }
3012
+ return undefined;
3013
+ };
2906
3014
  const dismissRerouteChooserMessage = async (
2907
3015
  query: TCallbackQuery,
2908
3016
  assertExecutionCurrent?: () => void,
@@ -6172,7 +6280,21 @@ export function createTelegramInboundRouteRuntime<
6172
6280
  for (const wake of [...liveRebindCleanupWakers.values()]) wake(reason);
6173
6281
  };
6174
6282
  const onSessionSettled = (ctx: TContext): void => wakeLiveRebindCleanup(ctx, "session-settled");
6175
- const onDeliverySettled = (ctx: TContext): void => wakeLiveRebindCleanup(ctx, "delivery-drained");
6283
+ const onDeliverySettled = (ctx: TContext): void => {
6284
+ wakeLiveRebindCleanup(ctx, "delivery-drained");
6285
+ const cap = captureTemporaryThreadAuthority(ctx);
6286
+ if (!cap?.isCurrent()) return;
6287
+ try {
6288
+ for (const entry of cap.store.listTemporaryThreads()) {
6289
+ if (!cap.isCurrent()) return;
6290
+ if (entry.phase === "created" && entry.target && !entry.cleanupIssued &&
6291
+ Threads.isTelegramTemporaryThreadFullyResolved(entry))
6292
+ scheduleTemporaryThreadCleanup(entry.target, ctx);
6293
+ }
6294
+ } catch (error) {
6295
+ deps.recordRuntimeEvent?.("routing", error, { phase: "temporary-thread-cleanup-drain" });
6296
+ }
6297
+ };
6176
6298
  const restoreWorkspace = deps.workspaceRestoreRecipient
6177
6299
  ? async (input: {
6178
6300
  operationId: string;
@@ -6802,6 +6924,97 @@ export function createTelegramInboundRouteRuntime<
6802
6924
  return false;
6803
6925
  }
6804
6926
  };
6927
+ /** Publish display copies under the native one-shot grant; positive output is not input/source settlement. */
6928
+ const publishPendingRerouteCopies = async (
6929
+ pending: PendingUnboundReroute,
6930
+ record: Threads.TelegramTopicTargetRecord,
6931
+ ctx: TContext,
6932
+ assertExecutionCurrent: () => void,
6933
+ foreign: boolean,
6934
+ ): Promise<(() => void) | undefined> => {
6935
+ const source = resolvePendingRerouteForwardSource(pending);
6936
+ const target = { ...record.target };
6937
+ const recordIdentity = (value: Threads.TelegramTopicTargetRecord | undefined) => value && ({
6938
+ target: value.target, profileKey: value.profileKey, owner: value.owner,
6939
+ instanceId: value.instanceId, slot: value.slot, createdAtMs: value.createdAtMs,
6940
+ });
6941
+ const bindingIdentity = () => deps.threadStore!.listWorkspaceBindings()
6942
+ .filter((value) => isDeepStrictEqual(value.target, target))
6943
+ .map((value) => ({ bindingKey: value.bindingKey, sessionId: value.sessionId, sessionKey: value.sessionKey,
6944
+ cwd: value.cwd, workspaceKey: value.workspaceKey, instanceSlot: value.instanceSlot, slot: value.slot,
6945
+ target: value.target }));
6946
+ const expectedRecord = structuredClone(recordIdentity(record));
6947
+ const expectedBindings = structuredClone(bindingIdentity());
6948
+ const ownership = deps.getTargetOwnership?.(target);
6949
+ if (
6950
+ !source || !deps.callApi || expectedBindings.length > 1 ||
6951
+ (foreign && (!ownership || ownership.instanceId !== record.instanceId ||
6952
+ !ownership.ownerGeneration || !ownership.recipientBindingKey))
6953
+ )
6954
+ return;
6955
+ const expectedOwnership = ownership && structuredClone(ownership);
6956
+ const cap = captureTemporaryThreadAuthority(ctx, () => {
6957
+ try {
6958
+ assertExecutionCurrent();
6959
+ const current = getTelegramRoutableThreadRecords(deps.threadStore!.list(), deps.getLiveThreadTargets?.())
6960
+ .find((value) => isDeepStrictEqual(value.target, target));
6961
+ return isDeepStrictEqual(recordIdentity(current), expectedRecord) &&
6962
+ isDeepStrictEqual(bindingIdentity(), expectedBindings) &&
6963
+ (!foreign || Bus.isTelegramBusForwardOwnershipCurrent(expectedOwnership!, deps.getTargetOwnership?.(target)));
6964
+ } catch { return false; }
6965
+ });
6966
+ if (!cap?.isCurrent()) return;
6967
+ const temporary = pending.temporaryThread ?? findCreatedTemporaryThread(pending.sourceTarget);
6968
+ if (!temporary || !issueTemporaryThreadForward(pending, ctx)) return;
6969
+ pending.phase = { kind: "forward-unknown" };
6970
+ const assertRecipientCurrent = (): void => {
6971
+ assertExecutionCurrent();
6972
+ if (!cap.isCurrent()) throw new Error("Reroute publication recipient authority changed.");
6973
+ };
6974
+ const assertPublicationCurrent = (): void => {
6975
+ assertRecipientCurrent();
6976
+ const entry = cap.store.listTemporaryThreads().find((value) => value.token === temporary.token);
6977
+ const ids = [...source.sourceUpdateIds].sort((a, b) => a - b);
6978
+ if (
6979
+ !entry || entry.phase !== "created" || entry.cleanupIssued ||
6980
+ entry.operatorUserId !== cap.operatorUserId ||
6981
+ !isDeepStrictEqual(entry.executor, cap.authority.executor) ||
6982
+ !isDeepStrictEqual(entry.target, pending.sourceTarget) ||
6983
+ !entry.forwardedInputs?.some((input) => input.journalBindingKey === cap.journalBindingKey &&
6984
+ isDeepStrictEqual(input.updateIds, ids)) ||
6985
+ !isDeepStrictEqual(resolvePendingRerouteForwardSource(pending), source)
6986
+ )
6987
+ throw new Error("Reroute publication source authority changed.");
6988
+ };
6989
+ try {
6990
+ assertPublicationCurrent();
6991
+ // Bot API requires ascending source IDs. Preserve the source-UID association even if copy IDs reorder them.
6992
+ const order = source.messageIds.map((_, index) => index)
6993
+ .sort((a, b) => source.messageIds[a]! - source.messageIds[b]!);
6994
+ const messageIds = order.map((index) => source.messageIds[index]!);
6995
+ const body = { chat_id: target.chatId, message_thread_id: target.threadId,
6996
+ from_chat_id: source.chatId, disable_notification: true };
6997
+ const options = { maxAttempts: 1, retrySafety: "non-idempotent" as const, assertAuthority: assertPublicationCurrent };
6998
+ const copies = messageIds.length === 1
6999
+ ? [await deps.callApi<{ message_id?: unknown }>("forwardMessage", { ...body, message_id: messageIds[0] }, options)]
7000
+ : await deps.callApi<Array<{ message_id?: unknown }>>("forwardMessages", { ...body, message_ids: messageIds }, options);
7001
+ assertPublicationCurrent();
7002
+ const copyIds = Array.isArray(copies) ? copies.map((copy) => copy?.message_id) : [];
7003
+ if (
7004
+ copyIds.length !== messageIds.length ||
7005
+ !copyIds.every((id): id is number => typeof id === "number" && Number.isSafeInteger(id) && id > 0) ||
7006
+ new Set(copyIds).size !== copyIds.length
7007
+ )
7008
+ return;
7009
+ pending.rerouteCopies = { target, bySourceUpdateId: new Map(order.map((index, position) =>
7010
+ [source.sourceUpdateIds[index]!, copyIds[position]!] as const)) };
7011
+ pending.phase = { kind: "selected" };
7012
+ return assertRecipientCurrent;
7013
+ } catch (error) {
7014
+ if (cap.isCurrent()) deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-destination-forward" });
7015
+ return;
7016
+ }
7017
+ };
6805
7018
  const forwardPendingRerouteMessages = async (
6806
7019
  pending: PendingUnboundReroute,
6807
7020
  instanceId: string,
@@ -6827,12 +7040,19 @@ export function createTelegramInboundRouteRuntime<
6827
7040
  )
6828
7041
  return false;
6829
7042
  const ownership = structuredClone(liveOwnership);
6830
- const messages = cloneTelegramMessagesForThread(pending.messages, threadId);
7043
+ const copies = pending.rerouteCopies;
7044
+ const published = !!copies && isDeepStrictEqual(copies.target, target);
7045
+ const messages = cloneTelegramMessagesForThread(
7046
+ pending.messages,
7047
+ threadId,
7048
+ published ? copies.bySourceUpdateId : undefined,
7049
+ );
6831
7050
  // Issuance is durable before the RPC: an unacknowledged temporary-tab Forward never becomes another dispatch grant,
6832
7051
  // even after restart. Any refusal or publication failure sends nothing and keeps the input held.
6833
7052
  if (
6834
7053
  !recordForwardAcceptance &&
6835
7054
  isTemporaryReroute(pending) &&
7055
+ !published &&
6836
7056
  !issueTemporaryThreadForward(pending, ctx)
6837
7057
  ) {
6838
7058
  if (isTemporaryThreadForwardIssued(pending))
@@ -6847,6 +7067,10 @@ export function createTelegramInboundRouteRuntime<
6847
7067
  message,
6848
7068
  ownership,
6849
7069
  ctx,
7070
+ assertAuthority() {
7071
+ assertExecutionCurrent?.();
7072
+ Updates.assertTelegramUpdateExecutionCurrent(message);
7073
+ },
6850
7074
  }),
6851
7075
  ),
6852
7076
  );
@@ -7073,6 +7297,16 @@ export function createTelegramInboundRouteRuntime<
7073
7297
  temporaryCleanupTimers.delete(token);
7074
7298
  if (retry) restoreSettlementTasks.add(task);
7075
7299
  runTemporaryThreadCleanup(token, cap, reconcileExpiry)
7300
+ .then(() => {
7301
+ if (!cap.isCurrent()) return;
7302
+ const retained = cap.store.listTemporaryThreads().find(value => value.token === token);
7303
+ // A follower ACK can precede recipient receipt disposal. Reobserve a protected, unissued tab;
7304
+ // elapsed time never grants deletion, and an issued/unknown delete can never enter this path.
7305
+ if (retained?.phase === "created" && !retained.cleanupIssued &&
7306
+ Threads.isTelegramTemporaryThreadFullyResolved(retained) &&
7307
+ cap.store.inspectTemporaryThreadTarget(retained, cap.authority)?.kind === "temporary" && cap.isCurrent())
7308
+ queueTemporaryThreadCleanup(token, cap, 1000, reconcileExpiry, true);
7309
+ })
7076
7310
  .catch((error) => {
7077
7311
  deps.recordRuntimeEvent?.("routing", error, {
7078
7312
  phase: "temporary-thread-cleanup",
@@ -7799,7 +8033,7 @@ export function createTelegramInboundRouteRuntime<
7799
8033
  ).includes(source.original.updateId)
7800
8034
  )
7801
8035
  continue;
7802
- movedIntoTab ||= pending.allTabCopiesForwarded === true;
8036
+ movedIntoTab ||= pending.allTabCopiesForwarded !== undefined;
7803
8037
  removePendingReroute(id);
7804
8038
  if (
7805
8039
  signal.aborted ||
@@ -8901,6 +9135,14 @@ export function createTelegramInboundRouteRuntime<
8901
9135
  );
8902
9136
  return true;
8903
9137
  }
9138
+ if (
9139
+ !parsed.useNewSlot &&
9140
+ isTemporaryReroute(pending) &&
9141
+ !resolvePendingRerouteForwardSource(pending)
9142
+ ) {
9143
+ await deps.answerCallbackQuery(query.id, "Source copy unavailable; not routing");
9144
+ return true;
9145
+ }
8904
9146
  if (!(await selectPendingDestination())) return true;
8905
9147
  if (parsed.useNewSlot && sourceTarget && restoreWorkspace) {
8906
9148
  const selection = (pending.workspaceRestore ??= {
@@ -9006,6 +9248,19 @@ export function createTelegramInboundRouteRuntime<
9006
9248
  );
9007
9249
  return true;
9008
9250
  }
9251
+ let assertRerouteCurrent = assertExecutionCurrent;
9252
+ if (isTemporaryReroute(pending)) {
9253
+ const confirmed = await publishPendingRerouteCopies(pending, record, ctx, assertExecutionCurrent,
9254
+ !!record.instanceId && record.instanceId !== currentInstanceId && !isCurrentLeaderRecord);
9255
+ if (!confirmed) {
9256
+ await deps.answerCallbackQuery(query.id, pending.phase.kind === "forward-unknown"
9257
+ ? "Publication unconfirmed; not resending"
9258
+ : "Publication unavailable; input kept");
9259
+ return true;
9260
+ }
9261
+ assertRerouteCurrent = confirmed;
9262
+ }
9263
+ assertRerouteCurrent();
9009
9264
  if (
9010
9265
  record.instanceId &&
9011
9266
  record.instanceId !== currentInstanceId &&
@@ -9016,7 +9271,7 @@ export function createTelegramInboundRouteRuntime<
9016
9271
  record.instanceId,
9017
9272
  parsed.threadId,
9018
9273
  ctx,
9019
- assertExecutionCurrent,
9274
+ assertRerouteCurrent,
9020
9275
  );
9021
9276
  if (!allForwarded) {
9022
9277
  await deps.answerCallbackQuery(
@@ -9032,6 +9287,7 @@ export function createTelegramInboundRouteRuntime<
9032
9287
  // Local handlers and queue admission consume the same one-time group grant as follower forwarding.
9033
9288
  if (
9034
9289
  isTemporaryReroute(pending) &&
9290
+ !pending.rerouteCopies &&
9035
9291
  !issueTemporaryThreadForward(pending, ctx)
9036
9292
  ) {
9037
9293
  await deps.answerCallbackQuery(
@@ -9040,7 +9296,11 @@ export function createTelegramInboundRouteRuntime<
9040
9296
  );
9041
9297
  return true;
9042
9298
  }
9043
- await dispatchPendingRerouteMessages(pending, reroutedMessages, ctx);
9299
+ const localMessages = pending.rerouteCopies
9300
+ ? cloneTelegramMessagesForThread(pending.messages, parsed.threadId, pending.rerouteCopies.bySourceUpdateId)
9301
+ : reroutedMessages;
9302
+ await dispatchPendingRerouteMessages(pending, localMessages, ctx);
9303
+ assertRerouteCurrent();
9044
9304
  pending.messages = [];
9045
9305
  return completeRoutedReroute();
9046
9306
  };
@@ -10632,6 +10892,16 @@ export function createTelegramInboundRouteRuntime<
10632
10892
  const tabCopyId = openedNow
10633
10893
  ? await moveAllTabSourceIntoTab(pending, target, current)
10634
10894
  : undefined;
10895
+ if (!current()) {
10896
+ removePendingReroute(rerouteId);
10897
+ throw new Error("Temporary Thread source presentation authority changed.");
10898
+ }
10899
+ if (
10900
+ openedNow &&
10901
+ !pending.allTabCopiesForwarded &&
10902
+ pending.allTabCopies.length === held.length
10903
+ )
10904
+ pending.allTabSourceRetained = true;
10635
10905
  try {
10636
10906
  if (!deps.sendInteractiveMessage)
10637
10907
  throw new Error("Temporary Thread chooser publication is unavailable.");
package/lib/status.ts CHANGED
@@ -66,7 +66,7 @@ export function formatTelegramConnectionFailure(error: unknown): string {
66
66
 
67
67
  const TELEGRAM_STATUS_DEFAULT_PROFILE_NAME = "default";
68
68
 
69
- export type TelegramStatusQueueLane = "control" | "priority" | "default";
69
+ export type TelegramStatusQueueLane = "control" | "steer" | "priority" | "default";
70
70
 
71
71
  export interface TelegramUsageStats {
72
72
  totalInput: number;
@@ -441,7 +441,6 @@ export interface TelegramBridgeStatusRuntimeDeps<
441
441
  hasPendingModelSwitch: () => boolean;
442
442
  getQueuedItems: () => TQueueItem[];
443
443
  getQueuedItemCount?: (items: TQueueItem[]) => number;
444
- formatQueuedStatus: (items: TQueueItem[]) => string;
445
444
  getRecentRuntimeEvents: () => TelegramRuntimeEvent[];
446
445
  getRuntimeLockState?: () => string;
447
446
  getBusRole?: () => TelegramBridgeBusRole | undefined;
@@ -1200,9 +1199,7 @@ export function buildTelegramStatusBarText(
1200
1199
  state: TelegramStatusBarState,
1201
1200
  ): string {
1202
1201
  const label = theme.fg("accent", getTelegramStatusBarLabel(state));
1203
- const queued = state.queuedStatus
1204
- ? theme.fg("warning", state.queuedStatus)
1205
- : "";
1202
+ const queued = state.queuedStatus ? theme.fg("warning", state.queuedStatus) : "";
1206
1203
  if (!state.hasBotToken)
1207
1204
  return `${label} ${theme.fg("muted", "not configured")}${queued}`;
1208
1205
  if (
@@ -1612,6 +1609,7 @@ function countTelegramQueueLanes(
1612
1609
  ): Record<TelegramStatusQueueLane, number> {
1613
1610
  const counts: Record<TelegramStatusQueueLane, number> = {
1614
1611
  control: 0,
1612
+ steer: 0,
1615
1613
  priority: 0,
1616
1614
  default: 0,
1617
1615
  };
@@ -1626,10 +1624,11 @@ function buildTelegramBridgeCompactStatusLines(
1626
1624
  control: controlQueueCount,
1627
1625
  priority: priorityQueueCount,
1628
1626
  default: defaultQueueCount,
1627
+ steer: steerQueueCount,
1629
1628
  } = countTelegramQueueLanes(state.queuedItems);
1630
1629
  const queueLine = `- queued turns: ${state.queuedItems.length}${
1631
1630
  state.queuedItems.length > 0
1632
- ? ` (control=${controlQueueCount}, priority=${priorityQueueCount}, default=${defaultQueueCount})`
1631
+ ? ` (control=${controlQueueCount}${steerQueueCount > 0 ? `, steer=${steerQueueCount}` : ""}, priority=${priorityQueueCount}, default=${defaultQueueCount})`
1633
1632
  : ""
1634
1633
  }`;
1635
1634
  const executionState = state.pendingDispatch
@@ -1698,6 +1697,7 @@ function buildTelegramBridgeDiagnosticStatusLines(
1698
1697
  control: controlQueueCount,
1699
1698
  priority: priorityQueueCount,
1700
1699
  default: defaultQueueCount,
1700
+ steer: steerQueueCount,
1701
1701
  } = countTelegramQueueLanes(state.queuedItems);
1702
1702
  return [
1703
1703
  "connection:",
@@ -1744,7 +1744,7 @@ function buildTelegramBridgeDiagnosticStatusLines(
1744
1744
  "",
1745
1745
  "queue:",
1746
1746
  `- queued turns: ${state.queuedItems.length}`,
1747
- `- lanes: control=${controlQueueCount}, priority=${priorityQueueCount}, default=${defaultQueueCount}`,
1747
+ `- lanes: control=${controlQueueCount}${steerQueueCount > 0 ? `, steer=${steerQueueCount}` : ""}, priority=${priorityQueueCount}, default=${defaultQueueCount}`,
1748
1748
  ...buildTelegramBusFollowerLines(state),
1749
1749
  ...buildTelegramLocalBusLines(state, { verbose: true }),
1750
1750
  ...buildTelegramTopicTargetDiagnosticLines(state),
package/lib/turns.ts CHANGED
@@ -51,6 +51,13 @@ export function withoutTelegramTurnReplyAnchor<TMessage extends object>(message:
51
51
  return { ...message, [REPLY_ANCHOR_SUPPRESSED]: true };
52
52
  }
53
53
 
54
+ /** A confirmed same-target copy may replace the former-tab suppression on a warm input projection. */
55
+ export function withTelegramTurnReplyAnchor<TMessage extends { message_id: number }>(message: TMessage): TMessage {
56
+ if (!Number.isSafeInteger(message.message_id) || message.message_id <= 0)
57
+ throw new Error("Telegram reply anchor requires a positive message ID.");
58
+ return { ...message, [REPLY_ANCHOR_SUPPRESSED]: false };
59
+ }
60
+
54
61
  export const TELEGRAM_GUEST_TURN_NOTE =
55
62
  "[guest] delivery: answer quickly with one concise, self-contained reply";
56
63