@llblab/pi-kit 0.19.1 → 0.20.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 (92) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +3 -3
  3. package/node_modules/@llblab/pi-state-flow/AGENTS.md +4 -4
  4. package/node_modules/@llblab/pi-state-flow/CHANGELOG.md +5 -0
  5. package/node_modules/@llblab/pi-state-flow/README.md +3 -1
  6. package/node_modules/@llblab/pi-state-flow/dist/lib/extension.js +45 -5
  7. package/node_modules/@llblab/pi-state-flow/dist/lib/protocol.js +9 -9
  8. package/node_modules/@llblab/pi-state-flow/dist/lib/skills.d.ts +19 -2
  9. package/node_modules/@llblab/pi-state-flow/dist/lib/skills.js +52 -11
  10. package/node_modules/@llblab/pi-state-flow/dist/lib/status.d.ts +1 -1
  11. package/node_modules/@llblab/pi-state-flow/dist/lib/status.js +0 -2
  12. package/node_modules/@llblab/pi-state-flow/dist/lib/telegram.js +2 -2
  13. package/node_modules/@llblab/pi-state-flow/dist/lib/transition.js +36 -15
  14. package/node_modules/@llblab/pi-state-flow/dist/package.json +1 -1
  15. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-guide/SKILL.md +1 -1
  16. package/node_modules/@llblab/pi-state-flow/dist/skills/state-flow-memory/SKILL.md +1 -1
  17. package/node_modules/@llblab/pi-state-flow/docs/architecture.md +5 -5
  18. package/node_modules/@llblab/pi-state-flow/docs/temporal-acceptance.md +2 -1
  19. package/node_modules/@llblab/pi-state-flow/docs/usage.md +2 -2
  20. package/node_modules/@llblab/pi-state-flow/lib/extension.ts +48 -5
  21. package/node_modules/@llblab/pi-state-flow/lib/protocol.ts +9 -9
  22. package/node_modules/@llblab/pi-state-flow/lib/skills.ts +65 -11
  23. package/node_modules/@llblab/pi-state-flow/lib/status.ts +1 -2
  24. package/node_modules/@llblab/pi-state-flow/lib/telegram.ts +2 -2
  25. package/node_modules/@llblab/pi-state-flow/lib/transition.ts +35 -15
  26. package/node_modules/@llblab/pi-state-flow/package.json +1 -1
  27. package/node_modules/@llblab/pi-state-flow/skills/state-flow-guide/SKILL.md +1 -1
  28. package/node_modules/@llblab/pi-state-flow/skills/state-flow-memory/SKILL.md +1 -1
  29. package/node_modules/@llblab/pi-telegram/AGENTS.md +9 -7
  30. package/node_modules/@llblab/pi-telegram/BACKLOG.md +13 -32
  31. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +11 -0
  32. package/node_modules/@llblab/pi-telegram/README.md +4 -3
  33. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.d.ts +2 -2
  34. package/node_modules/@llblab/pi-telegram/dist/lib/bindings.js +3 -5
  35. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +1 -1
  36. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +93 -9
  37. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.d.ts +9 -3
  38. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +231 -71
  39. package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +4 -0
  40. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +34 -7
  41. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +63 -2
  42. package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +3 -0
  43. package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +13 -0
  44. package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.d.ts +2 -0
  45. package/node_modules/@llblab/pi-telegram/dist/lib/lifecycle.js +25 -4
  46. package/node_modules/@llblab/pi-telegram/dist/lib/locks.d.ts +1 -0
  47. package/node_modules/@llblab/pi-telegram/dist/lib/locks.js +32 -12
  48. package/node_modules/@llblab/pi-telegram/dist/lib/paths.d.ts +10 -0
  49. package/node_modules/@llblab/pi-telegram/dist/lib/paths.js +22 -1
  50. package/node_modules/@llblab/pi-telegram/dist/lib/polling.js +2 -2
  51. package/node_modules/@llblab/pi-telegram/dist/lib/queue.d.ts +1 -1
  52. package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +1 -2
  53. package/node_modules/@llblab/pi-telegram/dist/lib/sync.d.ts +9 -0
  54. package/node_modules/@llblab/pi-telegram/dist/lib/sync.js +41 -1
  55. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.d.ts +9 -1
  56. package/node_modules/@llblab/pi-telegram/dist/lib/telegram-api.js +23 -3
  57. package/node_modules/@llblab/pi-telegram/dist/lib/thread-cleanup-manager.js +7 -2
  58. package/node_modules/@llblab/pi-telegram/dist/lib/threads.d.ts +2 -0
  59. package/node_modules/@llblab/pi-telegram/dist/lib/threads.js +68 -37
  60. package/node_modules/@llblab/pi-telegram/dist/lib/updates.d.ts +6 -4
  61. package/node_modules/@llblab/pi-telegram/dist/lib/updates.js +134 -61
  62. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.d.ts +9 -0
  63. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-admission.js +49 -3
  64. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.d.ts +77 -6
  65. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-retirement.js +384 -8
  66. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.d.ts +3 -0
  67. package/node_modules/@llblab/pi-telegram/dist/lib/workspace-slots.js +6 -0
  68. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  69. package/node_modules/@llblab/pi-telegram/docs/architecture.md +21 -14
  70. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +31 -10
  71. package/node_modules/@llblab/pi-telegram/docs/updates.md +2 -0
  72. package/node_modules/@llblab/pi-telegram/lib/bindings.ts +5 -7
  73. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +69 -12
  74. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +236 -94
  75. package/node_modules/@llblab/pi-telegram/lib/bus.ts +39 -9
  76. package/node_modules/@llblab/pi-telegram/lib/extension.ts +69 -2
  77. package/node_modules/@llblab/pi-telegram/lib/journal.ts +14 -0
  78. package/node_modules/@llblab/pi-telegram/lib/lifecycle.ts +23 -4
  79. package/node_modules/@llblab/pi-telegram/lib/locks.ts +32 -13
  80. package/node_modules/@llblab/pi-telegram/lib/paths.ts +29 -1
  81. package/node_modules/@llblab/pi-telegram/lib/polling.ts +2 -2
  82. package/node_modules/@llblab/pi-telegram/lib/queue.ts +2 -3
  83. package/node_modules/@llblab/pi-telegram/lib/sync.ts +45 -1
  84. package/node_modules/@llblab/pi-telegram/lib/telegram-api.ts +30 -2
  85. package/node_modules/@llblab/pi-telegram/lib/thread-cleanup-manager.ts +11 -3
  86. package/node_modules/@llblab/pi-telegram/lib/threads.ts +73 -36
  87. package/node_modules/@llblab/pi-telegram/lib/updates.ts +145 -78
  88. package/node_modules/@llblab/pi-telegram/lib/workspace-admission.ts +67 -4
  89. package/node_modules/@llblab/pi-telegram/lib/workspace-retirement.ts +465 -7
  90. package/node_modules/@llblab/pi-telegram/lib/workspace-slots.ts +7 -0
  91. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  92. package/package.json +3 -3
