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.
- package/dist/adapters/catalog-remote.js +5 -2
- package/dist/adapters/codex.d.ts +1 -0
- package/dist/adapters/codex.js +18 -10
- package/dist/adapters/model-lints.js +21 -11
- package/dist/adapters/model-windows.js +0 -1
- package/dist/adapters/registry.js +7 -3
- package/dist/adapters/types.d.ts +2 -0
- package/dist/cli/commands/beat.d.ts +3 -3
- package/dist/cli/commands/beat.js +39 -19
- package/dist/cli/commands/doctor.js +3 -0
- package/dist/cli/commands/fleet.js +2 -0
- package/dist/cli/commands/plan.js +16 -18
- package/dist/cli/commands/verify.js +4 -2
- package/dist/cli/help.js +5 -2
- package/dist/config/config.js +6 -5
- package/dist/drivers/herdr.js +2 -2
- package/dist/drivers/orca.d.ts +12 -4
- package/dist/drivers/orca.js +200 -39
- package/dist/gates/evidence.js +3 -3
- package/dist/gates/review.d.ts +1 -1
- package/dist/gates/review.js +62 -13
- package/dist/gates/run-gates.d.ts +3 -0
- package/dist/gates/run-gates.js +115 -20
- package/dist/gates/test-manifest.d.ts +36 -2
- package/dist/gates/test-manifest.js +55 -10
- package/dist/run/beat-lifecycle.d.ts +109 -24
- package/dist/run/beat-lifecycle.js +1173 -216
- package/dist/run/daemon.d.ts +4 -0
- package/dist/run/daemon.js +111 -33
- package/dist/run/git-trust.d.ts +78 -3
- package/dist/run/git-trust.js +175 -19
- package/dist/run/git.d.ts +24 -3
- package/dist/run/git.js +140 -21
- package/dist/run/merge.js +2 -2
- package/dist/run/verification-job.d.ts +2 -0
- package/dist/run/verification-job.js +32 -10
- package/dist/tui/ink/fleet-app.d.ts +7 -2
- package/dist/tui/ink/fleet-app.js +81 -23
- package/package.json +4 -1
- package/skills/tickmarkr-auto/SKILL.md +58 -14
- package/skills/tickmarkr-loop/SKILL.md +65 -14
- package/skills/tickmarkr-overseer/SKILL.md +97 -28
- 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 {
|
|
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
|
-
//
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
113
|
+
let why;
|
|
114
|
+
if (platform === "linux") {
|
|
115
|
+
try {
|
|
62
116
|
cwd = readlinkSync(`/proc/${pid}/cwd`);
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
cwd =
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
cwd = undefined;
|
|
66
120
|
}
|
|
67
121
|
}
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
135
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
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 = {
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
const recheck = held(generation);
|
|
874
|
+
if (underLaunchClaim(repoRoot, tier, generation, writer.deps)) {
|
|
875
|
+
const [recheck, guard] = held(generation);
|
|
197
876
|
recheck();
|
|
198
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
218
|
-
recheck();
|
|
219
|
-
result = await act(recheck);
|
|
944
|
+
launcher = writer.deps.identity(claim.pid);
|
|
220
945
|
}
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
323
|
-
*
|
|
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
|
|
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
|
|
1117
|
+
let read = "UNKNOWN";
|
|
371
1118
|
if (pid !== undefined) {
|
|
372
1119
|
try {
|
|
373
|
-
|
|
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
|
-
|
|
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
|
|
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 (
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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
|
-
|
|
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
|
|
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}
|
|
472
|
-
case "
|
|
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
|
-
/**
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
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
|
-
|
|
512
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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)
|
|
689
|
-
failed(
|
|
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
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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
|
-
|
|
772
|
-
|
|
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 } =
|
|
794
|
-
if (identity !== "DEAD" && identity !== "UNKNOWN" && !sameProcess(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
|
-
|
|
799
|
-
|
|
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
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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,
|
|
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
|
-
|
|
812
|
-
|
|
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
|
}
|