@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.
- 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/routing.js +60 -0
- 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 +14 -14
- 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/routing.ts +82 -0
- 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
|
@@ -385,6 +385,8 @@ export function createTelegramManualFollowerProfileKeyResolver(input: {
|
|
|
385
385
|
|
|
386
386
|
export interface TelegramBusFollowerElection {
|
|
387
387
|
expectedOwner?: TelegramLockEntry;
|
|
388
|
+
/** The expected owner is bus-proven unresponsive; the lock CAS still requires it to be the current owner. */
|
|
389
|
+
unresponsive?: boolean;
|
|
388
390
|
}
|
|
389
391
|
|
|
390
392
|
export type TelegramBusFollowerPromotionHandler<TContext> = (
|
|
@@ -568,6 +570,10 @@ export interface TelegramBusFollowerHeartbeatRecoveryHandlerDeps<TContext> {
|
|
|
568
570
|
scheduleRetry?: (retry: () => void, delayMs: number) => void;
|
|
569
571
|
getActiveContext?: () => TContext | undefined;
|
|
570
572
|
promotionGraceMs?: number;
|
|
573
|
+
/** Bus liveness proof for a live-PID leader that no longer answers; replaces the retired file heartbeat. */
|
|
574
|
+
proveLeaderUnresponsive?: (owner: TelegramLockEntry) => Promise<boolean>;
|
|
575
|
+
/** Shared bot state says Threaded Mode is off: followers cannot exist, so recovery goes offline instead. */
|
|
576
|
+
isThreadModeDisabled?: () => boolean | Promise<boolean>;
|
|
571
577
|
recordRuntimeEvent: (
|
|
572
578
|
category: string,
|
|
573
579
|
error: unknown,
|
|
@@ -755,7 +761,7 @@ export function createTelegramBusFollowerRuntimeAssembly<
|
|
|
755
761
|
...ports.recovery,
|
|
756
762
|
registrationState: ports.registrationState,
|
|
757
763
|
recordRuntimeEvent: ports.recordRuntimeEvent,
|
|
758
|
-
getRegistrationRuntime: () =>
|
|
764
|
+
getRegistrationRuntime: () => baseRegistration,
|
|
759
765
|
});
|
|
760
766
|
registration = createTelegramBusFollowerRegistrationRuntime({
|
|
761
767
|
...ports.registration,
|
|
@@ -768,6 +774,15 @@ export function createTelegramBusFollowerRuntimeAssembly<
|
|
|
768
774
|
const baseRegistration = registration;
|
|
769
775
|
registration = {
|
|
770
776
|
...baseRegistration,
|
|
777
|
+
// Public stop is deliberate (disconnect, suspension); recovery keeps the base stop for promotion.
|
|
778
|
+
stop() {
|
|
779
|
+
recovery.halt();
|
|
780
|
+
baseRegistration.stop();
|
|
781
|
+
},
|
|
782
|
+
registerWithLeader(ctx, leader, options) {
|
|
783
|
+
recovery.resume();
|
|
784
|
+
return baseRegistration.registerWithLeader(ctx, leader, options);
|
|
785
|
+
},
|
|
771
786
|
async setContext(ctx) {
|
|
772
787
|
const sessionGeneration = ports.registration.getSessionGeneration?.();
|
|
773
788
|
if (ports.registration.isContextActive?.(ctx) === false) return;
|
|
@@ -2202,9 +2217,18 @@ export function createTelegramBusFollowerRegistrationState(
|
|
|
2202
2217
|
};
|
|
2203
2218
|
}
|
|
2204
2219
|
|
|
2220
|
+
/** Heartbeat-failure recovery that a deliberate local stop halts until the next deliberate registration. */
|
|
2221
|
+
export type TelegramBusFollowerHeartbeatRecoveryHandler<TContext> = ((
|
|
2222
|
+
error: unknown,
|
|
2223
|
+
ctx: TContext,
|
|
2224
|
+
) => Promise<void>) & { halt: () => void; resume: () => void };
|
|
2225
|
+
|
|
2205
2226
|
export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
|
|
2206
2227
|
deps: TelegramBusFollowerHeartbeatRecoveryHandlerDeps<TContext>,
|
|
2207
|
-
):
|
|
2228
|
+
): TelegramBusFollowerHeartbeatRecoveryHandler<TContext> {
|
|
2229
|
+
// Promotion stops registration internally and must keep retrying; only an operator stop or a confirmed
|
|
2230
|
+
// switch to classic mode halts recovery, so it never re-registers behind a disconnect.
|
|
2231
|
+
let halted = false;
|
|
2208
2232
|
const promotionGraceMs =
|
|
2209
2233
|
deps.promotionGraceMs ?? TELEGRAM_BUS_FOLLOWER_PROMOTION_GRACE_MS;
|
|
2210
2234
|
const sleep =
|
|
@@ -2246,6 +2270,10 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
|
|
|
2246
2270
|
binding?.target ? { target: binding.target } : undefined,
|
|
2247
2271
|
);
|
|
2248
2272
|
if (!restored) return false;
|
|
2273
|
+
if (halted) {
|
|
2274
|
+
deps.getRegistrationRuntime().stop();
|
|
2275
|
+
return false;
|
|
2276
|
+
}
|
|
2249
2277
|
deps.setLifecyclePhase(undefined);
|
|
2250
2278
|
safeUpdateStatus(ctx);
|
|
2251
2279
|
deps.recordRuntimeEvent(
|
|
@@ -2273,6 +2301,7 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
|
|
|
2273
2301
|
binding: TelegramBusFollowerPromotedBinding,
|
|
2274
2302
|
) => {
|
|
2275
2303
|
const retry = () => {
|
|
2304
|
+
if (halted) return;
|
|
2276
2305
|
const activeCtx = deps.getActiveContext
|
|
2277
2306
|
? deps.getActiveContext()
|
|
2278
2307
|
: fallbackCtx;
|
|
@@ -2372,11 +2401,31 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
|
|
|
2372
2401
|
ctx: TContext,
|
|
2373
2402
|
carriedBinding?: TelegramBusFollowerPromotedBinding,
|
|
2374
2403
|
): Promise<void> => {
|
|
2375
|
-
if (promotionPending) return;
|
|
2404
|
+
if (promotionPending || halted) return;
|
|
2376
2405
|
promotionPending = true;
|
|
2377
2406
|
deps.registrationState.beginRecovery();
|
|
2378
2407
|
const initialBinding = carriedBinding ?? snapshotBinding();
|
|
2379
2408
|
try {
|
|
2409
|
+
let threadModeDisabled = false;
|
|
2410
|
+
try {
|
|
2411
|
+
threadModeDisabled = (await deps.isThreadModeDisabled?.()) === true;
|
|
2412
|
+
} catch (stateError) {
|
|
2413
|
+
deps.recordRuntimeEvent("bus", stateError, {
|
|
2414
|
+
phase: "follower-thread-mode-read",
|
|
2415
|
+
});
|
|
2416
|
+
}
|
|
2417
|
+
if (threadModeDisabled) {
|
|
2418
|
+
halted = true;
|
|
2419
|
+
deps.getRegistrationRuntime().stop();
|
|
2420
|
+
deps.setLifecyclePhase(undefined);
|
|
2421
|
+
safeUpdateStatus(ctx);
|
|
2422
|
+
deps.recordRuntimeEvent(
|
|
2423
|
+
"bus",
|
|
2424
|
+
"Telegram follower went offline: Threaded Mode is off. Run /telegram-connect after turning it back on.",
|
|
2425
|
+
{ phase: "follower-thread-mode-disabled" },
|
|
2426
|
+
);
|
|
2427
|
+
return;
|
|
2428
|
+
}
|
|
2380
2429
|
const state = deps.getLeaderState();
|
|
2381
2430
|
if (state.kind === "active-elsewhere") {
|
|
2382
2431
|
clearRegisteredState(ctx);
|
|
@@ -2410,6 +2459,13 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
|
|
|
2410
2459
|
) {
|
|
2411
2460
|
return;
|
|
2412
2461
|
}
|
|
2462
|
+
if (await deps.proveLeaderUnresponsive?.(graceState.lock)) {
|
|
2463
|
+
await promoteToLeader(error, ctx, initialBinding, {
|
|
2464
|
+
expectedOwner: graceState.lock,
|
|
2465
|
+
unresponsive: true,
|
|
2466
|
+
});
|
|
2467
|
+
return;
|
|
2468
|
+
}
|
|
2413
2469
|
deps.setLifecyclePhase(undefined);
|
|
2414
2470
|
safeUpdateStatus(ctx);
|
|
2415
2471
|
deps.recordRuntimeEvent(
|
|
@@ -2445,7 +2501,14 @@ export function createTelegramBusFollowerHeartbeatRecoveryHandler<TContext>(
|
|
|
2445
2501
|
promotionPending = false;
|
|
2446
2502
|
}
|
|
2447
2503
|
};
|
|
2448
|
-
return recover
|
|
2504
|
+
return Object.assign(recover, {
|
|
2505
|
+
halt() {
|
|
2506
|
+
halted = true;
|
|
2507
|
+
},
|
|
2508
|
+
resume() {
|
|
2509
|
+
halted = false;
|
|
2510
|
+
},
|
|
2511
|
+
});
|
|
2449
2512
|
}
|
|
2450
2513
|
|
|
2451
2514
|
export function createTelegramBusFollowerRegistrationRuntime<
|
|
@@ -3371,6 +3371,14 @@ export function createTelegramBusLeaderEnvelopeHandler(
|
|
|
3371
3371
|
return createUnauthorizedBusAck(envelope.requestId);
|
|
3372
3372
|
}
|
|
3373
3373
|
switch (envelope.kind) {
|
|
3374
|
+
case "bus.probe":
|
|
3375
|
+
// Liveness only: no registry, routing, journal or Bot API effect.
|
|
3376
|
+
return {
|
|
3377
|
+
kind: "bus.ack" as const,
|
|
3378
|
+
requestId: envelope.requestId,
|
|
3379
|
+
ok: true,
|
|
3380
|
+
protocol: deps.protocolIdentity,
|
|
3381
|
+
};
|
|
3374
3382
|
case "follower.register":
|
|
3375
3383
|
case "follower.restoreWorkspace": {
|
|
3376
3384
|
const compatibility = getTelegramBusProtocolCompatibility({
|
|
@@ -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>;
|
|
@@ -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,
|
|
@@ -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,11 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
1988
1995
|
},
|
|
1989
1996
|
recovery: {
|
|
1990
1997
|
getLeaderState: lockRuntime.getState,
|
|
1998
|
+
proveLeaderUnresponsive: proveTelegramLeaderUnresponsive,
|
|
1999
|
+
async isThreadModeDisabled() {
|
|
2000
|
+
await threadStore.refresh?.();
|
|
2001
|
+
return threadStore.getBotState().threadMode === "disabled";
|
|
2002
|
+
},
|
|
1991
2003
|
setLifecyclePhase: telegramBusFollowerControlState.setLifecyclePhase,
|
|
1992
2004
|
updateStatus,
|
|
1993
2005
|
promoteToLeader: promoteTelegramBusFollowerToLeader,
|
|
@@ -2347,6 +2359,7 @@ export default function (pi: Pi.ExtensionAPI) {
|
|
|
2347
2359
|
const threadAwarePollingPorts = telegramThreadCapabilityRuntime.pollingPorts;
|
|
2348
2360
|
const lockedPollingRuntime = Locks.createTelegramLockedPollingRuntime({
|
|
2349
2361
|
lock: lockRuntime,
|
|
2362
|
+
proveOwnerUnresponsive: proveTelegramLeaderUnresponsive,
|
|
2350
2363
|
resetDamagedState() {
|
|
2351
2364
|
return Locks.resetDamagedTelegramRuntimeState(
|
|
2352
2365
|
Paths.resolveTelegramStatePath(),
|
|
@@ -22,6 +22,11 @@ parentPort.on("message", (message) => {
|
|
|
22
22
|
});
|
|
23
23
|
|
|
24
24
|
function runBoundedProcess(input) {
|
|
25
|
+
// After an abort the parent may terminate this worker before execFile's own abort kill runs, which
|
|
26
|
+
// would orphan a freshly spawned child; never start a process once cancelled.
|
|
27
|
+
if (controller.signal.aborted) {
|
|
28
|
+
return Promise.reject(new Error("Generative App method was cancelled."));
|
|
29
|
+
}
|
|
25
30
|
if (!input || typeof input !== "object") {
|
|
26
31
|
throw new Error("Generative App run input must be an object.");
|
|
27
32
|
}
|
|
@@ -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 {
|
|
@@ -5591,36 +5607,40 @@ function createJournalStoreCore(
|
|
|
5591
5607
|
}
|
|
5592
5608
|
};
|
|
5593
5609
|
|
|
5610
|
+
const acquireSource = (): JournalSourceAcquisition | undefined =>
|
|
5611
|
+
sourceAccess
|
|
5612
|
+
? acquireTelegramUpdateJournalFamily(
|
|
5613
|
+
{
|
|
5614
|
+
...sourceAccess,
|
|
5615
|
+
path,
|
|
5616
|
+
profile,
|
|
5617
|
+
botIdentity: expectedIdentity,
|
|
5618
|
+
},
|
|
5619
|
+
version,
|
|
5620
|
+
)
|
|
5621
|
+
: undefined;
|
|
5622
|
+
const readAcquiredSource = (
|
|
5623
|
+
source: JournalSourceAcquisition,
|
|
5624
|
+
): ReadTelegramUpdateJournalResult => {
|
|
5625
|
+
const file =
|
|
5626
|
+
source.evidence.kind === "present" ? source.evidence.file : emptyFile();
|
|
5627
|
+
return {
|
|
5628
|
+
file,
|
|
5629
|
+
exists: source.evidence.kind === "present",
|
|
5630
|
+
serializedBytes: assertCapacity(file),
|
|
5631
|
+
source,
|
|
5632
|
+
};
|
|
5633
|
+
};
|
|
5634
|
+
|
|
5594
5635
|
const runJournalTransaction = <T>(
|
|
5595
5636
|
operation: (read: typeof readCurrent) => T,
|
|
5596
5637
|
): T => {
|
|
5597
5638
|
try {
|
|
5598
5639
|
// Acquire before the transaction helper can create parents or staging names.
|
|
5599
5640
|
// The config continuation excludes participating writers through consumption.
|
|
5600
|
-
const source =
|
|
5601
|
-
|
|
5602
|
-
|
|
5603
|
-
...sourceAccess,
|
|
5604
|
-
path,
|
|
5605
|
-
profile,
|
|
5606
|
-
botIdentity: expectedIdentity,
|
|
5607
|
-
},
|
|
5608
|
-
version,
|
|
5609
|
-
)
|
|
5610
|
-
: undefined;
|
|
5611
|
-
const readSource = (): ReadTelegramUpdateJournalResult => {
|
|
5612
|
-
if (!source) return readCurrent();
|
|
5613
|
-
const file =
|
|
5614
|
-
source.evidence.kind === "present"
|
|
5615
|
-
? source.evidence.file
|
|
5616
|
-
: emptyFile();
|
|
5617
|
-
return {
|
|
5618
|
-
file,
|
|
5619
|
-
exists: source.evidence.kind === "present",
|
|
5620
|
-
serializedBytes: assertCapacity(file),
|
|
5621
|
-
source,
|
|
5622
|
-
};
|
|
5623
|
-
};
|
|
5641
|
+
const source = acquireSource();
|
|
5642
|
+
const readSource = (): ReadTelegramUpdateJournalResult =>
|
|
5643
|
+
source ? readAcquiredSource(source) : readCurrent();
|
|
5624
5644
|
return withTelegramFileTransaction(`${path}.transaction`, () =>
|
|
5625
5645
|
operation(readSource),
|
|
5626
5646
|
);
|
|
@@ -7024,16 +7044,30 @@ function createJournalStoreCore(
|
|
|
7024
7044
|
assertCurrent();
|
|
7025
7045
|
return { issued, entries: result };
|
|
7026
7046
|
});
|
|
7047
|
+
const projectRead = (current: ReadTelegramUpdateJournalResult) => ({
|
|
7048
|
+
...cloneFile(current.file),
|
|
7049
|
+
exists: current.exists,
|
|
7050
|
+
serializedBytes: current.serializedBytes,
|
|
7051
|
+
});
|
|
7027
7052
|
const journal: TelegramUpdateJournalStore = {
|
|
7028
7053
|
read() {
|
|
7029
|
-
|
|
7030
|
-
|
|
7031
|
-
|
|
7032
|
-
|
|
7033
|
-
|
|
7034
|
-
|
|
7035
|
-
|
|
7036
|
-
|
|
7054
|
+
// Atomic publication makes a repair-free read safe without serialization, so idle
|
|
7055
|
+
// status and polling cursor reads create no guards. Any read that needs reconciliation,
|
|
7056
|
+
// or races a compaction, falls back to the serialized path that owns repair.
|
|
7057
|
+
const attempt = (): ReadTelegramUpdateJournalResult | undefined => {
|
|
7058
|
+
try {
|
|
7059
|
+
const source = acquireSource();
|
|
7060
|
+
return source ? readAcquiredSource(source) : readCurrentStrict(false);
|
|
7061
|
+
} catch {
|
|
7062
|
+
return undefined;
|
|
7063
|
+
}
|
|
7064
|
+
};
|
|
7065
|
+
const current = options.withWriterAdmission
|
|
7066
|
+
? options.withWriterAdmission(attempt)
|
|
7067
|
+
: attempt();
|
|
7068
|
+
return current
|
|
7069
|
+
? projectRead(current)
|
|
7070
|
+
: runMutation((readCurrent) => projectRead(readCurrent()));
|
|
7037
7071
|
},
|
|
7038
7072
|
appendBatch(updates, requestedAcceptedThroughUpdateId) {
|
|
7039
7073
|
const canonicalUpdates = updates.map((update) =>
|
|
@@ -7199,10 +7233,17 @@ function createJournalStoreCore(
|
|
|
7199
7233
|
serializedBytes: published.serializedBytes,
|
|
7200
7234
|
};
|
|
7201
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);
|
|
7202
7243
|
const append = () => {
|
|
7203
7244
|
if (withPairedAdmission) {
|
|
7204
7245
|
const result = withPairedAdmission(normalizedUpdates, () =>
|
|
7205
|
-
|
|
7246
|
+
serializedAppend(),
|
|
7206
7247
|
);
|
|
7207
7248
|
if (!result.admitted)
|
|
7208
7249
|
throw createJournalError(
|
|
@@ -7213,10 +7254,8 @@ function createJournalStoreCore(
|
|
|
7213
7254
|
return result.value;
|
|
7214
7255
|
}
|
|
7215
7256
|
if (withPairingAdmission)
|
|
7216
|
-
return withPairingAdmission(
|
|
7217
|
-
return
|
|
7218
|
-
? withSourceSerialization(() => appendWithEvidence())
|
|
7219
|
-
: appendWithEvidence();
|
|
7257
|
+
return withPairingAdmission(serializedAppend);
|
|
7258
|
+
return serializedAppend();
|
|
7220
7259
|
};
|
|
7221
7260
|
const admittedAppend = () => {
|
|
7222
7261
|
if (!workspaceAdmission) return append();
|