@@ -47,6 +47,9 @@ import { getTelegramBusTransportRetryPolicy } from "./bus-transport.ts";
47
47
  import type { TelegramQueueHandoffPayload } from "./queue.ts";
48
48
  import {
49
49
  createTelegramWorkspaceOperationRuntime,
50
+ createTelegramWorkspaceSlotRotation,
51
+ type TelegramWorkspaceCapacityRunner,
52
+ type TelegramWorkspaceSlotRotationPorts,
50
53
  type TelegramWorkspaceOperationRunner,
51
54
  } from "./workspace-retirement.ts";
52
55
  import {
@@ -54,6 +57,7 @@ import {
54
57
  runWithTelegramWorkspaceAdmissionsAsync,
55
58
  type TelegramWorkspaceAdmissionLedger,
56
59
  } from "./workspace-admission.ts";
60
+ import { TELEGRAM_WORKSPACE_SLOTS, TelegramWorkspaceSlotUnavailableError } from "./workspace-slots.ts";
57
61
 
58
62
  export const TELEGRAM_BUS_FOLLOWER_STALE_AFTER_MS = 15_000;
59
63
 
@@ -180,7 +184,6 @@ export interface TelegramBusFollowerDisconnectHandlerDeps {
180
184
  Threads.TelegramTopicTargetStore,
181
185
  | "list"
182
186
  | "markStaleByTarget"
183
- | "markWorkspaceBindingInactiveByTarget"
184
187
  | "persist"
185
188
  | "upsertPendingCleanup"
186
189
  | "removePendingCleanup"
@@ -230,6 +233,8 @@ export interface TelegramBusLeaderRuntimeAssemblyDeps<TContext> {
230
233
  | "callApi"
231
234
  | "onFollowerDisconnected"
232
235
  | "onFollowerConfirmedDead"
236
+ | "onFollowerConfirmedDeadPreserved"
237
+ | "getTelegramProfile"
233
238
  | "provisionFollowerTarget"
234
239
  | "provisionLeaderTarget"
235
240
  | "recordRuntimeEvent"
@@ -265,6 +270,7 @@ export interface TelegramBusLeaderRuntimeAssemblyDeps<TContext> {
265
270
  "acquireAdmission" | "releaseAdmission"
266
271
  > | undefined;
267
272
  runWorkspaceOperation?: TelegramBusWorkspaceAdmissionRunner;
273
+ workspaceRotation?: TelegramWorkspaceSlotRotationPorts;
268
274
  }
269
275
 
270
276
  export function createTelegramBusLeaderRuntimeAssembly<TContext>(
@@ -311,6 +317,13 @@ export function createTelegramBusLeaderRuntimeAssembly<TContext>(
311
317
  });
312
318
  },
313
319
  }).run;
320
+ const runWithWorkspaceCapacity = deps.workspaceRotation && deps.captureWorkspaceExternalProtection &&
321
+ deps.getCurrentLeaderEpoch ? createTelegramWorkspaceSlotRotation({
322
+ ...deps.workspaceRotation, store: deps.topicTargetStore,
323
+ getLeaderEpoch: deps.getCurrentLeaderEpoch,
324
+ getExternalProtection: deps.captureWorkspaceExternalProtection,
325
+ recordEvent(message, details) { deps.recordRuntimeEvent("bus", message, details); },
326
+ }) : undefined;
314
327
  const captureLiveBindingKeys = (
315
328
  bindings: readonly Threads.TelegramWorkspaceThreadBinding[],
316
329
  ): ReadonlySet<string> => resolveTelegramLiveWorkspaceBindingKeys(
@@ -382,12 +395,37 @@ export function createTelegramBusLeaderRuntimeAssembly<TContext>(
382
395
  },
383
396
  reconcileThreadDisplayOperation,
384
397
  );
