@llblab/pi-kit 0.30.1 → 0.31.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/BACKLOG.md +2 -1
  2. package/CHANGELOG.md +9 -0
  3. package/README.md +2 -2
  4. package/node_modules/@llblab/pi-telegram/AGENTS.md +1 -1
  5. package/node_modules/@llblab/pi-telegram/CHANGELOG.md +24 -0
  6. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +12 -1
  7. package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +52 -3
  8. package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +8 -0
  9. package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +35 -0
  10. package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +81 -3
  11. package/node_modules/@llblab/pi-telegram/dist/lib/config.d.ts +0 -6
  12. package/node_modules/@llblab/pi-telegram/dist/lib/config.js +0 -1
  13. package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +13 -2
  14. package/node_modules/@llblab/pi-telegram/dist/lib/generative-app-worker.mjs +5 -0
  15. package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +8 -0
  16. package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +60 -35
  17. package/node_modules/@llblab/pi-telegram/dist/lib/locks.d.ts +9 -0
  18. package/node_modules/@llblab/pi-telegram/dist/lib/locks.js +118 -57
  19. package/node_modules/@llblab/pi-telegram/dist/lib/routing.js +60 -0
  20. package/node_modules/@llblab/pi-telegram/dist/lib/status.js +25 -5
  21. package/node_modules/@llblab/pi-telegram/dist/lib/sync.js +5 -3
  22. package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
  23. package/node_modules/@llblab/pi-telegram/docs/architecture.md +14 -14
  24. package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +16 -16
  25. package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +67 -4
  26. package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +8 -0
  27. package/node_modules/@llblab/pi-telegram/lib/bus.ts +124 -3
  28. package/node_modules/@llblab/pi-telegram/lib/config.ts +0 -8
  29. package/node_modules/@llblab/pi-telegram/lib/extension.ts +15 -2
  30. package/node_modules/@llblab/pi-telegram/lib/generative-app-worker.mjs +5 -0
  31. package/node_modules/@llblab/pi-telegram/lib/journal.ts +76 -37
  32. package/node_modules/@llblab/pi-telegram/lib/locks.ts +148 -58
  33. package/node_modules/@llblab/pi-telegram/lib/routing.ts +82 -0
  34. package/node_modules/@llblab/pi-telegram/lib/status.ts +24 -5
  35. package/node_modules/@llblab/pi-telegram/lib/sync.ts +5 -5
  36. package/node_modules/@llblab/pi-telegram/package.json +1 -1
  37. package/package.json +2 -2
@@ -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;
@@ -141,6 +143,8 @@ interface TelegramLockAcquireOptions {
141
143
  force?: boolean;
142
144
  expectedOwner?: TelegramLockEntry;
143
145
  election?: boolean;
146
+ /** Bus-proven unresponsive owner: treated as stale only while it is still exactly the current owner. */
147
+ unresponsiveOwner?: TelegramLockEntry;
144
148
  }
145
149
 
146
150
  type TelegramLockAcquireResult =
@@ -1381,6 +1385,14 @@ function hasSameLockOwner(
1381
1385
  );
1382
1386
  }
1383
1387
 
