pi-braid-telegram 0.1.9 → 0.1.10

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/README.md CHANGED
@@ -65,6 +65,9 @@ Every `BRAID_TG_*` name has a `TELEGRAM_*` alias; the prefixed name wins when bo
65
65
 
66
66
  - Only whitelisted chats are read, and only owner messages can become a turn.
67
67
  - The poller is off by default and only one runs per process.
68
+ - A lock write the filesystem refuses is reported, never fatal: the acquisition answers "not taken"
69
+ and the running heartbeat records the failure and retries on its schedule. While the write is
70
+ refused, this holder's heartbeat ages into the takeover window.
68
71
  - Voice replies need a TTS source. `source: "none"` refuses the send rather than silently sending
69
72
  text.
70
73
  - Transcription needs a key. Without one a voice note is still delivered, as the downloaded file,
package/build-info.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "pi-braid-telegram",
3
- "version": "0.1.9",
4
- "sha": "ced405d5d39735e56ba7b2079c95b75371153e99",
5
- "shaShort": "ced405d5",
3
+ "version": "0.1.10",
4
+ "sha": "3a3de965131cd125b58a807b064a43d37b7f466f",
5
+ "shaShort": "3a3de965",
6
6
  "branch": "detached",
7
7
  "dirty": false,
8
- "packedAt": "2026-11-10T18:51:54.828Z"
8
+ "packedAt": "2026-11-11T22:45:42.463Z"
9
9
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-braid-telegram",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "Talk to your pi coding agent from Telegram: send text or voice, get the answer back, and tap a button to decide.",
5
5
  "type": "module",
6
6
  "files": [
package/src/index.ts CHANGED
@@ -138,6 +138,7 @@ import {
138
138
  type PollerLockInfo,
139
139
  type PollerLockRefusalReason,
140
140
  type PollerLockTakeoverReason,
141
+ type PollerLockWriteFailure,
141
142
  } from "./lib/poller-lock.js";
142
143
  import { isWorkerRole, resolveSessionRole } from "./lib/session-role.js";
143
144
  import {
@@ -485,9 +486,10 @@ let pollerWanted = false;
485
486
  * singleton), `role-forbidden` (worker session — the seat owns the poller),
486
487
  * `lock-held` (another process owns the machine-wide lock; `holder` names it,
487
488
  * `refusalReason` says which kind of holder it is, and `path` is the file an
488
- * operator inspects), or `lock-path-underivable` (no absolute braid root, so
489
- * the ownership lock has no home). Callers render the reason for their own
490
- * surface.
489
+ * operator inspects), `lock-write-failed` (the lock file could not be written,
490
+ * so no acquisition happened; `failure` carries the errno and the file), or
491
+ * `lock-path-underivable` (no absolute braid root, so the ownership lock has no
492
+ * home). Callers render the reason for their own surface.
491
493
  */
492
494
  type PollerStartRefusal =
493
495
  | { started: false; reason: "already-running" }
@@ -499,6 +501,12 @@ type PollerStartRefusal =
499
501
  refusalReason: PollerLockRefusalReason;
500
502
  path: string;
501
503
  }
504
+ | {
505
+ started: false;
506
+ reason: "lock-write-failed";
507
+ path: string;
508
+ failure: PollerLockWriteFailure;
509
+ }
502
510
  | { started: false; reason: "lock-path-underivable" };
503
511
  type PollerStartOutcome =
