tickmarkr 2.6.7 → 2.6.9

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 (43) hide show
  1. package/dist/adapters/catalog-remote.js +5 -2
  2. package/dist/adapters/codex.d.ts +1 -0
  3. package/dist/adapters/codex.js +18 -10
  4. package/dist/adapters/model-lints.js +21 -11
  5. package/dist/adapters/model-windows.js +0 -1
  6. package/dist/adapters/registry.js +7 -3
  7. package/dist/adapters/types.d.ts +2 -0
  8. package/dist/cli/commands/beat.d.ts +3 -3
  9. package/dist/cli/commands/beat.js +39 -19
  10. package/dist/cli/commands/doctor.js +3 -0
  11. package/dist/cli/commands/fleet.js +2 -0
  12. package/dist/cli/commands/plan.js +16 -18
  13. package/dist/cli/commands/verify.js +4 -2
  14. package/dist/cli/help.js +5 -2
  15. package/dist/config/config.js +6 -5
  16. package/dist/drivers/herdr.js +2 -2
  17. package/dist/drivers/orca.d.ts +12 -4
  18. package/dist/drivers/orca.js +200 -39
  19. package/dist/gates/evidence.js +3 -3
  20. package/dist/gates/review.d.ts +1 -1
  21. package/dist/gates/review.js +62 -13
  22. package/dist/gates/run-gates.d.ts +3 -0
  23. package/dist/gates/run-gates.js +115 -20
  24. package/dist/gates/test-manifest.d.ts +36 -2
  25. package/dist/gates/test-manifest.js +55 -10
  26. package/dist/run/beat-lifecycle.d.ts +109 -24
  27. package/dist/run/beat-lifecycle.js +1173 -216
  28. package/dist/run/daemon.d.ts +4 -0
  29. package/dist/run/daemon.js +111 -33
  30. package/dist/run/git-trust.d.ts +78 -3
  31. package/dist/run/git-trust.js +175 -19
  32. package/dist/run/git.d.ts +24 -3
  33. package/dist/run/git.js +140 -21
  34. package/dist/run/merge.js +2 -2
  35. package/dist/run/verification-job.d.ts +2 -0
  36. package/dist/run/verification-job.js +32 -10
  37. package/dist/tui/ink/fleet-app.d.ts +7 -2
  38. package/dist/tui/ink/fleet-app.js +81 -23
  39. package/package.json +4 -1
  40. package/skills/tickmarkr-auto/SKILL.md +58 -14
  41. package/skills/tickmarkr-loop/SKILL.md +65 -14
  42. package/skills/tickmarkr-overseer/SKILL.md +97 -28
  43. package/skills/tickmarkr-overseer/scripts/watch-journal.sh +9 -7
@@ -1,40 +1,100 @@
1
1
  import { spawn as nodeSpawn, spawnSync } from "node:child_process";
2
- import { randomUUID } from "node:crypto";
3
- import { closeSync, linkSync, mkdirSync, openSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
4
- import { dirname, join } from "node:path";
5
- import { SUPERVISION_BEAT_MS, isSeatTier, publishStandDown, readSupervisionArm, supervisionArmPath, supervisionBeatPath, supervisionStatus, } from "./supervision.js";
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";
6
7
  // H + D-1001: the detached beat is OWNED by the product, not by a shell recipe. One claim file per
7
8
  // (repository, tier) serializes start, stop and the legacy arm-mutating writers; one owner record names
8
9
  // the exact generation (seat, armId, pid, process birth/argv/cwd) that start launched. Every signal and
9
10
  // removal rechecks that generation first and reads the result back afterwards, so an older invocation can
10
11
  // never retire a replacement, and a pid that now belongs to another process is a mismatch, never a target.
11
- // Status takes no claim and writes nothing. Uncertain state is RETAINED and refused (nonzero), never
12
- // guessed: a stale beat is not proof of death and a foreign or stale claim is never stolen.
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).
13
52
  /** start's whole budget — launch, read-back and any overrun — before it must refuse and roll back. */
14
53
  export const BEAT_STARTUP_CEILING_MS = 60_000;
15
54
  /** The generation a started child carries; it lets the child act under its launcher's claim only. */
16
55
  export const BEAT_GENERATION_ENV = "TICKMARKR_BEAT_GENERATION";
17
- export function defaultBeatDeps() {
56
+ export function defaultBeatDeps(host = {}) {
18
57
  return {
19
58
  cli: [process.execPath, ...process.execArgv, process.argv[1] ?? ""],
20
59
  spawn: nodeSpawn,
21
60
  kill: (pid, signal) => { process.kill(pid, signal); },
22
- identity: readProcessIdentity,
61
+ identity: (pid) => readProcessIdentity(pid, host),
23
62
  openLog: (path) => openSync(path, "a"),
24
63
  now: Date.now,
25
64
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
26
65
  env: process.env,
27
66
  };
28
67
  }
29
- /** A named, nonzero refusal: dispatch renders the message and exits 1. */
68
+ /** A named, nonzero refusal: dispatch renders the message and exits 1. `notice` is a reclaim it must not lose. */
30
69
  export class BeatRefusal extends Error {
70
+ notice;
71
+ constructor(message, notice) {
72
+ super(message);
73
+ this.notice = notice;
74
+ }
31
75
  }
32
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
+ }
33
89
  /**
34
90
  * The live process behind a pid: birth (lstart), full argv, cwd and process group. A missing process is
35
- * DEAD only when the kernel says so (ESRCH or a zombie); anything unreadable is UNKNOWN, never DEAD.
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.
36
94
  */
