@llblab/pi-telegram 0.52.2 → 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 +9 -7
  2. package/BACKLOG.md +1 -18
  3. package/CHANGELOG.md +11 -0
  4. package/README.md +7 -11
  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 +639 -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 +29 -3
  44. package/dist/lib/locks.js +289 -108
  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 +21 -37
  76. package/dist/lib/polling.js +59 -50
  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 +1 -3
  95. package/dist/lib/sync.js +67 -43
  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 +148 -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 +31 -29
  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 +1206 -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 +677 -203
  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 +144 -147
  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 +448 -361
  185. package/lib/target.ts +22 -3
  186. package/lib/telegram-api.ts +302 -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";
@@ -180,9 +183,11 @@ export interface TelegramManualThreadDisconnectDeps<TSyncState> {
180
183
  getNowMs?: () => number;
181
184
  }
182
185
 
183
- function markTelegramConfigSyncChange<
184
- TSyncState extends TelegramSyncState,
185
- >(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 {
186
191
  const nowMs = options?.nowMs ?? Date.now();
187
192
  let nextState = markTelegramSyncSliceFresh(state, "pairing", {
188
193
  nowMs,
@@ -201,8 +206,10 @@ function markTelegramConfigSyncChange<
201
206
 
202
207
  export function createTelegramPreservedLeaderQuitHandler(deps: {
203
208
  instanceId: string;
204
- topicTargetStore: Pick<TelegramTopicTargetStore,
205
- "load" | "list" | "listPendingCleanups" | "detachTargetOwner">;
209
+ topicTargetStore: Pick<
210
+ TelegramTopicTargetStore,
211
+ "load" | "list" | "listPendingCleanups" | "detachTargetOwner"
212
+ >;
206
213
  getCurrentLeaderEpoch: () => number | string | undefined;
207
214
  getProfileName: () => string | undefined;
208
215
  isPollingSuspended: () => boolean;
@@ -213,29 +220,60 @@ export function createTelegramPreservedLeaderQuitHandler(deps: {
213
220
  const epoch = deps.getCurrentLeaderEpoch();
214
221
  const profile = deps.getProfileName();
215
222
  if (epoch === undefined || !isSessionCurrent()) return undefined;
216
- const isCurrent = () => isSessionCurrent() && deps.getCurrentLeaderEpoch() === epoch &&
217
- deps.getProfileName() === profile && deps.isPollingSuspended();
223
+ const isCurrent = () =>
224
+ isSessionCurrent() &&
225
+ deps.getCurrentLeaderEpoch() === epoch &&
226
+ deps.getProfileName() === profile &&
227
+ deps.isPollingSuspended();
218
228
  return async () => {
219
- if (!isCurrent() || await deps.resolveAutomaticThreadCleanupEnabled() !== false) return;
229
+ if (
230
+ !isCurrent() ||
231
+ (await deps.resolveAutomaticThreadCleanupEnabled()) !== false
232
+ )
233
+ return;
220
234
  if (!isCurrent()) return;
221
- await deps.runWorkspaceOperation({
222
- operationId: createTelegramWorkspaceAdmissionOperationId(),
223
- operationKind: "workspace.preserve-leader-quit",
224
- scopes: [{ kind: "profile" }],
225
- }, async () => {
226
- if (!isCurrent()) return;
227
- await deps.topicTargetStore.load();
228
- if (!isCurrent()) return;
229
- const records = deps.topicTargetStore.list().filter((record) =>
230
- record.instanceId === deps.instanceId && (record.status === "active" || record.status === "starting"));
231
- if (records.length !== 1) return;
232
- const record = records[0]!;
233
- if (deps.topicTargetStore.listPendingCleanups().some((intent) =>
234
- intent.target.chatId === record.target.chatId && intent.target.threadId === record.target.threadId)) return;
235
- if (!await deps.topicTargetStore.detachTargetOwner(record, isCurrent) && isCurrent()) {
236
- throw new Error("Telegram preserved leader detachment was not committed.");
237
- }
238
- });
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
+ );
239
277
  };
240
278
  };
241
279
  }
@@ -271,7 +309,10 @@ export function createTelegramThreadDisconnectAssembly<
271
309
  >(
272
310
  deps: Omit<TelegramManualThreadDisconnectDeps<TSyncState>, "stopPolling"> & {
273
311
  stopPolling: () => Promise<string>;
274
- captureStopPolling?: () => { isCurrent: () => boolean; stop: () => Promise<string> };
312
+ captureStopPolling?: () => {
313
+ isCurrent: () => boolean;
314
+ stop: () => Promise<string>;
315
+ };
275
316
  suspendPolling: () => Promise<void>;
276
317
  },
277
318
  ): TelegramThreadDisconnectAssembly {
@@ -294,26 +335,31 @@ export function createTelegramThreadDisconnectAssembly<
294
335
  } catch (error) {
295
336
  // Never retry a stop whose teardown/release outcome may already have committed.
296
337
  if (stopAttempted) {
297
- deps.recordRuntimeEvent("connection", error, { phase: "disconnect-stop" });
338
+ deps.recordRuntimeEvent("connection", error, {
339
+ phase: "disconnect-stop",
340
+ });
298
341
  throw error;
299
342
  }
300
- deps.recordRuntimeEvent("connection", error, { phase: "disconnect-cleanup" });
343
+ deps.recordRuntimeEvent("connection", error, {
344
+ phase: "disconnect-cleanup",
345
+ });
301
346
  let stopped: string;
302
347
  try {
303
348
  stopped = await stop();
304
349
  } catch (stopError) {
305
- deps.recordRuntimeEvent("connection", stopError, { phase: "disconnect-stop" });
350
+ deps.recordRuntimeEvent("connection", stopError, {
351
+ phase: "disconnect-stop",
352
+ });
306
353
  throw stopError;
307
354
  }
308
355
  const separator = /[.!?]$/u.test(stopped) ? " " : ". ";
309
356
  return `${stopped}${separator}Thread cleanup was not confirmed. Check /telegram-status --debug.`;
310
357
  }
311
358
  },
312
- cleanupForSessionRestart:
313
- createTelegramSessionRestartThreadCleanupHandler({
314
- ...deps,
315
- suspendPolling: deps.suspendPolling,
316
- }),
359
+ cleanupForSessionRestart: createTelegramSessionRestartThreadCleanupHandler({
360
+ ...deps,
361
+ suspendPolling: deps.suspendPolling,
362
+ }),
317
363
  };
318
364
  }
319
365
 
@@ -321,7 +367,10 @@ export function createTelegramManualThreadDisconnectHandler<
321
367
  TSyncState extends TelegramSyncState,
322
368
  >(deps: TelegramManualThreadDisconnectDeps<TSyncState>): () => Promise<string> {
323
369
  const assertCurrent = () => {
324
- if (deps.isDisconnectCurrent?.() === false) throw new Error("Telegram disconnect was superseded by a new connection.");
370
+ if (deps.isDisconnectCurrent?.() === false)
371
+ throw new Error(
372
+ "Telegram disconnect was superseded by a new connection.",
373
+ );
325
374
  };
326
375
  const operation = async (): Promise<string> => {
327
376
  assertCurrent();
@@ -359,9 +408,18 @@ export function createTelegramManualThreadDisconnectHandler<
359
408
  target,
360
409
  requestedAtMs: (deps.getNowMs ?? Date.now)(),
361
410
  };
362
- const departingRecord = deps.topicTargetStore.list().find((record) => record.instanceId === currentRecord.instanceId &&
363
- record.target.chatId === target.chatId && record.target.threadId === target.threadId);
364
- 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
+ );
365
423
  deps.topicTargetStore.upsertPendingCleanup(intent);
366
424
  await deps.topicTargetStore.persist();
367
425
  assertCurrent();
@@ -400,7 +458,11 @@ export function createTelegramManualThreadDisconnectHandler<
400
458
  },
401
459
  );
