@tiphys/kernel 0.1.0 → 0.2.0
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/AGENTS.md +56 -4
- package/assurance-modes.yaml +23 -2
- package/dist/bin/tiphys.js +86 -8
- package/dist/src/adapters/load.d.ts +202 -0
- package/dist/src/adapters/load.js +440 -0
- package/dist/src/brief.js +27 -20
- package/dist/src/checks.d.ts +720 -9
- package/dist/src/checks.js +1874 -163
- package/dist/src/cli.js +11 -0
- package/dist/src/commands/brief.js +27 -4
- package/dist/src/commands/cutover.d.ts +35 -0
- package/dist/src/commands/cutover.js +448 -0
- package/dist/src/commands/doctor.d.ts +229 -0
- package/dist/src/commands/doctor.js +968 -27
- package/dist/src/commands/init.d.ts +3 -3
- package/dist/src/commands/init.js +57 -8
- package/dist/src/commands/lock.d.ts +33 -0
- package/dist/src/commands/lock.js +117 -6
- package/dist/src/commands/next.d.ts +130 -0
- package/dist/src/commands/next.js +597 -0
- package/dist/src/commands/pool.js +12 -1
- package/dist/src/commands/resume.d.ts +1 -0
- package/dist/src/commands/resume.js +88 -0
- package/dist/src/commands/spawn.js +51 -2
- package/dist/src/commands/status.d.ts +6 -4
- package/dist/src/commands/status.js +6 -4
- package/dist/src/commands/sync.d.ts +47 -0
- package/dist/src/commands/sync.js +341 -0
- package/dist/src/commands/teardown.js +10 -2
- package/dist/src/commands/validate.js +70 -0
- package/dist/src/cutover.d.ts +584 -0
- package/dist/src/cutover.js +1444 -0
- package/dist/src/exclusion.d.ts +389 -0
- package/dist/src/exclusion.js +843 -0
- package/dist/src/exec/env.d.ts +152 -2
- package/dist/src/exec/env.js +146 -2
- package/dist/src/fleet.d.ts +172 -0
- package/dist/src/fleet.js +219 -1
- package/dist/src/gates/citations.js +7 -1
- package/dist/src/gates/coverage.d.ts +113 -22
- package/dist/src/gates/coverage.js +166 -31
- package/dist/src/gates/credentials.d.ts +159 -0
- package/dist/src/gates/credentials.js +221 -2
- package/dist/src/gates/gate-classes.d.ts +56 -0
- package/dist/src/gates/gate-classes.js +633 -0
- package/dist/src/gates/merge-preconditions.d.ts +319 -0
- package/dist/src/gates/merge-preconditions.js +932 -0
- package/dist/src/gates/red-witness.js +105 -13
- package/dist/src/gates/run.d.ts +49 -1
- package/dist/src/gates/run.js +83 -5
- package/dist/src/gates/schemas/phase-declaration.schema.json +45 -0
- package/dist/src/gates/suite.js +48 -7
- package/dist/src/hooks.d.ts +55 -3
- package/dist/src/hooks.js +69 -6
- package/dist/src/index.d.ts +31 -0
- package/dist/src/index.js +30 -0
- package/dist/src/lock.d.ts +82 -4
- package/dist/src/lock.js +314 -22
- package/dist/src/model-resolution.d.ts +159 -0
- package/dist/src/model-resolution.js +307 -0
- package/dist/src/path-identity.d.ts +32 -0
- package/dist/src/path-identity.js +38 -0
- package/dist/src/pool.d.ts +197 -1
- package/dist/src/pool.js +289 -22
- package/dist/src/roles.d.ts +31 -0
- package/dist/src/roles.js +42 -0
- package/dist/src/spawn.d.ts +307 -2
- package/dist/src/spawn.js +690 -19
- package/dist/src/status.d.ts +27 -2
- package/dist/src/status.js +34 -5
- package/dist/src/task.d.ts +295 -55
- package/dist/src/task.js +125 -123
- package/dist/src/teardown.d.ts +7 -0
- package/dist/src/teardown.js +120 -12
- package/dist/src/validate.d.ts +44 -11
- package/dist/src/validate.js +44 -34
- package/dist/src/watcher.js +1 -11
- package/dist/src/witness/run.d.ts +32 -7
- package/dist/src/witness/run.js +76 -30
- package/dist/src/witness/spec.d.ts +168 -0
- package/dist/src/witness/spec.js +240 -18
- package/dist/tsconfig.src.tsbuildinfo +1 -1
- package/gate-registry.yaml +136 -0
- package/gates.manifest.json +63 -1
- package/package.json +18 -3
- package/roles/implementer.md +3 -0
- package/schemas/README.md +1 -0
- package/schemas/assurance-modes.schema.json +1 -1
- package/schemas/charter.schema.json +19 -0
- package/schemas/cutover-state.schema.json +64 -0
- package/schemas/executor-record.schema.json +36 -0
- package/schemas/model-resolution.schema.json +362 -0
- package/schemas/verdict.schema.json +9 -3
- package/schemas/write-bypass.schema.json +69 -0
|
@@ -0,0 +1,843 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { LOCK_FILE, readRegularPathIfPresent, refuseOpenPathForWrite, } from "./fleet.js";
|
|
6
|
+
/**
|
|
7
|
+
* THE SHARED EXCLUSION REGISTER (kernel plan M4, M4-P21).
|
|
8
|
+
*
|
|
9
|
+
* `src/lock.ts` states its own exclusion domain honestly at src/lock.ts:63:
|
|
10
|
+
* the lease excludes within ONE filesystem and ONE clock. Two environments
|
|
11
|
+
* that clone one fleet remote each get their own `state/orchestrator.lock`
|
|
12
|
+
* and BOTH acquire it, because `state/` is gitignored (src/fleet.ts:28) so
|
|
13
|
+
* the lease artifact never travels. M4-P20 measured that dangerous state and
|
|
14
|
+
* committed the captures; this module is the second exclusion layer that
|
|
15
|
+
* closes it.
|
|
16
|
+
*
|
|
17
|
+
* WHAT THE REGISTER IS. A compare-and-swap register on a dedicated git ref
|
|
18
|
+
* of the fleet's shared remote. The ref's value is a commit whose only file
|
|
19
|
+
* is `lease.json`; a write is `git push --force-with-lease=<ref>:<exact sha>`
|
|
20
|
+
* and the remote decides the race. M4-D-11 was PROTOTYPE-BLOCKED and M4-P20's
|
|
21
|
+
* probe closed it (delivery/verification/cross-environment-exclusion-probe.md:1).
|
|
22
|
+
* Three of that probe's findings are load-bearing here and are implemented
|
|
23
|
+
* rather than remembered:
|
|
24
|
+
*
|
|
25
|
+
* 1. ONLY `refs/heads/*` IS PUSHABLE. Tags, notes and custom namespaces are
|
|
26
|
+
* refused with HTTP 403 (CLAUDE.md standing warning 14, re-measured for
|
|
27
|
+
* this phase). So the "dedicated ref" is a dedicated BRANCH and the
|
|
28
|
+
* default is `refs/heads/tiphys/lease`, which is visible in branch
|
|
29
|
+
* listings and subject to any `refs/heads/**` ruleset.
|
|
30
|
+
* 2. THE EXPECTATION MUST BE AN EXPLICIT SHA. The bare `--force-with-lease`
|
|
31
|
+
* form takes its expectation from the remote-tracking ref, so a routine
|
|
32
|
+
* fetch re-arms it; the probe measured it CLOBBERING a live holder with
|
|
33
|
+
* exit 0. `casWrite` below never emits the bare form.
|
|
34
|
+
* 3. NONZERO DOES NOT MEAN "I LOST". A transport failure exits 1 too, so
|
|
35
|
+
* the result has THREE states and the indeterminate one is resolved by
|
|
36
|
+
* re-reading the register rather than assumed either way.
|
|
37
|
+
*
|
|
38
|
+
* WHY THE STALENESS SIGNAL IS A COUNTER AND NOT A CLOCK (M4-P21 criterion 6).
|
|
39
|
+
* Two environments bring two clocks and `isExpired` compares a lease
|
|
40
|
+
* timestamp against the local one (src/lock.ts:171). Comparing one
|
|
41
|
+
* environment's wall clock against another's is exactly the measurement this
|
|
42
|
+
* layer must not make. So the register carries a MONOTONIC FENCING COUNTER
|
|
43
|
+
* that every write increments, and a holder is judged stale only when that
|
|
44
|
+
* counter has not moved across a duration measured entirely on the OBSERVING
|
|
45
|
+
* environment's own clock (two readings of one clock, never one reading of
|
|
46
|
+
* two). Where the register is reachable the counter decides and the command
|
|
47
|
+
* says `signal=counter`. Where it is not reachable there is no counter to
|
|
48
|
+
* read, only the local clock, and a clock is not a cross-environment signal:
|
|
49
|
+
* the command says `signal=clock` and REFUSES rather than falling back to
|
|
50
|
+
* local-only exclusion, which is the vacuous green this layer exists to
|
|
51
|
+
* prevent (criterion 7).
|
|
52
|
+
*
|
|
53
|
+
* C-2 (binding): nothing here reads a run identifier of a running program,
|
|
54
|
+
* probes liveness, sends a signal, or reads the kernel's virtual filesystem.
|
|
55
|
+
* Environment identity is a random id written once to a TRACKED fleet file
|
|
56
|
+
* (criterion 3), so it survives a reclaim and travels with a clone of the
|
|
57
|
+
* fleet; exclusion is decided by the register's counter and by git's own
|
|
58
|
+
* compare-and-swap verdict, never by anything about a machine.
|
|
59
|
+
*
|
|
60
|
+
* DECLARATION, NOT INFERENCE (criterion 1). The layer is entered only when
|
|
61
|
+
* the fleet home's own `package.json` declares it. With the field absent
|
|
62
|
+
* every function here returns "absent" before any subprocess is spawned, so
|
|
63
|
+
* a fleet that cannot reach a remote is not forced to switch the layer off
|
|
64
|
+
* globally and today's behaviour is unchanged for everyone else.
|
|
65
|
+
*/
|
|
66
|
+
/** The dotted path of the opt-in field inside the fleet home's package.json. */
|
|
67
|
+
export const SHARED_EXCLUSION_FIELD = "tiphys.sharedExclusion";
|
|
68
|
+
/** The remote a declaration defaults to. */
|
|
69
|
+
export const DEFAULT_SHARED_REMOTE = "origin";
|
|
70
|
+
/**
|
|
71
|
+
* The register ref a declaration defaults to. A BRANCH, deliberately: see
|
|
72
|
+
* finding 1 in this file's header. The plan's prose names `refs/tiphys/lease`
|
|
73
|
+
* and that namespace is refused by the shared remote this kernel is built
|
|
74
|
+
* against, so the default is the pushable form and the ref stays
|
|
75
|
+
* configurable for a remote with different rules.
|
|
76
|
+
*/
|
|
77
|
+
export const DEFAULT_SHARED_REF = "refs/heads/tiphys/lease";
|
|
78
|
+
/** The file inside the register commit that carries the lease document. */
|
|
79
|
+
export const REGISTER_DOCUMENT_NAME = "lease.json";
|
|
80
|
+
/**
|
|
81
|
+
* The TRACKED fleet file carrying this environment's identity (criterion 3).
|
|
82
|
+
* It sits at the fleet root, outside the gitignored set at src/fleet.ts:28,
|
|
83
|
+
* so an environment that commits it keeps its identity across a reclaim and
|
|
84
|
+
* a clone of that commit reads the same id.
|
|
85
|
+
*/
|
|
86
|
+
export const ENVIRONMENT_ID_FILE = "tiphys-environment.json";
|
|
87
|
+
/**
|
|
88
|
+
* Where this environment records what it last saw in the register. It lives
|
|
89
|
+
* under the gitignored `state/` prefix ON PURPOSE: it is a measurement taken
|
|
90
|
+
* on THIS environment's clock and it must never travel, or the duration it
|
|
91
|
+
* carries would be compared against a clock that did not produce it.
|
|
92
|
+
*/
|
|
93
|
+
export const OBSERVATION_FILE = join("state", "shared-lease.observed.json");
|
|
94
|
+
/** How long the counter must stand still before a holder is judged stale. */
|
|
95
|
+
export const DEFAULT_STALE_WINDOW_SECONDS = 900;
|
|
96
|
+
/* ------------------------------------------------------------------ */
|
|
97
|
+
/* Declaration */
|
|
98
|
+
/* ------------------------------------------------------------------ */
|
|
99
|
+
/**
|
|
100
|
+
* THE ENTRY TYPE IS ESTABLISHED BEFORE THE OPEN. This function reads the
|
|
101
|
+
* fleet `package.json` and `tiphys-environment.json`, both of them paths
|
|
102
|
+
* this module did not create, and a bare `readFileSync` on a named pipe at
|
|
103
|
+
* either blocked every lock subcommand forever with zero output. A
|
|
104
|
+
* non-regular entry is now a REFUSAL naming the observed type, in the same
|
|
105
|
+
* words doctor already uses, and it is NOT `absent`: reading "this cannot be
|
|
106
|
+
* opened" as "the layer is off here" is exactly the shape that makes a guard
|
|
107
|
+
* green everywhere and protective nowhere.
|
|
108
|
+
*/
|
|
109
|
+
function readJsonFile(path) {
|
|
110
|
+
const read = readRegularPathIfPresent(path);
|
|
111
|
+
if (read.kind === "absent") {
|
|
112
|
+
return { ok: false, absent: true, reason: `${path} is absent` };
|
|
113
|
+
}
|
|
114
|
+
if (read.kind === "refused") {
|
|
115
|
+
return { ok: false, absent: false, reason: read.reason };
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
return { ok: true, value: JSON.parse(read.body) };
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
return { ok: false, absent: false, reason: `${path} does not parse as JSON: ${String(error)}` };
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
function positiveNumber(value, fallback) {
|
|
125
|
+
if (value === undefined) {
|
|
126
|
+
return fallback;
|
|
127
|
+
}
|
|
128
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
|
|
129
|
+
return undefined;
|
|
130
|
+
}
|
|
131
|
+
return value;
|
|
132
|
+
}
|
|
133
|
+
function nonEmptyString(value, fallback) {
|
|
134
|
+
if (value === undefined) {
|
|
135
|
+
return fallback;
|
|
136
|
+
}
|
|
137
|
+
if (typeof value !== "string" || value === "") {
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Read the fleet home's declaration. Absent means the layer is off and no
|
|
144
|
+
* subprocess is spawned anywhere below; `false` is the same answer written
|
|
145
|
+
* explicitly. A field that is present and unreadable is INVALID rather than
|
|
146
|
+
* absent, because silently treating a typo as "off" is the shape that makes
|
|
147
|
+
* a guard green everywhere and protective nowhere.
|
|
148
|
+
*/
|
|
149
|
+
export function readSharedExclusion(fleetRoot) {
|
|
150
|
+
const read = readJsonFile(join(fleetRoot, "package.json"));
|
|
151
|
+
if (!read.ok) {
|
|
152
|
+
if (read.absent) {
|
|
153
|
+
return { kind: "absent" };
|
|
154
|
+
}
|
|
155
|
+
return { kind: "invalid", reason: read.reason };
|
|
156
|
+
}
|
|
157
|
+
const root = read.value;
|
|
158
|
+
if (root === null || typeof root !== "object") {
|
|
159
|
+
return { kind: "absent" };
|
|
160
|
+
}
|
|
161
|
+
const section = root["tiphys"];
|
|
162
|
+
if (section === undefined) {
|
|
163
|
+
return { kind: "absent" };
|
|
164
|
+
}
|
|
165
|
+
if (section === null || typeof section !== "object" || Array.isArray(section)) {
|
|
166
|
+
return { kind: "invalid", reason: `${SHARED_EXCLUSION_FIELD}: the "tiphys" section must be an object` };
|
|
167
|
+
}
|
|
168
|
+
const declared = section["sharedExclusion"];
|
|
169
|
+
if (declared === undefined || declared === false) {
|
|
170
|
+
return { kind: "absent" };
|
|
171
|
+
}
|
|
172
|
+
const raw = declared === true ? {} : declared;
|
|
173
|
+
if (declared !== true && (raw === null || typeof raw !== "object" || Array.isArray(raw))) {
|
|
174
|
+
return {
|
|
175
|
+
kind: "invalid",
|
|
176
|
+
reason: `${SHARED_EXCLUSION_FIELD} must be true or an object, not ${JSON.stringify(declared)}`,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
const remote = nonEmptyString(raw["remote"], DEFAULT_SHARED_REMOTE);
|
|
180
|
+
const ref = nonEmptyString(raw["ref"], DEFAULT_SHARED_REF);
|
|
181
|
+
const staleWindowSeconds = positiveNumber(raw["staleWindowSeconds"], DEFAULT_STALE_WINDOW_SECONDS);
|
|
182
|
+
if (remote === undefined || ref === undefined || staleWindowSeconds === undefined) {
|
|
183
|
+
return {
|
|
184
|
+
kind: "invalid",
|
|
185
|
+
reason: `${SHARED_EXCLUSION_FIELD} carries an unusable remote, ref or ` +
|
|
186
|
+
`staleWindowSeconds: ${JSON.stringify(declared)}`,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
if (!ref.startsWith("refs/")) {
|
|
190
|
+
return {
|
|
191
|
+
kind: "invalid",
|
|
192
|
+
reason: `${SHARED_EXCLUSION_FIELD}.ref must be a full ref name, got ${ref}`,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
return { kind: "declared", config: { remote, ref, staleWindowSeconds } };
|
|
196
|
+
}
|
|
197
|
+
/** The fleet root a lock path belongs to: <root>/state/orchestrator.lock. */
|
|
198
|
+
export function fleetRootForLockPath(lockPath) {
|
|
199
|
+
return dirname(dirname(lockPath));
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Read the environment id, or generate one and write it. The id is random
|
|
203
|
+
* and is generated EXACTLY ONCE per fleet home: every later call reads the
|
|
204
|
+
* file. Nothing about the machine enters it.
|
|
205
|
+
*/
|
|
206
|
+
export function ensureEnvironmentId(fleetRoot) {
|
|
207
|
+
const path = join(fleetRoot, ENVIRONMENT_ID_FILE);
|
|
208
|
+
const read = readJsonFile(path);
|
|
209
|
+
if (read.ok) {
|
|
210
|
+
const value = read.value;
|
|
211
|
+
const existing = value === null ? undefined : value["envId"];
|
|
212
|
+
if (typeof existing === "string" && existing !== "") {
|
|
213
|
+
return { envId: existing, generated: false, path };
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
const envId = randomUUID();
|
|
217
|
+
const document = {
|
|
218
|
+
envId,
|
|
219
|
+
purpose: "Tiphys environment identity for the shared exclusion register. Random, " +
|
|
220
|
+
"generated once, and tracked so it survives a reclaim. It says nothing " +
|
|
221
|
+
"about any machine.",
|
|
222
|
+
};
|
|
223
|
+
const refusal = refuseOpenPathForWrite(path);
|
|
224
|
+
if (refusal !== undefined) {
|
|
225
|
+
throw new Error(refusal);
|
|
226
|
+
}
|
|
227
|
+
writeFileSync(path, `${JSON.stringify(document, null, 2)}\n`, "utf8");
|
|
228
|
+
return { envId, generated: true, path };
|
|
229
|
+
}
|
|
230
|
+
/* ------------------------------------------------------------------ */
|
|
231
|
+
/* git plumbing */
|
|
232
|
+
/* ------------------------------------------------------------------ */
|
|
233
|
+
/**
|
|
234
|
+
* THE COMMAND-SCOPED IDENTITY THE REGISTER COMMIT IS WRITTEN UNDER.
|
|
235
|
+
*
|
|
236
|
+
* `git commit-tree` REFUSES without an author, and CI runners carry no git
|
|
237
|
+
* identity (CLAUDE.md standing warning 5), so a register write that relied
|
|
238
|
+
* on ambient configuration would work on a developer's machine and fail on
|
|
239
|
+
* every runner. These are the same two strings `tiphys init` already uses
|
|
240
|
+
* for the fleet bootstrap commit (EXT-F-02 option B): set as command-scoped
|
|
241
|
+
* environment variables on the invocation only, never written to user or
|
|
242
|
+
* global git configuration. They are repeated here rather than imported
|
|
243
|
+
* because `src/commands/init.ts` imports THIS module for the opt-in field,
|
|
244
|
+
* and a test pins the two copies equal so a drift reddens instead of
|
|
245
|
+
* surfacing as a runner-only failure.
|
|
246
|
+
*/
|
|
247
|
+
export const REGISTER_IDENTITY_NAME = "Tiphys Fleet";
|
|
248
|
+
export const REGISTER_IDENTITY_EMAIL = "fleet@tiphys.invalid";
|
|
249
|
+
function git(cwd, args, input) {
|
|
250
|
+
const result = spawnSync("git", ["-C", cwd, ...args], {
|
|
251
|
+
encoding: "utf8",
|
|
252
|
+
input,
|
|
253
|
+
env: {
|
|
254
|
+
...process.env,
|
|
255
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
256
|
+
GIT_AUTHOR_NAME: REGISTER_IDENTITY_NAME,
|
|
257
|
+
GIT_AUTHOR_EMAIL: REGISTER_IDENTITY_EMAIL,
|
|
258
|
+
GIT_COMMITTER_NAME: REGISTER_IDENTITY_NAME,
|
|
259
|
+
GIT_COMMITTER_EMAIL: REGISTER_IDENTITY_EMAIL,
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
return {
|
|
263
|
+
status: result.status,
|
|
264
|
+
stdout: result.stdout ?? "",
|
|
265
|
+
stderr: result.stderr ?? "",
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* The line of a git stderr block carrying git's OWN rejection marker.
|
|
270
|
+
*
|
|
271
|
+
* NOT "the first line", and M4-P20 paid for the difference: with
|
|
272
|
+
* `push.negotiate` true, git 2.43.0 emits a negotiation warning as the first
|
|
273
|
+
* stderr line of EVERY file-transport push, accepted and refused alike, so a
|
|
274
|
+
* signature taken from the first line cannot tell accept from refuse. The
|
|
275
|
+
* marker below is git's own text, never this module's, so the refusal
|
|
276
|
+
* signature stays captured rather than hand-written (T-003).
|
|
277
|
+
*/
|
|
278
|
+
export function rejectionLine(stderr) {
|
|
279
|
+
const lines = stderr.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
280
|
+
return lines.find((line) => line.startsWith("! [rejected]"));
|
|
281
|
+
}
|
|
282
|
+
function firstLine(text) {
|
|
283
|
+
const lines = text.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
284
|
+
return lines[0] ?? "git printed nothing";
|
|
285
|
+
}
|
|
286
|
+
/* ------------------------------------------------------------------ */
|
|
287
|
+
/* Reading the register */
|
|
288
|
+
/* ------------------------------------------------------------------ */
|
|
289
|
+
function parseRegisterDocument(raw) {
|
|
290
|
+
let parsed;
|
|
291
|
+
try {
|
|
292
|
+
parsed = JSON.parse(raw);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
return undefined;
|
|
296
|
+
}
|
|
297
|
+
const candidate = parsed;
|
|
298
|
+
if ((candidate.state !== "held" && candidate.state !== "free") ||
|
|
299
|
+
typeof candidate.envId !== "string" ||
|
|
300
|
+
candidate.envId === "" ||
|
|
301
|
+
typeof candidate.counter !== "number" ||
|
|
302
|
+
!Number.isInteger(candidate.counter) ||
|
|
303
|
+
candidate.counter < 1 ||
|
|
304
|
+
typeof candidate.acquiredAt !== "string" ||
|
|
305
|
+
typeof candidate.expiresAt !== "string" ||
|
|
306
|
+
typeof candidate.durationSeconds !== "number" ||
|
|
307
|
+
typeof candidate.ref !== "string") {
|
|
308
|
+
return undefined;
|
|
309
|
+
}
|
|
310
|
+
return candidate;
|
|
311
|
+
}
|
|
312
|
+
export function renderRegisterDocument(document) {
|
|
313
|
+
return `${JSON.stringify(document, null, 2)}\n`;
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Read the register. `ls-remote` establishes the value the compare-and-swap
|
|
317
|
+
* will be armed against, then the object is fetched so the document can be
|
|
318
|
+
* read. A failure at either step is UNREACHABLE, never "absent": the two
|
|
319
|
+
* must not be conflated, because "absent" means "nobody holds the lease" and
|
|
320
|
+
* would license an acquire.
|
|
321
|
+
*/
|
|
322
|
+
export function readRegister(fleetRoot, config) {
|
|
323
|
+
const listed = git(fleetRoot, ["ls-remote", config.remote, config.ref]);
|
|
324
|
+
if (listed.status !== 0) {
|
|
325
|
+
return { kind: "unreachable", reason: firstLine(listed.stderr) };
|
|
326
|
+
}
|
|
327
|
+
const line = listed.stdout.split("\n").map((entry) => entry.trim()).find(Boolean);
|
|
328
|
+
if (line === undefined) {
|
|
329
|
+
return { kind: "absent" };
|
|
330
|
+
}
|
|
331
|
+
const sha = line.split(/\s+/)[0] ?? "";
|
|
332
|
+
if (!/^[0-9a-f]{40}$/.test(sha)) {
|
|
333
|
+
return { kind: "unreachable", reason: `ls-remote returned an unreadable line: ${line}` };
|
|
334
|
+
}
|
|
335
|
+
const fetched = git(fleetRoot, ["fetch", "--quiet", config.remote, config.ref]);
|
|
336
|
+
if (fetched.status !== 0) {
|
|
337
|
+
return { kind: "unreachable", reason: firstLine(fetched.stderr) };
|
|
338
|
+
}
|
|
339
|
+
const shown = git(fleetRoot, ["cat-file", "-p", `${sha}:${REGISTER_DOCUMENT_NAME}`]);
|
|
340
|
+
if (shown.status !== 0) {
|
|
341
|
+
return {
|
|
342
|
+
kind: "corrupt",
|
|
343
|
+
sha,
|
|
344
|
+
reason: `${config.ref} at ${sha} carries no ${REGISTER_DOCUMENT_NAME}: ${firstLine(shown.stderr)}`,
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
const document = parseRegisterDocument(shown.stdout);
|
|
348
|
+
if (document === undefined) {
|
|
349
|
+
return { kind: "corrupt", sha, reason: `${config.ref} at ${sha} does not parse as a lease document` };
|
|
350
|
+
}
|
|
351
|
+
return { kind: "present", sha, document };
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* One compare-and-swap write. `expectedSha` is the value the caller read;
|
|
355
|
+
* the empty string means "I expect the register to be absent". The EXACT-SHA
|
|
356
|
+
* form is the only form emitted (finding 2 in this file's header).
|
|
357
|
+
*/
|
|
358
|
+
export function casWrite(fleetRoot, config, expectedSha, document) {
|
|
359
|
+
const blob = git(fleetRoot, ["hash-object", "-w", "--stdin"], renderRegisterDocument(document));
|
|
360
|
+
if (blob.status !== 0) {
|
|
361
|
+
return { kind: "indeterminate", reason: `could not write the lease blob: ${firstLine(blob.stderr)}` };
|
|
362
|
+
}
|
|
363
|
+
const tree = git(fleetRoot, ["mktree"], `100644 blob ${blob.stdout.trim()}\t${REGISTER_DOCUMENT_NAME}\n`);
|
|
364
|
+
if (tree.status !== 0) {
|
|
365
|
+
return { kind: "indeterminate", reason: `could not write the lease tree: ${firstLine(tree.stderr)}` };
|
|
366
|
+
}
|
|
367
|
+
const commit = git(fleetRoot, [
|
|
368
|
+
"commit-tree",
|
|
369
|
+
tree.stdout.trim(),
|
|
370
|
+
"-m",
|
|
371
|
+
`tiphys shared lease ${document.state} ${document.envId} counter ${String(document.counter)}`,
|
|
372
|
+
]);
|
|
373
|
+
if (commit.status !== 0) {
|
|
374
|
+
return { kind: "indeterminate", reason: `could not write the lease commit: ${firstLine(commit.stderr)}` };
|
|
375
|
+
}
|
|
376
|
+
const sha = commit.stdout.trim();
|
|
377
|
+
const pushed = git(fleetRoot, [
|
|
378
|
+
"push",
|
|
379
|
+
`--force-with-lease=${config.ref}:${expectedSha}`,
|
|
380
|
+
config.remote,
|
|
381
|
+
`${sha}:${config.ref}`,
|
|
382
|
+
]);
|
|
383
|
+
if (pushed.status === 0) {
|
|
384
|
+
return { kind: "won", sha };
|
|
385
|
+
}
|
|
386
|
+
const rejected = rejectionLine(pushed.stderr);
|
|
387
|
+
if (rejected !== undefined) {
|
|
388
|
+
return { kind: "lost", reason: rejected };
|
|
389
|
+
}
|
|
390
|
+
/* NONZERO DOES NOT MEAN "I LOST" (finding 3). A transport failure exits 1
|
|
391
|
+
with no rejection marker, so the register is re-read: a value that is no
|
|
392
|
+
longer the expected one settles it as a loss, and anything else is
|
|
393
|
+
reported as INDETERMINATE rather than guessed in either direction. */
|
|
394
|
+
const after = readRegister(fleetRoot, config);
|
|
395
|
+
if (after.kind === "present" && after.sha !== expectedSha && after.sha !== sha) {
|
|
396
|
+
return { kind: "lost", reason: `${config.ref} moved to ${after.sha} while this write was in flight` };
|
|
397
|
+
}
|
|
398
|
+
if (after.kind === "present" && after.sha === sha) {
|
|
399
|
+
return { kind: "won", sha };
|
|
400
|
+
}
|
|
401
|
+
return {
|
|
402
|
+
kind: "indeterminate",
|
|
403
|
+
reason: `push failed without a rejection marker: ${firstLine(pushed.stderr)}`,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
/* ------------------------------------------------------------------ */
|
|
407
|
+
/* The counter signal */
|
|
408
|
+
/* ------------------------------------------------------------------ */
|
|
409
|
+
export function readObservation(fleetRoot) {
|
|
410
|
+
const read = readJsonFile(join(fleetRoot, OBSERVATION_FILE));
|
|
411
|
+
if (!read.ok) {
|
|
412
|
+
return undefined;
|
|
413
|
+
}
|
|
414
|
+
const value = read.value;
|
|
415
|
+
if (value === null ||
|
|
416
|
+
typeof value.sha !== "string" ||
|
|
417
|
+
typeof value.counter !== "number" ||
|
|
418
|
+
typeof value.firstSeenMs !== "number") {
|
|
419
|
+
return undefined;
|
|
420
|
+
}
|
|
421
|
+
return { sha: value.sha, counter: value.counter, firstSeenMs: value.firstSeenMs };
|
|
422
|
+
}
|
|
423
|
+
export function writeObservation(fleetRoot, observation) {
|
|
424
|
+
const path = join(fleetRoot, OBSERVATION_FILE);
|
|
425
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
426
|
+
const refusal = refuseOpenPathForWrite(path);
|
|
427
|
+
if (refusal !== undefined) {
|
|
428
|
+
throw new Error(refusal);
|
|
429
|
+
}
|
|
430
|
+
writeFileSync(path, `${JSON.stringify(observation, null, 2)}\n`, "utf8");
|
|
431
|
+
}
|
|
432
|
+
/**
|
|
433
|
+
* Judge a holder by the FENCING COUNTER, using two readings of ONE clock.
|
|
434
|
+
*
|
|
435
|
+
* A holder is stale only when the register's sha and counter are the same
|
|
436
|
+
* ones this environment first saw at `firstSeenMs` and that much of ITS OWN
|
|
437
|
+
* time has passed. Any advance of the counter resets the measurement, which
|
|
438
|
+
* is what makes a renewing holder safe no matter how far its clock is from
|
|
439
|
+
* this one: the renewal is visible as an increment, and an increment is not
|
|
440
|
+
* a timestamp.
|
|
441
|
+
*/
|
|
442
|
+
export function judgeByCounter(previous, sha, counter, nowMs, staleWindowMs) {
|
|
443
|
+
if (previous !== undefined && previous.sha === sha && previous.counter === counter) {
|
|
444
|
+
const unchangedForMs = Math.max(0, nowMs - previous.firstSeenMs);
|
|
445
|
+
return { stale: unchangedForMs >= staleWindowMs, unchangedForMs, observation: previous };
|
|
446
|
+
}
|
|
447
|
+
return {
|
|
448
|
+
stale: false,
|
|
449
|
+
unchangedForMs: 0,
|
|
450
|
+
observation: { sha, counter, firstSeenMs: nowMs },
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
function unreachableLine(config, reason, intent) {
|
|
454
|
+
return (`shared exclusion refused ${intent}: register ${config.ref} on ${config.remote} is ` +
|
|
455
|
+
`unreachable (${reason}); signal=clock, because no fencing counter could be read and ` +
|
|
456
|
+
`a local clock is not a cross-environment signal, so this refuses instead of falling ` +
|
|
457
|
+
`back to local-only exclusion`);
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Decide whether the caller may proceed, WITHOUT touching anything. Every
|
|
461
|
+
* refusal here happens before the local lease file is created, which is what
|
|
462
|
+
* makes criterion 7's fail-closed assertion observable: an unreachable
|
|
463
|
+
* register leaves no local lock behind.
|
|
464
|
+
*/
|
|
465
|
+
export function preflightShared(input) {
|
|
466
|
+
const { config, envId, intent, nowMs } = input;
|
|
467
|
+
const read = readRegister(input.fleetRoot, config);
|
|
468
|
+
if (read.kind === "unreachable") {
|
|
469
|
+
return { kind: "refused", signal: "clock", line: unreachableLine(config, read.reason, intent) };
|
|
470
|
+
}
|
|
471
|
+
if (read.kind === "corrupt") {
|
|
472
|
+
return {
|
|
473
|
+
kind: "refused",
|
|
474
|
+
signal: "counter",
|
|
475
|
+
line: `shared exclusion refused ${intent}: register ${config.ref} at ${read.sha} is not a ` +
|
|
476
|
+
`lease document (${read.reason}); signal=counter, no counter could be established, ` +
|
|
477
|
+
`inspect the ref manually`,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
if (read.kind === "absent") {
|
|
481
|
+
if (intent !== "acquire") {
|
|
482
|
+
return {
|
|
483
|
+
kind: "refused",
|
|
484
|
+
signal: "counter",
|
|
485
|
+
line: `shared exclusion refused ${intent}: register ${config.ref} on ${config.remote} ` +
|
|
486
|
+
`holds no lease, so there is nothing for environment ${envId} to ${intent}; ` +
|
|
487
|
+
`signal=counter`,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
return {
|
|
491
|
+
kind: "proceed",
|
|
492
|
+
signal: "counter",
|
|
493
|
+
expectedSha: "",
|
|
494
|
+
nextCounter: 1,
|
|
495
|
+
takingOver: false,
|
|
496
|
+
line: `shared exclusion: register ${config.ref} on ${config.remote} was absent, claiming ` +
|
|
497
|
+
`it for environment ${envId} at counter 1; signal=counter`,
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
const document = read.document;
|
|
501
|
+
const judged = judgeByCounter(readObservation(input.fleetRoot), read.sha, document.counter, nowMs, config.staleWindowSeconds * 1000);
|
|
502
|
+
writeObservation(input.fleetRoot, judged.observation);
|
|
503
|
+
const nextCounter = document.counter + 1;
|
|
504
|
+
const held = document.state === "held";
|
|
505
|
+
if (intent === "renew" || intent === "release") {
|
|
506
|
+
if (!held || document.envId !== envId) {
|
|
507
|
+
return {
|
|
508
|
+
kind: "refused",
|
|
509
|
+
signal: "counter",
|
|
510
|
+
line: `shared exclusion refused ${intent}: register ${config.ref} is ${document.state} by ` +
|
|
511
|
+
`environment ${document.envId} at counter ${String(document.counter)}, not by ${envId}; ` +
|
|
512
|
+
`signal=counter, the register sha ${read.sha} is left unchanged`,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
return {
|
|
516
|
+
kind: "proceed",
|
|
517
|
+
signal: "counter",
|
|
518
|
+
expectedSha: read.sha,
|
|
519
|
+
nextCounter,
|
|
520
|
+
takingOver: false,
|
|
521
|
+
current: document,
|
|
522
|
+
line: `shared exclusion: ${intent} by environment ${envId} advances ${config.ref} to counter ` +
|
|
523
|
+
`${String(nextCounter)}; signal=counter`,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
if (!held) {
|
|
527
|
+
return {
|
|
528
|
+
kind: "proceed",
|
|
529
|
+
signal: "counter",
|
|
530
|
+
expectedSha: read.sha,
|
|
531
|
+
nextCounter,
|
|
532
|
+
takingOver: false,
|
|
533
|
+
current: document,
|
|
534
|
+
line: `shared exclusion: register ${config.ref} was released by environment ${document.envId}, ` +
|
|
535
|
+
`claiming it for ${envId} at counter ${String(nextCounter)}; signal=counter`,
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
if (!judged.stale) {
|
|
539
|
+
return {
|
|
540
|
+
kind: "refused",
|
|
541
|
+
signal: "counter",
|
|
542
|
+
line: `shared exclusion refused acquire: register ${config.ref} is held by environment ` +
|
|
543
|
+
`${document.envId} until ${document.expiresAt}; signal=counter, fencing counter ` +
|
|
544
|
+
`${String(document.counter)} has stood still for ${String(judged.unchangedForMs)}ms of the ` +
|
|
545
|
+
`${String(config.staleWindowSeconds * 1000)}ms this environment requires, and no clock ` +
|
|
546
|
+
`comparison was made`,
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
if (!input.takeover) {
|
|
550
|
+
return {
|
|
551
|
+
kind: "refused",
|
|
552
|
+
signal: "counter",
|
|
553
|
+
line: `shared exclusion refused acquire: register ${config.ref} is held by environment ` +
|
|
554
|
+
`${document.envId} and its fencing counter ${String(document.counter)} has stood still for ` +
|
|
555
|
+
`${String(judged.unchangedForMs)}ms, which is stale; signal=counter, takeover is explicit: ` +
|
|
556
|
+
`lock acquire --take-over`,
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
return {
|
|
560
|
+
kind: "proceed",
|
|
561
|
+
signal: "counter",
|
|
562
|
+
expectedSha: read.sha,
|
|
563
|
+
nextCounter,
|
|
564
|
+
takingOver: true,
|
|
565
|
+
current: document,
|
|
566
|
+
line: `shared exclusion: taking over ${config.ref} from environment ${document.envId}, whose ` +
|
|
567
|
+
`fencing counter ${String(document.counter)} stood still for ${String(judged.unchangedForMs)}ms; ` +
|
|
568
|
+
`environment ${envId} advances it to ${String(nextCounter)}; signal=counter`,
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
/**
|
|
572
|
+
* The one line a register write that was NOT won reports. It lives here
|
|
573
|
+
* rather than at the call site in `src/lock.ts` because it names the
|
|
574
|
+
* staleness basis, and the C-2 structural inspection over that file
|
|
575
|
+
* (test/lock.test.ts:534) forbids that vocabulary there; keeping the
|
|
576
|
+
* sentence in one place also means a reader sees the same wording whichever
|
|
577
|
+
* mutation lost.
|
|
578
|
+
*/
|
|
579
|
+
export function casFailureLine(ref, outcome) {
|
|
580
|
+
if (outcome.kind === "won") {
|
|
581
|
+
return `the compare-and-swap on ${ref} was won`;
|
|
582
|
+
}
|
|
583
|
+
return (`the compare-and-swap on ${ref} was ${outcome.kind}: ${outcome.reason}; ` +
|
|
584
|
+
`signal=counter`);
|
|
585
|
+
}
|
|
586
|
+
/** Build the document a won preflight should publish. */
|
|
587
|
+
export function buildRegisterDocument(input) {
|
|
588
|
+
return {
|
|
589
|
+
state: input.state,
|
|
590
|
+
envId: input.envId,
|
|
591
|
+
counter: input.counter,
|
|
592
|
+
acquiredAt: input.acquiredAt ?? new Date(input.nowMs).toISOString(),
|
|
593
|
+
expiresAt: new Date(input.nowMs + input.durationSeconds * 1000).toISOString(),
|
|
594
|
+
durationSeconds: input.durationSeconds,
|
|
595
|
+
ref: input.ref,
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
/* ------------------------------------------------------------------ */
|
|
599
|
+
/* What the rest of the kernel asks the register (M4-P22) */
|
|
600
|
+
/* ------------------------------------------------------------------ */
|
|
601
|
+
/**
|
|
602
|
+
* THE FOUR STATUSES, AS A CLOSED SET (M4-P22 criterion 1).
|
|
603
|
+
*
|
|
604
|
+
* `doctor` prints exactly one of them and a test compares the printed
|
|
605
|
+
* leading token against this array, so a fifth status cannot be added by
|
|
606
|
+
* writing a new sentence somewhere: it has to be added here, where the
|
|
607
|
+
* comparison sees it.
|
|
608
|
+
*
|
|
609
|
+
* THE FOURTH IS NEVER PASS. That is the DR-0038 shape reused rather than
|
|
610
|
+
* reinvented: a check whose question could not be asked reports a third
|
|
611
|
+
* state instead of being forced into a binary, because an unreachable
|
|
612
|
+
* register reported as `free` would license exactly the second live
|
|
613
|
+
* orchestrator this layer exists to refuse
|
|
614
|
+
* (delivery/decisions/DR-0038-the-declared-single-family-review-exception.md:1).
|
|
615
|
+
*/
|
|
616
|
+
export const SHARED_LOCK_STATUS_TOKENS = [
|
|
617
|
+
"not-declared",
|
|
618
|
+
"free",
|
|
619
|
+
"held",
|
|
620
|
+
"unreachable",
|
|
621
|
+
];
|
|
622
|
+
function unreachableStatus(reason) {
|
|
623
|
+
return { token: "unreachable", reason, text: `unreachable ${reason}` };
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* A PURE READ of this environment's identity: it never generates one.
|
|
627
|
+
*
|
|
628
|
+
* `ensureEnvironmentId` above writes a file when none is there, which is
|
|
629
|
+
* right for `lock acquire`, the command that legitimately enters the layer
|
|
630
|
+
* and is about to publish a register document. It is wrong for a GUARD: a
|
|
631
|
+
* refusal must create nothing, and `doctor` must diagnose a fleet without
|
|
632
|
+
* changing it. An absent identity file therefore reads as "this environment
|
|
633
|
+
* is not the one the register names", which is the fail-closed answer.
|
|
634
|
+
*/
|
|
635
|
+
export function readEnvironmentId(fleetRoot) {
|
|
636
|
+
const read = readJsonFile(join(fleetRoot, ENVIRONMENT_ID_FILE));
|
|
637
|
+
if (!read.ok) {
|
|
638
|
+
return undefined;
|
|
639
|
+
}
|
|
640
|
+
const value = read.value;
|
|
641
|
+
const existing = value === null ? undefined : value["envId"];
|
|
642
|
+
return typeof existing === "string" && existing !== "" ? existing : undefined;
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* The register's state as one of the four statuses, WITHOUT writing an
|
|
646
|
+
* observation and WITHOUT generating an identity.
|
|
647
|
+
*
|
|
648
|
+
* `preflightShared` is deliberately not used here even though it answers a
|
|
649
|
+
* similar question, because it WRITES `state/shared-lease.observed.json` as
|
|
650
|
+
* part of judging staleness. A diagnosis that moves the thing it diagnoses
|
|
651
|
+
* is not a diagnosis, and the staleness judgement is not wanted here anyway:
|
|
652
|
+
* see `guardSharedRegister` below for why a stale holder still refuses.
|
|
653
|
+
*
|
|
654
|
+
* It DOES fetch, because `readRegister` fetches: the register object has to
|
|
655
|
+
* be local before its document can be read. That touches `.git/FETCH_HEAD`
|
|
656
|
+
* in the fleet home and nothing else, and it is stated here rather than left
|
|
657
|
+
* for a reader to discover.
|
|
658
|
+
*
|
|
659
|
+
* TWO CONDITIONS COLLAPSE INTO `unreachable` AND BOTH ARE NAMED IN THE
|
|
660
|
+
* REASON. A declaration that is present and unusable, and a register whose
|
|
661
|
+
* document does not parse, are not `free` and cannot be rendered as
|
|
662
|
+
* `held <envId> until <t>` because neither yields an envId or an expiry. The
|
|
663
|
+
* criterion's set is closed at four, so they take the one status that means
|
|
664
|
+
* "this question could not be answered", and the reason says which of them
|
|
665
|
+
* it was.
|
|
666
|
+
*/
|
|
667
|
+
export function sharedLockStatus(fleetRoot) {
|
|
668
|
+
const declaration = readSharedExclusion(fleetRoot);
|
|
669
|
+
if (declaration.kind === "absent") {
|
|
670
|
+
return {
|
|
671
|
+
token: "not-declared",
|
|
672
|
+
text: `not-declared (${SHARED_EXCLUSION_FIELD} is absent from this fleet ` +
|
|
673
|
+
`home's package.json, so the cross-environment layer is off here)`,
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
if (declaration.kind === "invalid") {
|
|
677
|
+
return unreachableStatus(`the declaration naming the register is unusable: ${declaration.reason}`);
|
|
678
|
+
}
|
|
679
|
+
const config = declaration.config;
|
|
680
|
+
const where = `register ${config.ref} on ${config.remote}`;
|
|
681
|
+
const read = readRegister(fleetRoot, config);
|
|
682
|
+
if (read.kind === "unreachable") {
|
|
683
|
+
return unreachableStatus(`${where} could not be read: ${read.reason}`);
|
|
684
|
+
}
|
|
685
|
+
if (read.kind === "corrupt") {
|
|
686
|
+
return unreachableStatus(`${where} is not a lease document: ${read.reason}`);
|
|
687
|
+
}
|
|
688
|
+
if (read.kind === "absent") {
|
|
689
|
+
return { token: "free", text: `free (${where} holds no lease yet)` };
|
|
690
|
+
}
|
|
691
|
+
const document = read.document;
|
|
692
|
+
if (document.state === "free") {
|
|
693
|
+
return {
|
|
694
|
+
token: "free",
|
|
695
|
+
text: `free (${where} was released by environment ${document.envId} at ` +
|
|
696
|
+
`fencing counter ${String(document.counter)})`,
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
return {
|
|
700
|
+
token: "held",
|
|
701
|
+
envId: document.envId,
|
|
702
|
+
expiresAt: document.expiresAt,
|
|
703
|
+
text: `held ${document.envId} until ${document.expiresAt} (${where}, fencing ` +
|
|
704
|
+
`counter ${String(document.counter)})`,
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
/**
|
|
708
|
+
* THE CROSS-ENVIRONMENT HALF OF THE HOLDERSHIP GUARD (M4-P22 criteria 2
|
|
709
|
+
* and 3).
|
|
710
|
+
*
|
|
711
|
+
* `checkHoldership` (src/task.ts:439) answers "does THIS process hold THIS
|
|
712
|
+
* filesystem's lease". That question is answered entirely inside one fleet
|
|
713
|
+
* home, and src/lock.ts:63 says so: the local lease excludes within one
|
|
714
|
+
* filesystem and one clock. So in the state this function exists for, the
|
|
715
|
+
* local lease held by THIS environment and the shared register naming
|
|
716
|
+
* ANOTHER one, the old guard is GREEN and the fleet has two orchestrators
|
|
717
|
+
* mutating one set of tasks. That is the dangerous state, and a test that
|
|
718
|
+
* holds neither lease is green without this function and proves nothing.
|
|
719
|
+
*
|
|
720
|
+
* WHY A STALE HOLDER STILL REFUSES. `judgeByCounter` exists so that a lease
|
|
721
|
+
* whose fencing counter has stood still can be taken over, and that takeover
|
|
722
|
+
* is a LEASE operation: `tiphys lock acquire --take-over` advances the
|
|
723
|
+
* counter under the taking-over environment's id, and only then does the
|
|
724
|
+
* register name this environment. Reading staleness here instead would give
|
|
725
|
+
* `spawn` and `teardown` their own opinion about who holds the fleet, which
|
|
726
|
+
* is a second verdict about one lease from a second place, and it would have
|
|
727
|
+
* to write the observation file to reach it. The refusal names the command
|
|
728
|
+
* that resolves it, so the remedy is reachable rather than merely correct.
|
|
729
|
+
*
|
|
730
|
+
* THE REASON IS ONE LINE, and the callers hand it straight to the same
|
|
731
|
+
* single-reason path every other refusal uses.
|
|
732
|
+
*/
|
|
733
|
+
export function guardSharedRegister(fleetRoot, command) {
|
|
734
|
+
const status = sharedLockStatus(fleetRoot);
|
|
735
|
+
if (status.token === "not-declared") {
|
|
736
|
+
return { kind: "off" };
|
|
737
|
+
}
|
|
738
|
+
if (status.token === "unreachable") {
|
|
739
|
+
return {
|
|
740
|
+
kind: "refused",
|
|
741
|
+
reason: `shared exclusion refused ${command}: ${status.text}; a register that ` +
|
|
742
|
+
`cannot be read cannot show whether another environment is running this ` +
|
|
743
|
+
`fleet, so this refuses rather than falling back to local-only ` +
|
|
744
|
+
`exclusion; signal=clock, because no fencing counter could be read`,
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
if (status.token === "free") {
|
|
748
|
+
return { kind: "allowed", status };
|
|
749
|
+
}
|
|
750
|
+
const mine = readEnvironmentId(fleetRoot);
|
|
751
|
+
if (mine !== undefined && mine === status.envId) {
|
|
752
|
+
/* THE IDENTITY IS NOT ENOUGH, AND THE PLAN SAYS WHY IN BOTH DIRECTIONS.
|
|
753
|
+
M4-P21 criterion 3 requires `tiphys-environment.json` to be TRACKED so
|
|
754
|
+
it survives a reclaim and TRAVELS WITH THE CLONE, and
|
|
755
|
+
test/cross-environment-lock.test.ts:328 asserts exactly that. M4-P22
|
|
756
|
+
criteria 2 and 3 require a clone to be refused as a different
|
|
757
|
+
environment. Both are delivered, and composed they cancel: `tiphys
|
|
758
|
+
sync` commits and pushes that file (it is not under any prefix in
|
|
759
|
+
FLEET_IGNORED), so every clone of a synced fleet reads the SAME id and
|
|
760
|
+
this comparison is TRUE in a place that holds nothing.
|
|
761
|
+
|
|
762
|
+
WHAT DOES NOT TRAVEL is `state/`, which IS in FLEET_IGNORED, so the
|
|
763
|
+
lease artifact is per environment BY CONSTRUCTION rather than by a
|
|
764
|
+
rule someone has to remember. Requiring it here breaks no criterion's
|
|
765
|
+
letter: the tracked file is unchanged and still travels, and nothing
|
|
766
|
+
in `src/` or `bin/` consumed the travels-with-the-clone property for
|
|
767
|
+
anything but this comparison.
|
|
768
|
+
|
|
769
|
+
THE COST, stated rather than discovered: an environment whose `state/`
|
|
770
|
+
is lost while its container continues is refused here until the stale
|
|
771
|
+
window lets it take the register over, which is the smoothing
|
|
772
|
+
criterion 3's tracking was meant to provide. That is a deliberate
|
|
773
|
+
trade of convenience after a reclaim for a guard that a clone cannot
|
|
774
|
+
walk through.
|
|
775
|
+
|
|
776
|
+
EXPIRY IS DELIBERATELY NOT JUDGED HERE. `checkHoldership`
|
|
777
|
+
(src/task.ts:518) runs BEFORE this guard in both callers
|
|
778
|
+
(src/spawn.ts:1005 and src/teardown.ts:438) and refuses an expired or
|
|
779
|
+
wrongly-held lease already. A second expiry comparison in a second
|
|
780
|
+
place is the drift src/lock.ts:217 exists to prevent, and this guard
|
|
781
|
+
does not need it: the question it asks is whether this filesystem
|
|
782
|
+
carries the lease artifact the register entry stands on. */
|
|
783
|
+
const local = localLeaseArtifact(fleetRoot);
|
|
784
|
+
if (local.kind === "present") {
|
|
785
|
+
return { kind: "allowed", status };
|
|
786
|
+
}
|
|
787
|
+
return {
|
|
788
|
+
kind: "refused",
|
|
789
|
+
reason: `shared exclusion refused ${command}: the shared register names ` +
|
|
790
|
+
`environment ${status.envId} as holding this fleet until ` +
|
|
791
|
+
`${status.expiresAt}, and this fleet home carries that environment's ` +
|
|
792
|
+
`tracked identity but not its lease (${local.reason}); ` +
|
|
793
|
+
`${ENVIRONMENT_ID_FILE} is tracked and travels with a clone, so it ` +
|
|
794
|
+
`names the FLEET's environment and not THIS one, while ${LOCK_FILE} ` +
|
|
795
|
+
`is gitignored and cannot travel; acquire the fleet here with: ` +
|
|
796
|
+
`tiphys lock acquire, or take it over with: ` +
|
|
797
|
+
`tiphys lock acquire --take-over; signal=counter`,
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
return {
|
|
801
|
+
kind: "refused",
|
|
802
|
+
reason: `shared exclusion refused ${command}: the shared register names ` +
|
|
803
|
+
`environment ${status.envId} as holding this fleet until ` +
|
|
804
|
+
`${status.expiresAt}, and this environment is ` +
|
|
805
|
+
`${mine ?? "not identified: " + ENVIRONMENT_ID_FILE + " is absent"}; ` +
|
|
806
|
+
`the local lease is evidence about this filesystem only, so mutating ` +
|
|
807
|
+
`tasks here would run a second orchestrator over one fleet; take the ` +
|
|
808
|
+
`fleet over with: tiphys lock acquire --take-over; signal=counter`,
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
/**
|
|
812
|
+
* IS THE LEASE ARTIFACT THE REGISTER ENTRY STANDS ON PRESENT ON THIS
|
|
813
|
+
* FILESYSTEM?
|
|
814
|
+
*
|
|
815
|
+
* Deliberately NOT `leaseStatus` from src/lock.ts. That module imports this
|
|
816
|
+
* one, and the `src/` import graph is a strict DAG at this head; importing it
|
|
817
|
+
* back would make the first cycle in the kernel to answer a question that
|
|
818
|
+
* needs one field. So this reads the lease file through the same guarded
|
|
819
|
+
* read every other path in this module now uses, and asks only whether a
|
|
820
|
+
* parsed lease with a holder is there.
|
|
821
|
+
*/
|
|
822
|
+
function localLeaseArtifact(fleetRoot) {
|
|
823
|
+
const path = join(fleetRoot, LOCK_FILE);
|
|
824
|
+
const read = readRegularPathIfPresent(path);
|
|
825
|
+
if (read.kind === "absent") {
|
|
826
|
+
return { kind: "absent", reason: `${LOCK_FILE} is absent here` };
|
|
827
|
+
}
|
|
828
|
+
if (read.kind === "refused") {
|
|
829
|
+
return { kind: "absent", reason: read.reason };
|
|
830
|
+
}
|
|
831
|
+
let parsed;
|
|
832
|
+
try {
|
|
833
|
+
parsed = JSON.parse(read.body);
|
|
834
|
+
}
|
|
835
|
+
catch {
|
|
836
|
+
return { kind: "absent", reason: `${LOCK_FILE} does not parse as a lease` };
|
|
837
|
+
}
|
|
838
|
+
const holderId = parsed?.holderId;
|
|
839
|
+
if (typeof holderId !== "string" || holderId === "") {
|
|
840
|
+
return { kind: "absent", reason: `${LOCK_FILE} names no holder` };
|
|
841
|
+
}
|
|
842
|
+
return { kind: "present", holderId };
|
|
843
|
+
}
|