@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 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
- export declare function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(deps: TelegramBusFollowerHeartbeatRecoveryHandlerDeps<TContext>): (error: unknown, ctx: TContext) => Promise<void>;
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>;
@@ -190,7 +190,7 @@ export function createTelegramBusFollowerRuntimeAssembly(ports) {
190
190
  ...ports.recovery,
191
191
  registrationState: ports.registrationState,
192
192
  recordRuntimeEvent: ports.recordRuntimeEvent,
193
- getRegistrationRuntime: () => registration,
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;
@@ -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
- try {
1059
- const transports = Object.fromEntries(Object.entries(readTelegramRuntimeState(statePath).profiles)
1060
- .filter(([, value]) => Object.hasOwn(value, "transport"))
1061
- .map(([profile, value]) => [profile, value.transport]));
1062
- assertTelegramStateTransport(transports[resolveEffectiveKey()]);
1063
- return transports;
1064
- }
1065
- catch {
1066
- return {};
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
- if (deps.lock.owns(owner))
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
- if (!disconnected) {
154
- throw new Error("Telegram follower thread deletion requires a live leader registration.");
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.54.0",
3
+ "version": "0.54.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -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.
@@ -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: () => registration,
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
- ): (error: unknown, ctx: TContext) => Promise<void> {
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
- try {
1525
- const transports = Object.fromEntries(
1526
- Object.entries(readTelegramRuntimeState(statePath).profiles)
1527
- .filter(([, value]) => Object.hasOwn(value, "transport"))
1528
- .map(([profile, value]) => [profile, value.transport]),
1529
- );
1530
- assertTelegramStateTransport(transports[resolveEffectiveKey()]);
1531
- return transports;
1532
- } catch {
1533
- return {};
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
- if (deps.lock.owns(owner)) return;
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
- if (!disconnected) {
390
- throw new Error(
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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.54.0",
3
+ "version": "0.54.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"