37
- export function readProcessIdentity(pid) {
95
+ export function readProcessIdentity(pid, host = {}) {
96
+ const platform = host.platform ?? process.platform;
97
+ const lsofBin = host.lsof ?? "lsof";
38
98
  try {
39
99
  process.kill(pid, 0);
40
100
  }
@@ -45,30 +105,33 @@ export function readProcessIdentity(pid) {
45
105
  encoding: "utf8", env: { ...process.env, LC_ALL: "C" },
46
106
  });
47
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 ?? "");
48
- if (ps.status !== 0 || !row) {
49
- try {
50
- process.kill(pid, 0);
51
- return "UNKNOWN";
52
- }
53
- catch (error) {
54
- return error.code === "ESRCH" ? "DEAD" : "UNKNOWN";
55
- }
56
- }
108
+ if (ps.status !== 0 || !row)
109
+ return confirmedDead(pid) ? "DEAD" : "UNKNOWN";
57
110
  if (row[2].startsWith("Z"))
58
111
  return "DEAD";
59
112
  let cwd;
60
- try {
61
- if (process.platform === "linux")
113
+ let why;
114
+ if (platform === "linux") {
115
+ try {
62
116
  cwd = readlinkSync(`/proc/${pid}/cwd`);
63
- else {
64
- const lsof = spawnSync("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"], { encoding: "utf8" });
65
- cwd = lsof.stdout?.split("\n").find((line) => line.startsWith("n"))?.slice(1);
117
+ }
118
+ catch {
119
+ cwd = undefined;
66
120
  }
67
121
  }
68
- catch {
69
- cwd = undefined;
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
+ }
70
129
  }
71
- return cwd ? { pgid: Number(row[1]), birth: row[3], command: row[4], cwd } : "UNKNOWN";
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";
72
135
  }
73
136
  const supervisionDirOf = (repoRoot, tier) => dirname(supervisionBeatPath(repoRoot, tier));
74
137
  export const beatClaimPath = (repoRoot, tier) => join(supervisionDirOf(repoRoot, tier), `${tier}.claim`);
@@ -84,6 +147,9 @@ function readJson(path) {
84
147
  const code = error.code;
85
148
  return code === "ENOENT" || code === "ENOTDIR" ? "NONE" : "UNREADABLE";
86
149
  }
150
+ return parseJson(bytes);
151
+ }
152
+ function parseJson(bytes) {
87
153
  try {
88
154
  const value = JSON.parse(bytes);
89
155
  return value && typeof value === "object" ? value : "UNREADABLE";
@@ -114,123 +180,801 @@ export function readBeatOwner(repoRoot, tier) {
114
180
  return "UNREADABLE";
115
181
  return rec;
116
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);
117
190
  function readClaim(repoRoot, tier) {
118
- const rec = readJson(beatClaimPath(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) {
119
195
  if (typeof rec !== "object")
120
196
  return rec;
121
- return text(rec.token) && Number.isInteger(rec.pid) ? { token: rec.token, pid: rec.pid } : "UNREADABLE";
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 }) };
122
200
  }
123
- function publish(path, value) {
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) {
124
207
  mkdirSync(dirname(path), { recursive: true });
125
208
  const tmp = `${path}.${randomUUID()}.tmp`;
126
209
  try {
127
210
  writeFileSync(tmp, JSON.stringify(value) + "\n");
128
- renameSync(tmp, path);
211
+ guard(() => renameSync(tmp, path), check);
129
212
  }
130
213
  finally {
131
214
  rmSync(tmp, { force: true });
132
215
  }
133
216
  }
134
- /** Atomic first-writer-wins claim; a held claim is BUSY and is never taken over. */
135
- export function acquireBeatClaim(repoRoot, tier, token) {
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) {
136
424
  const path = beatClaimPath(repoRoot, tier);
137
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 = "";
138
429
  try {
139
430
  mkdirSync(dirname(path), { recursive: true });
140
- writeFileSync(tmp, JSON.stringify({ tier, token, pid: process.pid, claimedAt: new Date().toISOString() }) + "\n");
431
+ own = JSON.stringify({ tier, token, pid: process.pid, birth, claimedAt: new Date().toISOString() }) + "\n";
432
+ writeFileSync(tmp, own);
141
433
  }
142
434
  catch (error) {
435
+ inUse.delete(key);
143
436
  try {
144
437
  rmSync(tmp, { force: true });
145
438
  }
146
439
  catch { /* the path that failed holds nothing to remove */ }
147
440
  refuse(`${tier} claim failed at ${path}: ${error.message}`);
148
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); };
149
456
  try {
150
- linkSync(tmp, path);
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)");
151
514
  }
152
515
  catch (error) {
153
- if (error.code !== "EEXIST")
154
- refuse(`${tier} claim failed at ${path}: ${error.message}`);
155
- const holder = readClaim(repoRoot, tier);
156
- refuse(`${tier} BUSY — ${typeof holder === "object" ? `pid ${holder.pid} holds` : "an unreadable claim occupies"} ${path}; ` +
157
- "retry after it finishes (a claim is never taken over automatically)");
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;
158
519
  }