398
+ let startupDisplayTimer: ReturnType<typeof setTimeout> | undefined;
399
+ let startupDisplayGeneration = 0;
400
+ let startupDisplaySettled = true;
401
+ const cancelStartupDisplayStabilization = () => {
402
+ startupDisplayGeneration += 1;
403
+ startupDisplaySettled = true;
404
+ if (startupDisplayTimer) clearTimeout(startupDisplayTimer);
405
+ startupDisplayTimer = undefined;
406
+ };
385
407
  const scheduleDisplay = () => {
386
- if (!display) return;
408
+ if (!display || !startupDisplaySettled) return;
387
409
  void reconcileThreadDisplay().catch((error) => {
388
410
  deps.recordRuntimeEvent("bus", error, { phase: "thread-display-reconcile" });
389
411
  });
390
412
  };
413
+ const beginStartupDisplayStabilization = () => {
414
+ cancelStartupDisplayStabilization();
415
+ startupDisplaySettled = false;
416
+ };
417
+ const armStartupDisplayReconciliation = () => {
418
+ const generation = startupDisplayGeneration;
419
+ const delayMs = deps.runtime.followerStaleAfterMs ??
420
+ TELEGRAM_BUS_FOLLOWER_STALE_AFTER_MS;
421
+ startupDisplayTimer = setTimeout(() => {
422
+ if (startupDisplayGeneration !== generation) return;
423
+ startupDisplayTimer = undefined;
424
+ startupDisplaySettled = true;
425
+ scheduleDisplay();
426
+ }, delayMs);
427
+ startupDisplayTimer.unref?.();
428
+ };
391
429
  let modeTail: Promise<void> = Promise.resolve();