1388
+ /** Exact owner identity (pid, cwd, instance, leader epoch, runtime generation); absent entries never match. */
1389
+ export function isSameTelegramLockOwner(
1390
+ current: TelegramLockEntry | undefined,
1391
+ expected: TelegramLockEntry | undefined,
1392
+ ): boolean {
1393
+ return hasSameLockOwner(current, expected);
1394
+ }
1395
+
1384
1396
  function canSupersedeSameProcessOwner(
1385
1397
  current: TelegramLockEntry,
1386
1398
  pid: number,
@@ -1408,7 +1420,6 @@ function createLockEntry(
1408
1420
  instanceId?: string;
1409
1421
  busSocketPath?: string;
1410
1422
  busSecret?: string;
1411
- getNowMs?: () => number;
1412
1423
  mintLeaderEpoch?: () => number | string;
1413
1424
  runtimeGeneration?: number;
1414
1425
  journalPath?: string;
@@ -1417,9 +1428,7 @@ function createLockEntry(
1417
1428
  const lock: TelegramLockEntry = { pid, cwd: ctx.cwd };
1418
1429
  if (options.journalPath) lock.journalPath = options.journalPath;
1419
1430
  if (options.instanceId) {
1420
- const nowMs = options.getNowMs?.();
1421
1431
  lock.instanceId = options.instanceId;
1422
- lock.heartbeatMs = nowMs;
1423
1432
  lock.leaderEpoch = options.mintLeaderEpoch?.() ?? randomUUID();
1424
1433
  lock.runtimeGeneration = options.runtimeGeneration;
1425
1434
  }
@@ -1514,17 +1523,21 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
1514
1523
  const readOwners = (): Record<string, unknown> => {
1515
1524
  if (!statePath) return readLocks(locksPath);
1516
1525
  // Read-only ownership queries fail closed (no owner); acquisition/publication still refuse malformed state.
1517
- try {
1518
- const transports = Object.fromEntries(
1519
- Object.entries(readTelegramRuntimeState(statePath).profiles)
1520
- .filter(([, value]) => Object.hasOwn(value, "transport"))
1521
- .map(([profile, value]) => [profile, value.transport]),
1522
- );
1523
- assertTelegramStateTransport(transports[resolveEffectiveKey()]);
1524
- return transports;
1525
- } catch {
1526
- 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
+ }
1527
1539
  }
1540
+ return {};
1528
1541
  };
1529
1542
  const transactOwners = <T>(
1530
1543
  mutate: (locks: Record<string, unknown>) => { result: T; changed: boolean },
@@ -1623,7 +1636,17 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
1623
1636
  changed: false,
1624
1637
  };
1625
1638
  const current = parseTelegramLockEntry(locks[effectiveKey]);
1626
- const state = getLockState(current, pid, isAlive, stateOptions());
1639
+ const observedState = getLockState(
1640
+ current,
1641
+ pid,
1642
+ isAlive,
1643
+ stateOptions(),
1644
+ );
1645
+ const state: TelegramLockState =
1646
+ observedState.kind === "active-elsewhere" &&
1647
+ hasSameLockOwner(current, acquireOptions.unresponsiveOwner)
1648
+ ? { kind: "stale", lock: observedState.lock }
1649
+ : observedState;
1627
1650
  const expectedOwned = adoptCompatibleOwnedLock(
1628
1651
  effectiveKey,
1629
1652
  current,
@@ -1691,7 +1714,6 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
1691
1714
  instanceId: options.instanceId,
1692
1715
  busSocketPath: options.busSocketPath,
1693
1716
  busSecret: options.busSecret,
1694
- getNowMs,
1695
1717
  mintLeaderEpoch: options.mintLeaderEpoch,
1696
1718
  runtimeGeneration,
1697
1719
  });
@@ -1846,39 +1868,65 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
1846
1868
  { ...publication, isCurrent },
1847
1869
  );
1848
1870
  },
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 = {
1871
+ refresh: (ctx) => {
1872
+ if (deliveryRevoked) return false;
1873
+ // Owner fields only, never a timestamp: liveness is proven over the bus, so an exact owner needs no write.
1874
+ const refreshedEntry = (lock: TelegramLockEntry): TelegramLockEntry => {
1875
+ const busSecret = options.busSecret ?? lock.busSecret;
1876
+ return {
1864
1877
  pid: lock.pid,
1865
1878
  ...(lock.cwd ? { cwd: lock.cwd } : {}),
1866
1879
  instanceId: options.instanceId,
1867
- heartbeatMs: getNowMs(),
1868
1880
  leaderEpoch:
1869
1881
  lock.leaderEpoch ?? options.mintLeaderEpoch?.() ?? randomUUID(),
1870
1882
  runtimeGeneration: lock.runtimeGeneration ?? runtimeGeneration,
1871
1883
  ...(options.busSocketPath
1872
1884
  ? { busSocketPath: options.busSocketPath }
1873
1885
  : {}),
1874
- busSecret: options.busSecret ?? lock.busSecret,
1886
+ ...(busSecret !== undefined ? { busSecret } : {}),
1875
1887
  ...(lock.journalPath ? { journalPath: lock.journalPath } : {}),
1876
1888
  };
1889
+ };
1890
+ // Parsed entries carry explicit `undefined` fields; compare only the published ones.
1891
+ const isCurrentEntry = (lock: TelegramLockEntry): boolean =>
1892
+ lock.leaderEpoch !== undefined &&
1893
+ isDeepStrictEqual(
1894
+ Object.fromEntries(
1895
+ Object.entries(lock).filter(([, value]) => value !== undefined),
1896
+ ),
1897
+ refreshedEntry(lock),
1898
+ );
1899
+ const effectiveKey = resolveEffectiveKey();
1900
+ const observed = readLock();
1901
+ if (
1902
+ observed &&
1903
+ hasSameLockOwner(
1904
+ observed,
1905
+ adoptCompatibleOwnedLock(effectiveKey, observed, ctx),
1906
+ ) &&
1907
+ (!options.instanceId || isCurrentEntry(observed))
1908
+ ) {
1909
+ return true;
1910
+ }
1911
+ return transactOwners((locks) => {
1912
+ const lock = parseTelegramLockEntry(locks[effectiveKey]);
1913
+ const expectedOwner = adoptCompatibleOwnedLock(effectiveKey, lock, ctx);
1914
+ if (!lock || !hasSameLockOwner(lock, expectedOwner)) {
1915
+ if (ownedLockKey === effectiveKey) {
1916
+ ownedLockKey = undefined;
1917
+ ownedLock = undefined;
1918
+ }
1919
+ return { result: false, changed: false };
1920
+ }
1921
+ if (!options.instanceId) return { result: true, changed: false };
1922
+ if (isCurrentEntry(lock)) return { result: true, changed: false };
1923
+ const refreshedLock = refreshedEntry(lock);
1877
1924
  locks[effectiveKey] = refreshedLock;
1878
1925
  ownedLockKey = effectiveKey;
1879
1926
  ownedLock = refreshedLock;
1880
1927
  return { result: true, changed: true };
1881
- }),
1928
+ });
1929
+ },
1882
1930
  };