159
520
  finally {
160
521
  rmSync(tmp, { force: true });
161
522
  }
162
523
  }
163
- export function releaseBeatClaim(repoRoot, tier, token) {
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") {
164
735
  const claim = readClaim(repoRoot, tier);
165
736
  if (typeof claim !== "object" || claim.token !== token)
166
- refuse(`${tier} claim changed before release — left in place`);
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);
167
747
  try {
168
- rmSync(beatClaimPath(repoRoot, tier));
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
+ }
169
753
  }
170
754
  catch (error) {
171
- refuse(`${tier} claim release failed: ${error.message}`);
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 });
172
840
  }
173
841
  }
174
842
  /**
175
843
  * Run one legacy write (one-shot tick, new arm, EVERY loop tick, stand-down) under the tier claim. A launched
176
- * child acts under its launcher's claim while that claim is still held for its generation; afterwards it takes
177
- * the tier claim itself like every other writer. `act` receives the recheck it must call immediately before it
178
- * writes: the claim still names this holder and the tier's ownership still admits this writer. A claim held by
179
- * anyone else refuses with nothing written — `waits` only lets a running loop's tick outlast a brief holder.
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).
180
857
  */
181
- export async function withLegacyClaim(repoRoot, tier, writer, act, waits = { tries: 1, sleep: async () => { } }) {
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
182
860
  const { generation, settled = false } = writer;
183
861
  const held = (token) => {
184
862
  // The exact owner this claim's previous recheck validated: the generation every later recheck is held to.
185
863
  let checked;
186
- return (arm = writer.arm, created) => {
187
- const claim = readClaim(repoRoot, tier);
188
- if (typeof claim !== "object" || claim.token !== token)
189
- refuse(`${tier} claim is no longer held by this writer — nothing written`);
190
- checked = assertLegacyOwnership(repoRoot, tier, writer, arm, created, checked);
191
- };
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)];
192
872
  };
193
873
  if (generation !== undefined) {
194
- const claim = readClaim(repoRoot, tier);
195
- if (typeof claim === "object" && claim.token === generation) {
196
- const recheck = held(generation);
874
+ if (underLaunchClaim(repoRoot, tier, generation, writer.deps)) {
875
+ const [recheck, guard] = held(generation);
197
876
  recheck();
198
- return act(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
+ });
199
888
  }
200
889
  if (!settled)
201
- refuse(`${tier} superseded — the launch claim for generation ${generation} is gone`);
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);
202
892
  }
203
893
  const token = randomUUID();
204
- for (let attempt = 1;; attempt++) {
205
- try {
206
- acquireBeatClaim(repoRoot, tier, token);
207
- break;
208
- }
209
- catch (error) {
210
- if (attempt >= waits.tries || !(error instanceof BeatRefusal) || !error.message.includes(" BUSY — "))
211
- throw error;
212
- await waits.sleep(250);
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
+ }
213
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);
214
925
  }
215
- let result;
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";
216
943
  try {
217
- const recheck = held(token);
218
- recheck();
219
- result = await act(recheck);
944
+ launcher = writer.deps.identity(claim.pid);
220
945
  }
221
- finally {
222
- releaseBeatClaim(repoRoot, tier, token);
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`);
223
954
  }
224
- return result;
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`); });
225
969
  }
226
970
  /** The ownership a legacy writer needs before each mutation: its own COMPLETE launched generation, or no owner at all. */
227
- export function assertLegacyOwnership(repoRoot, tier, writer, arm, created = false, held) {
971
+ export function assertLegacyOwnership(repoRoot, tier, writer, arm, created = false, held, claimed = unclaimed) {
228
972
  if (writer.generation !== undefined)
229
- return assertOwnedGeneration(repoRoot, tier, { ...writer, generation: writer.generation }, arm, created, held);
973
+ return assertOwnedGeneration(repoRoot, tier, { ...writer, generation: writer.generation }, arm, created, held, claimed);
230
974
  const owner = readBeatOwner(repoRoot, tier);
231
975
  if (owner !== "NONE") {
232
976
  refuse(`${tier} is owned by a detached beat (${typeof owner === "object" ? `seat ${owner.seat}, generation ${owner.generation}` : "unreadable owner record"})` +
233
- ` — run \`tickmarkr beat stop ${tier} --seat <seat>\` first`);
977
+ ` — run \`tickmarkr beat stop ${tier} ${typeof owner === "object" ? seatFlag(owner.seat) : "--seat <seat>"}\` first`);
234
978
  }
235
979
  return undefined;
236
980
  }
