@llblab/pi-telegram 0.54.0 → 0.54.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 +10 -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/generative-app-worker.mjs +5 -0
- package/dist/lib/locks.d.ts +2 -0
- package/dist/lib/locks.js +46 -22
- package/dist/lib/sync.js +5 -3
- package/dist/package.json +1 -1
- package/docs/architecture.md +2 -2
- package/docs/multi-instance-bus.md +1 -1
- package/lib/bus-follower.ts +56 -4
- package/lib/extension.ts +4 -0
- package/lib/generative-app-worker.mjs +5 -0
- package/lib/locks.ts +51 -20
- package/lib/sync.ts +5 -5
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,16 @@
|
|
|
4
4
|
|
|
5
5
|
## Unreleased
|
|
6
6
|
|
|
7
|
+
## 0.54.2: Diagnosable ownership checks and safe app cancellation
|
|
8
|
+
|
|
9
|
+
- `Ownership checks`: An ownership check that cannot be verified no longer silently stops a live transport. Each such miss is recorded (`ownership-check-failed`) and polling stands down only after two consecutive tolerated misses; a confirmed loss is recorded as `ownership-lost` and stands down at once (#319).
|
|
10
|
+
- `Generative Apps`: A cancelled app method can no longer start a process: an abort that arrives before the method calls `run` previously let the worker spawn a child that its own termination could orphan.
|
|
11
|
+
|
|
12
|
+
## 0.54.1: Followers go offline when Threaded Mode is turned off
|
|
13
|
+
|
|
14
|
+
- `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.
|
|
15
|
+
- `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.
|
|
16
|
+
|
|
7
17
|
## 0.54.0: No periodic disk writes while idle
|
|
8
18
|
|
|
9
19
|
- `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,
|
|
@@ -22,6 +22,11 @@ parentPort.on("message", (message) => {
|
|
|
22
22
|
});
|
|
23
23
|
|
|
24
24
|
function runBoundedProcess(input) {
|
|
25
|
+
// After an abort the parent may terminate this worker before execFile's own abort kill runs, which
|
|
26
|
+
// would orphan a freshly spawned child; never start a process once cancelled.
|
|
27
|
+
if (controller.signal.aborted) {
|
|
28
|
+
return Promise.reject(new Error("Generative App method was cancelled."));
|
|
29
|
+
}
|
|
25
30
|
if (!input || typeof input !== "object") {
|
|
26
31
|
throw new Error("Generative App run input must be an object.");
|
|
27
32
|
}
|
package/dist/lib/locks.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export declare const TELEGRAM_LOCK_KEY = "default";
|
|
|
8
8
|
export declare const TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS = 8000;
|
|
9
9
|
export declare const TELEGRAM_OWNERSHIP_CHECK_MS = 1000;
|
|
10
10
|
export declare const TELEGRAM_OWNERSHIP_REFRESH_MS = 2000;
|
|
11
|
+
/** Consecutive unverified ownership checks tolerated before standing down; a concurrent shared-state replacement must not stop an owned transport. */
|
|
12
|
+
export declare const TELEGRAM_OWNERSHIP_CHECK_FAILURE_TOLERANCE = 2;
|
|
11
13
|
/**
|
|
12
14
|
* Resolve the extension-local owner slot for the active Telegram profile.
|
|
13
15
|
* Default profile → default
|
package/dist/lib/locks.js
CHANGED
|
@@ -14,6 +14,8 @@ export const TELEGRAM_LOCK_KEY = "default";
|
|
|
14
14
|
export const TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS = 8_000;
|
|
15
15
|
export const TELEGRAM_OWNERSHIP_CHECK_MS = 1_000;
|
|
16
16
|
export const TELEGRAM_OWNERSHIP_REFRESH_MS = 2_000;
|
|
17
|
+
/** Consecutive unverified ownership checks tolerated before standing down; a concurrent shared-state replacement must not stop an owned transport. */
|
|
18
|
+
export const TELEGRAM_OWNERSHIP_CHECK_FAILURE_TOLERANCE = 2;
|
|
17
19
|
const TELEGRAM_LOCK_WRITE_RETRY_ATTEMPTS = 5;
|
|
18
20
|
const TELEGRAM_LOCK_WRITE_RETRY_DELAY_MS = 25;
|
|
19
21
|
const TELEGRAM_LOCK_TRANSACTION_ATTEMPTS = 80;
|
|
@@ -1055,16 +1057,20 @@ export function createTelegramLockRuntime(options = {}) {
|
|
|
1055
1057
|
if (!statePath)
|
|
1056
1058
|
return readLocks(locksPath);
|
|
1057
1059
|
// Read-only ownership queries fail closed (no owner); acquisition/publication still refuse malformed state.
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
.
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1060
|
+
// A strict read racing a concurrent atomic replace fails transiently, so retry before failing closed.
|
|
1061
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
1062
|
+
try {
|
|
1063
|
+
const transports = Object.fromEntries(Object.entries(readTelegramRuntimeState(statePath).profiles)
|
|
1064
|
+
.filter(([, value]) => Object.hasOwn(value, "transport"))
|
|
1065
|
+
.map(([profile, value]) => [profile, value.transport]));
|
|
1066
|
+
assertTelegramStateTransport(transports[resolveEffectiveKey()]);
|
|
1067
|
+
return transports;
|
|
1068
|
+
}
|
|
1069
|
+
catch {
|
|
1070
|
+
// Retry; a persistent failure falls through to no owner.
|
|
1071
|
+
}
|
|
1067
1072
|
}
|
|
1073
|
+
return {};
|
|
1068
1074
|
};
|
|
1069
1075
|
const transactOwners = (mutate) => {
|
|
1070
1076
|
if (!statePath)
|
|
@@ -1375,6 +1381,7 @@ export function createTelegramLockedPollingRuntime(deps) {
|
|
|
1375
1381
|
let ownershipRefreshInterval;
|
|
1376
1382
|
let ownershipStop;
|
|
1377
1383
|
let activeContext;
|
|
1384
|
+
let ownershipCheckFailures = 0;
|
|
1378
1385
|
let takeoverCandidate;
|
|
1379
1386
|
let sessionAutoStartRun;
|
|
1380
1387
|
let pollingGeneration = 0;
|
|
@@ -1438,25 +1445,42 @@ export function createTelegramLockedPollingRuntime(deps) {
|
|
|
1438
1445
|
const startOwnershipWatcher = (ctx) => {
|
|
1439
1446
|
const owner = snapshotLockContext(ctx);
|
|
1440
1447
|
stopOwnershipWatcher();
|
|
1441
|
-
|
|
1448
|
+
ownershipCheckFailures = 0;
|
|
1449
|
+
// A serialized "not the owner" answer is definitive and stands down at once; only an observation that
|
|
1450
|
+
// cannot be verified at all (both reads failed) counts toward the bounded tolerance.
|
|
1451
|
+
const verifyOwnership = (observe) => {
|
|
1452
|
+
let owned;
|
|
1453
|
+
let failure;
|
|
1442
1454
|
try {
|
|
1443
|
-
|
|
1444
|
-
return;
|
|
1455
|
+
owned = observe();
|
|
1445
1456
|
}
|
|
1446
1457
|
catch (error) {
|
|
1447
|
-
|
|
1458
|
+
failure = error;
|
|
1448
1459
|
}
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
try {
|
|
1453
|
-
if (deps.lock.refresh(owner))
|
|
1454
|
-
return;
|
|
1460
|
+
if (owned === true) {
|
|
1461
|
+
ownershipCheckFailures = 0;
|
|
1462
|
+
return;
|
|
1455
1463
|
}
|
|
1456
|
-
|
|
1457
|
-
deps.recordRuntimeEvent?.("lock",
|
|
1464
|
+
if (owned === false) {
|
|
1465
|
+
deps.recordRuntimeEvent?.("lock", new Error("Telegram bridge ownership moved away from this instance."), { phase: "ownership-lost" });
|
|
1466
|
+
stopAfterOwnershipLoss();
|
|
1467
|
+
return;
|
|
1458
1468
|
}
|
|
1459
|
-
|
|
1469
|
+
ownershipCheckFailures += 1;
|
|
1470
|
+
deps.recordRuntimeEvent?.("lock", failure, {
|
|
1471
|
+
phase: "ownership-check-failed",
|
|
1472
|
+
consecutiveFailures: ownershipCheckFailures,
|
|
1473
|
+
tolerance: TELEGRAM_OWNERSHIP_CHECK_FAILURE_TOLERANCE,
|
|
1474
|
+
});
|
|
1475
|
+
if (ownershipCheckFailures > TELEGRAM_OWNERSHIP_CHECK_FAILURE_TOLERANCE)
|
|
1476
|
+
stopAfterOwnershipLoss();
|
|
1477
|
+
};
|
|
1478
|
+
ownershipCheckInterval = setInterval(() => {
|
|
1479
|
+
// A lock-free miss can be a strict read racing an atomic replace; refresh confirms through the serialized path.
|
|
1480
|
+
verifyOwnership(() => deps.lock.owns(owner) || deps.lock.refresh(owner));
|
|
1481
|
+
}, ownershipCheckMs);
|
|
1482
|
+
ownershipRefreshInterval = setInterval(() => {
|
|
1483
|
+
verifyOwnership(() => deps.lock.refresh(owner));
|
|
1460
1484
|
}, ownershipRefreshMs);
|
|
1461
1485
|
ownershipCheckInterval.unref?.();
|
|
1462
1486
|
ownershipRefreshInterval.unref?.();
|
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
package/docs/architecture.md
CHANGED
|
@@ -362,7 +362,7 @@ Read-only migration probes constrain that comparison. Publishing a v2 snapshot o
|
|
|
362
362
|
- Session replacement through `reload`, `new`, `resume`, or `fork` suspends polling/watchers without releasing ownership or publishing inactivity so the next session in the same process can resume. Late shutdown cannot clear a replacement context even when its identity is reused: the captured session generation must still match. For replacements other than `/resume`, a registered follower snapshots its assigned target into a short-lived same-process handoff, stops the old receiver/heartbeat, and re-registers through the live leader without marking or replacing its Telegram thread. Local `/resume` suppresses that source-target handoff and selects the destination session's binding instead. Hard process termination cannot run graceful teardown, so stale recovery retains its restart-hint path.
|
|
363
363
|
- Live external owners require explicit takeover confirmation. Long-lived timers compare against snapshotted owner identity and stop local transport work when the slot no longer matches.
|
|
364
364
|
- The profile's `transport` section owns only Telegram transport control. Local extension and accepted queue state remain per Pi instance when ownership moves, but previews, final delivery, dispatch transport mutations, and delayed Bot API work fail closed until exact direct or follower authority becomes valid again.
|
|
365
|
-
- Exact ownership remains checked every second and re-confirmed every two seconds by lock-free reads; the leader writes no heartbeat, and a live owner is replaceable only by dead-PID or bus liveness proof (see [Leader Election](./multi-instance-bus.md)). Every acquisition, entry migration, release, takeover, and stale recovery is one `transport`-section transaction through the shared `runtime/state.json.transaction` guard (see [Consolidated Runtime Root](#consolidated-runtime-root)); it fences stale recovery and delayed release against replacement-owner ABA and fails closed on malformed state, unverifiable ownership, contention timeout, or unsupported filesystem behavior. Publication uses private staging below `runtime/` and atomic rename.
|
|
365
|
+
- Exact ownership remains checked every second and re-confirmed every two seconds by lock-free reads. A lock-free miss is confirmed through the serialized refresh path: a serialized not-owner answer records `ownership-lost` and stands down at once, while an observation that cannot be verified at all records `ownership-check-failed` and stands down only after two consecutive tolerated misses; the leader writes no heartbeat, and a live owner is replaceable only by dead-PID or bus liveness proof (see [Leader Election](./multi-instance-bus.md)). Every acquisition, entry migration, release, takeover, and stale recovery is one `transport`-section transaction through the shared `runtime/state.json.transaction` guard (see [Consolidated Runtime Root](#consolidated-runtime-root)); it fences stale recovery and delayed release against replacement-owner ABA and fails closed on malformed state, unverifiable ownership, contention timeout, or unsupported filesystem behavior. Publication uses private staging below `runtime/` and atomic rename.
|
|
366
366
|
- `state.json` is authoritative and private: `transport` (ownership), `workspace` (canonical Workspace/Restore evidence) and `admission` per profile. The non-canonical runtime/roster/diagnostics projection is the `runtime` section, published only by the transport owner without touching canonical sections; it and `logs.jsonl` never grant routing authority. The leader reads optional follower slot hints from it; a missing or malformed projection is ignored. Followers remain authenticated bus registrations rather than transport/Workspace/runtime writers; they may publish their process-owned admission leases.
|
|
367
367
|
- Ordinary ownership and state mutations fail closed on a malformed or foreign `state.json`; read-only ownership queries report no owner, not proof of process absence. The legacy whole-file reset is not wired for the shared envelope. A non-election leader start performs the [damaged-state reset](#damaged-state-reset-operator-approved); section-scoped recovery is not planned. Startup best-effort removes obsolete current-runtime root/session `recovery/` directories, never the pre-0.52.0 tree.
|
|
368
368
|
|
|
@@ -422,7 +422,7 @@ The reset deliberately forgets every profile's polling cursor/journal pointer, b
|
|
|
422
422
|
|
|
423
423
|
The runtime sections and log have different authority and write pressure. Preserve that distinction when optimizing them:
|
|
424
424
|
|
|
425
|
-
- The `transport` section is safety-critical authority. Acquire, release, takeover, stale recovery, and
|
|
425
|
+
- The `transport` section is safety-critical authority. Acquire, release, takeover, stale recovery, and entry migration mutate it. Since 0.54 the steady-state baseline is zero rewrites: the one- and two-second ownership checks are lock-free reads. Every mutation serializes the full cross-process read/check/write through the shared `runtime/state.json.transaction` guard and replaces only that profile's section.
|
|
426
426
|
- The `workspace` section holds recovery-critical thread/capability state. Every explicit thread-store `persist()` builds a semantic snapshot, but an unchanged payload skips directory preparation, temporary-name generation, file creation, and rename after comparing JSON values independently of object-key order and ignoring `writtenAtMs`; array order, value types, and explicit null remain significant, and changed snapshots retain the full atomic replacement path. No-op saves still read and compare current disk state, rather than trusting a cache that could hide another owner's publication. Diagnostics scheduling coalesces requests across a bounded 100 ms window and writes only the `runtime` section (`persistStatus()`, skipped when the JSON value is unchanged), so an idle leader's polling snapshots never rewrite the canonical `workspace` section. Only the exact transport owner commits either section; non-owners reload current state instead of publishing.
|
|
427
427
|
- `logs.jsonl` is fail-soft observational evidence, never routing authority. Runtime events admitted in one JavaScript turn batch by captured profile path into one size check, one profile-wide file transaction, and one append while preserving event order. Batching adds no timer or shutdown-loss window; separate profiles remain isolated, and one failed group does not drop another. Scope reset and rotation retain their serialized copy/replace path. The 5 MiB value is a rotation threshold: an authorized writer rotates between batched records before the next record crosses it, so overshoot is bounded to one admitted record plus reset metadata; a writer without reset authority defers rotation to the owner.
|
|
428
428
|
|
|
@@ -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,
|
|
@@ -22,6 +22,11 @@ parentPort.on("message", (message) => {
|
|
|
22
22
|
});
|
|
23
23
|
|
|
24
24
|
function runBoundedProcess(input) {
|
|
25
|
+
// After an abort the parent may terminate this worker before execFile's own abort kill runs, which
|
|
26
|
+
// would orphan a freshly spawned child; never start a process once cancelled.
|
|
27
|
+
if (controller.signal.aborted) {
|
|
28
|
+
return Promise.reject(new Error("Generative App method was cancelled."));
|
|
29
|
+
}
|
|
25
30
|
if (!input || typeof input !== "object") {
|
|
26
31
|
throw new Error("Generative App run input must be an object.");
|
|
27
32
|
}
|
package/lib/locks.ts
CHANGED
|
@@ -38,6 +38,8 @@ export const TELEGRAM_LOCK_KEY = "default";
|
|
|
38
38
|
export const TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS = 8_000;
|
|
39
39
|
export const TELEGRAM_OWNERSHIP_CHECK_MS = 1_000;
|
|
40
40
|
export const TELEGRAM_OWNERSHIP_REFRESH_MS = 2_000;
|
|
41
|
+
/** Consecutive unverified ownership checks tolerated before standing down; a concurrent shared-state replacement must not stop an owned transport. */
|
|
42
|
+
export const TELEGRAM_OWNERSHIP_CHECK_FAILURE_TOLERANCE = 2;
|
|
41
43
|
const TELEGRAM_LOCK_WRITE_RETRY_ATTEMPTS = 5;
|
|
42
44
|
const TELEGRAM_LOCK_WRITE_RETRY_DELAY_MS = 25;
|
|
43
45
|
const TELEGRAM_LOCK_TRANSACTION_ATTEMPTS = 80;
|
|
@@ -1521,17 +1523,21 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
|
|
|
1521
1523
|
const readOwners = (): Record<string, unknown> => {
|
|
1522
1524
|
if (!statePath) return readLocks(locksPath);
|
|
1523
1525
|
// Read-only ownership queries fail closed (no owner); acquisition/publication still refuse malformed state.
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
.
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1526
|
+
// A strict read racing a concurrent atomic replace fails transiently, so retry before failing closed.
|
|
1527
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
1528
|
+
try {
|
|
1529
|
+
const transports = Object.fromEntries(
|
|
1530
|
+
Object.entries(readTelegramRuntimeState(statePath).profiles)
|
|
1531
|
+
.filter(([, value]) => Object.hasOwn(value, "transport"))
|
|
1532
|
+
.map(([profile, value]) => [profile, value.transport]),
|
|
1533
|
+
);
|
|
1534
|
+
assertTelegramStateTransport(transports[resolveEffectiveKey()]);
|
|
1535
|
+
return transports;
|
|
1536
|
+
} catch {
|
|
1537
|
+
// Retry; a persistent failure falls through to no owner.
|
|
1538
|
+
}
|
|
1534
1539
|
}
|
|
1540
|
+
return {};
|
|
1535
1541
|
};
|
|
1536
1542
|
const transactOwners = <T>(
|
|
1537
1543
|
mutate: (locks: Record<string, unknown>) => { result: T; changed: boolean },
|
|
@@ -2035,6 +2041,7 @@ export function createTelegramLockedPollingRuntime<
|
|
|
2035
2041
|
let ownershipRefreshInterval: ReturnType<typeof setInterval> | undefined;
|
|
2036
2042
|
let ownershipStop: Promise<void> | undefined;
|
|
2037
2043
|
let activeContext: TContext | undefined;
|
|
2044
|
+
let ownershipCheckFailures = 0;
|
|
2038
2045
|
let takeoverCandidate: TelegramLockEntry | undefined;
|
|
2039
2046
|
let sessionAutoStartRun: Promise<void> | undefined;
|
|
2040
2047
|
let pollingGeneration = 0;
|
|
@@ -2098,21 +2105,45 @@ export function createTelegramLockedPollingRuntime<
|
|
|
2098
2105
|
const startOwnershipWatcher = (ctx: TContext) => {
|
|
2099
2106
|
const owner = snapshotLockContext(ctx);
|
|
2100
2107
|
stopOwnershipWatcher();
|
|
2101
|
-
|
|
2108
|
+
ownershipCheckFailures = 0;
|
|
2109
|
+
// A serialized "not the owner" answer is definitive and stands down at once; only an observation that
|
|
2110
|
+
// cannot be verified at all (both reads failed) counts toward the bounded tolerance.
|
|
2111
|
+
const verifyOwnership = (observe: () => boolean): void => {
|
|
2112
|
+
let owned: boolean | undefined;
|
|
2113
|
+
let failure: unknown;
|
|
2102
2114
|
try {
|
|
2103
|
-
|
|
2115
|
+
owned = observe();
|
|
2104
2116
|
} catch (error) {
|
|
2105
|
-
|
|
2117
|
+
failure = error;
|
|
2118
|
+
}
|
|
2119
|
+
if (owned === true) {
|
|
2120
|
+
ownershipCheckFailures = 0;
|
|
2121
|
+
return;
|
|
2106
2122
|
}
|
|
2107
|
-
|
|
2123
|
+
if (owned === false) {
|
|
2124
|
+
deps.recordRuntimeEvent?.(
|
|
2125
|
+
"lock",
|
|
2126
|
+
new Error("Telegram bridge ownership moved away from this instance."),
|
|
2127
|
+
{ phase: "ownership-lost" },
|
|
2128
|
+
);
|
|
2129
|
+
stopAfterOwnershipLoss();
|
|
2130
|
+
return;
|
|
2131
|
+
}
|
|
2132
|
+
ownershipCheckFailures += 1;
|
|
2133
|
+
deps.recordRuntimeEvent?.("lock", failure, {
|
|
2134
|
+
phase: "ownership-check-failed",
|
|
2135
|
+
consecutiveFailures: ownershipCheckFailures,
|
|
2136
|
+
tolerance: TELEGRAM_OWNERSHIP_CHECK_FAILURE_TOLERANCE,
|
|
2137
|
+
});
|
|
2138
|
+
if (ownershipCheckFailures > TELEGRAM_OWNERSHIP_CHECK_FAILURE_TOLERANCE)
|
|
2139
|
+
stopAfterOwnershipLoss();
|
|
2140
|
+
};
|
|
2141
|
+
ownershipCheckInterval = setInterval(() => {
|
|
2142
|
+
// A lock-free miss can be a strict read racing an atomic replace; refresh confirms through the serialized path.
|
|
2143
|
+
verifyOwnership(() => deps.lock.owns(owner) || deps.lock.refresh(owner));
|
|
2108
2144
|
}, ownershipCheckMs);
|
|
2109
2145
|
ownershipRefreshInterval = setInterval(() => {
|
|
2110
|
-
|
|
2111
|
-
if (deps.lock.refresh(owner)) return;
|
|
2112
|
-
} catch (error) {
|
|
2113
|
-
deps.recordRuntimeEvent?.("lock", error, { phase: "refresh" });
|
|
2114
|
-
}
|
|
2115
|
-
stopAfterOwnershipLoss();
|
|
2146
|
+
verifyOwnership(() => deps.lock.refresh(owner));
|
|
2116
2147
|
}, ownershipRefreshMs);
|
|
2117
2148
|
ownershipCheckInterval.unref?.();
|
|
2118
2149
|
ownershipRefreshInterval.unref?.();
|
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;
|