@llblab/pi-telegram 0.53.1 → 0.54.0

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/lib/locks.ts CHANGED
@@ -141,6 +141,8 @@ interface TelegramLockAcquireOptions {
141
141
  force?: boolean;
142
142
  expectedOwner?: TelegramLockEntry;
143
143
  election?: boolean;
144
+ /** Bus-proven unresponsive owner: treated as stale only while it is still exactly the current owner. */
145
+ unresponsiveOwner?: TelegramLockEntry;
144
146
  }
145
147
 
146
148
  type TelegramLockAcquireResult =
@@ -1381,6 +1383,14 @@ function hasSameLockOwner(
1381
1383
  );
1382
1384
  }
1383
1385
 
1386
+ /** Exact owner identity (pid, cwd, instance, leader epoch, runtime generation); absent entries never match. */
1387
+ export function isSameTelegramLockOwner(
1388
+ current: TelegramLockEntry | undefined,
1389
+ expected: TelegramLockEntry | undefined,
1390
+ ): boolean {
1391
+ return hasSameLockOwner(current, expected);
1392
+ }
1393
+
1384
1394
  function canSupersedeSameProcessOwner(
1385
1395
  current: TelegramLockEntry,
1386
1396
  pid: number,
@@ -1408,7 +1418,6 @@ function createLockEntry(
1408
1418
  instanceId?: string;
1409
1419
  busSocketPath?: string;
1410
1420
  busSecret?: string;
1411
- getNowMs?: () => number;
1412
1421
  mintLeaderEpoch?: () => number | string;
1413
1422
  runtimeGeneration?: number;
1414
1423
  journalPath?: string;
@@ -1417,9 +1426,7 @@ function createLockEntry(
1417
1426
  const lock: TelegramLockEntry = { pid, cwd: ctx.cwd };
1418
1427
  if (options.journalPath) lock.journalPath = options.journalPath;
1419
1428
  if (options.instanceId) {
1420
- const nowMs = options.getNowMs?.();
1421
1429
  lock.instanceId = options.instanceId;
1422
- lock.heartbeatMs = nowMs;
1423
1430
  lock.leaderEpoch = options.mintLeaderEpoch?.() ?? randomUUID();
1424
1431
  lock.runtimeGeneration = options.runtimeGeneration;
1425
1432
  }
@@ -1623,7 +1630,17 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
1623
1630
  changed: false,
1624
1631
  };
1625
1632
  const current = parseTelegramLockEntry(locks[effectiveKey]);
1626
- const state = getLockState(current, pid, isAlive, stateOptions());
1633
+ const observedState = getLockState(
1634
+ current,
1635
+ pid,
1636
+ isAlive,
1637
+ stateOptions(),
1638
+ );
1639
+ const state: TelegramLockState =
1640
+ observedState.kind === "active-elsewhere" &&
1641
+ hasSameLockOwner(current, acquireOptions.unresponsiveOwner)
1642
+ ? { kind: "stale", lock: observedState.lock }
1643
+ : observedState;
1627
1644
  const expectedOwned = adoptCompatibleOwnedLock(
1628
1645
  effectiveKey,
1629
1646
  current,
@@ -1691,7 +1708,6 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
1691
1708
  instanceId: options.instanceId,
1692
1709
  busSocketPath: options.busSocketPath,
1693
1710
  busSecret: options.busSecret,
1694
- getNowMs,
1695
1711
  mintLeaderEpoch: options.mintLeaderEpoch,
1696
1712
  runtimeGeneration,
1697
1713
  });
@@ -1846,39 +1862,65 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
1846
1862
  { ...publication, isCurrent },
1847
1863
  );
1848
1864
  },