402
460
  assertCurrent();
403
- if (cleanupPlan.actions.some((action) => isCleanupTargetProtected(action.target, action))) {
461
+ if (
462
+ cleanupPlan.actions.some((action) =>
463
+ isCleanupTargetProtected(action.target, action),
464
+ )
465
+ ) {
404
466
  return "Thread disconnect superseded by a new binding.";
405
467
  }
406
468
  cleanupPending = Boolean(cleanup.incompleteActions?.length);
@@ -425,14 +487,16 @@ export function createTelegramManualThreadDisconnectHandler<
425
487
  ? `${stopped} Telegram thread cleanup remains pending for the next leader.`
426
488
  : stopped;
427
489
  };
428
- return () => deps.runWorkspaceOperation(
429
- {
430
- operationId: createTelegramWorkspaceAdmissionOperationId(),
431
- operationKind: deps.workspaceOperationKind ?? "workspace.disconnect-thread",
432
- scopes: [{ kind: "profile" }],
433
- },
434
- operation,
435
- );
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
+ );
436
500
  }
437
501
 
438
502
  export function createTelegramLeaderHealthRuntime<
@@ -539,7 +603,8 @@ export interface TelegramStaleTopicApiErrorRecoveryDeps<TSyncState> {
539
603
  topicTargetStore: Pick<
540
604
  TelegramTopicTargetStore,
541
605
  "load" | "markStaleByTarget" | "persist"
542
- > & Partial<Pick<TelegramTopicTargetStore, "invalidateTarget">>;
606
+ > &
607
+ Partial<Pick<TelegramTopicTargetStore, "invalidateTarget">>;
543
608
  getSyncState: () => TSyncState;
544
609
  setSyncState: (state: TSyncState) => void;
545
610
  recordEvent: (
@@ -550,16 +615,23 @@ export interface TelegramStaleTopicApiErrorRecoveryDeps<TSyncState> {
550
615
  getNowMs?: () => number;
551
616
  isCurrent?: () => boolean;
552
617
  isAuthorityCurrent?: () => boolean;
553
- getWorkspaceAdmission?: () => Pick<
554
- TelegramWorkspaceAdmissionLedger,
555
- "acquireAdmission" | "releaseAdmission"
556
- > | undefined;
618
+ getWorkspaceAdmission?: () =>
619
+ | Pick<
620
+ TelegramWorkspaceAdmissionLedger,
621
+ "acquireAdmission" | "releaseAdmission"
622
+ >
623
+ | undefined;
557
624
  }
558
625
 
559
- export function captureTelegramStaleTargetRequestRecovery<TSyncState extends TelegramSyncState>(
626
+ export function captureTelegramStaleTargetRequestRecovery<
627
+ TSyncState extends TelegramSyncState,
628
+ >(
560
629
  body: Record<string, unknown>,
561
630
  deps: TelegramStaleTopicApiErrorRecoveryDeps<TSyncState> & {
562
- topicTargetStore: Pick<TelegramTopicTargetStore, "load" | "list" | "markStaleByTarget" | "persist" | "invalidateTarget">;
631
+ topicTargetStore: Pick<
632
+ TelegramTopicTargetStore,
633
+ "load" | "list" | "markStaleByTarget" | "persist" | "invalidateTarget"
634
+ >;
563
635
  getCurrentLeaderEpoch: () => number | string | undefined;
564
636
  getSessionGeneration: () => number;
565
637
  getProfileName: () => string | undefined;
@@ -570,25 +642,44 @@ export function captureTelegramStaleTargetRequestRecovery<TSyncState extends Tel
570
642
  const epoch = deps.getCurrentLeaderEpoch();
571
643
  if (!target || epoch === undefined) return undefined;
572
644
  const key = getTelegramTargetKey(target);
573
- 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);
574
648
  if (!record) return undefined;
575
649
  const generation = deps.getSessionGeneration();
576
650
  const profile = deps.getProfileName();
577
- const isAuthorityCurrent = (): boolean => deps.getCurrentLeaderEpoch() === epoch &&
578
- deps.getSessionGeneration() === generation && deps.getProfileName() === profile;
651
+ const isAuthorityCurrent = (): boolean =>
652
+ deps.getCurrentLeaderEpoch() === epoch &&
653
+ deps.getSessionGeneration() === generation &&
654
+ deps.getProfileName() === profile;
579
655
  const isCurrent = (): boolean => {
580
- const current = deps.topicTargetStore.list().find((candidate) => getTelegramTargetKey(candidate.target) === key);
581
- return isAuthorityCurrent() &&
582
- current?.instanceId === record.instanceId && current?.profileKey === record.profileKey &&
583
- 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
+ );
584
666
  };
585
667
  return async (error) => {
586
668
  const requestTarget = getTelegramApiErrorRequestTarget(error);
587
- if (!isTelegramStaleTargetHttpError(error) || !requestTarget ||
588
- getTelegramTargetKey(requestTarget) !== key || !isCurrent()) return;
589
- if (await recoverStaleTelegramTopicApiError(
590
- { chat_id: target.chatId, message_thread_id: target.threadId }, error, { ...deps, isCurrent, isAuthorityCurrent },
591
- )) {
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
+ ) {
592
683
  deps.onRecovered();
593
684
  }
594
685
  };
@@ -631,17 +722,19 @@ export async function recoverStaleTelegramTopicApiError<
631
722
  !target ||
632
723
  !isTelegramTopicTargetStaleError(error) ||
633
724
  deps.isCurrent?.() === false
634
- ) return false;
725
+ )
726
+ return false;
635
727
  const recover = async (): Promise<boolean> => {
636
728
  if (deps.isCurrent) {
637
729
  if (
638
730
  !deps.topicTargetStore.invalidateTarget ||
639
- !await deps.topicTargetStore.invalidateTarget(
731
+ !(await deps.topicTargetStore.invalidateTarget(
640
732
  target,
641
733
  deps.isCurrent,
642
734
  String(error),
643
- )
644
- ) return false;
735
+ ))
736
+ )
737
+ return false;
645
738
  if (deps.isAuthorityCurrent?.() === false) return false;
646
739
  } else {
647
740
  await deps.topicTargetStore.load();
@@ -651,7 +744,8 @@ export async function recoverStaleTelegramTopicApiError<
651
744
  "deleted",
652
745
  String(error),
653
746
  )
654
- ) return false;
747
+ )
748
+ return false;
655
749
  await deps.topicTargetStore.persist();
