@llblab/pi-telegram 0.53.2 → 0.54.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.
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +11 -0
- package/dist/lib/bus-follower.d.ts +12 -1
- package/dist/lib/bus-follower.js +52 -3
- package/dist/lib/bus-leader.js +8 -0
- package/dist/lib/bus.d.ts +35 -0
- package/dist/lib/bus.js +81 -3
- package/dist/lib/config.d.ts +0 -6
- package/dist/lib/config.js +0 -1
- package/dist/lib/extension.js +13 -2
- package/dist/lib/journal.d.ts +8 -0
- package/dist/lib/journal.js +18 -6
- package/dist/lib/locks.d.ts +7 -0
- package/dist/lib/locks.js +87 -45
- package/dist/lib/status.js +9 -0
- package/dist/lib/sync.js +5 -3
- package/dist/package.json +1 -1
- package/docs/architecture.md +12 -12
- package/docs/multi-instance-bus.md +16 -16
- package/lib/bus-follower.ts +67 -4
- package/lib/bus-leader.ts +8 -0
- package/lib/bus.ts +124 -3
- package/lib/config.ts +0 -8
- package/lib/extension.ts +15 -2
- package/lib/journal.ts +26 -5
- package/lib/locks.ts +113 -49
- package/lib/status.ts +9 -0
- package/lib/sync.ts +5 -5
- package/package.json +1 -1
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
|
}
|
|
@@ -1514,17 +1521,21 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
|
|
|
1514
1521
|
const readOwners = (): Record<string, unknown> => {
|
|
1515
1522
|
if (!statePath) return readLocks(locksPath);
|
|
1516
1523
|
// Read-only ownership queries fail closed (no owner); acquisition/publication still refuse malformed state.
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
.
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1524
|
+
// A strict read racing a concurrent atomic replace fails transiently, so retry before failing closed.
|
|
1525
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
1526
|
+
try {
|
|
1527
|
+
const transports = Object.fromEntries(
|
|
1528
|
+
Object.entries(readTelegramRuntimeState(statePath).profiles)
|
|
1529
|
+
.filter(([, value]) => Object.hasOwn(value, "transport"))
|
|
1530
|
+
.map(([profile, value]) => [profile, value.transport]),
|
|
1531
|
+
);
|
|
1532
|
+
assertTelegramStateTransport(transports[resolveEffectiveKey()]);
|
|
1533
|
+
return transports;
|
|
1534
|
+
} catch {
|
|
1535
|
+
// Retry; a persistent failure falls through to no owner.
|
|
1536
|
+
}
|
|
1527
1537
|
}
|
|
1538
|
+
return {};
|
|
1528
1539
|
};
|
|
1529
1540
|
const transactOwners = <T>(
|
|
1530
1541
|
mutate: (locks: Record<string, unknown>) => { result: T; changed: boolean },
|
|
@@ -1623,7 +1634,17 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
|
|
|
1623
1634
|
changed: false,
|
|
1624
1635
|
};
|
|
1625
1636
|
const current = parseTelegramLockEntry(locks[effectiveKey]);
|
|
1626
|
-
const
|
|
1637
|
+
const observedState = getLockState(
|
|
1638
|
+
current,
|
|
1639
|
+
pid,
|
|
1640
|
+
isAlive,
|
|
1641
|
+
stateOptions(),
|
|
1642
|
+
);
|
|
1643
|
+
const state: TelegramLockState =
|
|
1644
|
+
observedState.kind === "active-elsewhere" &&
|
|
1645
|
+
hasSameLockOwner(current, acquireOptions.unresponsiveOwner)
|
|
1646
|
+
? { kind: "stale", lock: observedState.lock }
|
|
1647
|
+
: observedState;
|
|
1627
1648
|
const expectedOwned = adoptCompatibleOwnedLock(
|
|
1628
1649
|
effectiveKey,
|
|
1629
1650
|
current,
|
|
@@ -1691,7 +1712,6 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
|
|
|
1691
1712
|
instanceId: options.instanceId,
|
|
1692
1713
|
busSocketPath: options.busSocketPath,
|
|
1693
1714
|
busSecret: options.busSecret,
|
|
1694
|
-
getNowMs,
|
|
1695
1715
|
mintLeaderEpoch: options.mintLeaderEpoch,
|
|
1696
1716
|
runtimeGeneration,
|
|
1697
1717
|
});
|
|
@@ -1846,39 +1866,65 @@ export function createTelegramLockRuntime<TContext extends TelegramLockContext>(
|
|
|
1846
1866
|
{ ...publication, isCurrent },
|
|
1847
1867
|
);
|
|
1848
1868
|
},
|
|
1849
|
-
refresh: (ctx) =>
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
const
|
|
1854
|
-
|
|
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 = {
|
|
1869
|
+
refresh: (ctx) => {
|
|
1870
|
+
if (deliveryRevoked) return false;
|
|
1871
|
+
// Owner fields only, never a timestamp: liveness is proven over the bus, so an exact owner needs no write.
|
|
1872
|
+
const refreshedEntry = (lock: TelegramLockEntry): TelegramLockEntry => {
|
|
1873
|
+
const busSecret = options.busSecret ?? lock.busSecret;
|
|
1874
|
+
return {
|
|
1864
1875
|
pid: lock.pid,
|
|
1865
1876
|
...(lock.cwd ? { cwd: lock.cwd } : {}),
|
|
1866
1877
|
instanceId: options.instanceId,
|
|
1867
|
-
heartbeatMs: getNowMs(),
|
|
1868
1878
|
leaderEpoch:
|
|
1869
1879
|
lock.leaderEpoch ?? options.mintLeaderEpoch?.() ?? randomUUID(),
|
|
1870
1880
|
runtimeGeneration: lock.runtimeGeneration ?? runtimeGeneration,
|
|
1871
1881
|
...(options.busSocketPath
|
|
1872
1882
|
? { busSocketPath: options.busSocketPath }
|
|
1873
1883
|
: {}),
|
|
1874
|
-
busSecret
|
|
1884
|
+
...(busSecret !== undefined ? { busSecret } : {}),
|
|
1875
1885
|
...(lock.journalPath ? { journalPath: lock.journalPath } : {}),
|
|
1876
1886
|
};
|
|
1887
|
+
};
|
|
1888
|
+
// Parsed entries carry explicit `undefined` fields; compare only the published ones.
|
|
1889
|
+
const isCurrentEntry = (lock: TelegramLockEntry): boolean =>
|
|
1890
|
+
lock.leaderEpoch !== undefined &&
|
|
1891
|
+
isDeepStrictEqual(
|
|
1892
|
+
Object.fromEntries(
|
|
1893
|
+
Object.entries(lock).filter(([, value]) => value !== undefined),
|
|
1894
|
+
),
|
|
1895
|
+
refreshedEntry(lock),
|
|
1896
|
+
);
|
|
1897
|
+
const effectiveKey = resolveEffectiveKey();
|
|
1898
|
+
const observed = readLock();
|
|
1899
|
+
if (
|
|
1900
|
+
observed &&
|
|
1901
|
+
hasSameLockOwner(
|
|
1902
|
+
observed,
|
|
1903
|
+
adoptCompatibleOwnedLock(effectiveKey, observed, ctx),
|
|
1904
|
+
) &&
|
|
1905
|
+
(!options.instanceId || isCurrentEntry(observed))
|
|
1906
|
+
) {
|
|
1907
|
+
return true;
|
|
1908
|
+
}
|
|
1909
|
+
return transactOwners((locks) => {
|
|
1910
|
+
const lock = parseTelegramLockEntry(locks[effectiveKey]);
|
|
1911
|
+
const expectedOwner = adoptCompatibleOwnedLock(effectiveKey, lock, ctx);
|
|
1912
|
+
if (!lock || !hasSameLockOwner(lock, expectedOwner)) {
|
|
1913
|
+
if (ownedLockKey === effectiveKey) {
|
|
1914
|
+
ownedLockKey = undefined;
|
|
1915
|
+
ownedLock = undefined;
|
|
1916
|
+
}
|
|
1917
|
+
return { result: false, changed: false };
|
|
1918
|
+
}
|
|
1919
|
+
if (!options.instanceId) return { result: true, changed: false };
|
|
1920
|
+
if (isCurrentEntry(lock)) return { result: true, changed: false };
|
|
1921
|
+
const refreshedLock = refreshedEntry(lock);
|
|
1877
1922
|
locks[effectiveKey] = refreshedLock;
|
|
1878
1923
|
ownedLockKey = effectiveKey;
|
|
1879
1924
|
ownedLock = refreshedLock;
|
|
1880
1925
|
return { result: true, changed: true };
|
|
1881
|
-
})
|
|
1926
|
+
});
|
|
1927
|
+
},
|
|
1882
1928
|
};
|
|
1883
1929
|
}
|
|
1884
1930
|
|
|
@@ -1906,7 +1952,7 @@ interface TelegramLockedPollingStartOptions {
|
|
|
1906
1952
|
force?: boolean;
|
|
1907
1953
|
forceFreshLeaderThread?: boolean;
|
|
1908
1954
|
requestedThreadName?: string;
|
|
1909
|
-
election?: { expectedOwner?: TelegramLockEntry };
|
|
1955
|
+
election?: { expectedOwner?: TelegramLockEntry; unresponsive?: boolean };
|
|
1910
1956
|
onAcquired?: () => Promise<void> | void;
|
|
1911
1957
|
}
|
|
1912
1958
|
|
|
@@ -1966,6 +2012,8 @@ interface TelegramLockedPollingRuntimeDeps<
|
|
|
1966
2012
|
owner: TelegramLockEntry,
|
|
1967
2013
|
) => boolean | undefined | Promise<boolean | undefined>;
|
|
1968
2014
|
stopFollowerRegistration?: () => void;
|
|
2015
|
+
/** Threaded Mode takeover evidence after failed follower registration; replaces the retired file heartbeat. */
|
|
2016
|
+
proveOwnerUnresponsive?: (owner: TelegramLockEntry) => Promise<boolean>;
|
|
1969
2017
|
onTransportAvailabilityChanged?: () => void;
|
|
1970
2018
|
transportMonitor?: { start: (ctx: TContext) => void; stop: () => void };
|
|
1971
2019
|
updateStatus: (ctx: TContext) => void;
|
|
@@ -2056,7 +2104,8 @@ export function createTelegramLockedPollingRuntime<
|
|
|
2056
2104
|
stopOwnershipWatcher();
|
|
2057
2105
|
ownershipCheckInterval = setInterval(() => {
|
|
2058
2106
|
try {
|
|
2059
|
-
|
|
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;
|
|
2060
2109
|
} catch (error) {
|
|
2061
2110
|
deps.recordRuntimeEvent?.("lock", error, { phase: "check" });
|
|
2062
2111
|
}
|
|
@@ -2187,6 +2236,9 @@ export function createTelegramLockedPollingRuntime<
|
|
|
2187
2236
|
options.election?.expectedOwner ??
|
|
2188
2237
|
(options.force ? takeoverCandidate : undefined),
|
|
2189
2238
|
election: options.election !== undefined,
|
|
2239
|
+
unresponsiveOwner: options.election?.unresponsive
|
|
2240
|
+
? options.election.expectedOwner
|
|
2241
|
+
: undefined,
|
|
2190
2242
|
});
|
|
2191
2243
|
if (!acquired.ok && !options.election) {
|
|
2192
2244
|
const currentState = deps.lock.getState();
|
|
@@ -2231,22 +2283,34 @@ export function createTelegramLockedPollingRuntime<
|
|
|
2231
2283
|
});
|
|
2232
2284
|
}
|
|
2233
2285
|
if (failureMessage) {
|
|
2234
|
-
const
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2286
|
+
const unresponsiveOwner = acquired.lock;
|
|
2287
|
+
if (
|
|
2288
|
+
deps.proveOwnerUnresponsive &&
|
|
2289
|
+
(await deps.proveOwnerUnresponsive(unresponsiveOwner))
|
|
2290
|
+
) {
|
|
2291
|
+
if (!isCurrent()) return cancelled;
|
|
2292
|
+
acquired = deps.lock.acquire(ctx, { unresponsiveOwner });
|
|
2293
|
+
}
|
|
2294
|
+
if (!acquired.ok) {
|
|
2295
|
+
const owner = formatTelegramLockEntry(acquired.lock);
|
|
2296
|
+
return {
|
|
2297
|
+
ok: false,
|
|
2298
|
+
canTakeover: false,
|
|
2299
|
+
owner,
|
|
2300
|
+
message: `Telegram bridge is active in another Pi instance (${owner}); follower registration failed: ${formatTelegramFollowerRegistrationFailure(failureMessage)}.`,
|
|
2301
|
+
};
|
|
2302
|
+
}
|
|
2241
2303
|
}
|
|
2242
2304
|
}
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2305
|
+
if (!acquired.ok) {
|
|
2306
|
+
const owner = formatTelegramLockEntry(acquired.lock);
|
|
2307
|
+
return {
|
|
2308
|
+
ok: false,
|
|
2309
|
+
canTakeover: true,
|
|
2310
|
+
owner,
|
|
2311
|
+
message: `Telegram bridge is active in another Pi instance (${owner}).`,
|
|
2312
|
+
};
|
|
2313
|
+
}
|
|
2250
2314
|
}
|
|
2251
2315
|
takeoverCandidate = undefined;
|
|
2252
2316
|
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/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
|
-
|
|
390
|
-
|
|
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;
|