pi-braid-telegram 0.1.6 → 0.1.8

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.
@@ -2,19 +2,55 @@
2
2
  * Machine-wide poller ownership lock.
3
3
  *
4
4
  * Telegram's getUpdates is a per-bot singleton: only one consumer may long-
5
- * poll at a time. A module-local flag cannot provide this guarantee — it is
5
+ * poll at a time. A module-local flag cannot provide this guarantee: it is
6
6
  * per-process state, so two pi processes could each "own" the poller and
7
- * 409-fight over the stream. This lock file is the machine-wide half: every
8
- * start path acquires it first and refuses while a LIVE foreign pid holds
9
- * it; a stale lock (dead pid, corrupt, or unreadable file) is taken over
10
- * silently. A crashed holder therefore never locks the channel out, and a
11
- * same-pid re-acquire (session reload cycles) always succeeds.
7
+ * 409-fight over the stream. This lock file is the machine-wide half.
8
+ *
9
+ * Liveness is a rule over an identity and a heartbeat, never over the pid
10
+ * alone, because pids are namespace-local: a lock left by a dead container can
11
+ * name a pid this namespace now happens to use (the file reads as held
12
+ * forever), and a lock left by a live process in another namespace names a pid
13
+ * that does not exist here (the file reads as dead and gets displaced, putting
14
+ * two pollers on one bot). So the file records which boot wrote it and when
15
+ * that process last proved it was running; the pid is read as evidence only on
16
+ * the boot that wrote it. A recycled pid on one host is the same class, and the
17
+ * same rule covers it.
12
18
  */
13
19
 
14
- import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
20
+ import { execFileSync } from "node:child_process";
21
+ import {
22
+ existsSync,
23
+ mkdirSync,
24
+ readFileSync,
25
+ unlinkSync,
26
+ writeFileSync,
27
+ } from "node:fs";
28
+ import { hostname } from "node:os";
15
29
  import { dirname, join } from "node:path";
16
30
  import { braidDir } from "./state-paths.js";
17
31
 
32
+ /** How often a holder refreshes `heartbeatAt` while it owns the lock. */
33
+ export const POLLER_HEARTBEAT_INTERVAL_MS = 10_000;
34
+
35
+ /**
36
+ * Age past which a holder's heartbeat stops counting as evidence that it is
37
+ * polling. Three refreshes fit inside the window, so one late tick does not
38
+ * cost a healthy holder the channel, and a process that stopped refreshing is
39
+ * displaced within half a minute of going quiet.
40
+ */
41
+ export const POLLER_HEARTBEAT_STALE_MS = 30_000;
42
+
43
+ /**
44
+ * Slack between the moment a pre-identity holder wrote its lock and the start
45
+ * time of the process now answering to the same pid. A pid can be recycled, so a
46
+ * live pid alone cannot prove that the recorded holder is still there; a process
47
+ * that started well after the record was written is someone else.
48
+ */
49
+ const LEGACY_PID_REUSE_SLACK_MS = 60_000;
50
+
51
+ /** Boot identity recorded when neither the kernel id nor sysctl is readable. */
52
+ const BOOT_IDENTITY_FALLBACK = "boot-unknown";
53
+
18
54
  /** Default lock location: beside the other braid-owned telegram state
19
55
  * (~/.pi/agent/braid, BRAID_HOME-overridable). Null when no absolute braid
20
56
  * root resolves: the poller refuses to start rather than lock beside the cwd. */
@@ -23,18 +59,98 @@ export function defaultPollerLockPath(): string | null {
23
59
  return root === null ? null : join(root, "telegram-poller.lock");
24
60
  }
25
61
 
26
- /** Who holds the lock: the pid is the liveness key; startedAt/role/startedBy
27
- * make a refusal message name the holder. */
62
+ /**
63
+ * What a lock file may hold. A pid is the one field every version wrote.
64
+ * `instanceId` and `heartbeatAt` are optional here on purpose: the acquisition
65
+ * rule has to judge a file it cannot fully read (an older braid's lock, a
66
+ * truncated write), and a holder description has to render one.
67
+ */
28
68
  export interface PollerLockInfo {
29
69
  pid: number;
30
70
  startedAt: number;
71
+ instanceId?: string;
72
+ heartbeatAt?: number;
31
73
  role?: string;
32
74
  startedBy?: string;
33
75
  }