656
750
  }
657
751
  const nowMs = (deps.getNowMs ?? Date.now)();
@@ -740,7 +834,12 @@ export async function ensureTelegramLeaderThreadBinding(
740
834
  normalizedLeaderCwd,
741
835
  deps.instanceId,
742
836
  legacyLeaderRecord?.instanceId,
743
- { sessionId: deps.sessionId, onCapacityUnavailable() { capacityUnavailable = true; } },
837
+ {
838
+ sessionId: deps.sessionId,
839
+ onCapacityUnavailable() {
840
+ capacityUnavailable = true;
841
+ },
842
+ },
744
843
  )
745
844
  : undefined;
746
845
  if (deps.cwd && !workspaceIdentity) {
@@ -770,270 +869,290 @@ export async function ensureTelegramLeaderThreadBinding(
770
869
  assertLeaderEpoch("before-workspace-persist");
771
870
  await deps.topicTargetStore.persist();
772
871
  assertLeaderEpoch("after-workspace-persist");
773
- 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;
774
879
  return {
775
880
  ...rest,
776
881
  target: { ...committed.target },
777
882
  slot: committed.slot ?? result.slot,
778
883
  ...(committed.threadName ? { threadName: committed.threadName } : {}),
779
- ...(committed.displayTitle ? { displayTitle: committed.displayTitle } : {}),
884
+ ...(committed.displayTitle
885
+ ? { displayTitle: committed.displayTitle }
886
+ : {}),
780
887
  };
781
888
  };
782
889
  try {
783
- const unavailableTargetKeys = new Set([
784
- ...deps.topicTargetStore
785
- .listSyncObservations()
786
- .filter((observation) => observation.syncStatus === "deleted")
787
- .map((observation) => getTelegramTargetKey(observation.target)),
788
- ...deps.topicTargetStore
789
- .listPendingCleanups()
790
- .map((intent) => getTelegramTargetKey(intent.target)),
791
- ]);
792
- let invalidatedUnavailableTarget = false;
793
- 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
+ ));
794
960
  if (
795
- record.instanceId !== deps.instanceId ||
796
- !unavailableTargetKeys.has(getTelegramTargetKey(record.target))
961
+ recoverableWorkspaceBinding &&
962
+ !unavailableTargetKeys.has(recoverableTargetKey ?? "") &&
963
+ (!deps.forceFreshUnnamed || recoverableWorkspaceBinding.threadName) &&
964
+ (sameProcessWorkspaceBinding || deps.probeWorkspaceBinding)
797
965
  ) {
798
- continue;
799
- }
800
- invalidatedUnavailableTarget =
801
- deps.topicTargetStore.markStaleByTarget(record.target) ||
802
- invalidatedUnavailableTarget;
803
- }
804
- if (invalidatedUnavailableTarget) {
805
- assertLeaderEpoch("before-unavailable-persist");
806
- await deps.topicTargetStore.persist();
807
- assertLeaderEpoch("after-unavailable-persist");
808
- }
809
- const persistedWorkspaceBinding = workspaceIdentity
810
- ? deps.topicTargetStore.getWorkspaceBinding(
811
- workspaceIdentity.cwd,
812
- workspaceIdentity.instanceSlot,
813
- workspaceIdentity.sessionId,
814
- )
815
- : undefined;
816
- const legacyWorkspaceBinding =
817
- workspaceIdentity?.instanceSlot === "a" &&
818
- deps.sessionId === undefined &&
819
- !persistedWorkspaceBinding &&
820
- typeof legacyLeaderRecord?.target.threadId === "number"
821
- ? {
822
- ...workspaceIdentity,
823
- target: { ...legacyLeaderRecord.target },
824
- ...(legacyLeaderRecord.threadName
825
- ? { threadName: legacyLeaderRecord.threadName }
826
- : {}),
827
- ...(legacyLeaderRecord.slot ? { slot: legacyLeaderRecord.slot } : {}),
828
- 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
+ });
829
987
  }
