@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.
- package/AGENTS.md +5 -3
- package/BACKLOG.md +1 -1
- package/CHANGELOG.md +24 -0
- package/README.md +3 -2
- package/docs/architecture.md +5 -5
- package/docs/multi-instance-bus.md +22 -21
- package/index.ts +133 -219
- package/lib/bindings.ts +55 -14
- package/lib/bus-follower.ts +161 -32
- package/lib/bus-leader.ts +213 -42
- package/lib/bus.ts +34 -0
- package/lib/paths.ts +11 -0
- package/lib/prompts.ts +19 -7
- package/lib/setup.ts +27 -10
- package/lib/status.ts +9 -2
- package/lib/threads.ts +64 -26
- package/lib/turns.ts +0 -2
- package/package.json +1 -1
package/lib/bus-follower.ts
CHANGED
|
@@ -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<
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
|
544
|
+
"Telegram follower registration suspended for session replacement",
|
|
426
545
|
{
|
|
427
|
-
phase: "follower-session-
|
|
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
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
|
211
|
-
deps:
|
|
212
|
-
): () => Promise<
|
|
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 =
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
record.
|
|
222
|
-
record.
|
|
223
|
-
record.
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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 =
|
|
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
|
-
|
|
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");
|