@llblab/pi-telegram 0.50.1 → 0.51.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.
Files changed (73) hide show
  1. package/AGENTS.md +10 -8
  2. package/BACKLOG.md +13 -32
  3. package/CHANGELOG.md +16 -0
  4. package/README.md +4 -3
  5. package/dist/lib/bindings.d.ts +4 -2
  6. package/dist/lib/bindings.js +5 -5
  7. package/dist/lib/bus-follower.d.ts +4 -2
  8. package/dist/lib/bus-follower.js +99 -10
  9. package/dist/lib/bus-leader.d.ts +9 -3
  10. package/dist/lib/bus-leader.js +231 -71
  11. package/dist/lib/bus.d.ts +4 -0
  12. package/dist/lib/bus.js +34 -7
  13. package/dist/lib/commands.d.ts +5 -0
  14. package/dist/lib/commands.js +9 -0
  15. package/dist/lib/extension.js +68 -3
  16. package/dist/lib/journal.d.ts +3 -0
  17. package/dist/lib/journal.js +13 -0
  18. package/dist/lib/lifecycle.d.ts +2 -0
  19. package/dist/lib/lifecycle.js +25 -4
  20. package/dist/lib/locks.d.ts +1 -0
  21. package/dist/lib/locks.js +32 -12
  22. package/dist/lib/paths.d.ts +10 -0
  23. package/dist/lib/paths.js +22 -1
  24. package/dist/lib/polling.js +2 -2
  25. package/dist/lib/queue.d.ts +9 -1
  26. package/dist/lib/queue.js +64 -13
  27. package/dist/lib/replies.d.ts +4 -0
  28. package/dist/lib/replies.js +14 -0
  29. package/dist/lib/routing.d.ts +1 -0
  30. package/dist/lib/routing.js +5 -0
  31. package/dist/lib/sync.d.ts +9 -0
  32. package/dist/lib/sync.js +41 -1
  33. package/dist/lib/telegram-api.d.ts +9 -1
  34. package/dist/lib/telegram-api.js +23 -3
  35. package/dist/lib/thread-cleanup-manager.js +7 -2
  36. package/dist/lib/threads.d.ts +2 -0
  37. package/dist/lib/threads.js +68 -37
  38. package/dist/lib/updates.d.ts +6 -4
  39. package/dist/lib/updates.js +134 -61
  40. package/dist/lib/workspace-admission.d.ts +9 -0
  41. package/dist/lib/workspace-admission.js +49 -3
  42. package/dist/lib/workspace-retirement.d.ts +77 -6
  43. package/dist/lib/workspace-retirement.js +384 -8
  44. package/dist/lib/workspace-slots.d.ts +3 -0
  45. package/dist/lib/workspace-slots.js +6 -0
  46. package/dist/package.json +1 -1
  47. package/docs/architecture.md +22 -15
  48. package/docs/multi-instance-bus.md +32 -11
  49. package/docs/public-api.md +1 -1
  50. package/docs/updates.md +2 -0
  51. package/lib/bindings.ts +12 -7
  52. package/lib/bus-follower.ts +82 -14
  53. package/lib/bus-leader.ts +236 -94
  54. package/lib/bus.ts +39 -9
  55. package/lib/commands.ts +19 -0
  56. package/lib/extension.ts +78 -2
  57. package/lib/journal.ts +14 -0
  58. package/lib/lifecycle.ts +23 -4
  59. package/lib/locks.ts +32 -13
  60. package/lib/paths.ts +29 -1
  61. package/lib/polling.ts +2 -2
  62. package/lib/queue.ts +77 -19
  63. package/lib/replies.ts +18 -0
  64. package/lib/routing.ts +7 -0
  65. package/lib/sync.ts +45 -1
  66. package/lib/telegram-api.ts +30 -2
  67. package/lib/thread-cleanup-manager.ts +11 -3
  68. package/lib/threads.ts +73 -36
  69. package/lib/updates.ts +145 -78
  70. package/lib/workspace-admission.ts +67 -4
  71. package/lib/workspace-retirement.ts +465 -7
  72. package/lib/workspace-slots.ts +7 -0
  73. package/package.json +1 -1
