@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 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
- 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,
@@ -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
  }
@@ -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
- 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 {};
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
- ownershipCheckInterval = setInterval(() => {
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
- if (deps.lock.owns(owner))
1444
- return;
1455
+ owned = observe();
1445
1456
  }
1446
1457
  catch (error) {
1447
- deps.recordRuntimeEvent?.("lock", error, { phase: "check" });
1458
+ failure = error;
1448
1459
  }
1449
- stopAfterOwnershipLoss();
1450
- }, ownershipCheckMs);
1451
- ownershipRefreshInterval = setInterval(() => {
1452
- try {
1453
- if (deps.lock.refresh(owner))
1454
- return;
1460
+ if (owned === true) {
1461
+ ownershipCheckFailures = 0;
1462
+ return;
1455
1463
  }
1456
- catch (error) {
1457
- deps.recordRuntimeEvent?.("lock", error, { phase: "refresh" });
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
- stopAfterOwnershipLoss();
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
- 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.2",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -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 two-second leader lease refresh mutate it. The steady-state baseline is one cached atomic rewrite every two seconds per active profile, or 43,200 refreshes/day; one-second ownership checks are read-only. 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.
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.
@@ -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,
@@ -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
- 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 {};
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
- ownershipCheckInterval = setInterval(() => {
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
- if (deps.lock.owns(owner)) return;
2115
+ owned = observe();
2104
2116
  } catch (error) {
2105
- deps.recordRuntimeEvent?.("lock", error, { phase: "check" });
2117
+ failure = error;
2118
+ }
2119
+ if (owned === true) {
2120
+ ownershipCheckFailures = 0;
2121
+ return;
2106
2122
  }
2107
- stopAfterOwnershipLoss();
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
- try {
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
- 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.2",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"