392
430
  const applyThreadDisplayMode = (mode: TelegramThreadDisplayMode, isCurrent: () => boolean): Promise<void> => {
393
431
  const epoch = deps.getCurrentLeaderEpoch?.();
@@ -593,7 +631,7 @@ export function createTelegramBusLeaderRuntimeAssembly<TContext>(
593
631
  onFollowerRegistered: scheduleDisplay,
594
632
  provisionLeaderTarget: (ctx) => {
595
633
  const chatId = deps.getAllowedUserId();
596
- return runWorkspaceOperation(
634
+ const provision = () => runWorkspaceOperation(
597
635
  {
598
636
  operationId: `leader-provision:${deps.instanceId}`,
599
637
  operationKind: "workspace.provision-leader",
@@ -605,6 +643,7 @@ export function createTelegramBusLeaderRuntimeAssembly<TContext>(
605
643
  },
606
644
  () => provisionLeaderTarget(ctx),
607
645
  );
646
+ return runWithWorkspaceCapacity ? runWithWorkspaceCapacity(provision) : provision();
608
647
  },
609
648
  getFollowerDisplayTitle(follower) {
610
649
  const binding = deps.topicTargetStore.listWorkspaceBindings().find((binding) =>
@@ -722,6 +761,35 @@ export function createTelegramBusLeaderRuntimeAssembly<TContext>(
722
761
  );
723
762
  scheduleDisplay();
724
763
  },
764
+ async onFollowerConfirmedDeadPreserved(follower, isDetached, operationId) {
765
+ const epoch = deps.getCurrentLeaderEpoch?.();
766
+ const profile = deps.getTelegramProfile?.();
767
+ if (epoch === undefined || !follower.registrationGeneration ||
768
+ typeof follower.target?.threadId !== "number") return false;
769
+ const isCurrent = () => isDetached() && deps.getCurrentLeaderEpoch?.() === epoch &&
770
+ deps.getTelegramProfile?.() === profile;
771
+ if (!isCurrent()) return false;
772
+ const settled = await runWorkspaceOperation({
773
+ operationId,
774
+ operationKind: "workspace.preserve-dead-follower",
775
+ scopes: [{ kind: "profile" }],
776
+ }, async () => {
777
+ if (!isCurrent()) return false;
778
+ await deps.topicTargetStore.load();
779
+ if (!isCurrent()) return false;
780
+ const record = deps.topicTargetStore.getActiveByInstanceId(follower.instanceId);
781
+ if (!record || (follower.profileKey && record.profileKey !== follower.profileKey) ||
782
+ record.target.chatId !== follower.target!.chatId ||
783
+ record.target.threadId !== follower.target!.threadId) return true;
784
+ const committed = await deps.topicTargetStore.detachTargetOwner(record, isCurrent);
785
+ if (!committed && isCurrent()) {
786
+ throw new Error("Telegram preserved follower detachment was not committed.");
787
+ }
788
+ return committed;
789
+ });
790
+ if (settled && isCurrent()) scheduleDisplay();
791
+ return settled;
792
+ },
725
793
  provisionFollowerTarget: (registration, options) => runWorkspaceOperation(
726
794
  {
727
795
  operationId: `follower-provision:${registration.instanceId}:${registration.registrationGeneration}`,
@@ -731,7 +799,9 @@ export function createTelegramBusLeaderRuntimeAssembly<TContext>(
731
799
  () => provisionFollowerTarget(registration, options),
732
800
  ),
733
801
  getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
802
+ getTelegramProfile: deps.getTelegramProfile,
734
803
  runWorkspaceAdmission,
804
+ runWithWorkspaceCapacity,
735
805
  callApi: createTelegramBusLeaderApiProxy({
736
806
  call: deps.callApi,
737
807
  callMultipart: deps.callMultipart,
@@ -749,8 +819,18 @@ export function createTelegramBusLeaderRuntimeAssembly<TContext>(
749
819
  resetThreadNameAdmitted,
750
820
  captureWorkspaceExternalProtection: deps.captureWorkspaceExternalProtection,
751
821
  async startPolling(ctx: TContext) {
752
- await runtime.startPolling(ctx);
753
- if (display) scheduleDisplay();
822
+ if (display) beginStartupDisplayStabilization();
823
+ try {
824
+ await runtime.startPolling(ctx);
825
+ } catch (error) {
826
+ cancelStartupDisplayStabilization();
827
+ throw error;
828
+ }
829
+ if (display) armStartupDisplayReconciliation();
830
+ },
831
+ async stopPolling() {
832
+ cancelStartupDisplayStabilization();
833
+ await runtime.stopPolling();
754
834
  },
755
835
  };
756
836
  if (!display) return assembled;
@@ -824,8 +904,10 @@ export interface TelegramBusLeaderRuntimeDeps<TContext> {
824
904
  applyThreadDisplayMode?: (mode: TelegramThreadDisplayMode, isCurrent: () => boolean) => Promise<void>;
825
905
  getThreadDisplayMode?: () => TelegramThreadDisplayMode;
826
906
  getCurrentLeaderEpoch?: () => number | string | undefined;
907
+ getTelegramProfile?: () => string | undefined;
827
908
  provisionLeaderTarget?: (ctx: TContext) => Promise<void> | void;
828
909
  runWorkspaceAdmission?: TelegramBusWorkspaceAdmissionRunner;
910
+ runWithWorkspaceCapacity?: TelegramWorkspaceCapacityRunner;
829
911
  getNowMs?: () => number;
830
912
  timeoutMs?: number;
831
913
  followerPruneIntervalMs?: number;
@@ -838,6 +920,12 @@ export interface TelegramBusLeaderRuntimeDeps<TContext> {
838
920
  onFollowerConfirmedDead?: (
839
921
  follower: TelegramBusFollowerView,
840
922
  ) => Promise<void> | void;
923
+ /** True settles this observation; false needs fresh proof before another attempt. */
924
+ onFollowerConfirmedDeadPreserved?: (
925
+ follower: TelegramBusFollowerView,
926
+ isDetached: () => boolean,
927
+ operationId: string,
928
+ ) => Promise<boolean> | boolean;
841
929
  recordRuntimeEvent?: (
842
930
  category: string,
843
931
  error: unknown,
@@ -943,7 +1031,7 @@ export function createTelegramBusFollowerTargetProvisioner(
943
1031
  if (registration.cwd && !workspaceIdentity) {
944
1032
  if (options?.existingWorkspaceBindingOnly) return undefined;
945
1033
  if (capacityUnavailable) {
946
- throw new Error("Telegram Workspace slot reservation is unavailable.");
1034
+ throw new TelegramWorkspaceSlotUnavailableError();
947
1035
  }
948
1036
  throw new Error("Telegram Workspace identity is already claimed.");
949
1037
  }
@@ -1071,7 +1159,7 @@ export function createTelegramBusFollowerTargetProvisioner(
1071
1159
  workspaceIdentity?.bindingKey,
1072
1160
  );
1073
1161
  if (!recoveredSlot) {
1074
- throw new Error("Telegram Workspace slot reservation is unavailable.");
1162
+ throw new TelegramWorkspaceSlotUnavailableError();
1075
1163
  }
1076
1164
  const recoveredRecord: Threads.TelegramTopicTargetRecord = {
1077
1165
  profileKey: followerProfileKey,
@@ -1463,13 +1551,7 @@ function createTelegramBusFollowerCleanupHandler(
1463
1551
  isCleanupTargetProtected,
1464
1552
  callApi: deps.callApi,
1465
1553
  markStaleByTarget(target, syncStatus, lastSyncError) {
1466
- const stale = deps.topicTargetStore.markStaleByTarget(
1467
- target, syncStatus, lastSyncError,
1468
- );
1469
- const inactive = deps.topicTargetStore.markWorkspaceBindingInactiveByTarget(
1470
- target, (deps.getNowMs ?? Date.now)(),
1471
- );
1472
- return stale || inactive;
1554
+ return deps.topicTargetStore.markStaleByTarget(target, syncStatus, lastSyncError);
1473
1555
  },
1474
1556
  removeCleanupIntentById: deps.topicTargetStore.removePendingCleanup,
1475
1557
  persist: deps.topicTargetStore.persist,
@@ -1824,6 +1906,7 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
1824
1906
  getCurrentLeaderEpoch?: () => number | string | undefined;
1825
1907
  runFollowerMutation?: TelegramBusFollowerMutationRunner;
1826
1908
  runWorkspaceAdmission?: TelegramBusWorkspaceAdmissionRunner;
1909
+ runWithWorkspaceCapacity?: TelegramWorkspaceCapacityRunner;
1827
1910
  }): (
1828
1911
  envelope: TelegramBusEnvelope,
1829
1912
  ) => Promise<TelegramBusEnvelope> | TelegramBusEnvelope {
@@ -2148,14 +2231,16 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
2148
2231
  }.`,
2149
2232
  };
2150
2233
  }
2234
+ if (!envelope.registration.registrationGeneration) {
2235
+ return {
2236
+ kind: "bus.ack", requestId: envelope.requestId, ok: false,
2237
+ protocol: deps.protocolIdentity,
2238
+ message: "Telegram follower registration requires an exact generation.",
2239
+ };
2240
+ }
2151
2241
  const registrationOperation = () =>
2152
2242
  runFollowerMutation(envelope.registration, async () => {
2153
2243
  try {
2154
- if (!envelope.registration.registrationGeneration) {
2155
- throw new Error(
2156
- "Telegram follower registration requires an exact generation.",
2157
- );
2158
- }
2159
2244
  const leaderEpoch = deps.getCurrentLeaderEpoch?.();
2160
2245
  if (deps.getCurrentLeaderEpoch && leaderEpoch === undefined) {
2161
2246
  throw new Error(
@@ -2222,6 +2307,8 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
2222
2307
  } : {}),
2223
2308
  };
2224
2309
  } catch (error) {
2310
+ if (!restoringWorkspace && deps.runWithWorkspaceCapacity &&
2311
+ error instanceof TelegramWorkspaceSlotUnavailableError) throw error;
2225
2312
  return {
2226
2313
  kind: "bus.ack" as const,
2227
2314
  requestId: envelope.requestId,
@@ -2235,16 +2322,17 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
2235
2322
  }
2236
2323
  },
2237
2324
  );
2238
- if (!deps.runWorkspaceAdmission) return registrationOperation();
2325
+ const register = () => deps.runWorkspaceAdmission ? deps.runWorkspaceAdmission(
2326
+ {
2327
+ operationId: `follower-registration:${envelope.requestId}`,
2328
+ operationKind: "workspace.register-follower",
2329
+ scopes: [{ kind: "profile" }],
2330
+ },
2331
+ registrationOperation,
2332
+ ) : registrationOperation();
2239
2333
  try {
2240
- return await deps.runWorkspaceAdmission(
2241
- {
2242
- operationId: `follower-registration:${envelope.requestId}`,
2243
- operationKind: "workspace.register-follower",
2244
- scopes: [{ kind: "profile" }],
2245
- },
2246
- registrationOperation,
2247
- );
2334
+ return await (!restoringWorkspace && deps.runWithWorkspaceCapacity
2335
+ ? deps.runWithWorkspaceCapacity(register) : register());
2248
2336
  } catch (error) {
2249
2337
  return {
2250
2338
  kind: "bus.ack" as const,
@@ -2802,8 +2890,23 @@ export function createTelegramBusLeaderRuntime<TContext>(
2802
2890
  let pruneInterval: ReturnType<typeof setInterval> | undefined;
2803
2891
  let pruneGeneration = 0;
2804
2892
  let prunePromise: Promise<void> | undefined;
2893
+ type PreservationObservation = {
2894
+ follower: TelegramBusFollowerView;
2895
+ operationId: string;
2896
+ registration: ReturnType<TelegramBusFollowerRegistry["observeUnregistered"]>;
2897
+ isCurrent: () => boolean;
2898
+ };
2899
+ const pendingPreservations = new Map<string, PreservationObservation>();
2900
+ const forgetPreservation = (observation: PreservationObservation | undefined) => {
2901
+ if (!observation) return;
2902
+ if (pendingPreservations.get(observation.follower.instanceId) === observation) {
2903
+ pendingPreservations.delete(observation.follower.instanceId);
2904
+ }
2905
+ observation.registration.release();
2906
+ };
2805
2907
  const stopPruning = () => {
2806
2908
  pruneGeneration += 1;
2909
+ for (const observation of pendingPreservations.values()) forgetPreservation(observation);
2807
2910
  if (pruneInterval) clearInterval(pruneInterval);
2808
2911
  pruneInterval = undefined;
2809
2912
  prunePromise = undefined;
@@ -2819,99 +2922,133 @@ export function createTelegramBusLeaderRuntime<TContext>(
2819
2922
  }
2820
2923
  };
2821
2924
  const pruneFollowers = async (expectedGeneration: number) => {
2822
- const isCurrent = (): boolean => pruneGeneration === expectedGeneration;
2925
+ const epoch = deps.getCurrentLeaderEpoch?.();
2926
+ const profile = deps.getTelegramProfile?.();
2927
+ const isCurrent = (): boolean => pruneGeneration === expectedGeneration &&
2928
+ deps.getTelegramProfile?.() === profile &&
2929
+ (!deps.getCurrentLeaderEpoch || (epoch !== undefined && deps.getCurrentLeaderEpoch() === epoch));
2823
2930
  try {
2824
2931
  await localServer.ensureEndpoint();
2825
2932
  } catch (error) {
2826
- recordPruneEvent(error, {
2827
- phase: "leader-endpoint-recovery",
2828
- });
2933
+ recordPruneEvent(error, { phase: "leader-endpoint-recovery" });
2934
+ }
2935
+ if (pruneGeneration !== expectedGeneration) return;
2936
+ for (const observation of pendingPreservations.values()) {
2937
+ if (!observation.isCurrent()) forgetPreservation(observation);
2829
2938
  }
2830
2939
  if (!isCurrent()) return;
2831
- const removed = deps.followerRegistry.pruneStale(
2832
- getNowMs(),
2833
- followerStaleAfterMs,
2834
- );
2940
+ const attempts: { follower: TelegramBusFollowerView; observation?: PreservationObservation; fresh: boolean }[] =
2941
+ [...pendingPreservations.values()].map((observation) => ({
2942
+ follower: observation.follower, observation, fresh: false,
2943
+ }));
2944
+ const removed = deps.followerRegistry.pruneStale(getNowMs(), followerStaleAfterMs);
2835
2945
  for (const follower of removed) {
2946
+ forgetPreservation(pendingPreservations.get(follower.instanceId));
2947
+ let observation: PreservationObservation | undefined;
2948
+ if (deps.onFollowerConfirmedDeadPreserved && epoch !== undefined &&
2949
+ follower.registrationGeneration && Number.isSafeInteger(follower.pid) && follower.pid! > 0 &&
2950
+ Number.isSafeInteger(follower.target?.threadId) && follower.target!.threadId! > 0) {
2951
+ if (pendingPreservations.size < TELEGRAM_WORKSPACE_SLOTS.length) {
2952
+ const registration = deps.followerRegistry.observeUnregistered(follower);
2953
+ let current = true;
2954
+ observation = {
2955
+ follower, registration,
2956
+ // Reuse exact admission authority after a lost acquisition acknowledgement.
2957
+ operationId: createTelegramWorkspaceAdmissionOperationId(),
2958
+ isCurrent() {
2959
+ current = current && isCurrent() && registration.isCurrent();
2960
+ return current;
2961
+ },
2962
+ };
2963
+ pendingPreservations.set(follower.instanceId, observation);
2964
+ } else {
2965
+ recordPruneEvent("Telegram follower preservation observation capacity exhausted; retaining binding", {
2966
+ phase: "follower-preservation-capacity", instanceId: follower.instanceId,
2967
+ });
2968
+ }
2969
+ }
2970
+ attempts.push({ follower, observation, fresh: true });
2971
+ }
2972
+ for (const { follower, observation, fresh } of attempts) {
2973
+ if (!isCurrent()) return;
2974
+ if (observation && !observation.isCurrent()) {
2975
+ forgetPreservation(observation);
2976
+ continue;
2977
+ }
2836
2978
  let processConfirmedDead = false;
2837
- if (follower.pid !== undefined && deps.isFollowerProcessAlive) {
2979
+ if (follower.pid !== undefined && Number.isSafeInteger(follower.pid) &&
2980
+ follower.pid > 0 && deps.isFollowerProcessAlive) {
2838
2981
  try {
2839
- processConfirmedDead = !deps.isFollowerProcessAlive(follower.pid);
2982
+ processConfirmedDead = deps.isFollowerProcessAlive(follower.pid) === false;
2840
2983
  } catch (error) {
2841
- recordPruneEvent(error, {
2842
- phase: "follower-process-liveness",
2843
- instanceId: follower.instanceId,
2844
- pid: follower.pid,
2984
+ if (fresh) recordPruneEvent(error, {
2985
+ phase: "follower-process-liveness", instanceId: follower.instanceId, pid: follower.pid,
2845
2986
  });
2846
2987
  }
2847
2988
  }
2848
2989
  if (!processConfirmedDead) {
2849
- recordPruneEvent(
2850
- "Telegram bus follower heartbeat stale; preserving thread binding",
2851
- {
2852
- phase: "follower-pruned",
2853
- instanceId: follower.instanceId,
2854
- processLiveness:
2855
- follower.pid === undefined || !deps.isFollowerProcessAlive
2856
- ? "unknown"
2857
- : "alive-or-unknown",
2858
- },
2859
- );
2990
+ if (fresh) recordPruneEvent("Telegram bus follower heartbeat stale; preserving thread binding", {
2991
+ phase: "follower-pruned", instanceId: follower.instanceId,
2992
+ processLiveness: follower.pid === undefined || !deps.isFollowerProcessAlive ? "unknown" : "alive-or-unknown",
2993
+ });
2860
2994
  continue;
2861
2995
  }
2862
- let cleanupEnabled = false;
2996
+ let cleanupEnabled: boolean | undefined;
2863
2997
  try {
2864
- cleanupEnabled =
2865
- (await deps.shouldCleanupConfirmedDeadFollower?.()) ?? false;
2998
+ cleanupEnabled = await deps.shouldCleanupConfirmedDeadFollower?.();
2866
2999
  } catch (error) {
2867
3000
  recordPruneEvent(error, {
2868
- phase: "follower-confirmed-dead-cleanup-policy",
2869
- instanceId: follower.instanceId,
2870
- pid: follower.pid,
3001
+ phase: "follower-confirmed-dead-cleanup-policy", instanceId: follower.instanceId, pid: follower.pid,
2871
3002
  });
2872
3003
  }
2873
- if (!isCurrent()) return;
2874
- if (!cleanupEnabled || !deps.onFollowerConfirmedDead) {
2875
- recordPruneEvent(
2876
- "Telegram bus follower process confirmed dead; preserving thread binding",
2877
- {
2878
- phase: "follower-confirmed-dead-preserved",
2879
- instanceId: follower.instanceId,
2880
- pid: follower.pid,
2881
- cleanupEnabled,
2882
- },
2883
- );
3004
+ if (!isCurrent()) {
3005
+ forgetPreservation(observation);
3006
+ return;
3007
+ }
3008
+ const recordPreserved = () => recordPruneEvent(
3009
+ "Telegram bus follower process confirmed dead; preserving thread binding",
3010
+ { phase: "follower-confirmed-dead-preserved", instanceId: follower.instanceId,
3011
+ pid: follower.pid, cleanupEnabled },
3012
+ );
3013
+ // Deferred observations authorize only non-destructive publication, never deletion retries.
3014
+ if (cleanupEnabled === true && !fresh) {
3015
+ forgetPreservation(observation);
3016
+ recordPreserved();
3017
+ continue;
3018
+ }
3019
+ const onConfirmedDead = cleanupEnabled === true ? deps.onFollowerConfirmedDead :
3020
+ cleanupEnabled === false && observation ? deps.onFollowerConfirmedDeadPreserved : undefined;
3021
+ if (!onConfirmedDead) {
3022
+ if (cleanupEnabled === true) forgetPreservation(observation);
3023
+ if (fresh) recordPreserved();
2884
3024
  continue;
2885
3025
  }
3026
+ const findReplacement = () => deps.followerRegistry.list().find((candidate) =>
3027
+ candidate.instanceId === follower.instanceId ||
3028
+ (!!follower.profileKey && candidate.profileKey === follower.profileKey) ||
3029
+ (!!follower.target && candidate.target?.chatId === follower.target.chatId &&
3030
+ candidate.target?.threadId === follower.target.threadId),
3031
+ );
3032
+ let settled = false;
2886
3033
  try {
2887
3034
  await runFollowerMutation(follower, async () => {
2888
- if (!isCurrent()) return;
2889
- const replacement = deps.followerRegistry.list().find((candidate) =>
2890
- follower.profileKey
2891
- ? candidate.profileKey === follower.profileKey
2892
- : candidate.instanceId === follower.instanceId,
2893
- );
2894
- if (replacement) {
2895
- recordPruneEvent(
2896
- "Telegram bus follower replaced before confirmed-dead cleanup; preserving thread binding",
2897
- {
2898
- phase: "follower-confirmed-dead-replaced",
2899
- instanceId: follower.instanceId,
2900
- replacementInstanceId: replacement.instanceId,
2901
- },
2902
- );
2903
- return;
2904
- }
2905
- await deps.onFollowerConfirmedDead!(follower);
3035
+ const isDetached = () => isCurrent() && (!observation || observation.isCurrent()) &&
3036
+ !findReplacement() && deps.isFollowerProcessAlive?.(follower.pid!) === false;
3037
+ if (!isDetached()) return;
3038
+ if (cleanupEnabled === true) await deps.onFollowerConfirmedDead!(follower);
3039
+ else settled = await deps.onFollowerConfirmedDeadPreserved!(follower, isDetached, observation!.operationId);
2906
3040
  });
3041
+ if (!cleanupEnabled) recordPreserved();
2907
3042
  } catch (error) {
2908
3043
  recordPruneEvent(error, {
2909
- phase: "follower-confirmed-dead-cleanup",
2910
- instanceId: follower.instanceId,
2911
- pid: follower.pid,
2912
- chatId: follower.target?.chatId,
2913
- threadId: follower.target?.threadId,
3044
+ phase: cleanupEnabled ? "follower-confirmed-dead-cleanup" : "follower-confirmed-dead-preserve",
3045
+ instanceId: follower.instanceId, pid: follower.pid,
3046
+ chatId: follower.target?.chatId, threadId: follower.target?.threadId,
2914
3047
  });
3048
+ } finally {
3049
+ if (settled || cleanupEnabled === true || (observation && !observation.isCurrent())) {
3050
+ forgetPreservation(observation);
3051
+ }
2915
3052
  }
2916
3053
  }
2917
3054
  };
@@ -2949,7 +3086,11 @@ export function createTelegramBusLeaderRuntime<TContext>(
2949
3086
  resolveAgentTarget: deps.resolveAgentTarget,
2950
3087
  routeAgentMessage: deps.routeAgentMessage,
2951
3088
  routeQueueHandoff: deps.routeQueueHandoff,
2952
- provisionFollowerTarget: deps.provisionFollowerTarget,
3089
+ provisionFollowerTarget: deps.provisionFollowerTarget ? (registration, options) => {
3090
+ // Same-instance ownership can change in the store before registry publication.
3091
+ forgetPreservation(pendingPreservations.get(registration.instanceId));
3092
+ return deps.provisionFollowerTarget!(registration, options);
3093
+ } : undefined,
2953
3094
  onFollowerDisconnected: deps.onFollowerDisconnected,
2954
3095
  renameFollowerThread: deps.renameFollowerThread,
2955
3096
  resetFollowerThreadName: deps.resetFollowerThreadName,
@@ -2960,6 +3101,7 @@ export function createTelegramBusLeaderRuntime<TContext>(
2960
3101
  getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
2961
3102
  runFollowerMutation,
2962
3103
  runWorkspaceAdmission: deps.runWorkspaceAdmission,
3104
+ runWithWorkspaceCapacity: deps.runWithWorkspaceCapacity,
2963
3105
  });
2964
3106
  const routeQueueHandoffEnvelope = async (
2965
3107
  input: Parameters<TelegramBusLeaderRuntime<TContext>["routeQueueHandoff"]>[0],
@@ -2142,6 +2142,10 @@ export interface TelegramBusFollowerRegistry {
2142
2142
  list: () => TelegramBusFollowerView[];
2143
2143
  remove: (instanceId: string) => boolean;
2144
2144
  clear: () => void;
2145
+ observeUnregistered: (follower: TelegramBusFollowerView) => {
2146
+ isCurrent: () => boolean;
2147
+ release: () => void;
2148
+ };
2145
2149
  pruneStale: (
2146
2150
  nowMs: number,
2147
2151
  staleAfterMs: number,
@@ -2163,8 +2167,19 @@ export function createTelegramBusForwardOwnershipValidator(
2163
2167
  };
2164
2168
  }
2165
2169
 
2170
+ function hasTelegramBusFollowerIdentityOverlap(
2171
+ first: TelegramBusInstanceRegistration,
2172
+ second: TelegramBusInstanceRegistration,
2173
+ ): boolean {
2174
+ return first.instanceId === second.instanceId ||
2175
+ (first.profileKey !== undefined && first.profileKey === second.profileKey) ||
2176
+ (first.target !== undefined && first.target.chatId === second.target?.chatId &&
2177
+ first.target.threadId === second.target.threadId);
2178
+ }
2179
+
2166
2180
  export function createTelegramBusFollowerRegistry(): TelegramBusFollowerRegistry {
2167
2181
  const followers = new Map<string, TelegramBusFollowerView>();
2182
+ const observations = new Set<{ follower: TelegramBusFollowerView; current: boolean }>();
2168
2183
  const clone = (
2169
2184
  follower: TelegramBusFollowerView,
2170
2185
  ): TelegramBusFollowerView => ({
@@ -2184,14 +2199,7 @@ export function createTelegramBusFollowerRegistry(): TelegramBusFollowerRegistry
2184
2199
  const existing = followers.get(registration.instanceId);
2185
2200
  for (const [instanceId, follower] of followers.entries()) {
2186
2201
  if (instanceId === registration.instanceId) continue;
2187
- const sameProfile =
2188
- registration.profileKey !== undefined &&
2189
- registration.profileKey === follower.profileKey;
2190
- const sameTarget =
2191
- registration.target !== undefined &&
2192
- follower.target?.chatId === registration.target.chatId &&
2193
- follower.target.threadId === registration.target.threadId;
2194
- if (sameProfile || sameTarget) followers.delete(instanceId);
2202
+ if (hasTelegramBusFollowerIdentityOverlap(registration, follower)) followers.delete(instanceId);
2195
2203
  }
2196
2204
  const next: TelegramBusFollowerView = {
2197
2205
  ...registration,
@@ -2208,6 +2216,11 @@ export function createTelegramBusFollowerRegistry(): TelegramBusFollowerRegistry
2208
2216
  existing?.lastHeartbeatMs ?? registration.connectedAtMs,
2209
2217
  };
2210
2218
  followers.set(registration.instanceId, next);
2219
+ for (const observation of observations) {
2220
+ if (!hasTelegramBusFollowerIdentityOverlap(next, observation.follower)) continue;
2221
+ observation.current = false;
2222
+ observations.delete(observation);
2223
+ }
2211
2224
  return clone(next);
2212
2225
  },
2213
2226
  heartbeat: (instanceId, nowMs) => {
@@ -2234,7 +2247,24 @@ export function createTelegramBusFollowerRegistry(): TelegramBusFollowerRegistry
2234
2247
  },
2235
2248
  list: () => [...followers.values()].map(clone),
2236
2249
  remove: (instanceId) => followers.delete(instanceId),
2237
- clear: () => followers.clear(),
2250
+ clear: () => {
2251
+ followers.clear();
2252
+ for (const observation of observations) observation.current = false;
2253
+ observations.clear();
2254
+ },
2255
+ observeUnregistered: (follower) => {
2256
+ // This watches replacement, not process death or delivery authority.
2257
+ const observation = { follower: clone(follower), current: ![...followers.values()]
2258
+ .some((current) => hasTelegramBusFollowerIdentityOverlap(current, follower)) };
2259
+ if (observation.current) observations.add(observation);
2260
+ return {
2261
+ isCurrent: () => observation.current,
2262
+ release() {
2263
+ observation.current = false;
2264
+ observations.delete(observation);
2265
+ },
2266
+ };
2267
+ },
2238
2268
  pruneStale: (nowMs, staleAfterMs) => {
2239
2269
  const removed: TelegramBusFollowerView[] = [];
2240
2270
  for (const [instanceId, follower] of followers.entries()) {