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 +3 -0
- package/build-info.json +4 -4
- package/package.json +1 -1
- package/src/index.ts +28 -3
- package/src/lib/poller-lock.ts +137 -12
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.
|
|
4
|
-
"sha": "
|
|
5
|
-
"shaShort": "
|
|
3
|
+
"version": "0.1.10",
|
|
4
|
+
"sha": "3a3de965131cd125b58a807b064a43d37b7f466f",
|
|
5
|
+
"shaShort": "3a3de965",
|
|
6
6
|
"branch": "detached",
|
|
7
7
|
"dirty": false,
|
|
8
|
-
"packedAt": "2026-11-
|
|
8
|
+
"packedAt": "2026-11-11T22:45:42.463Z"
|
|
9
9
|
}
|
package/package.json
CHANGED
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),
|
|
489
|
-
*
|
|
490
|
-
*
|
|
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)}`,
|
package/src/lib/poller-lock.ts
CHANGED
|
@@ -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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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 = (
|
|
428
|
-
|
|
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
|
|
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: {
|
|
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, {
|
|
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
|
|
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
|
-
|
|
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);
|