tickmarkr 2.6.6 → 2.6.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.
Files changed (51) hide show
  1. package/dist/adapters/claude-code.js +8 -2
  2. package/dist/adapters/codex-commit-check.d.ts +3 -2
  3. package/dist/adapters/codex-commit-check.js +37 -10
  4. package/dist/adapters/codex.js +10 -4
  5. package/dist/cli/commands/beat.d.ts +7 -2
  6. package/dist/cli/commands/beat.js +144 -77
  7. package/dist/cli/commands/fleet.js +66 -21
  8. package/dist/cli/commands/init.js +18 -7
  9. package/dist/cli/commands/plan.js +16 -18
  10. package/dist/cli/commands/verify.js +4 -2
  11. package/dist/cli/help.d.ts +1 -0
  12. package/dist/cli/help.js +14 -6
  13. package/dist/cli/index.d.ts +1 -1
  14. package/dist/cli/index.js +1 -1
  15. package/dist/drivers/herdr.js +2 -2
  16. package/dist/drivers/orca.d.ts +12 -4
  17. package/dist/drivers/orca.js +200 -39
  18. package/dist/gates/baseline.d.ts +9 -0
  19. package/dist/gates/baseline.js +14 -3
  20. package/dist/gates/cache.js +1 -1
  21. package/dist/gates/evidence.js +3 -3
  22. package/dist/gates/review.d.ts +1 -1
  23. package/dist/gates/review.js +77 -13
  24. package/dist/gates/run-gates.d.ts +21 -6
  25. package/dist/gates/run-gates.js +439 -224
  26. package/dist/gates/test-manifest.d.ts +42 -0
  27. package/dist/gates/test-manifest.js +203 -118
  28. package/dist/run/beat-lifecycle.d.ts +239 -0
  29. package/dist/run/beat-lifecycle.js +1782 -0
  30. package/dist/run/daemon.d.ts +4 -0
  31. package/dist/run/daemon.js +129 -40
  32. package/dist/run/git-trust.d.ts +102 -0
  33. package/dist/run/git-trust.js +334 -0
  34. package/dist/run/git.d.ts +24 -3
  35. package/dist/run/git.js +169 -30
  36. package/dist/run/journal.js +15 -3
  37. package/dist/run/lease.d.ts +23 -0
  38. package/dist/run/lease.js +232 -38
  39. package/dist/run/merge.js +4 -4
  40. package/dist/run/stall.js +20 -1
  41. package/dist/run/supervision.d.ts +8 -0
  42. package/dist/run/supervision.js +24 -4
  43. package/dist/run/verification-job.d.ts +53 -0
  44. package/dist/run/verification-job.js +159 -0
  45. package/dist/tui/ink/fleet-app.d.ts +16 -2
  46. package/dist/tui/ink/fleet-app.js +265 -141
  47. package/package.json +1 -1
  48. package/skills/tickmarkr-auto/SKILL.md +80 -27
  49. package/skills/tickmarkr-loop/SKILL.md +87 -27
  50. package/skills/tickmarkr-overseer/SKILL.md +125 -47
  51. package/skills/tickmarkr-overseer/scripts/watch-journal.sh +9 -7
