@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/AGENTS.md +1 -1
- package/CHANGELOG.md +6 -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 +18 -6
- package/dist/lib/locks.d.ts +7 -0
- package/dist/lib/locks.js +72 -35
- package/dist/lib/status.js +9 -0
- 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 +26 -5
- package/lib/locks.ts +97 -38
- package/lib/status.ts +9 -0
- package/package.json +1 -1
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 {
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
-
|
|
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(
|
|
7235
|
-
return
|
|
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
|
|
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,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 = (
|