@llblab/pi-telegram 0.50.0 → 0.51.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 (73) hide show
  1. package/AGENTS.md +9 -7
  2. package/BACKLOG.md +13 -32
  3. package/CHANGELOG.md +18 -0
  4. package/README.md +5 -5
  5. package/dist/lib/bindings.d.ts +3 -5
  6. package/dist/lib/bindings.js +4 -11
  7. package/dist/lib/bus-follower.d.ts +1 -1
  8. package/dist/lib/bus-follower.js +93 -9
  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 +1 -4
  14. package/dist/lib/commands.js +13 -33
  15. package/dist/lib/extension.js +64 -6
  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/menu-queue.d.ts +1 -0
  23. package/dist/lib/menu-queue.js +72 -1
  24. package/dist/lib/paths.d.ts +10 -0
  25. package/dist/lib/paths.js +22 -1
  26. package/dist/lib/polling.js +2 -2
  27. package/dist/lib/queue.d.ts +8 -1
  28. package/dist/lib/queue.js +28 -2
  29. package/dist/lib/replies.d.ts +3 -0
  30. package/dist/lib/replies.js +17 -0
  31. package/dist/lib/sync.d.ts +9 -0
  32. package/dist/lib/sync.js +49 -2
  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 +23 -14
  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 +5 -17
  52. package/lib/bus-follower.ts +69 -12
  53. package/lib/bus-leader.ts +236 -94
  54. package/lib/bus.ts +39 -9
  55. package/lib/commands.ts +18 -41
  56. package/lib/extension.ts +70 -10
  57. package/lib/journal.ts +14 -0
  58. package/lib/lifecycle.ts +23 -4
  59. package/lib/locks.ts +32 -13
  60. package/lib/menu-queue.ts +126 -1
  61. package/lib/paths.ts +29 -1
  62. package/lib/polling.ts +2 -2
  63. package/lib/queue.ts +48 -3
  64. package/lib/replies.ts +19 -0
  65. package/lib/sync.ts +53 -2
  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/paths.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { createHash } from "node:crypto";
11
11
  import { homedir } from "node:os";
12
- import { join, resolve } from "node:path";
12
+ import { isAbsolute, join, resolve } from "node:path";
13
13
 
14
14
  export const TELEGRAM_DEFAULT_PROFILE_NAME = "default";
15
15
 
@@ -43,6 +43,20 @@ export function resolveAgentDir(
43
43
  return join(homedir(), ".pi", "agent");
44
44
  }
45
45
 
46
+ /**
47
+ * Pure reference preflight against an independently approved resource path.
48
+ * Reject aliases; never repair relative historical references or redirect storage.
49
+ * Equality proves spelling only, not file identity, consumer closure or migration readiness.
50
+ */
51
+ export function requireTelegramStoragePathReference(path: string, expectedPath: string): string {
52
+ if (typeof path !== "string" || typeof expectedPath !== "string" ||
53
+ !isAbsolute(path) || resolve(path) !== path ||
54
+ !isAbsolute(expectedPath) || resolve(expectedPath) !== expectedPath || path !== expectedPath) {
55
+ throw new Error("Telegram storage reference does not match its approved absolute path.");
56
+ }
57
+ return path;
58
+ }
59
+
46
60
  /** Telegram bridge configuration file (<agentDir>/telegram.json). */
47
61
  export function resolveTelegramConfigPath(): string {
48
62
  return join(resolveAgentDir(), "telegram.json");
@@ -100,6 +114,13 @@ export function resolveTelegramWorkspaceAdmissionPath(
100
114
  );
101
115
  }
102
116
 
117
+ /** Profile-only callback shape; binds storage to the configured agent directory. */
118
+ export function resolveTelegramWorkspaceAdmissionPathForProfile(
119
+ profileName?: string,
120
+ ): string {
121
+ return resolveTelegramWorkspaceAdmissionPath(resolveAgentDir(), profileName);
122
+ }
123
+
103
124
  /** Durable inactive Thread cleanup work-set journal. */
104
125
  export function resolveTelegramThreadCleanupWorkPath(
105
126
  agentDir = resolveAgentDir(),
@@ -129,6 +150,13 @@ export function resolveTelegramUpdateJournalPath(
129
150
  );
130
151
  }
131
152
 
