pi-braid-telegram 0.1.7 → 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.
package/build-info.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "pi-braid-telegram",
3
- "version": "0.1.7",
4
- "sha": "55abdd57dac34879f6b39b357e31283ad6c3e974",
5
- "shaShort": "55abdd57",
3
+ "version": "0.1.8",
4
+ "sha": "e1d647e5b1e0c9367bd49bcde72e3a316c908250",
5
+ "shaShort": "e1d647e5",
6
6
  "branch": "detached",
7
7
  "dirty": false,
8
- "packedAt": "2026-11-03T21:27:05.199Z"
8
+ "packedAt": "2026-11-04T17:20:23.646Z"
9
9
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-braid-telegram",
3
- "version": "0.1.7",
3
+ "version": "0.1.8",
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": [
@@ -40,6 +40,14 @@ export const POLLER_HEARTBEAT_INTERVAL_MS = 10_000;
40
40
  */
41
41
  export const POLLER_HEARTBEAT_STALE_MS = 30_000;
42
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
+
43
51
  /** Boot identity recorded when neither the kernel id nor sysctl is readable. */
44
52
  const BOOT_IDENTITY_FALLBACK = "boot-unknown";
45
53
 
@@ -93,7 +101,9 @@ export type PollerLockTakeoverReason =
93
101
  | "unreadable-file"
94
102
  | "same-pid-refresh"
95
103
  | "stale-same-instance"
96
- | "stale-across-namespace";
104
+ | "stale-across-namespace"
105
+ | "stale-legacy"
106
+ | "stale-legacy-recycled";
97
107
 
98
108
  /**
99
109
  * Why an acquisition refused.
@@ -109,6 +119,7 @@ export type PollerLockTakeoverReason =
109
119
  export type PollerLockRefusalReason =
110
120
  | "held-same-instance"
111
121
  | "held-across-namespace"
122
+ | "held-legacy"
112
123
  | "stale-unknown";
113
124
 
114
125
  export type PollerLockReason =
@@ -136,6 +147,9 @@ export type AcquirePollerLockResult =
136
147
  export interface AcquirePollerLockOptions {
137
148
  pidAlive?: (pid: number) => boolean;
138
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;
139
153
  }
140
154
 
141
155
  /** Read the lock file; absent/corrupt/unreadable reads as "no holder". */
@@ -292,6 +306,12 @@ export function describeLockReason(
292
306
  return "the holder shares this instance and is no longer polling (dead pid, or a heartbeat past the staleness window)";
293
307
  case "stale-across-namespace":
294
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)`;
295
315
  }
296
316
  }
297
317
 
@@ -303,6 +323,24 @@ function heartbeatIsCurrent(holder: PollerLockInfo, now: number): boolean {
303
323
  return now - (holder.heartbeatAt as number) <= POLLER_HEARTBEAT_STALE_MS;
304
324
  }
305
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
+
306
344
  /**
307
345
  * Take the lock. The rule, in order:
308
346
  *
@@ -319,6 +357,12 @@ function heartbeatIsCurrent(holder: PollerLockInfo, now: number): boolean {
319
357
  * 7. another boot or no recorded identity → refuse `held-across-namespace`
320
358
  * while the heartbeat is current, acquire `stale-across-namespace` once it
321
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.
322
366
  */
323
367
  export function acquirePollerLock(
324
368
  path: string,
@@ -326,6 +370,7 @@ export function acquirePollerLock(
326
370
  options: AcquirePollerLockOptions = {},
327
371
  ): AcquirePollerLockResult {
328
372
  const pidAlive = options.pidAlive ?? isPidAlive;
373
+ const pidStartedAt = options.pidStartedAt ?? readPidStartedAt;
329
374
  const now = options.now ?? Date.now;
330
375
  const at = now();
331
376
  const instanceId = stamp.instanceId ?? resolveInstanceId();
@@ -369,6 +414,27 @@ export function acquirePollerLock(
369
414
  return { ok: false, reason: "held-same-instance", holder: existing, path };
370
415
  }
371
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
+
372
438
  if (!Number.isFinite(existing.heartbeatAt)) {
373
439
  return { ok: false, reason: "stale-unknown", holder: existing, path };
374
440
  }