@llblab/pi-telegram 0.20.0 → 0.20.2

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.
@@ -16,8 +16,10 @@ import {
16
16
  createTelegramBusLocalServer,
17
17
  createUnauthorizedBusAck,
18
18
  getTelegramBusSocketPath,
19
+ resolveTelegramBusSocketPath,
19
20
  sendTelegramBusLocalEnvelope,
20
21
  type TelegramBusEnvelope,
22
+ type TelegramBusSocketPathSource,
21
23
  } from "./bus.ts";
22
24
  import {
23
25
  getTelegramBusTransportRetryPolicy,
@@ -133,7 +135,7 @@ export interface TelegramBusForwardedUpdateReceiverRuntime {
133
135
  }
134
136
 
135
137
  export interface TelegramBusFollowerApiCallerDeps {
136
- socketPath: string;
138
+ socketPath: TelegramBusSocketPathSource;
137
139
  instanceId: string;
138
140
  createRequestId: () => string;
139
141
  getAuthSecret?: () => string | undefined;
@@ -149,6 +151,7 @@ export interface TelegramBusFollowerRegistrationRuntimeDeps<
149
151
  getLeaderAuthSecret?: (leader: { busSecret?: string }) => string | undefined;
150
152
  setActiveAuthSecret?: (secret: string | undefined) => void;
151
153
  followerBusSocketPath?: string;
154
+ getFollowerBusSocketPath?: () => string;
152
155
  getLeaderSocketPath?: () => string;
153
156
  startReceiving?: () => Promise<void>;
154
157
  stopReceiving?: () => Promise<void> | void;
@@ -170,6 +173,63 @@ export interface TelegramBusFollowerRegistrationRuntimeDeps<
170
173
  onHeartbeatFailure?: (error: unknown, ctx: TContext) => Promise<void> | void;
171
174
  }
172
175
 
176
+ export function createTelegramManualFollowerProfileKeyResolver(input: {
177
+ getActiveProfileName: () => string | undefined;
178
+ manualFollowerOwnerId: string;
179
+ }): () => string {
180
+ return () =>
181
+ Threads.getTelegramThreadOwnerKey({
182
+ kind: "manual-follower",
183
+ instanceId: input.manualFollowerOwnerId,
184
+ telegramProfile: input.getActiveProfileName(),
185
+ });
186
+ }
187
+
188
+ export type TelegramBusFollowerPromotionHandler<TContext> = (
189
+ ctx: TContext,
190
+ binding: TelegramBusFollowerPromotedBinding,
191
+ ) => Promise<void>;
192
+
193
+ export function createTelegramBusFollowerPromotionHandler<
194
+ TContext extends { cwd: string },
195
+ >(input: {
196
+ topicTargetStore: Threads.TelegramTopicTargetStore;
197
+ instanceId: string;
198
+ getActiveProfileName: () => string | undefined;
199
+ startLeader: (ctx: TContext) => Promise<unknown> | unknown;
200
+ recordRuntimeEvent: (
201
+ category: string,
202
+ error: unknown,
203
+ details?: Record<string, unknown>,
204
+ ) => void;
205
+ }): TelegramBusFollowerPromotionHandler<TContext> {
206
+ return async (ctx, binding) => {
207
+ const promotedRecord = await Threads.promoteTelegramFollowerBindingToLeader({
208
+ store: input.topicTargetStore,
209
+ instanceId: input.instanceId,
210
+ cwd: ctx.cwd,
211
+ telegramProfile: input.getActiveProfileName(),
212
+ target: binding.target,
213
+ slot: binding.slot,
214
+ threadName: binding.threadName,
215
+ });
216
+ if (promotedRecord) {
217
+ input.recordRuntimeEvent(
218
+ "bus",
219
+ "Follower thread binding promoted to leader",
220
+ {
221
+ phase: "follower-promoted-binding",
222
+ chatId: promotedRecord.target.chatId,
223
+ threadId: promotedRecord.target.threadId,
224
+ slot: promotedRecord.slot,
225
+ threadName: promotedRecord.threadName,
226
+ },
227
+ );
228
+ }
229
+ await input.startLeader(ctx);
230
+ };
231
+ }
232
+
173
233
  export interface TelegramBusFollowerTargetReplacementHandlerDeps<TContext> {
174
234
  topicTargetStore: Pick<
175
235
  Threads.TelegramTopicTargetStore,
@@ -180,7 +240,7 @@ export interface TelegramBusFollowerTargetReplacementHandlerDeps<TContext> {
180
240
  "getTarget" | "setRegistered"
181
241
  >;
182
242
  instanceId: string;
183
- manualFollowerProfileKey: string;
243
+ getManualFollowerProfileKey: () => string;
184
244
  manualFollowerOwnerId: string;
185
245
  getSyncState: () => Sync.TelegramSyncState;
186
246
  setSyncState: (state: Sync.TelegramSyncState) => void;
@@ -238,7 +298,7 @@ export interface TelegramBusForwardedUpdateReceiverRuntimeDeps<
238
298
  TCallbackQuery,
239
299
  TMessage = unknown,
240
300
  > {
241
- socketPath: string;
301
+ socketPath: TelegramBusSocketPathSource;
242
302
  instanceId: string;
243
303
  getAuthSecret?: () => string | undefined;
244
304
  getContext: () => TContext | undefined;
@@ -273,6 +333,71 @@ export interface TelegramBusForwardedUpdateReceiverRuntimeDeps<
273
333
  ) => void;
274
334
  }
275
335
 
336
+ export interface TelegramBusFollowerRuntimeAssemblyDeps<
337
+ TContext extends { cwd?: string },
338
+ TReactionUpdate,
339
+ TCallbackQuery,
340
+ TMessage = unknown,
341
+ > {
342
+ receiver: Omit<
343
+ TelegramBusForwardedUpdateReceiverRuntimeDeps<
344
+ TContext,
345
+ TReactionUpdate,
346
+ TCallbackQuery,
347
+ TMessage
348
+ >,
349
+ "handleReplaceTarget"
350
+ >;
351
+ targetReplacement: TelegramBusFollowerTargetReplacementHandlerDeps<TContext>;
352
+ recovery: Omit<
353
+ TelegramBusFollowerHeartbeatRecoveryHandlerDeps<TContext>,
354
+ "getRegistrationRuntime"
355
+ >;
356
+ registration: Omit<
357
+ TelegramBusFollowerRegistrationRuntimeDeps<TContext>,
358
+ "startReceiving" | "stopReceiving" | "onHeartbeatFailure"
359
+ >;
360
+ }
361
+
362
+ export interface TelegramBusFollowerRuntimeAssembly<TContext> {
363
+ receiver: TelegramBusForwardedUpdateReceiverRuntime;
364
+ registration: TelegramBusFollowerRegistrationRuntime<TContext>;
365
+ }
366
+
367
+ export function createTelegramBusFollowerRuntimeAssembly<
368
+ TContext extends { cwd?: string },
369
+ TReactionUpdate,
370
+ TCallbackQuery,
371
+ TMessage = unknown,
372
+ >(
373
+ deps: TelegramBusFollowerRuntimeAssemblyDeps<
374
+ TContext,
375
+ TReactionUpdate,
376
+ TCallbackQuery,
377
+ TMessage
378
+ >,
379
+ ): TelegramBusFollowerRuntimeAssembly<TContext> {
380
+ const receiver = createTelegramBusForwardedUpdateReceiverRuntime({
381
+ ...deps.receiver,
382
+ handleReplaceTarget:
383
+ createTelegramBusFollowerTargetReplacementHandler(
384
+ deps.targetReplacement,
385
+ ),
386
+ });
387
+ let registration: TelegramBusFollowerRegistrationRuntime<TContext>;
388
+ const recovery = createTelegramBusFollowerHeartbeatRecoveryHandler({
389
+ ...deps.recovery,
390
+ getRegistrationRuntime: () => registration,
391
+ });
392
+ registration = createTelegramBusFollowerRegistrationRuntime({
393
+ ...deps.registration,
394
+ startReceiving: receiver.start,
395
+ stopReceiving: receiver.stop,
396
+ onHeartbeatFailure: recovery,
397
+ });
398
+ return { receiver, registration };
399
+ }
400
+
276
401
  export function createTelegramBusFollowerTargetReplacementHandler<TContext>(
277
402
  deps: TelegramBusFollowerTargetReplacementHandlerDeps<TContext>,
278
403
  ): NonNullable<
@@ -305,7 +430,7 @@ export function createTelegramBusFollowerTargetReplacementHandler<TContext>(
305
430
  );
306
431
  }
307
432
  const profileKey =
308
- currentRecord?.profileKey ?? deps.manualFollowerProfileKey;
433
+ currentRecord?.profileKey ?? deps.getManualFollowerProfileKey();
309
434
  deps.topicTargetStore.upsert({
310
435
  profileKey,
311
436
  owner: {
@@ -354,11 +479,12 @@ export function createTelegramBusFollowerApiCaller(
354
479
  const getNowMs = deps.getNowMs ?? Date.now;
355
480
  const timeoutMs = deps.timeoutMs ?? 30000;
356
481
  return async (method, args) => {
482
+ const socketPath = resolveTelegramBusSocketPath(deps.socketPath);
357
483
  const response = await sendTelegramBusLocalEnvelope({
358
- socketPath: deps.socketPath,
484
+ socketPath,
359
485
  timeoutMs,
360
486
  retry: getTelegramBusTransportRetryPolicy({
361
- endpoint: deps.socketPath,
487
+ endpoint: socketPath,
362
488
  operation: "operation",
363
489
  }),
364
490
  envelope: {
@@ -650,6 +776,7 @@ export function createTelegramBusFollowerRegistrationRuntime<
650
776
  let activeLeaderSocketPath: string | undefined;
651
777
  let activeAuthSecret: string | undefined;
652
778
  let activeContext: TContext | undefined;
779
+ let lastKnownTarget: TelegramTarget | undefined;
653
780
  const stopHeartbeat = () => {
654
781
  if (!heartbeatInterval) return;
655
782
  clearInterval(heartbeatInterval);
@@ -660,6 +787,7 @@ export function createTelegramBusFollowerRegistrationRuntime<
660
787
  activeAuthSecret = undefined;
661
788
  deps.setActiveAuthSecret?.(undefined);
662
789
  deps.registrationState?.setRegistered(false);
790
+ lastKnownTarget = undefined;
663
791
  activeContext = undefined;
664
792
  void deps.stopReceiving?.();
665
793
  };
@@ -725,7 +853,9 @@ export function createTelegramBusFollowerRegistrationRuntime<
725
853
  (ctx.cwd ? basename(ctx.cwd) : undefined),
726
854
  cwd: ctx.cwd,
727
855
  pid: getPid(),
728
- busSocketPath: deps.followerBusSocketPath,
856
+ target: deps.registrationState?.getTarget() ?? lastKnownTarget,
857
+ busSocketPath:
858
+ deps.getFollowerBusSocketPath?.() ?? deps.followerBusSocketPath,
729
859
  connectedAtMs: getNowMs(),
730
860
  },
731
861
  });
@@ -778,6 +908,7 @@ export function createTelegramBusFollowerRegistrationRuntime<
778
908
  registrationResult.target,
779
909
  registrationResult,
780
910
  );
911
+ lastKnownTarget = registrationResult.target;
781
912
  activeLeaderSocketPath = leaderSocketPath;
782
913
  activeContext = ctx;
783
914
  await sendHeartbeat();
package/lib/bus-leader.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  type TelegramBusFollowerRegistry,
21
21
  type TelegramBusFollowerView,
22
22
  type TelegramBusInstanceRegistration,
23
+ type TelegramBusSocketPathSource,
23
24
  } from "./bus.ts";
24
25
  import { getTelegramBusTransportRetryPolicy } from "./bus-transport.ts";
25
26
 
@@ -153,15 +154,105 @@ export interface TelegramBusLeaderApiProxyDeps {
153
154
  ) => Promise<unknown> | unknown;
154
155
  }
155
156
 
156
- export interface TelegramBusFollowerRegistryRestoreDeps {
157
- topicTargetStore: Pick<Threads.TelegramTopicTargetStore, "load" | "list">;
158
- followerRegistry: TelegramBusFollowerRegistry;
157
+ export interface TelegramBusLeaderRuntimeAssemblyDeps<TContext> {
158
+ runtime: Omit<
159
+ TelegramBusLeaderRuntimeDeps<TContext>,
160
+ | "callApi"
161
+ | "onFollowerPruned"
162
+ | "provisionFollowerTarget"
163
+ | "provisionLeaderTarget"
164
+ | "reconcileFollowerBindings"
165
+ | "recordRuntimeEvent"
166
+ >;
167
+ getAllowedUserId: () => number | undefined;
168
+ instanceId: string;
169
+ getCwd?: (ctx: TContext) => string | undefined;
170
+ getTelegramProfile?: () => string | undefined;
171
+ shouldForceFreshUnnamed?: () => boolean;
172
+ topicTargetStore: Threads.TelegramTopicTargetStore;
173
+ callApi: TelegramBusLeaderTargetProvisionerDeps<TContext>["callApi"];
174
+ callMultipart: TelegramBusLeaderApiProxyDeps["callMultipart"];
175
+ downloadFile: TelegramBusLeaderApiProxyDeps["downloadFile"];
176
+ recoverStaleTargetError?: TelegramBusLeaderApiProxyDeps["recoverStaleTargetError"];
177
+ getCurrentLeaderEpoch?: () => number | string | undefined;
178
+ getThreadReconciliationMachineState?: TelegramBusLeaderTargetProvisionerDeps<TContext>["getThreadReconciliationMachineState"];
179
+ recordThreadReconciliationPlan?: TelegramBusLeaderTargetProvisionerDeps<TContext>["recordThreadReconciliationPlan"];
180
+ getSyncState: () => Sync.TelegramSyncState;
181
+ setSyncState: (state: Sync.TelegramSyncState) => void;
182
+ setLeaderTarget: TelegramBusLeaderTargetProvisionerDeps<TContext>["setLeaderTarget"];
183
+ onProvisioningStart?: () => void;
184
+ onProvisioningEnd?: () => void;
185
+ recordRuntimeEvent: NonNullable<
186
+ TelegramBusLeaderRuntimeDeps<TContext>["recordRuntimeEvent"]
187
+ >;
188
+ }
189
+
190
+ export function createTelegramBusLeaderRuntimeAssembly<TContext>(
191
+ deps: TelegramBusLeaderRuntimeAssemblyDeps<TContext>,
192
+ ): TelegramBusLeaderRuntime<TContext> {
193
+ const provisionerPorts = {
194
+ getAllowedUserId: deps.getAllowedUserId,
195
+ topicTargetStore: deps.topicTargetStore,
196
+ callApi: deps.callApi,
197
+ getCurrentLeaderEpoch: deps.getCurrentLeaderEpoch,
198
+ getSyncState: deps.getSyncState,
199
+ setSyncState: deps.setSyncState,
200
+ onProvisioningStart: deps.onProvisioningStart,
201
+ onProvisioningEnd: deps.onProvisioningEnd,
202
+ recordRuntimeEvent: deps.recordRuntimeEvent,
203
+ };
204
+ return createTelegramBusLeaderRuntime({
205
+ ...deps.runtime,
206
+ provisionLeaderTarget: createTelegramBusLeaderTargetProvisioner({
207
+ ...provisionerPorts,
208
+ instanceId: deps.instanceId,
209
+ getCwd: deps.getCwd,
210
+ getTelegramProfile: deps.getTelegramProfile,
211
+ shouldForceFreshUnnamed: deps.shouldForceFreshUnnamed,
212
+ getThreadReconciliationMachineState:
213
+ deps.getThreadReconciliationMachineState,
214
+ recordThreadReconciliationPlan: deps.recordThreadReconciliationPlan,
215
+ setLeaderTarget: deps.setLeaderTarget,
216
+ }),
217
+ onFollowerPruned: createTelegramBusFollowerPruneHandler({
218
+ ...provisionerPorts,
219
+ }),
220
+ provisionFollowerTarget: createTelegramBusFollowerTargetProvisioner({
221
+ ...provisionerPorts,
222
+ }),
223
+ reconcileFollowerBindings:
224
+ createTelegramBusFollowerBindingRealityReconciler({
225
+ topicTargetStore: deps.topicTargetStore,
226
+ followerRegistry: deps.runtime.followerRegistry,
227
+ recordRuntimeEvent: deps.recordRuntimeEvent,
228
+ }),
229
+ callApi: createTelegramBusLeaderApiProxy({
230
+ call: deps.callApi,
231
+ callMultipart: deps.callMultipart,
232
+ downloadFile: deps.downloadFile,
233
+ recoverStaleTargetError: deps.recoverStaleTargetError,
234
+ }),
235
+ recordRuntimeEvent: deps.recordRuntimeEvent,
236
+ });
237
+ }
238
+
239
+ export interface TelegramBusFollowerBindingRealityDeps {
240
+ topicTargetStore: Pick<
241
+ Threads.TelegramTopicTargetStore,
242
+ | "load"
243
+ | "list"
244
+ | "markOfflineByInstanceId"
245
+ | "forgetIdentityByProfileKey"
246
+ | "getBotState"
247
+ | "persist"
248
+ | "setBotState"
249
+ >;
250
+ followerRegistry: Pick<TelegramBusFollowerRegistry, "list">;
159
251
  recordRuntimeEvent: (
160
252
  category: string,
161
253
  error: unknown,
162
254
  details?: Record<string, unknown>,
163
255
  ) => void;
164
- getNowMs?: () => number;
165
256
  }
166
257
 
167
258
  export interface TelegramBusFollowerMessageOwnershipRecord {
@@ -176,7 +267,7 @@ export type TelegramBusFollowerMessageOwnershipRecorder = (
176
267
  ) => void;
177
268
 
178
269
  export interface TelegramBusLeaderRuntimeDeps<TContext> {
179
- socketPath: string;
270
+ socketPath: TelegramBusSocketPathSource;
180
271
  followerRegistry: TelegramBusFollowerRegistry;
181
272
  authSecret?: string;
182
273
  startPolling: (ctx: TContext) => void | Promise<void>;
@@ -191,11 +282,12 @@ export interface TelegramBusLeaderRuntimeDeps<TContext> {
191
282
  provisionFollowerTarget?: (
192
283
  registration: TelegramBusInstanceRegistration,
193
284
  ) => Promise<TelegramTarget | undefined> | TelegramTarget | undefined;
194
- restoreFollowerRegistry?: () => Promise<void> | void;
285
+ reconcileFollowerBindings?: () => Promise<unknown> | unknown;
195
286
  provisionLeaderTarget?: (ctx: TContext) => Promise<void> | void;
196
287
  getNowMs?: () => number;
197
288
  followerPruneIntervalMs?: number;
198
289
  followerStaleAfterMs?: number;
290
+ followerRecoveryGraceMs?: number;
199
291
  onFollowerPruned?: (
200
292
  follower: TelegramBusFollowerView,
201
293
  ) => Promise<void> | void;
@@ -206,39 +298,61 @@ export interface TelegramBusLeaderRuntimeDeps<TContext> {
206
298
  ) => void;
207
299
  }
208
300
 
209
- export function createTelegramBusFollowerRegistryRestoreHandler(
210
- deps: TelegramBusFollowerRegistryRestoreDeps,
211
- ): () => Promise<void> {
301
+ export function createTelegramBusFollowerBindingRealityReconciler(
302
+ deps: TelegramBusFollowerBindingRealityDeps,
303
+ ): () => Promise<number> {
212
304
  return async () => {
213
305
  await deps.topicTargetStore.load();
214
- const nowMs = (deps.getNowMs ?? Date.now)();
215
- let restored = 0;
216
- for (const record of deps.topicTargetStore.list()) {
217
- if (record.owner?.kind !== "manual-follower") continue;
218
- if (!record.instanceId) continue;
219
- if (
220
- record.status !== "active" &&
221
- record.status !== "starting" &&
222
- record.status !== "pending"
223
- ) {
224
- continue;
225
- }
226
- deps.followerRegistry.register({
227
- instanceId: record.instanceId,
228
- profileKey: record.profileKey,
229
- threadName: record.threadName,
230
- target: record.target,
231
- busSocketPath: getTelegramBusFollowerSocketPath(record.instanceId),
232
- connectedAtMs: nowMs,
233
- });
234
- restored += 1;
306
+ const liveInstanceIds = new Set(
307
+ deps.followerRegistry.list().map((follower) => follower.instanceId),
308
+ );
309
+ const staleRecords = deps.topicTargetStore.list().filter((record) => {
310
+ return (
311
+ record.owner?.kind === "manual-follower" &&
312
+ !!record.instanceId &&
313
+ !liveInstanceIds.has(record.instanceId)
314
+ );
315
+ });
316
+ const staleInstanceIds = new Set(
317
+ staleRecords.flatMap((record) =>
318
+ record.instanceId ? [record.instanceId] : [],
319
+ ),
320
+ );
321
+ let removed = 0;
322
+ for (const instanceId of staleInstanceIds) {
323
+ removed += deps.topicTargetStore.markOfflineByInstanceId(instanceId);
235
324
  }
236
- if (restored > 0) {
237
- deps.recordRuntimeEvent("bus", "Telegram follower registry restored", {
238
- phase: "follower-registry-restore",
239
- followers: restored,
240
- });
325
+ for (const record of staleRecords) {
326
+ const profileKey =
327
+ record.profileKey ??
328
+ (record.owner
329
+ ? Threads.getTelegramThreadOwnerKey(record.owner)
330
+ : undefined);
331
+ if (profileKey) deps.topicTargetStore.forgetIdentityByProfileKey(profileKey);
332
+ }
333
+ if (removed === 0) return 0;
334
+ const cursorRealigned =
335
+ Threads.reconcileTelegramFreshAllocationCursor(deps.topicTargetStore);
336
+ await deps.topicTargetStore.persist();
337
+ deps.recordRuntimeEvent(
338
+ "bus",
339
+ "Historical follower bindings removed from live state",
340
+ {
341
+ phase: "follower-binding-reality",
342
+ removed,
343
+ },
344
+ );
345
+ if (cursorRealigned) {
346
+ deps.recordRuntimeEvent(
347
+ "bus",
348
+ "Fresh allocation cursor realigned after live-state compaction",
349
+ {
350
+ phase: "follower-binding-reality-cursor",
351
+ lastSlot: deps.topicTargetStore.getBotState().lastSlot,
352
+ },
353
+ );
241
354
  }
355
+ return removed;
242
356
  };
243
357
  }
244
358
 
@@ -338,6 +452,16 @@ export function createTelegramBusFollowerTargetProvisioner(
338
452
  claimPendingTargets: false,
339
453
  });
340
454
  const recordsBeforeProvision = deps.topicTargetStore.list();
455
+ const reconnectRecord = registration.target
456
+ ? recordsBeforeProvision.find((record) => {
457
+ return (
458
+ record.owner?.kind === "manual-follower" &&
459
+ record.instanceId === registration.instanceId &&
460
+ record.target.chatId === registration.target?.chatId &&
461
+ record.target.threadId === registration.target.threadId
462
+ );
463
+ })
464
+ : undefined;
341
465
  const followerProfileKey =
342
466
  registration.profileKey ?? `manual:${registration.instanceId}`;
343
467
  const followerOwner =
@@ -367,7 +491,9 @@ export function createTelegramBusFollowerTargetProvisioner(
367
491
  const runRegistration = async (): Promise<
368
492
  (TelegramTarget & { slot?: string; threadName?: string }) | undefined
369
493
  > => {
370
- let result = await provisionTarget();
494
+ let result = reconnectRecord
495
+ ? { target: reconnectRecord.target, reused: true, record: reconnectRecord }
496
+ : await provisionTarget();
371
497
  deps.setSyncState(
372
498
  Sync.markTelegramSyncSliceFresh(deps.getSyncState(), "target-bindings", {
373
499
  nowMs: getNowMs(),
@@ -943,12 +1069,32 @@ export function createTelegramBusLeaderRuntime<TContext>(
943
1069
  const getNowMs = deps.getNowMs ?? Date.now;
944
1070
  const followerPruneIntervalMs = deps.followerPruneIntervalMs ?? 1000;
945
1071
  const followerStaleAfterMs = deps.followerStaleAfterMs ?? 5000;
1072
+ const followerRecoveryGraceMs = deps.followerRecoveryGraceMs ?? 5000;
946
1073
  let pruneInterval: ReturnType<typeof setInterval> | undefined;
1074
+ let followerRealityTimer: ReturnType<typeof setTimeout> | undefined;
947
1075
  const stopPruning = () => {
948
1076
  if (!pruneInterval) return;
949
1077
  clearInterval(pruneInterval);
950
1078
  pruneInterval = undefined;
951
1079
  };
1080
+ const stopFollowerRealityTimer = () => {
1081
+ if (!followerRealityTimer) return;
1082
+ clearTimeout(followerRealityTimer);
1083
+ followerRealityTimer = undefined;
1084
+ };
1085
+ const scheduleFollowerBindingReality = () => {
1086
+ stopFollowerRealityTimer();
1087
+ if (!deps.reconcileFollowerBindings) return;
1088
+ followerRealityTimer = setTimeout(() => {
1089
+ followerRealityTimer = undefined;
1090
+ void Promise.resolve(deps.reconcileFollowerBindings?.()).catch((error) =>
1091
+ deps.recordRuntimeEvent?.("bus", error, {
1092
+ phase: "follower-binding-reality",
1093
+ }),
1094
+ );
1095
+ }, followerRecoveryGraceMs);
1096
+ followerRealityTimer.unref?.();
1097
+ };
952
1098
  const pruneFollowers = async () => {
953
1099
  const removed = deps.followerRegistry.pruneStale(
954
1100
  getNowMs(),
@@ -997,25 +1143,21 @@ export function createTelegramBusLeaderRuntime<TContext>(
997
1143
  return {
998
1144
  startPolling: async (ctx) => {
999
1145
  await localServer.start();
1000
- try {
1001
- await deps.restoreFollowerRegistry?.();
1002
- } catch (error) {
1003
- deps.recordRuntimeEvent?.("bus", error, {
1004
- phase: "follower-registry-restore",
1005
- });
1006
- }
1146
+ scheduleFollowerBindingReality();
1007
1147
  startPruning();
1008
1148
  try {
1009
1149
  await deps.provisionLeaderTarget?.(ctx);
1010
1150
  await deps.startPolling(ctx);
1011
1151
  } catch (error) {
1012
1152
  stopPruning();
1153
+ stopFollowerRealityTimer();
1013
1154
  await localServer.stop();
1014
1155
  throw error;
1015
1156
  }
1016
1157
  },
1017
1158
  stopPolling: async () => {
1018
1159
  stopPruning();
1160
+ stopFollowerRealityTimer();
1019
1161
  try {
1020
1162
  await deps.stopPolling();
1021
1163
  } finally {
@@ -99,31 +99,55 @@ export function getTelegramBusTransportRetryPolicy(input: {
99
99
  };
100
100
  }
101
101
 
102
+ function normalizeTelegramBusEndpointScope(value: string): string {
103
+ return value.replace(/[^a-zA-Z0-9_.-]/g, "_").slice(0, 80);
104
+ }
105
+
102
106
  export function getTelegramBusLeaderEndpoint(input: {
103
107
  agentDir: string;
104
108
  platform: NodeJS.Platform | string;
109
+ profileName?: string;
105
110
  }): string {
111
+ const profileScope = input.profileName
112
+ ? normalizeTelegramBusEndpointScope(input.profileName)
113
+ : undefined;
106
114
  return input.platform === "win32"
107
- ? getTelegramBusPipePath({ agentDir: input.agentDir, scope: "bus" })
108
- : join(input.agentDir, "tmp", "telegram", "bus.sock");
115
+ ? getTelegramBusPipePath({
116
+ agentDir: input.agentDir,
117
+ scope: profileScope ? `bus-${profileScope}` : "bus",
118
+ })
119
+ : join(
120
+ input.agentDir,
121
+ "tmp",
122
+ "telegram",
123
+ profileScope ? `bus.${profileScope}.sock` : "bus.sock",
124
+ );
109
125
  }
110
126
 
111
127
  export function getTelegramBusFollowerEndpoint(input: {
112
128
  agentDir: string;
113
129
  platform: NodeJS.Platform | string;
114
130
  instanceId: string;
131
+ profileName?: string;
115
132
  }): string {
133
+ const instanceScope = normalizeTelegramBusEndpointScope(input.instanceId);
134
+ const profileScope = input.profileName
135
+ ? normalizeTelegramBusEndpointScope(input.profileName)
136
+ : undefined;
116
137
  return input.platform === "win32"
117
138
  ? getTelegramBusPipePath({
118
139
  agentDir: input.agentDir,
119
- scope: `follower-${input.instanceId}`,
140
+ scope: profileScope
141
+ ? `follower-${profileScope}-${instanceScope}`
142
+ : `follower-${instanceScope}`,
120
143
  })
121
144
  : join(
122
145
  input.agentDir,
123
146
  "tmp",
124
147
  "telegram",
125
148
  "followers",
126
- `${input.instanceId.replace(/[^a-zA-Z0-9_.-]/g, "_")}.sock`,
149
+ ...(profileScope ? [profileScope] : []),
150
+ `${instanceScope}.sock`,
127
151
  );
128
152
  }
129
153