1849
- refresh: (ctx) =>
1850
- !deliveryRevoked &&
1851
- transactOwners((locks) => {
1852
- const effectiveKey = resolveEffectiveKey();
1853
- const lock = parseTelegramLockEntry(locks[effectiveKey]);
1854
- const expectedOwner = adoptCompatibleOwnedLock(effectiveKey, lock, ctx);
1855
- if (!lock || !hasSameLockOwner(lock, expectedOwner)) {
1856
- if (ownedLockKey === effectiveKey) {
1857
- ownedLockKey = undefined;
1858
- ownedLock = undefined;
1859
- }
1860
- return { result: false, changed: false };
1861
- }
1862
- if (!options.instanceId) return { result: true, changed: false };
1863
- const refreshedLock: TelegramLockEntry = {
1865
+ refresh: (ctx) => {
1866
+ if (deliveryRevoked) return false;
1867
+ // Owner fields only, never a timestamp: liveness is proven over the bus, so an exact owner needs no write.
1868
+ const refreshedEntry = (lock: TelegramLockEntry): TelegramLockEntry => {
1869
+ const busSecret = options.busSecret ?? lock.busSecret;
1870
+ return {
1864
1871
  pid: lock.pid,
1865
1872
  ...(lock.cwd ? { cwd: lock.cwd } : {}),
1866
1873
  instanceId: options.instanceId,
1867
- heartbeatMs: getNowMs(),
1868
1874
  leaderEpoch:
1869
1875
  lock.leaderEpoch ?? options.mintLeaderEpoch?.() ?? randomUUID(),
1870
1876
  runtimeGeneration: lock.runtimeGeneration ?? runtimeGeneration,
1871
1877
  ...(options.busSocketPath
1872
1878
  ? { busSocketPath: options.busSocketPath }
1873
1879
  : {}),
1874
- busSecret: options.busSecret ?? lock.busSecret,
1880
+ ...(busSecret !== undefined ? { busSecret } : {}),
1875
1881
  ...(lock.journalPath ? { journalPath: lock.journalPath } : {}),
1876
1882
  };
1883
+ };
1884
+ // Parsed entries carry explicit `undefined` fields; compare only the published ones.
1885
+ const isCurrentEntry = (lock: TelegramLockEntry): boolean =>
1886
+ lock.leaderEpoch !== undefined &&
1887
+ isDeepStrictEqual(
1888
+ Object.fromEntries(
1889
+ Object.entries(lock).filter(([, value]) => value !== undefined),
1890
+ ),
1891
+ refreshedEntry(lock),
1892
+ );
1893
+ const effectiveKey = resolveEffectiveKey();
1894
+ const observed = readLock();
1895
+ if (
1896
+ observed &&
1897
+ hasSameLockOwner(
1898
+ observed,
1899
+ adoptCompatibleOwnedLock(effectiveKey, observed, ctx),
1900
+ ) &&
1901
+ (!options.instanceId || isCurrentEntry(observed))
1902
+ ) {
1903
+ return true;
1904
+ }
1905
+ return transactOwners((locks) => {
1906
+ const lock = parseTelegramLockEntry(locks[effectiveKey]);
1907
+ const expectedOwner = adoptCompatibleOwnedLock(effectiveKey, lock, ctx);
1908
+ if (!lock || !hasSameLockOwner(lock, expectedOwner)) {
1909
+ if (ownedLockKey === effectiveKey) {
1910
+ ownedLockKey = undefined;
1911
+ ownedLock = undefined;
1912
+ }
1913
+ return { result: false, changed: false };
1914
+ }
1915
+ if (!options.instanceId) return { result: true, changed: false };
1916
+ if (isCurrentEntry(lock)) return { result: true, changed: false };
1917
+ const refreshedLock = refreshedEntry(lock);
1877
1918
  locks[effectiveKey] = refreshedLock;
1878
1919
  ownedLockKey = effectiveKey;
1879
1920
  ownedLock = refreshedLock;
1880
1921
  return { result: true, changed: true };
1881
- }),
1922
+ });
1923
+ },
1882
1924
  };
1883
1925
  }
1884
1926
 
@@ -1906,7 +1948,7 @@ interface TelegramLockedPollingStartOptions {
1906
1948
  force?: boolean;
1907
1949
  forceFreshLeaderThread?: boolean;
1908
1950
  requestedThreadName?: string;
1909
- election?: { expectedOwner?: TelegramLockEntry };
1951
+ election?: { expectedOwner?: TelegramLockEntry; unresponsive?: boolean };
1910
1952
  onAcquired?: () => Promise<void> | void;
1911
1953
  }
1912
1954
 
@@ -1966,6 +2008,8 @@ interface TelegramLockedPollingRuntimeDeps<
1966
2008
  owner: TelegramLockEntry,
1967
2009
  ) => boolean | undefined | Promise<boolean | undefined>;
1968
2010
  stopFollowerRegistration?: () => void;
2011
+ /** Threaded Mode takeover evidence after failed follower registration; replaces the retired file heartbeat. */
2012
+ proveOwnerUnresponsive?: (owner: TelegramLockEntry) => Promise<boolean>;
1969
2013
  onTransportAvailabilityChanged?: () => void;
1970
2014
  transportMonitor?: { start: (ctx: TContext) => void; stop: () => void };
1971
2015
  updateStatus: (ctx: TContext) => void;
@@ -2187,6 +2231,9 @@ export function createTelegramLockedPollingRuntime<
2187
2231
  options.election?.expectedOwner ??
2188
2232
  (options.force ? takeoverCandidate : undefined),
2189
2233
  election: options.election !== undefined,
2234
+ unresponsiveOwner: options.election?.unresponsive
2235
+ ? options.election.expectedOwner
2236
+ : undefined,
2190
2237
  });