1883
1931
  }
1884
1932
 
@@ -1906,7 +1954,7 @@ interface TelegramLockedPollingStartOptions {
1906
1954
  force?: boolean;
1907
1955
  forceFreshLeaderThread?: boolean;
1908
1956
  requestedThreadName?: string;
1909
- election?: { expectedOwner?: TelegramLockEntry };
1957
+ election?: { expectedOwner?: TelegramLockEntry; unresponsive?: boolean };
1910
1958
  onAcquired?: () => Promise<void> | void;
1911
1959
  }
1912
1960
 
@@ -1966,6 +2014,8 @@ interface TelegramLockedPollingRuntimeDeps<
1966
2014
  owner: TelegramLockEntry,
1967
2015
  ) => boolean | undefined | Promise<boolean | undefined>;
1968
2016
  stopFollowerRegistration?: () => void;
2017
+ /** Threaded Mode takeover evidence after failed follower registration; replaces the retired file heartbeat. */
2018
+ proveOwnerUnresponsive?: (owner: TelegramLockEntry) => Promise<boolean>;
1969
2019
  onTransportAvailabilityChanged?: () => void;
1970
2020
  transportMonitor?: { start: (ctx: TContext) => void; stop: () => void };
1971
2021
  updateStatus: (ctx: TContext) => void;
@@ -1991,6 +2041,7 @@ export function createTelegramLockedPollingRuntime<
1991
2041
  let ownershipRefreshInterval: ReturnType<typeof setInterval> | undefined;
1992
2042
  let ownershipStop: Promise<void> | undefined;
1993
2043
  let activeContext: TContext | undefined;
2044
+ let ownershipCheckFailures = 0;
1994
2045
  let takeoverCandidate: TelegramLockEntry | undefined;
1995
2046
  let sessionAutoStartRun: Promise<void> | undefined;
1996
2047
  let pollingGeneration = 0;
@@ -2054,21 +2105,45 @@ export function createTelegramLockedPollingRuntime<
2054
2105
  const startOwnershipWatcher = (ctx: TContext) => {
2055
2106
  const owner = snapshotLockContext(ctx);
2056
2107
  stopOwnershipWatcher();
2057
- 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;
2058
2114
  try {
2059
- if (deps.lock.owns(owner)) return;
2115
+ owned = observe();
2060
2116
  } catch (error) {
2061
- deps.recordRuntimeEvent?.("lock", error, { phase: "check" });
2117
+ failure = error;
2118
+ }
2119
+ if (owned === true) {
2120
+ ownershipCheckFailures = 0;
2121
+ return;
2062
2122
  }
2063
- 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));
2064
2144
  }, ownershipCheckMs);
2065
2145
  ownershipRefreshInterval = setInterval(() => {
2066
- try {
2067
- if (deps.lock.refresh(owner)) return;
2068
- } catch (error) {
2069
- deps.recordRuntimeEvent?.("lock", error, { phase: "refresh" });
2070
- }
2071
- stopAfterOwnershipLoss();
2146
+ verifyOwnership(() => deps.lock.refresh(owner));
2072
2147
  }, ownershipRefreshMs);
