@llblab/pi-telegram 0.20.1 → 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.
package/lib/bindings.ts CHANGED
@@ -94,7 +94,9 @@ export function registerTelegramCommandsAndTools({
94
94
  sendMarkdownReply(chatId, undefined, markdown, options),
95
95
  recordRuntimeEvent,
96
96
  });
97
- Prompts.registerTelegramHelpTool(pi);
97
+ Prompts.registerTelegramHelpTool(pi, {
98
+ getActiveProfileName: configStore.getActiveProfileName,
99
+ });
98
100
  Commands.registerTelegramBridgeCommands(pi, {
99
101
  promptForConfig: async (ctx, profileName) => {
100
102
  const nextProfileName = profileName ?? undefined;
@@ -103,36 +105,58 @@ export function registerTelegramCommandsAndTools({
103
105
  return;
104
106
  }
105
107
  const previousProfileName = configStore.getActiveProfileName();
106
- if (previousProfileName !== nextProfileName) {
107
- await (stopPolling ?? lockedPollingRuntime.stop)();
108
- }
108
+ let setupConfigStore = configStore;
109
+ let persistSetupConfig = persistConfig;
109
110
  if (!profileName) {
111
+ if (previousProfileName !== nextProfileName) {
112
+ await (stopPolling ?? lockedPollingRuntime.stop)();
113
+ }
110
114
  configStore.activateProfile(undefined);
111
115
  } else {
112
116
  const storedConfig = configStore.getStoredConfig();
113
- if (!storedConfig.profiles?.[profileName]) {
117
+ setupConfigStore = Config.createTelegramConfigStore({
118
+ initialConfig: {
119
+ ...storedConfig,
120
+ profiles: {
121
+ ...(storedConfig.profiles ?? {}),
122
+ [profileName]: storedConfig.profiles?.[profileName] ?? {
123
+ botToken: "",
124
+ },
125
+ },
126
+ },
127
+ });
128
+ setupConfigStore.activateProfile(profileName);
129
+ persistSetupConfig = async () => {
130
+ if (previousProfileName !== profileName) {
131
+ await (stopPolling ?? lockedPollingRuntime.stop)();
132
+ }
133
+ configStore.activateProfile(undefined);
114
134
  configStore.set({
115
135
  ...storedConfig,
116
136
  profiles: {
117
137
  ...(storedConfig.profiles ?? {}),
118
- [profileName]: { botToken: "" },
138
+ [profileName]: storedConfig.profiles?.[profileName] ?? {
139
+ botToken: "",
140
+ },
119
141
  },
120
142
  });
121
- }
122
- configStore.activateProfile(profileName);
143
+ configStore.activateProfile(profileName);
144
+ configStore.set(setupConfigStore.get());
145
+ await persistConfig();
146
+ };
123
147
  }
124
148
  const runSetup = Setup.createTelegramSetupPromptRuntime({
125
- getConfig: configStore.get,
126
- setConfig: configStore.set,
149
+ getConfig: setupConfigStore.get,
150
+ setConfig: setupConfigStore.set,
127
151
  setupGuard: setup,
128
152
  getMe: TelegramApi.fetchTelegramBotIdentity,
129
- persistConfig,
153
+ persistConfig: persistSetupConfig,
130
154
  startPolling: lockedPollingRuntime.start,
131
155
  updateStatus,
132
156
  recordRuntimeEvent,
133
157
  });
134
- await runSetup(ctx);
135
- if (profileName) {
158
+ const completion = await runSetup(ctx);
159
+ if (profileName && completion.status === "success") {
136
160
  ctx.ui.notify(`Profile "${profileName}" saved and connected.`, "info");
137
161
  }
138
162
  },
@@ -155,6 +179,8 @@ export function registerTelegramCommandsAndTools({
155
179
  activateProfileConfig: async (_ctx, profileName) => {
156
180
  await configStore.load();
157
181
  if (!Config.isValidTelegramProfileName(profileName)) return false;
182
+ const storedConfig = configStore.getStoredConfig();
183
+ if (!storedConfig.profiles?.[profileName]) return false;
158
184
  const previousProfileName = configStore.getActiveProfileName();
159
185
  if (previousProfileName !== profileName) {
160
186
  await (stopPolling ?? lockedPollingRuntime.stop)();
@@ -173,6 +173,63 @@ export interface TelegramBusFollowerRegistrationRuntimeDeps<
173
173
  onHeartbeatFailure?: (error: unknown, ctx: TContext) => Promise<void> | void;
174
174
  }
175
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
+
176
233
  export interface TelegramBusFollowerTargetReplacementHandlerDeps<TContext> {
177
234
  topicTargetStore: Pick<
178
235
  Threads.TelegramTopicTargetStore,
@@ -183,7 +240,7 @@ export interface TelegramBusFollowerTargetReplacementHandlerDeps<TContext> {
183
240
  "getTarget" | "setRegistered"
184
241
  >;
185
242
  instanceId: string;
186
- manualFollowerProfileKey: string;
243
+ getManualFollowerProfileKey: () => string;
187
244
  manualFollowerOwnerId: string;
188
245
  getSyncState: () => Sync.TelegramSyncState;
189
246
  setSyncState: (state: Sync.TelegramSyncState) => void;
@@ -276,6 +333,71 @@ export interface TelegramBusForwardedUpdateReceiverRuntimeDeps<
276
333
  ) => void;
277
334
  }
278
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
+
279
401
  export function createTelegramBusFollowerTargetReplacementHandler<TContext>(
280
402
  deps: TelegramBusFollowerTargetReplacementHandlerDeps<TContext>,
281
403
  ): NonNullable<
@@ -308,7 +430,7 @@ export function createTelegramBusFollowerTargetReplacementHandler<TContext>(
308
430
  );
309
431
  }
310
432
  const profileKey =
311
- currentRecord?.profileKey ?? deps.manualFollowerProfileKey;
433
+ currentRecord?.profileKey ?? deps.getManualFollowerProfileKey();
312
434
  deps.topicTargetStore.upsert({
313
435
  profileKey,
314
436
  owner: {
@@ -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
  };
@@ -729,6 +853,7 @@ export function createTelegramBusFollowerRegistrationRuntime<
729
853
  (ctx.cwd ? basename(ctx.cwd) : undefined),
730
854
  cwd: ctx.cwd,
731
855
  pid: getPid(),
856
+ target: deps.registrationState?.getTarget() ?? lastKnownTarget,
732
857
  busSocketPath:
733
858
  deps.getFollowerBusSocketPath?.() ?? deps.followerBusSocketPath,
734
859
  connectedAtMs: getNowMs(),
@@ -783,6 +908,7 @@ export function createTelegramBusFollowerRegistrationRuntime<
783
908
  registrationResult.target,
784
909
  registrationResult,
785
910
  );
911
+ lastKnownTarget = registrationResult.target;
786
912
  activeLeaderSocketPath = leaderSocketPath;
787
913
  activeContext = ctx;
788
914
  await sendHeartbeat();
package/lib/bus-leader.ts CHANGED
@@ -154,15 +154,105 @@ 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 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">;
160
251
  recordRuntimeEvent: (
161
252
  category: string,
162
253
  error: unknown,
163
254
  details?: Record<string, unknown>,
164
255
  ) => void;
165
- getNowMs?: () => number;
166
256
  }
167
257
 
168
258
  export interface TelegramBusFollowerMessageOwnershipRecord {
@@ -192,11 +282,12 @@ export interface TelegramBusLeaderRuntimeDeps<TContext> {
192
282
  provisionFollowerTarget?: (
193
283
  registration: TelegramBusInstanceRegistration,
194
284
  ) => Promise<TelegramTarget | undefined> | TelegramTarget | undefined;
195
- restoreFollowerRegistry?: () => Promise<void> | void;
285
+ reconcileFollowerBindings?: () => Promise<unknown> | unknown;
196
286
  provisionLeaderTarget?: (ctx: TContext) => Promise<void> | void;
197
287
  getNowMs?: () => number;
198
288
  followerPruneIntervalMs?: number;
199
289
  followerStaleAfterMs?: number;
290
+ followerRecoveryGraceMs?: number;
200
291
  onFollowerPruned?: (
201
292
  follower: TelegramBusFollowerView,
202
293
  ) => Promise<void> | void;
@@ -207,39 +298,61 @@ export interface TelegramBusLeaderRuntimeDeps<TContext> {
207
298
  ) => void;
208
299
  }
209
300
 
210
- export function createTelegramBusFollowerRegistryRestoreHandler(
211
- deps: TelegramBusFollowerRegistryRestoreDeps,
212
- ): () => Promise<void> {
301
+ export function createTelegramBusFollowerBindingRealityReconciler(
302
+ deps: TelegramBusFollowerBindingRealityDeps,
303
+ ): () => Promise<number> {
213
304
  return async () => {
214
305
  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;
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);
236
324
  }
237
- if (restored > 0) {
238
- deps.recordRuntimeEvent("bus", "Telegram follower registry restored", {
239
- phase: "follower-registry-restore",
240
- followers: restored,
241
- });
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
+ );
242
354
  }
355
+ return removed;
243
356
  };
244
357
  }
245
358
 
@@ -339,6 +452,16 @@ export function createTelegramBusFollowerTargetProvisioner(
339
452
  claimPendingTargets: false,
340
453
  });
341
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;
342
465
  const followerProfileKey =
343
466
  registration.profileKey ?? `manual:${registration.instanceId}`;
344
467
  const followerOwner =
@@ -368,7 +491,9 @@ export function createTelegramBusFollowerTargetProvisioner(
368
491
  const runRegistration = async (): Promise<
369
492
  (TelegramTarget & { slot?: string; threadName?: string }) | undefined
370
493
  > => {
371
- let result = await provisionTarget();
494
+ let result = reconnectRecord
495
+ ? { target: reconnectRecord.target, reused: true, record: reconnectRecord }
496
+ : await provisionTarget();
372
497
  deps.setSyncState(
373
498
  Sync.markTelegramSyncSliceFresh(deps.getSyncState(), "target-bindings", {
374
499
  nowMs: getNowMs(),
@@ -944,12 +1069,32 @@ export function createTelegramBusLeaderRuntime<TContext>(
944
1069
  const getNowMs = deps.getNowMs ?? Date.now;
945
1070
  const followerPruneIntervalMs = deps.followerPruneIntervalMs ?? 1000;
946
1071
  const followerStaleAfterMs = deps.followerStaleAfterMs ?? 5000;
1072
+ const followerRecoveryGraceMs = deps.followerRecoveryGraceMs ?? 5000;
947
1073
  let pruneInterval: ReturnType<typeof setInterval> | undefined;
1074
+ let followerRealityTimer: ReturnType<typeof setTimeout> | undefined;
948
1075
  const stopPruning = () => {
949
1076
  if (!pruneInterval) return;
950
1077
  clearInterval(pruneInterval);
951
1078
  pruneInterval = undefined;
952
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
+ };
953
1098
  const pruneFollowers = async () => {
954
1099
  const removed = deps.followerRegistry.pruneStale(
955
1100
  getNowMs(),
@@ -998,25 +1143,21 @@ export function createTelegramBusLeaderRuntime<TContext>(
998
1143
  return {
999
1144
  startPolling: async (ctx) => {
1000
1145
  await localServer.start();
1001
- try {
1002
- await deps.restoreFollowerRegistry?.();
1003
- } catch (error) {
1004
- deps.recordRuntimeEvent?.("bus", error, {
1005
- phase: "follower-registry-restore",
1006
- });
1007
- }
1146
+ scheduleFollowerBindingReality();
1008
1147
  startPruning();
1009
1148
  try {
1010
1149
  await deps.provisionLeaderTarget?.(ctx);
1011
1150
  await deps.startPolling(ctx);
1012
1151
  } catch (error) {
1013
1152
  stopPruning();
1153
+ stopFollowerRealityTimer();
1014
1154
  await localServer.stop();
1015
1155
  throw error;
1016
1156
  }
1017
1157
  },
1018
1158
  stopPolling: async () => {
1019
1159
  stopPruning();
1160
+ stopFollowerRealityTimer();
1020
1161
  try {
1021
1162
  await deps.stopPolling();
1022
1163
  } 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");