@@ -0,0 +1,1782 @@
1
+ import { spawn as nodeSpawn, spawnSync } from "node:child_process";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { closeSync, existsSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, realpathSync, renameSync, rmdirSync, rmSync, statSync, writeFileSync, } from "node:fs";
4
+ import { basename, dirname, join } from "node:path";
5
+ import { stateDirName, tickmarkrDir } from "../graph/graph.js";
6
+ import { SUPERVISION_BEAT_MS, isSeatTier, publishStandDown, readSupervisionArm, supervisionArmPath, supervisionBeatPath, supervisionStandDownPath, supervisionStatus, } from "./supervision.js";
7
+ // H + D-1001: the detached beat is OWNED by the product, not by a shell recipe. One claim file per
8
+ // (repository, tier) serializes start, stop and the legacy arm-mutating writers; one owner record names
9
+ // the exact generation (seat, armId, pid, process birth/argv/cwd) that start launched. Every signal and
10
+ // removal rechecks that generation first and reads the result back afterwards, so an older invocation can
11
+ // never retire a replacement, and a pid that now belongs to another process is a mismatch, never a target.
12
+ // Status takes no claim and writes nothing. Uncertain state is RETAINED and refused (nonzero), never guessed.
13
+ // Claims — the closed actor table. IN: compliant claim contenders (start, stop and every legacy write through this CLI;
14
+ // status takes no claim), a crashed owner (holder ESRCH or a zombie, its claim and records left behind) and a killed
15
+ // compliant recovery or release taker (dead at any step of the one removal path). OUT, the one residual: an external
16
+ // writer editing, replacing or deleting claim, lock or record files outside this CLI — nothing is promised under it
17
+ // beyond the identity MISMATCH refusals (e.g. its edit between a final check and the mutation's own syscall is not
18
+ // seen; POSIX has no compare-and-rename). A claim records its holder pid and birth (its runtime's own record, read with no
19
+ // process probe) and, for a launch claim, the launched writer publishing under it (namePublisher, before that writer's first act). EVERY claimant takes over a held
20
+ // claim only when every pid parsed from the very bytes checked is confirmed dead (ESRCH or a zombie) — the legacy
21
+ // {tier, token, pid, claimedAt} schema included — so a legacy writer killed inside its claimed tick never strands the
22
+ // printed migration step, while a live, reused or unreadable holder or writer is never taken over (BUSY). A launched
23
+ // writer whose launcher is dead releases the launch claim itself once settled (releaseDeadLaunch) and refuses while
24
+ // launching; one whose dead launcher never bound its arm epoch exits after that release, leaving an ABANDONED launch
25
+ // for ordinary recovery. Under its launcher's claim nothing is checked after its last act (each act verified the claim
26
+ // last), so the launcher releasing after its read-back never ends the loop it launched. A claim is removed in exactly
27
+ // ONE way, by recovery and release alike (takeClaim): under a per-claim removal lock that appears atomically WITH its
28
+ // owner (pid, birth) and only once no claimed act is running under those bytes, the checked bytes are moved aside into
29
+ // that lock and kept only when they are exactly those bytes — anything else is put straight back, and a claim linked
30
+ // meanwhile holds nothing until the lock is gone: its claimant withdraws it and refuses BUSY. Bytes a claimant could
31
+ // not remove (another live taker held or moved them aside, even on a mistaken death proof) stay provably ITS STRAND
32
+ // wherever they are — its pid, checked birth and a token no invocation of that process still holds (strandOf) — so its
33
+ // own next start, stop or legacy write withdraws them from the claim path (withdrawStrand) or discards them from a
34
+ // killed taker's lock (recoverLock), its status reports them wherever they are — the claim path or moved aside into a
35
+ // live or dead taker's lock, naming that taker — with the recovery command (strandsNamed), and once it exits they are
36
+ // a dead claim: even a
37
+ // long-lived caller never needs to exit for recovery to proceed. A lock whose owner is confirmed dead (a killed
38
+ // taker) is recovered by the next start, stop or legacy write (recoverLock) with a notice, as is the pid-named entry of
39
+ // a taker killed while assembling or releasing it; a live or unprovable owner's lock is never taken (BUSY). Every
40
+ // claimed mutation — owner record renames, the log open, spawn, signals, removals, and each record a STAGED supervision
41
+ // write changed (supervised) — is ONE claimed act (claimedAct): marked beside the claim, its owner-generation barrier
42
+ // (and, staged, a compare of every supervision record with what it was staged from) re-run inside it, the claim
43
+ // verified LAST, immediately before the mutation. So no claim removal lands inside an act, and a claim, owner or arm
44
+ // replaced before that final check lands nothing further — nothing more is written, signalled or removed and the
45
+ // invocation refuses nonzero at its next check, release included. What a dead invocation leaves (act marker, removal
46
+ // lock, moved-aside bytes, stage directory) is cleared only by a start, stop or legacy write that proves its owner
47
+ // dead; status reports it with that command, and names every live holder — the claim's and each removal lock's taker. Owners likewise: a generation whose recorded pid is confirmed dead
48
+ // (OWN-DEAD), or an unbound owner no launch holds (ABANDONED: its launcher died before binding pid or arm epoch, no
49
+ // recorded process alive), is retired — owner removed, nothing signalled — by the next stop or start (both stand its
50
+ // arm down; start then launches) or legacy write (whose requested write then proceeds on that arm: a one-shot records
51
+ // its beat) (retireDead).
52
+ /** start's whole budget — launch, read-back and any overrun — before it must refuse and roll back. */
53
+ export const BEAT_STARTUP_CEILING_MS = 60_000;
54
+ /** The generation a started child carries; it lets the child act under its launcher's claim only. */
55
+ export const BEAT_GENERATION_ENV = "TICKMARKR_BEAT_GENERATION";
56
+ export function defaultBeatDeps(host = {}) {
57
+ return {
58
+ cli: [process.execPath, ...process.execArgv, process.argv[1] ?? ""],
59
+ spawn: nodeSpawn,
60
+ kill: (pid, signal) => { process.kill(pid, signal); },
61
+ identity: (pid) => readProcessIdentity(pid, host),
62
+ openLog: (path) => openSync(path, "a"),
63
+ now: Date.now,
64
+ sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
65
+ env: process.env,
66
+ };
67
+ }
68
+ /** A named, nonzero refusal: dispatch renders the message and exits 1. `notice` is a reclaim it must not lose. */
69
+ export class BeatRefusal extends Error {
70
+ notice;
71
+ constructor(message, notice) {
72
+ super(message);
73
+ this.notice = notice;
74
+ }
75
+ }
76
+ function refuse(message) { throw new BeatRefusal(message); }
77
+ /** Death the kernel confirms: ESRCH, or a zombie awaiting its reaper. Anything else — a non-positive pid too — is not proof. */
78
+ function confirmedDead(pid) {
79
+ if (!positive(pid))
80
+ return false;
81
+ try {
82
+ process.kill(pid, 0);
83
+ }
84
+ catch (error) {
85
+ return error.code === "ESRCH";
86
+ }
87
+ return spawnSync("ps", ["-o", "stat=", "-p", String(pid)], { encoding: "utf8" }).stdout?.trim().startsWith("Z") === true;
88
+ }
89
+ /**
90
+ * The live process behind a pid: birth (lstart), full argv, cwd and process group. A missing process is
91
+ * DEAD only when the kernel says so (ESRCH or a zombie) — also when it exits between ps and the cwd read;
92
+ * anything unreadable is UNKNOWN, never DEAD. Linux reads cwd from /proc and never needs lsof; every other
93
+ * host reads it through lsof, so a missing or unusable lsof beside a LIVE pid is UNKNOWN that names lsof.
94
+ */
95
+ export function readProcessIdentity(pid, host = {}) {
96
+ const platform = host.platform ?? process.platform;
97
+ const lsofBin = host.lsof ?? "lsof";
98
+ try {
99
+ process.kill(pid, 0);
100
+ }
101
+ catch (error) {
102
+ return error.code === "ESRCH" ? "DEAD" : "UNKNOWN";
103
+ }
104
+ const ps = spawnSync("ps", ["-ww", "-o", "pgid=", "-o", "stat=", "-o", "lstart=", "-o", "command=", "-p", String(pid)], {
105
+ encoding: "utf8", env: { ...process.env, LC_ALL: "C" },
106
+ });
107
+ const row = /^\s*(\d+)\s+(\S+)\s+(\w{3} \w{3} [ \d]\d \d\d:\d\d:\d\d \d{4})\s+(.*\S)\s*$/.exec(ps.stdout ?? "");
108
+ if (ps.status !== 0 || !row)
109
+ return confirmedDead(pid) ? "DEAD" : "UNKNOWN";
110
+ if (row[2].startsWith("Z"))
111
+ return "DEAD";
112
+ let cwd;
113
+ let why;
114
+ if (platform === "linux") {
115
+ try {
116
+ cwd = readlinkSync(`/proc/${pid}/cwd`);
117
+ }
118
+ catch {
119
+ cwd = undefined;
120
+ }
121
+ }
122
+ else {
123
+ const lsof = spawnSync(lsofBin, ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], { encoding: "utf8" });
124
+ cwd = lsof.stdout?.split("\n").find((line) => line.startsWith("n"))?.slice(1) || undefined;
125
+ if (!cwd) {
126
+ why = lsof.error ? `lsof is missing or unusable (${lsofBin}: ${lsof.error.code ?? lsof.error.message})`
127
+ : `lsof (${lsofBin}) exited ${lsof.status ?? lsof.signal} without pid ${pid}'s cwd`;
128
+ }
129
+ }
130
+ if (cwd)
131
+ return { pgid: Number(row[1]), birth: row[3], command: row[4], cwd };
132
+ if (confirmedDead(pid))
133
+ return "DEAD";
134
+ return why ? `UNKNOWN — ${why}; ${platform} reads a process's cwd only through lsof — restore it` : "UNKNOWN";
135
+ }
136
+ const supervisionDirOf = (repoRoot, tier) => dirname(supervisionBeatPath(repoRoot, tier));
137
+ export const beatClaimPath = (repoRoot, tier) => join(supervisionDirOf(repoRoot, tier), `${tier}.claim`);
138
+ export const beatOwnerPath = (repoRoot, tier) => join(supervisionDirOf(repoRoot, tier), `${tier}.owner`);
139
+ export const beatLogPath = (repoRoot, tier) => join(supervisionDirOf(repoRoot, tier), `${tier}.log`);
140
+ const OWNER_CHANGED = "the owner record changed during inspection";
141
+ function readJson(path) {
142
+ let bytes;
143
+ try {
144
+ bytes = readFileSync(path, "utf8");
145
+ }
146
+ catch (error) {
147
+ const code = error.code;
148
+ return code === "ENOENT" || code === "ENOTDIR" ? "NONE" : "UNREADABLE";
149
+ }
150
+ return parseJson(bytes);
151
+ }
152
+ function parseJson(bytes) {
153
+ try {
154
+ const value = JSON.parse(bytes);
155
+ return value && typeof value === "object" ? value : "UNREADABLE";
156
+ }
157
+ catch {
158
+ return "UNREADABLE";
159
+ }
160
+ }
161
+ const text = (v) => typeof v === "string" && v.trim() !== "";
162
+ const optional = (v, ok) => v === undefined || ok(v);
163
+ const positive = (v) => Number.isInteger(v) && v > 0;
164
+ const percent = (v) => typeof v === "number" && v >= 0 && v <= 100;
165
+ /** The beat record's own schema (what writeSupervisionBeat publishes), checked independently of any marker. */
166
+ const validBeat = (rec, tier) => rec.tier === tier && text(rec.beatAt) && !Number.isNaN(Date.parse(rec.beatAt)) &&
167
+ (isSeatTier(tier) ? text(rec.seat) : optional(rec.seat, text)) && optional(rec.armId, text) &&
168
+ optional(rec.armEpoch, (v) => text(v) && !Number.isNaN(Date.parse(v))) && optional(rec.markerFence, (v) => typeof v === "string") &&
169
+ optional(rec.pct, percent) && optional(rec.thresholdPct, percent) &&
170
+ (rec.pid === undefined ? positive(rec.exitedWriterPid) : positive(rec.pid) && rec.exitedWriterPid === undefined);
171
+ export function readBeatOwner(repoRoot, tier) {
172
+ const rec = readJson(beatOwnerPath(repoRoot, tier));
173
+ if (typeof rec !== "object")
174
+ return rec;
175
+ const launched = rec.pid === undefined && rec.birth === undefined && rec.command === undefined && rec.pgid === undefined;
176
+ const running = positive(rec.pid) && text(rec.birth) && text(rec.command) && positive(rec.pgid);
177
+ if (rec.tier !== tier || !text(rec.seat) || !text(rec.generation) || !text(rec.armId) || !text(rec.cwd) ||
178
+ !text(rec.startedAt) || Number.isNaN(Date.parse(rec.startedAt)) || (rec.priorArmId !== undefined && !text(rec.priorArmId)) || (!launched && !running) ||
179
+ [rec.armEpoch, rec.priorArmEpoch].some((epoch) => epoch !== undefined && (!text(epoch) || Number.isNaN(Date.parse(epoch)))))
180
+ return "UNREADABLE";
181
+ return rec;
182
+ }
183
+ /**
184
+ * This process's birth as its own runtime recorded it at start (performance.timeOrigin, sub-millisecond), carried beside
185
+ * its pid by every claim and removal lock it publishes — a later process reusing the pid carries another; the legacy
186
+ * claim schema carries none. Checked from the process itself, never through a process probe: a claim write lists or
187
+ * names no process (the SUP-02 supervision tripwire), and ps lstart is only second-granular.
188
+ */
189
+ const birth = String(performance.timeOrigin);
190
+ function readClaim(repoRoot, tier) {
191
+ return claimOf(readJson(beatClaimPath(repoRoot, tier)));
192
+ }
193
+ /** Every recorded pid is a POSITIVE integer; absent (holder), 0, negative, fractional or non-numeric is unreadable. */
194
+ function claimOf(rec) {
195
+ if (typeof rec !== "object")
196
+ return rec;
197
+ if (!text(rec.token) || !positive(rec.pid) || !optional(rec.publisher, positive) || ![rec.birth, rec.publisherBirth].every((b) => optional(b, text)))
198
+ return "UNREADABLE";
199
+ return { token: rec.token, pid: rec.pid, ...(rec.publisher === undefined ? {} : { publisher: rec.publisher }) };
200
+ }
201
+ /** Outside any claim (a direct call): the barrier, then the mutation. */
202
+ export const unclaimed = (mutate, check) => { check?.(); return mutate(); };
203
+ /** `guard` with `barrier` run ahead of each act's own check, inside the act. */
204
+ const barred = (guard, barrier) => (mutate, check) => guard(mutate, () => { barrier(); check?.(); });
205
+ /** Publish atomically: the temporary file, then the rename as one claimed act whose barrier is `check`. */
206
+ function publish(path, value, guard, check) {
207
+ mkdirSync(dirname(path), { recursive: true });
208
+ const tmp = `${path}.${randomUUID()}.tmp`;
209
+ try {
210
+ writeFileSync(tmp, JSON.stringify(value) + "\n");
211
+ guard(() => renameSync(tmp, path), check);
212
+ }
213
+ finally {
214
+ rmSync(tmp, { force: true });
215
+ }
216
+ }
217
+ /** Names derived from exact claim bytes: `<stem>.reclaim` locks their removal, `<stem>.act.<pid>.<id>` marks an act under them. */
218
+ const claimStem = (path, bytes) => `${path}.${createHash("sha256").update(bytes).digest("hex").slice(0, 16)}`;
219
+ const PAUSE = new Int32Array(new SharedArrayBuffer(4));
220
+ /** The pids inside a claimed act under the bytes `stem` names; a confirmed-dead actor's marker is inert and removed. */
221
+ function liveActs(stem) {
222
+ const prefix = `${basename(stem)}.act.`;
223
+ return readdirSync(dirname(stem)).filter((name) => name.startsWith(prefix)).flatMap((name) => {
224
+ const pid = Number(name.slice(prefix.length).split(".")[0]);
225
+ if (!confirmedDead(pid))
226
+ return [pid];
227
+ rmSync(join(dirname(stem), name), { force: true });
228
+ return [];
229
+ });
230
+ }
231
+ /**
232
+ * ONE claimed act — every publication, spawn, signal and removal made under a claim. The claim must name `token`;
233
+ * a marker naming this pid is created beside exactly those claim bytes, `check` (the act's own barrier) runs, and
234
+ * LAST the claim is re-verified — no removal of those bytes under way, still the canonical claim — immediately
235
+ * before `mutate`. takeClaim never removes bytes a live act marker names, so no tickmarkr recovery or release lands
236
+ * inside an act; a claim lost before the final check refuses with nothing further done.
237
+ * ponytail: an OUTSIDE edit of the claim landing between that final read and `mutate`'s own syscall is not seen (POSIX
238
+ * has no compare-and-rename); the next act, or the release, refuses on it.
239
+ */
240
+ function claimedAct(repoRoot, tier, token, who, check, mutate) {
241
+ const path = beatClaimPath(repoRoot, tier);
242
+ const lost = () => refuse(`${tier} claim is no longer held by this ${who} — nothing further written, signalled or removed`);
243
+ const bytes = readBytes(path);
244
+ const claim = bytes === undefined ? "NONE" : claimOf(parseJson(bytes));
245
+ if (typeof claim !== "object" || claim.token !== token)
246
+ lost();
247
+ const stem = claimStem(path, bytes);
248
+ const marker = `${stem}.act.${process.pid}.${randomUUID()}`;
249
+ writeFileSync(marker, "", { flag: "wx" });
250
+ try {
251
+ check?.();
252
+ // A dead taker's lock is inert here: the claim is re-read below, and a dead taker moves nothing again.
253
+ const taker = lockOwner(`${stem}.reclaim`);
254
+ if (taker && !confirmedDead(taker.pid))
255
+ refuse(`${tier} BUSY — a removal of this ${who}'s claim is under way at ${stem}.reclaim by pid ${taker.pid}; nothing further written, signalled or removed`);
256
+ if (readBytes(path) !== bytes)
257
+ lost();
258
+ return mutate();
259
+ }
260
+ finally {
261
+ rmSync(marker, { force: true });
262
+ }
263
+ }
264
+ /**
265
+ * This tier's HELD claim-removal locks (recovery or release), each one whose taker is confirmed dead recovered first
266
+ * (recoverLock; `told` gets the notice): while one is held, a newly linked claim is not yet settled.
267
+ */
268
+ function heldLocks(repoRoot, tier, told) {
269
+ const path = beatClaimPath(repoRoot, tier);
270
+ const prefix = `${basename(path)}.`;
271
+ return readdirSync(dirname(path)).filter((name) => name.startsWith(prefix) && name.endsWith(".reclaim")).flatMap((name) => {
272
+ const lock = join(dirname(path), name);
273
+ const notice = recoverLock(repoRoot, tier, lock);
274
+ if (notice)
275
+ told(notice);
276
+ const taker = lockOwner(lock);
277
+ return taker ? [`${lock} by pid ${taker.pid}`] : [];
278
+ });
279
+ }
280
+ /** The owner a removal lock holds — its `owner.<pid>.<nonce>` entry — or undefined: an absent or emptied lock holds nothing. */
281
+ function lockOwner(lock) {
282
+ let names;
283
+ try {
284
+ names = readdirSync(lock);
285
+ }
286
+ catch {
287
+ return undefined;
288
+ }
289
+ const owner = names.find((name) => name.startsWith("owner."))?.split(".");
290
+ return owner && { pid: Number(owner[1]), nonce: owner[2] };
291
+ }
292
+ /**
293
+ * Take a removal lock: a directory that appears by ONE rename already holding `owner.<pid>.<nonce>` (this pid's birth
294
+ * inside), so no instant shows it ownerless; it is assembled as `<lock>.<pid>.<nonce>.tmp`, so a taker killed while
295
+ * assembling it leaves a directory named for its pid. A held lock is recovered first when its taker is confirmed dead
296
+ * (`told` gets the notice); a live or unprovable taker refuses BUSY naming it. An emptied lock — released, its owner
297
+ * entry moved out first — holds nothing and the rename simply replaces it. Returns the nonce naming this taker's entries.
298
+ */
299
+ function lockRemoval(repoRoot, tier, lock, what, told) {
300
+ const nonce = randomUUID();
301
+ const tmp = `${lock}.${process.pid}.${nonce}.tmp`;
302
+ try {
303
+ try {
304
+ mkdirSync(tmp);
305
+ writeFileSync(join(tmp, `owner.${process.pid}.${nonce}`), JSON.stringify({ pid: process.pid, birth, takenAt: new Date().toISOString() }) + "\n");
306
+ }
307
+ catch (error) {
308
+ refuse(`${tier} ${what} failed at ${lock}: ${error.message}`);
309
+ }
310
+ for (let retried = false;; retried = true) {
311
+ try {
312
+ renameSync(tmp, lock);
313
+ return nonce;
314
+ }
315
+ catch (error) {
316
+ const code = error.code;
317
+ if (code !== "ENOTEMPTY" && code !== "EEXIST")
318
+ refuse(`${tier} ${what} failed at ${lock}: ${error.message}`);
319
+ }
320
+ const notice = retried ? undefined : recoverLock(repoRoot, tier, lock);
321
+ if (notice)
322
+ told?.(notice);
323
+ const taker = lockOwner(lock);
324
+ if (!retried && (notice || !taker))
325
+ continue;
326
+ refuse(`${tier} BUSY — ${what} is already under way at ${lock}${taker ? ` by pid ${taker.pid}` : ""}; retry — a removal lock is never taken ` +
327
+ "from a live or unprovable taker, and the next start, stop or legacy write recovers it once its taker is confirmed dead");
328
+ }
329
+ }
330
+ finally {
331
+ rmSync(tmp, { recursive: true, force: true });
332
+ }
333
+ }
334
+ /**
335
+ * Release a removal lock: its owner entry is moved OUT beside the lock as `<lock>.<pid>.<nonce>.released` (the lock now
336
+ * holds nothing), then the emptied directory goes (a new taker's lock already in its place stays), then that entry. A
337
+ * taker killed between any two steps leaves the entry named for its pid: status reports it, and the next start, stop
338
+ * or legacy write removes it with the emptied lock (acquireBeatClaim). Only this owner's own entry is ever moved.
339
+ */
340
+ function unlock(lock, pid, nonce) {
341
+ const released = `${lock}.${pid}.${nonce}.released`;
342
+ try {
343
+ renameSync(join(lock, `owner.${pid}.${nonce}`), released);
344
+ }
345
+ catch {
346
+ return; /* a racing recovery released it first */
347
+ }
348
+ try {
349
+ rmdirSync(lock);
350
+ }
351
+ catch { /* not empty: another taker's lock replaced the emptied one */ }
352
+ rmSync(released, { force: true });
353
+ }
354
+ /**
355
+ * Recover a removal lock whose taker is confirmed dead — killed at any step: bytes it moved aside are discarded when
356
+ * every pid they record is confirmed dead or they are this process's own strand (strandOf: nobody acts under them, and
357
+ * restoring them would only block its own retry), else renamed back over the claim path (as that taker's own put-back
358
+ * would have, displacing only an unsettled claimant); then its owner entry and the emptied lock go. Every step names entries
359
+ * unique to that dead taker, so a racing recovery or a new taker's lock is never touched. Undefined, with nothing
360
+ * done, while the taker is live, unprovable or absent.
361
+ */
362
+ function recoverLock(repoRoot, tier, lock) {
363
+ const owner = lockOwner(lock);
364
+ if (!owner || !confirmedDead(owner.pid))
365
+ return undefined;
366
+ const path = beatClaimPath(repoRoot, tier);
367
+ const aside = join(lock, `claim.${owner.nonce}`);
368
+ const moved = readBytes(aside);
369
+ let fate = "";
370
+ if (moved !== undefined) {
371
+ const held = claimOf(parseJson(moved));
372
+ const dead = typeof held === "object" && confirmedDead(held.pid) && (held.publisher === undefined || confirmedDead(held.publisher));
373
+ const strand = strandOf(path, moved);
374
+ try {
375
+ if (dead || strand)
376
+ rmSync(aside);
377
+ else
378
+ renameSync(aside, path);
379
+ }
380
+ catch { /* a racing recovery resolved it first */ }
381
+ fate = strand ? `, discarding this process's own stranded claim (token ${strand}) it had moved aside`
382
+ : dead ? `, discarding the claim of dead pid ${held.pid} it had moved aside` : `, restoring the claim it had moved aside to ${path}`;
383
+ }
384
+ unlock(lock, owner.pid, owner.nonce);
385
+ return `${tier} recovered the removal lock of killed taker pid ${owner.pid} at ${lock}${fate}`;
386
+ }
387
+ /**
388
+ * What confirmed-dead invocations left beside the tier's claim — removal locks (by their owner entry), a lock still
389
+ * being assembled or a released lock's owner entry (by the pid in its name), act markers, stage directories — and
390
+ * each owner pid.
391
+ */
392
+ function deadLeftovers(repoRoot, tier) {
393
+ const dir = supervisionDirOf(repoRoot, tier);
394
+ let names;
395
+ try {
396
+ names = readdirSync(dir);
397
+ }
398
+ catch {
399
+ return [];
400
+ }
401
+ const owned = new RegExp(`^${tier}\\.(?:claim\\.[0-9a-f]{16}\\.(?:act|reclaim)|stage)\\.(\\d+)\\.`);
402
+ return names.flatMap((name) => {
403
+ const path = join(dir, name);
404
+ const pid = name.startsWith(`${tier}.claim.`) && name.endsWith(".reclaim") ? lockOwner(path)?.pid : Number(owned.exec(name)?.[1]);
405
+ return pid !== undefined && confirmedDead(pid) ? [{ path, pid }] : [];
406
+ });
407
+ }
408
+ /**
409
+ * Atomic first-writer-wins claim. A held claim is BUSY unless `reclaim` (every claimant passes its boundaries) confirms
410
+ * DEAD every pid parsed from the claim's own bytes — its holder and any launched writer publishing under it
411
+ * (reclaimDeadClaim): then exactly those bytes are removed (takeClaim) and the claim is taken; the returned notice says
412
+ * so (on a refusal, `BeatRefusal.notice` does). A live, reused or unreadable holder or publisher is never taken over.
413
+ * A linked claim is SETTLED (returned) only when no claim removal is under way and it is still the canonical claim: a
414
+ * removal's put-back may replace a claim linked into its gap, so until that removal ends the claim holds nothing — the
415
+ * claimant withdraws exactly its own linked bytes (takeClaim) and refuses BUSY, so no caller, a long-lived bridge
416
+ * included, is left holding a live claim nobody acts under; its next attempt links afresh. When another live taker
417
+ * holds or has moved aside those very bytes (a mistaken death proof included), the withdrawal cannot land then; once
418
+ * this attempt refuses, its token is no longer in use, so the bytes are this process's STRAND wherever they are
419
+ * (strandOf), proven from the bytes alone: every later attempt in this process withdraws them from the claim path
420
+ * first (withdrawStrand) and discards them from a killed taker's lock instead of restoring them (recoverLock) — so
421
+ * recovery never waits for the caller to exit.
422
+ */
423
+ export function acquireBeatClaim(repoRoot, tier, token, reclaim) {
424
+ const path = beatClaimPath(repoRoot, tier);
425
+ const tmp = `${path}.${token}.tmp`;
426
+ const key = `${path}\n${token}`;
427
+ inUse.add(key); // until this attempt refuses or its release ends: bytes under this token are not yet a strand
428
+ let own = "";
429
+ try {
430
+ mkdirSync(dirname(path), { recursive: true });
431
+ own = JSON.stringify({ tier, token, pid: process.pid, birth, claimedAt: new Date().toISOString() }) + "\n";
432
+ writeFileSync(tmp, own);
433
+ }
434
+ catch (error) {
435
+ inUse.delete(key);
436
+ try {
437
+ rmSync(tmp, { force: true });
438
+ }
439
+ catch { /* the path that failed holds nothing to remove */ }
440
+ refuse(`${tier} claim failed at ${path}: ${error.message}`);
441
+ }
442
+ const link = () => {
443
+ try {
444
+ linkSync(tmp, path);
445
+ return true;
446
+ }
447
+ catch (error) {
448
+ if (error.code !== "EEXIST")
449
+ refuse(`${tier} claim failed at ${path}: ${error.message}`);
450
+ return false;
451
+ }
452
+ };
453
+ const ours = () => { const claim = readClaim(repoRoot, tier); return typeof claim === "object" && claim.token === token; };
454
+ const notices = [];
455
+ const told = (notice) => { notices.push(notice); };
456
+ try {
457
+ withdrawStrand(repoRoot, tier, told);
458
+ const linked = link() || ours();
459
+ const reclaimed = !linked && reclaim !== undefined && reclaimDeadClaim(repoRoot, tier, reclaim, told);
460
+ const busy = (why) => refuse(`${tier} BUSY — ${why}`);
461
+ if (linked || (reclaimed && link())) {
462
+ const pending = heldLocks(repoRoot, tier, told);
463
+ if (pending.length > 0) {
464
+ // Unsettled: withdraw the claim this attempt linked (exactly its own bytes, takeClaim), so a caller that lives on
465
+ // — a bridge, not only an exiting CLI — never leaves a live claim nobody acts under; a retry links afresh.
466
+ // The bytes under this token wherever they are — re-read, never `own`: the linked bytes may be an earlier
467
+ // attempt's under the same token. Any still out once this refusal ends the token's use are this process's strand.
468
+ let left = "";
469
+ const current = readBytes(path);
470
+ try {
471
+ if (current !== undefined && ours())
472
+ takeClaim(repoRoot, tier, current, "withdrawal of an unsettled claim", false, told);
473
+ }
474
+ catch (error) {
475
+ if (!(error instanceof BeatRefusal))
476
+ throw error;
477
+ left = ` (${error.message})`;
478
+ }
479
+ const out = tokenBytesAt(repoRoot, tier, token);
480
+ if (out.length > 0) {
481
+ left = `; its own unsettled claim is still out at ${out.join(", ")}${left} — it is this process's strand: once no live taker holds it, ` +
482
+ "this process's next start, stop or legacy write withdraws it wherever a taker left it, and once this process exits any of them reclaims it";
483
+ }
484
+ busy(`a claim removal (dead-claim recovery or release) is under way at ${pending[0]}; a claim linked before it ends holds nothing yet — retry ` +
485
+ `(the next start, stop or legacy write recovers that lock once its taker is confirmed dead)${left}`);
486
+ }
487
+ if (ours()) {
488
+ // Settled: clear what confirmed-dead invocations left — act markers, stage directories, a half-assembled lock, a
489
+ // released lock's owner entry with its emptied lock (an empty lock is nobody's) — their held locks: recoverLock.
490
+ const swept = deadLeftovers(repoRoot, tier).filter(({ path: left }) => !left.endsWith(".reclaim"));
491
+ for (const { path: left } of swept) {
492
+ if (left.endsWith(".released"))
493
+ try {
494
+ rmdirSync(left.replace(/\.\d+\.[\w-]+\.released$/, ""));
495
+ }
496
+ catch { /* absent, or a taker holds it */ }
497
+ rmSync(left, { recursive: true, force: true });
498
+ }
499
+ for (const { path: left, pid } of swept)
500
+ told(`${tier} removed ${basename(left)}, left by dead pid ${pid}`);
501
+ return notices.length > 0 ? notices.join("\n") : undefined;
502
+ }
503
+ }
504
+ const holderBytes = readBytes(path);
505
+ const strand = strandOf(path, holderBytes);
506
+ const cover = strand ? lockOwner(`${claimStem(path, holderBytes)}.reclaim`) : undefined;
507
+ if (strand)
508
+ busy(`this process's own stranded claim (token ${strand}) occupies ${path}${cover ? ` under the removal lock of pid ${cover.pid}` : ""}; ` +
509
+ "retry — this process's next start, stop or legacy write withdraws it once no live taker holds it");
510
+ const holder = holderBytes === undefined ? "NONE" : claimOf(parseJson(holderBytes));
511
+ const publisher = typeof holder === "object" && holder.publisher !== undefined ? ` with its launched writer pid ${holder.publisher}` : "";
512
+ busy(`${typeof holder === "object" ? `pid ${holder.pid} holds` : "an unreadable claim occupies"} ${path}${publisher}; ` +
513
+ "retry after it finishes (a live, reused or unreadable holder is never taken over; only a claim whose recorded pids are all confirmed dead is taken over)");
514
+ }
515
+ catch (error) {
516
+ inUse.delete(key);
517
+ // A reclaim or recovery this attempt performed rides on its refusal, so the caller can still report it.
518
+ throw notices.length > 0 && error instanceof BeatRefusal ? new BeatRefusal(error.message, notices.join("\n")) : error;
519
+ }
520
+ finally {
521
+ rmSync(tmp, { force: true });
522
+ }
523
+ }
524
+ const readBytes = (file) => { try {
525
+ return readFileSync(file, "utf8");
526
+ }
527
+ catch {
528
+ return undefined;
529
+ } };
530
+ /** `${claim path}\n${token}` of every claim an invocation in this process is acquiring or still holds. */
531
+ const inUse = new Set();
532
+ /**
533
+ * The token of claim `bytes` that are this process's STRAND, else undefined: they record this process's own pid and
534
+ * checked birth (so this process wrote them) under a token no invocation of it is acquiring or holding (so nobody acts
535
+ * under them). Proven from the bytes alone, wherever a compliant taker left them — linked, moved aside, put back or
536
+ * restored — never from having seen a withdrawal run. Only this process can prove it; to any other process the
537
+ * holder is live until this process exits, when they are a dead claim.
538
+ */
539
+ function strandOf(path, bytes) {
540
+ if (bytes === undefined)
541
+ return undefined;
542
+ const rec = parseJson(bytes);
543
+ return typeof rec === "object" && rec.pid === process.pid && rec.birth === birth && text(rec.token) && !inUse.has(`${path}\n${rec.token}`)
544
+ ? rec.token : undefined;
545
+ }
546
+ /**
547
+ * Withdraw this process's strand from the claim path — exactly those bytes (takeClaim, which first recovers a lock
548
+ * whose taker is confirmed dead). A live taker holding them leaves them in place; the claimant's BUSY then names them.
549
+ */
550
+ function withdrawStrand(repoRoot, tier, told) {
551
+ const path = beatClaimPath(repoRoot, tier);
552
+ const bytes = readBytes(path);
553
+ const strand = strandOf(path, bytes);
554
+ if (!strand)
555
+ return;
556
+ try {
557
+ if (takeClaim(repoRoot, tier, bytes, "withdrawal of this process's stranded claim", false, told))
558
+ told(`${tier} withdrew this process's own stranded claim (token ${strand}) at ${path}`);
559
+ }
560
+ catch (error) {
561
+ if (!(error instanceof BeatRefusal))
562
+ throw error;
563
+ }
564
+ }
565
+ /**
566
+ * Every copy of claim bytes, read-only: the claim path, and each one moved aside inside a removal lock — each with the
567
+ * lock that holds it or, for the claim path, the lock its exact bytes would be removed under (absent: no taker).
568
+ */
569
+ function claimCopies(repoRoot, tier) {
570
+ const path = beatClaimPath(repoRoot, tier);
571
+ const list = (dir) => { try {
572
+ return readdirSync(dir).map((name) => join(dir, name));
573
+ }
574
+ catch {
575
+ return [];
576
+ } };
577
+ const locks = list(dirname(path)).filter((lock) => basename(lock).startsWith(`${basename(path)}.`) && lock.endsWith(".reclaim"));
578
+ const at = (file, lock) => {
579
+ const bytes = readBytes(file);
580
+ return bytes === undefined ? [] : [{ file, bytes, lock: lock ?? `${claimStem(path, bytes)}.reclaim` }];
581
+ };
582
+ return [...at(path), ...locks.flatMap((lock) => list(lock).filter((file) => basename(file).startsWith("claim.")).flatMap((file) => at(file, lock)))];
583
+ }
584
+ /** Where bytes under `token` still are: the claim path, or moved aside inside a removal lock. */
585
+ function tokenBytesAt(repoRoot, tier, token) {
586
+ return claimCopies(repoRoot, tier).filter(({ bytes }) => { const claim = claimOf(parseJson(bytes)); return typeof claim === "object" && claim.token === token; })
587
+ .map(({ file }) => file);
588
+ }
589
+ /**
590
+ * This process's strands (strandOf) wherever a compliant taker left them, read-only: each named with where it is and
591
+ * the taker whose removal lock holds or covers it; `live` collects the pids of those takers not confirmed dead.
592
+ */
593
+ function strandsNamed(repoRoot, tier, live) {
594
+ const path = beatClaimPath(repoRoot, tier);
595
+ return claimCopies(repoRoot, tier).flatMap(({ file, bytes, lock }) => {
596
+ const token = strandOf(path, bytes);
597
+ if (!token)
598
+ return [];
599
+ const taker = lockOwner(lock);
600
+ const dead = taker !== undefined && confirmedDead(taker.pid);
601
+ if (taker && !dead)
602
+ live.push(taker.pid);
603
+ return [`this process's own stranded claim (token ${token}, held by no invocation of pid ${process.pid})${file === path ? "" : ` moved aside into ${basename(lock)}`}` +
604
+ (taker ? ` under the removal lock of ${dead ? "dead" : "live"} taker pid ${taker.pid}` : "")];
605
+ });
606
+ }
607
+ /**
608
+ * The ONE way a claim is ever removed — dead-claim recovery and a holder's own release alike: ONLY when the canonical
609
+ * claim is still exactly `checked`, and never inside a claimed act under those bytes. One taker per checked bytes: the
610
+ * lock named by their digest appears atomically WITH its owner (lockRemoval); a live act marker under them refuses BUSY
611
+ * (a release first waits up to 2 s for its launched writer's act to finish); then the claim is re-read under the lock
612
+ * and moved aside into it atomically, and bytes that are not `checked` once moved are renamed straight back. That
613
+ * put-back displaces no holder: a claimant that linked into the gap finds this lock and is not settled
614
+ * (acquireBeatClaim), so it never acted under that claim, and its next attempt finds the restored claim BUSY. An act
615
+ * starting meanwhile finds the lock and refuses (claimedAct). True when exactly `checked` was removed; false when the
616
+ * claim was not those bytes (left or put back). A taker killed at ANY step leaves a lock naming its dead pid — and the
617
+ * moved-aside bytes once it got that far — which the next start, stop or legacy write recovers (recoverLock); one
618
+ * killed while assembling or releasing the lock leaves an entry named for its pid, which that writer removes
619
+ * (acquireBeatClaim). A claimant that linked into the gap withdraws its own unsettled claim before it refuses.
620
+ */
621
+ function takeClaim(repoRoot, tier, checked, what, waitActs = false, told) {
622
+ const path = beatClaimPath(repoRoot, tier);
623
+ const stem = claimStem(path, checked);
624
+ const lock = `${stem}.reclaim`;
625
+ const nonce = lockRemoval(repoRoot, tier, lock, what, told);
626
+ const aside = join(lock, `claim.${nonce}`);
627
+ let kept = false;
628
+ try {
629
+ for (let spins = 0;; spins++) {
630
+ const acting = liveActs(stem);
631
+ if (acting.length === 0)
632
+ break;
633
+ if (!waitActs || spins >= 200)
634
+ refuse(`${tier} BUSY — pid ${acting[0]} is inside a claimed act under this claim; ${what} left it in place — retry`);
635
+ Atomics.wait(PAUSE, 0, 0, 10);
636
+ }
637
+ if (readBytes(path) !== checked)
638
+ return false;
639
+ try {
640
+ renameSync(path, aside);
641
+ }
642
+ catch (error) {
643
+ if (error.code === "ENOENT")
644
+ return false;
645
+ refuse(`${tier} ${what} failed at ${path}: ${error.message}`);
646
+ }
647
+ if (readBytes(aside) === checked) {
648
+ rmSync(aside);
649
+ return true;
650
+ }
651
+ try {
652
+ renameSync(aside, path);
653
+ }
654
+ catch {
655
+ kept = true;
656
+ refuse(`${tier} BUSY — the claim was replaced during ${what} and could not be put back; it is kept at ${aside}, ` +
657
+ `and the next start, stop or legacy write restores it once pid ${process.pid} has exited`);
658
+ }
659
+ return false;
660
+ }
661
+ finally {
662
+ if (!kept)
663
+ unlock(lock, process.pid, nonce);
664
+ }
665
+ }
666
+ /**
667
+ * Take over the claim ONLY when every pid parsed from the checked bytes — the holder, and the launched writer recorded
668
+ * as publishing under a launch claim — is confirmed dead and the claim is still exactly those bytes (takeClaim). The
669
+ * pids are parsed from these checked bytes, never from a second read, so the death proven belongs to exactly the
670
+ * snapshot removed. A settled writer recorded as a launch claim's publisher releases it itself (releaseDeadLaunch).
671
+ */
672
+ function reclaimDeadClaim(repoRoot, tier, deps, told) {
673
+ const path = beatClaimPath(repoRoot, tier);
674
+ const checked = readBytes(path);
675
+ if (checked === undefined)
676
+ return false;
677
+ const holder = claimOf(parseJson(checked));
678
+ if (typeof holder !== "object")
679
+ return false;
680
+ const dead = (pid) => { try {
681
+ return deps.identity(pid) === "DEAD";
682
+ }
683
+ catch {
684
+ return false;
685
+ } }; // a failed inspection proves nothing
686
+ const publisher = holder.publisher === undefined ? "" : ` and its launched writer pid ${holder.publisher}`;
687
+ if (!dead(holder.pid) || (holder.publisher !== undefined && !dead(holder.publisher)))
688
+ return false;
689
+ if (!takeClaim(repoRoot, tier, checked, `recovery of dead pid ${holder.pid}'s claim`, false, told))
690
+ return false;
691
+ told(`${tier} reclaimed the claim of dead pid ${holder.pid}${publisher} (token ${holder.token}) at ${path} — its holder exited without releasing it`);
692
+ return true;
693
+ }
694
+ /** The ONE finite contention policy shared by every waiting claimant: loop ticks, legacy one-shots and stop. */
695
+ export const BEAT_CLAIM_WAIT = { tries: 20, intervalMs: 250 };
696
+ /** The wait policy a claimant runs under: absent fields default to BEAT_CLAIM_WAIT; anything else refuses first. */
697
+ function claimWait(tier, waits) {
698
+ const { tries = BEAT_CLAIM_WAIT.tries, intervalMs = BEAT_CLAIM_WAIT.intervalMs } = waits;
699
+ const whole = (v) => Number.isSafeInteger(v) && v > 0;
700
+ if (!whole(tries) || !whole(intervalMs))
701
+ refuse(`${tier} claim wait refused — tries ${tries} and interval ${intervalMs} ms must be positive integers; nothing written`);
702
+ return { tries, intervalMs };
703
+ }
704
+ /**
705
+ * Acquire under the wait policy; a holder still there at the last attempt refuses with the named BUSY. Every
706
+ * reclaim notice an attempt produced — also one whose own link then lost — is kept and leads the final
707
+ * success or refusal, so a recovery is never reported as a plain no-op.
708
+ */
709
+ async function acquireWaiting(repoRoot, tier, token, waits, reclaim) {
710
+ const { tries, intervalMs } = claimWait(tier, waits);
711
+ const notices = [];
712
+ const told = (message) => [...notices, message].join("\n");
713
+ for (let attempt = 1;; attempt++) {
714
+ try {
715
+ const notice = acquireBeatClaim(repoRoot, tier, token, reclaim);
716
+ if (notice)
717
+ notices.push(notice);
718
+ return notices.length > 0 ? notices.join("\n") : undefined;
719
+ }
720
+ catch (error) {
721
+ if (!(error instanceof BeatRefusal))
722
+ throw error;
723
+ if (error.notice)
724
+ notices.push(error.notice);
725
+ if (!error.message.includes(" BUSY — "))
726
+ refuse(told(error.message));
727
+ if (attempt >= tries)
728
+ refuse(told(tries > 1 ? `${error.message}; still held after ${tries} attempts ${intervalMs} ms apart` : error.message));
729
+ await waits.sleep(intervalMs);
730
+ }
731
+ }
732
+ }
733
+ /** The claim this invocation acquired still names it — the check before reporting success under a launcher's claim. */
734
+ function stillHeld(repoRoot, tier, token, who = "invocation") {
735
+ const claim = readClaim(repoRoot, tier);
736
+ if (typeof claim !== "object" || claim.token !== token)
737
+ refuse(`${tier} claim is no longer held by this ${who} — nothing further written, signalled or removed`);
738
+ }
739
+ /**
740
+ * Release removes exactly the bytes that name this token (takeClaim), after any claimed act under them — a launched
741
+ * writer's included — has finished: a replacement found at any instant is left in place. However it ends, the token is
742
+ * then no longer in use: bytes under it that a live taker kept or moved aside are this process's strand (strandOf),
743
+ * withdrawn or discarded by its next attempt. `told` gets the notice of a dead taker's lock recovered on the way.
744
+ */
745
+ export function releaseBeatClaim(repoRoot, tier, token, told) {
746
+ const path = beatClaimPath(repoRoot, tier);
747
+ try {
748
+ const checked = readBytes(path);
749
+ const claim = checked === undefined ? "NONE" : claimOf(parseJson(checked));
750
+ if (typeof claim !== "object" || claim.token !== token || !takeClaim(repoRoot, tier, checked, "release", true, told)) {
751
+ refuse(`${tier} claim changed before release — left in place`);
752
+ }
753
+ }
754
+ catch (error) {
755
+ if (!(error instanceof BeatRefusal) || tokenBytesAt(repoRoot, tier, token).length === 0)
756
+ throw error;
757
+ refuse(`${error.message}; its bytes are this process's strand — its next start, stop or legacy write withdraws them, and once it exits any of them reclaims them`);
758
+ }
759
+ finally {
760
+ inUse.delete(`${path}\n${token}`);
761
+ }
762
+ }
763
+ const DIR = Symbol("directory");
764
+ /** What occupies a record path: its bytes, DIR (mirrored so a staged writer refuses it exactly as it would), or nothing. */
765
+ function recordAt(path) {
766
+ try {
767
+ return statSync(path).isDirectory() ? DIR : readFileSync(path);
768
+ }
769
+ catch (error) {
770
+ const code = error.code;
771
+ if (code === "ENOENT" || code === "ENOTDIR")
772
+ return undefined;
773
+ throw error;
774
+ }
775
+ }
776
+ /**
777
+ * Run ONE supervision.ts writer (stand-down, arm or beat) under a claim without letting it touch the tier: it writes
778
+ * into a STAGED copy of the tier's records (arm, clear-duty latch, beat, stand-down marker), then every record it
779
+ * changed is committed by one rename — a discharged latch by one removal — as ONE claimed act each (`guard`), in the
780
+ * writers' own order (arm, raised latch, beat, marker, discharged latch). Compare-and-commit: inside each act, after
781
+ * the caller's own barrier (its owner generation) and before the final claim check, every record must still be
782
+ * exactly what this write was staged from (or what it committed itself) — a record replaced meanwhile, the owner by
783
+ * the caller's barrier, refuses with nothing further committed. A claim lost, or a record or owner replaced, before
784
+ * the first commit therefore lands nothing; one lost between two commits lands nothing further; either refuses. What a
785
+ * failing writer published before its failure is committed the same way, then the failure is rethrown.
786
+ * ponytail: staging stands in for a commit guard supervision.ts's atomic writers do not take; a writer killed mid-write
787
+ * leaves an inert `<tier>.stage.<pid>.<uuid>` directory (no reader lists it), which the next start, stop or legacy
788
+ * write removes once that pid is confirmed dead (acquireBeatClaim).
789
+ */
790
+ export function supervised(repoRoot, tier, guard, write) {
791
+ const stage = join(supervisionDirOf(repoRoot, tier), `${tier}.stage.${process.pid}.${randomUUID()}`);
792
+ // supervision.ts's record names; its clear-duty latch path is private there, so it is spelled here.
793
+ const records = (root) => [
794
+ supervisionArmPath(root, tier), join(supervisionDirOf(root, tier), `${tier}.clear-owed`), supervisionBeatPath(root, tier), supervisionStandDownPath(root, tier),
795
+ ];
796
+ const [real, staged] = [records(repoRoot), records(stage)];
797
+ const same = (a, b) => a === b || (a instanceof Buffer && b instanceof Buffer && a.equals(b));
798
+ try {
799
+ mkdirSync(dirname(staged[0]), { recursive: true });
800
+ const before = real.map((path, i) => {
801
+ const found = recordAt(path);
802
+ if (found === DIR)
803
+ mkdirSync(staged[i]);
804
+ else if (found)
805
+ writeFileSync(staged[i], found);
806
+ return found;
807
+ });
808
+ let result;
809
+ let failure;
810
+ try {
811
+ result = write(stage);
812
+ }
813
+ catch (error) {
814
+ failure = { error };
815
+ }
816
+ if (existsSync(join(stage, stateDirName(stage), ".gitignore")))
817
+ tickmarkrDir(repoRoot); // the writer creates the ignore file
818
+ const after = staged.map(recordAt);
819
+ const expected = [...before];
820
+ const unchanged = () => {
821
+ const moved = real.find((path, i) => !same(recordAt(path), expected[i]));
822
+ if (moved)
823
+ refuse(`${tier} superseded — ${moved} changed while this write was staged (the tier reads ${supervisionStatus(repoRoot, tier).state}); nothing further committed`);
824
+ };
825
+ for (const i of after[1] === undefined ? [0, 2, 3, 1] : [0, 1, 2, 3]) {
826
+ if (same(before[i], after[i]))
827
+ continue;
828
+ guard(() => { if (after[i] === undefined)
829
+ rmSync(real[i], { force: true });
830
+ else
831
+ renameSync(staged[i], real[i]); }, unchanged);
832
+ expected[i] = after[i];
833
+ }
834
+ if (failure)
835
+ throw failure.error;
836
+ return result;
837
+ }
838
+ finally {
839
+ rmSync(stage, { recursive: true, force: true });
840
+ }
841
+ }
842
+ /**
843
+ * Run one legacy write (one-shot tick, new arm, EVERY loop tick, stand-down) under the tier claim. A launched
844
+ * child acts under its launcher's claim (underLaunchClaim) while that claim is still held for its generation, names
845
+ * this child as its publisher and its launcher is not confirmed dead; afterwards it takes the tier claim itself like
846
+ * every other writer. `act` receives the recheck it must call before it writes (the tier's ownership still admits
847
+ * this writer, then the claim still names this holder) and the guard every supervision write it makes commits under
848
+ * (supervised): each commit is one claimed act whose barrier re-runs that same ownership check. A claim held by
849
+ * anyone else is waited out under BEAT_CLAIM_WAIT (20 attempts 250 ms apart, absent `waits` fields default to it)
850
+ * and then refuses BUSY with nothing written; invalid tries/interval refuse before any claim or write. Like start
851
+ * and stop, the writer reclaims a claim whose recorded pids are all confirmed dead (`noticed` receives the notice): a
852
+ * legacy --loop writer killed inside its claimed tick never strands the --stand-down migration step, and a live
853
+ * writer's claim is never taken. Under its own claim, the release that follows `act` is the claim's last check: a claim
854
+ * lost by then refuses, never reports success. Under the launcher's claim no release follows and nothing is checked
855
+ * after the last act — each act verified the claim last — so the launcher's release after its read-back never ends
856
+ * the loop it launched. A dead or abandoned generation recorded for this writer's seat is retired first (retireDead).
857
+ */
858
+ export async function withLegacyClaim(repoRoot, tier, writer, act, waits = { sleep: writer.deps.sleep }) {
859
+ claimWait(tier, waits); // before every branch, the launch-claim shortcut included: an invalid policy writes nothing
860
+ const { generation, settled = false } = writer;
861
+ const held = (token) => {
862
+ // The exact owner this claim's previous recheck validated: the generation every later recheck is held to.
863
+ let checked;
864
+ let last = [writer.arm, false];
865
+ const claimOnly = (mutate, check) => claimedAct(repoRoot, tier, token, "writer", check, mutate);
866
+ const owned = () => { checked = assertLegacyOwnership(repoRoot, tier, writer, last[0], last[1], checked, claimOnly); };
867
+ return [(arm = writer.arm, created) => {
868
+ last = [arm, created];
869
+ owned();
870
+ stillHeld(repoRoot, tier, token, "writer");
871
+ }, barred(claimOnly, owned)];
872
+ };
873
+ if (generation !== undefined) {
874
+ if (underLaunchClaim(repoRoot, tier, generation, writer.deps)) {
875
+ const [recheck, guard] = held(generation);
876
+ recheck();
877
+ // Before its first claimed act, this writer is recorded as the launch claim's publisher (namePublisher).
878
+ let named = false;
879
+ // No check follows the last act: every act verified the claim LAST, so what landed, landed under it — and the
880
+ // launcher releasing its claim after its read-back (start returning ARMED) never ends the loop it launched.
881
+ return act(recheck, (mutate, check) => {
882
+ if (!named) {
883
+ namePublisher(repoRoot, tier, generation);
884
+ named = true;
885
+ }
886
+ return guard(mutate, check);
887
+ });
888
+ }
889
+ if (!settled)
890
+ refuse(`${tier} superseded — the launch claim for generation ${generation} is gone, names another writer, or its launcher is dead`);
891
+ releaseDeadLaunch(repoRoot, tier, generation, writer);
892
+ }
893
+ const token = randomUUID();
894
+ const notice = await acquireWaiting(repoRoot, tier, token, waits, writer.deps);
895
+ if (notice)
896
+ writer.noticed?.(notice);
897
+ return holding(repoRoot, tier, token, async () => {
898
+ const [recheck, guard] = held(token);
899
+ if (generation === undefined && readBeatOwner(repoRoot, tier) !== "NONE") {
900
+ // A dead or abandoned generation recorded for this seat is retired first (retireDead) — its owner removed, its arm
901
+ // left to the write asked for (a one-shot records its beat on it) — then that write proceeds as an unowned one.
902
+ const checked = inspectBeat(repoRoot, tier, writer.seat, writer.deps, true);
903
+ if (checked.kind === "OWN-DEAD" || checked.kind === "ABANDONED") {
904
+ writer.noticed?.(retireDead(repoRoot, tier, checked, writer.deps, (mutate, check) => claimedAct(repoRoot, tier, token, "writer", check, mutate), false));
905
+ }
906
+ }
907
+ recheck();
908
+ return act(recheck, guard);
909
+ }, writer.noticed);
910
+ }
911
+ /**
912
+ * Whether a launched writer may act under its launcher's claim for `generation`: only while the launcher it names is
913
+ * not confirmed dead and the claim names no other publisher. Read-only: before its first claimed act the writer is
914
+ * recorded as that claim's publisher (namePublisher), so no recovery takes the claim while it publishes under it
915
+ * (reclaimDeadClaim). Once its launcher is dead, a settled writer releases that claim (releaseDeadLaunch) and takes
916
+ * the tier claim itself; an unsettled one refuses, its launch abandoned.
917
+ */
918
+ function underLaunchClaim(repoRoot, tier, generation, deps) {
919
+ const claim = readClaim(repoRoot, tier);
920
+ if (typeof claim !== "object" || claim.token !== generation || (claim.publisher ?? process.pid) !== process.pid)
921
+ return false;
922
+ let launcher = "UNKNOWN";
923
+ try {
924
+ launcher = deps.identity(claim.pid);
925
+ }
926
+ catch { /* an inspection failure proves nothing */ }
927
+ return launcher !== "DEAD";
928
+ }
929
+ /**
930
+ * A settled launched writer recorded as publishing under a launch claim whose launcher is confirmed dead is that
931
+ * claim's last live party: it removes exactly those bytes (takeClaim) with a notice, so neither recovery nor stop is
932
+ * left waiting on a claim nobody will release. A launcher killed before it bound this generation's arm epoch left an
933
+ * owner nobody can prove or stop: the writer then exits (refuses) once that claim is released, so the next start, stop
934
+ * or legacy write finds an ABANDONED launch — no claim, its recorded pid dead — and retires it (retireDead).
935
+ */
936
+ function releaseDeadLaunch(repoRoot, tier, generation, writer) {
937
+ const path = beatClaimPath(repoRoot, tier);
938
+ const bytes = readBytes(path);
939
+ const claim = bytes === undefined ? "NONE" : claimOf(parseJson(bytes));
940
+ if (typeof claim !== "object" || claim.token !== generation || claim.publisher !== process.pid)
941
+ return;
942
+ let launcher = "UNKNOWN";
943
+ try {
944
+ launcher = writer.deps.identity(claim.pid);
945
+ }
946
+ catch { /* an inspection failure proves nothing */ }
947
+ if (launcher !== "DEAD" || !takeClaim(repoRoot, tier, bytes, `release of dead launcher pid ${claim.pid}'s claim`, false, writer.noticed))
948
+ return;
949
+ writer.noticed?.(`${tier} released the launch claim of dead launcher pid ${claim.pid} (token ${generation}) it published under at ${path}`);
950
+ const owner = readBeatOwner(repoRoot, tier);
951
+ if (typeof owner === "object" && owner.generation === generation && owner.armEpoch === undefined) {
952
+ refuse(`${tier} superseded — launcher pid ${claim.pid} died before binding generation ${generation}'s arm epoch; this writer exits, ` +
953
+ `and the next start, stop or legacy write retires the abandoned launch`);
954
+ }
955
+ }
956
+ /** Record this process as the launch claim's publisher, by one claimed act that replaces exactly the bytes it read. */
957
+ function namePublisher(repoRoot, tier, generation) {
958
+ const path = beatClaimPath(repoRoot, tier);
959
+ const bytes = readBytes(path);
960
+ const rec = bytes === undefined ? "NONE" : parseJson(bytes);
961
+ const claim = claimOf(rec);
962
+ if (typeof claim === "object" && claim.publisher === process.pid)
963
+ return;
964
+ if (typeof rec !== "object" || typeof claim !== "object" || claim.token !== generation || claim.publisher !== undefined) {
965
+ refuse(`${tier} claim is no longer held by this launched writer — the launch claim for generation ${generation} changed before it published under it; nothing written`);
966
+ }
967
+ publish(path, { ...rec, publisher: process.pid, publisherBirth: birth }, (mutate, check) => claimedAct(repoRoot, tier, generation, "launched writer", check, mutate), () => { if (readBytes(path) !== bytes)
968
+ refuse(`${tier} superseded — the launch claim for generation ${generation} changed; nothing written`); });
969
+ }
970
+ /** The ownership a legacy writer needs before each mutation: its own COMPLETE launched generation, or no owner at all. */
971
+ export function assertLegacyOwnership(repoRoot, tier, writer, arm, created = false, held, claimed = unclaimed) {
972
+ if (writer.generation !== undefined)
973
+ return assertOwnedGeneration(repoRoot, tier, { ...writer, generation: writer.generation }, arm, created, held, claimed);
974
+ const owner = readBeatOwner(repoRoot, tier);
975
+ if (owner !== "NONE") {
976
+ refuse(`${tier} is owned by a detached beat (${typeof owner === "object" ? `seat ${owner.seat}, generation ${owner.generation}` : "unreadable owner record"})` +
977
+ ` — run \`tickmarkr beat stop ${tier} ${typeof owner === "object" ? seatFlag(owner.seat) : "--seat <seat>"}\` first`);
978
+ }
979
+ return undefined;
980
+ }
981
+ const sameOwner = (a, b) => JSON.stringify(a) === JSON.stringify(b);
982
+ /**
983
+ * The ONE owner change a writer accepts from someone else: its launcher completing an UNBOUND owner — publishing
984
+ * this writer's own pid and/or binding the epoch of the arm this writer itself holds. Verified field by field
985
+ * against the record the writer last checked; everything else the record says must be unchanged.
986
+ */
987
+ function launcherBound(before, after, pid, arm) {
988
+ if (before.armEpoch !== undefined)
989
+ return false;
990
+ const fixed = (o) => JSON.stringify([o.tier, o.seat, o.generation, o.armId, o.cwd, o.startedAt, o.priorArmId, o.priorArmEpoch]);
991
+ const proc = (o) => JSON.stringify([o.pid, o.birth, o.command, o.pgid]);
992
+ const published = before.pid === undefined && after.pid === pid;
993
+ const bound = after.armEpoch !== undefined;
994
+ return fixed(before) === fixed(after) && (published || proc(before) === proc(after)) && (published || bound) &&
995
+ (!bound || (after.pid === pid && arm !== undefined && after.armId === arm.armId && after.armEpoch === arm.armEpoch));
996
+ }
997
+ /**
998
+ * ONE RULE: every legacy write is preceded by the shared complete owned-generation check of the CURRENT owner
999
+ * record and durable arm against the EXACT generation this writer holds — token, seat, pid (once settled), armId
1000
+ * and armEpoch. `held` is the owner this claim's previous check validated and `arm` the arm the writer holds (a
1001
+ * loop's own arm; before the launcher binds the epoch that arm is the evidence). The first check of a claim by a
1002
+ * writer holding neither takes the generation on record; from then on the held generation changes in exactly
1003
+ * two verified ways, and any other change — also one between this check's own reads — refuses with no write:
1004
+ * (i) the writer's OWN re-arm (`created`: it published `arm` under this claim after the complete check): the
1005
+ * arm on disk must be exactly that arm and the owner exactly the owner checked before the arm write; only
1006
+ * then is the owner rebound to the arm (same token, seat, process), and the complete check runs AGAIN as
1007
+ * the new generation before the beat. After any mismatch the owner is never rewritten.
1008
+ * (ii) the launcher completing an UNBOUND owner for this writer (launcherBound) — never adopted from disk.
1009
+ * Once `settled` (past its first beat) the record must name its pid: a launch that refused without proving it
1010
+ * could signal its child leaves no pid behind, and that child retires itself.
1011
+ */
1012
+ export function assertOwnedGeneration(repoRoot, tier, writer, arm = writer.arm, created = false, held, claimed = unclaimed, pid = process.pid) {
1013
+ const { generation, seat, settled = false, deps } = writer;
1014
+ const otherArm = (owner) => arm !== undefined && (arm.armId !== owner.armId || (owner.armEpoch !== undefined && arm.armEpoch !== owner.armEpoch));
1015
+ const binds = (owner) => `${tier} superseded — the owner record binds arm ${owner.armId} (epoch ${owner.armEpoch ?? "unbound"}), not this writer's arm ${arm?.armId} (epoch ${arm?.armEpoch}); nothing written`;
1016
+ const changed = (owner) => refuse(typeof owner === "object" && otherArm(owner) ? binds(owner)
1017
+ : `${tier} superseded — the owner record changed under generation ${generation} while it was checked; nothing written`);
1018
+ let expected = held;
1019
+ for (let moved = false;; moved = true) {
1020
+ const owner = readBeatOwner(repoRoot, tier);
1021
+ if (owner === "UNREADABLE")
1022
+ refuse(`${tier} owner record is unreadable — this loop cannot prove it is still owned`);
1023
+ // The generation speaks for its recorded seat only: another seat carrying the same generation is foreign.
1024
+ if (owner !== "NONE" && owner.generation === generation && owner.seat !== seat)
1025
+ refuse(`${tier} is owned by seat ${owner.seat}, not ${seat} — nothing written`);
1026
+ if (owner === "NONE" || owner.generation !== generation || (owner.pid !== undefined && owner.pid !== pid)) {
1027
+ refuse(`${tier} superseded — the owner record no longer names generation ${generation}`);
1028
+ }
1029
+ if (settled && owner.pid === undefined)
1030
+ refuse(`${tier} superseded — generation ${generation} never recorded pid ${pid}; its launch was abandoned`);
1031
+ // `moved`: the shared check below saw the owner change between its reads — only the launcher's step passes.
1032
+ if (expected !== undefined && (moved || !sameOwner(expected, owner)) && !launcherBound(expected, owner, pid, arm))
1033
+ changed(owner);
1034
+ expected = owner;
1035
+ if (arm && created && owner.armEpoch !== undefined && otherArm(owner)) {
1036
+ if (held === undefined)
1037
+ refuse(`${tier} superseded — generation ${generation} re-armed without its complete check; owner left in place`);
1038
+ const next = { ...owner, armId: arm.armId, armEpoch: arm.armEpoch };
1039
+ // The rebind's own claimed act: inside it, the arm is exactly the arm just created and the owner exactly the one checked.
1040
+ publish(beatOwnerPath(repoRoot, tier), next, claimed, () => {
1041
+ const records = checkedRecords(repoRoot, tier, deps);
1042
+ if (records.kind === "UNKNOWN")
1043
+ refuse(`${tier} UNREADABLE — ${records.reason}; owner left in place`);
1044
+ if (records.arm?.armId !== arm.armId || records.arm.armEpoch !== arm.armEpoch) {
1045
+ refuse(`${tier} superseded — durable arm ${records.arm ? `${records.arm.armId} (epoch ${records.arm.armEpoch})` : "(absent)"} replaced the arm ${arm.armId} (epoch ${arm.armEpoch}) generation ${generation} just created; owner left in place`);
1046
+ }
1047
+ const current = readBeatOwner(repoRoot, tier);
1048
+ if (!sameOwner(owner, current))
1049
+ changed(current);
1050
+ });
1051
+ requireOwned(checkOwnedGeneration(repoRoot, tier, expectedGeneration(next), deps, undefined, true), tier);
1052
+ return next;
1053
+ }
1054
+ const check = checkOwnedGeneration(repoRoot, tier, { owner, armEpoch: owner.armEpoch ?? arm?.armEpoch }, deps, undefined, true);
1055
+ if (check.kind === "UNKNOWN" && check.reason === OWNER_CHANGED)
1056
+ continue;
1057
+ requireOwned(check, tier);
1058
+ // Records that agree with each other are still another generation when they are not the arm this writer holds.
1059
+ if (otherArm(owner))
1060
+ refuse(binds(owner));
1061
+ return owner;
1062
+ }
1063
+ }
1064
+ const sameIdentity = (a, id) => typeof id === "object" && id.birth === a.birth && id.command === a.command && id.cwd === a.cwd;
1065
+ const sameProcess = (owner, id) => sameIdentity(owner, id);
1066
+ /**
1067
+ * Linux frees an exiting process's memory (its argv) before its cwd and before it is a zombie, so in that window ps prints
1068
+ * `[comm]` beside the same birth, group and cwd. Before AND after any signal that reading is UNKNOWN — never an accepted
1069
+ * identity, never death: a subsequent complete read or confirmed death decides. A generation not yet bound to a birth
1070
+ * can only be torn down; once bound, a `[comm]` with another birth, group or cwd is a genuine foreign identity (MISMATCH).
1071
+ */
1072
+ const tearingDown = (a, id) => /^\[.+\]$/.test(id.command) && (a.birth === undefined || (id.birth === a.birth && id.pgid === a.pgid && id.cwd === a.cwd));
1073
+ /** The durable arm, or UNREADABLE for a torn one — never mistaken for no arm. */
1074
+ function armOf(repoRoot, tier) {
1075
+ try {
1076
+ return readSupervisionArm(repoRoot, tier);
1077
+ }
1078
+ catch {
1079
+ return "UNREADABLE";
1080
+ }
1081
+ }
1082
+ function checkedRecords(repoRoot, tier, deps) {
1083
+ const writer = readJson(supervisionBeatPath(repoRoot, tier));
1084
+ if (typeof writer === "object" && !validBeat(writer, tier))
1085
+ return { kind: "UNKNOWN", reason: `beat record ${supervisionBeatPath(repoRoot, tier)} is malformed` };
1086
+ const state = supervisionStatus(repoRoot, tier, deps.now());
1087
+ if (writer === "UNREADABLE" || state.state === "UNREADABLE")
1088
+ return { kind: "UNKNOWN", reason: "the tier's supervision records are unreadable" };
1089
+ const arm = armOf(repoRoot, tier);
1090
+ if (arm === "UNREADABLE")
1091
+ return { kind: "UNKNOWN", reason: `durable arm ${supervisionArmPath(repoRoot, tier)} is unreadable` };
1092
+ return { kind: "RECORDS", writer, arm, state };
1093
+ }
1094
+ /**
1095
+ * An arm that is neither this generation's COMPLETE arm (armId and, once bound, armEpoch) nor — only while the
1096
+ * launch has not yet bound its epoch — the fenced arm it launched over, has replaced it.
1097
+ */
1098
+ const replacedArm = (arm, owner) => {
1099
+ // Once the epoch is bound, an ABSENT arm proves nothing: ownership is the durable arm, so it is a mismatch.
1100
+ if (arm === undefined)
1101
+ return owner.armEpoch === undefined ? undefined : "(absent)";
1102
+ if (arm.armId === owner.armId) {
1103
+ return owner.armEpoch === undefined || arm.armEpoch === owner.armEpoch ? undefined : `${arm.armId} (epoch ${arm.armEpoch})`;
1104
+ }
1105
+ const prior = owner.armEpoch === undefined && arm.armId === owner.priorArmId && arm.armEpoch === owner.priorArmEpoch;
1106
+ return prior ? undefined : arm.armId;
1107
+ };
1108
+ const expectedGeneration = (owner) => ({ owner, armEpoch: owner.armEpoch });
1109
+ /**
1110
+ * ONE owned-generation barrier for inspection, launch, retirement and rollback. `expected` is the snapshot
1111
+ * the caller owns, never a replacement read from disk. Every identity inspection goes through this barrier:
1112
+ * identity FIRST, then fresh, independently validated beat/marker/arm records and the exact expected owner.
1113
+ * A caller without a process to inspect still uses the same barrier immediately before publishing/removing.
1114
+ */
1115
+ function checkOwnedGeneration(repoRoot, tier, snapshot, deps, pid, launching = false) {
1116
+ const expected = snapshot.owner;
1117
+ let read = "UNKNOWN";
1118
+ if (pid !== undefined) {
1119
+ try {
1120
+ read = deps.identity(pid);
1121
+ }
1122
+ catch { /* an inspection failure proves neither life nor death */ }
1123
+ }
1124
+ const unreadable = typeof read === "object" ? (tearingDown(expected, read) ? `pid ${pid} shows teardown argv ${read.command}; a complete read or confirmed death decides` : undefined)
1125
+ : read.startsWith("UNKNOWN — ") ? read.slice("UNKNOWN — ".length) : undefined;
1126
+ const identity = unreadable !== undefined ? "UNKNOWN" : read;
1127
+ const records = checkedRecords(repoRoot, tier, deps);
1128
+ if (records.kind === "UNKNOWN")
1129
+ return records;
1130
+ const current = readBeatOwner(repoRoot, tier);
1131
+ if (current === "UNREADABLE")
1132
+ return { kind: "UNKNOWN", reason: `malformed owner record ${beatOwnerPath(repoRoot, tier)}` };
1133
+ if (JSON.stringify(current) !== JSON.stringify(expected))
1134
+ return {
1135
+ kind: "UNKNOWN", reason: OWNER_CHANGED,
1136
+ mutationReason: `the owner record no longer names this generation; generation ${expected.generation} was replaced before its stop — left in place`,
1137
+ };
1138
+ const armOwner = { ...expected, armEpoch: snapshot.armEpoch ?? expected.armEpoch };
1139
+ const replaced = replacedArm(records.arm, armOwner);
1140
+ if (replaced)
1141
+ return {
1142
+ kind: "FOREIGN", reason: `durable arm ${replaced} is not generation ${expected.generation}'s arm`,
1143
+ mutationReason: `durable arm ${replaced} replaced generation ${expected.generation}'s arm — left in place`,
1144
+ };
1145
+ if (!launching && armOwner.armEpoch === undefined && records.arm?.armId === expected.armId)
1146
+ return {
1147
+ kind: "UNKNOWN", reason: `generation ${expected.generation} never proved its arm epoch; the current arm cannot authorize cleanup`,
1148
+ };
1149
+ return { kind: "OWNED", records, identity, ...(unreadable !== undefined ? { unreadable } : {}) };
1150
+ }
1151
+ const why = (check) => check.unreadable ? ` (${check.unreadable})` : "";
1152
+ function requireOwned(check, tier) {
1153
+ if (check.kind !== "OWNED")
1154
+ refuse(`${tier} ${check.mutationReason ?? `${check.kind === "UNKNOWN" ? "UNREADABLE" : "MISMATCH"} — ${check.reason}; left in place`}`);
1155
+ return check;
1156
+ }
1157
+ /** Freshness retains the existing mtime boundaries; payload attribution also requires a post-launch beat. */
1158
+ function advancingBeat(records, owner) {
1159
+ const w = typeof records.writer === "object" ? records.writer : {};
1160
+ return records.state.state === "ARMED" && owner.armEpoch !== undefined &&
1161
+ records.arm?.armId === owner.armId && records.arm.armEpoch === owner.armEpoch &&
1162
+ w.tier === owner.tier && w.seat === owner.seat && w.armId === owner.armId && w.armEpoch === owner.armEpoch &&
1163
+ w.pid === owner.pid && w.exitedWriterPid === undefined &&
1164
+ Date.parse(w.beatAt) >= Date.parse(owner.startedAt);
1165
+ }
1166
+ /**
1167
+ * The checked state of one tier, read-only: no claim, no write, no signal. Every authoritative record —
1168
+ * owner, marker, beat and durable arm — is validated before any outcome is selected, and ownership binds to
1169
+ * this repository's canonical root, the recorded seat and the recorded arm before the process is consulted.
1170
+ * An UNBOUND owner (no pid, or no arm epoch) is a launch in progress only while a claim is held: a launch publishes and
1171
+ * binds only under its own claim. So when the caller holds the tier claim (`held`), or no claim exists before and after
1172
+ * the owner is read (status), an unbound owner with no live recorded process is ABANDONED — its launcher died — and
1173
+ * the next start, stop or legacy write retires it (retireDead); a live recorded process keeps it UNKNOWN.
1174
+ */
1175
+ export function inspectBeat(repoRoot, tier, seat, deps, held = false) {
1176
+ const unclaimedBefore = held || readClaim(repoRoot, tier) === "NONE";
1177
+ const owner = readBeatOwner(repoRoot, tier);
1178
+ if (owner === "UNREADABLE")
1179
+ return { kind: "UNKNOWN", reason: `malformed owner record ${beatOwnerPath(repoRoot, tier)}` };
1180
+ const initial = checkedRecords(repoRoot, tier, deps);
1181
+ if (initial.kind === "UNKNOWN")
1182
+ return initial;
1183
+ const tierState = initial.state;
1184
+ if (owner === "NONE") {
1185
+ if (tierState.state === "ABSENT")
1186
+ return { kind: "EMPTY" };
1187
+ if (tierState.state === "DISARMED")
1188
+ return { kind: "DISARMED", ...(tierState.seat ? { seat: tierState.seat } : {}) };
1189
+ const migrate = legacyExit(tier, tierState.seat, initial.writer, deps);
1190
+ return {
1191
+ kind: "FOREIGN", reason: `${tierState.state} by an unowned writer${tierState.seat ? ` (seat ${tierState.seat})` : " that recorded no seat — none is invented"}`,
1192
+ ...(migrate ? { migrate } : {}),
1193
+ };
1194
+ }
1195
+ const root = realpathSync(repoRoot);
1196
+ if (owner.cwd !== root)
1197
+ return { kind: "FOREIGN", reason: `owner record names repository ${owner.cwd}, not ${root}` };
1198
+ if (seat !== undefined && owner.seat !== seat)
1199
+ return { kind: "FOREIGN", reason: `owned by seat ${owner.seat}, not ${seat}` };
1200
+ const unbound = owner.pid === undefined || owner.armEpoch === undefined;
1201
+ const idle = unbound && unclaimedBefore && (held || readClaim(repoRoot, tier) === "NONE");
1202
+ const checked = checkOwnedGeneration(repoRoot, tier, expectedGeneration(owner), deps, owner.pid, idle);
1203
+ if (checked.kind !== "OWNED")
1204
+ return checked;
1205
+ const id = checked.identity;
1206
+ if (unbound) {
1207
+ const what = owner.pid === undefined ? "recorded no process" : "never bound its arm epoch";
1208
+ if (idle && (owner.pid === undefined || id === "DEAD"))
1209
+ return {
1210
+ kind: "ABANDONED", owner, reason: `generation ${owner.generation} ${what} and no launch holds the claim${owner.pid === undefined ? "" : `; recorded pid ${owner.pid} is gone`} — its launch was abandoned`,
1211
+ };
1212
+ return { kind: "UNKNOWN", reason: `generation ${owner.generation} ${what} (launch in progress or abandoned)` };
1213
+ }
1214
+ const owned = owner;
1215
+ if (id === "DEAD")
1216
+ return { kind: "OWN-DEAD", owner: owned };
1217
+ if (id === "UNKNOWN")
1218
+ return { kind: "UNKNOWN", reason: `identity of recorded pid ${owned.pid} is unreadable${checked.unreadable ? ` (${checked.unreadable})` : ""}` };
1219
+ if (!sameProcess(owned, id))
1220
+ return { kind: "PID-REUSED", owner: owned };
1221
+ // Advancing means THIS child wrote the fresh beat — tier, seat, arm and its own live pid. A one-shot record
1222
+ // (exitedWriterPid) or any other writer reusing the arm id is not the owned child beating.
1223
+ return { kind: "OWN-LIVE", owner: owned, advancing: advancingBeat(checked.records, owned) };
1224
+ }
1225
+ const shellWord = (word) => /^[\w@%+=:,./-]+$/.test(word) ? word : `'${word.replaceAll("'", "'\\''")}'`;
1226
+ /**
1227
+ * The exit for a legacy (unowned, pre-lifecycle) arm: stand it down under its RECORDED seat, then start owns the
1228
+ * tier. A loop writer's own pid is named for the operator to stop first when it is not confirmed dead — tickmarkr
1229
+ * never signals a process it did not launch, nor takes its live claim. A writer stopped inside its claimed tick
1230
+ * leaves a confirmed-dead claim, which the printed --stand-down reclaims (withLegacyClaim). No recorded seat: no
1231
+ * recipe, never an invented one.
1232
+ */
1233
+ function legacyExit(tier, seat, writer, deps) {
1234
+ if (seat === undefined)
1235
+ return undefined;
1236
+ const pid = typeof writer === "object" && positive(writer.pid) ? writer.pid : undefined;
1237
+ let id = "DEAD";
1238
+ if (pid !== undefined)
1239
+ try {
1240
+ id = deps.identity(pid);
1241
+ }
1242
+ catch {
1243
+ id = "UNKNOWN";
1244
+ }
1245
+ const first = id === "DEAD" ? "" : typeof id === "object" ? `stop pid ${pid} first (its legacy --loop writer; the stand-down reclaims a claim it dies holding), then `
1246
+ : `pid ${pid}'s identity is unreadable — confirm it is gone or stop it first, then `;
1247
+ return `migrate the legacy writer: ${first}run \`tickmarkr beat ${tier} ${seatFlag(seat)} --stand-down\` and start again`;
1248
+ }
1249
+ // A seat beginning with a dash would parse as an option after a bare --seat: it rides the --seat=<seat> form.
1250
+ const seatFlag = (seat) => seat.startsWith("-") ? `--seat=${shellWord(seat)}` : `--seat ${shellWord(seat)}`;
1251
+ const migrating = (checked) => checked.migrate ? ` — ${checked.migrate}` : "";
1252
+ /**
1253
+ * Status: the recorded liveness, rendered without writing; healthy states exit 0, the rest nonzero. It is observation
1254
+ * only: a claim whose recorded pids are all dead, what a killed taker or writer left, or — read in the process that
1255
+ * stranded it (a bridge) — that process's own strand (strandOf), is REPORTED (nonzero) with the pids and the exact
1256
+ * command that recovers it — never recovered here, and no file is touched.
1257
+ */
1258
+ export function beatStatus(repoRoot, tier, seat, deps) {
1259
+ const checked = inspectBeat(repoRoot, tier, seat, deps);
1260
+ const claim = readClaim(repoRoot, tier);
1261
+ const dead = (pid) => { try {
1262
+ return deps.identity(pid) === "DEAD";
1263
+ }
1264
+ catch {
1265
+ return false;
1266
+ } };
1267
+ const deadClaim = typeof claim === "object" && dead(claim.pid) && (claim.publisher === undefined || dead(claim.publisher));
1268
+ const publisher = typeof claim === "object" && claim.publisher !== undefined ? ` with its launched writer pid ${claim.publisher}` : "";
1269
+ // Every holder is named: the claim's, and each removal lock's taker not confirmed dead (a removal under way).
1270
+ const dir = supervisionDirOf(repoRoot, tier);
1271
+ const names = (() => { try {
1272
+ return readdirSync(dir);
1273
+ }
1274
+ catch {
1275
+ return [];
1276
+ } })();
1277
+ const removing = names.filter((name) => name.startsWith(`${tier}.claim.`) && name.endsWith(".reclaim")).flatMap((name) => {
1278
+ const taker = lockOwner(join(dir, name));
1279
+ return taker && !confirmedDead(taker.pid) ? [`a claim removal under way at ${name} by pid ${taker.pid}`] : [];
1280
+ });
1281
+ const holders = [...(claim === "NONE" ? [] : [typeof claim === "object" ? `claim held by ${deadClaim ? "dead " : ""}pid ${claim.pid}${publisher}` : "unreadable claim"]), ...removing];
1282
+ const busy = holders.length > 0 ? ` · BUSY (${holders.join("; ")})` : "";
1283
+ const live = [];
1284
+ const left = [...strandsNamed(repoRoot, tier, live), ...deadLeftovers(repoRoot, tier).map(({ path, pid }) => `${basename(path)} of dead pid ${pid}`)];
1285
+ const owner = readBeatOwner(repoRoot, tier);
1286
+ const recorded = typeof owner === "object" ? owner.seat : supervisionStatus(repoRoot, tier, deps.now()).seat ?? seat;
1287
+ const waiting = live.length > 0 ? ` once live taker pid ${[...new Set(live)].join(", ")} has finished or exited (until then it refuses BUSY naming it)` : "";
1288
+ const recover = deadClaim || left.length > 0
1289
+ ? ` · left behind: ${[...(deadClaim ? ["the dead claim"] : []), ...left].join(", ")} — run \`tickmarkr beat stop ${tier} ${recorded ? seatFlag(recorded) : "--seat <seat>"}\` ` +
1290
+ `to recover it${waiting} (status reports, never recovers)` : "";
1291
+ const row = (() => {
1292
+ switch (checked.kind) {
1293
+ case "EMPTY": return ["ABSENT — no beat recorded", 0];
1294
+ case "DISARMED": return [`DISARMED${checked.seat ? ` (${checked.seat})` : ""}`, 0];
1295
+ case "OWN-LIVE": return checked.advancing
1296
+ ? [`ARMED (${checked.owner.seat}) — detached pid ${checked.owner.pid}, generation ${checked.owner.generation}`, 0]
1297
+ : [`STALE (${checked.owner.seat}) — detached pid ${checked.owner.pid} is alive but its beat is not advancing`, 1];
1298
+ case "OWN-DEAD": return [`STALE (${checked.owner.seat}) — recorded pid ${checked.owner.pid} is gone; run \`tickmarkr beat stop ${tier} ${seatFlag(checked.owner.seat)}\``, 1];
1299
+ case "ABANDONED": return [`STALE (${checked.owner.seat}) — ${checked.reason}; run \`tickmarkr beat stop ${tier} ${seatFlag(checked.owner.seat)}\``, 1];
1300
+ case "FOREIGN": return [`MISMATCH — ${checked.reason}${migrating(checked)}`, 1];
1301
+ case "PID-REUSED": return [`MISMATCH — recorded pid ${checked.owner.pid} now belongs to another process`, 1];
1302
+ case "UNKNOWN": return [`UNREADABLE — ${checked.reason}`, 1];
1303
+ }
1304
+ })();
1305
+ return { out: `${tier} ${row[0]}${busy}${recover}`, code: recover ? 1 : row[1] };
1306
+ }
1307
+ const exited = (child) => child.exitCode !== null || child.signalCode !== null;
1308
+ /** Every reclaim and recovery notice — the claim's, and its release's — leads the lifecycle's outcome, success or refusal alike. */
1309
+ async function noticed(notices, run) {
1310
+ try {
1311
+ const out = await run();
1312
+ return [...notices, out].join("\n");
1313
+ }
1314
+ catch (error) {
1315
+ if (error instanceof BeatRefusal && notices.length > 0)
1316
+ throw new BeatRefusal([...notices, error.message].join("\n"));
1317
+ throw error;
1318
+ }
1319
+ }
1320
+ /**
1321
+ * The identity reader's own tool, proven on this live process before start writes anything: a reader that
1322
+ * names its missing cause (Darwin's lsof) could never prove the child, so start refuses with nothing to undo.
1323
+ */
1324
+ function readerPreflight(tier, deps) {
1325
+ let self = "UNKNOWN";
1326
+ try {
1327
+ self = deps.identity(process.pid);
1328
+ }
1329
+ catch {
1330
+ return;
1331
+ }
1332
+ if (typeof self === "string" && self.startsWith("UNKNOWN — "))
1333
+ refuse(`${tier} UNREADABLE — ${self.slice("UNKNOWN — ".length)}; nothing started`);
1334
+ }
1335
+ /**
1336
+ * Run `body` under the claim `token` and release it. A refusal from the body is never masked by the release
1337
+ * refusal that follows a lost claim: both are reported.
1338
+ */
1339
+ async function holding(repoRoot, tier, token, body, told) {
1340
+ let result;
1341
+ try {
1342
+ result = await body();
1343
+ }
1344
+ catch (error) {
1345
+ try {
1346
+ releaseBeatClaim(repoRoot, tier, token, told);
1347
+ }
1348
+ catch (release) {
1349
+ if (error instanceof BeatRefusal)
1350
+ throw new BeatRefusal(`${error.message}; ${release.message}`);
1351
+ }
1352
+ throw error;
1353
+ }
1354
+ releaseBeatClaim(repoRoot, tier, token, told);
1355
+ return result;
1356
+ }
1357
+ /** start: one claim (a single attempt), one child; exit zero only after this exact child's own advancing beat reads back. */
1358
+ export async function startBeat(repoRoot, tier, seat, deps) {
1359
+ readerPreflight(tier, deps);
1360
+ const generation = randomUUID();
1361
+ const notices = [await acquireWaiting(repoRoot, tier, generation, { tries: 1, sleep: deps.sleep }, deps)].filter((n) => n !== undefined);
1362
+ return noticed(notices, () => holding(repoRoot, tier, generation, () => startClaimed(repoRoot, tier, seat, generation, deps), (n) => notices.push(n)));
1363
+ }
1364
+ async function startClaimed(repoRoot, tier, seat, generation, deps) {
1365
+ let result;
1366
+ const checked = inspectBeat(repoRoot, tier, seat, deps, true);
1367
+ switch (checked.kind) {
1368
+ case "EMPTY":
1369
+ case "DISARMED":
1370
+ result = await launch(repoRoot, tier, seat, generation, deps);
1371
+ break;
1372
+ case "OWN-LIVE":
1373
+ if (!checked.advancing)
1374
+ refuse(`${tier} STALE — owned pid ${checked.owner.pid} is alive but silent; run \`tickmarkr beat stop ${tier} ${seatFlag(seat)}\` first`);
1375
+ result = `${tier} already running as ${seat} — detached pid ${checked.owner.pid}, generation ${checked.owner.generation}; checked no-op`;
1376
+ break;
1377
+ // A dead or abandoned generation is retired first, exactly as stop retires it, then this start launches over it.
1378
+ case "OWN-DEAD":
1379
+ case "ABANDONED": {
1380
+ const guard = (mutate, check) => claimedAct(repoRoot, tier, generation, "start", check, mutate);
1381
+ result = await noticed([retireDead(repoRoot, tier, checked, deps, guard)], () => launch(repoRoot, tier, seat, generation, deps));
1382
+ break;
1383
+ }
1384
+ case "FOREIGN":
1385
+ refuse(`${tier} MISMATCH — ${checked.reason}; nothing started${migrating(checked)}`);
1386
+ break;
1387
+ case "PID-REUSED":
1388
+ refuse(`${tier} MISMATCH — recorded pid ${checked.owner.pid} now belongs to another process; nothing started`);
1389
+ break;
1390
+ case "UNKNOWN":
1391
+ refuse(`${tier} UNREADABLE — ${checked.reason}; nothing started`);
1392
+ break;
1393
+ }
1394
+ return result;
1395
+ }
1396
+ async function launch(repoRoot, tier, seat, generation, deps) {
1397
+ const startedAt = deps.now();
1398
+ const deadline = startedAt + BEAT_STARTUP_CEILING_MS;
1399
+ const prior = armOf(repoRoot, tier);
1400
+ const base = {
1401
+ tier, seat, generation, armId: generation, cwd: realpathSync(repoRoot), startedAt: new Date(startedAt).toISOString(),
1402
+ ...(typeof prior === "object" ? { priorArmId: prior.armId, priorArmEpoch: prior.armEpoch } : {}),
1403
+ };
1404
+ // This launch's expected snapshot is carried through EVERY failure, including rollback. Never replace
1405
+ // it with a reread owner: even the same token under a different arm epoch is another generation.
1406
+ let expected = expectedGeneration(base);
1407
+ const snapshot = () => expected;
1408
+ // Every mutation below is one claimed act under this launch's claim, its generation barrier inside the act.
1409
+ const guard = (mutate, check) => claimedAct(repoRoot, tier, generation, "start", check, mutate);
1410
+ const owned = () => requireOwned(checkOwnedGeneration(repoRoot, tier, snapshot(), deps, undefined, true), tier);
1411
+ try {
1412
+ publish(beatOwnerPath(repoRoot, tier), base, guard, () => {
1413
+ if (readBeatOwner(repoRoot, tier) !== "NONE")
1414
+ refuse(`${tier} superseded — an owner record appeared before generation ${generation} published its own`);
1415
+ });
1416
+ owned();
1417
+ }
1418
+ catch (error) {
1419
+ return rollback(repoRoot, tier, snapshot(), undefined, undefined, deps, `owner publication failed: ${error.message}`);
1420
+ }
1421
+ const log = beatLogPath(repoRoot, tier);
1422
+ let child;
1423
+ try {
1424
+ const fd = guard(() => deps.openLog(log), owned);
1425
+ try {
1426
+ child = guard(() => deps.spawn(deps.cli[0], [...deps.cli.slice(1), "beat", tier, "--seat", seat, "--loop", "--arm-id", generation], {
1427
+ cwd: repoRoot, detached: true, stdio: ["ignore", fd, fd], env: { ...deps.env, [BEAT_GENERATION_ENV]: generation },
1428
+ }), owned);
1429
+ }
1430
+ finally {
1431
+ closeSync(fd);
1432
+ }
1433
+ }
1434
+ catch (error) {
1435
+ return rollback(repoRoot, tier, snapshot(), undefined, undefined, deps, `log-open/spawn failed: ${error.message}`);
1436
+ }
1437
+ child.on("error", () => { });
1438
+ child.unref();
1439
+ const pid = child.pid;
1440
+ if (!pid)
1441
+ return rollback(repoRoot, tier, snapshot(), child, undefined, deps, "spawn produced no process");
1442
+ // Until its identity proves it is this generation's child (this cwd, this generation in its argv), the pid
1443
+ // is never a signal target: an unproven rollback retains the record and the child retires itself.
1444
+ const proof = checkOwnedGeneration(repoRoot, tier, snapshot(), deps, pid, true);
1445
+ if (proof.kind !== "OWNED")
1446
+ return rollback(repoRoot, tier, snapshot(), child, undefined, deps, proof.mutationReason ?? proof.reason);
1447
+ const id = proof.identity;
1448
+ if (exited(child) || id === "DEAD")
1449
+ return rollback(repoRoot, tier, snapshot(), child, undefined, deps, `child pid ${pid} died before its beat read back (log ${log})`);
1450
+ if (id === "UNKNOWN")
1451
+ return rollback(repoRoot, tier, snapshot(), child, undefined, deps, `UNREADABLE — child pid ${pid} identity is unreadable${why(proof)}`);
1452
+ if (id.cwd !== base.cwd || !id.command.includes(generation)) {
1453
+ return rollback(repoRoot, tier, snapshot(), child, undefined, deps, `child pid ${pid} is not the launched generation (cwd ${id.cwd})`);
1454
+ }
1455
+ const fail = (why) => rollback(repoRoot, tier, snapshot(), child, id, deps, why);
1456
+ const captureArm = (records) => {
1457
+ const w = typeof records.writer === "object" ? records.writer : {};
1458
+ if (expected.armEpoch === undefined && w.tier === tier && w.seat === seat && w.armId === generation &&
1459
+ w.pid === pid && w.exitedWriterPid === undefined && text(w.armEpoch)) {
1460
+ expected = { ...expected, armEpoch: w.armEpoch };
1461
+ }
1462
+ return requireOwned(checkOwnedGeneration(repoRoot, tier, snapshot(), deps, undefined, true), tier).records;
1463
+ };
1464
+ try {
1465
+ captureArm(proof.records);
1466
+ }
1467
+ catch (error) {
1468
+ return fail(error.message);
1469
+ }
1470
+ // Detached means its OWN session and process group, never "ppid 1": an orphan of a dying session has
1471
+ // ppid 1 too and still dies with that session's group.
1472
+ if (id.pgid !== pid)
1473
+ return fail(`child pid ${pid} is not detached (process group ${id.pgid})`);
1474
+ const record = (next) => {
1475
+ const prior = snapshot();
1476
+ const verified = () => requireOwned(checkOwnedGeneration(repoRoot, tier, prior, deps, undefined, true), tier);
1477
+ verified();
1478
+ // If publication or its read-back fails, the attempted snapshot remains the rollback authority.
1479
+ expected = { ...expected, owner: next };
1480
+ publish(beatOwnerPath(repoRoot, tier), next, guard, verified); // the prior generation, re-verified inside the act
1481
+ return owned().records;
1482
+ };
1483
+ try {
1484
+ record({ ...base, pid, birth: id.birth, command: id.command, pgid: id.pgid });
1485
+ }
1486
+ catch (error) {
1487
+ return fail(`owner publication failed: ${error.message}`);
1488
+ }
1489
+ for (;;) {
1490
+ const live = checkOwnedGeneration(repoRoot, tier, snapshot(), deps, pid, true);
1491
+ if (live.kind !== "OWNED")
1492
+ return fail(live.mutationReason ?? `UNREADABLE — ${live.reason}`);
1493
+ if (exited(child) || live.identity === "DEAD")
1494
+ return fail(`child pid ${pid} died before its beat read back (log ${log})`);
1495
+ // Exit can fall between ps and cwd reads. UNKNOWN proves no identity change: yield so the
1496
+ // exit is observable, then repeat the full generation/records check within the startup budget.
1497
+ if (live.identity === "UNKNOWN") {
1498
+ if (deps.now() >= deadline)
1499
+ return fail(`UNREADABLE — child pid ${pid} identity is unreadable during read-back${why(live)}`);
1500
+ await deps.sleep(Math.min(100, Math.max(0, deadline - deps.now())));
1501
+ continue;
1502
+ }
1503
+ if (!sameProcess(expected.owner, live.identity))
1504
+ return fail(`child pid ${pid} changed identity during read-back`);
1505
+ let records = live.records;
1506
+ if (expected.owner.armEpoch === undefined) {
1507
+ // Bind the epoch from the exact child's independently validated beat, never from whatever arm is
1508
+ // current after an identity read. A same-id replacement cannot become this launch's authority.
1509
+ try {
1510
+ records = captureArm(records);
1511
+ if (expected.armEpoch !== undefined)
1512
+ records = record({ ...expected.owner, armEpoch: expected.armEpoch });
1513
+ }
1514
+ catch (error) {
1515
+ return fail(`owner publication failed: ${error.message}`);
1516
+ }
1517
+ }
1518
+ // the read-back is the checked state itself: this exact child, advancing, over validated records
1519
+ if (advancingBeat(records, expected.owner))
1520
+ break;
1521
+ if (deps.now() >= deadline)
1522
+ return fail(`no beat from pid ${pid} within ${BEAT_STARTUP_CEILING_MS} ms (log ${log})`);
1523
+ await deps.sleep(Math.min(100, Math.max(0, deadline - deps.now())));
1524
+ }
1525
+ // The final barrier, in order: identity, then the owner record and the COMPLETE durable arm re-read after it,
1526
+ // then the records' own schema — and the ceiling LAST, so time spent in these blocking reads counts too.
1527
+ const final = checkOwnedGeneration(repoRoot, tier, snapshot(), deps, pid);
1528
+ if (final.kind !== "OWNED")
1529
+ return fail(final.mutationReason ?? `UNREADABLE — ${final.reason}`);
1530
+ if (exited(child) || final.identity === "DEAD")
1531
+ return fail(`child pid ${pid} died before its beat read back (log ${log})`);
1532
+ if (final.identity === "UNKNOWN")
1533
+ return fail(`UNREADABLE — child pid ${pid} identity is unreadable during read-back${why(final)}`);
1534
+ if (!sameProcess(expected.owner, final.identity))
1535
+ return fail(`child pid ${pid} changed identity during read-back`);
1536
+ const advancing = advancingBeat(final.records, expected.owner);
1537
+ if (deps.now() - startedAt > BEAT_STARTUP_CEILING_MS)
1538
+ return fail(`startup overran ${BEAT_STARTUP_CEILING_MS} ms`);
1539
+ if (!advancing)
1540
+ return fail(`STALE — child pid ${pid}'s beat is not advancing at final read-back`);
1541
+ return `${tier} ARMED as ${seat} — detached pid ${pid}, generation ${generation}; log ${log}`;
1542
+ }
1543
+ /** Wait until `gone()` holds or the deadline passes; true when it held. */
1544
+ async function waitFor(gone, ms, deps) {
1545
+ const deadline = deps.now() + ms;
1546
+ while (!gone()) {
1547
+ if (deps.now() >= deadline)
1548
+ return false;
1549
+ await deps.sleep(50);
1550
+ }
1551
+ return true;
1552
+ }
1553
+ /**
1554
+ * Undo only what this generation did; anything it cannot prove stays in place and the refusal says so.
1555
+ * EVERY signal is preceded by a recheck of both the owner generation and the child's live identity against
1556
+ * the identity proven at launch: a replaced generation or an unproven or changed identity is never signalled.
1557
+ * Every signal, stand-down commit and removal is then one claimed act under the launch claim (token = this
1558
+ * generation) with the owner generation re-verified inside it (the stand-down staged: supervised): a rollback that
1559
+ * lost its claim, or whose generation was replaced, signals, writes and removes nothing further and retains the owner.
1560
+ */
1561
+ async function rollback(repoRoot, tier, expected, child, proven, deps, why) {
1562
+ const failed = (what) => refuse(`${tier} start refused: ${why}; ${what}`);
1563
+ const guard = (mutate, check) => {
1564
+ try {
1565
+ return claimedAct(repoRoot, tier, expected.owner.generation, "start", check, mutate);
1566
+ }
1567
+ catch (error) {
1568
+ if (error instanceof BeatRefusal && !error.message.startsWith(`${tier} start refused`))
1569
+ failed(`${error.message}; owner record retained`);
1570
+ throw error;
1571
+ }
1572
+ };
1573
+ const ownerRead = () => readBeatOwner(repoRoot, tier);
1574
+ const check = (phase, pid) => {
1575
+ const checked = checkOwnedGeneration(repoRoot, tier, expected, deps, pid);
1576
+ if (checked.kind !== "OWNED")
1577
+ failed(`${phase} stopped — ${checked.mutationReason ?? `UNREADABLE — ${checked.reason}`}; ${pid ? `pid ${pid} not signalled` : "left in place"}`);
1578
+ return checked;
1579
+ };
1580
+ const pid = child?.pid;
1581
+ if (child && pid) {
1582
+ const unproven = `pid ${pid} not signalled — its identity is not proven to be this generation's child; owner record retained`;
1583
+ const gone = () => {
1584
+ const checked = check("rollback", pid);
1585
+ if (checked.identity === "DEAD")
1586
+ return true;
1587
+ // A process exiting can disappear between its ps and cwd reads or show teardown argv. UNKNOWN
1588
+ // proves neither death nor a changed identity: wait for checked death, and still require a
1589
+ // matching live identity at the separate barrier before any further signal.
1590
+ if (proven && checked.identity !== "UNKNOWN" && !sameIdentity(proven, checked.identity))
1591
+ failed(unproven);
1592
+ return false;
1593
+ };
1594
+ for (const signal of ["SIGTERM", "SIGKILL"]) {
1595
+ if (gone())
1596
+ break;
1597
+ // identity FIRST, ownership re-read after it: a generation replaced during the identity read is not signalled
1598
+ let checked = check("rollback", pid);
1599
+ // Before a signal, UNKNOWN is decided only by a later complete read or confirmed death — never guessed.
1600
+ if (proven && checked.identity === "UNKNOWN") {
1601
+ await waitFor(() => check("rollback", pid).identity !== "UNKNOWN", 3_000, deps);
1602
+ checked = check("rollback", pid);
1603
+ }
1604
+ if (checked.identity === "DEAD")
1605
+ break; // confirmed death between the probe and the proof needs no signal
1606
+ if (!proven || !sameIdentity(proven, checked.identity))
1607
+ failed(unproven);
1608
+ guard(() => { try {
1609
+ deps.kill(pid, signal);
1610
+ }
1611
+ catch { /* the wait below decides */ } }, () => check("rollback"));
1612
+ await waitFor(gone, 3_000, deps);
1613
+ }
1614
+ if (!gone())
1615
+ failed(`pid ${pid} did not exit — owner record retained`);
1616
+ }
1617
+ if (ownerRead() === "NONE")
1618
+ failed("no owner record was published; nothing to roll back");
1619
+ try {
1620
+ const checked = check("cleanup");
1621
+ if (checked.records.arm?.armId === expected.owner.armId) {
1622
+ supervised(repoRoot, tier, barred(guard, () => check("cleanup")), (root) => publishStandDown(root, tier, expected.owner.seat));
1623
+ check("cleanup");
1624
+ if (checkedRecords(repoRoot, tier, deps).kind === "UNKNOWN" || supervisionStatus(repoRoot, tier, deps.now()).state !== "DISARMED")
1625
+ failed("rollback stand-down read-back failed; owner retained");
1626
+ }
1627
+ removeOwner(repoRoot, tier, expected, deps, guard);
1628
+ }
1629
+ catch (error) {
1630
+ if (error instanceof BeatRefusal)
1631
+ throw error;
1632
+ failed(`rollback cleanup failed: ${error.message}`);
1633
+ }
1634
+ if (ownerRead() !== "NONE")
1635
+ failed("owner record could not be removed");
1636
+ return failed("rolled back this generation only");
1637
+ }
1638
+ /**
1639
+ * The ONE recovery of a dead generation — OWN-DEAD (its recorded pid confirmed gone) or ABANDONED (an unbound owner no
1640
+ * launch holds, no recorded process alive) — shared by stop, start and every legacy write, under the caller's claim:
1641
+ * stand its arm down unless already DISARMED (`standDown`: stop and start; a legacy write leaves the arm to the write it
1642
+ * was asked for — a one-shot records its beat on it, --stand-down stands it down, --new-arm/--loop replace it), then
1643
+ * remove its owner record, each one claimed act (`held`) with this exact generation re-verified inside it. Nothing is
1644
+ * signalled: no live process of that generation is on record (an unrecorded child of an abandoned launch refuses at its
1645
+ * next check and exits). Returns the notice naming it.
1646
+ */
1647
+ function retireDead(repoRoot, tier, checked, deps, held, standDown = true) {
1648
+ const { owner } = checked;
1649
+ const snapshot = expectedGeneration(owner);
1650
+ const unbound = owner.armEpoch === undefined;
1651
+ const owned = () => { requireOwned(checkOwnedGeneration(repoRoot, tier, snapshot, deps, undefined, unbound), tier); };
1652
+ owned();
1653
+ const state = supervisionStatus(repoRoot, tier, deps.now()).state;
1654
+ if (state === "UNREADABLE")
1655
+ refuse(`${tier} UNREADABLE — the tier's supervision records became unreadable; nothing removed`);
1656
+ if (standDown && state !== "DISARMED")
1657
+ supervised(repoRoot, tier, barred(held, owned), (root) => publishStandDown(root, tier, owner.seat));
1658
+ owned();
1659
+ removeOwner(repoRoot, tier, snapshot, deps, held, unbound);
1660
+ return `${tier} recovered dead generation ${owner.generation} (${checked.kind === "OWN-DEAD" ? `recorded pid ${owner.pid} is gone` : checked.reason}): ` +
1661
+ `${standDown ? "stood down and removed its owner record" : "removed its owner record, its arm left to this write"}, nothing signalled`;
1662
+ }
1663
+ /**
1664
+ * stop: retire only the recorded generation, signalling its exact pid, then read back DISARMED. A loop tick
1665
+ * holding the claim is waited out under BEAT_CLAIM_WAIT; a claim whose recorded pids are all confirmed dead is
1666
+ * reclaimed. Every stand-down commit (supervised), signal and removal is one claimed act under stop's claim (`held`)
1667
+ * with the owner generation it acts on re-verified inside it.
1668
+ */
1669
+ export async function stopBeat(repoRoot, tier, seat, deps) {
1670
+ const token = randomUUID();
1671
+ const notices = [await acquireWaiting(repoRoot, tier, token, { sleep: deps.sleep }, deps)].filter((n) => n !== undefined);
1672
+ const guard = (mutate, check) => claimedAct(repoRoot, tier, token, "stop", check, mutate);
1673
+ return noticed(notices, () => holding(repoRoot, tier, token, () => stopClaimed(repoRoot, tier, seat, guard, deps), (n) => notices.push(n)));
1674
+ }
1675
+ async function stopClaimed(repoRoot, tier, seat, held, deps) {
1676
+ let result;
1677
+ const checked = inspectBeat(repoRoot, tier, seat, deps, true);
1678
+ switch (checked.kind) {
1679
+ case "EMPTY":
1680
+ supervised(repoRoot, tier, barred(held, () => {
1681
+ if (readBeatOwner(repoRoot, tier) !== "NONE")
1682
+ refuse(`${tier} superseded — an owner record appeared during stop; nothing further written`);
1683
+ }), (root) => publishStandDown(root, tier, seat));
1684
+ result = readBackDisarmed(repoRoot, tier, deps, "published DISARMED; nothing signalled");
1685
+ break;
1686
+ case "DISARMED":
1687
+ result = `${tier} DISARMED — already stood down; checked no-op`;
1688
+ break;
1689
+ case "OWN-LIVE":
1690
+ result = await retire(repoRoot, tier, checked.owner, held, deps);
1691
+ break;
1692
+ case "OWN-DEAD":
1693
+ case "ABANDONED": {
1694
+ const notice = retireDead(repoRoot, tier, checked, deps, held);
1695
+ result = `${notice}\n${readBackDisarmed(repoRoot, tier, deps, checked.kind === "OWN-DEAD" ? `pid ${checked.owner.pid} was already gone; cleaned up, nothing signalled`
1696
+ : `generation ${checked.owner.generation}'s abandoned launch cleaned up, nothing signalled`)}`;
1697
+ break;
1698
+ }
1699
+ case "FOREIGN":
1700
+ refuse(`${tier} MISMATCH — ${checked.reason}; nothing signalled or removed${migrating(checked)}`);
1701
+ break;
1702
+ case "PID-REUSED":
1703
+ refuse(`${tier} MISMATCH — recorded pid ${checked.owner.pid} now belongs to another process; nothing signalled or removed`);
1704
+ break;
1705
+ case "UNKNOWN":
1706
+ refuse(`${tier} UNREADABLE — ${checked.reason}; nothing signalled or removed`);
1707
+ break;
1708
+ }
1709
+ return result;
1710
+ }
1711
+ /**
1712
+ * The generation barrier before every publication, signal and removal. Identity first (while the process is
1713
+ * meant to be live), then the owner record, then the durable arm: a replacement published while the identity
1714
+ * was being read still refuses, and so does an arm that is no longer this generation's (or is gone).
1715
+ */
1716
+ function recheckOwned(repoRoot, tier, owner, deps, live = true) {
1717
+ const checked = requireOwned(checkOwnedGeneration(repoRoot, tier, expectedGeneration(owner), deps, live ? owner.pid : undefined), tier);
1718
+ if (live && checked.identity === "UNKNOWN")
1719
+ refuse(`${tier} UNREADABLE — pid ${owner.pid} identity is unreadable${why(checked)}; nothing signalled`);
1720
+ if (live && !sameProcess(owner, checked.identity))
1721
+ refuse(`${tier} pid ${owner.pid} no longer matches its recorded identity — nothing further signalled`);
1722
+ }
1723
+ async function retire(repoRoot, tier, owner, held, deps) {
1724
+ recheckOwned(repoRoot, tier, owner, deps);
1725
+ // Each act below re-verifies this generation (owner, durable arm, records) inside it.
1726
+ const owned = barred(held, () => recheckOwned(repoRoot, tier, owner, deps, false));
1727
+ supervised(repoRoot, tier, owned, (root) => publishStandDown(root, tier, owner.seat));
1728
+ if (!Number.isInteger(owner.pid) || owner.pid <= 0)
1729
+ refuse(`${tier} recorded pid ${owner.pid} is not a positive pid`);
1730
+ const read = () => requireOwned(checkOwnedGeneration(repoRoot, tier, expectedGeneration(owner), deps, owner.pid), tier);
1731
+ const gone = () => {
1732
+ const { identity } = read();
1733
+ if (identity !== "DEAD" && identity !== "UNKNOWN" && !sameProcess(owner, identity))
1734
+ refuse(`${tier} MISMATCH — pid ${owner.pid} changed identity while stopping; owner record retained`);
1735
+ return identity === "DEAD";
1736
+ };
1737
+ // The live recheck after stand-down: the loop may already be exiting on it. Confirmed death needs no signal;
1738
+ // UNKNOWN (teardown argv, unreadable cwd) waits for a complete read or confirmed death, never a guess.
1739
+ // The await yields: only a fresh complete barrier read after it, with no await before the signal, authorises it.
1740
+ let last = read();
1741
+ if (last.identity === "UNKNOWN") {
1742
+ await waitFor(() => read().identity !== "UNKNOWN", SUPERVISION_BEAT_MS, deps);
1743
+ last = read();
1744
+ }
1745
+ if (last.identity === "UNKNOWN")
1746
+ refuse(`${tier} UNREADABLE — pid ${owner.pid} identity is unreadable after stand-down${why(last)}; nothing signalled, owner record retained`);
1747
+ if (last.identity !== "DEAD") {
1748
+ if (!sameProcess(owner, last.identity))
1749
+ refuse(`${tier} pid ${owner.pid} no longer matches its recorded identity — nothing further signalled`);
1750
+ let failure;
1751
+ owned(() => { try {
1752
+ deps.kill(owner.pid, "SIGTERM");
1753
+ }
1754
+ catch (error) {
1755
+ failure = error;
1756
+ } });
1757
+ if (failure && !gone())
1758
+ refuse(`${tier} signal to pid ${owner.pid} failed: ${failure.message}; owner record retained`);
1759
+ if (!await waitFor(gone, SUPERVISION_BEAT_MS, deps))
1760
+ refuse(`${tier} pid ${owner.pid} did not exit after SIGTERM; owner record retained`);
1761
+ }
1762
+ recheckOwned(repoRoot, tier, owner, deps, false);
1763
+ removeOwner(repoRoot, tier, expectedGeneration(owner), deps, held);
1764
+ return readBackDisarmed(repoRoot, tier, deps, last.identity === "DEAD"
1765
+ ? `pid ${owner.pid} exited on its stand-down (generation ${owner.generation}); nothing signalled`
1766
+ : `retired detached pid ${owner.pid} (generation ${owner.generation})`);
1767
+ }
1768
+ /** Remove the owner record as one claimed act: the generation barrier inside it, then the final claim check, then the removal. */
1769
+ function removeOwner(repoRoot, tier, expected, deps, held, unbound = false) {
1770
+ held(() => rmSync(beatOwnerPath(repoRoot, tier)), () => requireOwned(checkOwnedGeneration(repoRoot, tier, expected, deps, undefined, unbound), tier));
1771
+ if (readBeatOwner(repoRoot, tier) !== "NONE")
1772
+ refuse(`${tier} owner record could not be removed — cleanup unacknowledged`);
1773
+ }
1774
+ function readBackDisarmed(repoRoot, tier, deps, what) {
1775
+ const checked = checkedRecords(repoRoot, tier, deps);
1776
+ if (checked.kind === "UNKNOWN")
1777
+ refuse(`${tier} stop read back UNREADABLE — ${checked.reason}`);
1778
+ const state = checked.state.state;
1779
+ if (state !== "DISARMED" || readBeatOwner(repoRoot, tier) !== "NONE")
1780
+ refuse(`${tier} stop read back ${state}, not DISARMED`);
1781
+ return `${tier} DISARMED — ${what}`;
1782
+ }