@llblab/pi-telegram 0.54.0 → 0.54.1
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 +5 -0
- package/dist/lib/bus-follower.d.ts +8 -1
- package/dist/lib/bus-follower.js +45 -3
- package/dist/lib/extension.js +4 -0
- package/dist/lib/locks.js +15 -10
- package/dist/lib/sync.js +5 -3
- package/dist/package.json +1 -1
- package/docs/multi-instance-bus.md +1 -1
- package/lib/bus-follower.ts +56 -4
- package/lib/extension.ts +4 -0
- package/lib/locks.ts +16 -11
- package/lib/sync.ts +5 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.54.1: Followers go offline when Threaded Mode is turned off
|
|
8
|
+
|
|
9
|
+
- `Threaded Mode downgrade`: Turning Threaded Mode off in BotFather now takes followers offline instead of looping between `electing` and `disconnected` (and filling the log) against a leader that switched to classic polling; reconnect with `/telegram-connect` after turning it back on. `/telegram-disconnect` on a follower without a live leader now disconnects locally and reports the Thread as kept, and a pending recovery retry no longer re-registers a disconnected follower.
|
|
10
|
+
- `Ownership reads`: A lock-free ownership check that races a concurrent `state.json` replacement is now retried and confirmed through the serialized path before the leader stops polling, so a transient read miss (seen on slow Windows runners) is no longer mistaken for lost ownership. The integration test for a revoked follower provisioning now waits for its asynchronous lease release.
|
|
11
|
+
|
|
7
12
|
## 0.54.0: No periodic disk writes while idle
|
|
8
13
|
|
|
9
14
|
- `Journal serialization`: The update journal no longer borrows the `telegram.json` lock. Journal operations serialize on their own `tmp/pi-telegram/runtime/journals.transaction`, so receiving messages and repairing journals never touch the config file or its guard; only config writes and sender admission still take it. Bus protocol moves to v3 so 0.53.x peers, which serialize on the old lock, cannot register alongside 0.54 writers.
|
|
@@ -231,6 +231,8 @@ export interface TelegramBusFollowerHeartbeatRecoveryHandlerDeps<TContext> {
|
|
|
231
231
|
promotionGraceMs?: number;
|
|
232
232
|
/** Bus liveness proof for a live-PID leader that no longer answers; replaces the retired file heartbeat. */
|
|
233
233
|
proveLeaderUnresponsive?: (owner: TelegramLockEntry) => Promise<boolean>;
|
|
234
|
+
/** Shared bot state says Threaded Mode is off: followers cannot exist, so recovery goes offline instead. */
|
|
235
|
+
isThreadModeDisabled?: () => boolean | Promise<boolean>;
|
|
234
236
|
recordRuntimeEvent: (category: string, error: unknown, details?: Record<string, unknown>) => void;
|
|
235
237
|
}
|
|
236
238
|
export interface TelegramBusForwardedUpdateReceiverRuntimeDeps<TContext> {
|
|
@@ -419,7 +421,12 @@ export declare function createTelegramBusFollowerControlState(): TelegramBusFoll
|
|
|
419
421
|
export declare function createTelegramBusFollowerRegistrationState(options?: {
|
|
420
422
|
onAvailabilityChanged?: () => void;
|
|
421
423
|
}): TelegramBusFollowerRegistrationState;
|
|
422
|
-
|
|
424
|
+
/** Heartbeat-failure recovery that a deliberate local stop halts until the next deliberate registration. */
|
|
425
|
+
export type TelegramBusFollowerHeartbeatRecoveryHandler<TContext> = ((error: unknown, ctx: TContext) => Promise<void>) & {
|
|
426
|
+
halt: () => void;
|
|
427
|
+
resume: () => void;
|
|
428
|
+
};
|
|
429
|
+
export declare function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(deps: TelegramBusFollowerHeartbeatRecoveryHandlerDeps<TContext>): TelegramBusFollowerHeartbeatRecoveryHandler<TContext>;
|
|
423
430
|
export declare function createTelegramBusFollowerRegistrationRuntime<TContext extends {
|
|
424
431
|
cwd?: string;
|
|
425
432
|
}>(deps: TelegramBusFollowerRegistrationRuntimeDeps<TContext>): TelegramBusFollowerRegistrationRuntime<TContext>;
|
package/dist/lib/bus-follower.js
CHANGED
|
@@ -190,7 +190,7 @@ export function createTelegramBusFollowerRuntimeAssembly(ports) {
|
|
|
190
190
|
...ports.recovery,
|
|
191
191
|
registrationState: ports.registrationState,
|
|
192
192
|
recordRuntimeEvent: ports.recordRuntimeEvent,
|
|
193
|
-
getRegistrationRuntime: () =>
|
|
193
|
+
getRegistrationRuntime: () => baseRegistration,
|
|
194
194
|
});
|
|
195
195
|
registration = createTelegramBusFollowerRegistrationRuntime({
|
|
196
196
|
...ports.registration,
|
|
@@ -203,6 +203,15 @@ export function createTelegramBusFollowerRuntimeAssembly(ports) {
|
|
|
203
203
|
const baseRegistration = registration;
|
|
204
204
|
registration = {
|
|
205
205
|
...baseRegistration,
|
|
206
|
+
// Public stop is deliberate (disconnect, suspension); recovery keeps the base stop for promotion.
|
|
207
|
+
stop() {
|
|
208
|
+
recovery.halt();
|
|
209
|
+
baseRegistration.stop();
|
|
210
|
+
},
|
|
211
|
+
registerWithLeader(ctx, leader, options) {
|
|
212
|
+
recovery.resume();
|
|
213
|
+
return baseRegistration.registerWithLeader(ctx, leader, options);
|
|
214
|
+
},
|
|
206
215
|
async setContext(ctx) {
|
|
207
216
|
const sessionGeneration = ports.registration.getSessionGeneration?.();
|
|
208
217
|
if (ports.registration.isContextActive?.(ctx) === false)
|
|
@@ -1214,6 +1223,9 @@ export function createTelegramBusFollowerRegistrationState(options = {}) {
|
|
|
1214
1223
|
};
|
|
1215
1224
|
}
|
|
1216
1225
|
export function createTelegramBusFollowerHeartbeatRecoveryHandler(deps) {
|
|
1226
|
+
// Promotion stops registration internally and must keep retrying; only an operator stop or a confirmed
|
|
1227
|
+
// switch to classic mode halts recovery, so it never re-registers behind a disconnect.
|
|
1228
|
+
let halted = false;
|
|
1217
1229
|
const promotionGraceMs = deps.promotionGraceMs ?? TELEGRAM_BUS_FOLLOWER_PROMOTION_GRACE_MS;
|
|
1218
1230
|
const sleep = deps.sleep ??
|
|
1219
1231
|
((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
|
|
@@ -1246,6 +1258,10 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler(deps) {
|
|
|
1246
1258
|
.registerWithLeader(ctx, leader, binding?.target ? { target: binding.target } : undefined);
|
|
1247
1259
|
if (!restored)
|
|
1248
1260
|
return false;
|
|
1261
|
+
if (halted) {
|
|
1262
|
+
deps.getRegistrationRuntime().stop();
|
|
1263
|
+
return false;
|
|
1264
|
+
}
|
|
1249
1265
|
deps.setLifecyclePhase(undefined);
|
|
1250
1266
|
safeUpdateStatus(ctx);
|
|
1251
1267
|
deps.recordRuntimeEvent("bus", "Telegram follower registration restored", {
|
|
@@ -1266,6 +1282,8 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler(deps) {
|
|
|
1266
1282
|
});
|
|
1267
1283
|
const scheduleRecovery = (reason, fallbackCtx, binding) => {
|
|
1268
1284
|
const retry = () => {
|
|
1285
|
+
if (halted)
|
|
1286
|
+
return;
|
|
1269
1287
|
const activeCtx = deps.getActiveContext
|
|
1270
1288
|
? deps.getActiveContext()
|
|
1271
1289
|
: fallbackCtx;
|
|
@@ -1336,12 +1354,29 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler(deps) {
|
|
|
1336
1354
|
});
|
|
1337
1355
|
};
|
|
1338
1356
|
const recover = async (error, ctx, carriedBinding) => {
|
|
1339
|
-
if (promotionPending)
|
|
1357
|
+
if (promotionPending || halted)
|
|
1340
1358
|
return;
|
|
1341
1359
|
promotionPending = true;
|
|
1342
1360
|
deps.registrationState.beginRecovery();
|
|
1343
1361
|
const initialBinding = carriedBinding ?? snapshotBinding();
|
|
1344
1362
|
try {
|
|
1363
|
+
let threadModeDisabled = false;
|
|
1364
|
+
try {
|
|
1365
|
+
threadModeDisabled = (await deps.isThreadModeDisabled?.()) === true;
|
|
1366
|
+
}
|
|
1367
|
+
catch (stateError) {
|
|
1368
|
+
deps.recordRuntimeEvent("bus", stateError, {
|
|
1369
|
+
phase: "follower-thread-mode-read",
|
|
1370
|
+
});
|
|
1371
|
+
}
|
|
1372
|
+
if (threadModeDisabled) {
|
|
1373
|
+
halted = true;
|
|
1374
|
+
deps.getRegistrationRuntime().stop();
|
|
1375
|
+
deps.setLifecyclePhase(undefined);
|
|
1376
|
+
safeUpdateStatus(ctx);
|
|
1377
|
+
deps.recordRuntimeEvent("bus", "Telegram follower went offline: Threaded Mode is off. Run /telegram-connect after turning it back on.", { phase: "follower-thread-mode-disabled" });
|
|
1378
|
+
return;
|
|
1379
|
+
}
|
|
1345
1380
|
const state = deps.getLeaderState();
|
|
1346
1381
|
if (state.kind === "active-elsewhere") {
|
|
1347
1382
|
clearRegisteredState(ctx);
|
|
@@ -1397,7 +1432,14 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler(deps) {
|
|
|
1397
1432
|
promotionPending = false;
|
|
1398
1433
|
}
|
|
1399
1434
|
};
|
|
1400
|
-
return recover
|
|
1435
|
+
return Object.assign(recover, {
|
|
1436
|
+
halt() {
|
|
1437
|
+
halted = true;
|
|
1438
|
+
},
|
|
1439
|
+
resume() {
|
|
1440
|
+
halted = false;
|
|
1441
|
+
},
|
|
1442
|
+
});
|
|
1401
1443
|
}
|
|
1402
1444
|
export function createTelegramBusFollowerRegistrationRuntime(deps) {
|
|
1403
1445
|
const getNowMs = deps.getNowMs ?? Date.now;
|
package/dist/lib/extension.js
CHANGED
|
@@ -1560,6 +1560,10 @@ export default function (pi) {
|
|
|
1560
1560
|
recovery: {
|
|
1561
1561
|
getLeaderState: lockRuntime.getState,
|
|
1562
1562
|
proveLeaderUnresponsive: proveTelegramLeaderUnresponsive,
|
|
1563
|
+
async isThreadModeDisabled() {
|
|
1564
|
+
await threadStore.refresh?.();
|
|
1565
|
+
return threadStore.getBotState().threadMode === "disabled";
|
|
1566
|
+
},
|
|
1563
1567
|
setLifecyclePhase: telegramBusFollowerControlState.setLifecyclePhase,
|
|
1564
1568
|
updateStatus,
|
|
1565
1569
|
promoteToLeader: promoteTelegramBusFollowerToLeader,
|
package/dist/lib/locks.js
CHANGED
|
@@ -1055,16 +1055,20 @@ export function createTelegramLockRuntime(options = {}) {
|
|
|
1055
1055
|
if (!statePath)
|
|
1056
1056
|
return readLocks(locksPath);
|
|
1057
1057
|
// Read-only ownership queries fail closed (no owner); acquisition/publication still refuse malformed state.
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
.
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1058
|
+
// A strict read racing a concurrent atomic replace fails transiently, so retry before failing closed.
|
|
1059
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
1060
|
+
try {
|
|
1061
|
+
const transports = Object.fromEntries(Object.entries(readTelegramRuntimeState(statePath).profiles)
|
|
1062
|
+
.filter(([, value]) => Object.hasOwn(value, "transport"))
|
|
1063
|
+
.map(([profile, value]) => [profile, value.transport]));
|
|
1064
|
+
assertTelegramStateTransport(transports[resolveEffectiveKey()]);
|
|
1065
|
+
return transports;
|
|
1066
|
+
}
|
|
1067
|
+
catch {
|
|
1068
|
+
// Retry; a persistent failure falls through to no owner.
|
|
1069
|
+
}
|
|
1067
1070
|
}
|
|
1071
|
+
return {};
|
|
1068
1072
|
};
|
|
1069
1073
|
const transactOwners = (mutate) => {
|
|
1070
1074
|
if (!statePath)
|
|
@@ -1440,7 +1444,8 @@ export function createTelegramLockedPollingRuntime(deps) {
|
|
|
1440
1444
|
stopOwnershipWatcher();
|
|
1441
1445
|
ownershipCheckInterval = setInterval(() => {
|
|
1442
1446
|
try {
|
|
1443
|
-
|
|
1447
|
+
// A lock-free miss can be a strict read racing an atomic replace; refresh confirms through the serialized path.
|
|
1448
|
+
if (deps.lock.owns(owner) || deps.lock.refresh(owner))
|
|
1444
1449
|
return;
|
|
1445
1450
|
}
|
|
1446
1451
|
catch (error) {
|
package/dist/lib/sync.js
CHANGED
|
@@ -140,6 +140,7 @@ export function createTelegramManualThreadDisconnectHandler(deps) {
|
|
|
140
140
|
assertCurrent();
|
|
141
141
|
const currentRecord = deps.getCurrentThreadRecord();
|
|
142
142
|
let cleanupPending = false;
|
|
143
|
+
let followerThreadKept = false;
|
|
143
144
|
if (currentRecord?.target.threadId) {
|
|
144
145
|
const isManualFollower = currentRecord.owner?.kind === "manual-follower";
|
|
145
146
|
const leaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
@@ -150,9 +151,8 @@ export function createTelegramManualThreadDisconnectHandler(deps) {
|
|
|
150
151
|
if (deps.disconnectFollowerThread) {
|
|
151
152
|
const disconnected = await deps.disconnectFollowerThread();
|
|
152
153
|
assertCurrent();
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
154
|
+
// Without a live leader the Thread cannot be deleted; still stop locally and never claim deletion.
|
|
155
|
+
followerThreadKept = !disconnected;
|
|
156
156
|
}
|
|
157
157
|
}
|
|
158
158
|
else {
|
|
@@ -223,6 +223,8 @@ export function createTelegramManualThreadDisconnectHandler(deps) {
|
|
|
223
223
|
}
|
|
224
224
|
assertCurrent();
|
|
225
225
|
const stopped = await deps.stopPolling();
|
|
226
|
+
if (followerThreadKept)
|
|
227
|
+
return "Telegram bridge disconnected. Thread kept: no live leader could delete it.";
|
|
226
228
|
return cleanupPending
|
|
227
229
|
? `${stopped} Telegram thread cleanup remains pending for the next leader.`
|
|
228
230
|
: stopped;
|
package/dist/package.json
CHANGED
|
@@ -502,7 +502,7 @@ Named bots use sibling `profiles.<name>` entries. Shared bridge settings remain
|
|
|
502
502
|
|
|
503
503
|
Rules:
|
|
504
504
|
|
|
505
|
-
- Classic mode is selected by Telegram capability: when private-chat threads are unavailable or disabled, the polling owner uses ordinary single-DM behavior and blocked instances do not register as followers. During a live downgrade from Threaded Mode, the current bus leader becomes the classic polling owner after two 2.5-second capability-monitor probes and followers disconnect; if classic polling restore fails transiently, later monitor ticks retry the restore instead of allowing a follower takeover. Followers must not turn the downgrade into a takeover while active thread bindings prove the singleton owner was already established by the bus leader.
|
|
505
|
+
- Classic mode is selected by Telegram capability: when private-chat threads are unavailable or disabled, the polling owner uses ordinary single-DM behavior and blocked instances do not register as followers. During a live downgrade from Threaded Mode, the current bus leader becomes the classic polling owner after two 2.5-second capability-monitor probes and followers disconnect; if classic polling restore fails transiently, later monitor ticks retry the restore instead of allowing a follower takeover. Followers must not turn the downgrade into a takeover while active thread bindings prove the singleton owner was already established by the bus leader. A follower that loses its leader reads the leader-persisted `threadMode`; when it is `disabled`, the follower halts recovery and goes offline with one diagnostic instead of retrying registration, keeping its Thread binding. It stays offline after Threaded Mode returns until the operator runs `/telegram-connect`. A deliberate local stop halts recovery the same way, and `/telegram-disconnect` without a live leader still stops locally, reporting the Thread as kept rather than deleted.
|
|
506
506
|
- Telegram private-chat Threaded Mode enables local leader/follower behavior automatically. The leader owns `getUpdates`; registered followers route Telegram API work through the leader. `/telegram-connect` registers as follower when a live leader exists and does not offer manual takeover in that state. The TUI status bar keeps `telegram connected`, `telegram leader`, or `telegram follower` as stable transport identity during work; active work and queued items share the green Queue count instead of replacing that identity with an `active` processing label. Follower registration is unique by live profile/target: a reload or session replacement must replace stale registry entries rather than leaving multiple routable ids for one Telegram thread, and fallback target ownership must not classify leader records as followers.
|
|
507
507
|
- The thread chat is the owner's private bot DM (`allowedUserId`); no `topics.chatId` config is needed. Thread names are assigned by the bridge from a baked compact per-slot palette. There is no agent-facing `telegram_rename_thread` tool and no separate user-facing slash command for manual thread renames.
|
|
508
508
|
- Thread reuse is extension-owned through profile-scoped Workspace bindings; there is no separate `topics` config surface in the active private-chat thread model. Leader/follower record keys remain live role projections, while normalized exact-`cwd` bindings preserve identity and deterministic same-directory process suffixes across role and process changes.
|
package/lib/bus-follower.ts
CHANGED
|
@@ -572,6 +572,8 @@ export interface TelegramBusFollowerHeartbeatRecoveryHandlerDeps<TContext> {
|
|
|
572
572
|
promotionGraceMs?: number;
|
|
573
573
|
/** Bus liveness proof for a live-PID leader that no longer answers; replaces the retired file heartbeat. */
|
|
574
574
|
proveLeaderUnresponsive?: (owner: TelegramLockEntry) => Promise<boolean>;
|
|
575
|
+
/** Shared bot state says Threaded Mode is off: followers cannot exist, so recovery goes offline instead. */
|
|
576
|
+
isThreadModeDisabled?: () => boolean | Promise<boolean>;
|
|
575
577
|
recordRuntimeEvent: (
|
|
576
578
|
category: string,
|
|
577
579
|
error: unknown,
|
|
@@ -759,7 +761,7 @@ export function createTelegramBusFollowerRuntimeAssembly<
|
|
|
759
761
|
...ports.recovery,
|
|
760
762
|
registrationState: ports.registrationState,
|
|
761
763
|
recordRuntimeEvent: ports.recordRuntimeEvent,
|
|
762
|
-
getRegistrationRuntime: () =>
|
|
764
|
+
getRegistrationRuntime: () => baseRegistration,
|
|
763
765
|
});
|
|
764
766
|
registration = createTelegramBusFollowerRegistrationRuntime({
|
|
765
767
|
...ports.registration,
|
|
@@ -772,6 +774,15 @@ export function createTelegramBusFollowerRuntimeAssembly<
|
|
|
772
774
|
const baseRegistration = registration;
|
|
773
775
|
registration = {
|
|
774
776
|
...baseRegistration,
|
|
777
|
+
// Public stop is deliberate (disconnect, suspension); recovery keeps the base stop for promotion.
|
|
778
|
+
stop() {
|
|
779
|
+
recovery.halt();
|
|
780
|
+
baseRegistration.stop();
|
|
781
|
+
},
|
|
782
|
+
registerWithLeader(ctx, leader, options) {
|
|
783
|
+
recovery.resume();
|
|
784
|
+
return baseRegistration.registerWithLeader(ctx, leader, options);
|
|
785
|
+
},
|
|
775
786
|
async setContext(ctx) {
|
|
776
787
|
const sessionGeneration = ports.registration.getSessionGeneration?.();
|
|
777
788
|
if (ports.registration.isContextActive?.(ctx) === false) return;
|
|
@@ -2206,9 +2217,18 @@ export function createTelegramBusFollowerRegistrationState(
|
|
|
2206
2217
|
};
|
|
2207
2218
|
}
|
|
2208
2219
|
|
|
2220
|
+
/** Heartbeat-failure recovery that a deliberate local stop halts until the next deliberate registration. */
|
|
2221
|
+
export type TelegramBusFollowerHeartbeatRecoveryHandler<TContext> = ((
|
|
2222
|
+
error: unknown,
|
|
2223
|
+
ctx: TContext,
|
|
2224
|
+
) => Promise<void>) & { halt: () => void; resume: () => void };
|
|
2225
|
+
|
|
2209
2226
|
export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
|
|
2210
2227
|
deps: TelegramBusFollowerHeartbeatRecoveryHandlerDeps<TContext>,
|
|
2211
|
-
):
|
|
2228
|
+
): TelegramBusFollowerHeartbeatRecoveryHandler<TContext> {
|
|
2229
|
+
// Promotion stops registration internally and must keep retrying; only an operator stop or a confirmed
|
|
2230
|
+
// switch to classic mode halts recovery, so it never re-registers behind a disconnect.
|
|
2231
|
+
let halted = false;
|
|
2212
2232
|
const promotionGraceMs =
|
|
2213
2233
|
deps.promotionGraceMs ?? TELEGRAM_BUS_FOLLOWER_PROMOTION_GRACE_MS;
|
|
2214
2234
|
const sleep =
|
|
@@ -2250,6 +2270,10 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
|
|
|
2250
2270
|
binding?.target ? { target: binding.target } : undefined,
|
|
2251
2271
|
);
|
|
2252
2272
|
if (!restored) return false;
|
|
2273
|
+
if (halted) {
|
|
2274
|
+
deps.getRegistrationRuntime().stop();
|
|
2275
|
+
return false;
|
|
2276
|
+
}
|
|
2253
2277
|
deps.setLifecyclePhase(undefined);
|
|
2254
2278
|
safeUpdateStatus(ctx);
|
|
2255
2279
|
deps.recordRuntimeEvent(
|
|
@@ -2277,6 +2301,7 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
|
|
|
2277
2301
|
binding: TelegramBusFollowerPromotedBinding,
|
|
2278
2302
|
) => {
|
|
2279
2303
|
const retry = () => {
|
|
2304
|
+
if (halted) return;
|
|
2280
2305
|
const activeCtx = deps.getActiveContext
|
|
2281
2306
|
? deps.getActiveContext()
|
|
2282
2307
|
: fallbackCtx;
|
|
@@ -2376,11 +2401,31 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
|
|
|
2376
2401
|
ctx: TContext,
|
|
2377
2402
|
carriedBinding?: TelegramBusFollowerPromotedBinding,
|
|
2378
2403
|
): Promise<void> => {
|
|
2379
|
-
if (promotionPending) return;
|
|
2404
|
+
if (promotionPending || halted) return;
|
|
2380
2405
|
promotionPending = true;
|
|
2381
2406
|
deps.registrationState.beginRecovery();
|
|
2382
2407
|
const initialBinding = carriedBinding ?? snapshotBinding();
|
|
2383
2408
|
try {
|
|
2409
|
+
let threadModeDisabled = false;
|
|
2410
|
+
try {
|
|
2411
|
+
threadModeDisabled = (await deps.isThreadModeDisabled?.()) === true;
|
|
2412
|
+
} catch (stateError) {
|
|
2413
|
+
deps.recordRuntimeEvent("bus", stateError, {
|
|
2414
|
+
phase: "follower-thread-mode-read",
|
|
2415
|
+
});
|
|
2416
|
+
}
|
|
2417
|
+
if (threadModeDisabled) {
|
|
2418
|
+
halted = true;
|
|
2419
|
+
deps.getRegistrationRuntime().stop();
|
|
2420
|
+
deps.setLifecyclePhase(undefined);
|
|
2421
|
+
safeUpdateStatus(ctx);
|
|
2422
|
+
deps.recordRuntimeEvent(
|
|
2423
|
+
"bus",
|
|
2424
|
+
"Telegram follower went offline: Threaded Mode is off. Run /telegram-connect after turning it back on.",
|
|
2425
|
+
{ phase: "follower-thread-mode-disabled" },
|
|
2426
|
+
);
|
|
2427
|
+
return;
|
|
2428
|
+
}
|
|
2384
2429
|
const state = deps.getLeaderState();
|
|
2385
2430
|
if (state.kind === "active-elsewhere") {
|
|
2386
2431
|
clearRegisteredState(ctx);
|
|
@@ -2456,7 +2501,14 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
|
|
|
2456
2501
|
promotionPending = false;
|
|
2457
2502
|
}
|
|
2458
2503
|
};
|
|
2459
|
-
return recover
|
|
2504
|
+
return Object.assign(recover, {
|
|
2505
|
+
halt() {
|
|
2506
|
+
halted = true;
|
|
2507
|
+
},
|
|
2508
|
+
resume() {
|
|
2509
|
+
halted = false;
|
|
2510
|
+
},
|
|
2511
|
+
});
|
|
2460
2512
|
}
|
|
2461
2513
|
|
|
2462
2514
|
export function createTelegramBusFollowerRegistrationRuntime<
|
package/lib/extension.ts
CHANGED
|
@@ -1996,6 +1996,10 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
1996
1996
|
recovery: {
|
|
1997
1997
|
getLeaderState: lockRuntime.getState,
|
|
1998
1998
|
proveLeaderUnresponsive: proveTelegramLeaderUnresponsive,
|
|
1999
|
+
async isThreadModeDisabled() {
|
|
2000
|
+
await threadStore.refresh?.();
|
|
2001
|
+
return threadStore.getBotState().threadMode === "disabled";
|
|
2002
|
+
},
|
|
1999
2003
|
setLifecyclePhase: telegramBusFollowerControlState.setLifecyclePhase,
|
|
2000
2004
|
updateStatus,
|
|
2001
2005
|
promoteToLeader: promoteTelegramBusFollowerToLeader,
|
package/lib/locks.ts
CHANGED
|
@@ -1521,17 +1521,21 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
|
|
|
1521
1521
|
const readOwners = (): Record<string, unknown> => {
|
|
1522
1522
|
if (!statePath) return readLocks(locksPath);
|
|
1523
1523
|
// Read-only ownership queries fail closed (no owner); acquisition/publication still refuse malformed state.
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
.
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1524
|
+
// A strict read racing a concurrent atomic replace fails transiently, so retry before failing closed.
|
|
1525
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
1526
|
+
try {
|
|
1527
|
+
const transports = Object.fromEntries(
|
|
1528
|
+
Object.entries(readTelegramRuntimeState(statePath).profiles)
|
|
1529
|
+
.filter(([, value]) => Object.hasOwn(value, "transport"))
|
|
1530
|
+
.map(([profile, value]) => [profile, value.transport]),
|
|
1531
|
+
);
|
|
1532
|
+
assertTelegramStateTransport(transports[resolveEffectiveKey()]);
|
|
1533
|
+
return transports;
|
|
1534
|
+
} catch {
|
|
1535
|
+
// Retry; a persistent failure falls through to no owner.
|
|
1536
|
+
}
|
|
1534
1537
|
}
|
|
1538
|
+
return {};
|
|
1535
1539
|
};
|
|
1536
1540
|
const transactOwners = <T>(
|
|
1537
1541
|
mutate: (locks: Record<string, unknown>) => { result: T; changed: boolean },
|
|
@@ -2100,7 +2104,8 @@ export function createTelegramLockedPollingRuntime<
|
|
|
2100
2104
|
stopOwnershipWatcher();
|
|
2101
2105
|
ownershipCheckInterval = setInterval(() => {
|
|
2102
2106
|
try {
|
|
2103
|
-
|
|
2107
|
+
// A lock-free miss can be a strict read racing an atomic replace; refresh confirms through the serialized path.
|
|
2108
|
+
if (deps.lock.owns(owner) || deps.lock.refresh(owner)) return;
|
|
2104
2109
|
} catch (error) {
|
|
2105
2110
|
deps.recordRuntimeEvent?.("lock", error, { phase: "check" });
|
|
2106
2111
|
}
|
package/lib/sync.ts
CHANGED
|
@@ -376,6 +376,7 @@ export function createTelegramManualThreadDisconnectHandler<
|
|
|
376
376
|
assertCurrent();
|
|
377
377
|
const currentRecord = deps.getCurrentThreadRecord();
|
|
378
378
|
let cleanupPending = false;
|
|
379
|
+
let followerThreadKept = false;
|
|
379
380
|
if (currentRecord?.target.threadId) {
|
|
380
381
|
const isManualFollower = currentRecord.owner?.kind === "manual-follower";
|
|
381
382
|
const leaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
@@ -386,11 +387,8 @@ export function createTelegramManualThreadDisconnectHandler<
|
|
|
386
387
|
if (deps.disconnectFollowerThread) {
|
|
387
388
|
const disconnected = await deps.disconnectFollowerThread();
|
|
388
389
|
assertCurrent();
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
"Telegram follower thread deletion requires a live leader registration.",
|
|
392
|
-
);
|
|
393
|
-
}
|
|
390
|
+
// Without a live leader the Thread cannot be deleted; still stop locally and never claim deletion.
|
|
391
|
+
followerThreadKept = !disconnected;
|
|
394
392
|
}
|
|
395
393
|
} else {
|
|
396
394
|
const target = currentRecord.target as TelegramTarget & {
|
|
@@ -483,6 +481,8 @@ export function createTelegramManualThreadDisconnectHandler<
|
|
|
483
481
|
}
|
|
484
482
|
assertCurrent();
|
|
485
483
|
const stopped = await deps.stopPolling();
|
|
484
|
+
if (followerThreadKept)
|
|
485
|
+
return "Telegram bridge disconnected. Thread kept: no live leader could delete it.";
|
|
486
486
|
return cleanupPending
|
|
487
487
|
? `${stopped} Telegram thread cleanup remains pending for the next leader.`
|
|
488
488
|
: stopped;
|