@llblab/pi-telegram 0.54.1 → 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,11 @@
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
+
7
12
  ## 0.54.1: Followers go offline when Threaded Mode is turned off
8
13
 
9
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.
@@ -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;
@@ -1379,6 +1381,7 @@ export function createTelegramLockedPollingRuntime(deps) {
1379
1381
  let ownershipRefreshInterval;
1380
1382
  let ownershipStop;
1381
1383
  let activeContext;
1384
+ let ownershipCheckFailures = 0;
1382
1385
  let takeoverCandidate;
1383
1386
  let sessionAutoStartRun;
1384
1387
  let pollingGeneration = 0;
@@ -1442,26 +1445,42 @@ export function createTelegramLockedPollingRuntime(deps) {
1442
1445
  const startOwnershipWatcher = (ctx) => {
1443
1446
  const owner = snapshotLockContext(ctx);
1444
1447
  stopOwnershipWatcher();
1445
- 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;
1446
1454
  try {
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))
1449
- return;
1455
+ owned = observe();
1450
1456
  }
1451
1457
  catch (error) {
1452
- deps.recordRuntimeEvent?.("lock", error, { phase: "check" });
1458
+ failure = error;
1453
1459
  }
1454
- stopAfterOwnershipLoss();
1455
- }, ownershipCheckMs);
1456
- ownershipRefreshInterval = setInterval(() => {
1457
- try {
1458
- if (deps.lock.refresh(owner))
1459
- return;
1460
+ if (owned === true) {
1461
+ ownershipCheckFailures = 0;
1462
+ return;
1460
1463
  }
1461
- catch (error) {
1462
- 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;
1463
1468
  }
1464
- 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));
1465
1484
  }, ownershipRefreshMs);
1466
1485
  ownershipCheckInterval.unref?.();
1467
1486
  ownershipRefreshInterval.unref?.();
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.54.1",
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
 
@@ -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;
@@ -2039,6 +2041,7 @@ export function createTelegramLockedPollingRuntime<
2039
2041
  let ownershipRefreshInterval: ReturnType<typeof setInterval> | undefined;
2040
2042
  let ownershipStop: Promise<void> | undefined;
2041
2043
  let activeContext: TContext | undefined;
2044
+ let ownershipCheckFailures = 0;
2042
2045
  let takeoverCandidate: TelegramLockEntry | undefined;
2043
2046
  let sessionAutoStartRun: Promise<void> | undefined;
2044
2047
  let pollingGeneration = 0;
@@ -2102,22 +2105,45 @@ export function createTelegramLockedPollingRuntime<
2102
2105
  const startOwnershipWatcher = (ctx: TContext) => {
2103
2106
  const owner = snapshotLockContext(ctx);
2104
2107
  stopOwnershipWatcher();
2105
- 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;
2106
2114
  try {
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;
2115
+ owned = observe();
2109
2116
  } catch (error) {
2110
- deps.recordRuntimeEvent?.("lock", error, { phase: "check" });
2117
+ failure = error;
2118
+ }
2119
+ if (owned === true) {
2120
+ ownershipCheckFailures = 0;
2121
+ return;
2122
+ }
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;
2111
2131
  }
2112
- stopAfterOwnershipLoss();
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));
2113
2144
  }, ownershipCheckMs);
2114
2145
  ownershipRefreshInterval = setInterval(() => {
2115
- try {
2116
- if (deps.lock.refresh(owner)) return;
2117
- } catch (error) {
2118
- deps.recordRuntimeEvent?.("lock", error, { phase: "refresh" });
2119
- }
2120
- stopAfterOwnershipLoss();
2146
+ verifyOwnership(() => deps.lock.refresh(owner));
2121
2147
  }, ownershipRefreshMs);
2122
2148
  ownershipCheckInterval.unref?.();
2123
2149
  ownershipRefreshInterval.unref?.();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.54.1",
3
+ "version": "0.54.2",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"