@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/AGENTS.md +1 -1
- package/CHANGELOG.md +10 -0
- package/dist/lib/bus-follower.d.ts +4 -0
- package/dist/lib/bus-follower.js +7 -0
- 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 +9 -2
- package/dist/lib/journal.d.ts +8 -0
- package/dist/lib/journal.js +60 -35
- package/dist/lib/locks.d.ts +7 -0
- package/dist/lib/locks.js +72 -35
- package/dist/lib/status.js +25 -5
- package/dist/package.json +1 -1
- package/docs/architecture.md +12 -12
- package/docs/multi-instance-bus.md +15 -15
- package/lib/bus-follower.ts +11 -0
- package/lib/bus-leader.ts +8 -0
- package/lib/bus.ts +124 -3
- package/lib/config.ts +0 -8
- package/lib/extension.ts +11 -2
- package/lib/journal.ts +76 -37
- package/lib/locks.ts +97 -38
- package/lib/status.ts +24 -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
|
}
|
|
@@ -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
|
|
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
|
-
|
|
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 = {
|
|
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
|
|
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
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
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
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
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 (
|
|
1020
|
-
|
|
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(
|