2073
2148
  ownershipCheckInterval.unref?.();
2074
2149
  ownershipRefreshInterval.unref?.();
@@ -2187,6 +2262,9 @@ export function createTelegramLockedPollingRuntime<
2187
2262
  options.election?.expectedOwner ??
2188
2263
  (options.force ? takeoverCandidate : undefined),
2189
2264
  election: options.election !== undefined,
2265
+ unresponsiveOwner: options.election?.unresponsive
2266
+ ? options.election.expectedOwner
2267
+ : undefined,
2190
2268
  });
2191
2269
  if (!acquired.ok && !options.election) {
2192
2270
  const currentState = deps.lock.getState();
@@ -2231,22 +2309,34 @@ export function createTelegramLockedPollingRuntime<
2231
2309
  });
2232
2310
  }
2233
2311
  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
- };
2312
+ const unresponsiveOwner = acquired.lock;
2313
+ if (
2314
+ deps.proveOwnerUnresponsive &&
2315
+ (await deps.proveOwnerUnresponsive(unresponsiveOwner))
2316
+ ) {
2317
+ if (!isCurrent()) return cancelled;
2318
+ acquired = deps.lock.acquire(ctx, { unresponsiveOwner });
2319
+ }
2320
+ if (!acquired.ok) {
2321
+ const owner = formatTelegramLockEntry(acquired.lock);
2322
+ return {
2323
+ ok: false,
2324
+ canTakeover: false,
2325
+ owner,
2326
+ message: `Telegram bridge is active in another Pi instance (${owner}); follower registration failed: ${formatTelegramFollowerRegistrationFailure(failureMessage)}.`,
2327
+ };
2328
+ }
2241
2329
  }
2242
2330
  }
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
- };
2331
+ if (!acquired.ok) {
2332
+ const owner = formatTelegramLockEntry(acquired.lock);
2333
+ return {
2334
+ ok: false,
2335
+ canTakeover: true,
2336
+ owner,
2337
+ message: `Telegram bridge is active in another Pi instance (${owner}).`,
2338
+ };
2339
+ }
2250
2340
  }
2251
2341
  takeoverCandidate = undefined;
2252
2342
  if (!(await runOwnedPollingStart(ctx, options, isCurrent))) {
@@ -1330,6 +1330,8 @@ export const TELEGRAM_ALL_TAB_COMMAND_MAX_AGE_MS = 60 * 60_000;
1330
1330
  export const TELEGRAM_ROUTING_CHOICE_EXPIRED = "Routing choice expired";
1331
1331
  /** Every temporary routing tab carries this name, whether the bot created it or adopted Telegram's native tab. */
1332
1332
  const TELEGRAM_TEMPORARY_THREAD_NAME = "🚦 Routing";
1333
+ /** A mobile client's native tab creation precedes the All input it is named after by about a second. */
1334
+ const TELEGRAM_IMPLICIT_ALL_INPUT_WINDOW_SEC = 10;
1333
1335
 
1334
1336
  export function isTelegramAllTabCommandExpired(
1335
1337
  message: { date?: number; message_thread_id?: number },
@@ -1945,6 +1947,9 @@ export function createTelegramInboundRouteRuntime<
1945
1947
  executor: Threads.TelegramWorkspaceRestoreExecutor;
1946
1948
  generation: number | undefined;
1947
1949
  scope: string | undefined;
1950
+ target: { chatId: number; threadId: number };
1951
+ name: string;
1952
+ createdAtSec: number;
1948
1953
  }
1949
1954
  >();
1950
1955
  const guidedUnboundTopicKeys = new Set<string>();
@@ -10075,6 +10080,44 @@ export function createTelegramInboundRouteRuntime<
10075
10080
  }
10076
10081
  return undefined;
10077
10082
  };