830
- : undefined;
831
- const recoverableWorkspaceBinding =
832
- persistedWorkspaceBinding ?? legacyWorkspaceBinding;
833
- const recoverableTargetKey = recoverableWorkspaceBinding
834
- ? getTelegramTargetKey(recoverableWorkspaceBinding.target)
835
- : undefined;
836
- const recoverableRecord = recoverableWorkspaceBinding
837
- ? deps.topicTargetStore
838
- .list()
839
- .find(
840
- (record) =>
841
- getTelegramTargetKey(record.target) === recoverableTargetKey,
842
- )
843
- : undefined;
844
- const sameProcessWorkspaceBinding =
845
- !!recoverableRecord &&
846
- (recoverableRecord.instanceId === deps.instanceId ||
847
- isSameTelegramProcessInstance(
848
- recoverableRecord.instanceId,
849
- deps.instanceId,
850
- ));
851
- if (
852
- recoverableWorkspaceBinding &&
853
- !unavailableTargetKeys.has(recoverableTargetKey ?? "") &&
854
- (!deps.forceFreshUnnamed || recoverableWorkspaceBinding.threadName) &&
855
- (sameProcessWorkspaceBinding || deps.probeWorkspaceBinding)
856
- ) {
857
- let workspaceTargetVisible = sameProcessWorkspaceBinding;
858
- if (!workspaceTargetVisible && deps.probeWorkspaceBinding) {
859
- assertLeaderEpoch("before-workspace-probe");
860
- try {
861
- await deps.probeWorkspaceBinding(recoverableWorkspaceBinding);
862
- assertLeaderEpoch("after-workspace-probe");
863
- workspaceTargetVisible = true;
864
- } catch (error) {
865
- if (!isTelegramTopicTargetStaleError(error)) throw error;
866
- deps.topicTargetStore.markStaleByTarget(
867
- recoverableWorkspaceBinding.target,
868
- "deleted",
869
- error instanceof Error ? error.message : String(error),
870
- );
871
- await deps.topicTargetStore.persist();
872
- deps.recordEvent("bus", error, {
873
- phase: "leader-workspace-target-stale",
874
- chatId: recoverableWorkspaceBinding.target.chatId,
875
- 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 } : {}),
876
1011
  instanceId: deps.instanceId,
1012
+ slot: recovered.slot,
1013
+ syncStatus: "open",
1014
+ lastSyncObservedAtMs: nowMs,
1015
+ lastReconcileAction: "leader-workspace-binding-recovered",
877
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;
878
1032
  }
879
1033
  }
880
- if (workspaceTargetVisible) {
881
- const nowMs = deps.getNowMs?.() ?? Date.now();
882
- const slot = workspaceIdentity?.slot;
883
- if (!slot) {
884
- throw new Error("Telegram Thread slot authority is unavailable.");
885
- }
886
- const recovered = await commitWorkspaceBinding({
887
- target: { ...recoverableWorkspaceBinding.target },
888
- slot,
889
- ...(recoverableWorkspaceBinding.threadName
890
- ? { threadName: recoverableWorkspaceBinding.threadName }
891
- : {}),
892
- reused: true,
893
- });
894
- deps.topicTargetStore.upsert({
895
- profileKey: leaderProfileKey,
896
- owner: leaderOwner,
897
- target: { ...recovered.target },
898
- status: "active",
899
- createdAtMs: recoverableRecord?.createdAtMs ?? nowMs,
900
- updatedAtMs: nowMs,
901
- ...(recovered.threadName ? { threadName: recovered.threadName } : {}),
902
- instanceId: deps.instanceId,
903
- slot: recovered.slot,
904
- syncStatus: "open",
905
- lastSyncObservedAtMs: nowMs,
906
- lastReconcileAction: "leader-workspace-binding-recovered",
907
- });
908
- assertLeaderEpoch("before-recovered-record-persist");
909
- await deps.topicTargetStore.persist();
910
- 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];
911
1048
  deps.recordEvent(
912
1049
  "telegram",
913
- "Leader thread preserved after Workspace recovery",
1050
+ "Leader thread preserved after session lifecycle change",
914
1051
  {
915
1052
  phase: "leader-thread-reused",
916
1053
  instanceId: deps.instanceId,
917
- chatId: recovered.target.chatId,
918
- threadId: recovered.target.threadId,
919
- slot: recovered.slot,
1054
+ chatId: record.target.chatId,
1055
+ threadId: record.target.threadId,
1056
+ slot: record.slot,
920
1057
  },
921
1058
  );
922
- return recovered;
923
- }
924
- }
925
- const priorTargets = deps.topicTargetStore.list().filter((record) => {
926
- return (
927
- record.instanceId === deps.instanceId &&
928
- (record.status === "active" || record.status === "starting")
929
- );
930
- });
931
- // Legacy callers without session identity may reuse the active instance target.
932
- // Session-aware callers must resolve their exact Workspace binding instead.
933
- if (deps.sessionId === undefined && !deps.forceFreshUnnamed && priorTargets.length > 0) {
934
- const record = priorTargets[0];
935
- deps.recordEvent(
936
- "telegram",
937
- "Leader thread preserved after session lifecycle change",
938
- {
939
- phase: "leader-thread-reused",
940
- instanceId: deps.instanceId,
941
- chatId: record.target.chatId,
942
- threadId: record.target.threadId,
943
- slot: record.slot,
944
- },
945
- );
946
- assertLeaderEpoch("before-reuse");
947
- if (!record.slot) {
948
- throw new Error("Telegram Thread slot authority is unavailable.");
949
- }
950
- return await commitWorkspaceBinding({
951
- target: record.target,
952
- slot: record.slot,
953
- ...(record.threadName ? { threadName: record.threadName } : {}),
954
- reused: true,
955
- });
956
- }
957
- let forcedUnnamedStale = false;
958
- if (deps.forceFreshUnnamed) {
959
- for (const record of priorTargets) {
960
- const isLeaderOwned =
961
- record.owner?.kind === "leader" ||
962
- (!record.owner &&
963
- (record.profileKey.startsWith("cwd:") ||
964
- record.profileKey.startsWith("leader:")));
965
- if (!isLeaderOwned) continue;
966
- if (record.threadName) continue;
967
- forcedUnnamedStale =
968
- deps.topicTargetStore.markStaleByTarget(record.target) ||
969
- forcedUnnamedStale;
970
- deps.recordEvent("telegram", "Unnamed leader thread binding refreshed", {
971
- phase: "leader-thread-force-fresh-unnamed",
972
- instanceId: deps.instanceId,
973
- chatId: record.target.chatId,
974
- 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,
975
1065
  slot: record.slot,
1066
+ ...(record.threadName ? { threadName: record.threadName } : {}),
1067
+ reused: true,
976
1068
  });
977
1069
  }
978
- if (forcedUnnamedStale) await deps.topicTargetStore.persist();
979
- }
980
- assertLeaderEpoch("before-provision");
981
- const ownTarget = await provisionOwnBusTopic({
982
- getAllowedUserId: deps.getAllowedUserId,
983
- instanceId: deps.instanceId,
984
- cwd: normalizedLeaderCwd,
985
- telegramProfile: deps.telegramProfile,
986
- getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
987
- getThreadReconciliationMachineState:
988
- deps.getThreadReconciliationMachineState,
989
- recordThreadReconciliationPlan: deps.recordThreadReconciliationPlan,
990
- store: deps.topicTargetStore,
991
- callApi: deps.callApi,
992
- getNowMs: deps.getNowMs,
993
- getRandom: deps.getRandom,
994
- requestedThreadName:
995
- recoverableWorkspaceBinding?.threadName ?? deps.requestedThreadName,
996
- workspaceBindingKey: workspaceIdentity?.bindingKey,
997
- preferredSlot: workspaceIdentity?.slot,
998
- resolveInitialWorkspaceDisplayTitle:
999
- deps.resolveInitialWorkspaceDisplayTitle,
1000
- recordEvent: deps.recordEvent,
1001
- });
1002
- assertLeaderEpoch("after-provision");
1003
- if (!ownTarget) return undefined;
1004
- const replacementPlan = ThreadReconciler.planThreadReconciliation({
1005
- nowMs: Date.now(),
1006
- currentLeaderEpoch: deps.getCurrentLeaderEpoch?.(),
1007
- previousState: deps.getThreadReconciliationMachineState?.(),
1008
- records: priorTargets,
1009
- pendingProvisions: deps.topicTargetStore.listPendingProvisions(),
1010
- replacedBindings: [
1011
- {
1012
- instanceId: deps.instanceId,
1013
- replacementTarget: ownTarget.target,
1014
- },
1015
- ],
1016
- });
1017
- deps.recordThreadReconciliationPlan?.(replacementPlan);
1018
- await ThreadReconciler.applyThreadReconciliationPlan(replacementPlan, {
1019
- isCleanupTargetProtected: createTelegramCleanupTargetProtection(deps.topicTargetStore),
1020
- callApi: deps.callApi,
1021
- markStaleByTarget: (target, syncStatus, lastSyncError) =>
1022
- deps.topicTargetStore.markStaleByTarget(
1023
- target,
1024
- syncStatus,
1025
- 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,
1026
1138
  ),
1027
- persist: () => deps.topicTargetStore.persist(),
1028
- removePendingProvisionById: (id) =>
1029
- deps.topicTargetStore.removePendingProvision(id),
1030
- getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
1031
- recordRuntimeEvent: deps.recordEvent,
1032
- });
1033
- assertLeaderEpoch("before-final-persist");
1034
- await deps.topicTargetStore.persist();
1035
- assertLeaderEpoch("after-final-persist");
1036
- 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);
1037
1156
  } finally {
1038
1157
  deps.topicTargetStore.releaseWorkspaceClaim(deps.instanceId);
1039
1158
  }