504
512
  | {
@@ -513,6 +521,9 @@ function describePollerStartRefusal(outcome: PollerStartRefusal): string {
513
521
  if (outcome.reason === "lock-held") {
514
522
  return `ownership lock held by ${describeLockHolder(outcome.holder)}: ${describeLockReason(outcome.refusalReason, outcome.path)}`;
515
523
  }
524
+ if (outcome.reason === "lock-write-failed") {
525
+ return `${outcome.failure.message} — the ownership lock was not taken, so this process does not poll`;
526
+ }
516
527
  if (outcome.reason === "role-forbidden") {
517
528
  return "worker role — the orchestrator seat owns the Telegram poller";
518
529
  }
@@ -599,6 +610,20 @@ function startSingletonPoller(opts: {
599
610
  { pidAlive: opts.pidAlive },
600
611
  );
601
612
  if (!acquired.ok) {
613
+ // A write that never landed is not a verdict about a holder: the channel may
614
+ // be free and still unreachable, so the reason names the file and the errno.
615
+ if (acquired.reason === "write-failed") {
616
+ log(
617
+ "warn",
618
+ `telegram poller not started: ${acquired.failure.message} — the ownership lock was not taken, so this process does not poll`,
619
+ );
620
+ return {
621
+ started: false,
622
+ reason: "lock-write-failed",
623
+ path: acquired.path,
624
+ failure: acquired.failure,
625
+ };
626
+ }
602
627
  log(
603
628
  "warn",
604
629
  `telegram poller not started: ${describeLockHolder(acquired.holder)} holds the ownership lock — ${describeLockReason(acquired.reason, acquired.path)}`,
@@ -15,6 +15,12 @@
15
15
  * that process last proved it was running; the pid is read as evidence only on
16
16
  * the boot that wrote it. A recycled pid on one host is the same class, and the
17
17
  * same rule covers it.
18
+ *
19
+ * A write to the file can also fail: the volume fills, the tree goes read-only,
20
+ * a permission changes under it. None of that is a verdict about the channel.
21
+ * The acquire path answers "the lock was not taken", a beat records the failure
22
+ * and keeps its schedule, and a write that cannot land leaves the previous
23
+ * record on disk: the file is replaced whole or left as it was.
18
24
  */
19
25
 
20
26
  import { execFileSync } from "node:child_process";
@@ -22,6 +28,7 @@ import {
22
28
  existsSync,
23
29
  mkdirSync,
24
30
  readFileSync,
31
+ renameSync,
25
32
  unlinkSync,
26
33
  writeFileSync,
27
34
  } from "node:fs";
@@ -140,8 +147,32 @@ export type AcquirePollerLockResult =
140
147
  holder: PollerLockInfo;
141
148
  /** The file the caller should name in a refusal message. */
142
149
  path: string;
150
+ }
151
+ | {
152
+ /**
153
+ * The record could not be written, so nothing was acquired. Not a
154
+ * verdict about a holder: whoever held the channel may still hold it, and
155
+ * the file on disk still says who. The caller is not the owner.
156
+ */
157
+ ok: false;
158
+ reason: "write-failed";
159
+ path: string;
160
+ failure: PollerLockWriteFailure;
143
161
  };
144
162
 
163
+ /**
164
+ * A lock record write that did not land. Returned, never thrown: the three
165
+ * callers of the write are a start path, an interval callback and an exit hook,
166
+ * and a throw in any of them ends the process. A filesystem that refused a
167
+ * timestamp is news about the channel, not a reason to end the seat.
168
+ */
169
+ export interface PollerLockWriteFailure {
170
+ /** The errno code the filesystem reported (`ENOSPC`, `EACCES`, `EROFS`, …). */
171
+ code: string | undefined;
172
+ /** One sentence for an operator: which file, and what the filesystem said. */
173
+ message: string;
174
+ }
175
+
145
176
  /** Injectable reads for the acquisition rule (tests supply a fake clock and
146
177
  * process table; the live path uses the machine's). */
147
178
  export interface AcquirePollerLockOptions {
@@ -265,9 +296,51 @@ export function resolveInstanceId(): string {
265
296
  return cachedInstanceId;
266
297
  }
267
298
 
268
- function writeLock(path: string, info: PollerLockInfo): void {
269
- mkdirSync(dirname(path), { recursive: true });
270
- writeFileSync(path, JSON.stringify(info));
299
+ /** What a refused write reports: the errno when the filesystem gave one, and a
300
+ * sentence that names the file. */
301
+ function describeWriteFailure(
302
+ path: string,
303
+ error: unknown,
304
+ ): PollerLockWriteFailure {
305
+ const code = (error as NodeJS.ErrnoException | null)?.code;
306
+ const detail = error instanceof Error ? error.message : String(error);
307
+ return {
308
+ code: typeof code === "string" ? code : undefined,
309
+ message: `could not write ${path}: ${detail}`,
310
+ };
311
+ }
312
+
313
+ /**
314
+ * Write the record whole or not at all: a staging file beside the target is
315
+ * renamed over it, so a reader sees either the previous record or the new one.
316
+ * A direct `writeFileSync` on the target truncates before it writes, which is
317
+ * how a failed beat could erase the record that says who holds the channel.
318
+ *
319
+ * The failure is returned rather than thrown for the same reason it is
320
+ * recorded: the callers are a timer, a start path and an exit hook, and none of
321
+ * them may end the process over a file it could not write.
322
+ */
323
+ function writeLock(
324
+ path: string,
325
+ info: PollerLockInfo,
326
+ ): PollerLockWriteFailure | undefined {
327
+ const staging = `${path}.${process.pid}.tmp`;
328
+ try {
329
+ mkdirSync(dirname(path), { recursive: true });
330
+ writeFileSync(staging, JSON.stringify(info));
331
+ renameSync(staging, path);
332
+ return undefined;
333
+ } catch (error) {
334
+ // A staging file that was created but not renamed is this process's to
335
+ // remove; one that never existed fails this too. The record on disk is
336
+ // untouched whether this succeeds or not.
337
+ try {
338
+ unlinkSync(staging);
339
+ } catch {
340
+ // Nothing staged to remove, or the same condition that refused the write.
341
+ }
342
+ return describeWriteFailure(path, error);
343
+ }
271
344
  }
272
345
 
273
346
  // Best-effort release at process exit: the exit hook removes the lock this
@@ -411,6 +484,9 @@ function readPidStartedAt(pid: number): number | undefined {
411
484
  * recycled pid, so acquire `stale-legacy-recycled`; anything else refuses
412
485
  * `held-legacy` and names the file, because a live pid we cannot date is the
413
486
  * one case where guessing either steals a channel or wedges one.
487
+ *
488
+ * A takeover the rule allows still has to reach the disk: when the write fails,
489
+ * the answer is `write-failed` and this process is not the owner.
414
490
  */
415
491
  export function acquirePollerLock(
416
492
  path: string,
@@ -424,15 +500,24 @@ export function acquirePollerLock(
424
500
  const instanceId = stamp.instanceId ?? resolveInstanceId();
425
501
  const existing = readPollerLock(path);
426
502
 
427
- const acquire = (reason: PollerLockTakeoverReason) => {
428
- writeLock(path, {
503
+ const acquire = (
504
+ reason: PollerLockTakeoverReason,
505
+ ): AcquirePollerLockResult => {
506
+ const record: PollerLockInfo = {
429
507
  pid: stamp.pid,
430
508
  startedAt: stamp.startedAt ?? at,
431
509
  instanceId,
432
510
  heartbeatAt: at,
433
511
  ...(stamp.role === undefined ? {} : { role: stamp.role }),
434
512
  ...(stamp.startedBy === undefined ? {} : { startedBy: stamp.startedBy }),
435
- });
513
+ };
514
+ const failure = writeLock(path, record);
515
+ // The file on disk still names the previous holder (or no holder at all):
516
+ // this process did not acquire the channel, so it claims no exit hook and
517
+ // reports the write rather than dying on it.
518
+ if (failure !== undefined) {
519
+ return { ok: false, reason: "write-failed", path, failure };
520
+ }
436
521
  heldLockPath = path;
437
522
  installExitRelease();
438
523
  return {
@@ -499,26 +584,50 @@ export function acquirePollerLock(
499
584
 
500
585
  /**
501
586
  * Refresh `heartbeatAt` for a lock this process holds. False when the file is
502
- * gone or names another holder: a beat never rewrites a successor's record.
587
+ * gone, names another holder, or could not be written: a beat never rewrites a
588
+ * successor's record, and a write that cannot land reports instead of throwing.
589
+ * `onWriteFailure` says which of the two false answers this was.
503
590
  */
504
591
  export function refreshPollerHeartbeat(
505
592
  path: string,
506
- options: { now?: () => number; pid?: number; instanceId?: string } = {},
593
+ options: {
594
+ now?: () => number;
595
+ pid?: number;
596
+ instanceId?: string;
597
+ /** Called with the reason when the beat reached the filesystem and the
598
+ * filesystem refused it. Never called for a beat that the ownership rule
599
+ * dropped before any write was attempted. */
600
+ onWriteFailure?: (failure: PollerLockWriteFailure) => void;
601
+ } = {},
507
602
  ): boolean {
508
603
  const holder = readPollerLock(path);
509
604
  if (!holder) return false;
510
605
  const pid = options.pid ?? process.pid;
511
606
  const instanceId = options.instanceId ?? resolveInstanceId();
512
607
  if (holder.pid !== pid || holder.instanceId !== instanceId) return false;
513
- writeLock(path, { ...holder, heartbeatAt: (options.now ?? Date.now)() });
608
+ const failure = writeLock(path, {
609
+ ...holder,
610
+ heartbeatAt: (options.now ?? Date.now)(),
611
+ });
612
+ if (failure !== undefined) {
613
+ options.onWriteFailure?.(failure);
614
+ return false;
615
+ }
514
616
  return true;
515
617
  }
516
618
 
517
619
  /** One running heartbeat: at most one per process, on one lock file. */
518
620
  export interface PollerHeartbeat {
519
621
  readonly path: string;
520
- /** Refresh now; false once the lock is no longer ours. */
622
+ /** Refresh now; false when no new stamp reached the file (the lock is no
623
+ * longer ours, or the write failed — `lastError` says which). */
521
624
  beat(): boolean;
625
+ /**
626
+ * Why the last beat wrote nothing, or undefined once a beat lands. A failed
627
+ * beat is information about the channel: the beat keeps its schedule, and a
628
+ * later beat that writes clears this.
629
+ */
630
+ readonly lastError: PollerLockWriteFailure | undefined;
522
631
  /** Stop refreshing. Idempotent. */
523
632
  stop(): void;
524
633
  }
@@ -531,14 +640,27 @@ let activeHeartbeat:
531
640
  * Refresh the lock's heartbeat on an interval. Replaces any heartbeat already
532
641
  * running in this process: one lock, one beat. The timer is unref'd: it serves
533
642
  * the lock, never the event loop, so it cannot keep a process alive after the
534
- * poller it belongs to is gone.
643
+ * poller it belongs to is gone. The beat body cannot throw: its one fallible
644
+ * operation is the write, and that reports into `lastError`.
535
645
  */
536
646
  export function startPollerHeartbeat(
537
647
  path: string,
538
648
  options: { intervalMs?: number; now?: () => number } = {},
539
649
  ): PollerHeartbeat {
540
650
  stopPollerHeartbeat();
541
- const beat = () => refreshPollerHeartbeat(path, { now: options.now });
651
+ let lastError: PollerLockWriteFailure | undefined;
652
+ const beat = (): boolean => {
653
+ let failed: PollerLockWriteFailure | undefined;
654
+ const written = refreshPollerHeartbeat(path, {
655
+ now: options.now,
656
+ onWriteFailure: (failure) => {
657
+ failed = failure;
658
+ },
659
+ });
660
+ if (failed !== undefined) lastError = failed;
661
+ else if (written) lastError = undefined;
662
+ return written;
663
+ };
542
664
  const timer = setInterval(
543
665
  beat,
544
666
  options.intervalMs ?? POLLER_HEARTBEAT_INTERVAL_MS,
@@ -549,6 +671,9 @@ export function startPollerHeartbeat(
549
671
  return {
550
672
  path,
551
673
  beat,
674
+ get lastError() {
675
+ return lastError;
676
+ },
552
677
  stop: () => {
553
678
  if (activeHeartbeat === entry) activeHeartbeat = undefined;
554
679
  clearInterval(timer);