153
+ /** Profile-only callback shape; binds storage to the configured agent directory. */
154
+ export function resolveTelegramUpdateJournalPathForProfile(
155
+ profileName?: string,
156
+ ): string {
157
+ return resolveTelegramUpdateJournalPath(resolveAgentDir(), profileName);
158
+ }
159
+
132
160
  /** Durable follower delivery journal, isolated by stable recipient binding. */
133
161
  export function resolveTelegramFollowerJournalPath(
134
162
  recipientBindingKey: string,
package/lib/polling.ts CHANGED
@@ -28,8 +28,8 @@ const TELEGRAM_GET_UPDATES_CONFLICT_SLOW_RETRY_MS = 3_000;
28
28
  const TELEGRAM_POLLING_RETRY_MS = 3_000;
29
29
  export const TELEGRAM_GET_UPDATES_GRACE_MS = 10_000;
30
30
 
31
- // Standard Telegram DM polling does not expose ordinary message-deletion events,
32
- // so queue removal stays reaction-driven while delete-like business updates remain defensive-only.
31
+ // Standard Telegram DM polling does not expose ordinary message-deletion events.
32
+ // Business deletions belong to a separate namespace and default routing ignores them.
33
33
  export const TELEGRAM_ALLOWED_UPDATES = [
34
34
  "message",
35
35
  "edited_message",
package/lib/queue.ts CHANGED
@@ -922,6 +922,28 @@ export function removeTelegramQueueItemsByMessageIds<TContext = unknown>(
922
922
  };
923
923
  }
924
924
 
925
+ export function removeTelegramQueuedGuestPromptByOrder<TContext = unknown>(
926
+ items: TelegramQueueItem<TContext>[],
927
+ queueOrder: number,
928
+ ): {
929
+ items: TelegramQueueItem<TContext>[];
930
+ removedItems: PendingTelegramTurn[];
931
+ removedCount: number;
932
+ } {
933
+ const index = items.findIndex((item) => {
934
+ return isPendingTelegramTurn(item) &&
935
+ item.guestQueryId !== undefined &&
936
+ item.queueOrder === queueOrder;
937
+ });
938
+ if (index < 0) return { items, removedItems: [], removedCount: 0 };
939
+ const removedItem = items[index] as PendingTelegramTurn;
940
+ return {
941
+ items: [...items.slice(0, index), ...items.slice(index + 1)],
942
+ removedItems: [removedItem],
943
+ removedCount: 1,
944
+ };
945
+ }
946
+
925
947
  export function applyTelegramQueuePromptReactionDisposition<
926
948
  TContext = unknown,
927
949
  >(
@@ -1593,7 +1615,7 @@ export interface TelegramAgentEndHookRuntimeDeps<
1593
1615
  extractAssistant: (
1594
1616
  messages: readonly TMessage[],
1595
1617
  ) => TelegramAgentEndAssistantResult;
1596
- isRecoveredAssistantAlreadyPublished?: (assistant: TelegramAgentEndAssistantResult) => boolean;
1618
+ isAssistantAlreadyPublished?: (assistant: TelegramAgentEndAssistantResult) => boolean;
1597
1619
  getFoldQueuedPromptsIntoHistory: () => boolean;
1598
1620
  resetRuntimeState: () => void;
1599
1621
  isSessionActive?: (ctx: TContext) => boolean;
@@ -1739,8 +1761,7 @@ export function createTelegramAgentEndHook<
1739
1761
  if (deps.isSessionActive?.(ctx) === false) return;
1740
1762
  const turn = deps.getActiveTurn();
1741
1763
  const extractedAssistant = assistantOverride ?? (turn ? deps.extractAssistant(event.messages) : {});
1742
- const assistant = extractedAssistant.recoveredFromEarlier &&
1743
- deps.isRecoveredAssistantAlreadyPublished?.(extractedAssistant)
1764
+ const assistant = deps.isAssistantAlreadyPublished?.(extractedAssistant)
1744
1765
  ? { stopReason: extractedAssistant.stopReason }
1745
1766
  : extractedAssistant;
1746
1767
  const hasPublication = !!assistant.text || assistant.stopReason === "error" || !!turn?.queuedAttachments.length;
@@ -2331,6 +2352,10 @@ export interface TelegramQueueMutationController<TContext> {
2331
2352
  ctx: TContext,
2332
2353
  scope?: TelegramQueueMessageScope,
2333
2354
  ) => number;
2355
+ removeGuestPromptByQueueOrder?: (
2356
+ queueOrder: number,
2357
+ ctx: TContext,
2358
+ ) => boolean;
2334
2359
  applyReactionByMessageId: (
2335
2360
  messageId: number,
2336
2361
  disposition: TelegramQueueReactionDisposition,
@@ -2604,6 +2629,11 @@ export function createTelegramQueueMutationController<TContext>(
2604
2629
  buildRuntimeDeps(ctx),
2605
2630
  scope,
2606
2631
  ),
2632
+ removeGuestPromptByQueueOrder: (queueOrder, ctx) =>
2633
+ removeTelegramQueuedGuestPromptByOrderRuntime(
2634
+ queueOrder,
2635
+ buildRuntimeDeps(ctx),
2636
+ ),
2607
2637
  applyReactionByMessageId: (messageId, disposition, ctx, scope) =>
2608
2638
  applyTelegramQueuePromptReactionDispositionRuntime(
2609
2639
  messageId,
@@ -2684,6 +2714,21 @@ export function removeTelegramQueueItemsByMessageIdsRuntime<TContext>(
2684
2714
  return removedCount;
2685
2715
  }
2686
2716
 
2717
+ export function removeTelegramQueuedGuestPromptByOrderRuntime<TContext>(
2718
+ queueOrder: number,
2719
+ deps: TelegramQueueMutationRuntimeDeps<TContext>,
2720
+ ): boolean {
2721
+ const { items, removedItems, removedCount } =
2722
+ removeTelegramQueuedGuestPromptByOrder(deps.getQueuedItems(), queueOrder);
2723
+ if (removedCount === 0) return false;
2724
+ // A menu Skip means permanent discard, so settle durable admission before
2725
+ // removing the live item. A failed settlement keeps the prompt retryable.
2726
+ deps.onItemsDiscarded?.(removedItems, deps.ctx);
2727
+ deps.setQueuedItems(items);
2728
+ updateTelegramQueueStatusRuntime(deps);
2729
+ return true;
2730
+ }
2731
+
2687
2732
  export function applyTelegramQueuePromptReactionDispositionRuntime<TContext>(
2688
2733
  messageId: number,
2689
2734
  disposition: TelegramQueueReactionDisposition,
package/lib/replies.ts CHANGED
@@ -1018,6 +1018,10 @@ export const TELEGRAM_GUEST_PLACEHOLDER_FRAMES = [
1018
1018
  "<b>🌏 Working on it...</b>",
1019
1019
  ] as const;
1020
1020
 
1021
+ // Telegram does not expose deletion for inline messages. This non-printing,
1022
+ // non-empty edit visually clears a guest placeholder after its query is skipped.
1023
+ export const TELEGRAM_DISMISSED_GUEST_PLACEHOLDER_TEXT = "\u2063";
1024
+
1021
1025
  export function buildTelegramGuestPlaceholderFrame(step: number): string {
1022
1026
  const frames: readonly string[] = TELEGRAM_GUEST_PLACEHOLDER_FRAMES;
1023
1027
  const index = ((step % frames.length) + frames.length) % frames.length;
@@ -1052,6 +1056,8 @@ export interface TelegramGuestPlaceholderRuntime {
1052
1056
  stop: (inlineMessageId: string) => Promise<void>;
1053
1057
  /** Cancels every loop without waiting for in-flight edits (session shutdown). */
1054
1058
  stopAll: () => void;
1059
+ /** Stops rotation and visually clears the inline placeholder. */
1060
+ dismiss: (inlineMessageId: string) => Promise<void>;
1055
1061
  }
1056
1062
 
1057
1063
  interface TelegramGuestPlaceholderSession {
@@ -1202,5 +1208,18 @@ export function createTelegramGuestPlaceholderRuntime(
1202
1208
  }
1203
1209
  sessions.clear();
1204
1210
  },
1211
+ async dismiss(inlineMessageId) {
1212
+ await stop(inlineMessageId);
1213
+ try {
1214
+ await deps.editGuestInlineMessage(inlineMessageId, {
1215
+ text: TELEGRAM_DISMISSED_GUEST_PLACEHOLDER_TEXT,
1216
+ parseMode: "HTML",
1217
+ });
1218
+ } catch (error) {
1219
+ deps.recordRuntimeEvent?.("guest", error, {
1220
+ phase: "guest-placeholder-dismiss",
1221
+ });
1222
+ }
1223
+ },
1205
1224
  };
1206
1225
  }
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 (
@@ -684,7 +728,14 @@ export async function ensureTelegramLeaderThreadBinding(
684
728
  assertLeaderEpoch("before-workspace-persist");
685
729
  await deps.topicTargetStore.persist();
686
730
  assertLeaderEpoch("after-workspace-persist");
687
- return { ...result, ...(committed.displayTitle ? { displayTitle: committed.displayTitle } : {}) };
731
+ const { target: _target, slot: _slot, threadName: _threadName, displayTitle: _displayTitle, ...rest } = result;
732
+ return {
733
+ ...rest,
734
+ target: { ...committed.target },
735
+ slot: committed.slot ?? result.slot,
736
+ ...(committed.threadName ? { threadName: committed.threadName } : {}),
737
+ ...(committed.displayTitle ? { displayTitle: committed.displayTitle } : {}),
738
+ };
688
739
  };
689
740
  try {
690
741
  const unavailableTargetKeys = new Set([
@@ -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
  };
package/lib/threads.ts CHANGED
@@ -34,6 +34,7 @@ import * as ThreadReconciler from "./thread-reconciler.ts";
34
34
  import {
35
35
  planTelegramWorkspaceSlotAllocation,
36
36
  TELEGRAM_WORKSPACE_SLOTS,
37
+ TelegramWorkspaceSlotUnavailableError,
37
38
  type TelegramWorkspaceSlotOccupancy,
38
39
  } from "./workspace-slots.ts";
39
40
  import {
@@ -458,6 +459,11 @@ export interface TelegramTopicTargetStore {
458
459
  isCurrent: () => boolean,
459
460
  lastSyncError: string,
460
461
  ) => Promise<boolean>;
462
+ /** Caller proves owner detachment; this does not assert Telegram Thread absence. */
463
+ detachTargetOwner: (
464
+ expected: TelegramTopicTargetRecord,
465
+ isCurrent: () => boolean,
466
+ ) => Promise<boolean>;
461
467
  list: () => TelegramTopicTargetRecord[];
462
468
  getFollowerRecoveryHintByTarget?: (
463
469
  target: TelegramTarget,
@@ -2108,20 +2114,33 @@ export function createTelegramTopicTargetStore(
2108
2114
  mutationRevision += 1;
2109
2115
  };
2110
2116
 
2111
- const persistSnapshot = (invalidation?: {
2117
+ const markWorkspaceBindingInactiveByTarget = (target: TelegramTarget, inactiveSinceMs = getNowMs()): boolean => {
2118
+ if (!Number.isFinite(inactiveSinceMs) || inactiveSinceMs < 0) return false;
2119
+ for (const [key, binding] of workspaceBindings) {
2120
+ if (!targetMatches(binding.target, target) || binding.inactiveSinceMs !== undefined) continue;
2121
+ workspaceBindings.set(key, { ...binding, inactiveSinceMs });
2122
+ markDirty();
2123
+ return true;
2124
+ }
2125
+ return false;
2126
+ };
2127
+
2128
+ const persistSnapshot = (transition?: {
2112
2129
  target: TelegramTarget;
2113
2130
  isCurrent: () => boolean;
2114
- lastSyncError: string;
2115
- }): Promise<boolean> => {
2131
+ } & (
2132
+ | { kind: "invalidate"; lastSyncError: string }
2133
+ | { kind: "detach"; owner: TelegramTopicTargetRecord }
2134
+ )): Promise<boolean> => {
2116
2135
  const persist = persistQueue.then(async () => {
2117
2136
  const path = getPath();
2118
2137
  if (loadedPath !== path && !dirty) resetForPath(path);
2119
2138
  if (options.canPersist && !options.canPersist()) {
2120
- if (!invalidation) await loadFromDisk();
2139
+ if (!transition) await loadFromDisk();
2121
2140
  return false;
2122
2141
  }
2123
2142
  if (!dirty || !loaded) await loadFromDisk();
2124
- if (invalidation && !invalidation.isCurrent()) return false;
2143
+ if (transition && !transition.isCurrent()) return false;
2125
2144
  const nowMs = getNowMs();
2126
2145
  reservations = reservations.filter(
2127
2146
  (reservation) =>
@@ -2134,6 +2153,10 @@ export function createTelegramTopicTargetStore(
2134
2153
  const currentRecords = Array.from(records.values())
2135
2154
  .filter(isPersistedThreadRecord)
2136
2155
  .map(cloneRecord);
2156
+ if (transition?.kind === "detach") {
2157
+ const owners = currentRecords.filter((record) => targetMatches(record.target, transition.target));
2158
+ if (owners.length !== 1 || !isDeepStrictEqual(normalizeRecord(owners[0]), transition.owner)) return false;
2159
+ }
2137
2160
  records = new Map(
2138
2161
  currentRecords.map((record) => [
2139
2162
  getRecordOwnerKey(record),
@@ -2176,20 +2199,32 @@ export function createTelegramTopicTargetStore(
2176
2199
  return serialized;
2177
2200
  }),
2178
2201
  };
2179
- if (invalidation) {
2180
- const record = file.threads.find((record) => targetMatches(record.target, invalidation.target));
2202
+ if (transition) {
2203
+ const record = file.threads.find((record) => targetMatches(record.target, transition.target));
2181
2204
  if (!record) return false;
2205
+ if (transition.kind === "detach") {
2206
+ const bindings = file.workspaceBindings.filter((binding) => targetMatches(binding.target, record.target));
2207
+ if (bindings.length !== 1 || !/^[A-Z]$/.test(record.slot ?? "") ||
2208
+ bindings[0]!.slot !== record.slot) return false;
2209
+ }
2182
2210
  file.threads = file.threads.filter((candidate) => candidate !== record);
2183
- file.syncObservations = file.syncObservations.filter((observation) => !targetMatches(observation.target, record.target));
2184
- file.syncObservations.push({
2185
- target: { ...record.target },
2186
- syncStatus: "deleted",
2187
- observedAtMs: nowMs,
2188
- ...(record.instanceId ? { instanceId: record.instanceId } : {}),
2189
- ...(record.slot ? { slot: record.slot } : {}),
2190
- lastSyncError: invalidation.lastSyncError,
2191
- lastReconcileAction: "mark-stale",
2192
- });
2211
+ for (const binding of file.workspaceBindings) {
2212
+ if (targetMatches(binding.target, transition.target) && binding.inactiveSinceMs === undefined) {
2213
+ binding.inactiveSinceMs = nowMs;
2214
+ }
2215
+ }
2216
+ if (transition.kind === "invalidate") {
2217
+ file.syncObservations = file.syncObservations.filter((observation) => !targetMatches(observation.target, record.target));
2218
+ file.syncObservations.push({
2219
+ target: { ...record.target },
2220
+ syncStatus: "deleted",
2221
+ observedAtMs: nowMs,
2222
+ ...(record.instanceId ? { instanceId: record.instanceId } : {}),
2223
+ ...(record.slot ? { slot: record.slot } : {}),
2224
+ lastSyncError: transition.lastSyncError,
2225
+ lastReconcileAction: "mark-stale",
2226
+ });
2227
+ }
2193
2228
  }
2194
2229
  let persistedSemanticSnapshot: Record<string, unknown> | undefined;
2195
2230
  try {
@@ -2201,7 +2236,7 @@ export function createTelegramTopicTargetStore(
2201
2236
  }
2202
2237
  // Normalize optional fields to wire JSON; object key order is not a state change.
2203
2238
  if (
2204
- isDeepStrictEqual(
2239
+ !transition && isDeepStrictEqual(
2205
2240
  persistedSemanticSnapshot,
2206
2241
  getTelegramStateSemanticSnapshot(JSON.parse(JSON.stringify(file))),
2207
2242
  ) &&
@@ -2221,17 +2256,20 @@ export function createTelegramTopicTargetStore(
2221
2256
  });
2222
2257
  await chmod(tempPath, 0o600);
2223
2258
  try {
2224
- if (invalidation) {
2259
+ if (transition) {
2225
2260
  let applied = false;
2226
2261
  const commit = () => {
2227
2262
  if (getPath() !== path || mutationRevision !== persistedRevision ||
2228
- statusRevision !== persistedStatusRevision || !invalidation.isCurrent()) return;
2263
+ statusRevision !== persistedStatusRevision || !transition.isCurrent()) return;
2229
2264
  // Fence and rename share one synchronous commit boundary. No stale
2230
- // invalidation enters the live projection before durable commit.
2265
+ // transition enters the live projection before durable commit.
2231
2266
  renameSync(tempPath, path);
2232
2267
  loadedPath = path;
2233
- records = new Map(Array.from(records).filter(([, record]) => !targetMatches(record.target, invalidation.target)));
2268
+ records = new Map(Array.from(records).filter(([, record]) => !targetMatches(record.target, transition.target)));
2234
2269
  syncObservations = file.syncObservations;
2270
+ workspaceBindings = new Map(file.workspaceBindings.map((binding) => [
2271
+ getWorkspaceBindingMapKey(binding), cloneWorkspaceBinding(binding),
2272
+ ]));
2235
2273
  mutationRevision += 1;
2236
2274
  dirty = false;
2237
2275
  applied = true;
@@ -2283,7 +2321,12 @@ export function createTelegramTopicTargetStore(
2283
2321
  await persistSnapshot();
2284
2322
  },
2285
2323
  invalidateTarget(target, isCurrent, lastSyncError) {
2286
- return persistSnapshot({ target, isCurrent, lastSyncError });
2324
+ return persistSnapshot({ kind: "invalidate", target, isCurrent, lastSyncError });
2325
+ },
2326
+ detachTargetOwner(expected, isCurrent) {
2327
+ const owner = normalizeRecord(expected);
2328
+ if (!owner) return Promise.resolve(false);
2329
+ return persistSnapshot({ kind: "detach", owner, target: owner.target, isCurrent });
2287
2330
  },
2288
2331
  list() {
2289
2332
  return Array.from(records.values()).map(cloneRecord);
@@ -2838,16 +2881,7 @@ export function createTelegramTopicTargetStore(
2838
2881
  markDirty();
2839
2882
  return true;
2840
2883
  },
2841
- markWorkspaceBindingInactiveByTarget(target, inactiveSinceMs = getNowMs()) {
2842
- if (!Number.isFinite(inactiveSinceMs) || inactiveSinceMs < 0) return false;
2843
- for (const [key, binding] of workspaceBindings) {
2844
- if (!targetMatches(binding.target, target) || binding.inactiveSinceMs !== undefined) continue;
2845
- workspaceBindings.set(key, { ...binding, inactiveSinceMs });
2846
- markDirty();
2847
- return true;
2848
- }
2849
- return false;
2850
- },
2884
+ markWorkspaceBindingInactiveByTarget,
2851
2885
  markWorkspaceBindingActiveByTarget(target) {
2852
2886
  if (hasWorkspaceRetirementConflict({ target })) return false;
2853
2887
  for (const [key, binding] of workspaceBindings) {
@@ -3274,7 +3308,10 @@ export function createTelegramTopicTargetStore(
3274
3308
  entry.target && targetMatches(entry.target, target),
3275
3309
  ) : undefined;
3276
3310
  const source = record ?? pending;
3277
- if (!source?.target) return false;
3311
+ // Confirmed absence ends this binding's active target lifetime, even after
3312
+ // an earlier stale observation already removed its live record.
3313
+ const inactive = syncStatus === "deleted" && markWorkspaceBindingInactiveByTarget(target);
3314
+ if (!source?.target) return inactive;
3278
3315
  syncObservations = syncObservations.filter((entry) => !targetMatches(entry.target, target));
3279
3316
  syncObservations.push({
3280
3317
  target: { ...source.target }, syncStatus, observedAtMs: getNowMs(),
@@ -4886,7 +4923,7 @@ export function createTelegramTopicTargetProvisioner(
4886
4923
  if (existing && isCurrentThreadRecord(existing)) {
4887
4924
  const slot = existing.slot ?? deps.store.allocateSlot(request.profileKey);
4888
4925
  if (!slot) {
4889
- throw new Error("Telegram Workspace slot reservation is unavailable.");
4926
+ throw new TelegramWorkspaceSlotUnavailableError();
4890
4927
  }
4891
4928
  const occupied = listOccupiedTelegramThreadIdentities({
4892
4929
  records: deps.store.list(),
@@ -5015,7 +5052,7 @@ export function createTelegramTopicTargetProvisioner(
5015
5052
  request.workspaceBindingKey,
5016
5053
  );
5017
5054
  if (!slot) {
5018
- throw new Error("Telegram Workspace slot reservation is unavailable.");
5055
+ throw new TelegramWorkspaceSlotUnavailableError();
5019
5056
  }
5020
5057
  const uniqueCandidate =
5021
5058
  candidateThreadName &&