@@ -265,7 +1009,7 @@ function launcherBound(before, after, pid, arm) {
265
1009
  * Once `settled` (past its first beat) the record must name its pid: a launch that refused without proving it
266
1010
  * could signal its child leaves no pid behind, and that child retires itself.
267
1011
  */
268
- export function assertOwnedGeneration(repoRoot, tier, writer, arm = writer.arm, created = false, held, pid = process.pid) {
1012
+ export function assertOwnedGeneration(repoRoot, tier, writer, arm = writer.arm, created = false, held, claimed = unclaimed, pid = process.pid) {
269
1013
  const { generation, seat, settled = false, deps } = writer;
270
1014
  const otherArm = (owner) => arm !== undefined && (arm.armId !== owner.armId || (owner.armEpoch !== undefined && arm.armEpoch !== owner.armEpoch));
271
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`;
@@ -291,17 +1035,19 @@ export function assertOwnedGeneration(repoRoot, tier, writer, arm = writer.arm,
291
1035
  if (arm && created && owner.armEpoch !== undefined && otherArm(owner)) {
292
1036
  if (held === undefined)
293
1037
  refuse(`${tier} superseded — generation ${generation} re-armed without its complete check; owner left in place`);
294
- const records = checkedRecords(repoRoot, tier, deps);
295
- if (records.kind === "UNKNOWN")
296
- refuse(`${tier} UNREADABLE — ${records.reason}; owner left in place`);
297
- if (records.arm?.armId !== arm.armId || records.arm.armEpoch !== arm.armEpoch) {
298
- 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`);
299
- }
300
- const current = readBeatOwner(repoRoot, tier);
301
- if (!sameOwner(owner, current))
302
- changed(current);
303
1038
  const next = { ...owner, armId: arm.armId, armEpoch: arm.armEpoch };
304
- publish(beatOwnerPath(repoRoot, tier), next);
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
+ });
305
1051
  requireOwned(checkOwnedGeneration(repoRoot, tier, expectedGeneration(next), deps, undefined, true), tier);
306
1052
  return next;
307
1053
  }
@@ -319,10 +1065,11 @@ const sameIdentity = (a, id) => typeof id === "object" && id.birth === a.birth &
319
1065
  const sameProcess = (owner, id) => sameIdentity(owner, id);
320
1066
  /**
321
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
322
- * `[comm]` beside the same birth, group and cwd. Only a wait for death may read that as "not gone yet": it never authorises a
323
- * signal or a removal (both still need a full identity or checked death); any other difference stays a mismatch.
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).
324
1071
  */
325
- const exitingAfterSignal = (a, id) => typeof id === "object" && id.birth === a.birth && id.pgid === a.pgid && id.cwd === a.cwd && /^\[.+\]$/.test(id.command);
1072
+ const tearingDown = (a, id) => /^\[.+\]$/.test(id.command) && (a.birth === undefined || (id.birth === a.birth && id.pgid === a.pgid && id.cwd === a.cwd));
326
1073
  /** The durable arm, or UNREADABLE for a torn one — never mistaken for no arm. */
