@llblab/pi-telegram 0.20.1 → 0.20.3

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.
@@ -40,6 +40,9 @@ export interface TelegramFollowerSessionHandoff {
40
40
  pid: number;
41
41
  instanceId: string;
42
42
  createdAtMs: number;
43
+ target: TelegramTarget;
44
+ slot?: string;
45
+ threadName?: string;
43
46
  }
44
47
 
45
48
  export function getTelegramFollowerSessionHandoff():
@@ -53,7 +56,10 @@ export function getTelegramFollowerSessionHandoff():
53
56
  if (
54
57
  typeof handoff.pid !== "number" ||
55
58
  typeof handoff.instanceId !== "string" ||
56
- typeof handoff.createdAtMs !== "number"
59
+ typeof handoff.createdAtMs !== "number" ||
60
+ !handoff.target ||
61
+ typeof handoff.target !== "object" ||
62
+ typeof handoff.target.chatId !== "number"
57
63
  ) {
58
64
  return undefined;
59
65
  }
@@ -83,16 +89,19 @@ export interface TelegramBusFollowerRegistrationRuntime<TContext> {
83
89
  registerWithLeader: (
84
90
  ctx: TContext,
85
91
  leader: { busSocketPath?: string; busSecret?: string },
92
+ options?: { target?: TelegramTarget },
86
93
  ) => Promise<boolean>;
87
94
  setContext: (ctx: TContext) => void;
88
95
  stop: () => void;
89
96
  }
90
97
 