package/lib/queue.ts CHANGED
@@ -126,6 +126,8 @@ export interface PendingTelegramTurn extends TelegramQueueItemBase {
126
126
  historyText: string;
127
127
  priorityEmoji?: string;
128
128
  reactionSuppressionEmoji?: string;
129
+ /** Emit the explicit aborted-turn notice when /next settles this active turn. */
130
+ announceNextAbortOnEnd?: boolean;
129
131
 
130
132
  /** Turn should preferably be delivered as voice (mirror mode + user sent voice) */
131
133
  voiceReplyPreferred?: boolean;
@@ -235,6 +237,8 @@ export interface TelegramActiveTurnStore<
235
237
  has: () => boolean;
236
238
  set: (turn: TTurn) => void;
237
239
  clear: () => void;
240
+ markNextAbortAnnouncement: () => boolean;
241
+ clearNextAbortAnnouncement: () => boolean;
238
242
  getChatId: () => number | undefined;
239
243
  getTarget: () => TelegramQueueTarget | undefined;
240
244
  getReplyToMessageId: () => number | undefined;
@@ -461,6 +465,16 @@ export function createTelegramActiveTurnStore<
461
465
  clear: () => {
462
466
  activeTurn = undefined;
463
467
  },
468
+ markNextAbortAnnouncement: () => {
469
+ if (!activeTurn) return false;
470
+ activeTurn.announceNextAbortOnEnd = true;
471
+ return true;
472
+ },
473
+ clearNextAbortAnnouncement: () => {
474
+ if (!activeTurn?.announceNextAbortOnEnd) return false;
475
+ delete activeTurn.announceNextAbortOnEnd;
476
+ return true;
477
+ },
464
478
  getChatId: () => activeTurn?.chatId,
465
479
  getTarget: () =>
466
480
  activeTurn?.target ? { ...activeTurn.target } : undefined,
@@ -1488,6 +1502,7 @@ export interface TelegramAgentEndPlan {
1488
1502
  kind: "no-turn" | "aborted" | "error" | "text" | "attachments-only" | "empty";
1489
1503
  shouldClearPreview: boolean;
1490
1504
  shouldDispatchNext: boolean;
1505
+ shouldSendAbortMessage: boolean;
1491
1506
  shouldSendErrorMessage: boolean;
1492
1507
  shouldSendAttachmentNotice: boolean;
1493
1508
  }
@@ -1615,7 +1630,7 @@ export interface TelegramAgentEndHookRuntimeDeps<
1615
1630
  extractAssistant: (
1616
1631
  messages: readonly TMessage[],
1617
1632
  ) => TelegramAgentEndAssistantResult;
1618
- isRecoveredAssistantAlreadyPublished?: (assistant: TelegramAgentEndAssistantResult) => boolean;
1633
+ isAssistantAlreadyPublished?: (assistant: TelegramAgentEndAssistantResult) => boolean;
1619
1634
  getFoldQueuedPromptsIntoHistory: () => boolean;
1620
1635
  resetRuntimeState: () => void;
1621
1636
  isSessionActive?: (ctx: TContext) => boolean;
@@ -1680,6 +1695,7 @@ export function buildTelegramAgentEndPlan(options: {
1680
1695
  hasFinalText: boolean;
1681
1696
  hasQueuedAttachments: boolean;
1682
1697
  foldQueuedPromptsIntoHistory: boolean;
1698
+ announceNextAbortOnEnd?: boolean;
1683
1699
  }): TelegramAgentEndPlan {
1684
1700
  const shouldDispatchNext = shouldDispatchAfterTelegramAgentEnd({
1685
1701
  hasTurn: options.hasTurn,
@@ -1691,6 +1707,7 @@ export function buildTelegramAgentEndPlan(options: {
1691
1707
  kind: "no-turn",
1692
1708
  shouldClearPreview: false,
1693
1709
  shouldDispatchNext,
1710
+ shouldSendAbortMessage: false,
1694
1711
  shouldSendErrorMessage: false,
1695
1712
  shouldSendAttachmentNotice: false,
1696
1713
  };
@@ -1700,6 +1717,7 @@ export function buildTelegramAgentEndPlan(options: {
1700
1717
  kind: "aborted",
1701
1718
  shouldClearPreview: true,
1702
1719
  shouldDispatchNext,
1720
+ shouldSendAbortMessage: options.announceNextAbortOnEnd === true,
1703
1721
  shouldSendErrorMessage: false,
1704
1722
  shouldSendAttachmentNotice: false,
1705
1723
  };
@@ -1709,6 +1727,7 @@ export function buildTelegramAgentEndPlan(options: {
1709
1727
  kind: "error",
1710
1728
  shouldClearPreview: true,
1711
1729
  shouldDispatchNext,
1730
+ shouldSendAbortMessage: false,
1712
1731
  shouldSendErrorMessage: true,
1713
1732
  shouldSendAttachmentNotice: false,
1714
1733
  };
@@ -1718,6 +1737,7 @@ export function buildTelegramAgentEndPlan(options: {
1718
1737
  kind: "text",
1719
1738
  shouldClearPreview: false,
1720
1739
  shouldDispatchNext,
1740
+ shouldSendAbortMessage: false,
1721
1741
  shouldSendErrorMessage: false,
1722
1742
  shouldSendAttachmentNotice: false,
1723
1743
  };
@@ -1727,6 +1747,7 @@ export function buildTelegramAgentEndPlan(options: {
1727
1747
  kind: "attachments-only",
1728
1748
  shouldClearPreview: true,
1729
1749
  shouldDispatchNext,
1750
+ shouldSendAbortMessage: false,
1730
1751
  shouldSendErrorMessage: false,
1731
1752
  shouldSendAttachmentNotice: true,
1732
1753
  };
@@ -1735,6 +1756,7 @@ export function buildTelegramAgentEndPlan(options: {
1735
1756
  kind: "empty",
1736
1757
  shouldClearPreview: true,
1737
1758
  shouldDispatchNext,
1759
+ shouldSendAbortMessage: false,
1738
1760
  shouldSendErrorMessage: false,
1739
1761
  shouldSendAttachmentNotice: false,
1740
1762
  };
@@ -1761,8 +1783,7 @@ export function createTelegramAgentEndHook<
1761
1783
  if (deps.isSessionActive?.(ctx) === false) return;
1762
1784
  const turn = deps.getActiveTurn();
1763
1785
  const extractedAssistant = assistantOverride ?? (turn ? deps.extractAssistant(event.messages) : {});
1764
- const assistant = extractedAssistant.recoveredFromEarlier &&
1765
- deps.isRecoveredAssistantAlreadyPublished?.(extractedAssistant)
1786
+ const assistant = deps.isAssistantAlreadyPublished?.(extractedAssistant)
1766
1787
  ? { stopReason: extractedAssistant.stopReason }
1767
1788
  : extractedAssistant;
1768
1789
  const hasPublication = !!assistant.text || assistant.stopReason === "error" || !!turn?.queuedAttachments.length;
@@ -1892,6 +1913,7 @@ export async function handleTelegramAgentEndRuntime<
1892
1913
  hasFinalText: !!finalText || hasOutboundArtifacts,
1893
1914
  hasQueuedAttachments: (turn?.queuedAttachments.length ?? 0) > 0,
1894
1915
  foldQueuedPromptsIntoHistory: deps.foldQueuedPromptsIntoHistory,
1916
+ announceNextAbortOnEnd: turn?.announceNextAbortOnEnd === true,
1895
1917
  });
1896
1918
  if (!turn) {
1897
1919
  if (endPlan.shouldDispatchNext) deps.dispatchNextQueuedTelegramTurn();
@@ -2018,22 +2040,29 @@ export async function handleTelegramAgentEndRuntime<
2018
2040
  await clearTurnPreview();
2019
2041
  if (!isDeliveryActive()) return;
2020
2042
  }
2021
- if (endPlan.shouldSendErrorMessage) {
2043
+ if (endPlan.shouldSendAbortMessage || endPlan.shouldSendErrorMessage) {
2022
2044
  const errorMessage = assistant.errorMessage ||
2023
2045
  "Telegram bridge: Pi failed while processing the request.";
2024
- const isOperationAborted = errorMessage.trim().replace(/\.$/, "") ===
2025
- "This operation was aborted";
2026
- await deps.sendTextReply(
2027
- turn.chatId,
2028
- turn.replyToMessageId,
2029
- isOperationAborted
2030
- ? "<b>⏹️ This operation was aborted.</b>"
2031
- : errorMessage,
2032
- {
2033
- target: turn.target,
2034
- ...(isOperationAborted ? { parseMode: "HTML" as const } : {}),
2035
- },
2036
- );
2046
+ const isOperationAborted = endPlan.shouldSendAbortMessage ||
2047
+ errorMessage.trim().replace(/\.$/, "") === "This operation was aborted";
2048
+ try {
2049
+ await deps.sendTextReply(
2050
+ turn.chatId,
2051
+ turn.replyToMessageId,
2052
+ isOperationAborted
2053
+ ? "<b>⏹️ This operation was aborted.</b>"
2054
+ : errorMessage,
2055
+ {
2056
+ target: turn.target,
2057
+ ...(isOperationAborted ? { parseMode: "HTML" as const } : {}),
2058
+ },
2059
+ );
2060
+ } catch (error) {
2061
+ if (!isOperationAborted) throw error;
2062
+ deps.recordRuntimeEvent?.("dispatch", error, {
2063
+ phase: "next-abort-announcement",
2064
+ });
2065
+ }
2037
2066
  if (!isDeliveryActive()) return;
2038
2067
  if (endPlan.shouldDispatchNext) deps.dispatchNextQueuedTelegramTurn();
2039
2068
  return;
@@ -2181,7 +2210,7 @@ export async function handleTelegramAgentEndRuntime<
2181
2210
  };
2182
2211
  if (
2183
2212
  deps.scheduleActiveTurnDelivery &&
2184
- (endPlan.kind === "text" || endPlan.kind === "attachments-only" || endPlan.shouldSendErrorMessage || endPlan.shouldClearPreview)
2213
+ (endPlan.kind === "text" || endPlan.kind === "attachments-only" || endPlan.shouldSendAbortMessage || endPlan.shouldSendErrorMessage || endPlan.shouldClearPreview)
2185
2214
  ) {
2186
2215
  deps.scheduleActiveTurnDelivery(deliverActiveTurn);
2187
2216
  return;
@@ -3093,6 +3122,9 @@ export interface TelegramQueueDispatchControllerDeps<
3093
3122
  ) => boolean;
3094
3123
  sendUserMessage: TelegramDispatchRuntimeDeps<TContext>["sendUserMessage"];
3095
3124
  onPromptDispatchFailure: (ctx: TContext, message: string) => void;
3125
+ reconcileNextDispatchAnnouncementReplyOwnership?: (
3126
+ item: PendingTelegramTurn,
3127
+ ) => void;
3096
3128
  isQueueItemTransportActive?: (item: TelegramQueueItem<TContext>) => boolean;
3097
3129
  hasPendingInboundQueueMutationForItem?: (
3098
3130
  item: TelegramQueueItem<TContext>,
@@ -3108,6 +3140,7 @@ export interface TelegramQueueDispatchControllerDeps<
3108
3140
  export interface TelegramQueueDispatchController<TContext = unknown> {
3109
3141
  dispatchNext: (ctx: TContext) => void;
3110
3142
  requestNextDispatchAnnouncement: () => void;
3143
+ cancelNextDispatchAnnouncement: () => void;
3111
3144
  }
3112
3145
 
3113
3146
  export function executeTelegramQueueDispatchPlan<TContext = unknown>(
@@ -3162,6 +3195,8 @@ export function createTelegramQueueDispatchRuntime<TContext = unknown>(
3162
3195
  commitPromptDispatch: deps.commitPromptDispatch,
3163
3196
  sendUserMessage: deps.sendUserMessage,
3164
3197
  onPromptDispatchFailure: deps.onPromptDispatchFailure,
3198
+ reconcileNextDispatchAnnouncementReplyOwnership:
3199
+ deps.reconcileNextDispatchAnnouncementReplyOwnership,
3165
3200
  isQueueItemTransportActive: deps.isQueueItemTransportActive,
3166
3201
  hasPendingInboundQueueMutationForItem:
3167
3202
  deps.hasPendingInboundQueueMutationForItem,
@@ -3177,12 +3212,19 @@ export function createTelegramQueueDispatchController<TContext = unknown>(
3177
3212
  ): TelegramQueueDispatchController<TContext> {
3178
3213
  let controlDispatchPending = false;
3179
3214
  let nextDispatchAnnouncementRequested = false;
3215
+ let nextDispatchAnnouncementGeneration = 0;
3180
3216
  let nextDispatchAnnouncementAnchor: TelegramQueueItem<TContext> | undefined;
3181
3217
  const controller: TelegramQueueDispatchController<TContext> = {
3182
3218
  requestNextDispatchAnnouncement: () => {
3219
+ nextDispatchAnnouncementGeneration += 1;
3183
3220
  nextDispatchAnnouncementRequested = true;
3184
3221
  nextDispatchAnnouncementAnchor = undefined;
3185
3222
  },
3223
+ cancelNextDispatchAnnouncement: () => {
3224
+ nextDispatchAnnouncementGeneration += 1;
3225
+ nextDispatchAnnouncementRequested = false;
3226
+ nextDispatchAnnouncementAnchor = undefined;
3227
+ },
3186
3228
  dispatchNext: (ctx) => {
3187
3229
  if (deps.hasDispatchContext && !deps.hasDispatchContext()) return;
3188
3230
  if (controlDispatchPending) {
@@ -3394,6 +3436,7 @@ export function createTelegramQueueDispatchController<TContext = unknown>(
3394
3436
  if (dispatchPlan.kind === "prompt" && nextDispatchAnnouncementRequested) {
3395
3437
  nextDispatchAnnouncementRequested = false;
3396
3438
  controlDispatchPending = true;
3439
+ const announcementGeneration = nextDispatchAnnouncementGeneration;
3397
3440
  const dispatchGeneration = deps.getDispatchGeneration?.();
3398
3441
  deps.updateStatus(ctx);
3399
3442
  void deps.sendTextReply(
@@ -3402,8 +3445,19 @@ export function createTelegramQueueDispatchController<TContext = unknown>(
3402
3445
  "<b>⏩ Dispatching next queued turn.</b>",
3403
3446
  { target: dispatchPlan.item.target },
3404
3447
  ).catch((error) => {
3405
- deps.recordRuntimeEvent?.("dispatch", error, { phase: "next-announcement" });
3448
+ deps.recordRuntimeEvent?.("dispatch", error, {
3449
+ phase: "next-announcement",
3450
+ });
3406
3451
  }).finally(() => {
3452
+ try {
3453
+ deps.reconcileNextDispatchAnnouncementReplyOwnership?.(
3454
+ dispatchPlan.item,
3455
+ );
3456
+ } catch (error) {
3457
+ deps.recordRuntimeEvent?.("dispatch", error, {
3458
+ phase: "next-announcement-reply-ownership",
3459
+ });
3460
+ }
3407
3461
  controlDispatchPending = false;
3408
3462
  if (deps.hasDispatchContext && !deps.hasDispatchContext()) return;
3409
3463
  if (
@@ -3411,6 +3465,10 @@ export function createTelegramQueueDispatchController<TContext = unknown>(
3411
3465
  deps.isDispatchGenerationActive &&
3412
3466
  !deps.isDispatchGenerationActive(dispatchGeneration)
3413
3467
  ) return;
3468
+ if (announcementGeneration !== nextDispatchAnnouncementGeneration) {
3469
+ if (nextDispatchAnnouncementRequested) controller.dispatchNext(ctx);
3470
+ return;
3471
+ }
3414
3472
  executePlan();
3415
3473
  });
3416
3474
  return;
package/lib/replies.ts CHANGED
@@ -68,6 +68,7 @@ export function createReplyDedupRuntime(): ReplyDedupRuntime {
68
68
  // --- Transport-level dedup ---
69
69
 
70
70
  const lastRepliedToMessageIdByTarget = new Map<string, number>();
71
+ const replyDedupPreservedOnNextReset = new Map<string, number>();
71
72
  let replyDedupGeneration = 0;
72
73
 
73
74
  function getReplyDedupTargetKey(
@@ -83,6 +84,23 @@ function getReplyDedupTargetKey(
83
84
  export function resetTransportReplyDedup(): void {
84
85
  replyDedupGeneration += 1;
85
86
  lastRepliedToMessageIdByTarget.clear();
87
+ for (const [key, messageId] of replyDedupPreservedOnNextReset) {
88
+ lastRepliedToMessageIdByTarget.set(key, messageId);
89
+ }
90
+ replyDedupPreservedOnNextReset.clear();
91
+ }
92
+
93
+ /** Keeps a successfully published transition notice as the first reply of the
94
+ * next agent turn. The following agent-start reset consumes this one-shot
95
+ * preservation, so later messages in that turn do not repeat the reply header. */
96
+ export function preserveTransportReplyDedupOnNextReset(
97
+ chatId: number,
98
+ messageId: number,
99
+ target?: TelegramTarget,
100
+ ): void {
101
+ const key = getReplyDedupTargetKey(chatId, target);
102
+ if (lastRepliedToMessageIdByTarget.get(key) !== messageId) return;
103
+ replyDedupPreservedOnNextReset.set(key, messageId);
86
104
  }
87
105
 
88
106
  export function buildTelegramReplyParameters(
package/lib/routing.ts CHANGED
@@ -627,6 +627,7 @@ export interface TelegramInboundRouteRuntimeDeps<
627
627
  isContextActive?: (ctx: TContext) => boolean;
628
628
  dispatchNextQueuedTelegramTurn: (ctx: TContext) => void;
629
629
  requestNextDispatchAnnouncement?: () => void;
630
+ cancelNextDispatchAnnouncement?: () => void;
630
631
  requestDeferredDispatchNextQueuedTelegramTurn?: (
631
632
  dispatch: (ctx: TContext) => void,
632
633
  ) => void;
@@ -2222,6 +2223,10 @@ export function createTelegramInboundRouteRuntime<
2222
2223
  isContextActive: deps.isContextActive,
2223
2224
  dispatchNextQueuedTelegramTurn: deps.dispatchNextQueuedTelegramTurn,
2224
2225
  requestNextDispatchAnnouncement: deps.requestNextDispatchAnnouncement,
2226
+ cancelNextTransitionAnnouncements: () => {
2227
+ deps.activeTurnRuntime.clearNextAbortAnnouncement();
2228
+ deps.cancelNextDispatchAnnouncement?.();
2229
+ },
2225
2230
  requestDeferredDispatchNextQueuedTelegramTurn:
2226
2231
  deps.requestDeferredDispatchNextQueuedTelegramTurn,
2227
2232
  startTypingLoop: deps.startTypingLoop,
@@ -2297,6 +2302,8 @@ export function createTelegramInboundRouteRuntime<
2297
2302
  },
2298
2303
  getPromptTemplateCommands,
2299
2304
  sendTextReply: deps.sendTextReply,
2305
+ markActiveTurnNextAbortAnnouncement:
2306
+ deps.activeTurnRuntime.markNextAbortAnnouncement,
2300
2307
  getActiveTurnReply: () => {
2301
2308
  const activeTurn = deps.activeTurnRuntime.get();
2302
2309
  if (!activeTurn) return undefined;
package/lib/sync.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  import { getTelegramApiErrorRequestTarget, isTelegramStaleTargetHttpError } from "./telegram-api.ts";
8
8
  import { getTelegramTargetKey, type TelegramTarget } from "./target.ts";
9
9
  import * as ThreadReconciler from "./thread-reconciler.ts";
10
+ import { TelegramWorkspaceSlotUnavailableError } from "./workspace-slots.ts";
10
11
  import {
11
12
  createTelegramWorkspaceAdmissionOperationId,
12
13
  runWithTelegramWorkspaceAdmissionsAsync,
@@ -197,6 +198,47 @@ export function markTelegramConfigSyncChange<
197
198
  return nextState;
198
199
  }
199
200
 
201
+ export function createTelegramPreservedLeaderQuitHandler(deps: {
202
+ instanceId: string;
203
+ topicTargetStore: Pick<TelegramTopicTargetStore,
204
+ "load" | "list" | "listPendingCleanups" | "detachTargetOwner">;
205
+ getCurrentLeaderEpoch: () => number | string | undefined;
206
+ getProfileName: () => string | undefined;
207
+ isPollingSuspended: () => boolean;
208
+ resolveAutomaticThreadCleanupEnabled: () => boolean | Promise<boolean>;
209
+ runWorkspaceOperation: TelegramSyncWorkspaceOperationRunner;
210
+ }): (isSessionCurrent: () => boolean) => (() => Promise<void>) | undefined {
211
+ return (isSessionCurrent) => {
212
+ const epoch = deps.getCurrentLeaderEpoch();
213
+ const profile = deps.getProfileName();
214
+ if (epoch === undefined || !isSessionCurrent()) return undefined;
215
+ const isCurrent = () => isSessionCurrent() && deps.getCurrentLeaderEpoch() === epoch &&
216
+ deps.getProfileName() === profile && deps.isPollingSuspended();
217
+ return async () => {
218
+ if (!isCurrent() || await deps.resolveAutomaticThreadCleanupEnabled() !== false) return;
219
+ if (!isCurrent()) return;
220
+ await deps.runWorkspaceOperation({
221
+ operationId: createTelegramWorkspaceAdmissionOperationId(),
222
+ operationKind: "workspace.preserve-leader-quit",
223
+ scopes: [{ kind: "profile" }],
224
+ }, async () => {
225
+ if (!isCurrent()) return;
226
+ await deps.topicTargetStore.load();
227
+ if (!isCurrent()) return;
228
+ const records = deps.topicTargetStore.list().filter((record) =>
229
+ record.instanceId === deps.instanceId && (record.status === "active" || record.status === "starting"));
230
+ if (records.length !== 1) return;
231
+ const record = records[0]!;
232
+ if (deps.topicTargetStore.listPendingCleanups().some((intent) =>
233
+ intent.target.chatId === record.target.chatId && intent.target.threadId === record.target.threadId)) return;
234
+ if (!await deps.topicTargetStore.detachTargetOwner(record, isCurrent) && isCurrent()) {
235
+ throw new Error("Telegram preserved leader detachment was not committed.");
236
+ }
237
+ });
238
+ };
239
+ };
240
+ }
241
+
200
242
  export interface TelegramSessionRestartThreadCleanupDeps<
201
243
  TSyncState extends TelegramSyncState,
202
244
  > extends Omit<TelegramManualThreadDisconnectDeps<TSyncState>, "stopPolling"> {
@@ -650,15 +692,17 @@ export async function ensureTelegramLeaderThreadBinding(
650
692
  const leaderProfileKey = getTelegramThreadOwnerKey(leaderOwner);
651
693
  const legacyLeaderRecord =
652
694
  deps.topicTargetStore.getByProfileKey(leaderProfileKey);
695
+ let capacityUnavailable = false;
653
696
  const workspaceIdentity = normalizedLeaderCwd
654
697
  ? deps.topicTargetStore.claimWorkspaceIdentity(
655
698
  normalizedLeaderCwd,
656
699
  deps.instanceId,
657
700
  legacyLeaderRecord?.instanceId,
658
- { sessionId: deps.sessionId },
701
+ { sessionId: deps.sessionId, onCapacityUnavailable() { capacityUnavailable = true; } },
659
702
  )
660
703
  : undefined;
661
704
  if (deps.cwd && !workspaceIdentity) {
705
+ if (capacityUnavailable) throw new TelegramWorkspaceSlotUnavailableError();
662
706
  throw new Error("Telegram Workspace identity is already claimed.");
663
707
  }
664
708
  const commitWorkspaceBinding = async (
@@ -882,15 +882,18 @@ export function isTelegramApiCommitUnknownError(
882
882
  class TelegramApiHttpError extends Error {
883
883
  readonly status: number | undefined;
884
884
  readonly retryAfterSeconds: number | undefined;
885
+ readonly rejectedRequestMethod: string | undefined;
885
886
  requestTarget?: { chatId: number; threadId: number };
886
887
  constructor(
887
888
  message: string,
888
889
  status: number | undefined,
889
890
  retryAfterSeconds: number | undefined,
891
+ rejectedRequestMethod?: string,
890
892
  ) {
891
893
  super(message);
892
894
  this.status = status;
893
895
  this.retryAfterSeconds = retryAfterSeconds;
896
+ this.rejectedRequestMethod = rejectedRequestMethod;
894
897
  }
895
898
  }
896
899
 
@@ -934,6 +937,12 @@ export function isTelegramStaleTargetHttpError(error: unknown): boolean {
934
937
  return /^Telegram API \w+ failed: HTTP 400: Bad Request: (message thread not found|thread not found|topic not found|topic deleted|topic closed|thread closed|forum topic closed|message thread closed|topic_id_invalid|topic_closed)$/i.test(error.message);
935
938
  }
936
939
 
940
+ /** Only a parsed Telegram rejection of this method proves a request had no effect. */
941
+ export function isTelegramApiRequestRejected(error: unknown, method: string): boolean {
942
+ return error instanceof TelegramApiHttpError && error.rejectedRequestMethod !== undefined &&
943
+ error.rejectedRequestMethod === method;
944
+ }
945
+
937
946
  export function isTelegramMessageNotModifiedError(error: unknown): boolean {
938
947
  return (
939
948
  error instanceof Error && error.message.includes("message is not modified")
@@ -1106,6 +1115,8 @@ async function parseTelegramApiResponse<TResponse>(
1106
1115
  `Telegram API ${method} failed: ${status}${description}`,
1107
1116
  response.status,
1108
1117
  Number.isFinite(retryAfterSeconds) ? retryAfterSeconds : undefined,
1118
+ data?.ok === false && data.error_code === response.status &&
1119
+ [400, 401, 403, 404, 429].includes(response.status) ? method : undefined,
1109
1120
  );
1110
1121
  }
1111
1122
  if (!data) {
@@ -1785,6 +1796,10 @@ export function buildTelegramAnswerGuestQueryBody(
1785
1796
  return body;
1786
1797
  }
1787
1798
 
1799
+ export type TelegramWorkspaceThreadDeletionTransport = (
1800
+ authorize: () => { chatId: number; threadId: number },
1801
+ ) => Promise<void>;
1802
+
1788
1803
  export function createDefaultTelegramBridgeApiRuntime(deps: {
1789
1804
  getBotToken: () => string | undefined;
1790
1805
  recordRuntimeEvent: TelegramBridgeApiRuntimeDeps["recordRuntimeEvent"];
@@ -1793,7 +1808,9 @@ export function createDefaultTelegramBridgeApiRuntime(deps: {
1793
1808
  workspaceAdmission?:
1794
1809
  | TelegramApiWorkspaceAdmissionPort
1795
1810
  | (() => TelegramApiWorkspaceAdmissionPort | undefined);
1796
- }): TelegramBridgeApiRuntime {
1811
+ }): TelegramBridgeApiRuntime & {
1812
+ deleteWorkspaceThread: TelegramWorkspaceThreadDeletionTransport;
1813
+ } {
1797
1814
  const client = createTelegramApiClient(deps.getBotToken, {
1798
1815
  recordRuntimeEvent: deps.recordRuntimeEvent,
1799
1816
  });
@@ -1807,7 +1824,7 @@ export function createDefaultTelegramBridgeApiRuntime(deps: {
1807
1824
  },
1808
1825
  })
1809
1826
  : client;
1810
- return createTelegramBridgeApiRuntime({
1827
+ const runtime = createTelegramBridgeApiRuntime({
1811
1828
  client: deps.targetActivity
1812
1829
  ? createTelegramApiTargetTrackingClient(admittedClient, deps.targetActivity)
1813
1830
  : admittedClient,
@@ -1817,6 +1834,17 @@ export function createDefaultTelegramBridgeApiRuntime(deps: {
1817
1834
  recordRuntimeEvent: deps.recordRuntimeEvent,
1818
1835
  captureRequestErrorHandler: deps.captureRequestErrorHandler,
1819
1836
  });
1837
+ return {
1838
+ ...runtime,
1839
+ async deleteWorkspaceThread(authorize) {
1840
+ // The exclusive deletion permit replaces ordinary target admission.
1841
+ const target = authorize();
1842
+ const deleted = await client.call<boolean>("deleteForumTopic", {
1843
+ chat_id: target.chatId, message_thread_id: target.threadId,
1844
+ }, { maxAttempts: 1, retrySafety: "non-idempotent" });
1845
+ if (deleted !== true) throw new Error("Telegram Workspace Thread deletion was not confirmed.");
1846
+ },
1847
+ };
1820
1848
  }
1821
1849
 
1822
1850
  export function createTelegramBridgeApiRuntime(
@@ -433,7 +433,12 @@ export function createTelegramThreadCleanupWorkStore(options: {
433
433
  publish(file); return { confirmed: true, entry: structuredClone(entry) };
434
434
  });
435
435
  },
436
- list() { return structuredClone(read().workSets); },
436
+ list() {
437
+ return structuredClone(withTelegramFileTransaction(
438
+ `${options.path}.transaction`,
439
+ () => read().workSets,
440
+ ));
441
+ },
437
442
  };
438
443
  }
439
444
 
@@ -542,8 +547,11 @@ export function createTelegramThreadCleanupPermitRuntime(deps: {
542
547
  if (fence.leaderEpoch !== leaderEpoch || fence.owner.processId !== owner.processId ||
543
548
  fence.owner.processBirthId !== owner.processBirthId) {
544
549
  if (!deps.canAdoptFence(fence)) return undefined;
545
- try { fence = deps.ledger.adoptThreadCleanupFence(fence,
546
- { owner, leaderEpoch }); } catch { return undefined; }
550
+ try {
551
+ const adopted = deps.ledger.adoptThreadCleanupFence(fence, { owner, leaderEpoch });
552
+ if (adopted.destructiveKind !== "manual-thread-cleanup") return undefined;
553
+ fence = adopted;
554
+ } catch { return undefined; }
547
555
  }
548
556
  return deps.getLeaderEpoch() === leaderEpoch ? fence : undefined;
549
557
  };