10083
+ /** A fresh implicit native tab whose name the All input starts with, observed just before it in the same exact scope. */
10084
+ const findImplicitCreationForAllInput = (
10085
+ message: TMessage,
10086
+ updateId: number,
10087
+ cap: NonNullable<ReturnType<typeof captureTemporaryThreadAuthority>>,
10088
+ ctx: TContext,
10089
+ ) => {
10090
+ const text = (message.text ?? message.caption ?? "").trim();
10091
+ let match:
10092
+ | {
10093
+ key: string;
10094
+ implicit: typeof implicitThreadCreations extends Map<string, infer V>
10095
+ ? V
10096
+ : never;
10097
+ }
10098
+ | undefined;
10099
+ for (const [key, implicit] of implicitThreadCreations) {
10100
+ if (
10101
+ implicit.name &&
10102
+ text.startsWith(implicit.name) &&
10103
+ implicit.updateId < updateId &&
10104
+ implicit.createdAtSec > 0 &&
10105
+ message.date !== undefined &&
10106
+ message.date >= implicit.createdAtSec &&
10107
+ message.date - implicit.createdAtSec <=
10108
+ TELEGRAM_IMPLICIT_ALL_INPUT_WINDOW_SEC &&
10109
+ implicit.ctx === ctx &&
10110
+ implicit.operatorUserId === cap.operatorUserId &&
10111
+ implicit.journalBindingKey === cap.journalBindingKey &&
10112
+ isDeepStrictEqual(implicit.executor, cap.authority.executor) &&
10113
+ implicit.generation === deps.getSessionGeneration?.() &&
10114
+ implicit.scope === deps.getAdmissionScope?.() &&
10115
+ (!match || implicit.updateId > match.implicit.updateId)
10116
+ )
10117
+ match = { key, implicit };
10118
+ }
10119
+ return match;
10120
+ };
10078
10121
  /**
10079
10122
  * An All input gets one source-bound tab. The original stays deferred in All until explicit routing,
10080
10123
  * and an existing entry is reused on replay, so restart never creates a second tab or retries an unknown one.
@@ -10130,6 +10173,39 @@ export function createTelegramInboundRouteRuntime<
10130
10173
  entry = found;
10131
10174
  return;
10132
10175
  }
10176
+ // Mobile clients sending from All first create a native tab named after the input, then deliver
10177
+ // the input itself to All: adopt that tab rather than leaving it behind next to a second one.
10178
+ const native = findImplicitCreationForAllInput(message, updateId, cap, ctx);
10179
+ if (native) {
10180
+ const adopted = store.registerImplicitTemporaryThread(
10181
+ { journalBindingKey, updateIds: [updateId] },
10182
+ native.implicit.target,
10183
+ randomBytes(16).toString("hex"),
10184
+ {
10185
+ ...authority,
10186
+ isCurrent: () =>
10187
+ current() &&
10188
+ implicitThreadCreations.get(native.key) === native.implicit,
10189
+ },
10190
+ );
10191
+ if (adopted && current()) {
10192
+ implicitThreadCreations.delete(native.key);
10193
+ entry = adopted;
10194
+ // Adopted native tabs share the routing name; a failed rename never blocks routing.
10195
+ try {
10196
+ await deps.callApi!("editForumTopic", {
10197
+ chat_id: native.implicit.target.chatId,
10198
+ message_thread_id: native.implicit.target.threadId,
10199
+ name: TELEGRAM_TEMPORARY_THREAD_NAME,
10200
+ });
10201
+ } catch (error) {
10202
+ deps.recordRuntimeEvent?.("routing", error, {
10203
+ phase: "temporary-thread-name",
10204
+ });
10205
+ }
10206
+ return;
10207
+ }
10208
+ }
10133
10209
  const reservation = store.reserveTemporaryThread(
10134
10210
  source,
10135
10211
  randomBytes(16).toString("hex"),
@@ -10625,6 +10701,12 @@ export function createTelegramInboundRouteRuntime<
10625
10701
  executor: structuredClone(cap.authority.executor),
10626
10702
  generation: deps.getSessionGeneration?.(),
10627
10703
  scope: deps.getAdmissionScope?.(),
10704
+ target: {
10705
+ chatId: lifecycle.target.chatId,
10706
+ threadId: lifecycle.target.threadId!,
10707
+ },
10708
+ name: "name" in created && typeof created.name === "string" ? created.name : "",
10709
+ createdAtSec: message.date ?? 0,
10628
10710
  });
10629
10711
  } else implicitThreadCreations.delete(key);
10630
10712
  };
@@ -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(
@@ -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;
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.53.1",
3
+ "version": "0.54.3",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-kit",
3
- "version": "0.30.1",
3
+ "version": "0.31.1",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -47,7 +47,7 @@
47
47
  "@llblab/pi-codex-usage": "0.12.1",
48
48
  "@llblab/pi-grow-loop": "0.9.0",
49
49
  "@llblab/pi-state-flow": "0.26.4",
50
- "@llblab/pi-telegram": "0.53.1",
50
+ "@llblab/pi-telegram": "0.54.3",
51
51
  "@llblab/skills": "1.15.0"
52
52
  },
53
53
  "bundledDependencies": [