91
98
  export interface TelegramBusFollowerSessionReplacementSuspenderDeps {
92
- registrationState: Pick<TelegramBusFollowerRegistrationState, "isRegistered">;
99
+ registrationState: Pick<
100
+ TelegramBusFollowerRegistrationState,
101
+ "isRegistered" | "getTarget" | "getSlot" | "getThreadName"
102
+ >;
93
103
  instanceId: string;
94
104
  suspendPolling: () => Promise<void>;
95
- onFollowerSessionDisconnect?: () => Promise<void> | void;
96
105
  recordRuntimeEvent: (
97
106
  category: string,
98
107
  error: unknown,
@@ -173,6 +182,63 @@ export interface TelegramBusFollowerRegistrationRuntimeDeps<
173
182
  onHeartbeatFailure?: (error: unknown, ctx: TContext) => Promise<void> | void;
174
183
  }
175
184
 
185
+ export function createTelegramManualFollowerProfileKeyResolver(input: {
186
+ getActiveProfileName: () => string | undefined;
187
+ manualFollowerOwnerId: string;
188
+ }): () => string {
189
+ return () =>
190
+ Threads.getTelegramThreadOwnerKey({
191
+ kind: "manual-follower",
192
+ instanceId: input.manualFollowerOwnerId,
193
+ telegramProfile: input.getActiveProfileName(),
194
+ });
195
+ }
196
+
197
+ export type TelegramBusFollowerPromotionHandler<TContext> = (
198
+ ctx: TContext,
199
+ binding: TelegramBusFollowerPromotedBinding,
200
+ ) => Promise<void>;
201
+
202
+ export function createTelegramBusFollowerPromotionHandler<
203
+ TContext extends { cwd: string },
204
+ >(input: {
205
+ topicTargetStore: Threads.TelegramTopicTargetStore;
206
+ instanceId: string;
207
+ getActiveProfileName: () => string | undefined;
208
+ startLeader: (ctx: TContext) => Promise<unknown> | unknown;
209
+ recordRuntimeEvent: (
210
+ category: string,
211
+ error: unknown,
212
+ details?: Record<string, unknown>,
213
+ ) => void;
214
+ }): TelegramBusFollowerPromotionHandler<TContext> {
215
+ return async (ctx, binding) => {
216
+ const promotedRecord = await Threads.promoteTelegramFollowerBindingToLeader({
217
+ store: input.topicTargetStore,
218
+ instanceId: input.instanceId,
219
+ cwd: ctx.cwd,
220
+ telegramProfile: input.getActiveProfileName(),
221
+ target: binding.target,
222
+ slot: binding.slot,
223
+ threadName: binding.threadName,
224
+ });
225
+ if (promotedRecord) {
226
+ input.recordRuntimeEvent(
227
+ "bus",
228
+ "Follower thread binding promoted to leader",
229
+ {
230
+ phase: "follower-promoted-binding",
231
+ chatId: promotedRecord.target.chatId,
232
+ threadId: promotedRecord.target.threadId,
233
+ slot: promotedRecord.slot,
234
+ threadName: promotedRecord.threadName,
235
+ },
236
+ );
237
+ }
238
+ await input.startLeader(ctx);
239
+ };
240
+ }
241
+
176
242
  export interface TelegramBusFollowerTargetReplacementHandlerDeps<TContext> {
177
243
  topicTargetStore: Pick<
178
244
  Threads.TelegramTopicTargetStore,
@@ -183,7 +249,7 @@ export interface TelegramBusFollowerTargetReplacementHandlerDeps<TContext> {
183
249
  "getTarget" | "setRegistered"
184
250
  >;
185
251
  instanceId: string;
186
- manualFollowerProfileKey: string;
252
+ getManualFollowerProfileKey: () => string;
187
253
  manualFollowerOwnerId: string;
188
254
  getSyncState: () => Sync.TelegramSyncState;
189
255
  setSyncState: (state: Sync.TelegramSyncState) => void;
@@ -276,6 +342,71 @@ export interface TelegramBusForwardedUpdateReceiverRuntimeDeps<
276
342
  ) => void;
277
343
  }
278
344
 
345
+ export interface TelegramBusFollowerRuntimeAssemblyDeps<
346
+ TContext extends { cwd?: string },
347
+ TReactionUpdate,
348
+ TCallbackQuery,
349
+ TMessage = unknown,
350
+ > {
351
+ receiver: Omit<
352
+ TelegramBusForwardedUpdateReceiverRuntimeDeps<
353
+ TContext,
354
+ TReactionUpdate,
355
+ TCallbackQuery,
356
+ TMessage
357
+ >,
358
+ "handleReplaceTarget"
359
+ >;
360
+ targetReplacement: TelegramBusFollowerTargetReplacementHandlerDeps<TContext>;
361
+ recovery: Omit<
362
+ TelegramBusFollowerHeartbeatRecoveryHandlerDeps<TContext>,
363
+ "getRegistrationRuntime"
364
+ >;
365
+ registration: Omit<
366
+ TelegramBusFollowerRegistrationRuntimeDeps<TContext>,
367
+ "startReceiving" | "stopReceiving" | "onHeartbeatFailure"
368
+ >;
369
+ }
370
+
371
+ export interface TelegramBusFollowerRuntimeAssembly<TContext> {
372
+ receiver: TelegramBusForwardedUpdateReceiverRuntime;
373
+ registration: TelegramBusFollowerRegistrationRuntime<TContext>;
374
+ }
375
+
376
+ export function createTelegramBusFollowerRuntimeAssembly<
377
+ TContext extends { cwd?: string },
378
+ TReactionUpdate,
379
+ TCallbackQuery,
380
+ TMessage = unknown,
381
+ >(
382
+ deps: TelegramBusFollowerRuntimeAssemblyDeps<
383
+ TContext,
384
+ TReactionUpdate,
385
+ TCallbackQuery,
386
+ TMessage
387
+ >,
388
+ ): TelegramBusFollowerRuntimeAssembly<TContext> {
389
+ const receiver = createTelegramBusForwardedUpdateReceiverRuntime({
390
+ ...deps.receiver,
391
+ handleReplaceTarget:
392
+ createTelegramBusFollowerTargetReplacementHandler(
393
+ deps.targetReplacement,
394
+ ),
395
+ });
396
+ let registration: TelegramBusFollowerRegistrationRuntime<TContext>;
397
+ const recovery = createTelegramBusFollowerHeartbeatRecoveryHandler({
398
+ ...deps.recovery,
399
+ getRegistrationRuntime: () => registration,
400
+ });
401
+ registration = createTelegramBusFollowerRegistrationRuntime({
402
+ ...deps.registration,
403
+ startReceiving: receiver.start,
404
+ stopReceiving: receiver.stop,
405
+ onHeartbeatFailure: recovery,
406
+ });
407
+ return { receiver, registration };
408
+ }
409
+
279
410
  export function createTelegramBusFollowerTargetReplacementHandler<TContext>(
280
411
  deps: TelegramBusFollowerTargetReplacementHandlerDeps<TContext>,
281
412
  ): NonNullable<
@@ -308,7 +439,7 @@ export function createTelegramBusFollowerTargetReplacementHandler<TContext>(
308
439
  );
309
440
  }
