@llblab/pi-telegram 0.24.3 → 0.24.5
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 +6 -5
- package/CHANGELOG.md +11 -0
- package/README.md +8 -4
- package/docs/architecture.md +8 -7
- package/docs/multi-instance-bus.md +7 -5
- package/docs/public-api.md +7 -3
- package/index.ts +63 -22
- package/lib/bindings.ts +32 -11
- package/lib/bus-follower.ts +31 -1
- package/lib/commands.ts +44 -2
- package/lib/config.ts +51 -0
- package/lib/locks.ts +50 -45
- package/lib/menu-settings.ts +94 -6
- package/lib/outbound-attachments.ts +10 -15
- package/lib/pi.ts +6 -0
- package/lib/prompts.ts +126 -8
- package/lib/recovery.ts +464 -0
- package/lib/routing.ts +301 -256
- package/package.json +1 -1
package/lib/bindings.ts
CHANGED
|
@@ -99,6 +99,7 @@ interface TelegramCommandsAndToolsBindingDeps {
|
|
|
99
99
|
activeTurnRuntime: Queue.TelegramActiveTurnStore<Queue.PendingTelegramTurn>;
|
|
100
100
|
lockedPollingRuntime: Locks.TelegramLockedPollingRuntime<Pi.ExtensionContext>;
|
|
101
101
|
stopPolling?: () => Promise<void | string>;
|
|
102
|
+
recoverPollingStart?: Commands.TelegramBridgeCommandRegistrationDeps["recoverPollingStart"];
|
|
102
103
|
getDisconnectThreadName?: () => string | undefined;
|
|
103
104
|
onTransportChanged?: () => Promise<void> | void;
|
|
104
105
|
getStatusLines: (
|
|
@@ -127,6 +128,7 @@ export function registerTelegramCommandsAndTools({
|
|
|
127
128
|
activeTurnRuntime,
|
|
128
129
|
lockedPollingRuntime,
|
|
129
130
|
stopPolling,
|
|
131
|
+
recoverPollingStart,
|
|
130
132
|
getDisconnectThreadName,
|
|
131
133
|
onTransportChanged,
|
|
132
134
|
getStatusLines,
|
|
@@ -234,8 +236,16 @@ export function registerTelegramCommandsAndTools({
|
|
|
234
236
|
getStatusLines,
|
|
235
237
|
reloadConfig: configStore.load,
|
|
236
238
|
hasBotToken: configStore.hasBotToken,
|
|
237
|
-
startPolling:
|
|
239
|
+
startPolling: async (ctx, options) => {
|
|
240
|
+
try {
|
|
241
|
+
return await lockedPollingRuntime.start(ctx, options);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
recordRuntimeEvent("recovery", error, { phase: "polling-start" });
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
},
|
|
238
247
|
stopPolling: stopPolling ?? lockedPollingRuntime.stop,
|
|
248
|
+
recoverPollingStart,
|
|
239
249
|
getDisconnectThreadName,
|
|
240
250
|
updateStatus,
|
|
241
251
|
getProfileNames: () =>
|
|
@@ -295,10 +305,9 @@ interface TelegramLifecycleBindingDeps {
|
|
|
295
305
|
>;
|
|
296
306
|
promptDispatchRuntime: Runtime.TelegramPromptDispatchRuntime<Pi.ExtensionContext>;
|
|
297
307
|
deferredQueueDispatchRuntime: Queue.TelegramDeferredQueueDispatchRuntime<Pi.ExtensionContext>;
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
>;
|
|
308
|
+
modelContextAvailabilityRuntime: Prompts.TelegramModelContextAvailabilityRuntime;
|
|
309
|
+
disconnectOnQuit?: () => Promise<unknown>;
|
|
310
|
+
resolveAutomaticThreadCleanupEnabled?: () => boolean | Promise<boolean>;
|
|
302
311
|
buttonActionStore: OutboundHandlers.TelegramButtonActionStore;
|
|
303
312
|
callMultipart: OutboundHandlers.TelegramVoiceReplySenderDeps["sendMultipart"];
|
|
304
313
|
sendChatAction: NonNullable<
|
|
@@ -338,7 +347,6 @@ interface TelegramLifecycleBindingDeps {
|
|
|
338
347
|
Keyboard.TelegramInlineKeyboardMarkup
|
|
339
348
|
>["finalizeMarkdownPreview"];
|
|
340
349
|
proactivePushTargetGetter: () => Queue.TelegramQueueTarget | undefined;
|
|
341
|
-
isProactivePushEnabled: () => boolean;
|
|
342
350
|
getAssistantRenderingMode: () => "rich" | "html";
|
|
343
351
|
recordMessageOwnership?: (input: {
|
|
344
352
|
chatId: number;
|
|
@@ -367,7 +375,9 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
367
375
|
previewRuntime,
|
|
368
376
|
promptDispatchRuntime,
|
|
369
377
|
deferredQueueDispatchRuntime,
|
|
370
|
-
|
|
378
|
+
modelContextAvailabilityRuntime,
|
|
379
|
+
disconnectOnQuit,
|
|
380
|
+
resolveAutomaticThreadCleanupEnabled,
|
|
371
381
|
buttonActionStore,
|
|
372
382
|
callMultipart,
|
|
373
383
|
sendChatAction,
|
|
@@ -380,7 +390,6 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
380
390
|
sendGuestReply,
|
|
381
391
|
finalizeMarkdownPreview,
|
|
382
392
|
proactivePushTargetGetter,
|
|
383
|
-
isProactivePushEnabled,
|
|
384
393
|
getAssistantRenderingMode,
|
|
385
394
|
recordMessageOwnership,
|
|
386
395
|
canSendAgentActivity,
|
|
@@ -629,6 +638,7 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
629
638
|
previewRuntime.invalidate();
|
|
630
639
|
assistantOutputRuntime.start();
|
|
631
640
|
activityRuntime.onSessionStart?.();
|
|
641
|
+
modelContextAvailabilityRuntime.reconcile();
|
|
632
642
|
await sessionLifecycleRuntime.onSessionStart(event, ctx);
|
|
633
643
|
},
|
|
634
644
|
async onSessionShutdown(event, ctx) {
|
|
@@ -637,6 +647,17 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
637
647
|
activityRuntime.onSessionShutdown();
|
|
638
648
|
assistantOutputRuntime.stop();
|
|
639
649
|
compactionObserver.onSessionShutdown();
|
|
650
|
+
if (event.reason === "quit" && disconnectOnQuit) {
|
|
651
|
+
try {
|
|
652
|
+
const automaticCleanupEnabled =
|
|
653
|
+
(await resolveAutomaticThreadCleanupEnabled?.()) ?? true;
|
|
654
|
+
if (automaticCleanupEnabled) await disconnectOnQuit();
|
|
655
|
+
} catch (error) {
|
|
656
|
+
recordRuntimeEvent("session", error, {
|
|
657
|
+
phase: "automatic-disconnect-on-quit",
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
}
|
|
640
661
|
await sessionLifecycleRuntime.onSessionShutdown(event, ctx);
|
|
641
662
|
},
|
|
642
663
|
onSessionBeforeCompact(event, ctx) {
|
|
@@ -704,11 +725,11 @@ export function registerTelegramLifecycleRuntimeHooks({
|
|
|
704
725
|
if (!isSessionContextActive(ctx)) return;
|
|
705
726
|
await agentLifecycleHooks.onAgentSettled(event, ctx);
|
|
706
727
|
activityRuntime.onAgentSettled();
|
|
728
|
+
modelContextAvailabilityRuntime.reconcile();
|
|
707
729
|
},
|
|
708
730
|
onBeforeAgentStart: Prompts.createTelegramProactiveBeforeAgentStartHook({
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
isCurrentOwner: lockOwnershipGuard.ownsContext,
|
|
731
|
+
reconcileAvailability: modelContextAvailabilityRuntime.reconcile,
|
|
732
|
+
isAvailable: canSendAgentActivity,
|
|
712
733
|
}),
|
|
713
734
|
});
|
|
714
735
|
}
|
package/lib/bus-follower.ts
CHANGED
|
@@ -138,6 +138,17 @@ export interface TelegramBusFollowerSessionRefreshHookDeps<TContext> {
|
|
|
138
138
|
) => void;
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
export type TelegramBusFollowerControlLifecyclePhase = "electing";
|
|
142
|
+
|
|
143
|
+
export interface TelegramBusFollowerControlState {
|
|
144
|
+
getActiveAuthSecret: () => string | undefined;
|
|
145
|
+
setActiveAuthSecret: (secret: string | undefined) => void;
|
|
146
|
+
getLifecyclePhase: () => TelegramBusFollowerControlLifecyclePhase | undefined;
|
|
147
|
+
setLifecyclePhase: (
|
|
148
|
+
phase: TelegramBusFollowerControlLifecyclePhase | undefined,
|
|
149
|
+
) => void;
|
|
150
|
+
}
|
|
151
|
+
|
|
141
152
|
export interface TelegramBusFollowerRegistrationState {
|
|
142
153
|
isRegistered: () => boolean;
|
|
143
154
|
getTarget: () => TelegramTarget | undefined;
|
|
@@ -841,7 +852,24 @@ export function createTelegramBusFollowerSessionRefreshHook<TContext>(
|
|
|
841
852
|
};
|
|
842
853
|
}
|
|
843
854
|
|
|
844
|
-
export function
|
|
855
|
+
export function createTelegramBusFollowerControlState(): TelegramBusFollowerControlState {
|
|
856
|
+
let activeAuthSecret: string | undefined;
|
|
857
|
+
let lifecyclePhase: TelegramBusFollowerControlLifecyclePhase | undefined;
|
|
858
|
+
return {
|
|
859
|
+
getActiveAuthSecret: () => activeAuthSecret,
|
|
860
|
+
setActiveAuthSecret(secret) {
|
|
861
|
+
activeAuthSecret = secret;
|
|
862
|
+
},
|
|
863
|
+
getLifecyclePhase: () => lifecyclePhase,
|
|
864
|
+
setLifecyclePhase(phase) {
|
|
865
|
+
lifecyclePhase = phase;
|
|
866
|
+
},
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
export function createTelegramBusFollowerRegistrationState(
|
|
871
|
+
options: { onAvailabilityChanged?: () => void } = {},
|
|
872
|
+
): TelegramBusFollowerRegistrationState {
|
|
845
873
|
let registered = false;
|
|
846
874
|
let target: TelegramTarget | undefined;
|
|
847
875
|
let slot: string | undefined;
|
|
@@ -861,11 +889,13 @@ export function createTelegramBusFollowerRegistrationState(): TelegramBusFollowe
|
|
|
861
889
|
).sort();
|
|
862
890
|
},
|
|
863
891
|
setRegistered: (next, nextTarget, metadata) => {
|
|
892
|
+
const availabilityChanged = registered !== next;
|
|
864
893
|
registered = next;
|
|
865
894
|
target = next ? (nextTarget ? { ...nextTarget } : undefined) : undefined;
|
|
866
895
|
slot = next ? metadata?.slot : undefined;
|
|
867
896
|
threadName = next ? metadata?.threadName : undefined;
|
|
868
897
|
generation = next ? metadata?.generation : undefined;
|
|
898
|
+
if (availabilityChanged) options.onAvailabilityChanged?.();
|
|
869
899
|
},
|
|
870
900
|
};
|
|
871
901
|
}
|
package/lib/commands.ts
CHANGED
|
@@ -297,6 +297,11 @@ export interface TelegramBridgeCommandStartPollingResult {
|
|
|
297
297
|
owner?: string;
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
+
export type TelegramPollingStartRecoveryResult =
|
|
301
|
+
| { kind: "unhandled" }
|
|
302
|
+
| { kind: "retry"; message: string }
|
|
303
|
+
| { kind: "blocked"; message: string };
|
|
304
|
+
|
|
300
305
|
export interface TelegramBridgeCommandRegistrationDeps {
|
|
301
306
|
promptForConfig: (ctx: ExtensionCommandContext, profileName?: string) => Promise<void>;
|
|
302
307
|
getStatusLines: (options?: TelegramBridgeStatusLineOptions) => string[];
|
|
@@ -310,6 +315,9 @@ export interface TelegramBridgeCommandRegistrationDeps {
|
|
|
310
315
|
| Promise<void | TelegramBridgeCommandStartPollingResult>
|
|
311
316
|
| TelegramBridgeCommandStartPollingResult;
|
|
312
317
|
stopPolling: () => Promise<void | string>;
|
|
318
|
+
recoverPollingStart?: (
|
|
319
|
+
error: unknown,
|
|
320
|
+
) => Promise<TelegramPollingStartRecoveryResult>;
|
|
313
321
|
getDisconnectThreadName?: () => string | undefined;
|
|
314
322
|
updateStatus: (ctx: ExtensionCommandContext) => void;
|
|
315
323
|
getProfileNames?: () => string[];
|
|
@@ -390,7 +398,41 @@ export function registerTelegramBridgeCommands(
|
|
|
390
398
|
await deps.promptForConfig(ctx, profileName);
|
|
391
399
|
return;
|
|
392
400
|
}
|
|
393
|
-
let
|
|
401
|
+
let recoveryUsed = false;
|
|
402
|
+
const startWithRecovery = async (
|
|
403
|
+
options: TelegramBridgeCommandStartPollingOptions,
|
|
404
|
+
): Promise<void | TelegramBridgeCommandStartPollingResult> => {
|
|
405
|
+
try {
|
|
406
|
+
return await deps.startPolling(ctx, options);
|
|
407
|
+
} catch (error) {
|
|
408
|
+
if (!deps.recoverPollingStart || recoveryUsed) throw error;
|
|
409
|
+
const recovery = await deps.recoverPollingStart(error);
|
|
410
|
+
if (recovery.kind === "unhandled") throw error;
|
|
411
|
+
if (recovery.kind === "blocked") {
|
|
412
|
+
return { ok: false, message: recovery.message };
|
|
413
|
+
}
|
|
414
|
+
recoveryUsed = true;
|
|
415
|
+
try {
|
|
416
|
+
const retry = await deps.startPolling(ctx, options);
|
|
417
|
+
if (!retry) {
|
|
418
|
+
return { ok: true, message: recovery.message };
|
|
419
|
+
}
|
|
420
|
+
return {
|
|
421
|
+
...retry,
|
|
422
|
+
message: retry.ok
|
|
423
|
+
? `${recovery.message} ${retry.message ?? "Telegram bridge connected."}`
|
|
424
|
+
: retry.message,
|
|
425
|
+
};
|
|
426
|
+
} catch {
|
|
427
|
+
return {
|
|
428
|
+
ok: false,
|
|
429
|
+
message:
|
|
430
|
+
"Telegram temporary state was recovered, but the bridge could not restart. Restart this Pi instance and run /telegram-connect again.",
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
let result = await startWithRecovery({
|
|
394
436
|
forceFreshLeaderThread: true,
|
|
395
437
|
});
|
|
396
438
|
if (result && !result.ok && result.canTakeover) {
|
|
@@ -403,7 +445,7 @@ export function registerTelegramBridgeCommands(
|
|
|
403
445
|
deps.updateStatus(ctx);
|
|
404
446
|
return;
|
|
405
447
|
}
|
|
406
|
-
result = await
|
|
448
|
+
result = await startWithRecovery({
|
|
407
449
|
force: true,
|
|
408
450
|
forceFreshLeaderThread: true,
|
|
409
451
|
});
|
package/lib/config.ts
CHANGED
|
@@ -87,6 +87,10 @@ export interface TelegramConfig {
|
|
|
87
87
|
sendTranscript?: boolean;
|
|
88
88
|
};
|
|
89
89
|
time?: TelegramTimeConfig;
|
|
90
|
+
threads?: {
|
|
91
|
+
/** Delete this instance's bound Telegram thread on graceful Pi quit. */
|
|
92
|
+
automaticCleanup?: boolean;
|
|
93
|
+
};
|
|
90
94
|
/** Canonical bot/session profiles, including profiles.default. */
|
|
91
95
|
profiles?: Record<string, TelegramBotProfile>;
|
|
92
96
|
}
|
|
@@ -140,6 +144,7 @@ export interface TelegramConfigStore {
|
|
|
140
144
|
getOutboundHandlers: () => TelegramOutboundHandlerConfig[] | undefined;
|
|
141
145
|
setAllowedUserId: (userId: number) => void;
|
|
142
146
|
load: () => Promise<void>;
|
|
147
|
+
didLastLoadRecoverInvalidConfig: () => boolean;
|
|
143
148
|
persist: (config?: TelegramConfig) => Promise<void>;
|
|
144
149
|
}
|
|
145
150
|
|
|
@@ -188,6 +193,7 @@ type TelegramMutableConfigStore = Pick<
|
|
|
188
193
|
"get" | "set" | "persist"
|
|
189
194
|
> & {
|
|
190
195
|
load?: () => Promise<void>;
|
|
196
|
+
didLastLoadRecoverInvalidConfig?: () => boolean;
|
|
191
197
|
};
|
|
192
198
|
|
|
193
199
|
function isEmptyTelegramConfig(config: TelegramConfig): boolean {
|
|
@@ -498,6 +504,7 @@ export function createTelegramConfigStore(
|
|
|
498
504
|
let mutationVersion = 0;
|
|
499
505
|
let persistQueue: Promise<void> = Promise.resolve();
|
|
500
506
|
let activeProfileName: string | undefined;
|
|
507
|
+
let lastLoadRecoveredInvalidConfig = false;
|
|
501
508
|
const agentDir = options.agentDir ?? resolveAgentDir();
|
|
502
509
|
const configPath = options.configPath ?? getConfigPath();
|
|
503
510
|
const getEffectiveConfig = () =>
|
|
@@ -556,8 +563,10 @@ export function createTelegramConfigStore(
|
|
|
556
563
|
setEffectiveConfig(nextConfig);
|
|
557
564
|
},
|
|
558
565
|
load: async () => {
|
|
566
|
+
lastLoadRecoveredInvalidConfig = false;
|
|
559
567
|
const loadedConfig = await readTelegramConfig(configPath, {
|
|
560
568
|
onInvalidConfig: (recovery) => {
|
|
569
|
+
lastLoadRecoveredInvalidConfig = true;
|
|
561
570
|
options.recordRuntimeEvent?.("config", recovery.error, {
|
|
562
571
|
phase: "load",
|
|
563
572
|
configPath: recovery.configPath,
|
|
@@ -593,6 +602,7 @@ export function createTelegramConfigStore(
|
|
|
593
602
|
persistedConfig = cloneTelegramConfig(config);
|
|
594
603
|
mutationVersion += 1;
|
|
595
604
|
},
|
|
605
|
+
didLastLoadRecoverInvalidConfig: () => lastLoadRecoveredInvalidConfig,
|
|
596
606
|
persist: (nextConfig = getEffectiveConfig()) => {
|
|
597
607
|
const profileName = activeProfileName;
|
|
598
608
|
const desiredConfig = storeTelegramEffectiveConfig(
|
|
@@ -862,6 +872,41 @@ export function createTelegramProactivePushTargetGetter(deps: {
|
|
|
862
872
|
};
|
|
863
873
|
}
|
|
864
874
|
|
|
875
|
+
export function createTelegramAutomaticThreadCleanupChecker(
|
|
876
|
+
configStore: Pick<TelegramConfigStore, "get">,
|
|
877
|
+
): () => boolean {
|
|
878
|
+
return () => configStore.get().threads?.automaticCleanup ?? true;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
export function createTelegramAutomaticThreadCleanupResolver(
|
|
882
|
+
configStore: TelegramMutableConfigStore,
|
|
883
|
+
): () => Promise<boolean> {
|
|
884
|
+
return async () => {
|
|
885
|
+
await loadLatestTelegramConfig(configStore);
|
|
886
|
+
if (configStore.didLastLoadRecoverInvalidConfig?.()) {
|
|
887
|
+
throw new Error(
|
|
888
|
+
"Automatic thread cleanup setting is unavailable after invalid Telegram config recovery.",
|
|
889
|
+
);
|
|
890
|
+
}
|
|
891
|
+
return createTelegramAutomaticThreadCleanupChecker(configStore)();
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
export function createTelegramAutomaticThreadCleanupSetter(
|
|
896
|
+
configStore: TelegramMutableConfigStore,
|
|
897
|
+
): (enabled: boolean) => Promise<void> {
|
|
898
|
+
return async (enabled) => {
|
|
899
|
+
await loadLatestTelegramConfig(configStore);
|
|
900
|
+
const current = configStore.get();
|
|
901
|
+
const config = {
|
|
902
|
+
...current,
|
|
903
|
+
threads: { ...current.threads, automaticCleanup: enabled },
|
|
904
|
+
};
|
|
905
|
+
configStore.set(config);
|
|
906
|
+
await configStore.persist(config);
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
|
|
865
910
|
export function createTelegramConfigControls(
|
|
866
911
|
configStore: TelegramMutableConfigStore,
|
|
867
912
|
) {
|
|
@@ -880,6 +925,12 @@ export function createTelegramConfigControls(
|
|
|
880
925
|
setVoiceReplyMode: createTelegramVoiceReplyModeSetter(configStore),
|
|
881
926
|
getTimeInjectionMode: createTelegramTimeInjectionModeGetter(configStore),
|
|
882
927
|
setTimeInjectionMode: createTelegramTimeInjectionModeSetter(configStore),
|
|
928
|
+
isAutomaticThreadCleanupEnabled:
|
|
929
|
+
createTelegramAutomaticThreadCleanupChecker(configStore),
|
|
930
|
+
resolveAutomaticThreadCleanupEnabled:
|
|
931
|
+
createTelegramAutomaticThreadCleanupResolver(configStore),
|
|
932
|
+
setAutomaticThreadCleanupEnabled:
|
|
933
|
+
createTelegramAutomaticThreadCleanupSetter(configStore),
|
|
883
934
|
};
|
|
884
935
|
}
|
|
885
936
|
|
package/lib/locks.ts
CHANGED
|
@@ -176,6 +176,42 @@ function sleepSync(ms: number): void {
|
|
|
176
176
|
Atomics.wait(new Int32Array(buffer), 0, 0, ms);
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
export interface TelegramRenameRetryOptions {
|
|
180
|
+
rename?: typeof renameSync;
|
|
181
|
+
attempts?: number;
|
|
182
|
+
retryDelayMs?: number;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Rename one Telegram runtime artifact with bounded Windows sharing retries. */
|
|
186
|
+
export function renameTelegramPathWithRetry(
|
|
187
|
+
sourcePath: string,
|
|
188
|
+
destinationPath: string,
|
|
189
|
+
options: TelegramRenameRetryOptions = {},
|
|
190
|
+
): boolean {
|
|
191
|
+
const rename = options.rename ?? renameSync;
|
|
192
|
+
const attempts = Math.max(
|
|
193
|
+
1,
|
|
194
|
+
options.attempts ?? TELEGRAM_LOCK_WRITE_RETRY_ATTEMPTS,
|
|
195
|
+
);
|
|
196
|
+
const retryDelayMs = Math.max(
|
|
197
|
+
0,
|
|
198
|
+
options.retryDelayMs ?? TELEGRAM_LOCK_WRITE_RETRY_DELAY_MS,
|
|
199
|
+
);
|
|
200
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
201
|
+
try {
|
|
202
|
+
rename(sourcePath, destinationPath);
|
|
203
|
+
return true;
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if ((error as { code?: unknown })?.code === "ENOENT") return false;
|
|
206
|
+
if (!isRetryableLockWriteError(error) || attempt === attempts - 1) {
|
|
207
|
+
throw error;
|
|
208
|
+
}
|
|
209
|
+
sleepSync(retryDelayMs * (attempt + 1));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
|
|
179
215
|
interface TelegramLockTransactionOwner {
|
|
180
216
|
pid: number;
|
|
181
217
|
acquiredAtMs: number;
|
|
@@ -307,29 +343,11 @@ function releaseLockTransactionGuard(
|
|
|
307
343
|
);
|
|
308
344
|
}
|
|
309
345
|
const releasedPath = `${path}.released.${randomUUID()}`;
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
try {
|
|
316
|
-
renameSync(path, releasedPath);
|
|
317
|
-
try {
|
|
318
|
-
removeLockTransactionGuard(releasedPath);
|
|
319
|
-
} catch {
|
|
320
|
-
/* released debris cannot retain transaction authority */
|
|
321
|
-
}
|
|
322
|
-
return;
|
|
323
|
-
} catch (error) {
|
|
324
|
-
if ((error as { code?: unknown })?.code === "ENOENT") return;
|
|
325
|
-
if (
|
|
326
|
-
!isRetryableLockWriteError(error) ||
|
|
327
|
-
attempt === TELEGRAM_LOCK_WRITE_RETRY_ATTEMPTS - 1
|
|
328
|
-
) {
|
|
329
|
-
throw error;
|
|
330
|
-
}
|
|
331
|
-
sleepSync(TELEGRAM_LOCK_WRITE_RETRY_DELAY_MS * (attempt + 1));
|
|
332
|
-
}
|
|
346
|
+
if (!renameTelegramPathWithRetry(path, releasedPath)) return;
|
|
347
|
+
try {
|
|
348
|
+
removeLockTransactionGuard(releasedPath);
|
|
349
|
+
} catch {
|
|
350
|
+
/* released debris cannot retain transaction authority */
|
|
333
351
|
}
|
|
334
352
|
}
|
|
335
353
|
|
|
@@ -411,28 +429,8 @@ function reclaimAbandonedDirectoryGuard(
|
|
|
411
429
|
throw error;
|
|
412
430
|
}
|
|
413
431
|
|
|
414
|
-
const renameWithRetry = (fromPath: string, toPath: string): boolean =>
|
|
415
|
-
|
|
416
|
-
let attempt = 0;
|
|
417
|
-
attempt < TELEGRAM_LOCK_WRITE_RETRY_ATTEMPTS;
|
|
418
|
-
attempt += 1
|
|
419
|
-
) {
|
|
420
|
-
try {
|
|
421
|
-
renameRecovery(fromPath, toPath);
|
|
422
|
-
return true;
|
|
423
|
-
} catch (error) {
|
|
424
|
-
if ((error as { code?: unknown })?.code === "ENOENT") return false;
|
|
425
|
-
if (
|
|
426
|
-
!isRetryableLockWriteError(error) ||
|
|
427
|
-
attempt === TELEGRAM_LOCK_WRITE_RETRY_ATTEMPTS - 1
|
|
428
|
-
) {
|
|
429
|
-
throw error;
|
|
430
|
-
}
|
|
431
|
-
sleepSync(TELEGRAM_LOCK_WRITE_RETRY_DELAY_MS * (attempt + 1));
|
|
432
|
-
}
|
|
433
|
-
}
|
|
434
|
-
return false;
|
|
435
|
-
};
|
|
432
|
+
const renameWithRetry = (fromPath: string, toPath: string): boolean =>
|
|
433
|
+
renameTelegramPathWithRetry(fromPath, toPath, { rename: renameRecovery });
|
|
436
434
|
|
|
437
435
|
activeReclaims.add(reclaimGeneration);
|
|
438
436
|
const stalePath = `${path}.stale.${process.pid}.${randomUUID()}`;
|
|
@@ -1139,6 +1137,7 @@ export interface TelegramLockedPollingRuntimeDeps<
|
|
|
1139
1137
|
owner: TelegramLockEntry,
|
|
1140
1138
|
) => boolean | undefined | Promise<boolean | undefined>;
|
|
1141
1139
|
stopFollowerRegistration?: () => void;
|
|
1140
|
+
onTransportAvailabilityChanged?: () => void;
|
|
1142
1141
|
updateStatus: (ctx: TContext) => void;
|
|
1143
1142
|
recordRuntimeEvent?: (
|
|
1144
1143
|
category: string,
|
|
@@ -1189,6 +1188,7 @@ export function createTelegramLockedPollingRuntime<
|
|
|
1189
1188
|
const stopAfterOwnershipLoss = () => {
|
|
1190
1189
|
if (ownershipStop) return;
|
|
1191
1190
|
stopOwnershipWatcher();
|
|
1191
|
+
deps.onTransportAvailabilityChanged?.();
|
|
1192
1192
|
ownershipStop = deps
|
|
1193
1193
|
.stopPolling()
|
|
1194
1194
|
.catch((error) =>
|
|
@@ -1242,12 +1242,14 @@ export function createTelegramLockedPollingRuntime<
|
|
|
1242
1242
|
});
|
|
1243
1243
|
}
|
|
1244
1244
|
deps.lock.release();
|
|
1245
|
+
deps.onTransportAvailabilityChanged?.();
|
|
1245
1246
|
throw error;
|
|
1246
1247
|
}
|
|
1247
1248
|
if (deps.lock.owns(ctx)) return true;
|
|
1248
1249
|
stopOwnershipWatcher();
|
|
1249
1250
|
if (ownershipStop) await ownershipStop;
|
|
1250
1251
|
await deps.stopPolling();
|
|
1252
|
+
deps.onTransportAvailabilityChanged?.();
|
|
1251
1253
|
return false;
|
|
1252
1254
|
};
|
|
1253
1255
|
const canStartPolling = (ctx: TContext): boolean =>
|
|
@@ -1337,6 +1339,7 @@ export function createTelegramLockedPollingRuntime<
|
|
|
1337
1339
|
message: "Telegram leadership changed during polling startup.",
|
|
1338
1340
|
};
|
|
1339
1341
|
}
|
|
1342
|
+
deps.onTransportAvailabilityChanged?.();
|
|
1340
1343
|
deps.updateStatus(ctx);
|
|
1341
1344
|
const staleSuffix = acquired.replacedStale ? " Replaced stale lock." : "";
|
|
1342
1345
|
return { ok: true, message: `Telegram bridge connected.${staleSuffix}` };
|
|
@@ -1344,6 +1347,7 @@ export function createTelegramLockedPollingRuntime<
|
|
|
1344
1347
|
stop: async () => {
|
|
1345
1348
|
await suspendPolling();
|
|
1346
1349
|
const state = deps.lock.release();
|
|
1350
|
+
deps.onTransportAvailabilityChanged?.();
|
|
1347
1351
|
if (state.kind === "active-elsewhere") {
|
|
1348
1352
|
return `Telegram bridge is active in another Pi instance (${formatTelegramLockEntry(state.lock)}).`;
|
|
1349
1353
|
}
|
|
@@ -1391,6 +1395,7 @@ export function createTelegramLockedPollingRuntime<
|
|
|
1391
1395
|
if (generation !== sessionAutoStartGeneration) return;
|
|
1392
1396
|
if (!(await runOwnedPollingStart(ctx, {}))) return;
|
|
1393
1397
|
if (generation !== sessionAutoStartGeneration) return;
|
|
1398
|
+
deps.onTransportAvailabilityChanged?.();
|
|
1394
1399
|
deps.updateStatus(ctx);
|
|
1395
1400
|
deps.recordRuntimeEvent?.("lock", "Telegram auto-start completed", {
|
|
1396
1401
|
phase: "auto-start-complete",
|