@llblab/pi-telegram 0.53.2 → 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/bus.ts CHANGED
@@ -45,7 +45,12 @@ import {
45
45
  type TelegramBusTransportRetryPolicy,
46
46
  } from "./bus-transport.ts";
47
47
  import type { TelegramThreadDisplayMode } from "./config.ts";
48
- import { TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS } from "./locks.ts";
48
+ import {
49
+ isSameTelegramLockOwner,
50
+ TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS,
51
+ type TelegramLockEntry,
52
+ type TelegramLockState,
53
+ } from "./locks.ts";
49
54
  import { resolveAgentDir } from "./paths.ts";
50
55
  import { getTelegramProcessBirthIdentity } from "./process-identity.ts";
51
56
  import {
@@ -131,7 +136,8 @@ export function createTelegramBusAuthSecret(): string {
131
136
  return randomBytes(32).toString("base64url");
132
137
  }
133
138
 
134
- const TELEGRAM_BUS_PROTOCOL_VERSION = 2 as const;
139
+ // v3: journal source serialization left the config transaction; mixed v2/v3 peers would not exclude each other.
140
+ const TELEGRAM_BUS_PROTOCOL_VERSION = 3 as const;
135
141
  export const TELEGRAM_BUS_CAPABILITY_DURABLE_FOLLOWER_ADMISSION =
136
142
  "durable-follower-admission-v1" as const;
137
143
  export const TELEGRAM_BUS_CAPABILITY_QUEUE_HANDOFF =
@@ -1222,6 +1228,11 @@ export type TelegramBusEnvelope = (
1222
1228
  args: unknown[];
1223
1229
  sentAtMs: number;
1224
1230
  }
1231
+ | {
1232
+ /** Side-effect-free leader liveness probe; any acknowledgement proves a responsive event loop. */
1233
+ kind: "bus.probe";
1234
+ requestId: string;
1235
+ }
1225
1236
  | {
1226
1237
  kind: "bus.ack";
1227
1238
  requestId: string;
@@ -1269,7 +1280,8 @@ export function getTelegramBusEnvelopeTrafficClass(
1269
1280
  ): TelegramBusEnvelopeTrafficClass {
1270
1281
  if (
1271
1282
  envelope.kind === "follower.register" ||
1272
- envelope.kind === "follower.restoreWorkspace"
1283
+ envelope.kind === "follower.restoreWorkspace" ||
1284
+ envelope.kind === "bus.probe"
1273
1285
  )
1274
1286
  return "bootstrap";
1275
1287
  if (envelope.kind === "bus.ack") return "response";
@@ -1579,6 +1591,9 @@ export function parseTelegramBusEnvelope(
1579
1591
  case "follower.callApi":
1580
1592
  envelope = parseCallApiEnvelope(value, requestId);
1581
1593
  break;
1594
+ case "bus.probe":
1595
+ envelope = { kind, requestId };
1596
+ break;
1582
1597
  case "bus.ack":
1583
1598
  envelope = parseAckEnvelope(value, requestId);
1584
1599
  break;
@@ -1590,6 +1605,112 @@ export function parseTelegramBusEnvelope(
1590
1605
  return envelope;
1591
1606
  }
1592
1607
 
1608
+ /**
1609
+ * One leader liveness observation. `silent` (connected or connecting, no answer within the window) and
1610
+ * `unreachable` (an endpoint that refuses connections: nobody listens) can support a takeover. A missing endpoint is
1611
+ * `unknown`: a live classic-mode leader has no socket at all. Any bytes or an orderly close prove the peer's event
1612
+ * loop handled the connection, so even an older leader rejecting the unknown kind is `responsive`.
1613
+ */
1614
+ export type TelegramBusLeaderProbeResult =
1615
+ | "responsive"
1616
+ | "silent"
1617
+ | "unreachable"
1618
+ | "unknown";
1619
+
1620
+ export function probeTelegramBusLeader(input: {
1621
+ socketPath: TelegramBusSocketPathSource;
1622
+ secret?: string;
1623
+ timeoutMs?: number;
1624
+ }): Promise<TelegramBusLeaderProbeResult> {
1625
+ const timeoutMs = input.timeoutMs ?? TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS;
1626
+ return new Promise((resolve) => {
1627
+ let settled = false;
1628
+ let socket: Socket | undefined;
1629
+ const finish = (result: TelegramBusLeaderProbeResult) => {
1630
+ if (settled) return;
1631
+ settled = true;
1632
+ clearTimeout(timer);
1633
+ socket?.destroy();
1634
+ resolve(result);
1635
+ };
1636
+ const timer = setTimeout(() => finish("silent"), timeoutMs);
1637
+ let connected = false;
1638
+ try {
1639
+ socket = createConnection(resolveTelegramBusSocketPath(input.socketPath));
1640
+ } catch {
1641
+ finish("unknown");
1642
+ return;
1643
+ }
1644
+ socket.once("connect", () => {
1645
+ connected = true;
1646
+ socket!.write(
1647
+ encodeTelegramBusEnvelope({
1648
+ kind: "bus.probe",
1649
+ requestId: `probe:${randomBytes(8).toString("hex")}`,
1650
+ ...(input.secret ? { auth: input.secret } : {}),
1651
+ }),
1652
+ );
1653
+ });
1654
+ socket.once("data", () => finish("responsive"));
1655
+ socket.once("end", () => finish("responsive"));
1656
+ socket.once("error", (error) => {
1657
+ const code = (error as NodeJS.ErrnoException).code;
1658
+ finish(
1659
+ !connected && code === "ECONNREFUSED" ? "unreachable" : "unknown",
1660
+ );
1661
+ });
1662
+ });
1663
+ }
1664
+
1665
+ /**
1666
+ * Takeover evidence replacing the retired file heartbeat: a full silent window, or an unreachable endpoint
1667
+ * confirmed again after one more window (a just-started leader binds its socket well within it). The owner must
1668
+ * stay the same throughout; the lock acquisition then CAS-checks that exact owner.
1669
+ */
1670
+ export async function proveTelegramBusLeaderUnresponsive(input: {
1671
+ probe: () => Promise<TelegramBusLeaderProbeResult>;
1672
+ isSameOwner: () => boolean;
1673
+ sleep: (ms: number) => Promise<void>;
1674
+ windowMs?: number;
1675
+ }): Promise<boolean> {
1676
+ const first = await input.probe();
1677
+ if (first === "silent") return input.isSameOwner();
1678
+ if (first !== "unreachable") return false;
1679
+ await input.sleep(input.windowMs ?? TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS);
1680
+ if (!input.isSameOwner()) return false;
1681
+ const second = await input.probe();
1682
+ return (second === "silent" || second === "unreachable") && input.isSameOwner();
1683
+ }
1684
+
1685
+ /** Composition-ready takeover proof for one profile's leader lock and endpoint. */
1686
+ export function createTelegramBusLeaderUnresponsivenessProof(deps: {
1687
+ getLeaderState: () => TelegramLockState;
1688
+ getLeaderSocketPath: () => string;
1689
+ probe?: typeof probeTelegramBusLeader;
1690
+ sleep?: (ms: number) => Promise<void>;
1691
+ }): (owner: TelegramLockEntry) => Promise<boolean> {
1692
+ const probe = deps.probe ?? probeTelegramBusLeader;
1693
+ const sleep =
1694
+ deps.sleep ??
1695
+ ((ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
1696
+ return (owner) =>
1697
+ proveTelegramBusLeaderUnresponsive({
1698
+ probe: () =>
1699
+ probe({
1700
+ socketPath: owner.busSocketPath ?? deps.getLeaderSocketPath(),
1701
+ secret: owner.busSecret,
1702
+ }),
1703
+ isSameOwner: () => {
1704
+ const state = deps.getLeaderState();
1705
+ return (
1706
+ state.kind === "active-elsewhere" &&
1707
+ isSameTelegramLockOwner(state.lock, owner)
1708
+ );
1709
+ },
1710
+ sleep,
1711
+ });
1712
+ }
1713
+
1593
1714
  interface TelegramBusLocalServer {
1594
1715
  start: () => Promise<void>;
1595
1716
  stop: () => Promise<void>;
package/lib/config.ts CHANGED
@@ -289,12 +289,6 @@ export interface TelegramConfigStore {
289
289
  assertExecutionCurrent?: () => void,
290
290
  commitIfOwned?: (commit: () => void) => boolean,
291
291
  ) => Promise<boolean>;
292
- /** Lock-only serialization for trusted synchronous source operations, not authorization.
293
- * Does not read/adopt config. Acquire required Workspace admission first; never
294
- * acquire owners or nest config admission here. Do not pass async callbacks:
295
- * returned promises are not protected after their synchronous prefix.
296
- */
297
- withSourceSerialization: <T>(operation: () => T) => T;
298
292
  /** Trusted synchronous publication only; caller acquires Workspace admission before this config transaction. */
299
293
  withPairingAdmission: <T>(
300
294
  profileName: string,
@@ -779,8 +773,6 @@ export function createTelegramConfigStore(
779
773
  nextConfig.allowedUserId = userId;
780
774
  setEffectiveConfig(nextConfig);
781
775
  },
782
- withSourceSerialization: (operation) =>
783
- withTelegramFileTransaction(`${configPath}.transaction`, operation),
784
776
  withPairingAdmission: (profileName, tokenSha256, publish) =>
785
777
  withPersistedPairingProfile(
786
778
  profileName,
package/lib/extension.ts CHANGED
@@ -181,13 +181,15 @@ export default function (pi: Pi.ExtensionAPI) {
181
181
  },
182
182
  getProfileName: configStore.getActiveProfileName,
183
183
  });
184
+ const withTelegramJournalSourceSerialization =
185
+ Journal.createTelegramJournalSourceSerialization();
184
186
  const telegramJournalBindingRuntime =
185
187
  Journal.createTelegramUpdateJournalBindingRuntime({
186
188
  base: {
187
189
  getProfileName: configStore.getActiveProfileName,
188
190
  getBotToken: configStore.getBotToken,
189
191
  getBotId: getTelegramBotId,
190
- withSourceSerialization: configStore.withSourceSerialization,
192
+ withSourceSerialization: withTelegramJournalSourceSerialization,
191
193
  onRecovery(event) {
192
194
  recordRuntimeEvent(
193
195
  "recovery",
@@ -259,6 +261,11 @@ export default function (pi: Pi.ExtensionAPI) {
259
261
  return telegramLeaderJournalPath.createJournalPath();
260
262
  },
261
263
  });
264
+ const proveTelegramLeaderUnresponsive =
265
+ Bus.createTelegramBusLeaderUnresponsivenessProof({
266
+ getLeaderState: lockRuntime.getState,
267
+ getLeaderSocketPath: getTelegramBusSocketPath,
268
+ });
262
269
  const telegramSessionContextStore =
263
270
  Lifecycle.createTelegramSessionContextStore<Pi.ExtensionContext>({
264
271
  getIdentity(ctx) {
@@ -1368,7 +1375,7 @@ export default function (pi: Pi.ExtensionAPI) {
1368
1375
  botToken,
1369
1376
  botId: getTelegramBotId(),
1370
1377
  });
1371
- return configStore.withSourceSerialization(function () {
1378
+ return withTelegramJournalSourceSerialization(function () {
1372
1379
  return Journal.isTelegramThreadCleanupJournalNamespaceClear({
1373
1380
  directory: Paths.resolveTelegramTempDir(),
1374
1381
  profile,
@@ -1988,6 +1995,7 @@ export default function (pi: Pi.ExtensionAPI) {
1988
1995
  },
1989
1996
  recovery: {
1990
1997
  getLeaderState: lockRuntime.getState,
1998
+ proveLeaderUnresponsive: proveTelegramLeaderUnresponsive,
1991
1999
  setLifecyclePhase: telegramBusFollowerControlState.setLifecyclePhase,
1992
2000
  updateStatus,
1993
2001
  promoteToLeader: promoteTelegramBusFollowerToLeader,
@@ -2347,6 +2355,7 @@ export default function (pi: Pi.ExtensionAPI) {
2347
2355
  const threadAwarePollingPorts = telegramThreadCapabilityRuntime.pollingPorts;
2348
2356
  const lockedPollingRuntime = Locks.createTelegramLockedPollingRuntime({
2349
2357
  lock: lockRuntime,
2358
+ proveOwnerUnresponsive: proveTelegramLeaderUnresponsive,
2350
2359
  resetDamagedState() {
2351
2360
  return Locks.resetDamagedTelegramRuntimeState(
2352
2361
  Paths.resolveTelegramStatePath(),
package/lib/journal.ts CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  import {
47
47
  decodeTelegramSessionDirectoryName,
48
48
  getTelegramProfilePathSuffix,
49
+ resolveTelegramRuntimeDir,
49
50
  TELEGRAM_DEFAULT_PROFILE_NAME,
50
51
  } from "./paths.ts";
51
52
  import {
@@ -4889,6 +4890,21 @@ export function createTelegramUpdateJournalBindingRuntime(deps: {
4889
4890
  return runtime;
4890
4891
  }
4891
4892
 
4893
+ /**
4894
+ * Journal-owned cross-family source serializer, lock-only and never authorization. Its guard lives
4895
+ * in the runtime service directory, so journal work never touches `telegram.json` or its
4896
+ * transaction. Acquire Workspace admission and any sender (config) admission first; never acquire
4897
+ * owners inside it. Callbacks must be synchronous: a returned promise is unprotected after its
4898
+ * synchronous prefix.
4899
+ */
4900
+ export function createTelegramJournalSourceSerialization(
4901
+ getTransactionPath: () => string = () =>
4902
+ join(resolveTelegramRuntimeDir(), "journals.transaction"),
4903
+ ): <T>(operation: () => T) => T {
4904
+ return (operation) =>
4905
+ withTelegramFileTransaction(getTransactionPath(), operation);
4906
+ }
4907
+
4892
4908
  export function createTelegramUpdateJournalStore(
4893
4909
  options: TelegramUpdateJournalStoreOptions,
4894
4910
  ): TelegramUpdateJournalStore {
@@ -7217,10 +7233,17 @@ function createJournalStoreCore(
7217
7233
  serializedBytes: published.serializedBytes,
7218
7234
  };
7219
7235
  });
7236
+ // Sender admission is the outer config authority; journal serialization nests inside it.
7237
+ const serializedAppend = (preApprovalExcluded?: boolean) =>
7238
+ withSourceSerialization
7239
+ ? withSourceSerialization(() =>
7240
+ appendWithEvidence(preApprovalExcluded),
7241
+ )
7242
+ : appendWithEvidence(preApprovalExcluded);
7220
7243
  const append = () => {
7221
7244
  if (withPairedAdmission) {
7222
7245
  const result = withPairedAdmission(normalizedUpdates, () =>
7223
- appendWithEvidence(),
7246
+ serializedAppend(),
7224
7247
  );
7225
7248
  if (!result.admitted)
7226
7249
  throw createJournalError(
@@ -7231,10 +7254,8 @@ function createJournalStoreCore(
7231
7254
  return result.value;
7232
7255
  }
7233
7256
  if (withPairingAdmission)
7234
- return withPairingAdmission(appendWithEvidence);
7235
- return withSourceSerialization
7236
- ? withSourceSerialization(() => appendWithEvidence())
7237
- : appendWithEvidence();
7257
+ return withPairingAdmission(serializedAppend);
7258
+ return serializedAppend();
7238
7259
  };
7239
7260
  const admittedAppend = () => {
7240
7261
  if (!workspaceAdmission) return append();
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,6 +1008,15 @@ 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;
1013
1022
  const isUnchanged = (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.53.2",
3
+ "version": "0.54.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"