2191
2238
  if (!acquired.ok && !options.election) {
2192
2239
  const currentState = deps.lock.getState();
@@ -2231,22 +2278,34 @@ export function createTelegramLockedPollingRuntime<
2231
2278
  });
2232
2279
  }
2233
2280
  if (failureMessage) {
2234
- const owner = formatTelegramLockEntry(acquired.lock);
2235
- return {
2236
- ok: false,
2237
- canTakeover: false,
2238
- owner,
2239
- message: `Telegram bridge is active in another Pi instance (${owner}); follower registration failed: ${formatTelegramFollowerRegistrationFailure(failureMessage)}.`,
2240
- };
2281
+ const unresponsiveOwner = acquired.lock;
2282
+ if (
2283
+ deps.proveOwnerUnresponsive &&
2284
+ (await deps.proveOwnerUnresponsive(unresponsiveOwner))
2285
+ ) {
2286
+ if (!isCurrent()) return cancelled;
2287
+ acquired = deps.lock.acquire(ctx, { unresponsiveOwner });
2288
+ }
2289
+ if (!acquired.ok) {
2290
+ const owner = formatTelegramLockEntry(acquired.lock);
2291
+ return {
2292
+ ok: false,
2293
+ canTakeover: false,
2294
+ owner,
2295
+ message: `Telegram bridge is active in another Pi instance (${owner}); follower registration failed: ${formatTelegramFollowerRegistrationFailure(failureMessage)}.`,
2296
+ };
2297
+ }
2241
2298
  }
2242
2299
  }
2243
- const owner = formatTelegramLockEntry(acquired.lock);
2244
- return {
2245
- ok: false,
2246
- canTakeover: true,
2247
- owner,
2248
- message: `Telegram bridge is active in another Pi instance (${owner}).`,
2249
- };
2300
+ if (!acquired.ok) {
2301
+ const owner = formatTelegramLockEntry(acquired.lock);
2302
+ return {
2303
+ ok: false,
2304
+ canTakeover: true,
2305
+ owner,
2306
+ message: `Telegram bridge is active in another Pi instance (${owner}).`,
2307
+ };
2308
+ }
2250
2309
  }
2251
2310
  takeoverCandidate = undefined;
2252
2311
  if (!(await runOwnedPollingStart(ctx, options, isCurrent))) {
package/lib/status.ts CHANGED
@@ -1008,19 +1008,38 @@ export function createTelegramRuntimeProjectionStore(
1008
1008
  if (!isRuntimeProjection({ ...semantic, writtenAtMs: 0 }))
1009
1009
  throw new Error("Telegram runtime projection is malformed.");
1010
1010
  delete semantic.diagnostics.recentRuntimeEvents;
1011
+ // Poll-cycle timestamps change on every response; live status renders them from memory,
1012
+ // so persisting them would rewrite state.json each poll for no reader.
1013
+ const polling = semantic.runtime.polling;
1014
+ if (polling && typeof polling === "object" && !Array.isArray(polling)) {
1015
+ const volatile = polling as Record<string, unknown>;
1016
+ delete volatile.phaseStartedAtMs;
1017
+ delete volatile.lastSuccessfulResponseAtMs;
1018
+ delete volatile.lastSuccessfulResponseUpdateCount;
1019
+ }
1011
1020
  const isCurrent = (): boolean =>
1012
1021
  getPath() === path && getProfile() === profile && authority() === true;
1022
+ const isUnchanged = (
1023
+ current: unknown,
1024
+ ): current is TelegramStoredRuntimeProjection => {
1025
+ if (!isRuntimeProjection(current)) return false;
1026
+ const { writtenAtMs: _writtenAtMs, ...existing } = current;
1027
+ return isDeepStrictEqual(existing, semantic);
1028
+ };
1013
1029
  const publication = publicationQueue.then(() => {
1014
1030
  if (!isCurrent()) return false;
1031
+ // Observational state needs no transaction when the published projection already matches.
1032
+ try {
1033
+ if (isUnchanged(read({ path, profile }))) return false;
1034
+ } catch {
1035
+ // An unreadable envelope stays with the transactional publisher's fail-closed path.
1036
+ }
1015
1037
  return (
1016
1038
  publish(
1017
1039
  { path, profile },
1018
1040
  (current) => {
1019
- if (isRuntimeProjection(current)) {
1020
- const { writtenAtMs: _writtenAtMs, ...existing } = current;
1021
- if (isDeepStrictEqual(existing, semantic))
1022
- return { value: current, changed: false };
1023
- }
1041
+ if (isUnchanged(current))
1042
+ return { value: current, changed: false };
1024
1043
  const writtenAtMs = getNowMs();
1025
1044
  if (!Number.isSafeInteger(writtenAtMs) || writtenAtMs < 0)
1026
1045
  throw new Error(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.53.1",
3
+ "version": "0.54.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"