34
76
 
77
+ /** What an acquirer stamps; identity and heartbeat are added by `acquirePollerLock`. */
78
+ export interface PollerLockStamp {
79
+ pid: number;
80
+ startedAt?: number;
81
+ instanceId?: string;
82
+ role?: string;
83
+ startedBy?: string;
84
+ }
85
+
86
+ /**
87
+ * Why an acquisition succeeded.
88
+ *
89
+ * - `fresh`: no lock file held the channel;
90
+ * - `unreadable-file`: a file was there and named no holder, so nothing was
91
+ * displaced;
92
+ * - `same-pid-refresh`: this process re-acquired its own lock (session reload);
93
+ * - `stale-same-instance`: the holder shares this instance and is gone: its
94
+ * pid is dead, or its heartbeat is past the staleness window. A live pid with
95
+ * a quiet heartbeat is this case, not a refusal;
96
+ * - `stale-across-namespace`: the holder belongs to another boot (or recorded
97
+ * none) and its heartbeat is past the staleness window.
98
+ */
99
+ export type PollerLockTakeoverReason =
100
+ | "fresh"
101
+ | "unreadable-file"
102
+ | "same-pid-refresh"
103
+ | "stale-same-instance"
104
+ | "stale-across-namespace"
105
+ | "stale-legacy"
106
+ | "stale-legacy-recycled";
107
+
108
+ /**
109
+ * Why an acquisition refused.
110
+ *
111
+ * - `held-same-instance`: a different live pid on this instance, heartbeat
112
+ * within the window;
113
+ * - `held-across-namespace`: a holder from another boot whose heartbeat is
114
+ * within the window: its pid says nothing here, the heartbeat says it polls;
115
+ * - `stale-unknown`: a holder whose heartbeat cannot be read at all. Refused
116
+ * rather than guessed: without the heartbeat there is no evidence either way,
117
+ * and a wrong guess either steals a live channel or wedges a dead one.
118
+ */
119
+ export type PollerLockRefusalReason =
120
+ | "held-same-instance"
121
+ | "held-across-namespace"
122
+ | "held-legacy"
123
+ | "stale-unknown";
124
+
125
+ export type PollerLockReason =
126
+ | PollerLockTakeoverReason
127
+ | PollerLockRefusalReason;
128
+
35
129
  export type AcquirePollerLockResult =
36
- | { ok: true; tookOver: boolean }
37
- | { ok: false; holder: PollerLockInfo };
130
+ | {
131
+ ok: true;
132
+ reason: PollerLockTakeoverReason;
133
+ tookOver: boolean;
134
+ /** The displaced record, when one was readable. */
135
+ previous?: PollerLockInfo;
136
+ }
137
+ | {
138
+ ok: false;
139
+ reason: PollerLockRefusalReason;
140
+ holder: PollerLockInfo;
141
+ /** The file the caller should name in a refusal message. */
142
+ path: string;
143
+ };
144
+
145
+ /** Injectable reads for the acquisition rule (tests supply a fake clock and
146
+ * process table; the live path uses the machine's). */
147
+ export interface AcquirePollerLockOptions {
148
+ pidAlive?: (pid: number) => boolean;
149
+ now?: () => number;
150
+ /** Start time of a live pid, in epoch ms. Injected so the legacy arm can be
151
+ * tested without depending on the machine's process table. */
152
+ pidStartedAt?: (pid: number) => number | undefined;
153
+ }
38
154
 
39
155
  /** Read the lock file; absent/corrupt/unreadable reads as "no holder". */
40
156
  export function readPollerLock(path: string): PollerLockInfo | undefined {
@@ -42,14 +158,16 @@ export function readPollerLock(path: string): PollerLockInfo | undefined {
42
158
  const parsed = JSON.parse(readFileSync(path, "utf8")) as PollerLockInfo;
43
159
  if (Number.isFinite(parsed?.pid)) return parsed;
44
160
  } catch {
45
- // fall through to undefined
161
+ // A file that does not parse names no holder.
46
162
  }
47
163
  return undefined;
48
164
  }
49
165
 