310
441
  const profileKey =
311
- currentRecord?.profileKey ?? deps.manualFollowerProfileKey;
442
+ currentRecord?.profileKey ?? deps.getManualFollowerProfileKey();
312
443
  deps.topicTargetStore.upsert({
313
444
  profileKey,
314
445
  owner: {
@@ -392,40 +523,30 @@ function isTelegramStaleContextError(error: unknown): boolean {
392
523
  );
393
524
  }
394
525
 
395
- export async function markTelegramFollowerThreadStaleForSessionDisconnect(input: {
396
- topicTargetStore: Threads.TelegramTopicTargetStore;
397
- target: TelegramTarget;
398
- }): Promise<void> {
399
- await input.topicTargetStore.load();
400
- const record = input.topicTargetStore.list().find(
401
- (candidate) =>
402
- candidate.target.chatId === input.target.chatId &&
403
- candidate.target.threadId === input.target.threadId,
404
- );
405
- const marked = input.topicTargetStore.markStaleByTarget(
406
- input.target,
407
- "unknown",
408
- "Follower session reloaded before reconnect.",
409
- );
410
- const forgotIdentity = record?.profileKey
411
- ? input.topicTargetStore.forgetIdentityByProfileKey(record.profileKey)
412
- : false;
413
- if (marked || forgotIdentity) await input.topicTargetStore.persist();
414
- }
415
-
416
526
  export function createTelegramBusFollowerSessionReplacementSuspender(
417
527
  deps: TelegramBusFollowerSessionReplacementSuspenderDeps,
418
528
  ): () => Promise<void> {
529
+ const getNowMs = deps.getNowMs ?? Date.now;
530
+ const getPid = deps.getPid ?? (() => process.pid);
419
531
  return async () => {
420
- if (deps.registrationState.isRegistered()) {
421
- setTelegramFollowerSessionHandoff(undefined);
422
- await deps.onFollowerSessionDisconnect?.();
532
+ const target = deps.registrationState.getTarget();
533
+ if (deps.registrationState.isRegistered() && target) {
534
+ setTelegramFollowerSessionHandoff({
535
+ pid: getPid(),
536
+ instanceId: deps.instanceId,
537
+ createdAtMs: getNowMs(),
538
+ target,
539
+ slot: deps.registrationState.getSlot(),
540
+ threadName: deps.registrationState.getThreadName(),
541
+ });
423
542
  deps.recordRuntimeEvent(
424
543
  "bus",
425
- "Telegram follower registration stopped for session replacement",
544
+ "Telegram follower registration suspended for session replacement",
426
545
  {
427
- phase: "follower-session-disconnect",
546
+ phase: "follower-session-handoff",
428
547
  instanceId: deps.instanceId,
548
+ chatId: target.chatId,
549
+ threadId: target.threadId,
429
550
  },
430
551
  );
431
552
  }
@@ -446,6 +567,7 @@ export function createTelegramBusFollowerSessionRefreshHook<TContext>(
446
567
  const restored = await deps.registrationRuntime.registerWithLeader(
447
568
  ctx,
448
569
  lockState.lock,
570
+ { target: handoff.target },
449
571
  );
450
572
  if (restored) {
451
573
  setTelegramFollowerSessionHandoff(undefined);
@@ -654,6 +776,7 @@ export function createTelegramBusFollowerRegistrationRuntime<
654
776
  let activeLeaderSocketPath: string | undefined;
655
777
  let activeAuthSecret: string | undefined;
656
778
  let activeContext: TContext | undefined;
779
+ let lastKnownTarget: TelegramTarget | undefined;
657
780
  const stopHeartbeat = () => {
658
781
  if (!heartbeatInterval) return;
659
782
  clearInterval(heartbeatInterval);
@@ -664,6 +787,7 @@ export function createTelegramBusFollowerRegistrationRuntime<
664
787
  activeAuthSecret = undefined;
665
788
  deps.setActiveAuthSecret?.(undefined);
666
789
  deps.registrationState?.setRegistered(false);
790
+ lastKnownTarget = undefined;
667
791
  activeContext = undefined;
668
792
  void deps.stopReceiving?.();
669
793
  };
@@ -704,7 +828,7 @@ export function createTelegramBusFollowerRegistrationRuntime<
704
828
  heartbeatInterval.unref?.();
705
829
  };
706
830
  return {
707
- registerWithLeader: async (ctx, leader) => {
831
+ registerWithLeader: async (ctx, leader, options) => {
708
832
  const leaderSocketPath =
709
833
  leader.busSocketPath ??
710
834
  deps.getLeaderSocketPath?.() ??
@@ -729,6 +853,10 @@ export function createTelegramBusFollowerRegistrationRuntime<
729
853
  (ctx.cwd ? basename(ctx.cwd) : undefined),
730
854
  cwd: ctx.cwd,
731
855
  pid: getPid(),
856
+ target:
857
+ options?.target ??
858
+ deps.registrationState?.getTarget() ??
859
+ lastKnownTarget,
732
860
  busSocketPath:
733
861
  deps.getFollowerBusSocketPath?.() ?? deps.followerBusSocketPath,
734
862
  connectedAtMs: getNowMs(),
@@ -783,6 +911,7 @@ export function createTelegramBusFollowerRegistrationRuntime<
783
911
  registrationResult.target,
784
912
  registrationResult,
785
913
  );
914
+ lastKnownTarget = registrationResult.target;
786
915
  activeLeaderSocketPath = leaderSocketPath;
787
916
  activeContext = ctx;
788
917
  await sendHeartbeat();
package/lib/bus-leader.ts CHANGED
@@ -154,15 +154,109 @@ export interface TelegramBusLeaderApiProxyDeps {
154
154
  ) => Promise<unknown> | unknown;
155
155
  }
156
156
 
157
- export interface TelegramBusFollowerRegistryRestoreDeps {
158
- topicTargetStore: Pick<Threads.TelegramTopicTargetStore, "load" | "list">;
159
- 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 const TELEGRAM_BUS_RECENT_FOLLOWER_BINDING_GRACE_MS = 10_000;
240
+
241
+ export interface TelegramBusFollowerBindingRealityDeps {
242
+ topicTargetStore: Pick<
243
+ Threads.TelegramTopicTargetStore,
244
+ | "load"
245
+ | "list"
246
+ | "markOfflineByInstanceId"
247
+ | "forgetIdentityByProfileKey"
248
+ | "getBotState"
249
+ | "persist"
250
+ | "setBotState"
251
+ >;
252
+ followerRegistry: Pick<TelegramBusFollowerRegistry, "list">;
253
+ getNowMs?: () => number;
254
+ recentBindingGraceMs?: number;
160
255
  recordRuntimeEvent: (
161
256
  category: string,
162
257
  error: unknown,
163
258
  details?: Record<string, unknown>,
164
259
  ) => void;
165
- getNowMs?: () => number;
166
260
  }
167
261
 
168
262
  export interface TelegramBusFollowerMessageOwnershipRecord {
@@ -192,11 +286,12 @@ export interface TelegramBusLeaderRuntimeDeps<TContext> {
192
286
  provisionFollowerTarget?: (
193
287
  registration: TelegramBusInstanceRegistration,
194
288
  ) => Promise<TelegramTarget | undefined> | TelegramTarget | undefined;
195
- restoreFollowerRegistry?: () => Promise<void> | void;
289
+ reconcileFollowerBindings?: () => Promise<unknown> | unknown;
196
290
  provisionLeaderTarget?: (ctx: TContext) => Promise<void> | void;
197
291
  getNowMs?: () => number;
198
292
  followerPruneIntervalMs?: number;
199
293
  followerStaleAfterMs?: number;
294
+ followerRecoveryGraceMs?: number;
200
295
  onFollowerPruned?: (
201
296
  follower: TelegramBusFollowerView,
202
297
  ) => Promise<void> | void;
@@ -207,39 +302,67 @@ export interface TelegramBusLeaderRuntimeDeps<TContext> {
207
302
  ) => void;
208
303
  }
209
304
 
210
- export function createTelegramBusFollowerRegistryRestoreHandler(
211
- deps: TelegramBusFollowerRegistryRestoreDeps,
212
- ): () => Promise<void> {
305
+ export function createTelegramBusFollowerBindingRealityReconciler(
306
+ deps: TelegramBusFollowerBindingRealityDeps,
307
+ ): () => Promise<number> {
308
+ const getNowMs = deps.getNowMs ?? Date.now;
309
+ const recentBindingGraceMs =
310
+ deps.recentBindingGraceMs ??
311
+ TELEGRAM_BUS_RECENT_FOLLOWER_BINDING_GRACE_MS;
213
312
  return async () => {
214
313
  await deps.topicTargetStore.load();
215
- const nowMs = (deps.getNowMs ?? Date.now)();
216
- let restored = 0;
217
- for (const record of deps.topicTargetStore.list()) {
218
- if (record.owner?.kind !== "manual-follower") continue;
219
- if (!record.instanceId) continue;
220
- if (
221
- record.status !== "active" &&
222
- record.status !== "starting" &&
223
- record.status !== "pending"
224
- ) {
225
- continue;
226
- }
227
- deps.followerRegistry.register({
228
- instanceId: record.instanceId,
229
- profileKey: record.profileKey,
230
- threadName: record.threadName,
231
- target: record.target,
232
- busSocketPath: getTelegramBusFollowerSocketPath(record.instanceId),
233
- connectedAtMs: nowMs,
234
- });
235
- restored += 1;
314
+ const nowMs = getNowMs();
315
+ const liveInstanceIds = new Set(
316
+ deps.followerRegistry.list().map((follower) => follower.instanceId),
317
+ );
318
+ const staleRecords = deps.topicTargetStore.list().filter((record) => {
319
+ return (
320
+ record.owner?.kind === "manual-follower" &&
321
+ !!record.instanceId &&
322
+ !liveInstanceIds.has(record.instanceId) &&
323
+ nowMs - record.updatedAtMs > recentBindingGraceMs
324
+ );
325
+ });
326
+ const staleInstanceIds = new Set(
327
+ staleRecords.flatMap((record) =>
328
+ record.instanceId ? [record.instanceId] : [],
329
+ ),
330
+ );
331
+ let removed = 0;
332
+ for (const instanceId of staleInstanceIds) {
333
+ removed += deps.topicTargetStore.markOfflineByInstanceId(instanceId);
236
334
  }
237
- if (restored > 0) {
238
- deps.recordRuntimeEvent("bus", "Telegram follower registry restored", {
239
- phase: "follower-registry-restore",
240
- followers: restored,
241
- });
335
+ for (const record of staleRecords) {
336
+ const profileKey =
337
+ record.profileKey ??
338
+ (record.owner
339
+ ? Threads.getTelegramThreadOwnerKey(record.owner)
340
+ : undefined);
341
+ if (profileKey) deps.topicTargetStore.forgetIdentityByProfileKey(profileKey);
342
+ }
343
+ if (removed === 0) return 0;
344
+ const cursorRealigned =
345
+ Threads.reconcileTelegramFreshAllocationCursor(deps.topicTargetStore);
346
+ await deps.topicTargetStore.persist();
347
+ deps.recordRuntimeEvent(
348
+ "bus",
349
+ "Historical follower bindings removed from live state",
350
+ {
351
+ phase: "follower-binding-reality",
352
+ removed,
353
+ },
354
+ );
355
+ if (cursorRealigned) {
356
+ deps.recordRuntimeEvent(
357
+ "bus",
358
+ "Fresh allocation cursor realigned after live-state compaction",
359
+ {
360
+ phase: "follower-binding-reality-cursor",
361
+ lastSlot: deps.topicTargetStore.getBotState().lastSlot,
362
+ },
363
+ );
242
364
  }
365
+ return removed;
243
366
  };
244
367
  }
245
368
 
@@ -341,6 +464,17 @@ export function createTelegramBusFollowerTargetProvisioner(
341
464
  const recordsBeforeProvision = deps.topicTargetStore.list();
342
465
  const followerProfileKey =
343
466
  registration.profileKey ?? `manual:${registration.instanceId}`;
467
+ const reconnectRecord = registration.target
468
+ ? recordsBeforeProvision.find((record) => {
469
+ return (
470
+ record.owner?.kind === "manual-follower" &&
471
+ (record.instanceId === registration.instanceId ||
472
+ record.profileKey === followerProfileKey) &&
473
+ record.target.chatId === registration.target?.chatId &&
474
+ record.target.threadId === registration.target.threadId
475
+ );
476
+ })
477
+ : undefined;
344
478
  const followerOwner =
345
479
  Threads.getTelegramThreadOwnerFromProfileKey(followerProfileKey);
346
480
  const registrationKey = followerProfileKey || registration.instanceId;
@@ -368,7 +502,28 @@ export function createTelegramBusFollowerTargetProvisioner(
368
502
  const runRegistration = async (): Promise<
369
503
  (TelegramTarget & { slot?: string; threadName?: string }) | undefined
370
504
  > => {
371
- let result = await provisionTarget();
505
+ let result = reconnectRecord
506
+ ? { target: reconnectRecord.target, reused: true, record: reconnectRecord }
507
+ : await provisionTarget();
508
+ if (reconnectRecord) {
509
+ const nowMs = getNowMs();
510
+ const transferredRecord = deps.topicTargetStore.upsert({
511
+ ...reconnectRecord,
512
+ instanceId: registration.instanceId,
513
+ updatedAtMs: nowMs,
514
+ lastSyncObservedAtMs: nowMs,
515
+ lastReconcileAction:
516
+ reconnectRecord.instanceId === registration.instanceId
517
+ ? "follower-register-reuse"
518
+ : "follower-session-handoff",
519
+ });
520
+ await deps.topicTargetStore.persist();
521
+ result = {
522
+ target: transferredRecord.target,
523
+ reused: true,
524
+ record: transferredRecord,
525
+ };
526
+ }
372
527
  deps.setSyncState(
373
528
  Sync.markTelegramSyncSliceFresh(deps.getSyncState(), "target-bindings", {
374
529
  nowMs: getNowMs(),
@@ -944,12 +1099,32 @@ export function createTelegramBusLeaderRuntime<TContext>(
944
1099
  const getNowMs = deps.getNowMs ?? Date.now;
945
1100
  const followerPruneIntervalMs = deps.followerPruneIntervalMs ?? 1000;
946
1101
  const followerStaleAfterMs = deps.followerStaleAfterMs ?? 5000;
1102
+ const followerRecoveryGraceMs = deps.followerRecoveryGraceMs ?? 5000;
947
1103
  let pruneInterval: ReturnType<typeof setInterval> | undefined;
1104
+ let followerRealityTimer: ReturnType<typeof setTimeout> | undefined;
948
1105
  const stopPruning = () => {
949
1106
  if (!pruneInterval) return;
950
1107
  clearInterval(pruneInterval);
951
1108
  pruneInterval = undefined;
952
1109
  };
1110
+ const stopFollowerRealityTimer = () => {
1111
+ if (!followerRealityTimer) return;
1112
+ clearTimeout(followerRealityTimer);
1113
+ followerRealityTimer = undefined;
1114
+ };
1115
+ const scheduleFollowerBindingReality = () => {
1116
+ stopFollowerRealityTimer();
1117
+ if (!deps.reconcileFollowerBindings) return;
1118
+ followerRealityTimer = setTimeout(() => {
1119
+ followerRealityTimer = undefined;
1120
+ void Promise.resolve(deps.reconcileFollowerBindings?.()).catch((error) =>
1121
+ deps.recordRuntimeEvent?.("bus", error, {
1122
+ phase: "follower-binding-reality",
1123
+ }),
1124
+ );
1125
+ }, followerRecoveryGraceMs);
1126
+ followerRealityTimer.unref?.();
1127
+ };
953
1128
  const pruneFollowers = async () => {
954
1129
  const removed = deps.followerRegistry.pruneStale(
955
1130
  getNowMs(),
@@ -998,25 +1173,21 @@ export function createTelegramBusLeaderRuntime<TContext>(
998
1173
  return {
999
1174
  startPolling: async (ctx) => {
1000
1175
  await localServer.start();
1001
- try {
1002
- await deps.restoreFollowerRegistry?.();
1003
- } catch (error) {
1004
- deps.recordRuntimeEvent?.("bus", error, {
1005
- phase: "follower-registry-restore",
1006
- });
1007
- }
1176
+ scheduleFollowerBindingReality();
1008
1177
  startPruning();
1009
1178
  try {
1010
1179
  await deps.provisionLeaderTarget?.(ctx);
1011
1180
  await deps.startPolling(ctx);
1012
1181
  } catch (error) {
1013
1182
  stopPruning();
1183
+ stopFollowerRealityTimer();
1014
1184
  await localServer.stop();
1015
1185
  throw error;
1016
1186
  }
1017
1187
  },
1018
1188
  stopPolling: async () => {
1019
1189
  stopPruning();
1190
+ stopFollowerRealityTimer();
1020
1191
  try {
1021
1192
  await deps.stopPolling();
1022
1193
  } finally {
package/lib/bus.ts CHANGED
@@ -34,6 +34,40 @@ import { resolveAgentDir } from "./paths.ts";
34
34
 
35
35
  export type TelegramBusRole = "leader" | "follower";
36
36
 
37
+ export interface TelegramBusProcessRuntime {
38
+ instanceId: string;
39
+ manualFollowerOwnerId: string;
40
+ getLeaderSocketPath: () => string;
41
+ getFollowerSocketPath: () => string;
42
+ }
43
+
44
+ export function createTelegramBusProcessRuntime(input: {
45
+ getActiveProfileName: () => string | undefined;
46
+ pid: number;
47
+ parentPid: number;
48
+ createdAtMs: number;
49
+ }): TelegramBusProcessRuntime {
50
+ const instanceId = `${input.pid}:${input.createdAtMs}`;
51
+ const manualFollowerOwnerId = String(input.parentPid || input.pid);
52
+ return {
53
+ instanceId,
54
+ manualFollowerOwnerId,
55
+ getLeaderSocketPath: () =>
56
+ getTelegramBusSocketPath(
57
+ undefined,
58
+ undefined,
59
+ input.getActiveProfileName(),
60
+ ),
61
+ getFollowerSocketPath: () =>
62
+ getTelegramBusFollowerSocketPath(
63
+ instanceId,
64
+ undefined,
65
+ undefined,
66
+ input.getActiveProfileName(),
67
+ ),
68
+ };
69
+ }
70
+
37
71
  export function createTelegramBusAuthSecret(): string {
38
72
  return randomBytes(32).toString("base64url");
39
73
  }
package/lib/paths.ts CHANGED
@@ -71,6 +71,17 @@ export function resolveTelegramProfileTempFilePath(
71
71
  );
72
72
  }
73
73
 
74
+ export function getTelegramDiagnosticsDisplayPaths(profileName?: string): {
75
+ state: string;
76
+ logs: string;
77
+ } {
78
+ const suffix = getTelegramProfilePathSuffix(profileName);
79
+ return {
80
+ state: `~/.pi/agent/tmp/telegram/state${suffix}.json`,
81
+ logs: `~/.pi/agent/tmp/telegram/logs${suffix}.jsonl`,
82
+ };
83
+ }
84
+
74
85
  /** Runtime event log (<agentDir>/tmp/telegram/logs.jsonl). */
75
86
  export function resolveTelegramRuntimeLogPath(): string {
76
87
  return resolveTelegramProfileTempFilePath("logs", "jsonl");