@llblab/pi-kit 0.30.0 → 0.31.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/BACKLOG.md +2 -1
- package/CHANGELOG.md +9 -0
- package/README.md +2 -2
- package/node_modules/@llblab/pi-telegram/AGENTS.md +1 -1
- package/node_modules/@llblab/pi-telegram/CHANGELOG.md +24 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.d.ts +12 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-follower.js +52 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/bus-leader.js +8 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/bus.d.ts +35 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/bus.js +81 -3
- package/node_modules/@llblab/pi-telegram/dist/lib/config.d.ts +0 -6
- package/node_modules/@llblab/pi-telegram/dist/lib/config.js +0 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/extension.js +13 -2
- package/node_modules/@llblab/pi-telegram/dist/lib/generative-app-worker.mjs +5 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/journal.d.ts +8 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/journal.js +60 -35
- package/node_modules/@llblab/pi-telegram/dist/lib/locks.d.ts +9 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/locks.js +118 -57
- package/node_modules/@llblab/pi-telegram/dist/lib/outbound.js +4 -0
- package/node_modules/@llblab/pi-telegram/dist/lib/queue.js +3 -1
- package/node_modules/@llblab/pi-telegram/dist/lib/status.js +25 -5
- package/node_modules/@llblab/pi-telegram/dist/lib/sync.js +5 -3
- package/node_modules/@llblab/pi-telegram/dist/package.json +1 -1
- package/node_modules/@llblab/pi-telegram/docs/architecture.md +13 -13
- package/node_modules/@llblab/pi-telegram/docs/multi-instance-bus.md +16 -16
- package/node_modules/@llblab/pi-telegram/lib/bus-follower.ts +67 -4
- package/node_modules/@llblab/pi-telegram/lib/bus-leader.ts +8 -0
- package/node_modules/@llblab/pi-telegram/lib/bus.ts +124 -3
- package/node_modules/@llblab/pi-telegram/lib/config.ts +0 -8
- package/node_modules/@llblab/pi-telegram/lib/extension.ts +15 -2
- package/node_modules/@llblab/pi-telegram/lib/generative-app-worker.mjs +5 -0
- package/node_modules/@llblab/pi-telegram/lib/journal.ts +76 -37
- package/node_modules/@llblab/pi-telegram/lib/locks.ts +148 -58
- package/node_modules/@llblab/pi-telegram/lib/outbound.ts +3 -0
- package/node_modules/@llblab/pi-telegram/lib/queue.ts +5 -1
- package/node_modules/@llblab/pi-telegram/lib/status.ts +24 -5
- package/node_modules/@llblab/pi-telegram/lib/sync.ts +5 -5
- package/node_modules/@llblab/pi-telegram/package.json +1 -1
- 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
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
.
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
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
|
|
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
|
-
|
|
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 = {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
2115
|
+
owned = observe();
|
|
2060
2116
|
} catch (error) {
|
|
2061
|
-
|
|
2117
|
+
failure = error;
|
|
2118
|
+
}
|
|
2119
|
+
if (owned === true) {
|
|
2120
|
+
ownershipCheckFailures = 0;
|
|
2121
|
+
return;
|
|
2062
2122
|
}
|
|
2063
|
-
|
|
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
|
-
|
|
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
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
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
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
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))) {
|
|
@@ -900,6 +900,9 @@ export function createTelegramOutboundReplyArtifactSender(
|
|
|
900
900
|
: plan.voiceText
|
|
901
901
|
? [{ text: plan.voiceText, lang: plan.lang, rate: plan.rate }]
|
|
902
902
|
: [];
|
|
903
|
+
// A plan without voice content has nothing to deliver; absence of
|
|
904
|
+
// content is not a synthesis failure.
|
|
905
|
+
if (voiceReplies.length === 0) return;
|
|
903
906
|
|
|
904
907
|
let anyDelivered = false;
|
|
905
908
|
|
|
@@ -2356,7 +2356,11 @@ export async function handleTelegramAgentEndRuntime<
|
|
|
2356
2356
|
}
|
|
2357
2357
|
}
|
|
2358
2358
|
if (!isDeliveryActive()) return;
|
|
2359
|
-
if (
|
|
2359
|
+
if (
|
|
2360
|
+
outboundReply &&
|
|
2361
|
+
hasOutboundArtifacts &&
|
|
2362
|
+
deps.sendOutboundReplyArtifacts
|
|
2363
|
+
) {
|
|
2360
2364
|
try {
|
|
2361
2365
|
await deps.sendOutboundReplyArtifacts(turn, outboundReply, {
|
|
2362
2366
|
replyToPrompt: !finalText,
|
|
@@ -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(
|
|
@@ -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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@llblab/pi-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
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.
|
|
50
|
+
"@llblab/pi-telegram": "0.54.2",
|
|
51
51
|
"@llblab/skills": "1.15.0"
|
|
52
52
|
},
|
|
53
53
|
"bundledDependencies": [
|