@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
|
@@ -10,7 +10,7 @@ import { chmodSync, closeSync, fstatSync, lstatSync, mkdirSync, opendirSync, ope
|
|
|
10
10
|
import { basename, dirname, isAbsolute, join, relative, resolve, sep, } from "node:path";
|
|
11
11
|
import { isDeepStrictEqual } from "node:util";
|
|
12
12
|
import { TELEGRAM_STRICT_READ_FLAGS, renameTelegramPathWithRetry, withTelegramFileTransaction, } from "./locks.js";
|
|
13
|
-
import { decodeTelegramSessionDirectoryName, getTelegramProfilePathSuffix, TELEGRAM_DEFAULT_PROFILE_NAME, } from "./paths.js";
|
|
13
|
+
import { decodeTelegramSessionDirectoryName, getTelegramProfilePathSuffix, resolveTelegramRuntimeDir, TELEGRAM_DEFAULT_PROFILE_NAME, } from "./paths.js";
|
|
14
14
|
import { getTelegramProcessLiveness, } from "./process-identity.js";
|
|
15
15
|
import { hasOnlyWireKeys as hasOnlyKeys, isNonEmptyWireString as isNonEmptyString, isWireRecord as isRecord, isNonNegativeWireInteger as isSafeNonNegativeInteger, } from "./wire.js";
|
|
16
16
|
import { getTelegramWorkspaceAdmissionScopeKey, runWithTelegramWorkspaceAdmissions, } from "./workspace-admission.js";
|
|
@@ -2735,6 +2735,16 @@ export function createTelegramUpdateJournalBindingRuntime(deps) {
|
|
|
2735
2735
|
};
|
|
2736
2736
|
return runtime;
|
|
2737
2737
|
}
|
|
2738
|
+
/**
|
|
2739
|
+
* Journal-owned cross-family source serializer, lock-only and never authorization. Its guard lives
|
|
2740
|
+
* in the runtime service directory, so journal work never touches `telegram.json` or its
|
|
2741
|
+
* transaction. Acquire Workspace admission and any sender (config) admission first; never acquire
|
|
2742
|
+
* owners inside it. Callbacks must be synchronous: a returned promise is unprotected after its
|
|
2743
|
+
* synchronous prefix.
|
|
2744
|
+
*/
|
|
2745
|
+
export function createTelegramJournalSourceSerialization(getTransactionPath = () => join(resolveTelegramRuntimeDir(), "journals.transaction")) {
|
|
2746
|
+
return (operation) => withTelegramFileTransaction(getTransactionPath(), operation);
|
|
2747
|
+
}
|
|
2738
2748
|
export function createTelegramUpdateJournalStore(options) {
|
|
2739
2749
|
return createJournalStoreCore(options).journal;
|
|
2740
2750
|
}
|
|
@@ -3221,31 +3231,29 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
3221
3231
|
return { file: reset, exists: true, serializedBytes };
|
|
3222
3232
|
}
|
|
3223
3233
|
};
|
|
3234
|
+
const acquireSource = () => sourceAccess
|
|
3235
|
+
? acquireTelegramUpdateJournalFamily({
|
|
3236
|
+
...sourceAccess,
|
|
3237
|
+
path,
|
|
3238
|
+
profile,
|
|
3239
|
+
botIdentity: expectedIdentity,
|
|
3240
|
+
}, version)
|
|
3241
|
+
: undefined;
|
|
3242
|
+
const readAcquiredSource = (source) => {
|
|
3243
|
+
const file = source.evidence.kind === "present" ? source.evidence.file : emptyFile();
|
|
3244
|
+
return {
|
|
3245
|
+
file,
|
|
3246
|
+
exists: source.evidence.kind === "present",
|
|
3247
|
+
serializedBytes: assertCapacity(file),
|
|
3248
|
+
source,
|
|
3249
|
+
};
|
|
3250
|
+
};
|
|
3224
3251
|
const runJournalTransaction = (operation) => {
|
|
3225
3252
|
try {
|
|
3226
3253
|
// Acquire before the transaction helper can create parents or staging names.
|
|
3227
3254
|
// The config continuation excludes participating writers through consumption.
|
|
3228
|
-
const source =
|
|
3229
|
-
|
|
3230
|
-
...sourceAccess,
|
|
3231
|
-
path,
|
|
3232
|
-
profile,
|
|
3233
|
-
botIdentity: expectedIdentity,
|
|
3234
|
-
}, version)
|
|
3235
|
-
: undefined;
|
|
3236
|
-
const readSource = () => {
|
|
3237
|
-
if (!source)
|
|
3238
|
-
return readCurrent();
|
|
3239
|
-
const file = source.evidence.kind === "present"
|
|
3240
|
-
? source.evidence.file
|
|
3241
|
-
: emptyFile();
|
|
3242
|
-
return {
|
|
3243
|
-
file,
|
|
3244
|
-
exists: source.evidence.kind === "present",
|
|
3245
|
-
serializedBytes: assertCapacity(file),
|
|
3246
|
-
source,
|
|
3247
|
-
};
|
|
3248
|
-
};
|
|
3255
|
+
const source = acquireSource();
|
|
3256
|
+
const readSource = () => source ? readAcquiredSource(source) : readCurrent();
|
|
3249
3257
|
return withTelegramFileTransaction(`${path}.transaction`, () => operation(readSource));
|
|
3250
3258
|
}
|
|
3251
3259
|
catch (error) {
|
|
@@ -4091,16 +4099,31 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
4091
4099
|
assertCurrent();
|
|
4092
4100
|
return { issued, entries: result };
|
|
4093
4101
|
});
|
|
4102
|
+
const projectRead = (current) => ({
|
|
4103
|
+
...cloneFile(current.file),
|
|
4104
|
+
exists: current.exists,
|
|
4105
|
+
serializedBytes: current.serializedBytes,
|
|
4106
|
+
});
|
|
4094
4107
|
const journal = {
|
|
4095
4108
|
read() {
|
|
4096
|
-
|
|
4097
|
-
|
|
4098
|
-
|
|
4099
|
-
|
|
4100
|
-
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
|
|
4109
|
+
// Atomic publication makes a repair-free read safe without serialization, so idle
|
|
4110
|
+
// status and polling cursor reads create no guards. Any read that needs reconciliation,
|
|
4111
|
+
// or races a compaction, falls back to the serialized path that owns repair.
|
|
4112
|
+
const attempt = () => {
|
|
4113
|
+
try {
|
|
4114
|
+
const source = acquireSource();
|
|
4115
|
+
return source ? readAcquiredSource(source) : readCurrentStrict(false);
|
|
4116
|
+
}
|
|
4117
|
+
catch {
|
|
4118
|
+
return undefined;
|
|
4119
|
+
}
|
|
4120
|
+
};
|
|
4121
|
+
const current = options.withWriterAdmission
|
|
4122
|
+
? options.withWriterAdmission(attempt)
|
|
4123
|
+
: attempt();
|
|
4124
|
+
return current
|
|
4125
|
+
? projectRead(current)
|
|
4126
|
+
: runMutation((readCurrent) => projectRead(readCurrent()));
|
|
4104
4127
|
},
|
|
4105
4128
|
appendBatch(updates, requestedAcceptedThroughUpdateId) {
|
|
4106
4129
|
const canonicalUpdates = updates.map((update) => normalizeIncomingJournaledUpdate(update, path));
|
|
@@ -4206,18 +4229,20 @@ function createJournalStoreCore(options, getInputContext) {
|
|
|
4206
4229
|
serializedBytes: published.serializedBytes,
|
|
4207
4230
|
};
|
|
4208
4231
|
});
|
|
4232
|
+
// Sender admission is the outer config authority; journal serialization nests inside it.
|
|
4233
|
+
const serializedAppend = (preApprovalExcluded) => withSourceSerialization
|
|
4234
|
+
? withSourceSerialization(() => appendWithEvidence(preApprovalExcluded))
|
|
4235
|
+
: appendWithEvidence(preApprovalExcluded);
|
|
4209
4236
|
const append = () => {
|
|
4210
4237
|
if (withPairedAdmission) {
|
|
4211
|
-
const result = withPairedAdmission(normalizedUpdates, () =>
|
|
4238
|
+
const result = withPairedAdmission(normalizedUpdates, () => serializedAppend());
|
|
4212
4239
|
if (!result.admitted)
|
|
4213
4240
|
throw createJournalError("sender-denied", path, "refused paired-only sender admission");
|
|
4214
4241
|
return result.value;
|
|
4215
4242
|
}
|
|
4216
4243
|
if (withPairingAdmission)
|
|
4217
|
-
return withPairingAdmission(
|
|
4218
|
-
return
|
|
4219
|
-
? withSourceSerialization(() => appendWithEvidence())
|
|
4220
|
-
: appendWithEvidence();
|
|
4244
|
+
return withPairingAdmission(serializedAppend);
|
|
4245
|
+
return serializedAppend();
|
|
4221
4246
|
};
|
|
4222
4247
|
const admittedAppend = () => {
|
|
4223
4248
|
if (!workspaceAdmission)
|
|
@@ -8,6 +8,8 @@ export declare const TELEGRAM_LOCK_KEY = "default";
|
|
|
8
8
|
export declare const TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS = 8000;
|
|
9
9
|
export declare const TELEGRAM_OWNERSHIP_CHECK_MS = 1000;
|
|
10
10
|
export declare const TELEGRAM_OWNERSHIP_REFRESH_MS = 2000;
|
|
11
|
+
/** Consecutive unverified ownership checks tolerated before standing down; a concurrent shared-state replacement must not stop an owned transport. */
|
|
12
|
+
export declare const TELEGRAM_OWNERSHIP_CHECK_FAILURE_TOLERANCE = 2;
|
|
11
13
|
/**
|
|
12
14
|
* Resolve the extension-local owner slot for the active Telegram profile.
|
|
13
15
|
* Default profile → default
|
|
@@ -60,6 +62,8 @@ interface TelegramLockAcquireOptions {
|
|
|
60
62
|
force?: boolean;
|
|
61
63
|
expectedOwner?: TelegramLockEntry;
|
|
62
64
|
election?: boolean;
|
|
65
|
+
/** Bus-proven unresponsive owner: treated as stale only while it is still exactly the current owner. */
|
|
66
|
+
unresponsiveOwner?: TelegramLockEntry;
|
|
63
67
|
}
|
|
64
68
|
type TelegramLockAcquireResult = {
|
|
65
69
|
ok: true;
|
|
@@ -213,6 +217,8 @@ export interface TelegramOwnedStatePublicationOptions extends TelegramRuntimeSta
|
|
|
213
217
|
export declare function mutateTelegramRuntimeStateSection<T>(path: string, profile: string, section: TelegramRuntimeStateSection, mutate: TelegramRuntimeStateSectionReducer<T>, options: TelegramRuntimeStatePublicationOptions): T;
|
|
214
218
|
export declare function writeLocks(path: string, locks: Record<string, unknown>): void;
|
|
215
219
|
export declare function parseTelegramLockEntry(value: unknown): TelegramLockEntry | undefined;
|
|
220
|
+
/** Exact owner identity (pid, cwd, instance, leader epoch, runtime generation); absent entries never match. */
|
|
221
|
+
export declare function isSameTelegramLockOwner(current: TelegramLockEntry | undefined, expected: TelegramLockEntry | undefined): boolean;
|
|
216
222
|
export declare function createTelegramLockRuntime<TContext extends TelegramLockContext>(options?: TelegramLockRuntimeOptions): TelegramLockRuntime<TContext>;
|
|
217
223
|
export declare function createTelegramLockOwnershipGuard<TContext extends TelegramLockContext>(lock: TelegramLockRuntime<TContext>): TelegramLockOwnershipGuard<TContext>;
|
|
218
224
|
export declare function createTelegramDirectDeliveryOwnershipChecker<TContext extends TelegramLockContext>(deps: {
|
|
@@ -225,6 +231,7 @@ interface TelegramLockedPollingStartOptions {
|
|
|
225
231
|
requestedThreadName?: string;
|
|
226
232
|
election?: {
|
|
227
233
|
expectedOwner?: TelegramLockEntry;
|
|
234
|
+
unresponsive?: boolean;
|
|
228
235
|
};
|
|
229
236
|
onAcquired?: () => Promise<void> | void;
|
|
230
237
|
}
|
|
@@ -270,6 +277,8 @@ interface TelegramLockedPollingRuntimeDeps<TContext extends TelegramLockContext>
|
|
|
270
277
|
registerFollowerWithOwner?: (ctx: TContext, owner: TelegramLockEntry) => boolean | undefined | Promise<boolean | undefined>;
|
|
271
278
|
restoreFollowerWithOwner?: (ctx: TContext, owner: TelegramLockEntry) => boolean | undefined | Promise<boolean | undefined>;
|
|
272
279
|
stopFollowerRegistration?: () => void;
|
|
280
|
+
/** Threaded Mode takeover evidence after failed follower registration; replaces the retired file heartbeat. */
|
|
281
|
+
proveOwnerUnresponsive?: (owner: TelegramLockEntry) => Promise<boolean>;
|
|
273
282
|
onTransportAvailabilityChanged?: () => void;
|
|
274
283
|
transportMonitor?: {
|
|
275
284
|
start: (ctx: TContext) => void;
|
|
@@ -14,6 +14,8 @@ export const TELEGRAM_LOCK_KEY = "default";
|
|
|
14
14
|
export const TELEGRAM_BUS_LEADER_STALE_HEARTBEAT_MS = 8_000;
|
|
15
15
|
export const TELEGRAM_OWNERSHIP_CHECK_MS = 1_000;
|
|
16
16
|
export const TELEGRAM_OWNERSHIP_REFRESH_MS = 2_000;
|
|
17
|
+
/** Consecutive unverified ownership checks tolerated before standing down; a concurrent shared-state replacement must not stop an owned transport. */
|
|
18
|
+
export const TELEGRAM_OWNERSHIP_CHECK_FAILURE_TOLERANCE = 2;
|
|
17
19
|
const TELEGRAM_LOCK_WRITE_RETRY_ATTEMPTS = 5;
|
|
18
20
|
const TELEGRAM_LOCK_WRITE_RETRY_DELAY_MS = 25;
|
|
19
21
|
const TELEGRAM_LOCK_TRANSACTION_ATTEMPTS = 80;
|
|
@@ -959,6 +961,10 @@ function hasSameLockOwner(current, expected) {
|
|
|
959
961
|
current.leaderEpoch === expected.leaderEpoch &&
|
|
960
962
|
current.runtimeGeneration === expected.runtimeGeneration);
|
|
961
963
|
}
|
|
964
|
+
/** Exact owner identity (pid, cwd, instance, leader epoch, runtime generation); absent entries never match. */
|
|
965
|
+
export function isSameTelegramLockOwner(current, expected) {
|
|
966
|
+
return hasSameLockOwner(current, expected);
|
|
967
|
+
}
|
|
962
968
|
function canSupersedeSameProcessOwner(current, pid, ctx, instanceId, runtimeGeneration) {
|
|
963
969
|
if (current.pid !== pid ||
|
|
964
970
|
(current.cwd !== undefined && current.cwd !== ctx.cwd) ||
|
|
@@ -973,9 +979,7 @@ function createLockEntry(pid, ctx, options) {
|
|
|
973
979
|
if (options.journalPath)
|
|
974
980
|
lock.journalPath = options.journalPath;
|
|
975
981
|
if (options.instanceId) {
|
|
976
|
-
const nowMs = options.getNowMs?.();
|
|
977
982
|
lock.instanceId = options.instanceId;
|
|
978
|
-
lock.heartbeatMs = nowMs;
|
|
979
983
|
lock.leaderEpoch = options.mintLeaderEpoch?.() ?? randomUUID();
|
|
980
984
|
lock.runtimeGeneration = options.runtimeGeneration;
|
|
981
985
|
}
|
|
@@ -1053,16 +1057,20 @@ export function createTelegramLockRuntime(options = {}) {
|
|
|
1053
1057
|
if (!statePath)
|
|
1054
1058
|
return readLocks(locksPath);
|
|
1055
1059
|
// Read-only ownership queries fail closed (no owner); acquisition/publication still refuse malformed state.
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
.
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1060
|
+
// A strict read racing a concurrent atomic replace fails transiently, so retry before failing closed.
|
|
1061
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
1062
|
+
try {
|
|
1063
|
+
const transports = Object.fromEntries(Object.entries(readTelegramRuntimeState(statePath).profiles)
|
|
1064
|
+
.filter(([, value]) => Object.hasOwn(value, "transport"))
|
|
1065
|
+
.map(([profile, value]) => [profile, value.transport]));
|
|
1066
|
+
assertTelegramStateTransport(transports[resolveEffectiveKey()]);
|
|
1067
|
+
return transports;
|
|
1068
|
+
}
|
|
1069
|
+
catch {
|
|
1070
|
+
// Retry; a persistent failure falls through to no owner.
|
|
1071
|
+
}
|
|
1065
1072
|
}
|
|
1073
|
+
return {};
|
|
1066
1074
|
};
|
|
1067
1075
|
const transactOwners = (mutate) => {
|
|
1068
1076
|
if (!statePath)
|
|
@@ -1144,7 +1152,11 @@ export function createTelegramLockRuntime(options = {}) {
|
|
|
1144
1152
|
changed: false,
|
|
1145
1153
|
};
|
|
1146
1154
|
const current = parseTelegramLockEntry(locks[effectiveKey]);
|
|
1147
|
-
const
|
|
1155
|
+
const observedState = getLockState(current, pid, isAlive, stateOptions());
|
|
1156
|
+
const state = observedState.kind === "active-elsewhere" &&
|
|
1157
|
+
hasSameLockOwner(current, acquireOptions.unresponsiveOwner)
|
|
1158
|
+
? { kind: "stale", lock: observedState.lock }
|
|
1159
|
+
: observedState;
|
|
1148
1160
|
const expectedOwned = adoptCompatibleOwnedLock(effectiveKey, current, ctx);
|
|
1149
1161
|
if (state.kind === "active-here" &&
|
|
1150
1162
|
hasSameLockOwner(current, expectedOwned) &&
|
|
@@ -1191,7 +1203,6 @@ export function createTelegramLockRuntime(options = {}) {
|
|
|
1191
1203
|
instanceId: options.instanceId,
|
|
1192
1204
|
busSocketPath: options.busSocketPath,
|
|
1193
1205
|
busSecret: options.busSecret,
|
|
1194
|
-
getNowMs,
|
|
1195
1206
|
mintLeaderEpoch: options.mintLeaderEpoch,
|
|
1196
1207
|
runtimeGeneration,
|
|
1197
1208
|
});
|
|
@@ -1299,9 +1310,36 @@ export function createTelegramLockRuntime(options = {}) {
|
|
|
1299
1310
|
};
|
|
1300
1311
|
}, { ...publication, isCurrent });
|
|
1301
1312
|
},
|
|
1302
|
-
refresh: (ctx) =>
|
|
1303
|
-
|
|
1304
|
-
|
|
1313
|
+
refresh: (ctx) => {
|
|
1314
|
+
if (deliveryRevoked)
|
|
1315
|
+
return false;
|
|
1316
|
+
// Owner fields only, never a timestamp: liveness is proven over the bus, so an exact owner needs no write.
|
|
1317
|
+
const refreshedEntry = (lock) => {
|
|
1318
|
+
const busSecret = options.busSecret ?? lock.busSecret;
|
|
1319
|
+
return {
|
|
1320
|
+
pid: lock.pid,
|
|
1321
|
+
...(lock.cwd ? { cwd: lock.cwd } : {}),
|
|
1322
|
+
instanceId: options.instanceId,
|
|
1323
|
+
leaderEpoch: lock.leaderEpoch ?? options.mintLeaderEpoch?.() ?? randomUUID(),
|
|
1324
|
+
runtimeGeneration: lock.runtimeGeneration ?? runtimeGeneration,
|
|
1325
|
+
...(options.busSocketPath
|
|
1326
|
+
? { busSocketPath: options.busSocketPath }
|
|
1327
|
+
: {}),
|
|
1328
|
+
...(busSecret !== undefined ? { busSecret } : {}),
|
|
1329
|
+
...(lock.journalPath ? { journalPath: lock.journalPath } : {}),
|
|
1330
|
+
};
|
|
1331
|
+
};
|
|
1332
|
+
// Parsed entries carry explicit `undefined` fields; compare only the published ones.
|
|
1333
|
+
const isCurrentEntry = (lock) => lock.leaderEpoch !== undefined &&
|
|
1334
|
+
isDeepStrictEqual(Object.fromEntries(Object.entries(lock).filter(([, value]) => value !== undefined)), refreshedEntry(lock));
|
|
1335
|
+
const effectiveKey = resolveEffectiveKey();
|
|
1336
|
+
const observed = readLock();
|
|
1337
|
+
if (observed &&
|
|
1338
|
+
hasSameLockOwner(observed, adoptCompatibleOwnedLock(effectiveKey, observed, ctx)) &&
|
|
1339
|
+
(!options.instanceId || isCurrentEntry(observed))) {
|
|
1340
|
+
return true;
|
|
1341
|
+
}
|
|
1342
|
+
return transactOwners((locks) => {
|
|
1305
1343
|
const lock = parseTelegramLockEntry(locks[effectiveKey]);
|
|
1306
1344
|
const expectedOwner = adoptCompatibleOwnedLock(effectiveKey, lock, ctx);
|
|
1307
1345
|
if (!lock || !hasSameLockOwner(lock, expectedOwner)) {
|
|
@@ -1313,24 +1351,15 @@ export function createTelegramLockRuntime(options = {}) {
|
|
|
1313
1351
|
}
|
|
1314
1352
|
if (!options.instanceId)
|
|
1315
1353
|
return { result: true, changed: false };
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
instanceId: options.instanceId,
|
|
1320
|
-
heartbeatMs: getNowMs(),
|
|
1321
|
-
leaderEpoch: lock.leaderEpoch ?? options.mintLeaderEpoch?.() ?? randomUUID(),
|
|
1322
|
-
runtimeGeneration: lock.runtimeGeneration ?? runtimeGeneration,
|
|
1323
|
-
...(options.busSocketPath
|
|
1324
|
-
? { busSocketPath: options.busSocketPath }
|
|
1325
|
-
: {}),
|
|
1326
|
-
busSecret: options.busSecret ?? lock.busSecret,
|
|
1327
|
-
...(lock.journalPath ? { journalPath: lock.journalPath } : {}),
|
|
1328
|
-
};
|
|
1354
|
+
if (isCurrentEntry(lock))
|
|
1355
|
+
return { result: true, changed: false };
|
|
1356
|
+
const refreshedLock = refreshedEntry(lock);
|
|
1329
1357
|
locks[effectiveKey] = refreshedLock;
|
|
1330
1358
|
ownedLockKey = effectiveKey;
|
|
1331
1359
|
ownedLock = refreshedLock;
|
|
1332
1360
|
return { result: true, changed: true };
|
|
1333
|
-
})
|
|
1361
|
+
});
|
|
1362
|
+
},
|
|
1334
1363
|
};
|
|
1335
1364
|
}
|
|
1336
1365
|
export function createTelegramLockOwnershipGuard(lock) {
|
|
@@ -1352,6 +1381,7 @@ export function createTelegramLockedPollingRuntime(deps) {
|
|
|
1352
1381
|
let ownershipRefreshInterval;
|
|
1353
1382
|
let ownershipStop;
|
|
1354
1383
|
let activeContext;
|
|
1384
|
+
let ownershipCheckFailures = 0;
|
|
1355
1385
|
let takeoverCandidate;
|
|
1356
1386
|
let sessionAutoStartRun;
|
|
1357
1387
|
let pollingGeneration = 0;
|
|
@@ -1415,25 +1445,42 @@ export function createTelegramLockedPollingRuntime(deps) {
|
|
|
1415
1445
|
const startOwnershipWatcher = (ctx) => {
|
|
1416
1446
|
const owner = snapshotLockContext(ctx);
|
|
1417
1447
|
stopOwnershipWatcher();
|
|
1418
|
-
|
|
1448
|
+
ownershipCheckFailures = 0;
|
|
1449
|
+
// A serialized "not the owner" answer is definitive and stands down at once; only an observation that
|
|
1450
|
+
// cannot be verified at all (both reads failed) counts toward the bounded tolerance.
|
|
1451
|
+
const verifyOwnership = (observe) => {
|
|
1452
|
+
let owned;
|
|
1453
|
+
let failure;
|
|
1419
1454
|
try {
|
|
1420
|
-
|
|
1421
|
-
return;
|
|
1455
|
+
owned = observe();
|
|
1422
1456
|
}
|
|
1423
1457
|
catch (error) {
|
|
1424
|
-
|
|
1458
|
+
failure = error;
|
|
1425
1459
|
}
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
try {
|
|
1430
|
-
if (deps.lock.refresh(owner))
|
|
1431
|
-
return;
|
|
1460
|
+
if (owned === true) {
|
|
1461
|
+
ownershipCheckFailures = 0;
|
|
1462
|
+
return;
|
|
1432
1463
|
}
|
|
1433
|
-
|
|
1434
|
-
deps.recordRuntimeEvent?.("lock",
|
|
1464
|
+
if (owned === false) {
|
|
1465
|
+
deps.recordRuntimeEvent?.("lock", new Error("Telegram bridge ownership moved away from this instance."), { phase: "ownership-lost" });
|
|
1466
|
+
stopAfterOwnershipLoss();
|
|
1467
|
+
return;
|
|
1435
1468
|
}
|
|
1436
|
-
|
|
1469
|
+
ownershipCheckFailures += 1;
|
|
1470
|
+
deps.recordRuntimeEvent?.("lock", failure, {
|
|
1471
|
+
phase: "ownership-check-failed",
|
|
1472
|
+
consecutiveFailures: ownershipCheckFailures,
|
|
1473
|
+
tolerance: TELEGRAM_OWNERSHIP_CHECK_FAILURE_TOLERANCE,
|
|
1474
|
+
});
|
|
1475
|
+
if (ownershipCheckFailures > TELEGRAM_OWNERSHIP_CHECK_FAILURE_TOLERANCE)
|
|
1476
|
+
stopAfterOwnershipLoss();
|
|
1477
|
+
};
|
|
1478
|
+
ownershipCheckInterval = setInterval(() => {
|
|
1479
|
+
// A lock-free miss can be a strict read racing an atomic replace; refresh confirms through the serialized path.
|
|
1480
|
+
verifyOwnership(() => deps.lock.owns(owner) || deps.lock.refresh(owner));
|
|
1481
|
+
}, ownershipCheckMs);
|
|
1482
|
+
ownershipRefreshInterval = setInterval(() => {
|
|
1483
|
+
verifyOwnership(() => deps.lock.refresh(owner));
|
|
1437
1484
|
}, ownershipRefreshMs);
|
|
1438
1485
|
ownershipCheckInterval.unref?.();
|
|
1439
1486
|
ownershipRefreshInterval.unref?.();
|
|
@@ -1551,6 +1598,9 @@ export function createTelegramLockedPollingRuntime(deps) {
|
|
|
1551
1598
|
expectedOwner: options.election?.expectedOwner ??
|
|
1552
1599
|
(options.force ? takeoverCandidate : undefined),
|
|
1553
1600
|
election: options.election !== undefined,
|
|
1601
|
+
unresponsiveOwner: options.election?.unresponsive
|
|
1602
|
+
? options.election.expectedOwner
|
|
1603
|
+
: undefined,
|
|
1554
1604
|
});
|
|
1555
1605
|
if (!acquired.ok && !options.election) {
|
|
1556
1606
|
const currentState = deps.lock.getState();
|
|
@@ -1593,22 +1643,33 @@ export function createTelegramLockedPollingRuntime(deps) {
|
|
|
1593
1643
|
});
|
|
1594
1644
|
}
|
|
1595
1645
|
if (failureMessage) {
|
|
1596
|
-
const
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
}
|
|
1646
|
+
const unresponsiveOwner = acquired.lock;
|
|
1647
|
+
if (deps.proveOwnerUnresponsive &&
|
|
1648
|
+
(await deps.proveOwnerUnresponsive(unresponsiveOwner))) {
|
|
1649
|
+
if (!isCurrent())
|
|
1650
|
+
return cancelled;
|
|
1651
|
+
acquired = deps.lock.acquire(ctx, { unresponsiveOwner });
|
|
1652
|
+
}
|
|
1653
|
+
if (!acquired.ok) {
|
|
1654
|
+
const owner = formatTelegramLockEntry(acquired.lock);
|
|
1655
|
+
return {
|
|
1656
|
+
ok: false,
|
|
1657
|
+
canTakeover: false,
|
|
1658
|
+
owner,
|
|
1659
|
+
message: `Telegram bridge is active in another Pi instance (${owner}); follower registration failed: ${formatTelegramFollowerRegistrationFailure(failureMessage)}.`,
|
|
1660
|
+
};
|
|
1661
|
+
}
|
|
1603
1662
|
}
|
|
1604
1663
|
}
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1664
|
+
if (!acquired.ok) {
|
|
1665
|
+
const owner = formatTelegramLockEntry(acquired.lock);
|
|
1666
|
+
return {
|
|
1667
|
+
ok: false,
|
|
1668
|
+
canTakeover: true,
|
|
1669
|
+
owner,
|
|
1670
|
+
message: `Telegram bridge is active in another Pi instance (${owner}).`,
|
|
1671
|
+
};
|
|
1672
|
+
}
|
|
1612
1673
|
}
|
|
1613
1674
|
takeoverCandidate = undefined;
|
|
1614
1675
|
if (!(await runOwnedPollingStart(ctx, options, isCurrent))) {
|
|
@@ -910,6 +910,8 @@ export const TELEGRAM_ALL_TAB_COMMAND_MAX_AGE_MS = 60 * 60_000;
|
|
|
910
910
|
export const TELEGRAM_ROUTING_CHOICE_EXPIRED = "Routing choice expired";
|
|
911
911
|
/** Every temporary routing tab carries this name, whether the bot created it or adopted Telegram's native tab. */
|
|
912
912
|
const TELEGRAM_TEMPORARY_THREAD_NAME = "🚦 Routing";
|
|
913
|
+
/** A mobile client's native tab creation precedes the All input it is named after by about a second. */
|
|
914
|
+
const TELEGRAM_IMPLICIT_ALL_INPUT_WINDOW_SEC = 10;
|
|
913
915
|
export function isTelegramAllTabCommandExpired(message, nowMs = Date.now()) {
|
|
914
916
|
return (message.message_thread_id === undefined &&
|
|
915
917
|
typeof message.date === "number" &&
|
|
@@ -6854,6 +6856,30 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
6854
6856
|
}
|
|
6855
6857
|
return undefined;
|
|
6856
6858
|
};
|
|
6859
|
+
/** A fresh implicit native tab whose name the All input starts with, observed just before it in the same exact scope. */
|
|
6860
|
+
const findImplicitCreationForAllInput = (message, updateId, cap, ctx) => {
|
|
6861
|
+
const text = (message.text ?? message.caption ?? "").trim();
|
|
6862
|
+
let match;
|
|
6863
|
+
for (const [key, implicit] of implicitThreadCreations) {
|
|
6864
|
+
if (implicit.name &&
|
|
6865
|
+
text.startsWith(implicit.name) &&
|
|
6866
|
+
implicit.updateId < updateId &&
|
|
6867
|
+
implicit.createdAtSec > 0 &&
|
|
6868
|
+
message.date !== undefined &&
|
|
6869
|
+
message.date >= implicit.createdAtSec &&
|
|
6870
|
+
message.date - implicit.createdAtSec <=
|
|
6871
|
+
TELEGRAM_IMPLICIT_ALL_INPUT_WINDOW_SEC &&
|
|
6872
|
+
implicit.ctx === ctx &&
|
|
6873
|
+
implicit.operatorUserId === cap.operatorUserId &&
|
|
6874
|
+
implicit.journalBindingKey === cap.journalBindingKey &&
|
|
6875
|
+
isDeepStrictEqual(implicit.executor, cap.authority.executor) &&
|
|
6876
|
+
implicit.generation === deps.getSessionGeneration?.() &&
|
|
6877
|
+
implicit.scope === deps.getAdmissionScope?.() &&
|
|
6878
|
+
(!match || implicit.updateId > match.implicit.updateId))
|
|
6879
|
+
match = { key, implicit };
|
|
6880
|
+
}
|
|
6881
|
+
return match;
|
|
6882
|
+
};
|
|
6857
6883
|
/**
|
|
6858
6884
|
* An All input gets one source-bound tab. The original stays deferred in All until explicit routing,
|
|
6859
6885
|
* and an existing entry is reused on replay, so restart never creates a second tab or retries an unknown one.
|
|
@@ -6893,6 +6919,34 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
6893
6919
|
entry = found;
|
|
6894
6920
|
return;
|
|
6895
6921
|
}
|
|
6922
|
+
// Mobile clients sending from All first create a native tab named after the input, then deliver
|
|
6923
|
+
// the input itself to All: adopt that tab rather than leaving it behind next to a second one.
|
|
6924
|
+
const native = findImplicitCreationForAllInput(message, updateId, cap, ctx);
|
|
6925
|
+
if (native) {
|
|
6926
|
+
const adopted = store.registerImplicitTemporaryThread({ journalBindingKey, updateIds: [updateId] }, native.implicit.target, randomBytes(16).toString("hex"), {
|
|
6927
|
+
...authority,
|
|
6928
|
+
isCurrent: () => current() &&
|
|
6929
|
+
implicitThreadCreations.get(native.key) === native.implicit,
|
|
6930
|
+
});
|
|
6931
|
+
if (adopted && current()) {
|
|
6932
|
+
implicitThreadCreations.delete(native.key);
|
|
6933
|
+
entry = adopted;
|
|
6934
|
+
// Adopted native tabs share the routing name; a failed rename never blocks routing.
|
|
6935
|
+
try {
|
|
6936
|
+
await deps.callApi("editForumTopic", {
|
|
6937
|
+
chat_id: native.implicit.target.chatId,
|
|
6938
|
+
message_thread_id: native.implicit.target.threadId,
|
|
6939
|
+
name: TELEGRAM_TEMPORARY_THREAD_NAME,
|
|
6940
|
+
});
|
|
6941
|
+
}
|
|
6942
|
+
catch (error) {
|
|
6943
|
+
deps.recordRuntimeEvent?.("routing", error, {
|
|
6944
|
+
phase: "temporary-thread-name",
|
|
6945
|
+
});
|
|
6946
|
+
}
|
|
6947
|
+
return;
|
|
6948
|
+
}
|
|
6949
|
+
}
|
|
6896
6950
|
const reservation = store.reserveTemporaryThread(source, randomBytes(16).toString("hex"), authority);
|
|
6897
6951
|
entry = reservation?.entry;
|
|
6898
6952
|
if (!reservation?.reserved || !current())
|
|
@@ -7253,6 +7307,12 @@ export function createTelegramInboundRouteRuntime(deps) {
|
|
|
7253
7307
|
executor: structuredClone(cap.authority.executor),
|
|
7254
7308
|
generation: deps.getSessionGeneration?.(),
|
|
7255
7309
|
scope: deps.getAdmissionScope?.(),
|
|
7310
|
+
target: {
|
|
7311
|
+
chatId: lifecycle.target.chatId,
|
|
7312
|
+
threadId: lifecycle.target.threadId,
|
|
7313
|
+
},
|
|
7314
|
+
name: "name" in created && typeof created.name === "string" ? created.name : "",
|
|
7315
|
+
createdAtSec: message.date ?? 0,
|
|
7256
7316
|
});
|
|
7257
7317
|
}
|
|
7258
7318
|
else
|
|
@@ -433,16 +433,36 @@ export function createTelegramRuntimeProjectionStore(options) {
|
|
|
433
433
|
if (!isRuntimeProjection({ ...semantic, writtenAtMs: 0 }))
|
|
434
434
|
throw new Error("Telegram runtime projection is malformed.");
|
|
435
435
|
delete semantic.diagnostics.recentRuntimeEvents;
|
|
436
|
+
// Poll-cycle timestamps change on every response; live status renders them from memory,
|
|
437
|
+
// so persisting them would rewrite state.json each poll for no reader.
|
|
438
|
+
const polling = semantic.runtime.polling;
|
|
439
|
+
if (polling && typeof polling === "object" && !Array.isArray(polling)) {
|
|
440
|
+
const volatile = polling;
|
|
441
|
+
delete volatile.phaseStartedAtMs;
|
|
442
|
+
delete volatile.lastSuccessfulResponseAtMs;
|
|
443
|
+
delete volatile.lastSuccessfulResponseUpdateCount;
|
|
444
|
+
}
|
|
436
445
|
const isCurrent = () => getPath() === path && getProfile() === profile && authority() === true;
|
|
446
|
+
const isUnchanged = (current) => {
|
|
447
|
+
if (!isRuntimeProjection(current))
|
|
448
|
+
return false;
|
|
449
|
+
const { writtenAtMs: _writtenAtMs, ...existing } = current;
|
|
450
|
+
return isDeepStrictEqual(existing, semantic);
|
|
451
|
+
};
|
|
437
452
|
const publication = publicationQueue.then(() => {
|
|
438
453
|
if (!isCurrent())
|
|
439
454
|
return false;
|
|
455
|
+
// Observational state needs no transaction when the published projection already matches.
|
|
456
|
+
try {
|
|
457
|
+
if (isUnchanged(read({ path, profile })))
|
|
458
|
+
return false;
|
|
459
|
+
}
|
|
460
|
+
catch {
|
|
461
|
+
// An unreadable envelope stays with the transactional publisher's fail-closed path.
|
|
462
|
+
}
|
|
440
463
|
return (publish({ path, profile }, (current) => {
|
|
441
|
-
if (
|
|
442
|
-
|
|
443
|
-
if (isDeepStrictEqual(existing, semantic))
|
|
444
|
-
return { value: current, changed: false };
|
|
445
|
-
}
|
|
464
|
+
if (isUnchanged(current))
|
|
465
|
+
return { value: current, changed: false };
|
|
446
466
|
const writtenAtMs = getNowMs();
|
|
447
467
|
if (!Number.isSafeInteger(writtenAtMs) || writtenAtMs < 0)
|
|
448
468
|
throw new Error("Telegram runtime projection timestamp is invalid.");
|
|
@@ -140,6 +140,7 @@ export function createTelegramManualThreadDisconnectHandler(deps) {
|
|
|
140
140
|
assertCurrent();
|
|
141
141
|
const currentRecord = deps.getCurrentThreadRecord();
|
|
142
142
|
let cleanupPending = false;
|
|
143
|
+
let followerThreadKept = false;
|
|
143
144
|
if (currentRecord?.target.threadId) {
|
|
144
145
|
const isManualFollower = currentRecord.owner?.kind === "manual-follower";
|
|
145
146
|
const leaderEpoch = deps.getCurrentLeaderEpoch?.();
|
|
@@ -150,9 +151,8 @@ export function createTelegramManualThreadDisconnectHandler(deps) {
|
|
|
150
151
|
if (deps.disconnectFollowerThread) {
|
|
151
152
|
const disconnected = await deps.disconnectFollowerThread();
|
|
152
153
|
assertCurrent();
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}
|
|
154
|
+
// Without a live leader the Thread cannot be deleted; still stop locally and never claim deletion.
|
|
155
|
+
followerThreadKept = !disconnected;
|
|
156
156
|
}
|
|
157
157
|
}
|
|
158
158
|
else {
|
|
@@ -223,6 +223,8 @@ export function createTelegramManualThreadDisconnectHandler(deps) {
|
|
|
223
223
|
}
|
|
224
224
|
assertCurrent();
|
|
225
225
|
const stopped = await deps.stopPolling();
|
|
226
|
+
if (followerThreadKept)
|
|
227
|
+
return "Telegram bridge disconnected. Thread kept: no live leader could delete it.";
|
|
226
228
|
return cleanupPending
|
|
227
229
|
? `${stopped} Telegram thread cleanup remains pending for the next leader.`
|
|
228
230
|
: stopped;
|