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.
- package/README.md +69 -211
- package/build-info.json +5 -5
- package/docs/turn-complete-notify.md +2 -2
- package/package.json +9 -10
- package/src/index.ts +222 -163
- package/src/lib/poller-lock.ts +469 -48
- package/src/lib/session-role.ts +7 -6
- package/src/lib/telegram-client.ts +1 -1
package/src/lib/poller-lock.ts
CHANGED
|
@@ -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
|
|
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
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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 {
|
|
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
|
-
/**
|
|
27
|
-
*
|
|
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
|
-
| {
|
|
37
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
*
|
|
85
|
-
*
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
94
|
-
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
*
|
|
105
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
}
|
package/src/lib/session-role.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Session role marker: which seat this pi session occupies in the braid
|
|
3
|
-
* topology. The orchestrator
|
|
4
|
-
* worker's pane before
|
|
5
|
-
* registration time and
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
|
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
|