@llblab/pi-telegram 0.52.1 → 0.53.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 (204) hide show
  1. package/AGENTS.md +10 -8
  2. package/BACKLOG.md +1 -16
  3. package/CHANGELOG.md +17 -0
  4. package/README.md +8 -10
  5. package/api/commands.ts +3 -0
  6. package/dist/api/commands.d.ts +1 -1
  7. package/dist/lib/activity-verbosity.d.ts +1 -5
  8. package/dist/lib/activity-verbosity.js +15 -42
  9. package/dist/lib/activity.d.ts +9 -0
  10. package/dist/lib/activity.js +39 -10
  11. package/dist/lib/agent-messages.js +7 -3
  12. package/dist/lib/bindings.d.ts +43 -5
  13. package/dist/lib/bindings.js +163 -20
  14. package/dist/lib/bus-api.d.ts +12 -0
  15. package/dist/lib/bus-api.js +182 -79
  16. package/dist/lib/bus-follower.d.ts +111 -31
  17. package/dist/lib/bus-follower.js +1324 -359
  18. package/dist/lib/bus-leader.d.ts +41 -91
  19. package/dist/lib/bus-leader.js +969 -637
  20. package/dist/lib/bus-transport.js +16 -9
  21. package/dist/lib/bus.d.ts +215 -18
  22. package/dist/lib/bus.js +1048 -327
  23. package/dist/lib/channel-posts.d.ts +5 -0
  24. package/dist/lib/channel-posts.js +434 -261
  25. package/dist/lib/command-templates.d.ts +26 -5
  26. package/dist/lib/command-templates.js +92 -176
  27. package/dist/lib/commands.d.ts +168 -82
  28. package/dist/lib/commands.js +1409 -291
  29. package/dist/lib/config.d.ts +1 -8
  30. package/dist/lib/config.js +34 -76
  31. package/dist/lib/delivery.d.ts +2 -2
  32. package/dist/lib/delivery.js +16 -16
  33. package/dist/lib/extension.js +640 -171
  34. package/dist/lib/generative-app-worker.mjs +19 -7
  35. package/dist/lib/generative-apps.d.ts +0 -5
  36. package/dist/lib/generative-apps.js +61 -49
  37. package/dist/lib/inbound.d.ts +5 -0
  38. package/dist/lib/inbound.js +35 -59
  39. package/dist/lib/journal.d.ts +33 -3
  40. package/dist/lib/journal.js +2063 -782
  41. package/dist/lib/lifecycle.d.ts +2 -6
  42. package/dist/lib/lifecycle.js +75 -58
  43. package/dist/lib/locks.d.ts +34 -3
  44. package/dist/lib/locks.js +318 -119
  45. package/dist/lib/logging.js +50 -14
  46. package/dist/lib/media.d.ts +22 -1
  47. package/dist/lib/media.js +46 -28
  48. package/dist/lib/menu-model.d.ts +19 -5
  49. package/dist/lib/menu-model.js +53 -11
  50. package/dist/lib/menu-queue.d.ts +5 -4
  51. package/dist/lib/menu-queue.js +45 -63
  52. package/dist/lib/menu-settings.d.ts +5 -4
  53. package/dist/lib/menu-settings.js +114 -78
  54. package/dist/lib/menu-status.d.ts +1 -1
  55. package/dist/lib/menu-status.js +8 -25
  56. package/dist/lib/menu-thinking.d.ts +6 -1
  57. package/dist/lib/menu-thinking.js +14 -21
  58. package/dist/lib/menu.d.ts +8 -61
  59. package/dist/lib/menu.js +92 -164
  60. package/dist/lib/model.d.ts +1 -1
  61. package/dist/lib/model.js +2 -16
  62. package/dist/lib/outbound-attachments.d.ts +6 -27
  63. package/dist/lib/outbound-attachments.js +45 -38
  64. package/dist/lib/outbound-buttons.js +22 -16
  65. package/dist/lib/outbound-markup.d.ts +0 -2
  66. package/dist/lib/outbound-markup.js +51 -64
  67. package/dist/lib/outbound-voice.js +6 -3
  68. package/dist/lib/outbound.d.ts +6 -6
  69. package/dist/lib/outbound.js +61 -51
  70. package/dist/lib/ownership.js +6 -2
  71. package/dist/lib/paths.d.ts +5 -0
  72. package/dist/lib/paths.js +37 -21
  73. package/dist/lib/pi.d.ts +2 -0
  74. package/dist/lib/pi.js +8 -1
  75. package/dist/lib/polling.d.ts +23 -37
  76. package/dist/lib/polling.js +71 -52
  77. package/dist/lib/preview.js +39 -19
  78. package/dist/lib/process-identity.d.ts +24 -0
  79. package/dist/lib/process-identity.js +133 -0
  80. package/dist/lib/prompts.js +2 -7
  81. package/dist/lib/queue.d.ts +14 -16
  82. package/dist/lib/queue.js +249 -126
  83. package/dist/lib/recovery.d.ts +2 -83
  84. package/dist/lib/recovery.js +20 -274
  85. package/dist/lib/replies.d.ts +16 -24
  86. package/dist/lib/replies.js +99 -116
  87. package/dist/lib/routing.d.ts +120 -7
  88. package/dist/lib/routing.js +4667 -1152
  89. package/dist/lib/runtime.d.ts +1 -11
  90. package/dist/lib/sections.d.ts +4 -29
  91. package/dist/lib/sections.js +7 -20
  92. package/dist/lib/skills.js +4 -3
  93. package/dist/lib/status.js +86 -57
  94. package/dist/lib/sync.d.ts +6 -3
  95. package/dist/lib/sync.js +112 -45
  96. package/dist/lib/target.d.ts +4 -0
  97. package/dist/lib/target.js +18 -0
  98. package/dist/lib/telegram-api.d.ts +18 -13
  99. package/dist/lib/telegram-api.js +165 -53
  100. package/dist/lib/text-groups.js +4 -39
  101. package/dist/lib/thread-cleanup-manager.d.ts +6 -27
  102. package/dist/lib/thread-cleanup-manager.js +369 -186
  103. package/dist/lib/thread-display.d.ts +1 -2
  104. package/dist/lib/thread-display.js +58 -38
  105. package/dist/lib/thread-naming.d.ts +28 -8
  106. package/dist/lib/thread-naming.js +175 -66
  107. package/dist/lib/thread-reconciler.d.ts +42 -16
  108. package/dist/lib/thread-reconciler.js +90 -50
  109. package/dist/lib/threads.d.ts +82 -20
  110. package/dist/lib/threads.js +2247 -855
  111. package/dist/lib/time-injection.js +1 -2
  112. package/dist/lib/turns.js +1 -19
  113. package/dist/lib/updates.d.ts +169 -80
  114. package/dist/lib/updates.js +2418 -641
  115. package/dist/lib/voice.d.ts +7 -22
  116. package/dist/lib/voice.js +7 -22
  117. package/dist/lib/wire.js +1 -1
  118. package/dist/lib/workspace-admission.d.ts +3 -1
  119. package/dist/lib/workspace-admission.js +425 -211
  120. package/dist/lib/workspace-identity.js +8 -13
  121. package/dist/lib/workspace-retirement.d.ts +1 -1
  122. package/dist/lib/workspace-retirement.js +328 -140
  123. package/dist/lib/workspace-slots.js +8 -4
  124. package/dist/package.json +1 -1
  125. package/docs/architecture.md +36 -30
  126. package/docs/inbound.md +1 -1
  127. package/docs/multi-instance-bus.md +15 -3
  128. package/docs/outbound.md +1 -1
  129. package/docs/public-api.md +29 -1
  130. package/docs/ui-style.md +25 -8
  131. package/lib/activity-verbosity.ts +59 -82
  132. package/lib/activity.ts +146 -102
  133. package/lib/agent-messages.ts +12 -5
  134. package/lib/bindings.ts +389 -82
  135. package/lib/bus-api.ts +263 -102
  136. package/lib/bus-follower.ts +2440 -601
  137. package/lib/bus-leader.ts +2209 -1290
  138. package/lib/bus-transport.ts +46 -12
  139. package/lib/bus.ts +1673 -444
  140. package/lib/channel-posts.ts +848 -327
  141. package/lib/command-templates.ts +152 -265
  142. package/lib/commands.ts +2398 -555
  143. package/lib/config.ts +177 -170
  144. package/lib/delivery.ts +41 -29
  145. package/lib/extension.ts +1207 -433
  146. package/lib/generative-app-worker.mjs +19 -7
  147. package/lib/generative-apps.ts +324 -211
  148. package/lib/inbound.ts +95 -97
  149. package/lib/journal.ts +5311 -1763
  150. package/lib/keyboard.ts +1 -3
  151. package/lib/lifecycle.ts +128 -78
  152. package/lib/locks.ts +703 -212
  153. package/lib/logging.ts +147 -58
  154. package/lib/media.ts +82 -43
  155. package/lib/menu-model.ts +85 -18
  156. package/lib/menu-queue.ts +88 -129
  157. package/lib/menu-settings.ts +260 -155
  158. package/lib/menu-status.ts +20 -60
  159. package/lib/menu-thinking.ts +41 -56
  160. package/lib/menu.ts +112 -285
  161. package/lib/model.ts +8 -22
  162. package/lib/outbound-attachments.ts +156 -125
  163. package/lib/outbound-buttons.ts +76 -49
  164. package/lib/outbound-markup.ts +96 -94
  165. package/lib/outbound-voice.ts +33 -27
  166. package/lib/outbound.ts +125 -125
  167. package/lib/ownership.ts +19 -10
  168. package/lib/paths.ts +148 -49
  169. package/lib/pi.ts +35 -15
  170. package/lib/polling.ts +165 -149
  171. package/lib/preview.ts +93 -40
  172. package/lib/process-identity.ts +164 -0
  173. package/lib/prompts.ts +4 -11
  174. package/lib/queue.ts +511 -300
  175. package/lib/recovery.ts +50 -454
  176. package/lib/rendering.ts +1 -3
  177. package/lib/replies.ts +248 -209
  178. package/lib/routing.ts +8509 -2186
  179. package/lib/runtime.ts +1 -12
  180. package/lib/sections.ts +24 -89
  181. package/lib/setup.ts +1 -3
  182. package/lib/skills.ts +9 -4
  183. package/lib/status.ts +215 -110
  184. package/lib/sync.ts +488 -359
  185. package/lib/target.ts +22 -3
  186. package/lib/telegram-api.ts +322 -127
  187. package/lib/text-groups.ts +5 -39
  188. package/lib/thread-cleanup-manager.ts +833 -331
  189. package/lib/thread-display.ts +213 -97
  190. package/lib/thread-naming.ts +232 -87
  191. package/lib/thread-reconciler.ts +155 -72
  192. package/lib/threads.ts +5318 -1845
  193. package/lib/time-injection.ts +1 -4
  194. package/lib/turns.ts +15 -28
  195. package/lib/updates.ts +5184 -1612
  196. package/lib/voice.ts +7 -35
  197. package/lib/wire.ts +1 -1
  198. package/lib/workspace-admission.ts +996 -416
  199. package/lib/workspace-identity.ts +14 -17
  200. package/lib/workspace-retirement.ts +878 -376
  201. package/lib/workspace-slots.ts +22 -13
  202. package/package.json +1 -1
  203. package/scripts/check-downgrade.mjs +0 -472
  204. package/scripts/measure-workspace.mjs +0 -108
