@llblab/pi-kit 0.35.0 → 0.35.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/BACKLOG.md +2 -0
  2. package/CHANGELOG.md +11 -0
  3. package/README.md +1 -1
  4. package/node_modules/@llblab/pi-telegram/AGENTS.md +3 -2
  5. package/node_modules/@llblab/pi-telegram/BACKLOG.md +1 -6
  6. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +18 -0
  7. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +3 -2
  8. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +11 -3
  9. package/node_modules/@llblab/pi-telegram/dist/lib/bus-api.js +2 -2
  10. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +22 -0
  11. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +75 -16
  12. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.d.ts +1 -1
  13. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +11 -1
  14. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +30 -19
  15. package/node_modules/@llblab/pi-telegram/dist/lib/commands.d.ts +2 -0
  16. package/node_modules/@llblab/pi-telegram/dist/lib/commands.js +21 -8
  17. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +61 -46
  18. package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +7 -0
  19. package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +80 -0
  20. package/node_modules/@llblab/pi-telegram/dist/lib/logging.d.ts +36 -0
  21. package/node_modules/@llblab/pi-telegram/dist/lib/logging.js +80 -0
  22. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.d.ts +12 -0
  23. package/node_modules/@llblab/pi-telegram/dist/lib/outbound-attachments.js +33 -3
  24. package/node_modules/@llblab/pi-telegram/dist/lib/paths.d.ts +2 -1
  25. package/node_modules/@llblab/pi-telegram/dist/lib/paths.js +18 -3
  26. package/node_modules/@llblab/pi-telegram/dist/lib/polling.d.ts +13 -0
  27. package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +33 -12
  28. package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +6 -2
  29. package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +46 -4
  30. package/node_modules/@llblab/pi-telegram/dist/lib/replies.d.ts +5 -1
  31. package/node_modules/@llblab/pi-telegram/dist/lib/replies.js +2 -2
  32. package/node_modules/@llblab/pi-telegram/dist/lib/routing.d.ts +2 -0
  33. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +137 -13
  34. package/node_modules/@llblab/pi-telegram/dist/lib/runtime.d.ts +11 -0
  35. package/node_modules/@llblab/pi-telegram/dist/lib/runtime.js +31 -8
  36. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +12 -5
  37. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +21 -3
  38. package/node_modules/@llblab/pi-telegram/dist/lib/thread-reconciler.js +5 -1
  39. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +5 -1
  40. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +26 -13
  41. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +6 -2
  42. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +57 -12
  43. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  44. package/node_modules/@llblab/pi-telegram/docs/architecture.md +5 -1
  45. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +6 -2
  46. package/node_modules/@llblab/pi-telegram/docs/outbound.md +4 -2
  47. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +13 -2
  48. package/node_modules/@llblab/pi-telegram/lib/bus-api.ts +2 -1
  49. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +111 -15
  50. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +15 -0
  51. package/node_modules/@llblab/pi-telegram/lib/bus.ts +30 -21
  52. package/node_modules/@llblab/pi-telegram/lib/commands.ts +21 -8
  53. package/node_modules/@llblab/pi-telegram/lib/extension.ts +64 -56
  54. package/node_modules/@llblab/pi-telegram/lib/journal.ts +80 -0
  55. package/node_modules/@llblab/pi-telegram/lib/logging.ts +122 -0
  56. package/node_modules/@llblab/pi-telegram/lib/outbound-attachments.ts +36 -3
  57. package/node_modules/@llblab/pi-telegram/lib/paths.ts +22 -3
  58. package/node_modules/@llblab/pi-telegram/lib/polling.ts +44 -10
  59. package/node_modules/@llblab/pi-telegram/lib/queue.ts +43 -4
  60. package/node_modules/@llblab/pi-telegram/lib/replies.ts +3 -2
  61. package/node_modules/@llblab/pi-telegram/lib/routing.ts +140 -18
  62. package/node_modules/@llblab/pi-telegram/lib/runtime.ts +40 -7
  63. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +30 -10
  64. package/node_modules/@llblab/pi-telegram/lib/thread-reconciler.ts +6 -1
  65. package/node_modules/@llblab/pi-telegram/lib/threads.ts +37 -20
  66. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +69 -17
  67. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  68. package/package.json +2 -2
@@ -471,6 +471,7 @@ export function createTelegramThreadCapabilityOrchestration(deps) {
471
471
  isTopicModeUnavailableError: deps.isTopicModeUnavailableError,
472
472
  updateStatus: deps.updateStatus,
473
473
  recordEvent: deps.recordEvent,
474
+ failureLog: deps.failureLog,
474
475
  };
