@llblab/pi-telegram 0.37.0 → 0.37.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/CHANGELOG.md +8 -0
- package/docs/multi-instance-bus.md +1 -1
- package/index.ts +2 -0
- package/lib/bus-follower.ts +112 -25
- package/lib/menu-model.ts +4 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
> Each release keeps at most 8 outcome records of at most 512 characters.
|
|
4
4
|
|
|
5
|
+
## 0.37.2: Follower Recovery Delivery Hotfix
|
|
6
|
+
|
|
7
|
+
- `Follower Recovery Delivery`: Holds follower Bot API calls behind a bounded registration wait when heartbeat recovery temporarily clears local authority, then sends once with the restored exact generation. Calls still fail closed if registration is not restored, and acknowledgement ambiguity remains non-retryable.
|
|
8
|
+
|
|
9
|
+
## 0.37.1: Settings Manager Compatibility Hotfix
|
|
10
|
+
|
|
11
|
+
- `Menu Compatibility`: Keeps `/start`, model, and queue menu rendering compatible with Pi settings-manager implementations that do not expose `reload()`. Hosts with reload retain explicit refresh behavior; other hosts use the freshly constructed settings snapshot instead of failing with `settingsManager.reload is not a function`.
|
|
12
|
+
|
|
5
13
|
## 0.37.0: Journal-Owned Telegram Admission
|
|
6
14
|
|
|
7
15
|
- `Configuration-Only State`: Moves the per-profile polling cursor out of `telegram.json` into atomic durable journal revisions, including cursor-only initial sync, compaction, reconstruction, and recovery. A journal-first one-shot cutover removes legacy config state idempotently, exact bot/profile fences remain enforced, status reads journal authority, and unsafe downgrade now fails closed.
|
|
@@ -157,7 +157,7 @@ Leader election is heartbeat-gated and lock-backed. The polling owner checks exa
|
|
|
157
157
|
4. If the leader heartbeat is stale, attempt an atomic leadership takeover; ordinary `/telegram-connect` on a follower is not a leadership move while the leader is live.
|
|
158
158
|
5. Heartbeat acknowledgements carry the authenticated live follower-slot roster. If several followers detect stale leadership, the lowest observed live slot attempts promotion immediately; higher slots defer one bounded election grace and re-check the lock. Atomic compare/write acquisition remains the final ownership authority, and a missing lower-slot follower cannot block a higher survivor beyond that grace.
|
|
159
159
|
|
|
160
|
-
Followers first try to re-register after leader reload or unknown-heartbeat responses, carrying their last known target, slot, and thread name so the new leader can reuse the same binding. After the grace window
|
|
160
|
+
Followers first try to re-register after leader reload or unknown-heartbeat responses, carrying their last known target, slot, and thread name so the new leader can reuse the same binding. Follower Bot API calls already admitted by the active Pi turn wait for that bounded re-registration and capture its new exact generation before entering transport; they do not fail merely because recovery temporarily cleared local registration, and they never replay after an ambiguous transport commit. After the grace window followers promote only when the exact observed leader lease has become stale or inactive; an unavailable IPC endpoint never authorizes replacing a still-live owner. If the exact carried target is absent from persisted bindings, the leader first runs the same synchronous visibility probe: success recovers it instead of creating another Telegram thread, explicit stale evidence provisions a replacement, and ambiguous failure rejects registration. An ambiguous absent-target probe persists only non-routable `probe-required` restoration evidence, so targetless retries and leader reloads must probe that exact target again instead of activating it or provisioning a speculative replacement. A carried slot survives only when that slot remains free. Every successful reuse refreshes the binding timestamp. The leader never restores persisted followers into the live registry speculatively. Absent follower records remain durable restart hints until explicit stale, deleted, offline, or reconciliation evidence invalidates them; only fresh authenticated registration creates live routing authority. This preserves real thread bindings through reload and process-absence gaps without allowing historical records or competing pollers to masquerade as live state.
|
|
161
161
|
|
|
162
162
|
## Leader/Follower Communication
|
|
163
163
|
|
package/index.ts
CHANGED
|
@@ -415,6 +415,8 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
415
415
|
},
|
|
416
416
|
getRegistrationGeneration:
|
|
417
417
|
telegramBusFollowerRegistrationState.getGeneration,
|
|
418
|
+
waitForRegistrationGeneration:
|
|
419
|
+
telegramBusFollowerRegistrationState.waitForGeneration,
|
|
418
420
|
getForwardCommentBatchPosition:
|
|
419
421
|
textGroupRuntime.getPreparedForwardingPosition,
|
|
420
422
|
recordRuntimeEvent,
|
package/lib/bus-follower.ts
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
export const TELEGRAM_BUS_FOLLOWER_PROMOTION_GRACE_MS = 2_500;
|
|
48
48
|
export const TELEGRAM_FOLLOWER_SESSION_HANDOFF_TTL_MS = 30_000;
|
|
49
49
|
export const TELEGRAM_BUS_FOLLOWER_CLIENT_TIMEOUT_MS = 30_000;
|
|
50
|
+
export const TELEGRAM_BUS_FOLLOWER_REGISTRATION_WAIT_MS = 30_000;
|
|
50
51
|
export const TELEGRAM_BUS_FOLLOWER_REGISTRATION_RETRY_ATTEMPTS =
|
|
51
52
|
TELEGRAM_BUS_REGISTRATION_RETRY.attempts;
|
|
52
53
|
export const TELEGRAM_BUS_FOLLOWER_REGISTRATION_RETRY_DELAY_MS =
|
|
@@ -167,6 +168,9 @@ export interface TelegramBusFollowerRegistrationState {
|
|
|
167
168
|
getSlot: () => string | undefined;
|
|
168
169
|
getThreadName: () => string | undefined;
|
|
169
170
|
getGeneration: () => string | undefined;
|
|
171
|
+
beginRecovery: () => number;
|
|
172
|
+
cancelRecovery: () => void;
|
|
173
|
+
waitForGeneration: (timeoutMs?: number) => Promise<string | undefined>;
|
|
170
174
|
getLeaderProtocol: () => TelegramBusProtocolIdentity | undefined;
|
|
171
175
|
getEligibleElectionSlots: () => readonly string[];
|
|
172
176
|
setEligibleElectionSlots: (slots: readonly string[]) => void;
|
|
@@ -214,6 +218,9 @@ export interface TelegramBusFollowerClientRuntimeDeps<TMessage = unknown> {
|
|
|
214
218
|
getApiAuthSecret?: () => string | undefined;
|
|
215
219
|
getForwardingAuthSecret?: () => string | undefined;
|
|
216
220
|
getRegistrationGeneration: () => string | undefined;
|
|
221
|
+
waitForRegistrationGeneration?: (
|
|
222
|
+
timeoutMs?: number,
|
|
223
|
+
) => Promise<string | undefined>;
|
|
217
224
|
getForwardCommentBatchPosition?: (
|
|
218
225
|
message: TMessage,
|
|
219
226
|
) => "comment" | "forward" | undefined;
|
|
@@ -231,6 +238,9 @@ export interface TelegramBusFollowerApiCallerDeps {
|
|
|
231
238
|
createRequestId: () => string;
|
|
232
239
|
getAuthSecret?: () => string | undefined;
|
|
233
240
|
getRegistrationGeneration: () => string | undefined;
|
|
241
|
+
waitForRegistrationGeneration?: (
|
|
242
|
+
timeoutMs?: number,
|
|
243
|
+
) => Promise<string | undefined>;
|
|
234
244
|
getNowMs?: () => number;
|
|
235
245
|
timeoutMs?: number;
|
|
236
246
|
}
|
|
@@ -417,6 +427,7 @@ export interface TelegramBusFollowerHeartbeatRecoveryHandlerDeps<TContext> {
|
|
|
417
427
|
| "getSlot"
|
|
418
428
|
| "getThreadName"
|
|
419
429
|
| "getEligibleElectionSlots"
|
|
430
|
+
| "beginRecovery"
|
|
420
431
|
| "setRegistered"
|
|
421
432
|
>;
|
|
422
433
|
getRegistrationRuntime: () => TelegramBusFollowerRegistrationRuntime<TContext>;
|
|
@@ -638,6 +649,7 @@ export function createTelegramBusFollowerClientRuntime<
|
|
|
638
649
|
socketPath: deps.socketPath,
|
|
639
650
|
createRequestId,
|
|
640
651
|
timeoutMs,
|
|
652
|
+
waitForRegistrationGeneration: deps.waitForRegistrationGeneration,
|
|
641
653
|
};
|
|
642
654
|
return {
|
|
643
655
|
createRequestId,
|
|
@@ -695,14 +707,14 @@ export function createTelegramBusFollowerQueueHandoffClient(
|
|
|
695
707
|
const timeoutMs =
|
|
696
708
|
deps.timeoutMs ?? TELEGRAM_BUS_FOLLOWER_CLIENT_TIMEOUT_MS;
|
|
697
709
|
return async (input) => {
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
710
|
+
const registration = await resolveTelegramBusFollowerRegistration(
|
|
711
|
+
deps,
|
|
712
|
+
timeoutMs,
|
|
713
|
+
);
|
|
702
714
|
const socketPath = resolveTelegramBusSocketPath(deps.socketPath);
|
|
703
715
|
const response = await sendTelegramBusLocalEnvelope({
|
|
704
716
|
socketPath,
|
|
705
|
-
timeoutMs,
|
|
717
|
+
timeoutMs: registration.remainingTimeoutMs,
|
|
706
718
|
retry: getTelegramBusTransportRetryPolicy({
|
|
707
719
|
endpoint: socketPath,
|
|
708
720
|
operation: "operation",
|
|
@@ -712,7 +724,7 @@ export function createTelegramBusFollowerQueueHandoffClient(
|
|
|
712
724
|
requestId: deps.createRequestId(),
|
|
713
725
|
auth: deps.getAuthSecret?.(),
|
|
714
726
|
instanceId: deps.instanceId,
|
|
715
|
-
registrationGeneration,
|
|
727
|
+
registrationGeneration: registration.generation,
|
|
716
728
|
...input,
|
|
717
729
|
sentAtMs: getNowMs(),
|
|
718
730
|
},
|
|
@@ -770,11 +782,12 @@ export function createTelegramBusAgentMessageClient(
|
|
|
770
782
|
envelope:
|
|
771
783
|
| Extract<TelegramBusEnvelope, { kind: "follower.resolveAgentTarget" }>
|
|
772
784
|
| Extract<TelegramBusEnvelope, { kind: "follower.routeAgentMessage" }>,
|
|
785
|
+
requestTimeoutMs = timeoutMs,
|
|
773
786
|
): Promise<unknown> => {
|
|
774
787
|
const socketPath = resolveTelegramBusSocketPath(deps.socketPath);
|
|
775
788
|
const response = await sendTelegramBusLocalEnvelope({
|
|
776
789
|
socketPath,
|
|
777
|
-
timeoutMs,
|
|
790
|
+
timeoutMs: requestTimeoutMs,
|
|
778
791
|
retry: getTelegramBusTransportRetryPolicy({
|
|
779
792
|
endpoint: socketPath,
|
|
780
793
|
operation: "operation",
|
|
@@ -788,26 +801,30 @@ export function createTelegramBusAgentMessageClient(
|
|
|
788
801
|
: "Telegram bus agent message did not return an acknowledgement.",
|
|
789
802
|
);
|
|
790
803
|
};
|
|
791
|
-
const registrationFields = () => {
|
|
792
|
-
const
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
804
|
+
const registrationFields = async () => {
|
|
805
|
+
const registration = await resolveTelegramBusFollowerRegistration(
|
|
806
|
+
deps,
|
|
807
|
+
timeoutMs,
|
|
808
|
+
);
|
|
796
809
|
return {
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
810
|
+
fields: {
|
|
811
|
+
auth: deps.getAuthSecret?.(),
|
|
812
|
+
instanceId: deps.instanceId,
|
|
813
|
+
registrationGeneration: registration.generation,
|
|
814
|
+
},
|
|
815
|
+
remainingTimeoutMs: registration.remainingTimeoutMs,
|
|
800
816
|
};
|
|
801
817
|
};
|
|
802
818
|
return {
|
|
803
819
|
async resolveTarget(selector) {
|
|
820
|
+
const registration = await registrationFields();
|
|
804
821
|
const result = await request({
|
|
805
822
|
kind: "follower.resolveAgentTarget",
|
|
806
823
|
requestId: deps.createRequestId(),
|
|
807
|
-
...
|
|
824
|
+
...registration.fields,
|
|
808
825
|
selector,
|
|
809
826
|
sentAtMs: getNowMs(),
|
|
810
|
-
});
|
|
827
|
+
}, registration.remainingTimeoutMs);
|
|
811
828
|
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
|
812
829
|
throw new Error("Telegram bus returned an invalid agent target.");
|
|
813
830
|
}
|
|
@@ -821,13 +838,14 @@ export function createTelegramBusAgentMessageClient(
|
|
|
821
838
|
return { chatId: target.chatId, threadId: target.threadId };
|
|
822
839
|
},
|
|
823
840
|
async routeMessage(message) {
|
|
841
|
+
const registration = await registrationFields();
|
|
824
842
|
await request({
|
|
825
843
|
kind: "follower.routeAgentMessage",
|
|
826
844
|
requestId: deps.createRequestId(),
|
|
827
|
-
...
|
|
845
|
+
...registration.fields,
|
|
828
846
|
message,
|
|
829
847
|
sentAtMs: getNowMs(),
|
|
830
|
-
});
|
|
848
|
+
}, registration.remainingTimeoutMs);
|
|
831
849
|
},
|
|
832
850
|
};
|
|
833
851
|
}
|
|
@@ -839,16 +857,16 @@ export function createTelegramBusFollowerApiCaller(
|
|
|
839
857
|
const timeoutMs =
|
|
840
858
|
deps.timeoutMs ?? TELEGRAM_BUS_FOLLOWER_CLIENT_TIMEOUT_MS;
|
|
841
859
|
return async (method, args) => {
|
|
860
|
+
const registration = await resolveTelegramBusFollowerRegistration(
|
|
861
|
+
deps,
|
|
862
|
+
timeoutMs,
|
|
863
|
+
);
|
|
842
864
|
const socketPath = resolveTelegramBusSocketPath(deps.socketPath);
|
|
843
|
-
const registrationGeneration = deps.getRegistrationGeneration();
|
|
844
|
-
if (!registrationGeneration) {
|
|
845
|
-
throw new Error("Telegram bus follower is not registered.");
|
|
846
|
-
}
|
|
847
865
|
let response: TelegramBusEnvelope | undefined;
|
|
848
866
|
try {
|
|
849
867
|
response = await sendTelegramBusLocalEnvelope({
|
|
850
868
|
socketPath,
|
|
851
|
-
timeoutMs,
|
|
869
|
+
timeoutMs: registration.remainingTimeoutMs,
|
|
852
870
|
retry: getTelegramBusTransportRetryPolicy({
|
|
853
871
|
endpoint: socketPath,
|
|
854
872
|
operation: "operation",
|
|
@@ -858,7 +876,7 @@ export function createTelegramBusFollowerApiCaller(
|
|
|
858
876
|
requestId: deps.createRequestId(),
|
|
859
877
|
auth: deps.getAuthSecret?.(),
|
|
860
878
|
instanceId: deps.instanceId,
|
|
861
|
-
registrationGeneration,
|
|
879
|
+
registrationGeneration: registration.generation,
|
|
862
880
|
method,
|
|
863
881
|
args,
|
|
864
882
|
sentAtMs: getNowMs(),
|
|
@@ -893,6 +911,29 @@ export function createTelegramBusFollowerApiCaller(
|
|
|
893
911
|
};
|
|
894
912
|
}
|
|
895
913
|
|
|
914
|
+
async function resolveTelegramBusFollowerRegistration(
|
|
915
|
+
deps: Pick<
|
|
916
|
+
TelegramBusFollowerApiCallerDeps,
|
|
917
|
+
| "getRegistrationGeneration"
|
|
918
|
+
| "waitForRegistrationGeneration"
|
|
919
|
+
| "getNowMs"
|
|
920
|
+
>,
|
|
921
|
+
timeoutMs: number,
|
|
922
|
+
): Promise<{ generation: string; remainingTimeoutMs: number }> {
|
|
923
|
+
const current = deps.getRegistrationGeneration();
|
|
924
|
+
if (current) return { generation: current, remainingTimeoutMs: timeoutMs };
|
|
925
|
+
const getNowMs = deps.getNowMs ?? Date.now;
|
|
926
|
+
const startedAtMs = getNowMs();
|
|
927
|
+
const restored = await deps.waitForRegistrationGeneration?.(
|
|
928
|
+
timeoutMs,
|
|
929
|
+
);
|
|
930
|
+
const remainingTimeoutMs = Math.max(0, timeoutMs - (getNowMs() - startedAtMs));
|
|
931
|
+
if (restored && remainingTimeoutMs > 0) {
|
|
932
|
+
return { generation: restored, remainingTimeoutMs };
|
|
933
|
+
}
|
|
934
|
+
throw new Error("Telegram bus follower is not registered.");
|
|
935
|
+
}
|
|
936
|
+
|
|
896
937
|
function isTelegramStaleContextError(error: unknown): boolean {
|
|
897
938
|
return (
|
|
898
939
|
error instanceof Error &&
|
|
@@ -1046,12 +1087,52 @@ export function createTelegramBusFollowerRegistrationState(
|
|
|
1046
1087
|
let generation: string | undefined;
|
|
1047
1088
|
let leaderProtocol: TelegramBusProtocolIdentity | undefined;
|
|
1048
1089
|
let eligibleElectionSlots: string[] = [];
|
|
1090
|
+
let recoveryEpoch = 0;
|
|
1091
|
+
let activeRecoveryEpoch: number | undefined;
|
|
1092
|
+
const generationWaiters = new Set<{
|
|
1093
|
+
epoch: number;
|
|
1094
|
+
settle: (value: string | undefined) => void;
|
|
1095
|
+
}>();
|
|
1096
|
+
const settleGenerationWaiters = (
|
|
1097
|
+
value: string | undefined,
|
|
1098
|
+
epoch?: number,
|
|
1099
|
+
) => {
|
|
1100
|
+
for (const waiter of [...generationWaiters]) {
|
|
1101
|
+
if (epoch === undefined || waiter.epoch === epoch) waiter.settle(value);
|
|
1102
|
+
}
|
|
1103
|
+
};
|
|
1049
1104
|
return {
|
|
1050
1105
|
isRegistered: () => registered,
|
|
1051
1106
|
getTarget: () => (target ? { ...target } : undefined),
|
|
1052
1107
|
getSlot: () => slot,
|
|
1053
1108
|
getThreadName: () => threadName,
|
|
1054
1109
|
getGeneration: () => generation,
|
|
1110
|
+
beginRecovery: () => {
|
|
1111
|
+
if (activeRecoveryEpoch !== undefined) return activeRecoveryEpoch;
|
|
1112
|
+
activeRecoveryEpoch = ++recoveryEpoch;
|
|
1113
|
+
return activeRecoveryEpoch;
|
|
1114
|
+
},
|
|
1115
|
+
cancelRecovery: () => {
|
|
1116
|
+
const epoch = activeRecoveryEpoch;
|
|
1117
|
+
activeRecoveryEpoch = undefined;
|
|
1118
|
+
if (epoch !== undefined) settleGenerationWaiters(undefined, epoch);
|
|
1119
|
+
},
|
|
1120
|
+
waitForGeneration: (timeoutMs = TELEGRAM_BUS_FOLLOWER_REGISTRATION_WAIT_MS) => {
|
|
1121
|
+
if (generation) return Promise.resolve(generation);
|
|
1122
|
+
const epoch = activeRecoveryEpoch;
|
|
1123
|
+
if (epoch === undefined) return Promise.resolve(undefined);
|
|
1124
|
+
return new Promise((resolve) => {
|
|
1125
|
+
let timer: NodeJS.Timeout | undefined;
|
|
1126
|
+
const settle = (value: string | undefined) => {
|
|
1127
|
+
generationWaiters.delete(waiter);
|
|
1128
|
+
if (timer) clearTimeout(timer);
|
|
1129
|
+
resolve(value);
|
|
1130
|
+
};
|
|
1131
|
+
const waiter = { epoch, settle };
|
|
1132
|
+
generationWaiters.add(waiter);
|
|
1133
|
+
timer = setTimeout(() => settle(undefined), Math.max(0, timeoutMs));
|
|
1134
|
+
});
|
|
1135
|
+
},
|
|
1055
1136
|
getLeaderProtocol: () =>
|
|
1056
1137
|
leaderProtocol
|
|
1057
1138
|
? { ...leaderProtocol, capabilities: [...leaderProtocol.capabilities] }
|
|
@@ -1077,6 +1158,10 @@ export function createTelegramBusFollowerRegistrationState(
|
|
|
1077
1158
|
}
|
|
1078
1159
|
: undefined;
|
|
1079
1160
|
if (availabilityChanged) options.onAvailabilityChanged?.();
|
|
1161
|
+
if (generation) {
|
|
1162
|
+
activeRecoveryEpoch = undefined;
|
|
1163
|
+
settleGenerationWaiters(generation);
|
|
1164
|
+
}
|
|
1080
1165
|
},
|
|
1081
1166
|
};
|
|
1082
1167
|
}
|
|
@@ -1253,6 +1338,7 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
|
|
|
1253
1338
|
): Promise<void> => {
|
|
1254
1339
|
if (promotionPending) return;
|
|
1255
1340
|
promotionPending = true;
|
|
1341
|
+
deps.registrationState.beginRecovery();
|
|
1256
1342
|
try {
|
|
1257
1343
|
const initialBinding = carriedBinding ?? snapshotBinding();
|
|
1258
1344
|
const state = deps.getLeaderState();
|
|
@@ -1366,6 +1452,7 @@ export function createTelegramBusFollowerRegistrationRuntime<
|
|
|
1366
1452
|
heartbeatPromise = undefined;
|
|
1367
1453
|
heartbeatPromiseGeneration = undefined;
|
|
1368
1454
|
deps.setActiveAuthSecret?.(undefined);
|
|
1455
|
+
deps.registrationState?.cancelRecovery();
|
|
1369
1456
|
deps.registrationState?.setRegistered(false);
|
|
1370
1457
|
lastKnownTarget = undefined;
|
|
1371
1458
|
lastKnownSlot = undefined;
|
package/lib/menu-model.ts
CHANGED
|
@@ -103,7 +103,7 @@ export interface TelegramModelMenuRuntimeOptions<
|
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
export interface MenuSettingsManager {
|
|
106
|
-
reload
|
|
106
|
+
reload?: () => Promise<void>;
|
|
107
107
|
flush?: () => Promise<void>;
|
|
108
108
|
getEnabledModels: () => string[] | undefined;
|
|
109
109
|
setEnabledModels?: (patterns: string[] | undefined) => void;
|
|
@@ -498,7 +498,9 @@ export function createTelegramModelMenuStateBuilder<
|
|
|
498
498
|
threadId,
|
|
499
499
|
activeModel: deps.getActiveModel(ctx),
|
|
500
500
|
ctx,
|
|
501
|
-
reloadSettings: () =>
|
|
501
|
+
reloadSettings: async () => {
|
|
502
|
+
await settingsManager.reload?.();
|
|
503
|
+
},
|
|
502
504
|
getConfiguredScopedModelPatterns: () =>
|
|
503
505
|
settingsManager.getEnabledModels(),
|
|
504
506
|
});
|