327
1074
  function armOf(repoRoot, tier) {
328
1075
  try {
@@ -367,13 +1114,16 @@ const expectedGeneration = (owner) => ({ owner, armEpoch: owner.armEpoch });
367
1114
  */
368
1115
  function checkOwnedGeneration(repoRoot, tier, snapshot, deps, pid, launching = false) {
369
1116
  const expected = snapshot.owner;
370
- let identity = "UNKNOWN";
1117
+ let read = "UNKNOWN";
371
1118
  if (pid !== undefined) {
372
1119
  try {
373
- identity = deps.identity(pid);
1120
+ read = deps.identity(pid);
374
1121
  }
375
1122
  catch { /* an inspection failure proves neither life nor death */ }
376
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;
377
1127
  const records = checkedRecords(repoRoot, tier, deps);
378
1128
  if (records.kind === "UNKNOWN")
379
1129
  return records;
@@ -396,8 +1146,9 @@ function checkOwnedGeneration(repoRoot, tier, snapshot, deps, pid, launching = f
396
1146
  return {
397
1147
  kind: "UNKNOWN", reason: `generation ${expected.generation} never proved its arm epoch; the current arm cannot authorize cleanup`,
398
1148
  };
399
- return { kind: "OWNED", records, identity };
1149
+ return { kind: "OWNED", records, identity, ...(unreadable !== undefined ? { unreadable } : {}) };
400
1150
  }
1151
+ const why = (check) => check.unreadable ? ` (${check.unreadable})` : "";
401
1152
  function requireOwned(check, tier) {
402
1153
  if (check.kind !== "OWNED")
403
1154
  refuse(`${tier} ${check.mutationReason ?? `${check.kind === "UNKNOWN" ? "UNREADABLE" : "MISMATCH"} — ${check.reason}; left in place`}`);
@@ -416,8 +1167,13 @@ function advancingBeat(records, owner) {
416
1167
  * The checked state of one tier, read-only: no claim, no write, no signal. Every authoritative record —
417
1168
  * owner, marker, beat and durable arm — is validated before any outcome is selected, and ownership binds to
418
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.
419
1174
  */
420
- export function inspectBeat(repoRoot, tier, seat, deps) {
1175
+ export function inspectBeat(repoRoot, tier, seat, deps, held = false) {
1176
+ const unclaimedBefore = held || readClaim(repoRoot, tier) === "NONE";
421
1177
  const owner = readBeatOwner(repoRoot, tier);
422
1178
  if (owner === "UNREADABLE")
423
1179
  return { kind: "UNKNOWN", reason: `malformed owner record ${beatOwnerPath(repoRoot, tier)}` };
@@ -430,37 +1186,108 @@ export function inspectBeat(repoRoot, tier, seat, deps) {
430
1186
  return { kind: "EMPTY" };
431
1187
  if (tierState.state === "DISARMED")
432
1188
  return { kind: "DISARMED", ...(tierState.seat ? { seat: tierState.seat } : {}) };
433
- return { kind: "FOREIGN", reason: `${tierState.state} by an unowned writer${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
+ };
434
1194
  }
435
1195
  const root = realpathSync(repoRoot);
436
1196
  if (owner.cwd !== root)
437
1197
  return { kind: "FOREIGN", reason: `owner record names repository ${owner.cwd}, not ${root}` };
438
1198
  if (seat !== undefined && owner.seat !== seat)
439
1199
  return { kind: "FOREIGN", reason: `owned by seat ${owner.seat}, not ${seat}` };
440
- const checked = checkOwnedGeneration(repoRoot, tier, expectedGeneration(owner), deps, owner.pid);
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);
441
1203
  if (checked.kind !== "OWNED")
442
1204
  return checked;
443
1205
  const id = checked.identity;
444
- if (owner.pid === undefined)
445
- return { kind: "UNKNOWN", reason: `generation ${owner.generation} recorded no process (launch in progress or abandoned)` };
446
- if (owner.armEpoch === undefined)
447
- return { kind: "UNKNOWN", reason: `generation ${owner.generation} never bound its arm epoch (launch in progress or abandoned)` };
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
+ }
448
1214
  const owned = owner;
449
1215
  if (id === "DEAD")
450
1216
  return { kind: "OWN-DEAD", owner: owned };
451
1217
  if (id === "UNKNOWN")
452
- return { kind: "UNKNOWN", reason: `identity of recorded pid ${owned.pid} is unreadable` };
1218
+ return { kind: "UNKNOWN", reason: `identity of recorded pid ${owned.pid} is unreadable${checked.unreadable ? ` (${checked.unreadable})` : ""}` };
453
1219
  if (!sameProcess(owned, id))
454
1220
  return { kind: "PID-REUSED", owner: owned };
455
1221
  // Advancing means THIS child wrote the fresh beat — tier, seat, arm and its own live pid. A one-shot record
456
1222
  // (exitedWriterPid) or any other writer reusing the arm id is not the owned child beating.
457
1223
  return { kind: "OWN-LIVE", owner: owned, advancing: advancingBeat(checked.records, owned) };
458
1224
  }
459
- /** Status: the recorded liveness, rendered without writing; healthy states exit 0, the rest nonzero. */
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
+ */
460
1258
  export function beatStatus(repoRoot, tier, seat, deps) {
461
1259
  const checked = inspectBeat(repoRoot, tier, seat, deps);
462
1260
  const claim = readClaim(repoRoot, tier);
463
- const busy = claim === "NONE" ? "" : ` · BUSY (${typeof claim === "object" ? `claim held by pid ${claim.pid}` : "unreadable claim"})`;
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)` : "";
464
1291
  const row = (() => {
465
1292
  switch (checked.kind) {
466
1293
  case "EMPTY": return ["ABSENT — no beat recorded", 0];
@@ -468,48 +1295,101 @@ export function beatStatus(repoRoot, tier, seat, deps) {
468
1295
  case "OWN-LIVE": return checked.advancing
469
1296
  ? [`ARMED (${checked.owner.seat}) — detached pid ${checked.owner.pid}, generation ${checked.owner.generation}`, 0]
470
1297
  : [`STALE (${checked.owner.seat}) — detached pid ${checked.owner.pid} is alive but its beat is not advancing`, 1];
471
- case "OWN-DEAD": return [`STALE (${checked.owner.seat}) — recorded pid ${checked.owner.pid} is gone; run \`tickmarkr beat stop ${tier} --seat ${checked.owner.seat}\``, 1];
472
- case "FOREIGN": return [`MISMATCH — ${checked.reason}`, 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];
473
1301
  case "PID-REUSED": return [`MISMATCH — recorded pid ${checked.owner.pid} now belongs to another process`, 1];
474
1302
  case "UNKNOWN": return [`UNREADABLE — ${checked.reason}`, 1];
475
1303
  }
476
1304
  })();
477
- return { out: `${tier} ${row[0]}${busy}`, code: row[1] };
1305
+ return { out: `${tier} ${row[0]}${busy}${recover}`, code: recover ? 1 : row[1] };
478
1306
  }
479
1307
  const exited = (child) => child.exitCode !== null || child.signalCode !== null;
480
- /** start: one claim, one child; exit zero only after this exact child's own advancing beat reads back. */
481
- export async function startBeat(repoRoot, tier, seat, deps) {
482
- const generation = randomUUID();
483
- acquireBeatClaim(repoRoot, tier, generation);
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) {
484
1340
  let result;
485
1341
  try {
486
- const checked = inspectBeat(repoRoot, tier, seat, deps);
487
- switch (checked.kind) {
488
- case "EMPTY":
489
- case "DISARMED":
490
- result = await launch(repoRoot, tier, seat, generation, deps);
491
- break;
492
- case "OWN-LIVE":
493
- if (!checked.advancing)
494
- refuse(`${tier} STALE — owned pid ${checked.owner.pid} is alive but silent; run \`tickmarkr beat stop ${tier} --seat ${seat}\` first`);
495
- result = `${tier} already running as ${seat} — detached pid ${checked.owner.pid}, generation ${checked.owner.generation}; checked no-op`;
496
- break;
497
- case "OWN-DEAD":
498
- refuse(`${tier} STALE — owned pid ${checked.owner.pid} is gone; run \`tickmarkr beat stop ${tier} --seat ${seat}\` first`);
499
- break;
500
- case "FOREIGN":
501
- refuse(`${tier} MISMATCH — ${checked.reason}; nothing started`);
502
- break;
503
- case "PID-REUSED":
504
- refuse(`${tier} MISMATCH — recorded pid ${checked.owner.pid} now belongs to another process; nothing started`);
505
- break;
506
- case "UNKNOWN":
507
- refuse(`${tier} UNREADABLE — ${checked.reason}; nothing started`);
508
- break;
1342
+ result = await body();
1343
+ }
1344
+ catch (error) {
1345
+ try {
1346
+ releaseBeatClaim(repoRoot, tier, token, told);
509
1347
  }
1348
+ catch (release) {
1349
+ if (error instanceof BeatRefusal)
1350
+ throw new BeatRefusal(`${error.message}; ${release.message}`);
1351
+ }
1352
+ throw error;
510
1353
  }
511
- finally {
512
- releaseBeatClaim(repoRoot, tier, generation);
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;
513
1393
  }
514
1394
  return result;
515
1395
  }
@@ -525,9 +1405,15 @@ async function launch(repoRoot, tier, seat, generation, deps) {
525
1405
  // it with a reread owner: even the same token under a different arm epoch is another generation.
526
1406
  let expected = expectedGeneration(base);
527
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);
528
1411
  try {
529
- publish(beatOwnerPath(repoRoot, tier), base);
530
- requireOwned(checkOwnedGeneration(repoRoot, tier, snapshot(), deps, undefined, true), tier);
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();
531
1417
  }
532
1418
  catch (error) {
533
1419
  return rollback(repoRoot, tier, snapshot(), undefined, undefined, deps, `owner publication failed: ${error.message}`);
@@ -535,11 +1421,11 @@ async function launch(repoRoot, tier, seat, generation, deps) {
535
1421
  const log = beatLogPath(repoRoot, tier);
536
1422
  let child;
537
1423
  try {
538
- const fd = deps.openLog(log);
1424
+ const fd = guard(() => deps.openLog(log), owned);
539
1425
  try {
540
- child = deps.spawn(deps.cli[0], [...deps.cli.slice(1), "beat", tier, "--seat", seat, "--loop", "--arm-id", generation], {
1426
+ child = guard(() => deps.spawn(deps.cli[0], [...deps.cli.slice(1), "beat", tier, "--seat", seat, "--loop", "--arm-id", generation], {
541
1427
  cwd: repoRoot, detached: true, stdio: ["ignore", fd, fd], env: { ...deps.env, [BEAT_GENERATION_ENV]: generation },
542
- });
1428
+ }), owned);
543
1429
  }
544
1430
  finally {
545
1431
  closeSync(fd);
@@ -562,7 +1448,7 @@ async function launch(repoRoot, tier, seat, generation, deps) {
562
1448
  if (exited(child) || id === "DEAD")
563
1449
  return rollback(repoRoot, tier, snapshot(), child, undefined, deps, `child pid ${pid} died before its beat read back (log ${log})`);
564
1450
  if (id === "UNKNOWN")
565
- return rollback(repoRoot, tier, snapshot(), child, undefined, deps, `child pid ${pid} identity is unreadable`);
1451
+ return rollback(repoRoot, tier, snapshot(), child, undefined, deps, `UNREADABLE — child pid ${pid} identity is unreadable${why(proof)}`);
566
1452
  if (id.cwd !== base.cwd || !id.command.includes(generation)) {
567
1453
  return rollback(repoRoot, tier, snapshot(), child, undefined, deps, `child pid ${pid} is not the launched generation (cwd ${id.cwd})`);
568
1454
  }
@@ -586,11 +1472,13 @@ async function launch(repoRoot, tier, seat, generation, deps) {
586
1472
  if (id.pgid !== pid)
587
1473
  return fail(`child pid ${pid} is not detached (process group ${id.pgid})`);
588
1474
  const record = (next) => {
589
- requireOwned(checkOwnedGeneration(repoRoot, tier, snapshot(), deps, undefined, true), tier);
1475
+ const prior = snapshot();
1476
+ const verified = () => requireOwned(checkOwnedGeneration(repoRoot, tier, prior, deps, undefined, true), tier);
1477
+ verified();
590
1478
  // If publication or its read-back fails, the attempted snapshot remains the rollback authority.
591
1479
  expected = { ...expected, owner: next };
592
- publish(beatOwnerPath(repoRoot, tier), next);
593
- return requireOwned(checkOwnedGeneration(repoRoot, tier, snapshot(), deps, undefined, true), tier).records;
1480
+ publish(beatOwnerPath(repoRoot, tier), next, guard, verified); // the prior generation, re-verified inside the act
1481
+ return owned().records;
594
1482
  };
595
1483
  try {
596
1484
  record({ ...base, pid, birth: id.birth, command: id.command, pgid: id.pgid });
@@ -608,7 +1496,7 @@ async function launch(repoRoot, tier, seat, generation, deps) {
608
1496
  // exit is observable, then repeat the full generation/records check within the startup budget.
609
1497
  if (live.identity === "UNKNOWN") {
610
1498
  if (deps.now() >= deadline)
611
- return fail(`UNREADABLE — child pid ${pid} identity is unreadable during read-back`);
1499
+ return fail(`UNREADABLE — child pid ${pid} identity is unreadable during read-back${why(live)}`);
612
1500
  await deps.sleep(Math.min(100, Math.max(0, deadline - deps.now())));
613
1501
  continue;
614
1502
  }
@@ -642,7 +1530,7 @@ async function launch(repoRoot, tier, seat, generation, deps) {
642
1530
  if (exited(child) || final.identity === "DEAD")
643
1531
  return fail(`child pid ${pid} died before its beat read back (log ${log})`);
644
1532
  if (final.identity === "UNKNOWN")
645
- return fail(`UNREADABLE — child pid ${pid} identity is unreadable during read-back`);
1533
+ return fail(`UNREADABLE — child pid ${pid} identity is unreadable during read-back${why(final)}`);
646
1534
  if (!sameProcess(expected.owner, final.identity))
647
1535
  return fail(`child pid ${pid} changed identity during read-back`);
648
1536
  const advancing = advancingBeat(final.records, expected.owner);
@@ -666,9 +1554,22 @@ async function waitFor(gone, ms, deps) {
666
1554
  * Undo only what this generation did; anything it cannot prove stays in place and the refusal says so.
667
1555
  * EVERY signal is preceded by a recheck of both the owner generation and the child's live identity against
668
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.
669
1560
  */
670
1561
  async function rollback(repoRoot, tier, expected, child, proven, deps, why) {
671
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
+ };
672
1573
  const ownerRead = () => readBeatOwner(repoRoot, tier);
673
1574
  const check = (phase, pid) => {
674
1575
  const checked = checkOwnedGeneration(repoRoot, tier, expected, deps, pid);
@@ -678,29 +1579,36 @@ async function rollback(repoRoot, tier, expected, child, proven, deps, why) {
678
1579
  };
679
1580
  const pid = child?.pid;
680
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`;
681
1583
  const gone = () => {
682
1584
  const checked = check("rollback", pid);
683
1585
  if (checked.identity === "DEAD")
684
1586
  return true;
685
- // A process exiting after our signal can disappear between its ps and cwd reads. UNKNOWN
1587
+ // A process exiting can disappear between its ps and cwd reads or show teardown argv. UNKNOWN
686
1588
  // proves neither death nor a changed identity: wait for checked death, and still require a
687
1589
  // matching live identity at the separate barrier before any further signal.
688
- if (proven && checked.identity !== "UNKNOWN" && !sameIdentity(proven, checked.identity) && !exitingAfterSignal(proven, checked.identity))
689
- failed(`pid ${pid} not signalled — its identity is not proven to be this generation's child; owner record retained`);
1590
+ if (proven && checked.identity !== "UNKNOWN" && !sameIdentity(proven, checked.identity))
1591
+ failed(unproven);
690
1592
  return false;
691
1593
  };
692
1594
  for (const signal of ["SIGTERM", "SIGKILL"]) {
693
1595
  if (gone())
694
1596
  break;
695
1597
  // identity FIRST, ownership re-read after it: a generation replaced during the identity read is not signalled
696
- const checked = check("rollback", pid);
697
- if (!proven || !sameIdentity(proven, checked.identity)) {
698
- failed(`pid ${pid} not signalled — its identity is not proven to be this generation's child; owner record retained`);
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);
699
1603
  }
700
- try {
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 {
701
1609
  deps.kill(pid, signal);
702
1610
  }
703
- catch { /* the wait below decides */ }
1611
+ catch { /* the wait below decides */ } }, () => check("rollback"));
704
1612
  await waitFor(gone, 3_000, deps);
705
1613
  }
706
1614
  if (!gone())
@@ -711,12 +1619,12 @@ async function rollback(repoRoot, tier, expected, child, proven, deps, why) {
711
1619
  try {
712
1620
  const checked = check("cleanup");
713
1621
  if (checked.records.arm?.armId === expected.owner.armId) {
714
- publishStandDown(repoRoot, tier, expected.owner.seat);
1622
+ supervised(repoRoot, tier, barred(guard, () => check("cleanup")), (root) => publishStandDown(root, tier, expected.owner.seat));
715
1623
  check("cleanup");
716
1624
  if (checkedRecords(repoRoot, tier, deps).kind === "UNKNOWN" || supervisionStatus(repoRoot, tier, deps.now()).state !== "DISARMED")
717
1625
  failed("rollback stand-down read-back failed; owner retained");
718
1626
  }
719
- removeOwner(repoRoot, tier, expected, deps);
1627
+ removeOwner(repoRoot, tier, expected, deps, guard);
720
1628
  }
721
1629
  catch (error) {
722
1630
  if (error instanceof BeatRefusal)
@@ -727,49 +1635,76 @@ async function rollback(repoRoot, tier, expected, child, proven, deps, why) {
727
1635
  failed("owner record could not be removed");
728
1636
  return failed("rolled back this generation only");
729
1637
  }
730
- /** stop: retire only the recorded generation, signalling its exact pid, then read back DISARMED. */
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
+ */
731
1669
  export async function stopBeat(repoRoot, tier, seat, deps) {
732
1670
  const token = randomUUID();
733
- acquireBeatClaim(repoRoot, tier, token);
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) {
734
1676
  let result;
735
- try {
736
- const checked = inspectBeat(repoRoot, tier, seat, deps);
737
- switch (checked.kind) {
738
- case "EMPTY":
739
- publishStandDown(repoRoot, tier, seat);
740
- result = readBackDisarmed(repoRoot, tier, deps, "published DISARMED; nothing signalled");
741
- break;
742
- case "DISARMED":
743
- result = `${tier} DISARMED — already stood down; checked no-op`;
744
- break;
745
- case "OWN-LIVE":
746
- result = await retire(repoRoot, tier, checked.owner, deps);
747
- break;
748
- case "OWN-DEAD": {
749
- recheckOwned(repoRoot, tier, checked.owner, deps, false);
750
- const state = supervisionStatus(repoRoot, tier, deps.now()).state;
751
- if (state === "UNREADABLE")
752
- refuse(`${tier} UNREADABLE — the tier's supervision records became unreadable; nothing removed`);
753
- if (state !== "DISARMED")
754
- publishStandDown(repoRoot, tier, checked.owner.seat);
755
- recheckOwned(repoRoot, tier, checked.owner, deps, false);
756
- removeOwner(repoRoot, tier, expectedGeneration(checked.owner), deps);
757
- result = readBackDisarmed(repoRoot, tier, deps, `pid ${checked.owner.pid} was already gone; cleaned up, nothing signalled`);
758
- break;
759
- }
760
- case "FOREIGN":
761
- refuse(`${tier} MISMATCH — ${checked.reason}; nothing signalled or removed`);
762
- break;
763
- case "PID-REUSED":
764
- refuse(`${tier} MISMATCH — recorded pid ${checked.owner.pid} now belongs to another process; nothing signalled or removed`);
765
- break;
766
- case "UNKNOWN":
767
- refuse(`${tier} UNREADABLE — ${checked.reason}; nothing signalled or removed`);
768
- break;
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;
769
1698
  }
770
- }
771
- finally {
772
- releaseBeatClaim(repoRoot, tier, token);
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;
773
1708
  }
774
1709
  return result;
775
1710
  }
@@ -780,37 +1715,59 @@ export async function stopBeat(repoRoot, tier, seat, deps) {
780
1715
  */
781
1716
  function recheckOwned(repoRoot, tier, owner, deps, live = true) {
782
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`);
783
1720
  if (live && !sameProcess(owner, checked.identity))
784
1721
  refuse(`${tier} pid ${owner.pid} no longer matches its recorded identity — nothing further signalled`);
785
1722
  }
786
- async function retire(repoRoot, tier, owner, deps) {
787
- recheckOwned(repoRoot, tier, owner, deps);
788
- publishStandDown(repoRoot, tier, owner.seat);
1723
+ async function retire(repoRoot, tier, owner, held, deps) {
789
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));
790
1728
  if (!Number.isInteger(owner.pid) || owner.pid <= 0)
791
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);
792
1731
  const gone = () => {
793
- const { identity } = requireOwned(checkOwnedGeneration(repoRoot, tier, expectedGeneration(owner), deps, owner.pid), tier);
794
- if (identity !== "DEAD" && identity !== "UNKNOWN" && !sameProcess(owner, identity) && !exitingAfterSignal(owner, identity))
1732
+ const { identity } = read();
1733
+ if (identity !== "DEAD" && identity !== "UNKNOWN" && !sameProcess(owner, identity))
795
1734
  refuse(`${tier} MISMATCH — pid ${owner.pid} changed identity while stopping; owner record retained`);
796
1735
  return identity === "DEAD";
797
1736
  };
798
- try {
799
- deps.kill(owner.pid, "SIGTERM");
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();
800
1744
  }
801
- catch (error) {
802
- if (!gone())
803
- refuse(`${tier} signal to pid ${owner.pid} failed: ${error.message}; owner record retained`);
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`);
804
1761
  }
805
- if (!await waitFor(gone, SUPERVISION_BEAT_MS, deps))
806
- refuse(`${tier} pid ${owner.pid} did not exit after SIGTERM; owner record retained`);
807
1762
  recheckOwned(repoRoot, tier, owner, deps, false);
808
- removeOwner(repoRoot, tier, expectedGeneration(owner), deps);
809
- return readBackDisarmed(repoRoot, tier, deps, `retired detached pid ${owner.pid} (generation ${owner.generation})`);
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})`);
810
1767
  }
811
- function removeOwner(repoRoot, tier, expected, deps) {
812
- requireOwned(checkOwnedGeneration(repoRoot, tier, expected, deps), tier);
813
- rmSync(beatOwnerPath(repoRoot, tier));
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));
814
1771
  if (readBeatOwner(repoRoot, tier) !== "NONE")
815
1772
  refuse(`${tier} owner record could not be removed — cleanup unacknowledged`);
816
1773
  }