@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 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 both retry, 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.
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
- if (
281
+ return (
285
282
  code === "EEXIST" ||
286
283
  code === "ENOTEMPTY" ||
287
284
  code === "ENOTDIR" ||
288
- code === "EISDIR"
289
- ) {
290
- return true;
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, path)) throw 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, path)) return undefined;
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, path)) throw 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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@llblab/pi-telegram",
3
- "version": "0.27.11",
3
+ "version": "0.27.12",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"