@llblab/pi-telegram 0.27.11 → 0.27.12
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 +5 -0
- package/lib/locks.ts +17 -19
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -78,7 +78,7 @@ The core product loop is mobile continuation: start or supervise work in the ter
|
|
|
78
78
|
- The bridge is session-local, paired to one allowed Telegram user, and owns a local queue aligned with Pi lifecycle hooks
|
|
79
79
|
- The Telegram lock owns external Telegram transport, not local Pi execution. Local extension state and queue runtime are per Pi instance: losing exact `/telegram-connect` ownership must not clear accepted queue state or stop local Pi dispatch, but preview, final delivery, attachments, menus, and other Bot API mutations fail closed once another runtime owns transport. A replacement owner must never receive stale direct mutations merely to preserve old-runtime Telegram output. Bus leader lock entries carry a stable `leaderEpoch` minted on acquisition and preserved across heartbeat refreshes; leader-owned Thread Reconciler cleanup/provisioning actions stamp that epoch and skip destructive side effects if current leadership differs.
|
|
80
80
|
- Telegram transport ownership lives only in the extension-local `tmp/telegram/owners.json` store under `default` or named profile slots. Every mutation must serialize the complete cross-process read/check/write transaction through `owners.json.transaction` and fail closed on contention; atomic payload replacement alone does not make ownership linearizable. Refresh, release, takeover, and irreversible leader work additionally fence against the exact acquired owner and leader epoch. In classic/private-chat mode, transport ownership is the whole bridge ownership and `/telegram-connect` from another live Pi offers classic takeover confirmation. In Threaded Mode, transport ownership is the bus leader role and another live Pi registers as follower instead of offering takeover while the leader is live. If follower registration is unavailable, fall back to classic takeover.
|
|
81
|
-
- Polling checks exact `owners.json` ownership every second but refreshes the durable owner heartbeat only every two seconds; an eight-second stale threshold preserves bounded recovery headroom without turning every ownership check into an atomic disk rewrite. Ownership transactions publish a fully initialized private guard directory containing one exact generation-specific `owner.<generation>.json` through same-parent atomic rename. The owner filename and payload generation must match so stale observation cannot rename replacement metadata. Release verifies the owner and atomically renames the stable guard away before cleanup. Directory recovery first claims owner metadata inside the still-occupied guard so a crashed recoverer can itself be replaced without exposing the stable path; transient guard moves and rollback
|
|
81
|
+
- Polling checks exact `owners.json` ownership every second but refreshes the durable owner heartbeat only every two seconds; an eight-second stale threshold preserves bounded recovery headroom without turning every ownership check into an atomic disk rewrite. Ownership transactions publish a fully initialized private guard directory containing one exact generation-specific `owner.<generation>.json` through same-parent atomic rename. The owner filename and payload generation must match so stale observation cannot rename replacement metadata. Release verifies the owner and atomically renames the stable guard away before cleanup. Directory recovery first claims owner metadata inside the still-occupied guard so a crashed recoverer can itself be replaced without exposing the stable path; transient guard publication, recovery moves, and rollback all retry even when a competing path disappears before error classification, while inactive process-global claim generations remain recoverable after failed restoration. If secondary recovery cleanup fails after publishing a replacement main guard, release that exact replacement before propagating the failure. Guards must not depend on hard links, direct file rename-overwrite, incomplete create-then-write state, or age-only recovery; malformed or unsupported guards fail closed during ordinary mutation.
|
|
82
82
|
- `/telegram-connect` is the bounded liveness-recovery boundary for unclean-shutdown corruption in disposable `tmp/telegram` state. It may quarantine only classifier-approved malformed `owners.json`, active-profile `state*.json`, and `owners.json.transaction` after serializing recoverers and revalidating under ownership fencing; it must preserve `telegram.json`, diagnostics, unrelated artifacts, and every verifiable live owner/transaction holder. Stale heartbeats older than eight seconds do not gain protection through PID reuse. Require confirmed local polling suspension before quarantine mutation; a suspension failure blocks recovery, while an ownership-release failure may continue only into final guarded live-owner revalidation. Use the established bounded Windows sharing-violation rename retries. Retry polling exactly once after recovery, preserve unrelated startup errors, and replace blocked/repeated recovery failure with one explicit restart instruction rather than broad `tmp/` deletion or an automatic loop.
|
|
83
83
|
- Telegram profiles are parallel bot/runtime realities, not merely alternate credentials. Persist every bot/session field (`botToken`, `botUsername`, `botId`, `allowedUserId`, `lastUpdateId`) under `telegram.json` `profiles.<name>`, including `profiles.default`; keep shared handlers and assistant/voice/time/thread-lifecycle settings top-level. Bare and explicit `default` setup/connect commands address the same profile. Legacy root identity may normalize atomically only when unambiguous; conflicts fail closed without mutation. Any owner slot, IPC endpoint, state snapshot, diagnostics log, thread/bus binding, runtime cache, ownership map, or operator-visible status surface that could mix bot identities must be deliberately classified as global, session-local, or profile-scoped. Use the `default` owner slot for the default profile and validated profile names for named slots; give named profiles isolated observable surfaces unless there is an explicit top-level shared-config reason not to.
|
|
84
84
|
- Telegram private-chat Threaded Mode is the public switch for the local multi-instance bus. There is no separate public `telegram.json` bus switch: classic/private-chat singleton DM behavior is the base product mode, and when Telegram reports Threaded Mode available for the bot the bridge enables leader/follower bus behavior. If Telegram reports Threaded Mode unavailable or thread operations prove it unavailable, the bridge returns to classic singleton behavior as a first-class mode. During a live Threaded Mode downgrade, the current transport leader keeps the singleton classic polling role and followers disconnect instead of taking over or offering takeover while active thread bindings prove this is a downgrade from a live bus organism. Do not inject slot/thread rename guidance in classic mode. Blocked instances should not silently register as followers unless the effective runtime mode is Threaded Mode.
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.27.12: Windows Log Serialization Hotfix
|
|
4
|
+
|
|
5
|
+
- `Windows Log Serialization`: File-transaction guard publication now retries transient `EPERM`, `EBUSY`, and `EACCES` failures even when the competing guard disappears before the error is classified. Impact: concurrent runtime JSONL writers no longer drop one process's complete batch during the narrow Windows publication race, while permanent failures remain bounded and fail closed.
|
|
6
|
+
- `Transaction Coverage`: A deterministic regression injects a vanished-path `EPERM` during guard publication and requires the transaction operation to run exactly once after retry. Impact: cross-process diagnostics retain all lines without weakening guard ownership or recovery fencing.
|
|
7
|
+
|
|
3
8
|
## 0.27.11: Follower Connection Notice Hotfix
|
|
4
9
|
|
|
5
10
|
- `Follower Connection Notice`: An exact immediate follower session handoff now verifies its carried thread with a target-scoped chat action instead of sending the connected notice again. Impact: fresh follower startup still announces the assigned instance once, while the automatic session replacement no longer produces duplicate `Instance <name> connected` messages.
|
package/lib/locks.ts
CHANGED
|
@@ -276,20 +276,15 @@ function createLockTransactionContentionError(path: string): Error {
|
|
|
276
276
|
);
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
-
function isLockTransactionContentionError(
|
|
280
|
-
error: unknown,
|
|
281
|
-
path: string,
|
|
282
|
-
): boolean {
|
|
279
|
+
function isLockTransactionContentionError(error: unknown): boolean {
|
|
283
280
|
const code = (error as { code?: unknown })?.code;
|
|
284
|
-
|
|
281
|
+
return (
|
|
285
282
|
code === "EEXIST" ||
|
|
286
283
|
code === "ENOTEMPTY" ||
|
|
287
284
|
code === "ENOTDIR" ||
|
|
288
|
-
code === "EISDIR"
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
}
|
|
292
|
-
return existsSync(path) && (code === "EPERM" || code === "EACCES");
|
|
285
|
+
code === "EISDIR" ||
|
|
286
|
+
isRetryableLockWriteError(error)
|
|
287
|
+
);
|
|
293
288
|
}
|
|
294
289
|
|
|
295
290
|
function removeLockTransactionGuard(path: string): void {
|
|
@@ -298,6 +293,7 @@ function removeLockTransactionGuard(path: string): void {
|
|
|
298
293
|
|
|
299
294
|
function createLockTransactionGuard(
|
|
300
295
|
path: string,
|
|
296
|
+
options: TelegramFileTransactionOptions = {},
|
|
301
297
|
): TelegramLockTransactionOwner {
|
|
302
298
|
const owner: TelegramLockTransactionOwner = {
|
|
303
299
|
pid: process.pid,
|
|
@@ -313,7 +309,7 @@ function createLockTransactionGuard(
|
|
|
313
309
|
{ encoding: "utf8", flag: "wx", mode: 0o600 },
|
|
314
310
|
);
|
|
315
311
|
if (existsSync(path)) throw createLockTransactionContentionError(path);
|
|
316
|
-
renameSync(stagedPath, path);
|
|
312
|
+
(options.publishRename ?? renameSync)(stagedPath, path);
|
|
317
313
|
return owner;
|
|
318
314
|
} finally {
|
|
319
315
|
try {
|
|
@@ -368,6 +364,7 @@ type TelegramTransactionGlobal = typeof globalThis & {
|
|
|
368
364
|
|
|
369
365
|
export interface TelegramFileTransactionOptions {
|
|
370
366
|
recoveryRename?: typeof renameSync;
|
|
367
|
+
publishRename?: typeof renameSync;
|
|
371
368
|
attempts?: number;
|
|
372
369
|
retryDelayMs?: number;
|
|
373
370
|
}
|
|
@@ -465,9 +462,9 @@ function acquireRecoverableDirectoryGuard(
|
|
|
465
462
|
): TelegramLockTransactionOwner | undefined {
|
|
466
463
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
467
464
|
try {
|
|
468
|
-
return createLockTransactionGuard(path);
|
|
465
|
+
return createLockTransactionGuard(path, options);
|
|
469
466
|
} catch (error) {
|
|
470
|
-
if (!isLockTransactionContentionError(error
|
|
467
|
+
if (!isLockTransactionContentionError(error)) throw error;
|
|
471
468
|
if (!reclaimAbandonedDirectoryGuard(path, options)) return undefined;
|
|
472
469
|
}
|
|
473
470
|
}
|
|
@@ -528,11 +525,12 @@ function acquireLegacyRecoveryGuard(
|
|
|
528
525
|
|
|
529
526
|
function createRecoveredLockTransactionGuard(
|
|
530
527
|
path: string,
|
|
528
|
+
options: TelegramFileTransactionOptions = {},
|
|
531
529
|
): TelegramLockTransactionOwner | undefined {
|
|
532
530
|
try {
|
|
533
|
-
return createLockTransactionGuard(path);
|
|
531
|
+
return createLockTransactionGuard(path, options);
|
|
534
532
|
} catch (error) {
|
|
535
|
-
if (isLockTransactionContentionError(error
|
|
533
|
+
if (isLockTransactionContentionError(error)) return undefined;
|
|
536
534
|
throw error;
|
|
537
535
|
}
|
|
538
536
|
}
|
|
@@ -550,7 +548,7 @@ function recoverAbandonedLockTransaction(
|
|
|
550
548
|
}
|
|
551
549
|
if (isDirectory) {
|
|
552
550
|
if (!reclaimAbandonedDirectoryGuard(path, options)) return undefined;
|
|
553
|
-
const recoveredOwner = createRecoveredLockTransactionGuard(path);
|
|
551
|
+
const recoveredOwner = createRecoveredLockTransactionGuard(path, options);
|
|
554
552
|
try {
|
|
555
553
|
reclaimAbandonedDirectoryGuard(`${path}.recovery`, options);
|
|
556
554
|
return recoveredOwner;
|
|
@@ -584,7 +582,7 @@ function recoverAbandonedLockTransaction(
|
|
|
584
582
|
} catch {
|
|
585
583
|
/* stale debris cannot retain transaction authority */
|
|
586
584
|
}
|
|
587
|
-
recoveredOwner = createRecoveredLockTransactionGuard(path);
|
|
585
|
+
recoveredOwner = createRecoveredLockTransactionGuard(path, options);
|
|
588
586
|
return recoveredOwner;
|
|
589
587
|
} finally {
|
|
590
588
|
try {
|
|
@@ -617,9 +615,9 @@ function acquireLockTransaction(
|
|
|
617
615
|
mkdirSync(dirname(path), { recursive: true });
|
|
618
616
|
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
619
617
|
try {
|
|
620
|
-
return createLockTransactionGuard(path);
|
|
618
|
+
return createLockTransactionGuard(path, options);
|
|
621
619
|
} catch (error) {
|
|
622
|
-
if (!isLockTransactionContentionError(error
|
|
620
|
+
if (!isLockTransactionContentionError(error)) throw error;
|
|
623
621
|
const recoveredOwner = recoverAbandonedLockTransaction(path, options);
|
|
624
622
|
if (recoveredOwner !== undefined) return recoveredOwner;
|
|
625
623
|
if (attempt === attempts - 1) {
|