@@ -1055,20 +1174,6 @@ export const TELEGRAM_SYNC_SLICES = [
1055
1174
 
1056
1175
  export type TelegramSyncSlice = (typeof TELEGRAM_SYNC_SLICES)[number];
1057
1176
 
1058
- export type TelegramSyncTrigger =
1059
- | "startup"
1060
- | "reload"
1061
- | "topic-lifecycle"
1062
- | "stale-api-error"
1063
- | "setup-change"
1064
- | "pairing-change"
1065
- | "follower-register"
1066
- | "follower-prune"
1067
- | "status-request"
1068
- | "leader-health-tick"
1069
- | "ordinary-message"
1070
- | "ordinary-send";
1071
-
1072
1177
  export interface TelegramSyncSliceState {
1073
1178
  status: "fresh" | "suspect" | "unknown";
1074
1179
  updatedAtMs?: number;
@@ -1144,25 +1249,6 @@ export function createTelegramProvisioningActivityRuntime(): TelegramProvisionin
1144
1249
  };
1145
1250
  }
1146
1251
 
1147
- const RECONCILE_TRIGGERS = new Set<TelegramSyncTrigger>([
1148
- "startup",
1149
- "reload",
1150
- "topic-lifecycle",
1151
- "stale-api-error",
1152
- "setup-change",
1153
- "pairing-change",
1154
- "follower-register",
1155
- "follower-prune",
1156
- "status-request",
1157
- "leader-health-tick",
1158
- ]);
1159
-
1160
- export function shouldReconcileTelegramSync(
1161
- trigger: TelegramSyncTrigger,
1162
- ): boolean {
1163
- return RECONCILE_TRIGGERS.has(trigger);
1164
- }
1165
-
1166
1252
  export function markTelegramSyncSliceSuspect(
1167
1253
  state: TelegramSyncState,
1168
1254
  slice: TelegramSyncSlice,
@@ -1231,14 +1317,15 @@ export function createTelegramObservedTopicLifecycleSyncHandler<
1231
1317
  }) as TSyncState,
1232
1318
  );
1233
1319
  };
1234
- return (lifecycle) => deps.runWorkspaceOperation(
1235
- {
1236
- operationId: createTelegramWorkspaceAdmissionOperationId(),
1237
- operationKind: "workspace.sync-topic-lifecycle",
1238
- scopes: [{ kind: "profile" }],
1239
- },
1240
- () => operation(lifecycle),
1241
- );
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
+ );
1242
1329
  }
1243
1330
 
1244
1331
  export function createTelegramTopicLifecycleSyncHandler<TMessage = unknown>(