475
476
  return {
476
477
  monitor: createTelegramThreadCapabilityMonitor(capabilityDeps),
@@ -813,8 +814,37 @@ export function createTelegramThreadCapabilityMonitor(deps) {
813
814
  let generation = 0;
814
815
  let transitionPromise;
815
816
  let consecutiveDisabledProbes = 0;
817
+ // An absent leader endpoint repeats one identical probe failure every interval; the port collapses the run.
818
+ // Running it through one guard keeps a faulty diagnostics port from breaking probes or the interval callback.
819
+ const capabilityFailureLog = {
820
+ record(error, details) {
821
+ try {
822
+ deps.failureLog.record(error, details);
823
+ }
824
+ catch {
825
+ // Monitor diagnostics cannot create an uncaught interval exception.
826
+ }
827
+ },
828
+ settled() {
829
+ try {
830
+ deps.failureLog.settled();
831
+ }
832
+ catch {
833
+ // Monitor diagnostics cannot fail a completed probe.
834
+ }
835
+ },
836
+ reset() {
837
+ try {
838
+ deps.failureLog.reset();
839
+ }
840
+ catch {
841
+ // Monitor diagnostics cannot fail a monitor stop.
842
+ }
843
+ },
844
+ };
816
845
  const stop = () => {
817
846
  generation += 1;
847
+ capabilityFailureLog.reset();
818
848
  if (interval)
819
849
  clearInterval(interval);
820
850
  interval = undefined;
@@ -832,6 +862,7 @@ export function createTelegramThreadCapabilityMonitor(deps) {
832
862
  .then(async (threadModeEnabled) => {
833
863
  if (!isCurrent())
834
864
  return;
865
+ capabilityFailureLog.settled();
835
866
  if (threadModeEnabled === undefined) {
836
867
  if (deps.topicTargetStore.getBotState().threadMode !== "enabled" &&
837
868
  !deps.getPollingStartedWithTelegramBus() &&
@@ -879,12 +910,7 @@ export function createTelegramThreadCapabilityMonitor(deps) {
879
910
  .catch((error) => {
880
911
  if (!isCurrent())
881
912
  return;
882
- try {
883
- deps.recordEvent("bus", error, { phase: "capability-monitor" });
884
- }
885
- catch {
886
- // Monitor diagnostics cannot create an unhandled interval rejection.
887
- }
913
+ capabilityFailureLog.record(error);
888
914
  })
889
915
  .finally(() => {
890
916
  if (transitionPromise === tracked)
@@ -911,12 +937,7 @@ export function createTelegramThreadCapabilityMonitor(deps) {
911
937
  catch {
912
938
  /* Timer shutdown must not escape the callback. */
913
939
  }
914
- try {
915
- deps.recordEvent("bus", error, { phase: "capability-monitor" });
916
- }
917
- catch {
918
- // Monitor diagnostics cannot create an uncaught interval exception.
919
- }
940
+ capabilityFailureLog.record(error);
920
941
  }
921
942
  }, intervalMs);
922
943
  interval.unref?.();
@@ -506,10 +506,14 @@ export interface TelegramAgentEndRuntimeDeps<TTurn extends PendingTelegramTurn,
506
506
  }) => Promise<void>;
507
507
  sendGuestReply?: (guestQueryId: string, markdown: string) => Promise<void>;
508
508
  /** Replaces the early guest ACK with the final text. */
509
- editGuestReply?: (inlineMessageId: string, markdown: string) => Promise<void>;
509
+ editGuestReply?: (inlineMessageId: string, markdown: string, options?: {
510
+ assertAuthority?: () => void;
511
+ }) => Promise<void>;
510
512
  /** Cancels the animated guest placeholder before the final edit. */
511
513
  stopGuestPlaceholder?: (inlineMessageId: string) => Promise<void>;
512
- sendGuestAttachment?: (turn: TTurn, attachment: QueuedAttachment, caption?: string) => Promise<void>;
514
+ sendGuestAttachment?: (turn: TTurn, attachment: QueuedAttachment, caption?: string, options?: {
515
+ isDeliveryActive?: () => boolean;
516
+ }) => Promise<void>;
513
517
  sendGuestVoiceReply?: (turn: TTurn, plan: TelegramAgentEndOutboundReplyPlan<TReplyMarkup>, caption?: string) => Promise<void>;
514
518
  planOutboundReply?: (markdown: string) => TelegramAgentEndOutboundReplyPlan<TReplyMarkup>;
515
519
  sendOutboundReplyArtifacts?: (turn: TTurn, plan: TelegramAgentEndOutboundReplyPlan, options?: {
@@ -1377,13 +1377,55 @@ export async function handleTelegramAgentEndRuntime(deps) {
1377
1377
  });
1378
1378
  }
1379
1379
  }
1380
- if (turn.guestInlineMessageId && deps.editGuestReply) {
1381
- const experimentText = assistant.errorMessage
1380
+ if (!isDeliveryActive())
1381
+ return;
1382
+ if (turn.guestInlineMessageId) {
1383
+ let experimentText = assistant.errorMessage
1382
1384
  ? "Telegram bridge: Pi failed while processing the request."
1383
1385
  : finalText;
1386
+ const [attachment] = turn.queuedAttachments;
1387
+ if (!assistant.errorMessage && attachment) {
1388
+ try {
1389
+ if (!deps.sendGuestAttachment)
1390
+ throw new Error("Guest attachment delivery is unavailable.");
1391
+ await deps.sendGuestAttachment(turn, attachment, finalText || undefined, { isDeliveryActive });
1392
+ if (isDeliveryActive() && endPlan.shouldDispatchNext)
1393
+ deps.dispatchNextQueuedTelegramTurn();
1394
+ return;
1395
+ }
1396
+ catch (error) {
1397
+ deps.recordRuntimeEvent?.("delivery", error, {
1398
+ phase: "guest-attachment", guestQueryId: turn.guestQueryId,
1399
+ });
1400
+ if (!isDeliveryActive())
1401
+ return;
1402
+ if (isTelegramApiCommitUnknownError(error)) {
1403
+ if (endPlan.shouldDispatchNext)
1404
+ deps.dispatchNextQueuedTelegramTurn();
1405
+ return;
1406
+ }
1407
+ experimentText = "Telegram bridge could not deliver the requested attachment. Please retry in a normal bot chat.";
1408
+ }
1409
+ }
1410
+ else if (!assistant.errorMessage && hasOutboundArtifacts) {
1411
+ experimentText = [finalText, "Telegram Guest Mode cannot replace an inline placeholder with a voice note. Please request MP3 audio or use a normal bot chat."].filter(Boolean).join("\n\n");
1412
+ }
1413
+ if (!deps.editGuestReply) {
1414
+ deps.recordRuntimeEvent?.("delivery", new Error("Guest inline reply editing is unavailable."), {
1415
+ phase: "guest-ack-edit", guestQueryId: turn.guestQueryId,
1416
+ });
1417
+ if (endPlan.shouldDispatchNext)
1418
+ deps.dispatchNextQueuedTelegramTurn();
1419
+ return;
1420
+ }
1384
1421
  if (experimentText) {
1385
1422
  try {
1386
- await deps.editGuestReply(turn.guestInlineMessageId, experimentText);
1423
+ await deps.editGuestReply(turn.guestInlineMessageId, experimentText, {
1424
+ assertAuthority() {
1425
+ if (!isDeliveryActive())
1426
+ throw new Error("Guest reply edit lost authority.");
1427
+ },
1428
+ });
1387
1429
  deps.recordRuntimeEvent?.("guest", new Error("Guest ACK edited the guest answer"), { phase: "guest-ack-edited", guestQueryId: turn.guestQueryId });
1388
1430
  }
1389
1431
  catch (error) {
@@ -1419,7 +1461,7 @@ export async function handleTelegramAgentEndRuntime(deps) {
1419
1461
  const [guestAttachment] = turn.queuedAttachments;
1420
1462
  if (guestAttachment && deps.sendGuestAttachment) {
1421
1463
  try {
1422
- await deps.sendGuestAttachment(turn, guestAttachment, finalText || undefined);
1464
+ await deps.sendGuestAttachment(turn, guestAttachment, finalText || undefined, { isDeliveryActive });
1423
1465
  }
1424
1466
  catch (error) {
1425
1467
  deps.recordRuntimeEvent?.("delivery", error, {
@@ -163,8 +163,12 @@ export declare function createGuestMarkdownReplyEditor(deps: {
163
163
  editGuestInlineMessage: (inlineMessageId: string, content: {
164
164
  richMessage?: TelegramInputRichMessage;
165
165
  text?: string;
166
+ }, options?: {
167
+ assertAuthority?: () => void;
166
168
  }) => Promise<void>;
167
- }): (inlineMessageId: string, markdown: string) => Promise<void>;
169
+ }): (inlineMessageId: string, markdown: string, options?: {
170
+ assertAuthority?: () => void;
171
+ }) => Promise<void>;
168
172
  /**
169
173
  * Guest Mode placeholder rotation: the early ACK is the first placeholder
170
174
  * frame and the runtime steps the remaining frames once per interval, moving
@@ -578,11 +578,11 @@ export function createGuestMarkdownReplySender(deps) {
578
578
  * chat/message pair.
579
579
  */
580
580
  export function createGuestMarkdownReplyEditor(deps) {
581
- return async (inlineMessageId, markdown) => {
581
+ return async (inlineMessageId, markdown, options) => {
582
582
  const [richMarkdown = markdown] = splitTelegramNativeMarkdown(markdown);
583
583
  await deps.editGuestInlineMessage(inlineMessageId, {
584
584
  richMessage: { markdown: richMarkdown },
585
- });
585
+ }, options);
586
586
  };
587
587
  }
588
588
  /**
@@ -211,6 +211,8 @@ export interface TelegramInboundRouteRuntimeDeps<TMessage extends TelegramRouted
211
211
  canUsePreDispatchRebind?: (recipient?: Threads.TelegramWorkspaceRestoreRecipient) => boolean;
212
212
  /** Strict complete namespace plus exact current/historical references; only an empty result clears journal protection. */
213
213
  inspectTemporaryThreadSources?: (target: Queue.TelegramQueueTarget, requiredJournalBindingKeys: readonly string[], ownInputs?: readonly Threads.TelegramTemporaryThreadInput[]) => readonly number[] | undefined;
214
+ /** Explicit cleanup-only recovery; never called by a read-only protection observer. */
215
+ recoverTemporaryThreadDeadQueueOwners?: (target: Queue.TelegramQueueTarget, requiredJournalBindingKeys: readonly string[], ownInputs: readonly Threads.TelegramTemporaryThreadInput[], isCurrent: () => boolean) => number;
214
216
  /** Quiet period after the last cancelled input before one cleanup attempt; defaults to 0 ms. */
215
217
  temporaryThreadCleanupDelayMs?: number;
216
218
  /** Live-rebind cleanup attempt pacing; the bounded window ends in `not-issued`, never a time-based deletion. */
@@ -4574,6 +4574,100 @@ export function createTelegramInboundRouteRuntime(deps) {
4574
4574
  }
4575
4575
  await promptEnqueue(messages, ctx);
4576
4576
  };
4577
+ /** Local menu acceptance owns a one-shot effect; source removal and cleanup precede its release, never its replay. */
4578
+ const stageTemporaryRerouteMenu = async (pending, command, messages, ctx, assertRecipientCurrent, completion) => {
4579
+ const original = pending.messages[0];
4580
+ const cap = captureTemporaryThreadAuthority(ctx);
4581
+ const temporary = pending.temporaryThread ?? findCreatedTemporaryThread(pending.sourceTarget);
4582
+ if (!cap?.isCurrent() || !temporary)
4583
+ return undefined;
4584
+ let accept, resume;
4585
+ const accepted = new Promise(resolve => { accept = resolve; });
4586
+ const released = new Promise(resolve => { resume = resolve; });
4587
+ const admission = {
4588
+ assertSourceCurrent() {
4589
+ if (!completion.isCurrent())
4590
+ throw new Error("Rerouted menu source authority changed.");
4591
+ },
4592
+ assertRecipientCurrent,
4593
+ reportCompleted() {
4594
+ const result = completion.reportCompleted();
4595
+ accept(result);
4596
+ return result;
4597
+ },
4598
+ async beforeEffects() {
4599
+ if (!(await released))
4600
+ throw new Error("Rerouted menu execution was not released.");
4601
+ },
4602
+ };
4603
+ const prepare = command.name === "start" ? commandHandler.prepareSelectedStartCommand
4604
+ : command.name === "help" ? commandHandler.prepareSelectedHelpCommand : commandHandler.prepareSelectedMenuCommand;
4605
+ const dispatch = prepare(command, messages, ctx, admission);
4606
+ if (!dispatch)
4607
+ return undefined;
4608
+ const execution = dispatch().catch(error => {
4609
+ deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-menu-execution" });
4610
+ return false;
4611
+ });
4612
+ if (!(await Promise.race([accepted, execution.then(() => false)]))) {
4613
+ resume(false);
4614
+ await execution;
4615
+ return undefined;
4616
+ }
4617
+ return async () => {
4618
+ let allowEffects = false;
4619
+ try {
4620
+ const confirmed = await new Promise(resolve => {
4621
+ let done = false, unsubscribe;
4622
+ const signal = Updates.getTelegramUpdateExecutionFence(original)?.signal;
4623
+ const finish = (result) => {
4624
+ if (done)
4625
+ return;
4626
+ done = true;
4627
+ clearTimeout(timer);
4628
+ unsubscribe?.();
4629
+ signal?.removeEventListener("abort", abort);
4630
+ resolve(result);
4631
+ };
4632
+ const abort = () => finish(false);
4633
+ const observe = () => {
4634
+ if (!cap.isCurrent() || !completion.isCurrent())
4635
+ finish(false);
4636
+ else if (completion.inspectCompletion())
4637
+ finish(true);
4638
+ };
4639
+ const timer = setTimeout(abort, deps.liveRebindCommandCompletionTimeoutMs ?? 8_000);
4640
+ timer.unref?.();
4641
+ unsubscribe = Updates.subscribeTelegramDeferredSourceCompletion(original, observe);
4642
+ signal?.addEventListener("abort", abort, { once: true });
4643
+ if (signal?.aborted)
4644
+ abort();
4645
+ else
4646
+ observe();
4647
+ });
4648
+ if (!confirmed)
4649
+ throw new Error("Rerouted menu source removal is unconfirmed; execution remains suppressed.");
4650
+ assertRecipientCurrent();
4651
+ cancelTemporaryThreadCleanup(temporary.token);
4652
+ try {
4653
+ await runTemporaryThreadCleanup(temporary.token, cap, false);
4654
+ }
4655
+ catch (error) {
4656
+ // Cleanup uncertainty retains its canonical grant, not command execution authority or a retry license.
4657
+ deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-menu-cleanup" });
4658
+ }
4659
+ assertRecipientCurrent();
4660
+ allowEffects = true;
4661
+ }
4662
+ catch (error) {
4663
+ deps.recordRuntimeEvent?.("routing", error, { phase: "reroute-menu-release" });
4664
+ }
4665
+ finally {
4666
+ resume(allowEffects);
4667
+ await execution;
4668
+ }
4669
+ };
4670
+ };
4577
4671
  const finalizePendingReroute = async (rerouteId, pending, query, successMessage, assertExecutionCurrent = Updates.createTelegramUpdateExecutionFenceGuard(query), retainRecord = false) => {
4578
4672
  assertExecutionCurrent();
4579
4673
  const dismissed = await dismissRerouteChooserMessage(query, assertExecutionCurrent);
@@ -4888,9 +4982,17 @@ export function createTelegramInboundRouteRuntime(deps) {
4888
4982
  if (!cancelledCurrent())
4889
4983
  return;
4890
4984
  entry = cap.adopt(entry);
4891
- if (!entry ||
4892
- !cap.isCurrent() ||
4893
- isRerouteTargetProtected(target, undefined, entry))
4985
+ if (!entry || !cap.isCurrent())
4986
+ return;
4987
+ if (isRerouteTargetProtected(target, undefined, entry) && deps.recoverTemporaryThreadDeadQueueOwners &&
4988
+ store.inspectTemporaryThreadTarget(entry, cap.authority)?.kind === "temporary") {
4989
+ const inputs = Threads.getTelegramTemporaryThreadInputs(entry);
4990
+ const removed = deps.recoverTemporaryThreadDeadQueueOwners(target, [cap.journalBindingKey, ...inputs.map(input => input.journalBindingKey)], inputs, cap.isCurrent);
4991
+ if (removed > 0)
4992
+ deps.recordRuntimeEvent?.("routing", "Discarded historical queue receipts from confirmed-dead owners before temporary Thread cleanup.", { phase: "temporary-thread-dead-owner-recovery", removedUpdateCount: removed });
4993
+ }
4994
+ // Recovery is not deletion authority: recapture every ordinary protection after the journal CAS.
4995
+ if (!cap.isCurrent() || isRerouteTargetProtected(target, undefined, entry))
4894
4996
  return;
4895
4997
  const issued = store.issueTemporaryThreadCleanup(entry, cap.authority);
4896
4998
  if (!issued || !cap.isCurrent())
@@ -5911,7 +6013,7 @@ export function createTelegramInboundRouteRuntime(deps) {
5911
6013
  await deps.answerCallbackQuery(query.id);
5912
6014
  return true;
5913
6015
  };
5914
- const executeUnboundRerouteCallbackOperation = async (query, ctx) => {
6016
+ const executeUnboundRerouteCallbackOperation = async (query, ctx, afterAdmission) => {
5915
6017
  const parsed = parseTelegramUnboundRerouteCallbackData(query.data);
5916
6018
  if (!parsed)
5917
6019
  return false;
@@ -6432,7 +6534,21 @@ export function createTelegramInboundRouteRuntime(deps) {
6432
6534
  const localMessages = pending.rerouteCopies
6433
6535
  ? cloneTelegramMessagesForThread(pending.messages, parsed.threadId, pending.rerouteCopies.bySourceUpdateId)
6434
6536
  : reroutedMessages;
6435
- await dispatchPendingRerouteMessages(pending, localMessages, ctx);
6537
+ const command = pending.dispatchKind === "command"
6538
+ ? getLiveRebindMenu(localMessages) ?? getLiveRebindHelp(localMessages) : undefined;
6539
+ const completion = pending.rerouteCopies && command
6540
+ ? Updates.prepareTelegramDeferredSourceCompletion(pending.messages[0]) : undefined;
6541
+ if (command && completion) {
6542
+ const release = await stageTemporaryRerouteMenu(pending, command, localMessages, ctx, assertRerouteCurrent, completion);
6543
+ if (!release) {
6544
+ await deps.answerCallbackQuery(query.id, "Command acceptance unconfirmed; not resending");
6545
+ return true;
6546
+ }
6547
+ afterAdmission(release);
6548
+ }
6549
+ else {
6550
+ await dispatchPendingRerouteMessages(pending, localMessages, ctx);
6551
+ }
6436
6552
  assertRerouteCurrent();
6437
6553
  pending.messages = [];
6438
6554
  return completeRoutedReroute();
@@ -6441,14 +6557,22 @@ export function createTelegramInboundRouteRuntime(deps) {
6441
6557
  const parsed = parseTelegramUnboundRerouteCallbackData(query.data);
6442
6558
  if (!parsed)
6443
6559
  return false;
6444
- return withPendingRerouteSelection(parsed.rerouteId, query, () => {
6445
- if (!deps.runWorkspaceOperation)
6446
- return executeUnboundRerouteCallbackOperation(query, ctx);
6447
- return deps.runWorkspaceOperation({
6448
- operationId: `workspace-reroute:${query.id}`,
6449
- operationKind: "workspace.route-unbound-thread",
6450
- scopes: [{ kind: "profile" }],
6451
- }, () => executeUnboundRerouteCallbackOperation(query, ctx));
6560
+ return withPendingRerouteSelection(parsed.rerouteId, query, async () => {
6561
+ let release;
6562
+ const operation = () => executeUnboundRerouteCallbackOperation(query, ctx, value => { release = value; });
6563
+ try {
6564
+ if (!deps.runWorkspaceOperation)
6565
+ return await operation();
6566
+ return await deps.runWorkspaceOperation({
6567
+ operationId: `workspace-reroute:${query.id}`,
6568
+ operationKind: "workspace.route-unbound-thread",
6569
+ scopes: [{ kind: "profile" }],
6570
+ }, operation);
6571
+ }
6572
+ finally {
6573
+ // Keep the exact source references, but release the non-reentrant Workspace gate before cleanup.
6574
+ await release?.();
6575
+ }
6452
6576
  });
6453
6577
  };
6454
6578
  const handleUnboundRerouteCallback = async (query, ctx) => {
@@ -4,6 +4,15 @@
4
4
  * Owns small session-local runtime primitives that are shared by orchestration but are not specific to queueing, rendering, polling, or Telegram transport
5
5
  */
6
6
  import * as Machine from "./machine.ts";
7
+ /**
8
+ * Structural port for run-collapsing diagnostics: a repeated identical failure extends its run instead of logging
9
+ * again, and the closing success records one outcome. The composition root owns the collapsed implementation.
10
+ */
11
+ export interface TelegramRepeatedFailureLogPort {
12
+ record: (error: unknown, details?: Record<string, unknown>) => void;
13
+ settled: () => void;
14
+ reset: () => void;
15
+ }
7
16
  export interface TelegramRuntimeQueueCounters {
8
17
  nextQueuedTelegramItemOrder: number;
9
18
  nextQueuedTelegramControlOrder: number;
@@ -103,6 +112,8 @@ export interface TelegramRuntimeEventRecorderPort {
103
112
  recordRuntimeEvent?: (category: string, error: unknown, details?: Record<string, unknown>) => void;
104
113
  }
105
114
  export interface TelegramTypingLoopStarterDeps<TContext> extends TelegramRuntimeEventRecorderPort {
115
+ /** Collapses one sustained presence failure run; the caller supplies its owner. */
116
+ failureLog: TelegramRepeatedFailureLogPort;
106
117
  typing: TelegramRuntimeTypingPort;
107
118
  getDefaultChatId: () => number | undefined;
108
119
  sendTypingAction: (chatId: number, options?: {
@@ -162,6 +162,34 @@ function updateTelegramRuntimeStatusSafely(updateStatus, ctx, options) {
162
162
  }
163
163
  export function createTelegramTypingLoopStarter(deps) {
164
164
  let currentOwner;
165
+ // An unreachable transport repeats one identical presence failure every tick; the port collapses the run.
166
+ // Running it through one guard keeps a faulty diagnostics port from escaping the in-flight action owner.
167
+ const typingFailureLog = {
168
+ record(error, details) {
169
+ try {
170
+ deps.failureLog.record(error, details);
171
+ }
172
+ catch {
173
+ // Typing diagnostics cannot escape the in-flight action owner.
174
+ }
175
+ },
176
+ settled() {
177
+ try {
178
+ deps.failureLog.settled();
179
+ }
180
+ catch {
181
+ // A successful presence outranks its own diagnostic.
182
+ }
183
+ },
184
+ reset() {
185
+ try {
186
+ deps.failureLog.reset();
187
+ }
188
+ catch {
189
+ // Typing diagnostics cannot fail a loop stop.
190
+ }
191
+ },
192
+ };
165
193
  return (ctx, chatId, options) => {
166
194
  const sessionGeneration = deps.getSessionGeneration?.();
167
195
  const transportAuthority = deps.getTransportAuthority?.();
@@ -214,6 +242,7 @@ export function createTelegramTypingLoopStarter(deps) {
214
242
  throw new Error("Typing context authority changed.");
215
243
  },
216
244
  });
245
+ typingFailureLog.settled();
217
246
  }
218
247
  catch (error) {
219
248
  if (!active)
@@ -222,19 +251,13 @@ export function createTelegramTypingLoopStarter(deps) {
222
251
  deps.typing.stop();
223
252
  return;
224
253
  }
225
- try {
226
- deps.recordRuntimeEvent?.("typing", error, {
227
- chatId: targetChatId,
228
- });
229
- }
230
- catch {
231
- // Typing diagnostics cannot escape the in-flight action owner.
232
- }
254
+ typingFailureLog.record(error, { chatId: targetChatId });
233
255
  }
234
256
  },
235
257
  shouldContinue: canContinue,
236
258
  onStopped: () => {
237
259
  active = false;
260
+ typingFailureLog.reset();
238
261
  if (currentOwner === owner)
239
262
  currentOwner = undefined;
240
263
  },
@@ -299,6 +299,8 @@ export interface TelegramApiCallOptions {
299
299
  sleep?: (ms: number) => Promise<void>;
300
300
  /** Observability hook fired before a 429 retry wait, never for 5xx waits. */
301
301
  onRetryWait?: (wait: TelegramApiRetryWait) => void;
302
+ /** Caller-owned expected failure: still thrown, but the caller records the outcome instead of the API log. */
303
+ isExpectedError?: (error: unknown) => boolean;
302
304
  }
303
305
  export declare class TelegramApiAuthorityError extends Error {
304
306
  readonly requestIssued: boolean;
@@ -343,6 +345,12 @@ export interface TelegramAnswerGuestQueryOptions {
343
345
  result?: TelegramGuestCachedMediaResult;
344
346
  }
345
347
  export interface TelegramEditGuestInlineMessageContent {
348
+ /** Cached media only: inline edits cannot upload a new local file. */
349
+ media?: {
350
+ type: "photo" | "document" | "audio";
351
+ media: string;
352
+ caption?: string;
353
+ };
346
354
  text?: string;
347
355
  richMessage?: TelegramInputRichMessage;
348
356
  parseMode?: "HTML";
@@ -465,13 +473,12 @@ export interface TelegramBridgeApiRuntime extends Pick<TelegramApiClient, "call"
465
473
  answerCallbackQuery: (callbackQueryId: string, text?: string, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
466
474
  answerGuestQuery: (guestQueryId: string, text?: string, options?: TelegramAnswerGuestQueryOptions) => Promise<void>;
467
475
  /**
468
- * Temporary Guest Mode ACK experiment: answers the guest query and returns
469
- * the sent inline message id so the final answer can edit that early reply.
470
- * Requires direct transport ownership; Telegram does not document editing
471
- * guest answers, so this exists only to falsify that behavior live.
476
+ * Answers the guest query and retains the sent inline message id for final
477
+ * text or cached-media replacement. Requires direct transport ownership;
478
+ * actual guest-media rendering remains live-client gated.
472
479
  */
473
480
  answerGuestQueryForInlineMessage: (guestQueryId: string, text?: string, options?: TelegramAnswerGuestQueryOptions) => Promise<string | undefined>;
474
- editGuestInlineMessage: (inlineMessageId: string, content: TelegramEditGuestInlineMessageContent) => Promise<void>;
481
+ editGuestInlineMessage: (inlineMessageId: string, content: TelegramEditGuestInlineMessageContent, options?: Pick<TelegramApiCallOptions, "assertAuthority">) => Promise<void>;
475
482
  deleteMessage: (chatId: number, messageId: number) => Promise<void>;
476
483
  prepareTempDir: () => Promise<number>;
477
484
  }
@@ -1134,7 +1134,8 @@ export function createTelegramBridgeApiRuntime(deps) {
1134
1134
  "Telegram API deleteMessage failed: HTTP 400: Bad Request: message to delete not found") {
1135
1135
  return true;
1136
1136
  }
1137
- deps.recordRuntimeEvent("api", error, withTelegramTransportDiagnostics(error, { method }));
1137
+ if (!options?.isExpectedError?.(error))
1138
+ deps.recordRuntimeEvent("api", error, withTelegramTransportDiagnostics(error, { method }));
1138
1139
  throw error;
1139
1140
  }
1140
1141
  };
@@ -1267,14 +1268,31 @@ export function createTelegramBridgeApiRuntime(deps) {
1267
1268
  const sent = await callRecorded("answerGuestQuery", buildTelegramAnswerGuestQueryBody(guestQueryId, text, options));
1268
1269
  return sent?.inline_message_id;
1269
1270
  },
1270
- editGuestInlineMessage: async (inlineMessageId, content) => {
1271
+ editGuestInlineMessage: async (inlineMessageId, content, options) => {
1272
+ if (content.media) {
1273
+ try {
1274
+ const result = await callRecorded("editMessageMedia", {
1275
+ inline_message_id: inlineMessageId,
1276
+ media: content.media,
1277
+ }, { ...options, maxAttempts: 1, retryRateLimit: false });
1278
+ if (result !== true)
1279
+ throw new Error("Missing inline edit acknowledgement.");
1280
+ }
1281
+ catch (error) {
1282
+ // Only a parsed rejection permits replacing this attempt with a failure notice.
1283
+ if (isTelegramApiRequestRejected(error, "editMessageMedia"))
1284
+ throw error;
1285
+ throw new TelegramApiCommitUnknownError("editMessageMedia", error);
1286
+ }
1287
+ return;
1288
+ }
1271
1289
  await callRecorded("editMessageText", {
1272
1290
  inline_message_id: inlineMessageId,
1273
1291
  ...(content.richMessage
1274
1292
  ? { rich_message: content.richMessage }
1275
1293
  : { text: content.text }),
1276
1294
  ...(content.parseMode ? { parse_mode: content.parseMode } : {}),
1277
- });
1295
+ }, options);
1278
1296
  },
1279
1297
  prepareTempDir: () => prepareTelegramTempDir(deps.tempDir, deps.tempFileMaxAgeMs),
1280
1298
  deleteMessage: (chatId, messageId) => callRecorded("deleteMessage", {
@@ -339,7 +339,11 @@ export async function applyThreadReconciliationPlan(plan, ports) {
339
339
  }
340
340
  let deleteConfirmed = false;
341
341
  let superseded = false;
342
- for (const method of ports.skipCloseBeforeDelete
342
+ // Private-chat topics (positive chat ids) cannot be closed; Telegram only supports closeForumTopic in forums.
343
+ const skipClose = ports.skipCloseBeforeDelete ||
344
+ (action.kind === "close-delete-graceful-shutdown-topic" &&
345
+ action.target.chatId > 0);
346
+ for (const method of skipClose
343
347
  ? ["deleteForumTopic"]
344
348
  : ["closeForumTopic", "deleteForumTopic"]) {
345
349
  if (ports.isCleanupTargetProtected?.(action.target, action)) {
@@ -157,6 +157,10 @@ export interface TelegramWorkspaceExternalProtectionEvidence {
157
157
  acceptedWork: TelegramWorkspaceProtectionState;
158
158
  deliveryAuthority: TelegramWorkspaceProtectionState;
159
159
  }
160
+ /** Per-binding probe; `withSnapshot` lets one occupancy capture share binding-independent evidence reads. */
161
+ export type TelegramWorkspaceExternalProtectionCapture = ((binding: TelegramWorkspaceThreadBinding) => TelegramWorkspaceExternalProtectionEvidence) & {
162
+ withSnapshot?: <T>(capture: () => T) => T;
163
+ };
160
164
  export interface TelegramWorkspaceSlotOccupancySnapshot {
161
165
  bindings: TelegramWorkspaceSlotOccupancy[];
162
166
  reservedSlots: string[];
@@ -549,7 +553,7 @@ export interface TelegramTopicTargetStore {
549
553
  bindingUpdatedAtMs: number;
550
554
  }, isCurrent: () => boolean) => Promise<boolean>;
551
555
  /** Caller must separately prove no external live owner, accepted work, or delivery authority. */
552
- captureWorkspaceSlotOccupancy: (getExternalProtection: (binding: TelegramWorkspaceThreadBinding) => TelegramWorkspaceExternalProtectionEvidence, options?: {
556
+ captureWorkspaceSlotOccupancy: (getExternalProtection: TelegramWorkspaceExternalProtectionCapture, options?: {
553
557
  expectedRetirement?: TelegramWorkspaceRetirementIntent;
554
558
  }) => TelegramWorkspaceSlotOccupancySnapshot;
555
559
  hasWorkspaceBinding: (cwd: string, sessionId?: string) => boolean;