@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/dist/lib/queue.js CHANGED
@@ -168,6 +168,18 @@ export function createTelegramActiveTurnStore() {
168
168
  clear: () => {
169
169
  activeTurn = undefined;
170
170
  },
171
+ markNextAbortAnnouncement: () => {
172
+ if (!activeTurn)
173
+ return false;
174
+ activeTurn.announceNextAbortOnEnd = true;
175
+ return true;
176
+ },
177
+ clearNextAbortAnnouncement: () => {
178
+ if (!activeTurn?.announceNextAbortOnEnd)
179
+ return false;
180
+ delete activeTurn.announceNextAbortOnEnd;
181
+ return true;
182
+ },
171
183
  getChatId: () => activeTurn?.chatId,
172
184
  getTarget: () => activeTurn?.target ? { ...activeTurn.target } : undefined,
173
185
  getReplyToMessageId: () => activeTurn?.replyToMessageId,
@@ -815,6 +827,7 @@ export function buildTelegramAgentEndPlan(options) {
815
827
  kind: "no-turn",
816
828
  shouldClearPreview: false,
817
829
  shouldDispatchNext,
830
+ shouldSendAbortMessage: false,
818
831
  shouldSendErrorMessage: false,
819
832
  shouldSendAttachmentNotice: false,
820
833
  };
@@ -824,6 +837,7 @@ export function buildTelegramAgentEndPlan(options) {
824
837
  kind: "aborted",
825
838
  shouldClearPreview: true,
826
839
  shouldDispatchNext,
840
+ shouldSendAbortMessage: options.announceNextAbortOnEnd === true,
827
841
  shouldSendErrorMessage: false,
828
842
  shouldSendAttachmentNotice: false,
829
843
  };
@@ -833,6 +847,7 @@ export function buildTelegramAgentEndPlan(options) {
833
847
  kind: "error",
834
848
  shouldClearPreview: true,
835
849
  shouldDispatchNext,
850
+ shouldSendAbortMessage: false,
836
851
  shouldSendErrorMessage: true,
837
852
  shouldSendAttachmentNotice: false,
838
853
  };
@@ -842,6 +857,7 @@ export function buildTelegramAgentEndPlan(options) {
842
857
  kind: "text",
843
858
  shouldClearPreview: false,
844
859
  shouldDispatchNext,
860
+ shouldSendAbortMessage: false,
845
861
  shouldSendErrorMessage: false,
846
862
  shouldSendAttachmentNotice: false,
847
863
  };
@@ -851,6 +867,7 @@ export function buildTelegramAgentEndPlan(options) {
851
867
  kind: "attachments-only",
852
868
  shouldClearPreview: true,
853
869
  shouldDispatchNext,
870
+ shouldSendAbortMessage: false,
854
871
  shouldSendErrorMessage: false,
855
872
  shouldSendAttachmentNotice: true,
856
873
  };
@@ -859,6 +876,7 @@ export function buildTelegramAgentEndPlan(options) {
859
876
  kind: "empty",
860
877
  shouldClearPreview: true,
861
878
  shouldDispatchNext,
879
+ shouldSendAbortMessage: false,
862
880
  shouldSendErrorMessage: false,
863
881
  shouldSendAttachmentNotice: false,
864
882
  };
@@ -869,8 +887,7 @@ export function createTelegramAgentEndHook(deps) {
869
887
  return;
870
888
  const turn = deps.getActiveTurn();
871
889
  const extractedAssistant = assistantOverride ?? (turn ? deps.extractAssistant(event.messages) : {});
872
- const assistant = extractedAssistant.recoveredFromEarlier &&
873
- deps.isRecoveredAssistantAlreadyPublished?.(extractedAssistant)
890
+ const assistant = deps.isAssistantAlreadyPublished?.(extractedAssistant)
874
891
  ? { stopReason: extractedAssistant.stopReason }
875
892
  : extractedAssistant;
876
893
  const hasPublication = !!assistant.text || assistant.stopReason === "error" || !!turn?.queuedAttachments.length;
@@ -992,6 +1009,7 @@ export async function handleTelegramAgentEndRuntime(deps) {
992
1009
  hasFinalText: !!finalText || hasOutboundArtifacts,
993
1010
  hasQueuedAttachments: (turn?.queuedAttachments.length ?? 0) > 0,
994
1011
  foldQueuedPromptsIntoHistory: deps.foldQueuedPromptsIntoHistory,
1012
+ announceNextAbortOnEnd: turn?.announceNextAbortOnEnd === true,
995
1013
  });
996
1014
  if (!turn) {
997
1015
  if (endPlan.shouldDispatchNext)
@@ -1117,17 +1135,26 @@ export async function handleTelegramAgentEndRuntime(deps) {
1117
1135
  if (!isDeliveryActive())
1118
1136
  return;
1119
1137
  }
1120
- if (endPlan.shouldSendErrorMessage) {
1138
+ if (endPlan.shouldSendAbortMessage || endPlan.shouldSendErrorMessage) {
1121
1139
  const errorMessage = assistant.errorMessage ||
1122
1140
  "Telegram bridge: Pi failed while processing the request.";
1123
- const isOperationAborted = errorMessage.trim().replace(/\.$/, "") ===
1124
- "This operation was aborted";
1125
- await deps.sendTextReply(turn.chatId, turn.replyToMessageId, isOperationAborted
1126
- ? "<b>⏹️ This operation was aborted.</b>"
1127
- : errorMessage, {
1128
- target: turn.target,
1129
- ...(isOperationAborted ? { parseMode: "HTML" } : {}),
1130
- });
1141
+ const isOperationAborted = endPlan.shouldSendAbortMessage ||
1142
+ errorMessage.trim().replace(/\.$/, "") === "This operation was aborted";
1143
+ try {
1144
+ await deps.sendTextReply(turn.chatId, turn.replyToMessageId, isOperationAborted
1145
+ ? "<b>⏹️ This operation was aborted.</b>"
1146
+ : errorMessage, {
1147
+ target: turn.target,
1148
+ ...(isOperationAborted ? { parseMode: "HTML" } : {}),
1149
+ });
1150
+ }
1151
+ catch (error) {
1152
+ if (!isOperationAborted)
1153
+ throw error;
1154
+ deps.recordRuntimeEvent?.("dispatch", error, {
1155
+ phase: "next-abort-announcement",
1156
+ });
1157
+ }
1131
1158
  if (!isDeliveryActive())
1132
1159
  return;
1133
1160
  if (endPlan.shouldDispatchNext)
@@ -1268,7 +1295,7 @@ export async function handleTelegramAgentEndRuntime(deps) {
1268
1295
  deps.dispatchNextQueuedTelegramTurn();
1269
1296
  };
1270
1297
  if (deps.scheduleActiveTurnDelivery &&
1271
- (endPlan.kind === "text" || endPlan.kind === "attachments-only" || endPlan.shouldSendErrorMessage || endPlan.shouldClearPreview)) {
1298
+ (endPlan.kind === "text" || endPlan.kind === "attachments-only" || endPlan.shouldSendAbortMessage || endPlan.shouldSendErrorMessage || endPlan.shouldClearPreview)) {
1272
1299
  deps.scheduleActiveTurnDelivery(deliverActiveTurn);
1273
1300
  return;
1274
1301
  }
@@ -1766,6 +1793,7 @@ export function createTelegramQueueDispatchRuntime(deps) {
1766
1793
  commitPromptDispatch: deps.commitPromptDispatch,
1767
1794
  sendUserMessage: deps.sendUserMessage,
1768
1795
  onPromptDispatchFailure: deps.onPromptDispatchFailure,
1796
+ reconcileNextDispatchAnnouncementReplyOwnership: deps.reconcileNextDispatchAnnouncementReplyOwnership,
1769
1797
  isQueueItemTransportActive: deps.isQueueItemTransportActive,
1770
1798
  hasPendingInboundQueueMutationForItem: deps.hasPendingInboundQueueMutationForItem,
1771
1799
  isQueueItemAdmissionReady: deps.isQueueItemAdmissionReady,
@@ -1777,12 +1805,19 @@ export function createTelegramQueueDispatchRuntime(deps) {
1777
1805
  export function createTelegramQueueDispatchController(deps) {
1778
1806
  let controlDispatchPending = false;
1779
1807
  let nextDispatchAnnouncementRequested = false;
1808
+ let nextDispatchAnnouncementGeneration = 0;
1780
1809
  let nextDispatchAnnouncementAnchor;
1781
1810
  const controller = {
1782
1811
  requestNextDispatchAnnouncement: () => {
1812
+ nextDispatchAnnouncementGeneration += 1;
1783
1813
  nextDispatchAnnouncementRequested = true;
1784
1814
  nextDispatchAnnouncementAnchor = undefined;
1785
1815
  },
1816
+ cancelNextDispatchAnnouncement: () => {
1817
+ nextDispatchAnnouncementGeneration += 1;
1818
+ nextDispatchAnnouncementRequested = false;
1819
+ nextDispatchAnnouncementAnchor = undefined;
1820
+ },
1786
1821
  dispatchNext: (ctx) => {
1787
1822
  if (deps.hasDispatchContext && !deps.hasDispatchContext())
1788
1823
  return;
@@ -1973,11 +2008,22 @@ export function createTelegramQueueDispatchController(deps) {
1973
2008
  if (dispatchPlan.kind === "prompt" && nextDispatchAnnouncementRequested) {
1974
2009
  nextDispatchAnnouncementRequested = false;
1975
2010
  controlDispatchPending = true;
2011
+ const announcementGeneration = nextDispatchAnnouncementGeneration;
1976
2012
  const dispatchGeneration = deps.getDispatchGeneration?.();
1977
2013
  deps.updateStatus(ctx);
1978
2014
  void deps.sendTextReply(dispatchPlan.item.chatId, dispatchPlan.item.replyToMessageId, "<b>⏩ Dispatching next queued turn.</b>", { target: dispatchPlan.item.target }).catch((error) => {
1979
- deps.recordRuntimeEvent?.("dispatch", error, { phase: "next-announcement" });
2015
+ deps.recordRuntimeEvent?.("dispatch", error, {
2016
+ phase: "next-announcement",
2017
+ });
1980
2018
  }).finally(() => {
2019
+ try {
2020
+ deps.reconcileNextDispatchAnnouncementReplyOwnership?.(dispatchPlan.item);
2021
+ }
2022
+ catch (error) {
2023
+ deps.recordRuntimeEvent?.("dispatch", error, {
2024
+ phase: "next-announcement-reply-ownership",
2025
+ });
2026
+ }
1981
2027
  controlDispatchPending = false;
1982
2028
  if (deps.hasDispatchContext && !deps.hasDispatchContext())
1983
2029
  return;
@@ -1985,6 +2031,11 @@ export function createTelegramQueueDispatchController(deps) {
1985
2031
  deps.isDispatchGenerationActive &&
1986
2032
  !deps.isDispatchGenerationActive(dispatchGeneration))
1987
2033
  return;
2034
+ if (announcementGeneration !== nextDispatchAnnouncementGeneration) {
2035
+ if (nextDispatchAnnouncementRequested)
2036
+ controller.dispatchNext(ctx);
2037
+ return;
2038
+ }
1988
2039
  executePlan();
1989
2040
  });
1990
2041
  return;
@@ -23,6 +23,10 @@ export interface ReplyDedupRuntime {
23
23
  }
24
24
  export declare function createReplyDedupRuntime(): ReplyDedupRuntime;
25
25
  export declare function resetTransportReplyDedup(): void;
26
+ /** Keeps a successfully published transition notice as the first reply of the
27
+ * next agent turn. The following agent-start reset consumes this one-shot
28
+ * preservation, so later messages in that turn do not repeat the reply header. */
29
+ export declare function preserveTransportReplyDedupOnNextReset(chatId: number, messageId: number, target?: TelegramTarget): void;
26
30
  export declare function buildTelegramReplyParameters(chatId: number, messageId: number | undefined, target?: TelegramTarget): TelegramReplyParameters | undefined;
27
31
  export declare function withTelegramReplyParameters<T>(chatId: number, messageId: number | undefined, target: TelegramTarget | undefined, send: (parameters: TelegramReplyParameters | undefined) => Promise<T>): Promise<T>;
28
32
  export declare function isAssistantAgentMessage(message: unknown): boolean;
@@ -31,6 +31,7 @@ export function createReplyDedupRuntime() {
31
31
  }
32
32
  // --- Transport-level dedup ---
33
33
  const lastRepliedToMessageIdByTarget = new Map();
34
+ const replyDedupPreservedOnNextReset = new Map();
34
35
  let replyDedupGeneration = 0;
35
36
  function getReplyDedupTargetKey(chatId, target) {
36
37
  const threadId = target?.threadId;
@@ -41,6 +42,19 @@ function getReplyDedupTargetKey(chatId, target) {
41
42
  export function resetTransportReplyDedup() {
42
43
  replyDedupGeneration += 1;
43
44
  lastRepliedToMessageIdByTarget.clear();
45
+ for (const [key, messageId] of replyDedupPreservedOnNextReset) {
46
+ lastRepliedToMessageIdByTarget.set(key, messageId);
47
+ }
48
+ replyDedupPreservedOnNextReset.clear();
49
+ }
50
+ /** Keeps a successfully published transition notice as the first reply of the
51
+ * next agent turn. The following agent-start reset consumes this one-shot
52
+ * preservation, so later messages in that turn do not repeat the reply header. */
53
+ export function preserveTransportReplyDedupOnNextReset(chatId, messageId, target) {
54
+ const key = getReplyDedupTargetKey(chatId, target);
55
+ if (lastRepliedToMessageIdByTarget.get(key) !== messageId)
56
+ return;
57
+ replyDedupPreservedOnNextReset.set(key, messageId);
44
58
  }
45
59
  export function buildTelegramReplyParameters(chatId, messageId, target) {
46
60
  if (messageId === undefined || messageId <= 0)
@@ -122,6 +122,7 @@ export interface TelegramInboundRouteRuntimeDeps<TMessage extends TelegramRouted
122
122
  isContextActive?: (ctx: TContext) => boolean;
123
123
  dispatchNextQueuedTelegramTurn: (ctx: TContext) => void;
124
124
  requestNextDispatchAnnouncement?: () => void;
125
+ cancelNextDispatchAnnouncement?: () => void;
125
126
  requestDeferredDispatchNextQueuedTelegramTurn?: (dispatch: (ctx: TContext) => void) => void;
126
127
  hasDeferredDispatchContext?: () => boolean;
127
128
  startTypingLoop?: (ctx: TContext, chatId?: number, options?: {
@@ -1418,6 +1418,10 @@ export function createTelegramInboundRouteRuntime(deps) {
1418
1418
  isContextActive: deps.isContextActive,
1419
1419
  dispatchNextQueuedTelegramTurn: deps.dispatchNextQueuedTelegramTurn,
1420
1420
  requestNextDispatchAnnouncement: deps.requestNextDispatchAnnouncement,
1421
+ cancelNextTransitionAnnouncements: () => {
1422
+ deps.activeTurnRuntime.clearNextAbortAnnouncement();
1423
+ deps.cancelNextDispatchAnnouncement?.();
1424
+ },
1421
1425
  requestDeferredDispatchNextQueuedTelegramTurn: deps.requestDeferredDispatchNextQueuedTelegramTurn,
1422
1426
  startTypingLoop: deps.startTypingLoop,
1423
1427
  stopTypingLoop: deps.stopTypingLoop,
@@ -1476,6 +1480,7 @@ export function createTelegramInboundRouteRuntime(deps) {
1476
1480
  },
1477
1481
  getPromptTemplateCommands,
1478
1482
  sendTextReply: deps.sendTextReply,
1483
+ markActiveTurnNextAbortAnnouncement: deps.activeTurnRuntime.markNextAbortAnnouncement,
1479
1484
  getActiveTurnReply: () => {
1480
1485
  const activeTurn = deps.activeTurnRuntime.get();
1481
1486
  if (!activeTurn)
@@ -97,6 +97,15 @@ export interface TelegramManualThreadDisconnectDeps<TSyncState> {
97
97
  export declare function markTelegramConfigSyncChange<TSyncState extends TelegramSyncState>(state: TSyncState, action: string, options?: {
98
98
  nowMs?: number;
99
99
  }): TSyncState;
100
+ export declare function createTelegramPreservedLeaderQuitHandler(deps: {
101
+ instanceId: string;
102
+ topicTargetStore: Pick<TelegramTopicTargetStore, "load" | "list" | "listPendingCleanups" | "detachTargetOwner">;
103
+ getCurrentLeaderEpoch: () => number | string | undefined;
104
+ getProfileName: () => string | undefined;
105
+ isPollingSuspended: () => boolean;
106
+ resolveAutomaticThreadCleanupEnabled: () => boolean | Promise<boolean>;
107
+ runWorkspaceOperation: TelegramSyncWorkspaceOperationRunner;
108
+ }): (isSessionCurrent: () => boolean) => (() => Promise<void>) | undefined;
100
109
  export interface TelegramSessionRestartThreadCleanupDeps<TSyncState extends TelegramSyncState> extends Omit<TelegramManualThreadDisconnectDeps<TSyncState>, "stopPolling"> {
101
110
  suspendPolling: () => Promise<void>;
102
111
  }
package/dist/lib/sync.js CHANGED
@@ -6,6 +6,7 @@
6
6
  import { getTelegramApiErrorRequestTarget, isTelegramStaleTargetHttpError } from "./telegram-api.js";
7
7
  import { getTelegramTargetKey } from "./target.js";
8
8
  import * as ThreadReconciler from "./thread-reconciler.js";
9
+ import { TelegramWorkspaceSlotUnavailableError } from "./workspace-slots.js";
9
10
  import { createTelegramWorkspaceAdmissionOperationId, runWithTelegramWorkspaceAdmissionsAsync, } from "./workspace-admission.js";
10
11
  import { createTelegramCleanupTargetProtection, commitTelegramWorkspaceProvisionBinding, getTelegramTargetFromApiBody, getTelegramThreadOwnerKey, isSameTelegramProcessInstance, isTelegramTopicTargetStaleError, normalizeTelegramWorkspacePath, provisionOwnBusTopic, } from "./threads.js";
11
12
  export function markTelegramConfigSyncChange(state, action, options) {
@@ -24,6 +25,42 @@ export function markTelegramConfigSyncChange(state, action, options) {
24
25
  });
25
26
  return nextState;
26
27
  }
28
+ export function createTelegramPreservedLeaderQuitHandler(deps) {
29
+ return (isSessionCurrent) => {
30
+ const epoch = deps.getCurrentLeaderEpoch();
31
+ const profile = deps.getProfileName();
32
+ if (epoch === undefined || !isSessionCurrent())
33
+ return undefined;
34
+ const isCurrent = () => isSessionCurrent() && deps.getCurrentLeaderEpoch() === epoch &&
35
+ deps.getProfileName() === profile && deps.isPollingSuspended();
36
+ return async () => {
37
+ if (!isCurrent() || await deps.resolveAutomaticThreadCleanupEnabled() !== false)
38
+ return;
39
+ if (!isCurrent())
40
+ return;
41
+ await deps.runWorkspaceOperation({
42
+ operationId: createTelegramWorkspaceAdmissionOperationId(),
43
+ operationKind: "workspace.preserve-leader-quit",
44
+ scopes: [{ kind: "profile" }],
45
+ }, async () => {
46
+ if (!isCurrent())
47
+ return;
48
+ await deps.topicTargetStore.load();
49
+ if (!isCurrent())
50
+ return;
51
+ const records = deps.topicTargetStore.list().filter((record) => record.instanceId === deps.instanceId && (record.status === "active" || record.status === "starting"));
52
+ if (records.length !== 1)
53
+ return;
54
+ const record = records[0];
55
+ if (deps.topicTargetStore.listPendingCleanups().some((intent) => intent.target.chatId === record.target.chatId && intent.target.threadId === record.target.threadId))
56
+ return;
57
+ if (!await deps.topicTargetStore.detachTargetOwner(record, isCurrent) && isCurrent()) {
58
+ throw new Error("Telegram preserved leader detachment was not committed.");
59
+ }
60
+ });
61
+ };
62
+ };
63
+ }
27
64
  export function createTelegramSessionRestartThreadCleanupHandler(deps) {
28
65
  return createTelegramManualThreadDisconnectHandler({
29
66
  ...deps,
@@ -353,10 +390,13 @@ export async function ensureTelegramLeaderThreadBinding(deps) {
353
390
  };
354
391
  const leaderProfileKey = getTelegramThreadOwnerKey(leaderOwner);
355
392
  const legacyLeaderRecord = deps.topicTargetStore.getByProfileKey(leaderProfileKey);
393
+ let capacityUnavailable = false;
356
394
  const workspaceIdentity = normalizedLeaderCwd
357
- ? deps.topicTargetStore.claimWorkspaceIdentity(normalizedLeaderCwd, deps.instanceId, legacyLeaderRecord?.instanceId, { sessionId: deps.sessionId })
395
+ ? deps.topicTargetStore.claimWorkspaceIdentity(normalizedLeaderCwd, deps.instanceId, legacyLeaderRecord?.instanceId, { sessionId: deps.sessionId, onCapacityUnavailable() { capacityUnavailable = true; } })
358
396
  : undefined;
359
397
  if (deps.cwd && !workspaceIdentity) {
398
+ if (capacityUnavailable)
399
+ throw new TelegramWorkspaceSlotUnavailableError();
360
400
  throw new Error("Telegram Workspace identity is already claimed.");
361
401
  }
362
402
  const commitWorkspaceBinding = async (result) => {
@@ -484,6 +484,8 @@ export declare function getTelegramApiErrorRequestTarget(error: unknown): {
484
484
  threadId: number;
485
485
  } | undefined;
486
486
  export declare function isTelegramStaleTargetHttpError(error: unknown): boolean;
487
+ /** Only a parsed Telegram rejection of this method proves a request had no effect. */
488
+ export declare function isTelegramApiRequestRejected(error: unknown, method: string): boolean;
487
489
  export declare function isTelegramMessageNotModifiedError(error: unknown): boolean;
488
490
  export declare function isTelegramApiMethodRetrySafe(method: string): boolean;
489
491
  export declare function isRetryableTelegramApiError(error: unknown): boolean;
@@ -522,13 +524,19 @@ export declare function createTelegramAssistantDraftSender(deps: {
522
524
  sendRichMessageDraft: TelegramBridgeApiRuntime["sendRichMessageDraft"];
523
525
  }): TelegramBridgeApiRuntime["sendMessageDraft"];
524
526
  export declare function buildTelegramAnswerGuestQueryBody(guestQueryId: string, text?: string, options?: TelegramAnswerGuestQueryOptions): Record<string, unknown>;
527
+ export type TelegramWorkspaceThreadDeletionTransport = (authorize: () => {
528
+ chatId: number;
529
+ threadId: number;
530
+ }) => Promise<void>;
525
531
  export declare function createDefaultTelegramBridgeApiRuntime(deps: {
526
532
  getBotToken: () => string | undefined;
527
533
  recordRuntimeEvent: TelegramBridgeApiRuntimeDeps["recordRuntimeEvent"];
528
534
  captureRequestErrorHandler?: TelegramBridgeApiRuntimeDeps["captureRequestErrorHandler"];
529
535
  targetActivity?: TelegramApiTargetActivityRuntime;
530
536
  workspaceAdmission?: TelegramApiWorkspaceAdmissionPort | (() => TelegramApiWorkspaceAdmissionPort | undefined);
531
- }): TelegramBridgeApiRuntime;
537
+ }): TelegramBridgeApiRuntime & {
538
+ deleteWorkspaceThread: TelegramWorkspaceThreadDeletionTransport;
539
+ };
532
540
  export declare function createTelegramBridgeApiRuntime(deps: TelegramBridgeApiRuntimeDeps): TelegramBridgeApiRuntime;
533
541
  /**
534
542
  * Creates a low-level Telegram Bot API client.
@@ -231,11 +231,13 @@ export function isTelegramApiCommitUnknownError(error) {
231
231
  class TelegramApiHttpError extends Error {
232
232
  status;
233
233
  retryAfterSeconds;
234
+ rejectedRequestMethod;
234
235
  requestTarget;
235
- constructor(message, status, retryAfterSeconds) {
236
+ constructor(message, status, retryAfterSeconds, rejectedRequestMethod) {
236
237
  super(message);
237
238
  this.status = status;
238
239
  this.retryAfterSeconds = retryAfterSeconds;
240
+ this.rejectedRequestMethod = rejectedRequestMethod;
239
241
  }
240
242
  }
241
243
  function attachTelegramApiRequestTarget(error, body) {
@@ -267,6 +269,11 @@ export function isTelegramStaleTargetHttpError(error) {
267
269
  return false;
268
270
  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);
269
271
  }
272
+ /** Only a parsed Telegram rejection of this method proves a request had no effect. */
273
+ export function isTelegramApiRequestRejected(error, method) {
274
+ return error instanceof TelegramApiHttpError && error.rejectedRequestMethod !== undefined &&
275
+ error.rejectedRequestMethod === method;
276
+ }
270
277
  export function isTelegramMessageNotModifiedError(error) {
271
278
  return (error instanceof Error && error.message.includes("message is not modified"));
272
279
  }
@@ -398,7 +405,8 @@ async function parseTelegramApiResponse(response, method) {
398
405
  const retryAfterHeader = response.headers?.get("retry-after");
399
406
  const retryAfterSeconds = data?.parameters?.retry_after ??
400
407
  (retryAfterHeader ? Number.parseInt(retryAfterHeader, 10) : undefined);
401
- throw new TelegramApiHttpError(`Telegram API ${method} failed: ${status}${description}`, response.status, Number.isFinite(retryAfterSeconds) ? retryAfterSeconds : undefined);
408
+ throw new TelegramApiHttpError(`Telegram API ${method} failed: ${status}${description}`, response.status, Number.isFinite(retryAfterSeconds) ? retryAfterSeconds : undefined, data?.ok === false && data.error_code === response.status &&
409
+ [400, 401, 403, 404, 429].includes(response.status) ? method : undefined);
402
410
  }
403
411
  if (!data) {
404
412
  throw new TelegramApiMalformedSuccessError(method, "returned invalid JSON");
@@ -856,7 +864,7 @@ export function createDefaultTelegramBridgeApiRuntime(deps) {
856
864
  },
857
865
  })
858
866
  : client;
859
- return createTelegramBridgeApiRuntime({
867
+ const runtime = createTelegramBridgeApiRuntime({
860
868
  client: deps.targetActivity
861
869
  ? createTelegramApiTargetTrackingClient(admittedClient, deps.targetActivity)
862
870
  : admittedClient,
@@ -866,6 +874,18 @@ export function createDefaultTelegramBridgeApiRuntime(deps) {
866
874
  recordRuntimeEvent: deps.recordRuntimeEvent,
867
875
  captureRequestErrorHandler: deps.captureRequestErrorHandler,
868
876
  });
877
+ return {
878
+ ...runtime,
879
+ async deleteWorkspaceThread(authorize) {
880
+ // The exclusive deletion permit replaces ordinary target admission.
881
+ const target = authorize();
882
+ const deleted = await client.call("deleteForumTopic", {
883
+ chat_id: target.chatId, message_thread_id: target.threadId,
884
+ }, { maxAttempts: 1, retrySafety: "non-idempotent" });
885
+ if (deleted !== true)
886
+ throw new Error("Telegram Workspace Thread deletion was not confirmed.");
887
+ },
888
+ };
869
889
  }
870
890
  export function createTelegramBridgeApiRuntime(deps) {
871
891
  const recoverRequestError = async (handler, error) => {
@@ -342,7 +342,9 @@ export function createTelegramThreadCleanupWorkStore(options) {
342
342
  return { confirmed: true, entry: structuredClone(entry) };
343
343
  });
344
344
  },
345
- list() { return structuredClone(read().workSets); },
345
+ list() {
346
+ return structuredClone(withTelegramFileTransaction(`${options.path}.transaction`, () => read().workSets));
347
+ },
346
348
  };
347
349
  }
348
350
  export async function commitTelegramInactiveThreadCleanup(input) {
@@ -401,7 +403,10 @@ export function createTelegramThreadCleanupPermitRuntime(deps) {
401
403
  if (!deps.canAdoptFence(fence))
402
404
  return undefined;
403
405
  try {
404
- fence = deps.ledger.adoptThreadCleanupFence(fence, { owner, leaderEpoch });
406
+ const adopted = deps.ledger.adoptThreadCleanupFence(fence, { owner, leaderEpoch });
407
+ if (adopted.destructiveKind !== "manual-thread-cleanup")
408
+ return undefined;
409
+ fence = adopted;
405
410
  }
406
411
  catch {
407
412
  return undefined;
@@ -212,6 +212,8 @@ export interface TelegramTopicTargetStore {
212
212
  refresh?: () => Promise<void>;
213
213
  persist: () => Promise<void>;
214
214
  invalidateTarget: (target: TelegramTarget, isCurrent: () => boolean, lastSyncError: string) => Promise<boolean>;
215
+ /** Caller proves owner detachment; this does not assert Telegram Thread absence. */
216
+ detachTargetOwner: (expected: TelegramTopicTargetRecord, isCurrent: () => boolean) => Promise<boolean>;
215
217
  list: () => TelegramTopicTargetRecord[];
216
218
  getFollowerRecoveryHintByTarget?: (target: TelegramTarget) => {
217
219
  slot?: string;