50
166
  /**
51
167
  * Live-pid probe. Signal-0 errors with EPERM when the pid exists but is not
52
- * ours to signal (still alive); ESRCH means the pid is gone.
168
+ * ours to signal (still alive); ESRCH means the pid is gone. Meaningful only to
169
+ * the boot that wrote the pid, which is why the acquisition rule gates it on
170
+ * the instance identity.
53
171
  */
54
172
  export function isPidAlive(pid: number): boolean {
55
173
  try {
@@ -60,68 +178,371 @@ export function isPidAlive(pid: number): boolean {
60
178
  }
61
179
  }
62
180
 
181
+ /**
182
+ * The kernel's boot identity: `/proc/sys/kernel/random/boot_id` where the proc
183
+ * filesystem is mounted, `sysctl -n kern.boottime` on darwin, and a recorded
184
+ * fallback when neither is readable. A fallback is deliberate rather than a
185
+ * guess: two boots that both fall back and share a hostname look identical,
186
+ * which the heartbeat then has to cover.
187
+ */
188
+ function readBootIdentity(): string {
189
+ try {
190
+ const proc = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
191
+ if (proc) return proc;
192
+ } catch {
193
+ // Not Linux, or /proc not mounted in this namespace.
194
+ }
195
+ try {
196
+ const sysctl = execFileSync("sysctl", ["-n", "kern.boottime"], {
197
+ encoding: "utf8",
198
+ timeout: 2000,
199
+ stdio: ["ignore", "pipe", "ignore"],
200
+ }).trim();
201
+ if (sysctl) return sysctl;
202
+ } catch {
203
+ // Absent on this platform, or sysctl refused.
204
+ }
205
+ return BOOT_IDENTITY_FALLBACK;
206
+ }
207
+
208
+ let cachedInstanceId: string | undefined;
209
+
210
+ /**
211
+ * Identity of the boot this process runs on: hostname plus the kernel's boot
212
+ * id, so two boots, and a container's boot against its host's, never share
213
+ * one. Memoized: the answer cannot change inside a process.
214
+ */
215
+ export function resolveInstanceId(): string {
216
+ cachedInstanceId ??= `${hostname()}:${readBootIdentity()}`;
217
+ return cachedInstanceId;
218
+ }
219
+
220
+ function writeLock(path: string, info: PollerLockInfo): void {
221
+ mkdirSync(dirname(path), { recursive: true });
222
+ writeFileSync(path, JSON.stringify(info));
223
+ }
224
+
63
225
  // Best-effort release at process exit: the exit hook removes the lock this
64
226
  // process last acquired. A hook that never runs (kill -9, crash) leaves the
65
- // file behind — harmless, because the next acquirer sees a dead pid and
66
- // takes the lock over.
227
+ // file behind, which is harmless: the next acquirer sees a dead or past-window
228
+ // record and takes the lock over.
67
229
  let exitHookInstalled = false;
68
230
  let heldLockPath: string | undefined;
69
231
 
232
+ /**
233
+ * The exit hook's body, exported so a test can run it without exiting.
234
+ * Ownership-checked on purpose: by the time a process exits, the file may name
235
+ * a successor that took the channel over, and unlinking by path would hand the
236
+ * singleton to a third process while the successor is still polling.
237
+ */
238
+ export function releaseHeldLockAtExit(): void {
239
+ if (!heldLockPath) return;
240
+ releasePollerLock(heldLockPath);
241
+ }
242
+
70
243
  function installExitRelease(): void {
71
244
  if (exitHookInstalled) return;
72
245
  exitHookInstalled = true;
73
- process.on("exit", () => {
74
- if (!heldLockPath) return;
75
- try {
76
- unlinkSync(heldLockPath);
77
- } catch {
78
- // best effort — the live-pid check covers a surviving file
79
- }
80
- });
246
+ process.on("exit", releaseHeldLockAtExit);
247
+ }
248
+
249
+ /** Age of a heartbeat, rendered for a human reading a refusal. */
250
+ function describeHeartbeatAge(holder: PollerLockInfo, now: number): string {
251
+ if (!Number.isFinite(holder.heartbeatAt)) return "no heartbeat recorded";
252
+ const ageMs = now - (holder.heartbeatAt as number);
253
+ const seconds = (Math.abs(ageMs) / 1000).toFixed(1);
254
+ const rendered =
255
+ Number(seconds) < 10
256
+ ? `${seconds}s`
257
+ : `${Math.round(Math.abs(ageMs) / 1000)}s`;
258
+ // A heartbeat from the future is a skewed clock on the recording instance,
259
+ // not a fresh holder; saying which way it is wrong keeps the refusal readable.
260
+ return ageMs < 0
261
+ ? `heartbeat ${rendered} in the future`
262
+ : `heartbeat ${rendered} ago`;
263
+ }
264
+
265
+ /** Human-readable holder description for refusal messages. */
266
+ export function describeLockHolder(
267
+ holder: PollerLockInfo,
268
+ now: number = Date.now(),
269
+ ): string {
270
+ const since = Number.isFinite(holder.startedAt)
271
+ ? new Date(holder.startedAt).toISOString()
272
+ : "unknown time";
273
+ const role = holder.role ?? "unknown role";
274
+ const by = holder.startedBy ? `, started by ${holder.startedBy}` : "";
275
+ const instance =
276
+ typeof holder.instanceId === "string" && holder.instanceId
277
+ ? ` on ${holder.instanceId}`
278
+ : " on an instance that recorded no identity";
279
+ return `pid ${holder.pid}${instance} since ${since} (${role}${by}), ${describeHeartbeatAge(holder, now)}`;
81
280
  }
82
281
 
83
282
  /**
84
- * Take the lock. A live FOREIGN holder refuses (the caller reports it); our
85
- * own pid, a dead pid, or an unreadable file is taken over silently.
283
+ * One sentence per verdict: what the rule saw, and which holder a retry will
284
+ * act on. A refusal an operator cannot classify is a support ticket; the four
285
+ * situations (live holder, holder that stopped heartbeating, dead container,
286
+ * garbled file) have to be tellable apart from the message alone.
287
+ */
288
+ export function describeLockReason(
289
+ reason: PollerLockReason,
290
+ path: string,
291
+ ): string {
292
+ switch (reason) {
293
+ case "held-same-instance":
294
+ return "a live process on this instance holds it and its heartbeat is current, so it is polling, and this acquisition refuses until that heartbeat goes stale";
295
+ case "held-across-namespace":
296
+ return "the holder was recorded by another boot and its heartbeat is current, so it is polling (its pid is not evidence here), and this acquisition refuses until that heartbeat goes stale";
297
+ case "stale-unknown":
298
+ return `the holder records no readable heartbeat, so it cannot be judged live or gone, and this acquisition refuses rather than guess; inspect ${path} and remove it if its holder is finished`;
299
+ case "fresh":
300
+ return "no lock file held the channel";
301
+ case "unreadable-file":
302
+ return "the lock file named no readable holder, so nothing was displaced";
303
+ case "same-pid-refresh":
304
+ return "this process already held the lock";
305
+ case "stale-same-instance":
306
+ return "the holder shares this instance and is no longer polling (dead pid, or a heartbeat past the staleness window)";
307
+ case "stale-across-namespace":
308
+ return "the holder was recorded by another boot and its heartbeat is past the staleness window";
309
+ case "stale-legacy":
310
+ return "the holder was recorded by a build that kept no identity or heartbeat, and its pid is dead, so the channel is free";
311
+ case "stale-legacy-recycled":
312
+ return "the holder was recorded by a build that kept no identity or heartbeat, and the live pid now wearing its number started after that record was written, so it is a recycled pid and not the holder";
313
+ case "held-legacy":
314
+ return `the holder was recorded by a build that kept no identity or heartbeat, so liveness cannot be read from the record, and its pid is still alive; confirm it is not polling, then remove ${path} (a clean disable releases it)`;
315
+ }
316
+ }
317
+
318
+ /** Whether a recorded heartbeat still counts as evidence that its process polls. */
319
+ function heartbeatIsCurrent(holder: PollerLockInfo, now: number): boolean {
320
+ if (!Number.isFinite(holder.heartbeatAt)) return false;
321
+ // A skewed or future-stamped heartbeat counts as current: the alternative is
322
+ // displacing a holder that may be polling, which is the worse failure.
323
+ return now - (holder.heartbeatAt as number) <= POLLER_HEARTBEAT_STALE_MS;
324
+ }
325
+
326
+ /**
327
+ * Start time of a live pid in epoch ms, from `ps`. Undefined when ps cannot say:
328
+ * an undated live pid is a refusal, never a takeover.
329
+ */
330
+ function readPidStartedAt(pid: number): number | undefined {
331
+ try {
332
+ const out = execFileSync("ps", ["-o", "lstart=", "-p", String(pid)], {
333
+ encoding: "utf8",
334
+ stdio: ["ignore", "pipe", "ignore"],
335
+ }).trim();
336
+ if (!out) return undefined;
337
+ const parsed = Date.parse(out);
338
+ return Number.isFinite(parsed) ? parsed : undefined;
339
+ } catch {
340
+ return undefined;
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Take the lock. The rule, in order:
346
+ *
347
+ * 1. no readable holder → acquire;
348
+ * 2. this process's own record → acquire (session reload cycles re-arm);
349
+ * 3. same instance, holder's pid dead → acquire, `stale-same-instance`;
350
+ * 4. same instance, pid alive, heartbeat current → refuse `held-same-instance`;
351
+ * 5. same instance, pid alive, heartbeat past the window → acquire,
352
+ * `stale-same-instance`: a live pid that stopped refreshing is not polling,
353
+ * and trusting it is what turns a stuck poller into a channel that never
354
+ * comes back;
355
+ * 6. same instance, pid alive, no readable heartbeat → refuse `stale-unknown`
356
+ * (no evidence either way, so no guess);
357
+ * 7. another boot or no recorded identity → refuse `held-across-namespace`
358
+ * while the heartbeat is current, acquire `stale-across-namespace` once it
359
+ * is not, and refuse `stale-unknown` when no heartbeat can be read at all.
360
+ * 8. a record written before identity existed (`instanceId` absent, so no
361
+ * heartbeat ever): a dead pid is evidence enough, so acquire `stale-legacy`;
362
+ * a live pid whose process started later than the record was written is a
363
+ * recycled pid, so acquire `stale-legacy-recycled`; anything else refuses
364
+ * `held-legacy` and names the file, because a live pid we cannot date is the
365
+ * one case where guessing either steals a channel or wedges one.
86
366
  */
87
367
  export function acquirePollerLock(
88
368
  path: string,
89
- info: PollerLockInfo,
90
- pidAlive: (pid: number) => boolean = isPidAlive,
369
+ stamp: PollerLockStamp,
370
+ options: AcquirePollerLockOptions = {},
91
371
  ): AcquirePollerLockResult {
372
+ const pidAlive = options.pidAlive ?? isPidAlive;
373
+ const pidStartedAt = options.pidStartedAt ?? readPidStartedAt;
374
+ const now = options.now ?? Date.now;
375
+ const at = now();
376
+ const instanceId = stamp.instanceId ?? resolveInstanceId();
92
377
  const existing = readPollerLock(path);
93
- if (existing && existing.pid !== info.pid && pidAlive(existing.pid)) {
94
- return { ok: false, holder: existing };
378
+
379
+ const acquire = (reason: PollerLockTakeoverReason) => {
380
+ writeLock(path, {
381
+ pid: stamp.pid,
382
+ startedAt: stamp.startedAt ?? at,
383
+ instanceId,
384
+ heartbeatAt: at,
385
+ ...(stamp.role === undefined ? {} : { role: stamp.role }),
386
+ ...(stamp.startedBy === undefined ? {} : { startedBy: stamp.startedBy }),
387
+ });
388
+ heldLockPath = path;
389
+ installExitRelease();
390
+ return {
391
+ ok: true as const,
392
+ reason,
393
+ tookOver: existing !== undefined,
394
+ ...(existing === undefined ? {} : { previous: existing }),
395
+ };
396
+ };
397
+
398
+ if (existing === undefined) {
399
+ // Distinguish "nothing there" from "something there that named no holder":
400
+ // the second is an operator-visible takeover reason, not a clean start.
401
+ return acquire(existsSync(path) ? "unreadable-file" : "fresh");
95
402
  }
96
- mkdirSync(dirname(path), { recursive: true });
97
- writeFileSync(path, JSON.stringify(info));
98
- heldLockPath = path;
99
- installExitRelease();
100
- return { ok: true, tookOver: existing !== undefined };
403
+
404
+ if (existing.instanceId === instanceId) {
405
+ // Own pid first: a reload re-arm must re-take its own lock even though its
406
+ // pid is alive by definition.
407
+ if (existing.pid === stamp.pid) return acquire("same-pid-refresh");
408
+ if (!pidAlive(existing.pid)) return acquire("stale-same-instance");
409
+ if (!Number.isFinite(existing.heartbeatAt)) {
410
+ return { ok: false, reason: "stale-unknown", holder: existing, path };
411
+ }
412
+ if (!heartbeatIsCurrent(existing, at))
413
+ return acquire("stale-same-instance");
414
+ return { ok: false, reason: "held-same-instance", holder: existing, path };
415
+ }
416
+
417
+ // A record from a build that had no instance identity and no heartbeat. Its
418
+ // pid is the only field, and it is the field that used to wedge the channel:
419
+ // the arm below read the absent heartbeat as "cannot tell" and refused
420
+ // forever, even when the holder had died days earlier.
421
+ if (existing.instanceId === undefined) {
422
+ if (existing.pid === stamp.pid) return acquire("same-pid-refresh");
423
+ if (!pidAlive(existing.pid)) return acquire("stale-legacy");
424
+ const startedAt = pidStartedAt(existing.pid);
425
+ const recordedAt = Number.isFinite(existing.startedAt)
426
+ ? (existing.startedAt as number)
427
+ : undefined;
428
+ if (
429
+ startedAt !== undefined &&
430
+ recordedAt !== undefined &&
431
+ startedAt - recordedAt > LEGACY_PID_REUSE_SLACK_MS
432
+ ) {
433
+ return acquire("stale-legacy-recycled");
434
+ }
435
+ return { ok: false, reason: "held-legacy", holder: existing, path };
436
+ }
437
+
438
+ if (!Number.isFinite(existing.heartbeatAt)) {
439
+ return { ok: false, reason: "stale-unknown", holder: existing, path };
440
+ }
441
+ if (heartbeatIsCurrent(existing, at)) {
442
+ return {
443
+ ok: false,
444
+ reason: "held-across-namespace",
445
+ holder: existing,
446
+ path,
447
+ };
448
+ }
449
+ return acquire("stale-across-namespace");
101
450
  }
102
451
 
103
452
  /**
104
- * Release the lock — only when this process holds it; a foreign lock is
105
- * never touched. Best-effort: reports whether the file was removed.
453
+ * Refresh `heartbeatAt` for a lock this process holds. False when the file is
454
+ * gone or names another holder: a beat never rewrites a successor's record.
455
+ */
456
+ export function refreshPollerHeartbeat(
457
+ path: string,
458
+ options: { now?: () => number; pid?: number; instanceId?: string } = {},
459
+ ): boolean {
460
+ const holder = readPollerLock(path);
461
+ if (!holder) return false;
462
+ const pid = options.pid ?? process.pid;
463
+ const instanceId = options.instanceId ?? resolveInstanceId();
464
+ if (holder.pid !== pid || holder.instanceId !== instanceId) return false;
465
+ writeLock(path, { ...holder, heartbeatAt: (options.now ?? Date.now)() });
466
+ return true;
467
+ }
468
+
469
+ /** One running heartbeat: at most one per process, on one lock file. */
470
+ export interface PollerHeartbeat {
471
+ readonly path: string;
472
+ /** Refresh now; false once the lock is no longer ours. */
473
+ beat(): boolean;
474
+ /** Stop refreshing. Idempotent. */
475
+ stop(): void;
476
+ }
477
+
478
+ let activeHeartbeat:
479
+ | { path: string; timer: ReturnType<typeof setInterval> }
480
+ | undefined;
481
+
482
+ /**
483
+ * Refresh the lock's heartbeat on an interval. Replaces any heartbeat already
484
+ * running in this process: one lock, one beat. The timer is unref'd: it serves
485
+ * the lock, never the event loop, so it cannot keep a process alive after the
486
+ * poller it belongs to is gone.
487
+ */
488
+ export function startPollerHeartbeat(
489
+ path: string,
490
+ options: { intervalMs?: number; now?: () => number } = {},
491
+ ): PollerHeartbeat {
492
+ stopPollerHeartbeat();
493
+ const beat = () => refreshPollerHeartbeat(path, { now: options.now });
494
+ const timer = setInterval(
495
+ beat,
496
+ options.intervalMs ?? POLLER_HEARTBEAT_INTERVAL_MS,
497
+ );
498
+ timer.unref?.();
499
+ const entry = { path, timer };
500
+ activeHeartbeat = entry;
501
+ return {
502
+ path,
503
+ beat,
504
+ stop: () => {
505
+ if (activeHeartbeat === entry) activeHeartbeat = undefined;
506
+ clearInterval(timer);
507
+ },
508
+ };
509
+ }
510
+
511
+ /** Stop the running heartbeat, or the one on `path` when given. */
512
+ export function stopPollerHeartbeat(path?: string): boolean {
513
+ const entry = activeHeartbeat;
514
+ if (!entry) return false;
515
+ if (path !== undefined && entry.path !== path) return false;
516
+ activeHeartbeat = undefined;
517
+ clearInterval(entry.timer);
518
+ return true;
519
+ }
520
+
521
+ /**
522
+ * Release the lock, and only when this process holds it; a foreign lock is never
523
+ * touched. The heartbeat stops here so a released lock cannot keep being
524
+ * stamped fresh. Best-effort: reports whether the file was removed.
106
525
  */
107
526
  export function releasePollerLock(path: string): boolean {
108
527
  const existing = readPollerLock(path);
109
- if (!existing || existing.pid !== process.pid) return false;
528
+ const ours =
529
+ existing !== undefined &&
530
+ existing.pid === process.pid &&
531
+ existing.instanceId === resolveInstanceId();
532
+ // An older record without an identity is still ours when the pid is: this
533
+ // process wrote it before the identity field existed only within one run of
534
+ // a single build, so a pid match is the whole evidence available.
535
+ const legacyOurs =
536
+ existing !== undefined &&
537
+ existing.pid === process.pid &&
538
+ existing.instanceId === undefined;
539
+ stopPollerHeartbeat(path);
540
+ if (!existing || !(ours || legacyOurs)) return false;
110
541
  try {
111
542
  unlinkSync(path);
112
543
  } catch {
113
- // already gone — treat as released
544
+ // already gone: treat as released
114
545
  }
115
546
  if (heldLockPath === path) heldLockPath = undefined;
116
547
  return true;
117
548
  }
118
-
119
- /** Human-readable holder description for refusal messages. */
120
- export function describeLockHolder(holder: PollerLockInfo): string {
121
- const since = Number.isFinite(holder.startedAt)
122
- ? new Date(holder.startedAt).toISOString()
123
- : "unknown time";
124
- const role = holder.role ?? "unknown role";
125
- const by = holder.startedBy ? `, started by ${holder.startedBy}` : "";
126
- return `pid ${holder.pid} since ${since} (${role}${by})`;
127
- }
@@ -1,11 +1,12 @@
1
1
  /**
2
2
  * Session role marker: which seat this pi session occupies in the braid
3
- * topology. The orchestrator seat exports BRAID_SESSION_ROLE=worker into a
4
- * worker's pane before pi starts; the telegram extension reads it at
5
- * registration time and keeps the poller-ownership surfaces seat-only —
6
- * Telegram's getUpdates is a per-bot singleton the orchestrator seat owns,
7
- * so a worker session never registers the poller control or the pull-inbox
8
- * tool and never starts the poller (telegram_send stays available).
3
+ * topology. The orchestrator exports BRAID_SESSION_ROLE=worker into the
4
+ * worker's pane shell before the agent starts; the telegram extension reads it
5
+ * at registration time and returns before registering any surface: a worker
6
+ * reaches the owner through the orchestrator, never through the channel it
7
+ * shares a bot token with. Only the exact value "worker" selects the worker
8
+ * role; unset (or any other value) keeps the seat default, so deployments
9
+ * without the marker are unchanged.
9
10
  */
10
11
 
11
12
  export const ENV_SESSION_ROLE = "BRAID_SESSION_ROLE";
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Low-level Telegram Bot API client built on Node 22's global fetch.
2
+ * Low-level Telegram Bot API client built on the global fetch Node ships.
3
3
  *
4
4
  * Zero npm dependencies. Every method takes an injectable fetch (defaults to
5
5
  * `globalThis.fetch`) so tests can substitute a recorded-fixture fake without