@llblab/pi-telegram 0.22.0 → 0.23.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.
package/lib/bus-leader.ts CHANGED
@@ -162,6 +162,7 @@ export interface TelegramBusLeaderRuntimeAssemblyDeps<TContext> {
162
162
  TelegramBusLeaderRuntimeDeps<TContext>,
163
163
  | "callApi"
164
164
  | "onFollowerPruned"
165
+ | "onFollowerDisconnected"
165
166
  | "provisionFollowerTarget"
166
167
  | "provisionLeaderTarget"
167
168
  | "reconcileFollowerBindings"
@@ -220,6 +221,9 @@ export function createTelegramBusLeaderRuntimeAssembly<TContext>(
220
221
  onFollowerPruned: createTelegramBusFollowerPruneHandler({
221
222
  ...provisionerPorts,
222
223
  }),
224
+ onFollowerDisconnected: createTelegramBusFollowerDisconnectHandler({
225
+ ...provisionerPorts,
226
+ }),
223
227
  provisionFollowerTarget: createTelegramBusFollowerTargetProvisioner({
224
228
  ...provisionerPorts,
225
229
  }),
@@ -301,6 +305,9 @@ export interface TelegramBusLeaderRuntimeDeps<TContext> {
301
305
  onFollowerPruned?: (
302
306
  follower: TelegramBusFollowerView,
303
307
  ) => Promise<void> | void;
308
+ onFollowerDisconnected?: (
309
+ follower: TelegramBusFollowerView,
310
+ ) => Promise<void> | void;
304
311
  recordRuntimeEvent?: (
305
312
  category: string,
306
313
  error: unknown,
@@ -311,65 +318,12 @@ export interface TelegramBusLeaderRuntimeDeps<TContext> {
311
318
  export function createTelegramBusFollowerBindingRealityReconciler(
312
319
  deps: TelegramBusFollowerBindingRealityDeps,
313
320
  ): () => Promise<number> {
314
- const getNowMs = deps.getNowMs ?? Date.now;
315
- const recentBindingGraceMs =
316
- deps.recentBindingGraceMs ?? TELEGRAM_BUS_RECENT_FOLLOWER_BINDING_GRACE_MS;
317
321
  return async () => {
318
322
  await deps.topicTargetStore.load();
319
- const nowMs = getNowMs();
320
- const liveInstanceIds = new Set(
321
- deps.followerRegistry.list().map((follower) => follower.instanceId),
322
- );
323
- const staleRecords = deps.topicTargetStore.list().filter((record) => {
324
- return (
325
- record.owner?.kind === "manual-follower" &&
326
- !!record.instanceId &&
327
- !liveInstanceIds.has(record.instanceId) &&
328
- nowMs - record.updatedAtMs > recentBindingGraceMs
329
- );
330
- });
331
- const staleInstanceIds = new Set(
332
- staleRecords.flatMap((record) =>
333
- record.instanceId ? [record.instanceId] : [],
334
- ),
335
- );
336
- let removed = 0;
337
- for (const instanceId of staleInstanceIds) {
338
- removed += deps.topicTargetStore.markOfflineByInstanceId(instanceId);
339
- }
340
- for (const record of staleRecords) {
341
- const profileKey =
342
- record.profileKey ??
343
- (record.owner
344
- ? Threads.getTelegramThreadOwnerKey(record.owner)
345
- : undefined);
346
- if (profileKey)
347
- deps.topicTargetStore.forgetIdentityByProfileKey(profileKey);
348
- }
349
- if (removed === 0) return 0;
350
- const cursorRealigned = Threads.reconcileTelegramFreshAllocationCursor(
351
- deps.topicTargetStore,
352
- );
353
- await deps.topicTargetStore.persist();
354
- deps.recordRuntimeEvent(
355
- "bus",
356
- "Historical follower bindings removed from live state",
357
- {
358
- phase: "follower-binding-reality",
359
- removed,
360
- },
361
- );
362
- if (cursorRealigned) {
363
- deps.recordRuntimeEvent(
364
- "bus",
365
- "Fresh allocation cursor realigned after live-state compaction",
366
- {
367
- phase: "follower-binding-reality-cursor",
368
- lastSlot: deps.topicTargetStore.getBotState().lastSlot,
369
- },
370
- );
371
- }
372
- return removed;
323
+ // A missing live registration proves only that the follower process is
324
+ // currently absent. Keep its durable owner/target binding so a replacement
325
+ // process with the same manual profile can reclaim the existing thread.
326
+ return 0;
373
327
  };
374
328
  }
375
329
 
@@ -473,29 +427,29 @@ export function createTelegramBusFollowerTargetProvisioner(
473
427
  const recordsBeforeProvision = deps.topicTargetStore.list();
474
428
  const followerProfileKey =
475
429
  registration.profileKey ?? `manual:${registration.instanceId}`;
476
- const reconnectRecord = registration.target
477
- ? recordsBeforeProvision.find((record) => {
478
- return (
479
- record.owner?.kind === "manual-follower" &&
480
- (record.instanceId === registration.instanceId ||
481
- record.profileKey === followerProfileKey) &&
482
- record.target.chatId === registration.target?.chatId &&
483
- record.target.threadId === registration.target.threadId
484
- );
485
- })
486
- : undefined;
430
+ const requestedTarget = registration.target;
431
+ const reconnectRecord = recordsBeforeProvision.find((record) => {
432
+ return (
433
+ record.owner?.kind === "manual-follower" &&
434
+ (record.instanceId === registration.instanceId ||
435
+ record.profileKey === followerProfileKey) &&
436
+ (!requestedTarget ||
437
+ (record.target.chatId === requestedTarget.chatId &&
438
+ record.target.threadId === requestedTarget.threadId))
439
+ );
440
+ });
487
441
  const followerOwner =
488
442
  Threads.getTelegramThreadOwnerFromProfileKey(followerProfileKey);
489
443
  const recoverableTarget =
490
444
  !reconnectRecord &&
491
- registration.target?.chatId === chatId &&
492
- registration.target.threadId !== undefined &&
445
+ requestedTarget?.chatId === chatId &&
446
+ requestedTarget.threadId !== undefined &&
493
447
  !recordsBeforeProvision.some(
494
448
  (record) =>
495
- record.target.chatId === registration.target?.chatId &&
496
- record.target.threadId === registration.target.threadId,
449
+ record.target.chatId === requestedTarget.chatId &&
450
+ record.target.threadId === requestedTarget.threadId,
497
451
  )
498
- ? registration.target
452
+ ? requestedTarget
499
453
  : undefined;
500
454
  const recoveryHint = recoverableTarget
501
455
  ? deps.topicTargetStore.getFollowerRecoveryHintByTarget?.(
@@ -526,13 +480,14 @@ export function createTelegramBusFollowerTargetProvisioner(
526
480
  };
527
481
  const recoverRequestedTarget = async () => {
528
482
  const nowMs = getNowMs();
483
+ const carriedThreadName = registration.threadName;
529
484
  const requestedThreadName =
530
- registration.threadName &&
485
+ carriedThreadName &&
531
486
  Threads.isTelegramTopicThreadNameValidForSlot(
532
- registration.threadName,
487
+ carriedThreadName,
533
488
  registration.slot,
534
489
  )
535
- ? registration.threadName
490
+ ? carriedThreadName
536
491
  : recoveryHint?.threadName &&
537
492
  Threads.isTelegramTopicThreadNameValidForSlot(
538
493
  recoveryHint.threadName,
@@ -540,7 +495,7 @@ export function createTelegramBusFollowerTargetProvisioner(
540
495
  )
541
496
  ? recoveryHint.threadName
542
497
  : undefined;
543
- let recoveredRecord = deps.topicTargetStore.upsert({
498
+ let recoveredRecord: Threads.TelegramTopicTargetRecord = {
544
499
  profileKey: followerProfileKey,
545
500
  owner:
546
501
  followerOwner.kind === "manual-follower"
@@ -560,19 +515,15 @@ export function createTelegramBusFollowerTargetProvisioner(
560
515
  ...(requestedThreadName ? { threadName: requestedThreadName } : {}),
561
516
  lastSyncObservedAtMs: nowMs,
562
517
  lastReconcileAction: "follower-live-target-recovery",
563
- });
518
+ };
564
519
  const requestedSlot = registration.slot ?? recoveryHint?.slot;
565
520
  const requestedSlotAvailable =
566
521
  !!requestedSlot &&
567
522
  /^[A-Z]$/.test(requestedSlot) &&
568
523
  !recordsBeforeProvision.some((record) => record.slot === requestedSlot);
569
524
  if (requestedSlotAvailable) {
570
- recoveredRecord = deps.topicTargetStore.upsert({
571
- ...recoveredRecord,
572
- slot: requestedSlot,
573
- });
525
+ recoveredRecord = { ...recoveredRecord, slot: requestedSlot };
574
526
  }
575
- await deps.topicTargetStore.persist();
576
527
  return {
577
528
  target: recoveredRecord.target,
578
529
  reused: true,
@@ -591,25 +542,117 @@ export function createTelegramBusFollowerTargetProvisioner(
591
542
  : recoverableTarget
592
543
  ? await recoverRequestedTarget()
593
544
  : await provisionTarget();
594
- if (reconnectRecord) {
545
+ const crossSessionReuse =
546
+ !!reconnectRecord &&
547
+ reconnectRecord.instanceId !== registration.instanceId;
548
+ if (reconnectRecord && !crossSessionReuse) {
595
549
  const nowMs = getNowMs();
596
- const transferredRecord = deps.topicTargetStore.upsert({
550
+ const refreshedRecord = deps.topicTargetStore.upsert({
597
551
  ...reconnectRecord,
598
552
  instanceId: registration.instanceId,
599
553
  updatedAtMs: nowMs,
600
554
  lastSyncObservedAtMs: nowMs,
601
- lastReconcileAction:
602
- reconnectRecord.instanceId === registration.instanceId
603
- ? "follower-register-reuse"
604
- : "follower-session-handoff",
555
+ lastReconcileAction: "follower-register-reuse",
605
556
  });
606
557
  await deps.topicTargetStore.persist();
607
558
  result = {
608
- target: transferredRecord.target,
559
+ target: refreshedRecord.target,
609
560
  reused: true,
610
- record: transferredRecord,
561
+ record: refreshedRecord,
611
562
  };
612
563
  }
564
+ const probeRequiredRecord =
565
+ reconnectRecord?.status === "probe-required";
566
+ const requiresVisibilityProbe =
567
+ crossSessionReuse ||
568
+ probeRequiredRecord ||
569
+ recoverableTarget !== undefined;
570
+ let connectedAnnouncement =
571
+ !result.reused || requiresVisibilityProbe
572
+ ? createTelegramBusInstanceLifecycleAnnouncement({
573
+ target: result.target,
574
+ threadName: result.record.threadName,
575
+ slot: result.record.slot,
576
+ state: "connected",
577
+ })
578
+ : undefined;
579
+ if (requiresVisibilityProbe && connectedAnnouncement) {
580
+ try {
581
+ await deps.callApi("sendMessage", {
582
+ chat_id: connectedAnnouncement.target.chatId,
583
+ message_thread_id: connectedAnnouncement.target.threadId,
584
+ text: connectedAnnouncement.text,
585
+ parse_mode: connectedAnnouncement.parseMode,
586
+ });
587
+ if (recoverableTarget || probeRequiredRecord) {
588
+ const activatedRecord = deps.topicTargetStore.upsert({
589
+ ...result.record,
590
+ status: "active",
591
+ updatedAtMs: getNowMs(),
592
+ lastSyncObservedAtMs: getNowMs(),
593
+ lastReconcileAction: "follower-live-target-recovery",
594
+ });
595
+ await deps.topicTargetStore.persist();
596
+ result = {
597
+ target: activatedRecord.target,
598
+ reused: true,
599
+ record: activatedRecord,
600
+ };
601
+ } else if (crossSessionReuse && reconnectRecord) {
602
+ const nowMs = getNowMs();
603
+ const transferredRecord = deps.topicTargetStore.upsert({
604
+ ...reconnectRecord,
605
+ instanceId: registration.instanceId,
606
+ updatedAtMs: nowMs,
607
+ lastSyncObservedAtMs: nowMs,
608
+ lastReconcileAction: "follower-session-handoff",
609
+ });
610
+ await deps.topicTargetStore.persist();
611
+ result = {
612
+ target: transferredRecord.target,
613
+ reused: true,
614
+ record: transferredRecord,
615
+ };
616
+ }
617
+ connectedAnnouncement = undefined;
618
+ } catch (error) {
619
+ if (Threads.isTelegramTopicTargetStaleError(error)) {
620
+ deps.topicTargetStore.markStaleByTarget(
621
+ result.target,
622
+ "deleted",
623
+ error instanceof Error ? error.message : String(error),
624
+ );
625
+ await deps.topicTargetStore.persist();
626
+ result = await provisionTarget();
627
+ connectedAnnouncement =
628
+ createTelegramBusInstanceLifecycleAnnouncement({
629
+ target: result.target,
630
+ threadName: result.record.threadName,
631
+ slot: result.record.slot,
632
+ state: "connected",
633
+ });
634
+ } else {
635
+ deps.recordRuntimeEvent("telegram", error, {
636
+ phase: "follower-topic-reuse-probe",
637
+ instanceId: registration.instanceId,
638
+ chatId: result.target.chatId,
639
+ threadId: result.target.threadId,
640
+ });
641
+ if (recoverableTarget) {
642
+ deps.topicTargetStore.upsert({
643
+ ...result.record,
644
+ status: "probe-required",
645
+ updatedAtMs: getNowMs(),
646
+ lastSyncError:
647
+ error instanceof Error ? error.message : String(error),
648
+ lastReconcileAction: "follower-visibility-probe-required",
649
+ });
650
+ await deps.topicTargetStore.persist();
651
+ }
652
+ throw error;
653
+ }
654
+ }
655
+ }
613
656
  deps.setSyncState(
614
657
  Sync.markTelegramSyncSliceFresh(
615
658
  deps.getSyncState(),
@@ -620,14 +663,6 @@ export function createTelegramBusFollowerTargetProvisioner(
620
663
  },
621
664
  ),
622
665
  );
623
- const connectedAnnouncement = result.reused
624
- ? undefined
625
- : createTelegramBusInstanceLifecycleAnnouncement({
626
- target: result.target,
627
- threadName: result.record.threadName,
628
- slot: result.record.slot,
629
- state: "connected",
630
- });
631
666
  recordSlowTelegramBusFollowerRegistrationStep(deps, {
632
667
  phase: "follower-register-critical",
633
668
  elapsedMs: Date.now() - registrationStartedAtMs,
@@ -733,6 +768,58 @@ export function createTelegramBusFollowerPruneHandler(
733
768
  };
734
769
  }
735
770
 
771
+ export function createTelegramBusFollowerDisconnectHandler(
772
+ deps: TelegramBusFollowerPruneHandlerDeps,
773
+ ): (follower: TelegramBusFollowerView) => Promise<void> {
774
+ return async (follower) => {
775
+ const target = follower.target;
776
+ if (!target?.threadId) return;
777
+ const leaderEpoch = deps.getCurrentLeaderEpoch?.();
778
+ if (deps.getCurrentLeaderEpoch && leaderEpoch === undefined) {
779
+ throw new Error("Follower disconnect cleanup requires leader ownership.");
780
+ }
781
+ const cleanup = await ThreadReconciler.applyThreadReconciliationPlan(
782
+ ThreadReconciler.planDisconnectedInstanceThreadCleanup({
783
+ target: { chatId: target.chatId, threadId: target.threadId },
784
+ instanceId: follower.instanceId,
785
+ leaderEpoch,
786
+ }),
787
+ {
788
+ callApi: deps.callApi,
789
+ persist: deps.topicTargetStore.persist,
790
+ getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
791
+ recordRuntimeEvent: deps.recordRuntimeEvent,
792
+ },
793
+ );
794
+ if (cleanup.incompleteActions?.length) {
795
+ throw new Error(
796
+ "Telegram follower thread deletion was not confirmed; reconnect the leader and retry /telegram-disconnect.",
797
+ );
798
+ }
799
+ if (
800
+ deps.getCurrentLeaderEpoch &&
801
+ deps.getCurrentLeaderEpoch() !== leaderEpoch
802
+ ) {
803
+ throw new Error("Follower disconnect cleanup lost leader ownership.");
804
+ }
805
+ const changed =
806
+ deps.topicTargetStore.markOfflineByInstanceId(follower.instanceId) > 0;
807
+ if (changed) await deps.topicTargetStore.persist();
808
+ deps.setSyncState(
809
+ Sync.markTelegramSyncSliceFresh(deps.getSyncState(), "target-bindings", {
810
+ nowMs: (deps.getNowMs ?? Date.now)(),
811
+ action: "manual-follower-disconnect",
812
+ }),
813
+ );
814
+ deps.recordRuntimeEvent("bus", "Telegram bus follower disconnected", {
815
+ phase: "follower-disconnect",
816
+ instanceId: follower.instanceId,
817
+ chatId: target.chatId,
818
+ threadId: target.threadId,
819
+ });
820
+ };
821
+ }
822
+
736
823
  export function createTelegramBusLeaderTargetProvisioner<TContext>(
737
824
  deps: TelegramBusLeaderTargetProvisionerDeps<TContext>,
738
825
  ): (ctx: TContext) => Promise<void> {
@@ -875,12 +962,49 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
875
962
  recordFollowerMessageOwnership?: TelegramBusFollowerMessageOwnershipRecorder;
876
963
  provisionFollowerTarget?: (
877
964
  registration: TelegramBusInstanceRegistration,
878
- ) => Promise<TelegramTarget | undefined> | TelegramTarget | undefined;
965
+ ) =>
966
+ | Promise<
967
+ | (TelegramTarget & { slot?: string; threadName?: string })
968
+ | undefined
969
+ >
970
+ | (TelegramTarget & { slot?: string; threadName?: string })
971
+ | undefined;
972
+ onFollowerDisconnected?: (
973
+ follower: TelegramBusFollowerView,
974
+ ) => Promise<void> | void;
879
975
  getCurrentLeaderEpoch?: () => number | string | undefined;
880
976
  }): (
881
977
  envelope: TelegramBusEnvelope,
882
978
  ) => Promise<TelegramBusEnvelope> | TelegramBusEnvelope {
883
979
  const getNowMs = deps.getNowMs ?? Date.now;
980
+ const followerMutationTails = new Map<string, Promise<void>>();
981
+ const getFollowerMutationKey = (follower: {
982
+ instanceId: string;
983
+ profileKey?: string;
984
+ }): string =>
985
+ follower.profileKey
986
+ ? `profile:${follower.profileKey}`
987
+ : `instance:${follower.instanceId}`;
988
+ const runFollowerMutation = async <T>(
989
+ mutationKey: string,
990
+ operation: () => Promise<T>,
991
+ ): Promise<T> => {
992
+ const previous = followerMutationTails.get(mutationKey);
993
+ let release!: () => void;
994
+ const current = new Promise<void>((resolve) => {
995
+ release = resolve;
996
+ });
997
+ followerMutationTails.set(mutationKey, current);
998
+ if (previous) await previous;
999
+ try {
1000
+ return await operation();
1001
+ } finally {
1002
+ release();
1003
+ if (followerMutationTails.get(mutationKey) === current) {
1004
+ followerMutationTails.delete(mutationKey);
1005
+ }
1006
+ }
1007
+ };
884
1008
  const forwardToFollower = async (
885
1009
  envelope: Extract<
886
1010
  TelegramBusEnvelope,
@@ -943,47 +1067,115 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
943
1067
  }
944
1068
  switch (envelope.kind) {
945
1069
  case "follower.register": {
946
- try {
947
- const leaderEpoch = deps.getCurrentLeaderEpoch?.();
948
- if (deps.getCurrentLeaderEpoch && leaderEpoch === undefined) {
949
- throw new Error(
950
- "Telegram follower registration requires leader ownership.",
1070
+ return runFollowerMutation(
1071
+ getFollowerMutationKey(envelope.registration),
1072
+ async () => {
1073
+ try {
1074
+ if (!envelope.registration.registrationGeneration) {
1075
+ throw new Error(
1076
+ "Telegram follower registration requires an exact generation.",
1077
+ );
1078
+ }
1079
+ const leaderEpoch = deps.getCurrentLeaderEpoch?.();
1080
+ if (deps.getCurrentLeaderEpoch && leaderEpoch === undefined) {
1081
+ throw new Error(
1082
+ "Telegram follower registration requires leader ownership.",
1083
+ );
1084
+ }
1085
+ const target = await deps.provisionFollowerTarget?.(
1086
+ envelope.registration,
951
1087
  );
1088
+ if (
1089
+ deps.getCurrentLeaderEpoch &&
1090
+ deps.getCurrentLeaderEpoch() !== leaderEpoch
1091
+ ) {
1092
+ throw new Error(
1093
+ "Telegram follower registration lost leader ownership.",
1094
+ );
1095
+ }
1096
+ const registeredTarget = target ?? envelope.registration.target;
1097
+ deps.followerRegistry.register({
1098
+ ...envelope.registration,
1099
+ connectedAtMs: getNowMs(),
1100
+ target: registeredTarget,
1101
+ ...((target?.slot ?? envelope.registration.slot)
1102
+ ? { slot: target?.slot ?? envelope.registration.slot }
1103
+ : {}),
1104
+ ...((target?.threadName ?? envelope.registration.threadName)
1105
+ ? {
1106
+ threadName:
1107
+ target?.threadName ?? envelope.registration.threadName,
1108
+ }
1109
+ : {}),
1110
+ });
1111
+ return {
1112
+ kind: "bus.ack" as const,
1113
+ requestId: envelope.requestId,
1114
+ ok: true,
1115
+ ...(registeredTarget ? { result: registeredTarget } : {}),
1116
+ };
1117
+ } catch (error) {
1118
+ return {
1119
+ kind: "bus.ack" as const,
1120
+ requestId: envelope.requestId,
1121
+ ok: false,
1122
+ message:
1123
+ error instanceof Error
1124
+ ? error.message
1125
+ : "Telegram bus follower target provisioning failed.",
1126
+ };
1127
+ }
1128
+ },
1129
+ );
1130
+ }
1131
+ case "follower.disconnect": {
1132
+ const registeredFollower = deps.followerRegistry.get(envelope.instanceId);
1133
+ return runFollowerMutation(
1134
+ getFollowerMutationKey(
1135
+ registeredFollower ?? { instanceId: envelope.instanceId },
1136
+ ),
1137
+ async () => {
1138
+ const follower = deps.followerRegistry.get(envelope.instanceId);
1139
+ if (!follower) {
1140
+ return {
1141
+ kind: "bus.ack" as const,
1142
+ requestId: envelope.requestId,
1143
+ ok: false,
1144
+ message: "Unknown Telegram bus follower instance.",
1145
+ };
952
1146
  }
953
- const target = await deps.provisionFollowerTarget?.(
954
- envelope.registration,
955
- );
956
1147
  if (
957
- deps.getCurrentLeaderEpoch &&
958
- deps.getCurrentLeaderEpoch() !== leaderEpoch
1148
+ !follower.registrationGeneration ||
1149
+ !envelope.registrationGeneration ||
1150
+ envelope.registrationGeneration !== follower.registrationGeneration
959
1151
  ) {
960
- throw new Error(
961
- "Telegram follower registration lost leader ownership.",
962
- );
1152
+ return {
1153
+ kind: "bus.ack" as const,
1154
+ requestId: envelope.requestId,
1155
+ ok: false,
1156
+ message: "Stale Telegram bus follower registration generation.",
1157
+ };
963
1158
  }
964
- const registeredTarget = target ?? envelope.registration.target;
965
- deps.followerRegistry.register({
966
- ...envelope.registration,
967
- connectedAtMs: getNowMs(),
968
- target: registeredTarget,
969
- });
1159
+ await deps.onFollowerDisconnected?.(follower);
1160
+ const current = deps.followerRegistry.get(follower.instanceId);
1161
+ if (
1162
+ current?.registrationGeneration !== follower.registrationGeneration
1163
+ ) {
1164
+ return {
1165
+ kind: "bus.ack" as const,
1166
+ requestId: envelope.requestId,
1167
+ ok: false,
1168
+ message: "Stale Telegram bus follower registration generation.",
1169
+ };
1170
+ }
1171
+ deps.followerRegistry.remove(follower.instanceId);
970
1172
  return {
971
- kind: "bus.ack",
1173
+ kind: "bus.ack" as const,
972
1174
  requestId: envelope.requestId,
973
1175
  ok: true,
974
- ...(registeredTarget ? { result: registeredTarget } : {}),
975
- };
976
- } catch (error) {
977
- return {
978
- kind: "bus.ack",
979
- requestId: envelope.requestId,
980
- ok: false,
981
- message:
982
- error instanceof Error
983
- ? error.message
984
- : "Telegram bus follower target provisioning failed.",
985
1176
  };
986
- }
1177
+ },
1178
+ );
987
1179
  }
988
1180
  case "follower.heartbeat": {
989
1181
  const current = deps.followerRegistry.get(envelope.instanceId);
@@ -1003,7 +1195,20 @@ export function createTelegramBusLeaderEnvelopeHandler(deps: {
1003
1195
  getNowMs(),
1004
1196
  );
1005
1197
  return follower
1006
- ? { kind: "bus.ack", requestId: envelope.requestId, ok: true }
1198
+ ? {
1199
+ kind: "bus.ack",
1200
+ requestId: envelope.requestId,
1201
+ ok: true,
1202
+ result: {
1203
+ eligibleElectionSlots: deps.followerRegistry
1204
+ .list()
1205
+ .map((candidate) => candidate.slot)
1206
+ .filter((slot): slot is string =>
1207
+ typeof slot === "string" && /^[A-Z]$/.test(slot),
1208
+ )
1209
+ .sort(),
1210
+ },
1211
+ }
1007
1212
  : {
1008
1213
  kind: "bus.ack",
1009
1214
  requestId: envelope.requestId,
@@ -1330,6 +1535,7 @@ export function createTelegramBusLeaderRuntime<TContext>(
1330
1535
  authorizeFollowerApiCall: deps.authorizeFollowerApiCall,
1331
1536
  recordFollowerMessageOwnership: deps.recordFollowerMessageOwnership,
1332
1537
  provisionFollowerTarget: deps.provisionFollowerTarget,
1538
+ onFollowerDisconnected: deps.onFollowerDisconnected,
1333
1539
  getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
1334
1540
  }),
1335
1541
  });