package/lib/sync.ts CHANGED
@@ -4,19 +4,15 @@
4
4
  * Owns pure contracts for deciding when local Telegram mirror state should be refreshed without querying Telegram on every action
5
5
  */
6
6
 
7
- import { getTelegramApiErrorRequestTarget, isTelegramStaleTargetHttpError } from "./telegram-api.ts";
8
7
  import { getTelegramTargetKey, type TelegramTarget } from "./target.ts";
9
- import * as ThreadReconciler from "./thread-reconciler.ts";
10
- import { TelegramWorkspaceSlotUnavailableError } from "./workspace-slots.ts";
11
- import { normalizeTelegramWorkspacePath } from "./workspace-identity.ts";
12
8
  import {
13
- createTelegramWorkspaceAdmissionOperationId,
14
- runWithTelegramWorkspaceAdmissionsAsync,
15
- type TelegramWorkspaceAdmissionLedger,
16
- } from "./workspace-admission.ts";
9
+ getTelegramApiErrorRequestTarget,
10
+ isTelegramStaleTargetHttpError,
11
+ } from "./telegram-api.ts";
12
+ import * as ThreadReconciler from "./thread-reconciler.ts";
17
13
  import {
18
- createTelegramCleanupTargetProtection,
19
14
  commitTelegramWorkspaceProvisionBinding,
15
+ createTelegramCleanupTargetProtection,
20
16
  getTelegramTargetFromApiBody,
21
17
  getTelegramThreadOwnerKey,
22
18
  isSameTelegramProcessInstance,
@@ -27,6 +23,13 @@ import {
27
23
  type TelegramWorkspaceDisplayBinding,
28
24
  type TelegramWorkspaceThreadBinding,
29
25
  } from "./threads.ts";
26
+ import {
27
+ createTelegramWorkspaceAdmissionOperationId,
28
+ runWithTelegramWorkspaceAdmissionsAsync,
29
+ type TelegramWorkspaceAdmissionLedger,
30
+ } from "./workspace-admission.ts";
31
+ import { normalizeTelegramWorkspacePath } from "./workspace-identity.ts";
32
+ import { TelegramWorkspaceSlotUnavailableError } from "./workspace-slots.ts";
30
33
 
31
34
  export interface TelegramTopicLifecycleSyncUpdate<TMessage = unknown> {
32
35
  kind: "created" | "closed" | "reopened";
@@ -176,12 +179,15 @@ export interface TelegramManualThreadDisconnectDeps<TSyncState> {
176
179
  ) => void;
177
180
  runWorkspaceOperation: TelegramSyncWorkspaceOperationRunner;
178
181
  workspaceOperationKind?: string;
182
+ isDisconnectCurrent?: () => boolean;
179
183
  getNowMs?: () => number;
180
184
  }
181
185
 
182
- function markTelegramConfigSyncChange<
183
- TSyncState extends TelegramSyncState,
184
- >(state: TSyncState, action: string, options?: { nowMs?: number }): TSyncState {
186
+ function markTelegramConfigSyncChange<TSyncState extends TelegramSyncState>(
187
+ state: TSyncState,
188
+ action: string,
189
+ options?: { nowMs?: number },
190
+ ): TSyncState {
185
191
  const nowMs = options?.nowMs ?? Date.now();
186
192
  let nextState = markTelegramSyncSliceFresh(state, "pairing", {
187
193
  nowMs,
@@ -200,8 +206,10 @@ function markTelegramConfigSyncChange<
200
206
 
201
207
  export function createTelegramPreservedLeaderQuitHandler(deps: {
202
208
  instanceId: string;
203
- topicTargetStore: Pick<TelegramTopicTargetStore,
204
- "load" | "list" | "listPendingCleanups" | "detachTargetOwner">;
209
+ topicTargetStore: Pick<
210
+ TelegramTopicTargetStore,
211
+ "load" | "list" | "listPendingCleanups" | "detachTargetOwner"
212
+ >;
205
213
  getCurrentLeaderEpoch: () => number | string | undefined;
206
214
  getProfileName: () => string | undefined;
207
215
  isPollingSuspended: () => boolean;
@@ -212,29 +220,60 @@ export function createTelegramPreservedLeaderQuitHandler(deps: {
212
220
  const epoch = deps.getCurrentLeaderEpoch();
213
221
  const profile = deps.getProfileName();
214
222
  if (epoch === undefined || !isSessionCurrent()) return undefined;
215
- const isCurrent = () => isSessionCurrent() && deps.getCurrentLeaderEpoch() === epoch &&
216
- deps.getProfileName() === profile && deps.isPollingSuspended();
223
+ const isCurrent = () =>
224
+ isSessionCurrent() &&
225
+ deps.getCurrentLeaderEpoch() === epoch &&
226
+ deps.getProfileName() === profile &&
227
+ deps.isPollingSuspended();
217
228
  return async () => {
218
- if (!isCurrent() || await deps.resolveAutomaticThreadCleanupEnabled() !== false) return;
229
+ if (
230
+ !isCurrent() ||
231
+ (await deps.resolveAutomaticThreadCleanupEnabled()) !== false
232
+ )
233
+ return;
219
234
  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
- });
235
+ await deps.runWorkspaceOperation(
236
+ {
237
+ operationId: createTelegramWorkspaceAdmissionOperationId(),
238
+ operationKind: "workspace.preserve-leader-quit",
239
+ scopes: [{ kind: "profile" }],
240
+ },
241
+ async () => {
242
+ if (!isCurrent()) return;
243
+ await deps.topicTargetStore.load();
244
+ if (!isCurrent()) return;
245
+ const records = deps.topicTargetStore
246
+ .list()
247
+ .filter(
248
+ (record) =>
249
+ record.instanceId === deps.instanceId &&
250
+ (record.status === "active" || record.status === "starting"),
251
+ );
252
+ if (records.length !== 1) return;
253
+ const record = records[0]!;
254
+ if (
255
+ deps.topicTargetStore
256
+ .listPendingCleanups()
257
+ .some(
258
+ (intent) =>
259
+ intent.target.chatId === record.target.chatId &&
260
+ intent.target.threadId === record.target.threadId,
261
+ )
262
+ )
263
+ return;
264
+ if (
265
+ !(await deps.topicTargetStore.detachTargetOwner(
266
+ record,
267
+ isCurrent,
268
+ )) &&
269
+ isCurrent()
270
+ ) {
271
+ throw new Error(
272
+ "Telegram preserved leader detachment was not committed.",
273
+ );
274
+ }
275
+ },
276
+ );
238
277
  };
239
278
  };
240
279
  }
@@ -270,27 +309,71 @@ export function createTelegramThreadDisconnectAssembly<
270
309
  >(
271
310
  deps: Omit<TelegramManualThreadDisconnectDeps<TSyncState>, "stopPolling"> & {
272
311
  stopPolling: () => Promise<string>;
312
+ captureStopPolling?: () => {
313
+ isCurrent: () => boolean;
314
+ stop: () => Promise<string>;
315
+ };
273
316
  suspendPolling: () => Promise<void>;
274
317
  },
275
318
  ): TelegramThreadDisconnectAssembly {
276
319
  return {
277
- disconnect: createTelegramManualThreadDisconnectHandler({
320
+ async disconnect() {
321
+ const captured = deps.captureStopPolling?.();
322
+ const stop = captured?.stop ?? deps.stopPolling;
323
+ let stopAttempted = false;
324
+ const disconnect = createTelegramManualThreadDisconnectHandler({
325
+ ...deps,
326
+ workspaceOperationKind: "workspace.disconnect-thread",
327
+ isDisconnectCurrent: captured?.isCurrent ?? deps.isDisconnectCurrent,
328
+ stopPolling() {
329
+ stopAttempted = true;
330
+ return stop();
331
+ },
332
+ });
333
+ try {
334
+ return await disconnect();
335
+ } catch (error) {
336
+ // Never retry a stop whose teardown/release outcome may already have committed.
337
+ if (stopAttempted) {
338
+ deps.recordRuntimeEvent("connection", error, {
339
+ phase: "disconnect-stop",
340
+ });
341
+ throw error;
342
+ }
343
+ deps.recordRuntimeEvent("connection", error, {
344
+ phase: "disconnect-cleanup",
345
+ });
346
+ let stopped: string;
347
+ try {
348
+ stopped = await stop();
349
+ } catch (stopError) {
350
+ deps.recordRuntimeEvent("connection", stopError, {
351
+ phase: "disconnect-stop",
352
+ });
353
+ throw stopError;
354
+ }
355
+ const separator = /[.!?]$/u.test(stopped) ? " " : ". ";
356
+ return `${stopped}${separator}Thread cleanup was not confirmed. Check /telegram-status --debug.`;
357
+ }
358
+ },
359
+ cleanupForSessionRestart: createTelegramSessionRestartThreadCleanupHandler({
278
360
  ...deps,
279
- workspaceOperationKind: "workspace.disconnect-thread",
280
- stopPolling: deps.stopPolling,
361
+ suspendPolling: deps.suspendPolling,
281
362
  }),
282
- cleanupForSessionRestart:
283
- createTelegramSessionRestartThreadCleanupHandler({
284
- ...deps,
285
- suspendPolling: deps.suspendPolling,
286
- }),
287
363
  };
288
364
  }
289
365
 
290
366
  export function createTelegramManualThreadDisconnectHandler<
291
367
  TSyncState extends TelegramSyncState,
292
368
  >(deps: TelegramManualThreadDisconnectDeps<TSyncState>): () => Promise<string> {
369
+ const assertCurrent = () => {
370
+ if (deps.isDisconnectCurrent?.() === false)
371
+ throw new Error(
372
+ "Telegram disconnect was superseded by a new connection.",
373
+ );
374
+ };
293
375
  const operation = async (): Promise<string> => {
376
+ assertCurrent();
294
377
  const currentRecord = deps.getCurrentThreadRecord();
295
378
  let cleanupPending = false;
296
379
  if (currentRecord?.target.threadId) {
@@ -302,6 +385,7 @@ export function createTelegramManualThreadDisconnectHandler<
302
385
  if (isManualFollower && !ownsLeader) {
303
386
  if (deps.disconnectFollowerThread) {
304
387
  const disconnected = await deps.disconnectFollowerThread();
388
+ assertCurrent();
305
389
  if (!disconnected) {
306
390
  throw new Error(
307
391
  "Telegram follower thread deletion requires a live leader registration.",
@@ -324,11 +408,21 @@ export function createTelegramManualThreadDisconnectHandler<
324
408
  target,
325
409
  requestedAtMs: (deps.getNowMs ?? Date.now)(),
326
410
  };
327
- const departingRecord = deps.topicTargetStore.list().find((record) => record.instanceId === currentRecord.instanceId &&
328
- record.target.chatId === target.chatId && record.target.threadId === target.threadId);
329
- const isCleanupTargetProtected = createTelegramCleanupTargetProtection(deps.topicTargetStore, departingRecord);
411
+ const departingRecord = deps.topicTargetStore
412
+ .list()
413
+ .find(
414
+ (record) =>
415
+ record.instanceId === currentRecord.instanceId &&
416
+ record.target.chatId === target.chatId &&
417
+ record.target.threadId === target.threadId,
418
+ );
419
+ const isCleanupTargetProtected = createTelegramCleanupTargetProtection(
420
+ deps.topicTargetStore,
421
+ departingRecord,
422
+ );
330
423
  deps.topicTargetStore.upsertPendingCleanup(intent);
331
424
  await deps.topicTargetStore.persist();
425
+ assertCurrent();
332
426
  const cleanupPlan = ThreadReconciler.planThreadReconciliation({
333
427
  nowMs: (deps.getNowMs ?? Date.now)(),
334
428
  currentLeaderEpoch: leaderEpoch,
@@ -340,9 +434,11 @@ export function createTelegramManualThreadDisconnectHandler<
340
434
  {
341
435
  isCleanupTargetProtected,
342
436
  callApi(method, body) {
437
+ assertCurrent();
343
438
  return deps.callApi(method, body);
344
439
  },
345
440
  markStaleByTarget(targetToMark, syncStatus, lastSyncError) {
441
+ assertCurrent();
346
442
  return deps.topicTargetStore.markStaleByTarget(
347
443
  targetToMark,
348
444
  syncStatus,
@@ -350,16 +446,23 @@ export function createTelegramManualThreadDisconnectHandler<
350
446
  );
351
447
  },
352
448
  removeCleanupIntentById(id) {
449
+ assertCurrent();
353
450
  return deps.topicTargetStore.removePendingCleanup(id);
354
451
  },
355
452
  persist() {
453
+ assertCurrent();
356
454
  return deps.topicTargetStore.persist();
357
455
  },
358
456
  getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
359
457
  recordRuntimeEvent: deps.recordRuntimeEvent,
360
458
  },
361
459
  );
362
- if (cleanupPlan.actions.some((action) => isCleanupTargetProtected(action.target, action))) {
460
+ assertCurrent();
461
+ if (
462
+ cleanupPlan.actions.some((action) =>
463
+ isCleanupTargetProtected(action.target, action),
464
+ )
465
+ ) {
363
466
  return "Thread disconnect superseded by a new binding.";
364
467
  }
365
468
  cleanupPending = Boolean(cleanup.incompleteActions?.length);
@@ -378,19 +481,22 @@ export function createTelegramManualThreadDisconnectHandler<
378
481
  }) as TSyncState,
379
482
  );
380
483
  }
484
+ assertCurrent();
381
485
  const stopped = await deps.stopPolling();
382
486
  return cleanupPending
383
487
  ? `${stopped} Telegram thread cleanup remains pending for the next leader.`
384
488
  : stopped;
385
489
  };
386
- return () => deps.runWorkspaceOperation(
387
- {
388
- operationId: createTelegramWorkspaceAdmissionOperationId(),
389
- operationKind: deps.workspaceOperationKind ?? "workspace.disconnect-thread",
390
- scopes: [{ kind: "profile" }],
391
- },
392
- operation,
393
- );
490
+ return () =>
491
+ deps.runWorkspaceOperation(
492
+ {
493
+ operationId: createTelegramWorkspaceAdmissionOperationId(),
494
+ operationKind:
495
+ deps.workspaceOperationKind ?? "workspace.disconnect-thread",
496
+ scopes: [{ kind: "profile" }],
497
+ },
498
+ operation,
499
+ );
394
500
  }
395
501
 
396
502
  export function createTelegramLeaderHealthRuntime<
@@ -497,7 +603,8 @@ export interface TelegramStaleTopicApiErrorRecoveryDeps<TSyncState> {
497
603
  topicTargetStore: Pick<
498
604
  TelegramTopicTargetStore,
499
605
  "load" | "markStaleByTarget" | "persist"
500
- > & Partial<Pick<TelegramTopicTargetStore, "invalidateTarget">>;
606
+ > &
607
+ Partial<Pick<TelegramTopicTargetStore, "invalidateTarget">>;
501
608
  getSyncState: () => TSyncState;
502
609
  setSyncState: (state: TSyncState) => void;
503
610
  recordEvent: (
@@ -508,16 +615,23 @@ export interface TelegramStaleTopicApiErrorRecoveryDeps<TSyncState> {
508
615
  getNowMs?: () => number;
509
616
  isCurrent?: () => boolean;
510
617
  isAuthorityCurrent?: () => boolean;
511
- getWorkspaceAdmission?: () => Pick<
512
- TelegramWorkspaceAdmissionLedger,
513
- "acquireAdmission" | "releaseAdmission"
514
- > | undefined;
618
+ getWorkspaceAdmission?: () =>
619
+ | Pick<
620
+ TelegramWorkspaceAdmissionLedger,
621
+ "acquireAdmission" | "releaseAdmission"
622
+ >
623
+ | undefined;
515
624
  }
516
625
 
517
- export function captureTelegramStaleTargetRequestRecovery<TSyncState extends TelegramSyncState>(
626
+ export function captureTelegramStaleTargetRequestRecovery<
627
+ TSyncState extends TelegramSyncState,
628
+ >(
518
629
  body: Record<string, unknown>,
519
630
  deps: TelegramStaleTopicApiErrorRecoveryDeps<TSyncState> & {
520
- topicTargetStore: Pick<TelegramTopicTargetStore, "load" | "list" | "markStaleByTarget" | "persist" | "invalidateTarget">;
631
+ topicTargetStore: Pick<
632
+ TelegramTopicTargetStore,
633
+ "load" | "list" | "markStaleByTarget" | "persist" | "invalidateTarget"
634
+ >;
521
635
  getCurrentLeaderEpoch: () => number | string | undefined;
522
636
  getSessionGeneration: () => number;
523
637
  getProfileName: () => string | undefined;
@@ -528,25 +642,44 @@ export function captureTelegramStaleTargetRequestRecovery<TSyncState extends Tel
528
642
  const epoch = deps.getCurrentLeaderEpoch();
529
643
  if (!target || epoch === undefined) return undefined;
530
644
  const key = getTelegramTargetKey(target);
531
- const record = deps.topicTargetStore.list().find((candidate) => getTelegramTargetKey(candidate.target) === key);
645
+ const record = deps.topicTargetStore
646
+ .list()
647
+ .find((candidate) => getTelegramTargetKey(candidate.target) === key);
532
648
  if (!record) return undefined;
533
649
  const generation = deps.getSessionGeneration();
534
650
  const profile = deps.getProfileName();
535
- const isAuthorityCurrent = (): boolean => deps.getCurrentLeaderEpoch() === epoch &&
536
- deps.getSessionGeneration() === generation && deps.getProfileName() === profile;
651
+ const isAuthorityCurrent = (): boolean =>
652
+ deps.getCurrentLeaderEpoch() === epoch &&
653
+ deps.getSessionGeneration() === generation &&
654
+ deps.getProfileName() === profile;
537
655
  const isCurrent = (): boolean => {
538
- const current = deps.topicTargetStore.list().find((candidate) => getTelegramTargetKey(candidate.target) === key);
539
- return isAuthorityCurrent() &&
540
- current?.instanceId === record.instanceId && current?.profileKey === record.profileKey &&
541
- current?.updatedAtMs === record.updatedAtMs && current?.createdAtMs === record.createdAtMs;
656
+ const current = deps.topicTargetStore
657
+ .list()
658
+ .find((candidate) => getTelegramTargetKey(candidate.target) === key);
659
+ return (
660
+ isAuthorityCurrent() &&
661
+ current?.instanceId === record.instanceId &&
662
+ current?.profileKey === record.profileKey &&
663
+ current?.updatedAtMs === record.updatedAtMs &&
664
+ current?.createdAtMs === record.createdAtMs
665
+ );
542
666
  };
543
667
  return async (error) => {
544
668
  const requestTarget = getTelegramApiErrorRequestTarget(error);
545
- if (!isTelegramStaleTargetHttpError(error) || !requestTarget ||
546
- getTelegramTargetKey(requestTarget) !== key || !isCurrent()) return;
547
- if (await recoverStaleTelegramTopicApiError(
548
- { chat_id: target.chatId, message_thread_id: target.threadId }, error, { ...deps, isCurrent, isAuthorityCurrent },
549
- )) {
669
+ if (
670
+ !isTelegramStaleTargetHttpError(error) ||
671
+ !requestTarget ||
672
+ getTelegramTargetKey(requestTarget) !== key ||
673
+ !isCurrent()
674
+ )
675
+ return;
676
+ if (
677
+ await recoverStaleTelegramTopicApiError(
678
+ { chat_id: target.chatId, message_thread_id: target.threadId },
679
+ error,
680
+ { ...deps, isCurrent, isAuthorityCurrent },
681
+ )
682
+ ) {
550
683
  deps.onRecovered();
551
684
  }
552
685
  };
@@ -589,17 +722,19 @@ export async function recoverStaleTelegramTopicApiError<
589
722
  !target ||
590
723
  !isTelegramTopicTargetStaleError(error) ||
591
724
  deps.isCurrent?.() === false
592
- ) return false;
725
+ )
726
+ return false;
593
727
  const recover = async (): Promise<boolean> => {
594
728
  if (deps.isCurrent) {
595
729
  if (
596
730
  !deps.topicTargetStore.invalidateTarget ||
597
- !await deps.topicTargetStore.invalidateTarget(
731
+ !(await deps.topicTargetStore.invalidateTarget(
598
732
  target,
599
733
  deps.isCurrent,
600
734
  String(error),
601
- )
602
- ) return false;
735
+ ))
736
+ )
737
+ return false;
603
738
  if (deps.isAuthorityCurrent?.() === false) return false;
604
739
  } else {
605
740
  await deps.topicTargetStore.load();
@@ -609,7 +744,8 @@ export async function recoverStaleTelegramTopicApiError<
609
744
  "deleted",
610
745
  String(error),
611
746
  )
612
- ) return false;
747
+ )
748
+ return false;
613
749
  await deps.topicTargetStore.persist();
614
750
  }
615
751
  const nowMs = (deps.getNowMs ?? Date.now)();
@@ -698,7 +834,12 @@ export async function ensureTelegramLeaderThreadBinding(
698
834
  normalizedLeaderCwd,
699
835
  deps.instanceId,
700
836
  legacyLeaderRecord?.instanceId,
701
- { sessionId: deps.sessionId, onCapacityUnavailable() { capacityUnavailable = true; } },
837
+ {
838
+ sessionId: deps.sessionId,
839
+ onCapacityUnavailable() {
840
+ capacityUnavailable = true;
841
+ },
842
+ },
702
843
  )
703
844
  : undefined;
704
845
  if (deps.cwd && !workspaceIdentity) {
@@ -728,270 +869,290 @@ export async function ensureTelegramLeaderThreadBinding(
728
869
  assertLeaderEpoch("before-workspace-persist");
729
870
  await deps.topicTargetStore.persist();
730
871
  assertLeaderEpoch("after-workspace-persist");
731
- const { target: _target, slot: _slot, threadName: _threadName, displayTitle: _displayTitle, ...rest } = result;
872
+ const {
873
+ target: _target,
874
+ slot: _slot,
875
+ threadName: _threadName,
876
+ displayTitle: _displayTitle,
877
+ ...rest
878
+ } = result;
732
879
  return {
733
880
  ...rest,
734
881
  target: { ...committed.target },
735
882
  slot: committed.slot ?? result.slot,
736
883
  ...(committed.threadName ? { threadName: committed.threadName } : {}),
737
- ...(committed.displayTitle ? { displayTitle: committed.displayTitle } : {}),
884
+ ...(committed.displayTitle
885
+ ? { displayTitle: committed.displayTitle }
886
+ : {}),
738
887
  };
739
888
  };
740
889
  try {
741
- const unavailableTargetKeys = new Set([
742
- ...deps.topicTargetStore
743
- .listSyncObservations()
744
- .filter((observation) => observation.syncStatus === "deleted")
745
- .map((observation) => getTelegramTargetKey(observation.target)),
746
- ...deps.topicTargetStore
747
- .listPendingCleanups()
748
- .map((intent) => getTelegramTargetKey(intent.target)),
749
- ]);
750
- let invalidatedUnavailableTarget = false;
751
- for (const record of deps.topicTargetStore.list()) {
890
+ const unavailableTargetKeys = new Set([
891
+ ...deps.topicTargetStore
892
+ .listSyncObservations()
893
+ .filter((observation) => observation.syncStatus === "deleted")
894
+ .map((observation) => getTelegramTargetKey(observation.target)),
895
+ ...deps.topicTargetStore
896
+ .listPendingCleanups()
897
+ .map((intent) => getTelegramTargetKey(intent.target)),
898
+ ]);
899
+ let invalidatedUnavailableTarget = false;
900
+ for (const record of deps.topicTargetStore.list()) {
901
+ if (
902
+ record.instanceId !== deps.instanceId ||
903
+ !unavailableTargetKeys.has(getTelegramTargetKey(record.target))
904
+ ) {
905
+ continue;
906
+ }
907
+ invalidatedUnavailableTarget =
908
+ deps.topicTargetStore.markStaleByTarget(record.target) ||
909
+ invalidatedUnavailableTarget;
910
+ }
911
+ if (invalidatedUnavailableTarget) {
912
+ assertLeaderEpoch("before-unavailable-persist");
913
+ await deps.topicTargetStore.persist();
914
+ assertLeaderEpoch("after-unavailable-persist");
915
+ }
916
+ const persistedWorkspaceBinding = workspaceIdentity
917
+ ? deps.topicTargetStore.getWorkspaceBinding(
918
+ workspaceIdentity.cwd,
919
+ workspaceIdentity.instanceSlot,
920
+ workspaceIdentity.sessionId,
921
+ )
922
+ : undefined;
923
+ const legacyWorkspaceBinding =
924
+ workspaceIdentity?.instanceSlot === "a" &&
925
+ deps.sessionId === undefined &&
926
+ !persistedWorkspaceBinding &&
927
+ typeof legacyLeaderRecord?.target.threadId === "number"
928
+ ? {
929
+ ...workspaceIdentity,
930
+ target: { ...legacyLeaderRecord.target },
931
+ ...(legacyLeaderRecord.threadName
932
+ ? { threadName: legacyLeaderRecord.threadName }
933
+ : {}),
934
+ ...(legacyLeaderRecord.slot
935
+ ? { slot: legacyLeaderRecord.slot }
936
+ : {}),
937
+ updatedAtMs: legacyLeaderRecord.updatedAtMs,
938
+ }
939
+ : undefined;
940
+ const recoverableWorkspaceBinding =
941
+ persistedWorkspaceBinding ?? legacyWorkspaceBinding;
942
+ const recoverableTargetKey = recoverableWorkspaceBinding
943
+ ? getTelegramTargetKey(recoverableWorkspaceBinding.target)
944
+ : undefined;
945
+ const recoverableRecord = recoverableWorkspaceBinding
946
+ ? deps.topicTargetStore
947
+ .list()
948
+ .find(
949
+ (record) =>
950
+ getTelegramTargetKey(record.target) === recoverableTargetKey,
951
+ )
952
+ : undefined;
953
+ const sameProcessWorkspaceBinding =
954
+ !!recoverableRecord &&
955
+ (recoverableRecord.instanceId === deps.instanceId ||
956
+ isSameTelegramProcessInstance(
957
+ recoverableRecord.instanceId,
958
+ deps.instanceId,
959
+ ));
752
960
  if (
753
- record.instanceId !== deps.instanceId ||
754
- !unavailableTargetKeys.has(getTelegramTargetKey(record.target))
961
+ recoverableWorkspaceBinding &&
962
+ !unavailableTargetKeys.has(recoverableTargetKey ?? "") &&
963
+ (!deps.forceFreshUnnamed || recoverableWorkspaceBinding.threadName) &&
964
+ (sameProcessWorkspaceBinding || deps.probeWorkspaceBinding)
755
965
  ) {
756
- continue;
757
- }
758
- invalidatedUnavailableTarget =
759
- deps.topicTargetStore.markStaleByTarget(record.target) ||
760
- invalidatedUnavailableTarget;
761
- }
762
- if (invalidatedUnavailableTarget) {
763
- assertLeaderEpoch("before-unavailable-persist");
764
- await deps.topicTargetStore.persist();
765
- assertLeaderEpoch("after-unavailable-persist");
766
- }
767
- const persistedWorkspaceBinding = workspaceIdentity
768
- ? deps.topicTargetStore.getWorkspaceBinding(
769
- workspaceIdentity.cwd,
770
- workspaceIdentity.instanceSlot,
771
- workspaceIdentity.sessionId,
772
- )
773
- : undefined;
774
- const legacyWorkspaceBinding =
775
- workspaceIdentity?.instanceSlot === "a" &&
776
- deps.sessionId === undefined &&
777
- !persistedWorkspaceBinding &&
778
- typeof legacyLeaderRecord?.target.threadId === "number"
779
- ? {
780
- ...workspaceIdentity,
781
- target: { ...legacyLeaderRecord.target },
782
- ...(legacyLeaderRecord.threadName
783
- ? { threadName: legacyLeaderRecord.threadName }
784
- : {}),
785
- ...(legacyLeaderRecord.slot ? { slot: legacyLeaderRecord.slot } : {}),
786
- updatedAtMs: legacyLeaderRecord.updatedAtMs,
966
+ let workspaceTargetVisible = sameProcessWorkspaceBinding;
967
+ if (!workspaceTargetVisible && deps.probeWorkspaceBinding) {
968
+ assertLeaderEpoch("before-workspace-probe");
969
+ try {
970
+ await deps.probeWorkspaceBinding(recoverableWorkspaceBinding);
971
+ assertLeaderEpoch("after-workspace-probe");
972
+ workspaceTargetVisible = true;
973
+ } catch (error) {
974
+ if (!isTelegramTopicTargetStaleError(error)) throw error;
975
+ deps.topicTargetStore.markStaleByTarget(
976
+ recoverableWorkspaceBinding.target,
977
+ "deleted",
978
+ error instanceof Error ? error.message : String(error),
979
+ );
980
+ await deps.topicTargetStore.persist();
981
+ deps.recordEvent("bus", error, {
982
+ phase: "leader-workspace-target-stale",
983
+ chatId: recoverableWorkspaceBinding.target.chatId,
984
+ threadId: recoverableWorkspaceBinding.target.threadId,
985
+ instanceId: deps.instanceId,
986
+ });
787
987
  }
788
- : undefined;
789
- const recoverableWorkspaceBinding =
790
- persistedWorkspaceBinding ?? legacyWorkspaceBinding;
791
- const recoverableTargetKey = recoverableWorkspaceBinding
792
- ? getTelegramTargetKey(recoverableWorkspaceBinding.target)
793
- : undefined;
794
- const recoverableRecord = recoverableWorkspaceBinding
795
- ? deps.topicTargetStore
796
- .list()
797
- .find(
798
- (record) =>
799
- getTelegramTargetKey(record.target) === recoverableTargetKey,
800
- )
801
- : undefined;
802
- const sameProcessWorkspaceBinding =
803
- !!recoverableRecord &&
804
- (recoverableRecord.instanceId === deps.instanceId ||
805
- isSameTelegramProcessInstance(
806
- recoverableRecord.instanceId,
807
- deps.instanceId,
808
- ));
809
- if (
810
- recoverableWorkspaceBinding &&
811
- !unavailableTargetKeys.has(recoverableTargetKey ?? "") &&
812
- (!deps.forceFreshUnnamed || recoverableWorkspaceBinding.threadName) &&
813
- (sameProcessWorkspaceBinding || deps.probeWorkspaceBinding)
814
- ) {
815
- let workspaceTargetVisible = sameProcessWorkspaceBinding;
816
- if (!workspaceTargetVisible && deps.probeWorkspaceBinding) {
817
- assertLeaderEpoch("before-workspace-probe");
818
- try {
819
- await deps.probeWorkspaceBinding(recoverableWorkspaceBinding);
820
- assertLeaderEpoch("after-workspace-probe");
821
- workspaceTargetVisible = true;
822
- } catch (error) {
823
- if (!isTelegramTopicTargetStaleError(error)) throw error;
824
- deps.topicTargetStore.markStaleByTarget(
825
- recoverableWorkspaceBinding.target,
826
- "deleted",
827
- error instanceof Error ? error.message : String(error),
828
- );
829
- await deps.topicTargetStore.persist();
830
- deps.recordEvent("bus", error, {
831
- phase: "leader-workspace-target-stale",
832
- chatId: recoverableWorkspaceBinding.target.chatId,
833
- threadId: recoverableWorkspaceBinding.target.threadId,
988
+ }
989
+ if (workspaceTargetVisible) {
990
+ const nowMs = deps.getNowMs?.() ?? Date.now();
991
+ const slot = workspaceIdentity?.slot;
992
+ if (!slot) {
993
+ throw new Error("Telegram Thread slot authority is unavailable.");
994
+ }
995
+ const recovered = await commitWorkspaceBinding({
996
+ target: { ...recoverableWorkspaceBinding.target },
997
+ slot,
998
+ ...(recoverableWorkspaceBinding.threadName
999
+ ? { threadName: recoverableWorkspaceBinding.threadName }
1000
+ : {}),
1001
+ reused: true,
1002
+ });
1003
+ deps.topicTargetStore.upsert({
1004
+ profileKey: leaderProfileKey,
1005
+ owner: leaderOwner,
1006
+ target: { ...recovered.target },
1007
+ status: "active",
1008
+ createdAtMs: recoverableRecord?.createdAtMs ?? nowMs,
1009
+ updatedAtMs: nowMs,
1010
+ ...(recovered.threadName ? { threadName: recovered.threadName } : {}),
834
1011
  instanceId: deps.instanceId,
1012
+ slot: recovered.slot,
1013
+ syncStatus: "open",
1014
+ lastSyncObservedAtMs: nowMs,
1015
+ lastReconcileAction: "leader-workspace-binding-recovered",
835
1016
  });
1017
+ assertLeaderEpoch("before-recovered-record-persist");
1018
+ await deps.topicTargetStore.persist();
1019
+ assertLeaderEpoch("after-recovered-record-persist");
1020
+ deps.recordEvent(
1021
+ "telegram",
1022
+ "Leader thread preserved after Workspace recovery",
1023
+ {
1024
+ phase: "leader-thread-reused",
1025
+ instanceId: deps.instanceId,
1026
+ chatId: recovered.target.chatId,
1027
+ threadId: recovered.target.threadId,
1028
+ slot: recovered.slot,
1029
+ },
1030
+ );
1031
+ return recovered;
836
1032
  }
837
1033
  }
838
- if (workspaceTargetVisible) {
839
- const nowMs = deps.getNowMs?.() ?? Date.now();
840
- const slot = workspaceIdentity?.slot;
841
- if (!slot) {
842
- throw new Error("Telegram Thread slot authority is unavailable.");
843
- }
844
- const recovered = await commitWorkspaceBinding({
845
- target: { ...recoverableWorkspaceBinding.target },
846
- slot,
847
- ...(recoverableWorkspaceBinding.threadName
848
- ? { threadName: recoverableWorkspaceBinding.threadName }
849
- : {}),
850
- reused: true,
851
- });
852
- deps.topicTargetStore.upsert({
853
- profileKey: leaderProfileKey,
854
- owner: leaderOwner,
855
- target: { ...recovered.target },
856
- status: "active",
857
- createdAtMs: recoverableRecord?.createdAtMs ?? nowMs,
858
- updatedAtMs: nowMs,
859
- ...(recovered.threadName ? { threadName: recovered.threadName } : {}),
860
- instanceId: deps.instanceId,
861
- slot: recovered.slot,
862
- syncStatus: "open",
863
- lastSyncObservedAtMs: nowMs,
864
- lastReconcileAction: "leader-workspace-binding-recovered",
865
- });
866
- assertLeaderEpoch("before-recovered-record-persist");
867
- await deps.topicTargetStore.persist();
868
- assertLeaderEpoch("after-recovered-record-persist");
1034
+ const priorTargets = deps.topicTargetStore.list().filter((record) => {
1035
+ return (
1036
+ record.instanceId === deps.instanceId &&
1037
+ (record.status === "active" || record.status === "starting")
1038
+ );
1039
+ });
1040
+ // Legacy callers without session identity may reuse the active instance target.
1041
+ // Session-aware callers must resolve their exact Workspace binding instead.
1042
+ if (
1043
+ deps.sessionId === undefined &&
1044
+ !deps.forceFreshUnnamed &&
1045
+ priorTargets.length > 0
1046
+ ) {
1047
+ const record = priorTargets[0];
869
1048
  deps.recordEvent(
870
1049
  "telegram",
871
- "Leader thread preserved after Workspace recovery",
1050
+ "Leader thread preserved after session lifecycle change",
872
1051
  {
873
1052
  phase: "leader-thread-reused",
874
1053
  instanceId: deps.instanceId,
875
- chatId: recovered.target.chatId,
876
- threadId: recovered.target.threadId,
877
- slot: recovered.slot,
1054
+ chatId: record.target.chatId,
1055
+ threadId: record.target.threadId,
1056
+ slot: record.slot,
878
1057
  },
879
1058
  );
880
- return recovered;
881
- }
882
- }
883
- const priorTargets = deps.topicTargetStore.list().filter((record) => {
884
- return (
885
- record.instanceId === deps.instanceId &&
886
- (record.status === "active" || record.status === "starting")
887
- );
888
- });
889
- // Legacy callers without session identity may reuse the active instance target.
890
- // Session-aware callers must resolve their exact Workspace binding instead.
891
- if (deps.sessionId === undefined && !deps.forceFreshUnnamed && priorTargets.length > 0) {
892
- const record = priorTargets[0];
893
- deps.recordEvent(
894
- "telegram",
895
- "Leader thread preserved after session lifecycle change",
896
- {
897
- phase: "leader-thread-reused",
898
- instanceId: deps.instanceId,
899
- chatId: record.target.chatId,
900
- threadId: record.target.threadId,
901
- slot: record.slot,
902
- },
903
- );
904
- assertLeaderEpoch("before-reuse");
905
- if (!record.slot) {
906
- throw new Error("Telegram Thread slot authority is unavailable.");
907
- }
908
- return await commitWorkspaceBinding({
909
- target: record.target,
910
- slot: record.slot,
911
- ...(record.threadName ? { threadName: record.threadName } : {}),
912
- reused: true,
913
- });
914
- }
915
- let forcedUnnamedStale = false;
916
- if (deps.forceFreshUnnamed) {
917
- for (const record of priorTargets) {
918
- const isLeaderOwned =
919
- record.owner?.kind === "leader" ||
920
- (!record.owner &&
921
- (record.profileKey.startsWith("cwd:") ||
922
- record.profileKey.startsWith("leader:")));
923
- if (!isLeaderOwned) continue;
924
- if (record.threadName) continue;
925
- forcedUnnamedStale =
926
- deps.topicTargetStore.markStaleByTarget(record.target) ||
927
- forcedUnnamedStale;
928
- deps.recordEvent("telegram", "Unnamed leader thread binding refreshed", {
929
- phase: "leader-thread-force-fresh-unnamed",
930
- instanceId: deps.instanceId,
931
- chatId: record.target.chatId,
932
- threadId: record.target.threadId,
1059
+ assertLeaderEpoch("before-reuse");
1060
+ if (!record.slot) {
1061
+ throw new Error("Telegram Thread slot authority is unavailable.");
1062
+ }
1063
+ return await commitWorkspaceBinding({
1064
+ target: record.target,
933
1065
  slot: record.slot,
1066
+ ...(record.threadName ? { threadName: record.threadName } : {}),
1067
+ reused: true,
934
1068
  });
935
1069
  }
936
- if (forcedUnnamedStale) await deps.topicTargetStore.persist();
937
- }
938
- assertLeaderEpoch("before-provision");
939
- const ownTarget = await provisionOwnBusTopic({
940
- getAllowedUserId: deps.getAllowedUserId,
941
- instanceId: deps.instanceId,
942
- cwd: normalizedLeaderCwd,
943
- telegramProfile: deps.telegramProfile,
944
- getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
945
- getThreadReconciliationMachineState:
946
- deps.getThreadReconciliationMachineState,
947
- recordThreadReconciliationPlan: deps.recordThreadReconciliationPlan,
948
- store: deps.topicTargetStore,
949
- callApi: deps.callApi,
950
- getNowMs: deps.getNowMs,
951
- getRandom: deps.getRandom,
952
- requestedThreadName:
953
- recoverableWorkspaceBinding?.threadName ?? deps.requestedThreadName,
954
- workspaceBindingKey: workspaceIdentity?.bindingKey,
955
- preferredSlot: workspaceIdentity?.slot,
956
- resolveInitialWorkspaceDisplayTitle:
957
- deps.resolveInitialWorkspaceDisplayTitle,
958
- recordEvent: deps.recordEvent,
959
- });
960
- assertLeaderEpoch("after-provision");
961
- if (!ownTarget) return undefined;
962
- const replacementPlan = ThreadReconciler.planThreadReconciliation({
963
- nowMs: Date.now(),
964
- currentLeaderEpoch: deps.getCurrentLeaderEpoch?.(),
965
- previousState: deps.getThreadReconciliationMachineState?.(),
966
- records: priorTargets,
967
- pendingProvisions: deps.topicTargetStore.listPendingProvisions(),
968
- replacedBindings: [
969
- {
970
- instanceId: deps.instanceId,
971
- replacementTarget: ownTarget.target,
972
- },
973
- ],
974
- });
975
- deps.recordThreadReconciliationPlan?.(replacementPlan);
976
- await ThreadReconciler.applyThreadReconciliationPlan(replacementPlan, {
977
- isCleanupTargetProtected: createTelegramCleanupTargetProtection(deps.topicTargetStore),
978
- callApi: deps.callApi,
979
- markStaleByTarget: (target, syncStatus, lastSyncError) =>
980
- deps.topicTargetStore.markStaleByTarget(
981
- target,
982
- syncStatus,
983
- lastSyncError,
1070
+ let forcedUnnamedStale = false;
1071
+ if (deps.forceFreshUnnamed) {
1072
+ for (const record of priorTargets) {
1073
+ const isLeaderOwned =
1074
+ record.owner?.kind === "leader" ||
1075
+ (!record.owner &&
1076
+ (record.profileKey.startsWith("cwd:") ||
1077
+ record.profileKey.startsWith("leader:")));
1078
+ if (!isLeaderOwned) continue;
1079
+ if (record.threadName) continue;
1080
+ forcedUnnamedStale =
1081
+ deps.topicTargetStore.markStaleByTarget(record.target) ||
1082
+ forcedUnnamedStale;
1083
+ deps.recordEvent(
1084
+ "telegram",
1085
+ "Unnamed leader thread binding refreshed",
1086
+ {
1087
+ phase: "leader-thread-force-fresh-unnamed",
1088
+ instanceId: deps.instanceId,
1089
+ chatId: record.target.chatId,
1090
+ threadId: record.target.threadId,
1091
+ slot: record.slot,
1092
+ },
1093
+ );
1094
+ }
1095
+ if (forcedUnnamedStale) await deps.topicTargetStore.persist();
1096
+ }
1097
+ assertLeaderEpoch("before-provision");
1098
+ const ownTarget = await provisionOwnBusTopic({
1099
+ getAllowedUserId: deps.getAllowedUserId,
1100
+ instanceId: deps.instanceId,
1101
+ cwd: normalizedLeaderCwd,
1102
+ telegramProfile: deps.telegramProfile,
1103
+ getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
1104
+ getThreadReconciliationMachineState:
1105
+ deps.getThreadReconciliationMachineState,
1106
+ recordThreadReconciliationPlan: deps.recordThreadReconciliationPlan,
1107
+ store: deps.topicTargetStore,
1108
+ callApi: deps.callApi,
1109
+ getNowMs: deps.getNowMs,
1110
+ getRandom: deps.getRandom,
1111
+ requestedThreadName:
1112
+ recoverableWorkspaceBinding?.threadName ?? deps.requestedThreadName,
1113
+ workspaceBindingKey: workspaceIdentity?.bindingKey,
1114
+ preferredSlot: workspaceIdentity?.slot,
1115
+ resolveInitialWorkspaceDisplayTitle:
1116
+ deps.resolveInitialWorkspaceDisplayTitle,
1117
+ recordEvent: deps.recordEvent,
1118
+ });
1119
+ assertLeaderEpoch("after-provision");
1120
+ if (!ownTarget) return undefined;
1121
+ const replacementPlan = ThreadReconciler.planThreadReconciliation({
1122
+ nowMs: Date.now(),
1123
+ currentLeaderEpoch: deps.getCurrentLeaderEpoch?.(),
1124
+ previousState: deps.getThreadReconciliationMachineState?.(),
1125
+ records: priorTargets,
1126
+ pendingProvisions: deps.topicTargetStore.listPendingProvisions(),
1127
+ replacedBindings: [
1128
+ {
1129
+ instanceId: deps.instanceId,
1130
+ replacementTarget: ownTarget.target,
1131
+ },
1132
+ ],
1133
+ });
1134
+ deps.recordThreadReconciliationPlan?.(replacementPlan);
1135
+ await ThreadReconciler.applyThreadReconciliationPlan(replacementPlan, {
1136
+ isCleanupTargetProtected: createTelegramCleanupTargetProtection(
1137
+ deps.topicTargetStore,
984
1138
  ),
985
- persist: () => deps.topicTargetStore.persist(),
986
- removePendingProvisionById: (id) =>
987
- deps.topicTargetStore.removePendingProvision(id),
988
- getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
989
- recordRuntimeEvent: deps.recordEvent,
990
- });
991
- assertLeaderEpoch("before-final-persist");
992
- await deps.topicTargetStore.persist();
993
- assertLeaderEpoch("after-final-persist");
994
- return await commitWorkspaceBinding(ownTarget);
1139
+ callApi: deps.callApi,
1140
+ markStaleByTarget: (target, syncStatus, lastSyncError) =>
1141
+ deps.topicTargetStore.markStaleByTarget(
1142
+ target,
1143
+ syncStatus,
1144
+ lastSyncError,
1145
+ ),
1146
+ persist: () => deps.topicTargetStore.persist(),
1147
+ removePendingProvisionById: (id) =>
1148
+ deps.topicTargetStore.removePendingProvision(id),
1149
+ getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
1150
+ recordRuntimeEvent: deps.recordEvent,
1151
+ });
1152
+ assertLeaderEpoch("before-final-persist");
1153
+ await deps.topicTargetStore.persist();
1154
+ assertLeaderEpoch("after-final-persist");
1155
+ return await commitWorkspaceBinding(ownTarget);
995
1156
  } finally {
996
1157
  deps.topicTargetStore.releaseWorkspaceClaim(deps.instanceId);
997
1158
  }
@@ -1013,20 +1174,6 @@ export const TELEGRAM_SYNC_SLICES = [
1013
1174
 
1014
1175
  export type TelegramSyncSlice = (typeof TELEGRAM_SYNC_SLICES)[number];
1015
1176
 
1016
- export type TelegramSyncTrigger =
1017
- | "startup"
1018
- | "reload"
1019
- | "topic-lifecycle"
1020
- | "stale-api-error"
1021
- | "setup-change"
1022
- | "pairing-change"
1023
- | "follower-register"
1024
- | "follower-prune"
1025
- | "status-request"
1026
- | "leader-health-tick"
1027
- | "ordinary-message"
1028
- | "ordinary-send";
1029
-
1030
1177
  export interface TelegramSyncSliceState {
1031
1178
  status: "fresh" | "suspect" | "unknown";
1032
1179
  updatedAtMs?: number;
@@ -1102,25 +1249,6 @@ export function createTelegramProvisioningActivityRuntime(): TelegramProvisionin
1102
1249
  };
1103
1250
  }
1104
1251
 
1105
- const RECONCILE_TRIGGERS = new Set<TelegramSyncTrigger>([
1106
- "startup",
1107
- "reload",
1108
- "topic-lifecycle",
1109
- "stale-api-error",
1110
- "setup-change",
1111
- "pairing-change",
1112
- "follower-register",
1113
- "follower-prune",
1114
- "status-request",
1115
- "leader-health-tick",
1116
- ]);
1117
-
1118
- export function shouldReconcileTelegramSync(
1119
- trigger: TelegramSyncTrigger,
1120
- ): boolean {
1121
- return RECONCILE_TRIGGERS.has(trigger);
1122
- }
1123
-
1124
1252
  export function markTelegramSyncSliceSuspect(
1125
1253
  state: TelegramSyncState,
1126
1254
  slice: TelegramSyncSlice,
@@ -1189,14 +1317,15 @@ export function createTelegramObservedTopicLifecycleSyncHandler<
1189
1317
  }) as TSyncState,
1190
1318
  );
1191
1319
  };
1192
- return (lifecycle) => deps.runWorkspaceOperation(
1193
- {
1194
- operationId: createTelegramWorkspaceAdmissionOperationId(),
1195
- operationKind: "workspace.sync-topic-lifecycle",
1196
- scopes: [{ kind: "profile" }],
1197
- },
1198
- () => operation(lifecycle),
1199
- );
1320
+ return (lifecycle) =>
1321
+ deps.runWorkspaceOperation(
1322
+ {
1323
+ operationId: createTelegramWorkspaceAdmissionOperationId(),
1324
+ operationKind: "workspace.sync-topic-lifecycle",
1325
+ scopes: [{ kind: "profile" }],
1326
+ },
1327
+ () => operation(lifecycle),
1328
+ );
1200
1329
  }
1201
1330
 
1202
1331
  export function createTelegramTopicLifecycleSyncHandler<TMessage = unknown>(