pi-braid-telegram 0.1.8 → 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 +10 -6
- package/build-info.json +4 -4
- package/package.json +1 -1
- package/src/index.ts +64 -7
- package/src/lib/poller-lock.ts +201 -28
- package/src/lib/telegram-settings.ts +68 -0
package/README.md
CHANGED
|
@@ -53,17 +53,21 @@ Every `BRAID_TG_*` name has a `TELEGRAM_*` alias; the prefixed name wins when bo
|
|
|
53
53
|
|
|
54
54
|
`braid.telegram.*` keys in `~/.pi/agent/settings.json`:
|
|
55
55
|
|
|
56
|
-
| Key
|
|
57
|
-
|
|
|
58
|
-
| `braid.telegram.sendMaxChars`
|
|
59
|
-
| `braid.telegram.notifyMaxChars`
|
|
60
|
-
| `braid.telegram.
|
|
61
|
-
| `braid.telegram.
|
|
56
|
+
| Key | Default | Effect |
|
|
57
|
+
| -------------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
58
|
+
| `braid.telegram.sendMaxChars` | `4096` | Cap for plain `telegram_send` text; re-read per send |
|
|
59
|
+
| `braid.telegram.notifyMaxChars` | `1200` | Cap for the turn-complete update; re-read per notification |
|
|
60
|
+
| `braid.telegram.pollerAutoStart` | off | Working directories that start the inbound poller by themselves: `true` everywhere, an absolute-cwd array those only, exact match; read at load |
|
|
61
|
+
| `braid.telegram.tts` | edge-tts | Voice source, rate and voices; read at extension load |
|
|
62
|
+
| `braid.telegram.pauseStateFile` | unset | Where the pause control keeps its state; read at load |
|
|
62
63
|
|
|
63
64
|
## Limits
|
|
64
65
|
|
|
65
66
|
- Only whitelisted chats are read, and only owner messages can become a turn.
|
|
66
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.
|
|
67
71
|
- Voice replies need a TTS source. `source: "none"` refuses the send rather than silently sending
|
|
68
72
|
text.
|
|
69
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
|
@@ -94,6 +94,7 @@ import {
|
|
|
94
94
|
type SttConfig,
|
|
95
95
|
} from "./lib/transcribe.js";
|
|
96
96
|
import { resolveSttKeyCandidates } from "./lib/stt-keys.js";
|
|
97
|
+
import { pollerAutoStartFor } from "./lib/telegram-settings.js";
|
|
97
98
|
import { normalizeAudioBytes } from "./lib/audio-normalize.js";
|
|
98
99
|
import { populateVoiceTts } from "./lib/tts.js";
|
|
99
100
|
import { runTelegramSend } from "./lib/tool-logic.js";
|
|
@@ -137,6 +138,7 @@ import {
|
|
|
137
138
|
type PollerLockInfo,
|
|
138
139
|
type PollerLockRefusalReason,
|
|
139
140
|
type PollerLockTakeoverReason,
|
|
141
|
+
type PollerLockWriteFailure,
|
|
140
142
|
} from "./lib/poller-lock.js";
|
|
141
143
|
import { isWorkerRole, resolveSessionRole } from "./lib/session-role.js";
|
|
142
144
|
import {
|
|
@@ -484,9 +486,10 @@ let pollerWanted = false;
|
|
|
484
486
|
* singleton), `role-forbidden` (worker session — the seat owns the poller),
|
|
485
487
|
* `lock-held` (another process owns the machine-wide lock; `holder` names it,
|
|
486
488
|
* `refusalReason` says which kind of holder it is, and `path` is the file an
|
|
487
|
-
* operator inspects),
|
|
488
|
-
*
|
|
489
|
-
*
|
|
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.
|
|
490
493
|
*/
|
|
491
494
|
type PollerStartRefusal =
|
|
492
495
|
| { started: false; reason: "already-running" }
|
|
@@ -498,6 +501,12 @@ type PollerStartRefusal =
|
|
|
498
501
|
refusalReason: PollerLockRefusalReason;
|
|
499
502
|
path: string;
|
|
500
503
|
}
|
|
504
|
+
| {
|
|
505
|
+
started: false;
|
|
506
|
+
reason: "lock-write-failed";
|
|
507
|
+
path: string;
|
|
508
|
+
failure: PollerLockWriteFailure;
|
|
509
|
+
}
|
|
501
510
|
| { started: false; reason: "lock-path-underivable" };
|
|
502
511
|
type PollerStartOutcome =
|
|
503
512
|
| {
|
|
@@ -512,6 +521,9 @@ function describePollerStartRefusal(outcome: PollerStartRefusal): string {
|
|
|
512
521
|
if (outcome.reason === "lock-held") {
|
|
513
522
|
return `ownership lock held by ${describeLockHolder(outcome.holder)}: ${describeLockReason(outcome.refusalReason, outcome.path)}`;
|
|
514
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
|
+
}
|
|
515
527
|
if (outcome.reason === "role-forbidden") {
|
|
516
528
|
return "worker role — the orchestrator seat owns the Telegram poller";
|
|
517
529
|
}
|
|
@@ -598,6 +610,20 @@ function startSingletonPoller(opts: {
|
|
|
598
610
|
{ pidAlive: opts.pidAlive },
|
|
599
611
|
);
|
|
600
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
|
+
}
|
|
601
627
|
log(
|
|
602
628
|
"warn",
|
|
603
629
|
`telegram poller not started: ${describeLockHolder(acquired.holder)} holds the ownership lock — ${describeLockReason(acquired.reason, acquired.path)}`,
|
|
@@ -1705,6 +1731,29 @@ export default async function telegramExtension(
|
|
|
1705
1731
|
pi.registerCommand("telegram", {
|
|
1706
1732
|
description:
|
|
1707
1733
|
"Telegram channel control: /telegram enable | /telegram send <text>",
|
|
1734
|
+
getArgumentCompletions: (prefix: string) => {
|
|
1735
|
+
const typed = (prefix ?? "").toLowerCase();
|
|
1736
|
+
const verbs = [
|
|
1737
|
+
{
|
|
1738
|
+
value: "enable",
|
|
1739
|
+
label: "enable",
|
|
1740
|
+
description: "Start the background poller",
|
|
1741
|
+
},
|
|
1742
|
+
{
|
|
1743
|
+
value: "disable",
|
|
1744
|
+
label: "disable",
|
|
1745
|
+
description: "Stop the background poller",
|
|
1746
|
+
},
|
|
1747
|
+
{ value: "send", label: "send", description: "Send text to the owner" },
|
|
1748
|
+
{
|
|
1749
|
+
value: "status",
|
|
1750
|
+
label: "status",
|
|
1751
|
+
description: "Show channel and poller state",
|
|
1752
|
+
},
|
|
1753
|
+
];
|
|
1754
|
+
const matches = verbs.filter((verb) => verb.value.startsWith(typed));
|
|
1755
|
+
return matches.length > 0 ? matches : null;
|
|
1756
|
+
},
|
|
1708
1757
|
handler: async (args: string, ctx: any) => {
|
|
1709
1758
|
const trimmed = args.trim();
|
|
1710
1759
|
|
|
@@ -1818,7 +1867,13 @@ export default async function telegramExtension(
|
|
|
1818
1867
|
// it is unset AND BRAID_TG_POLL_ENABLED is set. BRAID_TG_POLL_ENABLED opts
|
|
1819
1868
|
// in explicitly.
|
|
1820
1869
|
const pollDisabled = Boolean(env(ENV_POLL_DISABLED));
|
|
1821
|
-
|
|
1870
|
+
// Two ways in: the env var, or a settings entry naming this working directory
|
|
1871
|
+
// (`braid.telegram.pollerAutoStart`, read through the same uncached reader the
|
|
1872
|
+
// caps use, so an edit applies without a restart). The settings path is the
|
|
1873
|
+
// one that survives a hand-started session after a reboot, which is the case
|
|
1874
|
+
// that left the owner with no channel at all.
|
|
1875
|
+
const pollByCwd = pollerAutoStartFor(process.cwd());
|
|
1876
|
+
const pollEnabled = Boolean(env(ENV_POLL_ENABLED)) || pollByCwd;
|
|
1822
1877
|
|
|
1823
1878
|
if (pollDisabled) {
|
|
1824
1879
|
log(
|
|
@@ -1838,20 +1893,22 @@ export default async function telegramExtension(
|
|
|
1838
1893
|
stt,
|
|
1839
1894
|
onOwnerMessage: deliverOwnerMessage,
|
|
1840
1895
|
onCallbackQuery: handleCallbackQuery,
|
|
1841
|
-
startedBy:
|
|
1896
|
+
startedBy: env(ENV_POLL_ENABLED)
|
|
1897
|
+
? `${ENV_POLL_ENABLED}=1`
|
|
1898
|
+
: "settings:pollerAutoStart",
|
|
1842
1899
|
onStart: () => void placeControlKeyboardOnce(),
|
|
1843
1900
|
pidAlive: deps?.pidAlive,
|
|
1844
1901
|
});
|
|
1845
1902
|
if (!outcome.started && outcome.reason !== "already-running") {
|
|
1846
1903
|
log(
|
|
1847
1904
|
outcome.reason === "lock-held" ? "warn" : "info",
|
|
1848
|
-
`telegram poller not auto-started (${ENV_POLL_ENABLED}=1): ${describePollerStartRefusal(outcome)}`,
|
|
1905
|
+
`telegram poller not auto-started (${env(ENV_POLL_ENABLED) ? `${ENV_POLL_ENABLED}=1` : "settings:pollerAutoStart"}): ${describePollerStartRefusal(outcome)}`,
|
|
1849
1906
|
);
|
|
1850
1907
|
}
|
|
1851
1908
|
} else {
|
|
1852
1909
|
log(
|
|
1853
1910
|
"info",
|
|
1854
|
-
`telegram background poller default-off (set ${ENV_POLL_ENABLED}=1
|
|
1911
|
+
`telegram background poller default-off (set ${ENV_POLL_ENABLED}=1, name this cwd in braid.telegram.pollerAutoStart, or use /telegram enable or the telegram_poller tool)`,
|
|
1855
1912
|
);
|
|
1856
1913
|
}
|
|
1857
1914
|
|
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";
|
|
@@ -48,7 +55,7 @@ export const POLLER_HEARTBEAT_STALE_MS = 30_000;
|
|
|
48
55
|
*/
|
|
49
56
|
const LEGACY_PID_REUSE_SLACK_MS = 60_000;
|
|
50
57
|
|
|
51
|
-
/** Boot identity recorded when
|
|
58
|
+
/** Boot identity recorded when the kernel offers no readable name at all. */
|
|
52
59
|
const BOOT_IDENTITY_FALLBACK = "boot-unknown";
|
|
53
60
|
|
|
54
61
|
/** Default lock location: beside the other braid-owned telegram state
|
|
@@ -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 {
|
|
@@ -178,30 +209,78 @@ export function isPidAlive(pid: number): boolean {
|
|
|
178
209
|
}
|
|
179
210
|
}
|
|
180
211
|
|
|
181
|
-
/**
|
|
182
|
-
*
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
212
|
+
/** Injectable kernel probes for the boot identity. Each returns the raw text
|
|
213
|
+
* its source offers, or undefined when that source is unreadable here. */
|
|
214
|
+
export interface BootIdentityProbes {
|
|
215
|
+
/** Contents of `/proc/sys/kernel/random/boot_id`, where procfs is mounted. */
|
|
216
|
+
readProcBootId?: () => string | undefined;
|
|
217
|
+
/** `sysctl -n <name>` on this host. */
|
|
218
|
+
readSysctl?: (name: string) => string | undefined;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The Linux kernel's own name for the boot. Absent where procfs is not mounted
|
|
222
|
+
* in this namespace. */
|
|
223
|
+
function readProcBootIdFile(): string | undefined {
|
|
189
224
|
try {
|
|
190
|
-
|
|
191
|
-
if (proc) return proc;
|
|
225
|
+
return readFileSync("/proc/sys/kernel/random/boot_id", "utf8");
|
|
192
226
|
} catch {
|
|
193
|
-
|
|
227
|
+
return undefined;
|
|
194
228
|
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** One `sysctl -n <name>` read: undefined when the name does not exist on this
|
|
232
|
+
* platform or sysctl refuses. */
|
|
233
|
+
function runSysctl(name: string): string | undefined {
|
|
195
234
|
try {
|
|
196
|
-
|
|
235
|
+
return execFileSync("sysctl", ["-n", name], {
|
|
197
236
|
encoding: "utf8",
|
|
198
237
|
timeout: 2000,
|
|
199
238
|
stdio: ["ignore", "pipe", "ignore"],
|
|
200
|
-
})
|
|
201
|
-
if (sysctl) return sysctl;
|
|
239
|
+
});
|
|
202
240
|
} catch {
|
|
203
|
-
|
|
241
|
+
return undefined;
|
|
204
242
|
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** A probe's answer names a boot only when it carries text: an empty read falls
|
|
246
|
+
* through to the next source. */
|
|
247
|
+
function nonEmpty(value: string | undefined): string | undefined {
|
|
248
|
+
const trimmed = value?.trim();
|
|
249
|
+
return trimmed ? trimmed : undefined;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The kernel's boot identity, in the order the kernel can be trusted to keep it.
|
|
254
|
+
* `/proc/sys/kernel/random/boot_id` is written once per boot and is free where
|
|
255
|
+
* procfs exists; `kern.bootsessionuuid` is darwin's name for the same fact.
|
|
256
|
+
* `kern.boottime` is the wall clock minus uptime, so a clock step moves it and
|
|
257
|
+
* two processes on one boot then compute different identities: it stays only as
|
|
258
|
+
* the last resort, a documented degradation rather than the darwin default. The
|
|
259
|
+
* recorded fallback covers a kernel that offers none of the three; two boots
|
|
260
|
+
* that both fall back and share a hostname look identical, which the heartbeat
|
|
261
|
+
* then has to cover.
|
|
262
|
+
*
|
|
263
|
+
* Probes are injected so a fixture can answer for two readings of one boot and
|
|
264
|
+
* observe which source the identity took. The live path passes none and reads
|
|
265
|
+
* this machine.
|
|
266
|
+
*/
|
|
267
|
+
export function readBootIdentity(probes: BootIdentityProbes = {}): string {
|
|
268
|
+
const readProc = probes.readProcBootId ?? readProcBootIdFile;
|
|
269
|
+
const sysctl = probes.readSysctl ?? runSysctl;
|
|
270
|
+
const probe = (read: () => string | undefined): string | undefined => {
|
|
271
|
+
try {
|
|
272
|
+
return nonEmpty(read());
|
|
273
|
+
} catch {
|
|
274
|
+
// A probe that throws did not read its source.
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
const kernelId = probe(readProc);
|
|
279
|
+
if (kernelId) return kernelId;
|
|
280
|
+
const sessionUuid = probe(() => sysctl("kern.bootsessionuuid"));
|
|
281
|
+
if (sessionUuid) return sessionUuid;
|
|
282
|
+
const bootTime = probe(() => sysctl("kern.boottime"));
|
|
283
|
+
if (bootTime) return bootTime;
|
|
205
284
|
return BOOT_IDENTITY_FALLBACK;
|
|
206
285
|
}
|
|
207
286
|
|
|
@@ -217,9 +296,51 @@ export function resolveInstanceId(): string {
|
|
|
217
296
|
return cachedInstanceId;
|
|
218
297
|
}
|
|
219
298
|
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
+
}
|
|
223
344
|
}
|
|
224
345
|
|
|
225
346
|
// Best-effort release at process exit: the exit hook removes the lock this
|
|
@@ -363,6 +484,9 @@ function readPidStartedAt(pid: number): number | undefined {
|
|
|
363
484
|
* recycled pid, so acquire `stale-legacy-recycled`; anything else refuses
|
|
364
485
|
* `held-legacy` and names the file, because a live pid we cannot date is the
|
|
365
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.
|
|
366
490
|
*/
|
|
367
491
|
export function acquirePollerLock(
|
|
368
492
|
path: string,
|
|
@@ -376,15 +500,24 @@ export function acquirePollerLock(
|
|
|
376
500
|
const instanceId = stamp.instanceId ?? resolveInstanceId();
|
|
377
501
|
const existing = readPollerLock(path);
|
|
378
502
|
|
|
379
|
-
const acquire = (
|
|
380
|
-
|
|
503
|
+
const acquire = (
|
|
504
|
+
reason: PollerLockTakeoverReason,
|
|
505
|
+
): AcquirePollerLockResult => {
|
|
506
|
+
const record: PollerLockInfo = {
|
|
381
507
|
pid: stamp.pid,
|
|
382
508
|
startedAt: stamp.startedAt ?? at,
|
|
383
509
|
instanceId,
|
|
384
510
|
heartbeatAt: at,
|
|
385
511
|
...(stamp.role === undefined ? {} : { role: stamp.role }),
|
|
386
512
|
...(stamp.startedBy === undefined ? {} : { startedBy: stamp.startedBy }),
|
|
387
|
-
}
|
|
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
|
+
}
|
|
388
521
|
heldLockPath = path;
|
|
389
522
|
installExitRelease();
|
|
390
523
|
return {
|
|
@@ -451,26 +584,50 @@ export function acquirePollerLock(
|
|
|
451
584
|
|
|
452
585
|
/**
|
|
453
586
|
* Refresh `heartbeatAt` for a lock this process holds. False when the file is
|
|
454
|
-
* 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.
|
|
455
590
|
*/
|
|
456
591
|
export function refreshPollerHeartbeat(
|
|
457
592
|
path: string,
|
|
458
|
-
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
|
+
} = {},
|
|
459
602
|
): boolean {
|
|
460
603
|
const holder = readPollerLock(path);
|
|
461
604
|
if (!holder) return false;
|
|
462
605
|
const pid = options.pid ?? process.pid;
|
|
463
606
|
const instanceId = options.instanceId ?? resolveInstanceId();
|
|
464
607
|
if (holder.pid !== pid || holder.instanceId !== instanceId) return false;
|
|
465
|
-
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
|
+
}
|
|
466
616
|
return true;
|
|
467
617
|
}
|
|
468
618
|
|
|
469
619
|
/** One running heartbeat: at most one per process, on one lock file. */
|
|
470
620
|
export interface PollerHeartbeat {
|
|
471
621
|
readonly path: string;
|
|
472
|
-
/** 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). */
|
|
473
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;
|
|
474
631
|
/** Stop refreshing. Idempotent. */
|
|
475
632
|
stop(): void;
|
|
476
633
|
}
|
|
@@ -483,14 +640,27 @@ let activeHeartbeat:
|
|
|
483
640
|
* Refresh the lock's heartbeat on an interval. Replaces any heartbeat already
|
|
484
641
|
* running in this process: one lock, one beat. The timer is unref'd: it serves
|
|
485
642
|
* the lock, never the event loop, so it cannot keep a process alive after the
|
|
486
|
-
* 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`.
|
|
487
645
|
*/
|
|
488
646
|
export function startPollerHeartbeat(
|
|
489
647
|
path: string,
|
|
490
648
|
options: { intervalMs?: number; now?: () => number } = {},
|
|
491
649
|
): PollerHeartbeat {
|
|
492
650
|
stopPollerHeartbeat();
|
|
493
|
-
|
|
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
|
+
};
|
|
494
664
|
const timer = setInterval(
|
|
495
665
|
beat,
|
|
496
666
|
options.intervalMs ?? POLLER_HEARTBEAT_INTERVAL_MS,
|
|
@@ -501,6 +671,9 @@ export function startPollerHeartbeat(
|
|
|
501
671
|
return {
|
|
502
672
|
path,
|
|
503
673
|
beat,
|
|
674
|
+
get lastError() {
|
|
675
|
+
return lastError;
|
|
676
|
+
},
|
|
504
677
|
stop: () => {
|
|
505
678
|
if (activeHeartbeat === entry) activeHeartbeat = undefined;
|
|
506
679
|
clearInterval(timer);
|
|
@@ -9,6 +9,14 @@
|
|
|
9
9
|
* `rich: true` (split-to-fit) or an explicit strategy.
|
|
10
10
|
* - `notifyMaxChars` — craft target + guard for turn-end notifications.
|
|
11
11
|
* Default 1200: a full readable update, not a 280-char fragment.
|
|
12
|
+
* - `pollerAutoStart` — which working directories start the inbound poller by
|
|
13
|
+
* themselves. `true` means everywhere, an array means those cwds only, and
|
|
14
|
+
* anything else means nowhere: the env var and the tools stay the way in.
|
|
15
|
+
* Matching is EXACT, never a prefix: a seat at
|
|
16
|
+
* ~/.pi/agent/braid/orchestrator opts in without every worker worktree
|
|
17
|
+
* under /private/tmp inheriting the channel with it. A session started by
|
|
18
|
+
* hand in a listed directory therefore has a channel with no environment
|
|
19
|
+
* variable at all.
|
|
12
20
|
*
|
|
13
21
|
* Degradation: a missing file, unreadable JSON, or an invalid entry (not a
|
|
14
22
|
* finite number ≥ 1) falls back to the default — a bad setting never breaks a
|
|
@@ -29,11 +37,15 @@ export const DEFAULT_SEND_MAX_CHARS = 4096;
|
|
|
29
37
|
/** Fallback notify cap: full readable update, not a tweet-length squeeze. */
|
|
30
38
|
export const DEFAULT_NOTIFY_MAX_CHARS = 1200;
|
|
31
39
|
|
|
40
|
+
/** Fallback auto-start: no directory reaches the poller until settings names it. */
|
|
41
|
+
export const DEFAULT_POLLER_AUTO_START = false;
|
|
42
|
+
|
|
32
43
|
interface RawBraidSettings {
|
|
33
44
|
braid?: {
|
|
34
45
|
telegram?: {
|
|
35
46
|
sendMaxChars?: unknown;
|
|
36
47
|
notifyMaxChars?: unknown;
|
|
48
|
+
pollerAutoStart?: unknown;
|
|
37
49
|
};
|
|
38
50
|
};
|
|
39
51
|
}
|
|
@@ -43,6 +55,9 @@ export interface TelegramCaps {
|
|
|
43
55
|
notifyMaxChars: number;
|
|
44
56
|
}
|
|
45
57
|
|
|
58
|
+
/** Which directories start the poller on their own: everywhere, a list, or nowhere. */
|
|
59
|
+
export type PollerAutoStart = boolean | string[];
|
|
60
|
+
|
|
46
61
|
/** A cap must be a positive finite number; anything else → the default. */
|
|
47
62
|
function sanitizeCap(raw: unknown, fallback: number): number {
|
|
48
63
|
return typeof raw === "number" && Number.isFinite(raw) && raw >= 1
|
|
@@ -77,6 +92,59 @@ export function readTelegramCaps(
|
|
|
77
92
|
};
|
|
78
93
|
}
|
|
79
94
|
|
|
95
|
+
/** Normalize a path for the cwd comparison: no trailing slash, no symlink tail. */
|
|
96
|
+
function normalizeCwd(path: string): string {
|
|
97
|
+
let out = path.trim();
|
|
98
|
+
while (out.length > 1 && out.endsWith("/")) out = out.slice(0, -1);
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Read `braid.telegram.pollerAutoStart`: `true` (everywhere), an array of
|
|
104
|
+
* absolute cwds (those only), or `false` when absent or malformed. A relative
|
|
105
|
+
* entry, an empty string, or any other type is dropped rather than guessed at,
|
|
106
|
+
* because a mistyped cwd would otherwise start the poller somewhere nobody
|
|
107
|
+
* asked for.
|
|
108
|
+
*/
|
|
109
|
+
export function readPollerAutoStart(
|
|
110
|
+
settingsPath: string = SETTINGS_PATH,
|
|
111
|
+
): PollerAutoStart {
|
|
112
|
+
let parsed: RawBraidSettings | undefined;
|
|
113
|
+
try {
|
|
114
|
+
if (existsSync(settingsPath)) {
|
|
115
|
+
parsed = JSON.parse(
|
|
116
|
+
readFileSync(settingsPath, "utf8"),
|
|
117
|
+
) as RawBraidSettings;
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
return DEFAULT_POLLER_AUTO_START;
|
|
121
|
+
}
|
|
122
|
+
const raw = parsed?.braid?.telegram?.pollerAutoStart;
|
|
123
|
+
if (raw === true) return true;
|
|
124
|
+
if (!Array.isArray(raw)) return DEFAULT_POLLER_AUTO_START;
|
|
125
|
+
const cwds = raw
|
|
126
|
+
.filter((entry): entry is string => typeof entry === "string")
|
|
127
|
+
.map((entry) => normalizeCwd(entry))
|
|
128
|
+
.filter((entry) => entry.startsWith("/"));
|
|
129
|
+
return cwds;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Does this working directory start the poller by itself? Exact match only.
|
|
134
|
+
* A subdirectory of a listed cwd does NOT inherit it: the seat opts in for the
|
|
135
|
+
* directory it runs in, and a worker in a worktree beneath it must not.
|
|
136
|
+
*/
|
|
137
|
+
export function pollerAutoStartFor(
|
|
138
|
+
cwd: string,
|
|
139
|
+
settingsPath: string = SETTINGS_PATH,
|
|
140
|
+
): boolean {
|
|
141
|
+
const setting = readPollerAutoStart(settingsPath);
|
|
142
|
+
if (setting === true) return true;
|
|
143
|
+
if (setting === false) return false;
|
|
144
|
+
const here = normalizeCwd(cwd);
|
|
145
|
+
return setting.includes(here);
|
|
146
|
+
}
|
|
147
|
+
|
|
80
148
|
/** Default cap for `telegram_send` plain text (`braid.telegram.sendMaxChars`). */
|
|
81
149
|
export function resolveSendMaxChars(
|
|
82
150
|
settingsPath: string = SETTINGS_PATH,
|