@tiphys/kernel 0.0.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.
Files changed (207) hide show
  1. package/AGENTS.md +663 -0
  2. package/LICENSE +202 -0
  3. package/assurance-modes.yaml +299 -0
  4. package/checklists/clean-room.yaml +325 -0
  5. package/checklists/env-failure-diagnosis.yaml +68 -0
  6. package/checklists/flake-playbook.yaml +68 -0
  7. package/checklists/hazard-review.yaml +144 -0
  8. package/checklists/plan-review.yaml +103 -0
  9. package/dist/bin/tiphys.d.ts +2 -0
  10. package/dist/bin/tiphys.js +92 -0
  11. package/dist/src/adapters/load.d.ts +202 -0
  12. package/dist/src/adapters/load.js +440 -0
  13. package/dist/src/brief.d.ts +32 -0
  14. package/dist/src/brief.js +66 -0
  15. package/dist/src/checklists.d.ts +169 -0
  16. package/dist/src/checklists.js +310 -0
  17. package/dist/src/checks.d.ts +1539 -0
  18. package/dist/src/checks.js +5025 -0
  19. package/dist/src/cli.d.ts +9 -0
  20. package/dist/src/cli.js +68 -0
  21. package/dist/src/commands/brief.d.ts +92 -0
  22. package/dist/src/commands/brief.js +343 -0
  23. package/dist/src/commands/checklist.d.ts +42 -0
  24. package/dist/src/commands/checklist.js +168 -0
  25. package/dist/src/commands/cutover.d.ts +35 -0
  26. package/dist/src/commands/cutover.js +448 -0
  27. package/dist/src/commands/doctor.d.ts +264 -0
  28. package/dist/src/commands/doctor.js +1546 -0
  29. package/dist/src/commands/gates.d.ts +9 -0
  30. package/dist/src/commands/gates.js +360 -0
  31. package/dist/src/commands/init.d.ts +30 -0
  32. package/dist/src/commands/init.js +177 -0
  33. package/dist/src/commands/lock.d.ts +34 -0
  34. package/dist/src/commands/lock.js +340 -0
  35. package/dist/src/commands/mode.d.ts +40 -0
  36. package/dist/src/commands/mode.js +134 -0
  37. package/dist/src/commands/next.d.ts +130 -0
  38. package/dist/src/commands/next.js +597 -0
  39. package/dist/src/commands/plan.d.ts +20 -0
  40. package/dist/src/commands/plan.js +105 -0
  41. package/dist/src/commands/pool.d.ts +1 -0
  42. package/dist/src/commands/pool.js +139 -0
  43. package/dist/src/commands/resume.d.ts +1 -0
  44. package/dist/src/commands/resume.js +88 -0
  45. package/dist/src/commands/spawn.d.ts +1 -0
  46. package/dist/src/commands/spawn.js +195 -0
  47. package/dist/src/commands/status.d.ts +21 -0
  48. package/dist/src/commands/status.js +122 -0
  49. package/dist/src/commands/sync.d.ts +47 -0
  50. package/dist/src/commands/sync.js +341 -0
  51. package/dist/src/commands/teardown.d.ts +1 -0
  52. package/dist/src/commands/teardown.js +87 -0
  53. package/dist/src/commands/tuition.d.ts +21 -0
  54. package/dist/src/commands/tuition.js +218 -0
  55. package/dist/src/commands/validate.d.ts +78 -0
  56. package/dist/src/commands/validate.js +430 -0
  57. package/dist/src/commands/watch.d.ts +1 -0
  58. package/dist/src/commands/watch.js +172 -0
  59. package/dist/src/cutover.d.ts +584 -0
  60. package/dist/src/cutover.js +1444 -0
  61. package/dist/src/exclusion.d.ts +389 -0
  62. package/dist/src/exclusion.js +843 -0
  63. package/dist/src/exec/env.d.ts +278 -0
  64. package/dist/src/exec/env.js +334 -0
  65. package/dist/src/fleet.d.ts +223 -0
  66. package/dist/src/fleet.js +298 -0
  67. package/dist/src/gates/adapters/http-json.d.ts +5 -0
  68. package/dist/src/gates/adapters/http-json.js +283 -0
  69. package/dist/src/gates/adapters/migrations-command.d.ts +1 -0
  70. package/dist/src/gates/adapters/migrations-command.js +373 -0
  71. package/dist/src/gates/citations.d.ts +408 -0
  72. package/dist/src/gates/citations.js +1169 -0
  73. package/dist/src/gates/coverage.d.ts +375 -0
  74. package/dist/src/gates/coverage.js +836 -0
  75. package/dist/src/gates/credentials.d.ts +233 -0
  76. package/dist/src/gates/credentials.js +752 -0
  77. package/dist/src/gates/deploy.d.ts +1 -0
  78. package/dist/src/gates/deploy.js +33 -0
  79. package/dist/src/gates/gate-classes.d.ts +56 -0
  80. package/dist/src/gates/gate-classes.js +633 -0
  81. package/dist/src/gates/manifest.d.ts +99 -0
  82. package/dist/src/gates/manifest.js +208 -0
  83. package/dist/src/gates/merge-preconditions.d.ts +319 -0
  84. package/dist/src/gates/merge-preconditions.js +932 -0
  85. package/dist/src/gates/migrations.d.ts +1 -0
  86. package/dist/src/gates/migrations.js +36 -0
  87. package/dist/src/gates/pin.d.ts +114 -0
  88. package/dist/src/gates/pin.js +154 -0
  89. package/dist/src/gates/red-witness.d.ts +22 -0
  90. package/dist/src/gates/red-witness.js +482 -0
  91. package/dist/src/gates/release.d.ts +283 -0
  92. package/dist/src/gates/release.js +820 -0
  93. package/dist/src/gates/result.d.ts +116 -0
  94. package/dist/src/gates/result.js +91 -0
  95. package/dist/src/gates/run.d.ts +614 -0
  96. package/dist/src/gates/run.js +1614 -0
  97. package/dist/src/gates/schemas/citation-config.schema.json +59 -0
  98. package/dist/src/gates/schemas/coverage-config.schema.json +77 -0
  99. package/dist/src/gates/schemas/gate-manifest.schema.json +125 -0
  100. package/dist/src/gates/schemas/gate-result.schema.json +160 -0
  101. package/dist/src/gates/schemas/phase-declaration.schema.json +87 -0
  102. package/dist/src/gates/schemas/release-record.schema.json +119 -0
  103. package/dist/src/gates/schemas/verifier-config.schema.json +101 -0
  104. package/dist/src/gates/schemas/witness-spec.schema.json +110 -0
  105. package/dist/src/gates/scope.d.ts +131 -0
  106. package/dist/src/gates/scope.js +1018 -0
  107. package/dist/src/gates/suite.d.ts +217 -0
  108. package/dist/src/gates/suite.js +968 -0
  109. package/dist/src/gates/validate.d.ts +121 -0
  110. package/dist/src/gates/validate.js +414 -0
  111. package/dist/src/hooks.d.ts +84 -0
  112. package/dist/src/hooks.js +125 -0
  113. package/dist/src/index.d.ts +31 -0
  114. package/dist/src/index.js +30 -0
  115. package/dist/src/liveness.d.ts +321 -0
  116. package/dist/src/liveness.js +396 -0
  117. package/dist/src/lock.d.ts +256 -0
  118. package/dist/src/lock.js +792 -0
  119. package/dist/src/model-resolution.d.ts +159 -0
  120. package/dist/src/model-resolution.js +307 -0
  121. package/dist/src/modes.d.ts +149 -0
  122. package/dist/src/modes.js +258 -0
  123. package/dist/src/path-identity.d.ts +34 -0
  124. package/dist/src/path-identity.js +48 -0
  125. package/dist/src/plan.d.ts +73 -0
  126. package/dist/src/plan.js +153 -0
  127. package/dist/src/pool.d.ts +326 -0
  128. package/dist/src/pool.js +988 -0
  129. package/dist/src/roles.d.ts +461 -0
  130. package/dist/src/roles.js +776 -0
  131. package/dist/src/spawn.d.ts +482 -0
  132. package/dist/src/spawn.js +1003 -0
  133. package/dist/src/status.d.ts +116 -0
  134. package/dist/src/status.js +148 -0
  135. package/dist/src/task.d.ts +504 -0
  136. package/dist/src/task.js +307 -0
  137. package/dist/src/teardown.d.ts +39 -0
  138. package/dist/src/teardown.js +422 -0
  139. package/dist/src/tuition.d.ts +159 -0
  140. package/dist/src/tuition.js +311 -0
  141. package/dist/src/validate.d.ts +263 -0
  142. package/dist/src/validate.js +742 -0
  143. package/dist/src/version.d.ts +3 -0
  144. package/dist/src/version.js +38 -0
  145. package/dist/src/watcher.d.ts +275 -0
  146. package/dist/src/watcher.js +849 -0
  147. package/dist/src/witness/run.d.ts +299 -0
  148. package/dist/src/witness/run.js +1373 -0
  149. package/dist/src/witness/spec.d.ts +270 -0
  150. package/dist/src/witness/spec.js +475 -0
  151. package/dist/tsconfig.src.tsbuildinfo +1 -0
  152. package/gate-registry.yaml +526 -0
  153. package/gates.manifest.json +257 -0
  154. package/package.json +72 -3
  155. package/role-model-config.yaml +88 -0
  156. package/roles/README.md +128 -0
  157. package/roles/_shared-dispatch-contract.md +87 -0
  158. package/roles/adversarial-plan-reviewer.md +80 -0
  159. package/roles/clean-room-reviewer.md +140 -0
  160. package/roles/implementer.md +463 -0
  161. package/roles/investigator.md +138 -0
  162. package/roles/plan-writer.md +95 -0
  163. package/schemas/README.md +82 -0
  164. package/schemas/assurance-modes.schema.json +264 -0
  165. package/schemas/charter.schema.json +185 -0
  166. package/schemas/checklist.schema.json +114 -0
  167. package/schemas/cutover-state.schema.json +64 -0
  168. package/schemas/decision-record.schema.json +88 -0
  169. package/schemas/executor-record.schema.json +36 -0
  170. package/schemas/final-report.schema.json +90 -0
  171. package/schemas/finding.schema.json +106 -0
  172. package/schemas/gate-registry.schema.json +260 -0
  173. package/schemas/mechanism-index.schema.json +94 -0
  174. package/schemas/model-resolution.schema.json +362 -0
  175. package/schemas/plan.schema.json +300 -0
  176. package/schemas/report.schema.json +579 -0
  177. package/schemas/role-brief.schema.json +105 -0
  178. package/schemas/role-model-config.schema.json +90 -0
  179. package/schemas/status-line.schema.json +40 -0
  180. package/schemas/tuition.schema.json +191 -0
  181. package/schemas/verdict.schema.json +295 -0
  182. package/schemas/work-history.schema.json +183 -0
  183. package/schemas/write-bypass.schema.json +69 -0
  184. package/templates/charter.example.yaml +54 -0
  185. package/templates/decision-record.example.yaml +27 -0
  186. package/templates/final-report.example.yaml +80 -0
  187. package/templates/plan.example.yaml +87 -0
  188. package/templates/report.example.yaml +236 -0
  189. package/templates/warnings.md +74 -0
  190. package/templates/work-history.example.yaml +185 -0
  191. package/tuition/README.md +76 -0
  192. package/tuition/T-001.yaml +48 -0
  193. package/tuition/T-002.yaml +51 -0
  194. package/tuition/T-003.yaml +100 -0
  195. package/tuition/T-004.yaml +52 -0
  196. package/tuition/T-005.yaml +72 -0
  197. package/tuition/T-006.yaml +81 -0
  198. package/tuition/T-007.yaml +56 -0
  199. package/tuition/T-008.yaml +111 -0
  200. package/tuition/T-009.yaml +50 -0
  201. package/tuition/T-015.yaml +36 -0
  202. package/tuition/T-016.yaml +36 -0
  203. package/tuition/T-017.yaml +46 -0
  204. package/tuition/T-018.yaml +84 -0
  205. package/tuition/T-021.yaml +40 -0
  206. package/tuition/T-022.yaml +36 -0
  207. package/tuition/mechanism-index.yaml +256 -0
@@ -0,0 +1,792 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { linkSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
3
+ import { hostname } from "node:os";
4
+ import { setTimeout as sleep } from "node:timers/promises";
5
+ import { buildRegisterDocument, casFailureLine, casWrite, ensureEnvironmentId, fleetRootForLockPath, preflightShared, readSharedExclusion, } from "./exclusion.js";
6
+ import { readRegularPathIfPresent } from "./fleet.js";
7
+ /**
8
+ * Lease-based session lock (kernel plan v1, M1-P3 step 1; DR-0007; plan
9
+ * constraint C-2). One orchestrator per fleet: the lock file at
10
+ * state/orchestrator.lock holds a JSON lease {holderId, hostname,
11
+ * acquiredAt, expiresAt, durationSeconds, token} where holderId is an
12
+ * opaque value generated at acquire. Liveness is lease freshness only:
13
+ * this module never probes any running program in any way (FM-053), and
14
+ * the death of a holder is deliberately invisible to the lease (an
15
+ * unexpired lease excludes even when its holder is gone; recovery is the
16
+ * explicit --take-over after expiry).
17
+ *
18
+ * Mutation contract (EXT-F-01, adopted verbatim from the plan):
19
+ * - Every mutation (acquire, renew, release, takeover) goes through the
20
+ * ONE shared atomic mutation primitive, applyLeaseMutation. A mutation
21
+ * is decided against an observed lease state and applied only if the
22
+ * file still holds exactly that state; the application is confirmed by
23
+ * re-reading the unique per-mutation token it wrote. A confirmation
24
+ * showing another writer's token means the mutation lost and returns
25
+ * failure without retry. Ownership is not valid until the primitive
26
+ * completes.
27
+ * - Renew fails on an expired lease and succeeds only while holderId
28
+ * matches and expiresAt is in the future.
29
+ * - Takeover succeeds only if the observed lease is still the lease being
30
+ * replaced (compare-and-swap over the lease file content) and
31
+ * serializes with renew, release, and competing takeovers.
32
+ *
33
+ * Serialization mechanism: mutations are serialized through a claim file
34
+ * beside the lock (<lock>.mutex, created O_EXCL; FM-022's
35
+ * serialize-through-a-claim pattern), and inside that claim the primitive
36
+ * re-reads the lock, byte-compares it with the observed state, applies
37
+ * via O_EXCL create (absent lock, PR-006) or write-temp-then-rename, and
38
+ * confirms by re-reading its own token (FM-022 write-then-verify). No
39
+ * steal protocol exists on purpose (FM-058): a claim file left behind by
40
+ * a crashed mutation makes later mutations fail loudly after a bounded
41
+ * wait, naming the file for manual removal; the critical section is a
42
+ * few file operations, so this window is tiny.
43
+ *
44
+ * What the token confirmation is and is NOT (corrected per D-2; the
45
+ * previous wording here claimed a second safety net that does not
46
+ * exist). The confirmation read asserts only "my bytes are in the file
47
+ * now". That is last-writer-wins: it catches an intruder who applied
48
+ * AFTER this mutation, and it does NOT catch one who applied before and
49
+ * merely lost the race to write last. The O_EXCL claim file is
50
+ * therefore the sole serializer, and it is advisory: no handle is held
51
+ * on it and nothing checks ownership when it is unlinked. The apply is
52
+ * preceded by a second read-and-compare (stillMatches) so that a lost
53
+ * claim degrades to a clean loss instead of a double win, but that
54
+ * narrows the window rather than closing it. Deleting a live claim file
55
+ * can still produce two holders; that is why the CLI's remedy text now
56
+ * says so instead of inviting it.
57
+ *
58
+ * Exclusion domain (PR-201, DR-0007 stated honestly): the lease excludes
59
+ * within one filesystem and one clock, the fleet home the lock file lives
60
+ * in. Mutations of the lock file made outside this module (manual edits)
61
+ * are not covered by the contract.
62
+ *
63
+ * CROSS-ENVIRONMENT EXCLUSION IS A SECOND LAYER ABOVE THIS ONE (M4-P21),
64
+ * and it is OFF unless the fleet home declares it. When the fleet's own
65
+ * `package.json` carries `tiphys.sharedExclusion`, every mutation below
66
+ * first asks `src/exclusion.ts` for a verdict from the shared register on
67
+ * the fleet's git remote, and only a won verdict reaches the local lease.
68
+ * With the field ABSENT, `readSharedExclusion` returns before spawning
69
+ * anything and every path in this module behaves exactly as it did, which
70
+ * is the property M4-P21 criterion 1 asserts. The refusal never touches the
71
+ * lock file, which is what makes the fail-closed behaviour on an
72
+ * unreachable register observable (criterion 7): no local lease appears.
73
+ *
74
+ * Renewal discipline (PR-203): the default lease lasts 900 seconds and
75
+ * the holder renews at or before half-life (renewByMs). Holdership on
76
+ * mutating kernel commands (spawn, teardown) is verified against the
77
+ * lease by M1-P4.
78
+ */
79
+ export const DEFAULT_LEASE_DURATION_SECONDS = 900;
80
+ /** Bounded wait for the mutation claim file, then fail loudly. */
81
+ const MUTEX_WAIT_TOTAL_MS = 5000;
82
+ const MUTEX_WAIT_POLL_MS = 10;
83
+ function parseLease(raw) {
84
+ let parsed;
85
+ try {
86
+ parsed = JSON.parse(raw);
87
+ }
88
+ catch {
89
+ return undefined;
90
+ }
91
+ const candidate = parsed;
92
+ if (typeof candidate.holderId !== "string" ||
93
+ candidate.holderId === "" ||
94
+ typeof candidate.hostname !== "string" ||
95
+ typeof candidate.acquiredAt !== "string" ||
96
+ typeof candidate.expiresAt !== "string" ||
97
+ typeof candidate.durationSeconds !== "number" ||
98
+ typeof candidate.token !== "string" ||
99
+ Number.isNaN(Date.parse(candidate.expiresAt))) {
100
+ return undefined;
101
+ }
102
+ return candidate;
103
+ }
104
+ export function renderLease(lease) {
105
+ return `${JSON.stringify(lease, null, 2)}\n`;
106
+ }
107
+ /**
108
+ * Read the current lock file state: absent, or present with raw bytes.
109
+ *
110
+ * THE ENTRY TYPE IS ESTABLISHED BEFORE THE OPEN (T-008's shape in shipped
111
+ * code). A bare `readFileSync` here blocked FOREVER with zero output on a
112
+ * named pipe at the lease path, and took `lock status`, `lock acquire`,
113
+ * `lock renew` and `lock release` with it, while `tiphys doctor` returned in
114
+ * the same second against the same FIFO with "is a named pipe, not a regular
115
+ * file, so it was not opened". Two readers of one path, one of which
116
+ * established the type; this is now the same reader.
117
+ *
118
+ * A refusal THROWS rather than returning a fourth `ObservedLease` variant.
119
+ * The function already threw on every non-ENOENT error, so the contract its
120
+ * callers were written against is unchanged, and bin/tiphys.ts turns the
121
+ * throw into one diagnostic line and a nonzero exit.
122
+ */
123
+ export function observeLease(lockPath) {
124
+ const read = readRegularPathIfPresent(lockPath);
125
+ if (read.kind === "absent") {
126
+ return { kind: "absent" };
127
+ }
128
+ if (read.kind === "refused") {
129
+ throw new Error(read.reason);
130
+ }
131
+ return { kind: "present", raw: read.body, lease: parseLease(read.body) };
132
+ }
133
+ /**
134
+ * THE ONE EXPIRY COMPARISON IN THE KERNEL (M4-P17 criterion 2).
135
+ *
136
+ * `isExpired` below needs a whole `Lease`, and doctor's lock check does not
137
+ * have one: it reads the lease file defensively and holds only `holderId` and
138
+ * `expiresAt`, because a lease file that fails `parseLease` must still produce
139
+ * a diagnosis rather than nothing. Before this phase that forced doctor to
140
+ * carry its own `Date.parse(...) <= Date.now()`, which is a SECOND comparison
141
+ * of the same property, free to drift from this one. The boundary is where
142
+ * that drift shows: `<=` makes expiry INCLUSIVE, so a lease whose `expiresAt`
143
+ * is exactly the current millisecond is expired, and a second copy written
144
+ * with `<` disagrees for exactly one millisecond and agrees everywhere else.
145
+ * A disagreement that narrow is not something a reviewer finds by reading.
146
+ *
147
+ * So the comparison lives here once and both callers reach it.
148
+ */
149
+ export function expiryHasPassed(expiresAt, nowMs) {
150
+ return Date.parse(expiresAt) <= nowMs;
151
+ }
152
+ export function isExpired(lease, nowMs) {
153
+ return expiryHasPassed(lease.expiresAt, nowMs);
154
+ }
155
+ /** The renew-by deadline (half-life of the current term), for holders. */
156
+ export function renewByMs(lease) {
157
+ return Date.parse(lease.expiresAt) - (lease.durationSeconds * 1000) / 2;
158
+ }
159
+ /** The same guarded read as observeLease, for the inside of the claim. */
160
+ function readCurrent(lockPath) {
161
+ const read = readRegularPathIfPresent(lockPath);
162
+ if (read.kind === "absent") {
163
+ return { present: false, raw: "" };
164
+ }
165
+ if (read.kind === "refused") {
166
+ throw new Error(read.reason);
167
+ }
168
+ return { present: true, raw: read.body };
169
+ }
170
+ /**
171
+ * Re-read the lock file and re-compare it against the state a mutation
172
+ * was decided on. Used immediately before every apply (D-2).
173
+ */
174
+ function stillMatches(lockPath, observed) {
175
+ const current = readCurrent(lockPath);
176
+ if (observed.kind === "absent") {
177
+ return !current.present;
178
+ }
179
+ return current.present && current.raw === observed.raw;
180
+ }
181
+ /**
182
+ * The single staging path a lease rename goes through (CR-202). One
183
+ * fixed name is safe because staging only ever happens inside the
184
+ * mutation claim, and it makes strand cleanup deterministic.
185
+ */
186
+ export function stagePathFor(lockPath) {
187
+ return `${lockPath}.stage`;
188
+ }
189
+ /**
190
+ * The one shared atomic mutation primitive (EXT-F-01). Applies next (new
191
+ * file content, or null to remove the lock) only if the lock file still
192
+ * holds exactly the observed state, and confirms the application by
193
+ * re-reading the mutation's own token. Loses without retry otherwise.
194
+ */
195
+ export async function applyLeaseMutation(lockPath, observed, next, token) {
196
+ const mutexPath = `${lockPath}.mutex`;
197
+ const deadline = Date.now() + MUTEX_WAIT_TOTAL_MS;
198
+ for (;;) {
199
+ try {
200
+ writeFileSync(mutexPath, token, { flag: "wx" });
201
+ break;
202
+ }
203
+ catch (error) {
204
+ if (error.code !== "EEXIST") {
205
+ throw error;
206
+ }
207
+ if (Date.now() >= deadline) {
208
+ // CR-204: name the lease situation first, so an operator is not
209
+ // nudged toward a takeover when the obstacle is a claim file and
210
+ // there is no lease at all.
211
+ // The remedy sentence is deliberately NOT part of this reason:
212
+ // the CLI appends it from the claimTimeout flag, so the
213
+ // classification is load-bearing at every layer rather than
214
+ // carried along as prose (CR-204).
215
+ // U-6: an expired lease is not a held one. Saying "lock held"
216
+ // here contradicted lock status in the same fleet, which calls
217
+ // the same lease expired, and reads as "the holder is alive"
218
+ // when the truth is the opposite.
219
+ const holder = observed.kind === "present" && observed.lease !== undefined
220
+ ? isExpired(observed.lease, Date.now())
221
+ ? `expired lease from ${observed.lease.holderId}`
222
+ : `lock held by ${observed.lease.holderId}`
223
+ : "no lease, no live holder";
224
+ return {
225
+ won: false,
226
+ claimTimeout: true,
227
+ reason: `${holder}; stale claim file ${mutexPath} blocking after ` +
228
+ `${String(MUTEX_WAIT_TOTAL_MS)}ms`,
229
+ };
230
+ }
231
+ await sleep(MUTEX_WAIT_POLL_MS);
232
+ }
233
+ }
234
+ try {
235
+ // CR-202: clear any stranded stage left by a mutation that died
236
+ // between its stage write and its rename. This runs inside the
237
+ // claim, so it provably cannot race a live mutation (a live one
238
+ // would hold the claim), which is why no age heuristic is needed
239
+ // or wanted. It is unconditional because the release path (unlink)
240
+ // and the absent-lock acquire path (O_EXCL create) never touch the
241
+ // stage: cleaning only in the rename branch would let a strand
242
+ // survive a release/acquire-only sequence indefinitely.
243
+ try {
244
+ unlinkSync(stagePathFor(lockPath));
245
+ }
246
+ catch {
247
+ // No strand present, which is the normal case.
248
+ }
249
+ const current = readCurrent(lockPath);
250
+ if (observed.kind === "absent") {
251
+ if (current.present) {
252
+ return {
253
+ won: false,
254
+ reason: "lost: a lease appeared after this mutation observed none",
255
+ };
256
+ }
257
+ }
258
+ else if (!current.present) {
259
+ return {
260
+ won: false,
261
+ reason: "lost: the observed lease is gone",
262
+ };
263
+ }
264
+ else if (current.raw !== observed.raw) {
265
+ return {
266
+ won: false,
267
+ reason: "lost: the lease changed after this mutation observed it",
268
+ };
269
+ }
270
+ // D-2: re-read and re-compare immediately before every apply. The
271
+ // claim file is the ONLY serializer, and it is advisory: no handle
272
+ // is held on it, and the operator remedy this CLI prints tells a
273
+ // human to delete it. If a claim is lost that way, another mutation
274
+ // can enter this section concurrently; without this second compare
275
+ // both could apply and both could believe they won. With it, the
276
+ // loser sees changed bytes and degrades to a clean loss. This
277
+ // narrows the window to the syscall gap; it does not remove it, and
278
+ // the module docs say so rather than claiming a guarantee.
279
+ if (!stillMatches(lockPath, observed)) {
280
+ return {
281
+ won: false,
282
+ reason: "lost: the lease changed while this mutation held the claim",
283
+ };
284
+ }
285
+ if (next === null) {
286
+ unlinkSync(lockPath);
287
+ const confirm = readCurrent(lockPath);
288
+ if (confirm.present) {
289
+ return { won: false, reason: "lost: removal did not stick" };
290
+ }
291
+ return { won: true };
292
+ }
293
+ if (observed.kind === "absent") {
294
+ // D-1: publish the initial lease atomically. writeFileSync with
295
+ // flag "wx" is openSync(O_EXCL) followed by a SEPARATE writeSync,
296
+ // so the lock file's NAME becomes visible at length zero before
297
+ // the lease bytes land. observeLease, leaseStatus and doctor all
298
+ // read outside the claim, so a reader landing in that window sees
299
+ // an empty file and reports a healthy fleet as corrupt; it turned
300
+ // acceptance criterion 3's own witness red on pristine code.
301
+ //
302
+ // linkSync gives both properties at once: the stage file already
303
+ // holds the complete lease, and link fails with EEXIST if the
304
+ // lock path exists, which is exactly the atomic exclusive-create
305
+ // test PR-006 asks for. So exclusion is preserved and the name
306
+ // never exists half-published. renew and takeover were already
307
+ // immune because they stage then rename; this makes the absent
308
+ // lock path use the same discipline, which is the asymmetry the
309
+ // module previously left unjustified.
310
+ const stagePath = stagePathFor(lockPath);
311
+ /* THE STAGE IS OPENED FOR WRITING and is NOT guarded here, which is a
312
+ measurement rather than an oversight. open(2) for writing on a FIFO
313
+ blocks exactly as reading one does, so this looked like a member of
314
+ the class this round closed. It is not: the claim-held sweep above
315
+ (CR-202) unlinks `<lock>.stage` UNCONDITIONALLY before either branch
316
+ writes it, so a planted FIFO is gone by the time this line runs.
317
+ Measured at this head with a real mkfifo at
318
+ `state/orchestrator.lock.stage`: `tiphys lock acquire` exits 0 in
319
+ under a second and the lease is taken. A guard here would therefore
320
+ be code no witness can redden. */
321
+ writeFileSync(stagePath, next);
322
+ try {
323
+ linkSync(stagePath, lockPath);
324
+ }
325
+ catch (error) {
326
+ if (error.code === "EEXIST") {
327
+ return {
328
+ won: false,
329
+ reason: "lost: a lease appeared after this mutation observed none",
330
+ };
331
+ }
332
+ throw error;
333
+ }
334
+ finally {
335
+ try {
336
+ unlinkSync(stagePath);
337
+ }
338
+ catch {
339
+ // The stage sweep at the top of the claim also covers this.
340
+ }
341
+ }
342
+ }
343
+ else {
344
+ // The stage is ONE fixed path beside the lock (CR-202). Every
345
+ // stage write happens inside the claim, so no two stages can ever
346
+ // coexist and a fixed name needs no uniqueness to be safe; the
347
+ // claim-held sweep above then makes cleanup of a crash strand
348
+ // deterministic (exactly one possible strand path, removed
349
+ // unconditionally) instead of an age-based guess over a family of
350
+ // unique names. The CAS is untouched by this choice: the
351
+ // byte-compare against the observed state and the token
352
+ // confirmation read both still happen inside the claim, and the
353
+ // rename remains atomic within one directory.
354
+ const stagePath = stagePathFor(lockPath);
355
+ /* THE STAGE IS OPENED FOR WRITING and is NOT guarded here, which is a
356
+ measurement rather than an oversight. open(2) for writing on a FIFO
357
+ blocks exactly as reading one does, so this looked like a member of
358
+ the class this round closed. It is not: the claim-held sweep above
359
+ (CR-202) unlinks `<lock>.stage` UNCONDITIONALLY before either branch
360
+ writes it, so a planted FIFO is gone by the time this line runs.
361
+ Measured at this head with a real mkfifo at
362
+ `state/orchestrator.lock.stage`: `tiphys lock acquire` exits 0 in
363
+ under a second and the lease is taken. A guard here would therefore
364
+ be code no witness can redden. */
365
+ writeFileSync(stagePath, next);
366
+ try {
367
+ renameSync(stagePath, lockPath);
368
+ }
369
+ catch (error) {
370
+ try {
371
+ unlinkSync(stagePath);
372
+ }
373
+ catch {
374
+ // Stage cleanup is best effort; the original error surfaces.
375
+ }
376
+ if (error.code === "ENOENT") {
377
+ // Another mutation swept this stage, which means it entered
378
+ // the critical section concurrently, which means this claim
379
+ // was lost (D-2). Report it as a loss rather than letting a
380
+ // raw ENOENT stack out of the CLI.
381
+ return {
382
+ won: false,
383
+ reason: "lost: the staged lease disappeared before it was published, " +
384
+ "which means another mutation held the claim concurrently",
385
+ };
386
+ }
387
+ throw error;
388
+ }
389
+ }
390
+ const confirm = readCurrent(lockPath);
391
+ const confirmedLease = confirm.present ? parseLease(confirm.raw) : undefined;
392
+ if (confirmedLease === undefined || confirmedLease.token !== token) {
393
+ return {
394
+ won: false,
395
+ reason: "lost: confirmation read shows another writer's token",
396
+ };
397
+ }
398
+ return { won: true };
399
+ }
400
+ finally {
401
+ try {
402
+ unlinkSync(mutexPath);
403
+ }
404
+ catch {
405
+ // Claim file already gone; nothing to release.
406
+ }
407
+ }
408
+ }
409
+ /**
410
+ * Resolve the fleet home's declaration. The ONLY entry into the shared
411
+ * layer, and the only place a fleet root is derived: a lock path is
412
+ * `<root>/state/orchestrator.lock` (src/fleet.ts:37), so the root is two
413
+ * directories up unless a caller names it. A declaration that is present
414
+ * and unusable is reported rather than treated as absent, because reading a
415
+ * typo as "off" is the failure that would make this layer green everywhere.
416
+ */
417
+ function sharedGate(lockPath, fleetRoot) {
418
+ const root = fleetRoot ?? fleetRootForLockPath(lockPath);
419
+ const declaration = readSharedExclusion(root);
420
+ if (declaration.kind === "absent") {
421
+ return { kind: "off" };
422
+ }
423
+ if (declaration.kind === "invalid") {
424
+ return { kind: "invalid", reason: declaration.reason };
425
+ }
426
+ const identity = ensureEnvironmentId(root);
427
+ return {
428
+ kind: "on",
429
+ ctx: { config: declaration.config, envId: identity.envId, fleetRoot: root },
430
+ };
431
+ }
432
+ function invalidDeclarationOutcome(reason) {
433
+ return {
434
+ ok: false,
435
+ reason: `shared exclusion is declared and unusable, refusing rather than running ` +
436
+ `local-only: ${reason}`,
437
+ };
438
+ }
439
+ /** Ask the shared register for a verdict, without touching anything. */
440
+ function sharedPreflight(ctx, intent, takeover, nowMs) {
441
+ return preflightShared({
442
+ fleetRoot: ctx.fleetRoot,
443
+ config: ctx.config,
444
+ envId: ctx.envId,
445
+ intent,
446
+ takeover,
447
+ nowMs,
448
+ });
449
+ }
450
+ function sharedNote(ctx, preflight) {
451
+ return { line: preflight.line, envId: ctx.envId };
452
+ }
453
+ /**
454
+ * Publish the won verdict to the register. Called only AFTER the local
455
+ * mutation succeeded, so the two layers agree or the local one is rolled
456
+ * back by the caller: a register entry with no local lease behind it would
457
+ * exclude every environment including the one that wrote it.
458
+ */
459
+ function sharedCommit(ctx, preflight, state, nowMs, durationSeconds) {
460
+ const document = buildRegisterDocument({
461
+ state,
462
+ envId: ctx.envId,
463
+ counter: preflight.nextCounter,
464
+ nowMs,
465
+ durationSeconds,
466
+ ref: ctx.config.ref,
467
+ ...(preflight.current !== undefined && state === "held" && !preflight.takingOver
468
+ ? { acquiredAt: preflight.current.acquiredAt }
469
+ : {}),
470
+ });
471
+ const outcome = casWrite(ctx.fleetRoot, ctx.config, preflight.expectedSha, document);
472
+ if (outcome.kind === "won") {
473
+ return { ok: true, sha: outcome.sha };
474
+ }
475
+ return { ok: false, reason: casFailureLine(ctx.config.ref, outcome) };
476
+ }
477
+ /**
478
+ * UNDO A LOCAL LEASE MUTATION WHOSE REGISTER PUBLISH LOST.
479
+ *
480
+ * `current` is what the lock file holds NOW (the state this mutation left),
481
+ * and `restore` is the observation the mutation was decided against, whose
482
+ * raw bytes are written back verbatim so the restored file is BYTE-IDENTICAL
483
+ * to the one the command claimed not to have changed.
484
+ *
485
+ * The token handed to the primitive is the RESTORED lease's own token, not a
486
+ * fresh one: the primitive confirms an application by re-reading the token in
487
+ * the file it just wrote, and a fresh token would fail that confirmation
488
+ * against bytes that carry the old one.
489
+ *
490
+ * Returns the sentence appended to the failure line. A rollback that ITSELF
491
+ * loses is stated rather than swallowed, because "the local lease was
492
+ * restored" is a claim a later reader will act on.
493
+ */
494
+ async function restoreLocal(lockPath, current, restore) {
495
+ if (restore.lease === undefined) {
496
+ return "; the local lease could NOT be restored: the observed lease does not parse";
497
+ }
498
+ const undone = await applyLeaseMutation(lockPath, current, restore.raw, restore.lease.token);
499
+ return undone.won
500
+ ? "; the local lease was restored"
501
+ : `; the local lease could NOT be restored (${undone.reason}), so ${lockPath} and the register now disagree`;
502
+ }
503
+ function buildLease(nowMs, durationSeconds) {
504
+ return {
505
+ holderId: randomUUID(),
506
+ hostname: hostname(),
507
+ acquiredAt: new Date(nowMs).toISOString(),
508
+ expiresAt: new Date(nowMs + durationSeconds * 1000).toISOString(),
509
+ durationSeconds,
510
+ token: randomUUID(),
511
+ };
512
+ }
513
+ /**
514
+ * Acquire the lease, or take over an expired one when takeover is set.
515
+ * Refusals (lock held, expired without takeover, unexpired takeover)
516
+ * never mutate the file; only won mutations do.
517
+ */
518
+ export async function acquireLease(lockPath, options = {}) {
519
+ const nowMs = options.nowMs ?? Date.now();
520
+ const durationSeconds = options.durationSeconds ?? DEFAULT_LEASE_DURATION_SECONDS;
521
+ /* THE SHARED LAYER RUNS FIRST AND MUTATES NOTHING (M4-P21 criteria 1 and
522
+ 7). Off unless declared, so the read below is the whole cost for every
523
+ fleet that has not opted in. A refusal returns here, before the local
524
+ lease file can be created, which is the fail-closed property: an
525
+ unreachable register leaves no local lock behind to be mistaken for
526
+ exclusion that is not there. */
527
+ const gate = sharedGate(lockPath, options.fleetRoot);
528
+ if (gate.kind === "invalid") {
529
+ return invalidDeclarationOutcome(gate.reason);
530
+ }
531
+ let preflight;
532
+ if (gate.kind === "on") {
533
+ preflight = sharedPreflight(gate.ctx, "acquire", options.takeover === true, nowMs);
534
+ if (preflight.kind === "refused") {
535
+ return {
536
+ ok: false,
537
+ reason: preflight.line,
538
+ shared: sharedNote(gate.ctx, preflight),
539
+ };
540
+ }
541
+ }
542
+ const observed = options.observed ?? observeLease(lockPath);
543
+ if (observed.kind === "present") {
544
+ if (observed.lease === undefined) {
545
+ return {
546
+ ok: false,
547
+ reason: `lease file ${lockPath} is corrupt; inspect it manually`,
548
+ };
549
+ }
550
+ if (!isExpired(observed.lease, nowMs)) {
551
+ return {
552
+ ok: false,
553
+ reason: options.takeover === true
554
+ ? `takeover refused: lock held by ${observed.lease.holderId}, unexpired until ${observed.lease.expiresAt}`
555
+ : `lock held by ${observed.lease.holderId}, expires ${observed.lease.expiresAt}`,
556
+ };
557
+ }
558
+ if (options.takeover !== true) {
559
+ return {
560
+ ok: false,
561
+ reason: `lease expired (holder ${observed.lease.holderId}, expired ` +
562
+ `${observed.lease.expiresAt}); acquire refused, takeover is ` +
563
+ `explicit: lock acquire --take-over`,
564
+ };
565
+ }
566
+ }
567
+ const lease = buildLease(nowMs, durationSeconds);
568
+ const result = await applyLeaseMutation(lockPath, observed, renderLease(lease), lease.token);
569
+ if (!result.won) {
570
+ // CR-204: a claim-file timeout is not a held lease. Its reason
571
+ // already states the lease situation, so it is never re-prefixed
572
+ // with "lock held", and the classification travels to the caller.
573
+ if (result.claimTimeout === true) {
574
+ return { ok: false, reason: result.reason, claimTimeout: true };
575
+ }
576
+ return { ok: false, reason: `lock held (${result.reason})` };
577
+ }
578
+ if (gate.kind === "on" && preflight !== undefined && preflight.kind === "proceed") {
579
+ const published = sharedCommit(gate.ctx, preflight, "held", nowMs, durationSeconds);
580
+ if (!published.ok) {
581
+ /* The register refused after the local lease was written, so the local
582
+ lease is rolled back through the SAME mutation primitive. Leaving it
583
+ would make this environment believe it holds a fleet another
584
+ environment holds, which is the exact state this layer exists to
585
+ prevent. */
586
+ await applyLeaseMutation(lockPath, { kind: "present", raw: renderLease(lease), lease }, null, randomUUID());
587
+ const line = `shared exclusion refused acquire: ${published.reason}; the local lease was rolled back`;
588
+ return {
589
+ ok: false,
590
+ reason: line,
591
+ shared: { line, envId: gate.ctx.envId },
592
+ };
593
+ }
594
+ return {
595
+ ok: true,
596
+ lease,
597
+ shared: {
598
+ line: `${preflight.line}; register now ${published.sha}`,
599
+ envId: gate.ctx.envId,
600
+ },
601
+ };
602
+ }
603
+ return { ok: true, lease };
604
+ }
605
+ /**
606
+ * Renew the lease held by holderId. Fails on an expired lease even when
607
+ * holderId matches (EXT-F-01: a paused holder whose lease expired cannot
608
+ * renew), fails on a holder mismatch, and never mutates the file on any
609
+ * failure. The new expiry strictly increases (a renew that does not
610
+ * extend the lease would be useless).
611
+ */
612
+ export async function renewLease(lockPath, holderId, options = {}) {
613
+ const nowMs = options.nowMs ?? Date.now();
614
+ const gate = sharedGate(lockPath, options.fleetRoot);
615
+ if (gate.kind === "invalid") {
616
+ return invalidDeclarationOutcome(gate.reason);
617
+ }
618
+ let preflight;
619
+ if (gate.kind === "on") {
620
+ preflight = sharedPreflight(gate.ctx, "renew", false, nowMs);
621
+ if (preflight.kind === "refused") {
622
+ return {
623
+ ok: false,
624
+ reason: preflight.line,
625
+ shared: sharedNote(gate.ctx, preflight),
626
+ };
627
+ }
628
+ }
629
+ const observed = options.observed ?? observeLease(lockPath);
630
+ if (observed.kind === "absent") {
631
+ return { ok: false, reason: "renew refused: no lease present" };
632
+ }
633
+ if (observed.lease === undefined) {
634
+ return {
635
+ ok: false,
636
+ reason: `lease file ${lockPath} is corrupt; inspect it manually`,
637
+ };
638
+ }
639
+ if (observed.lease.holderId !== holderId) {
640
+ return {
641
+ ok: false,
642
+ reason: `renew refused: lease is held by ${observed.lease.holderId}, not ${holderId}`,
643
+ };
644
+ }
645
+ if (isExpired(observed.lease, nowMs)) {
646
+ return {
647
+ ok: false,
648
+ reason: `renew refused: lease expired ${observed.lease.expiresAt}; an ` +
649
+ `expired lease cannot be renewed, re-acquire or take over instead`,
650
+ };
651
+ }
652
+ const durationSeconds = options.durationSeconds ?? observed.lease.durationSeconds;
653
+ const newExpiresMs = Math.max(nowMs + durationSeconds * 1000, Date.parse(observed.lease.expiresAt) + 1);
654
+ const lease = {
655
+ ...observed.lease,
656
+ expiresAt: new Date(newExpiresMs).toISOString(),
657
+ durationSeconds,
658
+ token: randomUUID(),
659
+ };
660
+ const result = await applyLeaseMutation(lockPath, observed, renderLease(lease), lease.token);
661
+ if (!result.won) {
662
+ if (result.claimTimeout === true) {
663
+ return { ok: false, reason: result.reason, claimTimeout: true };
664
+ }
665
+ return { ok: false, reason: `renew ${result.reason}` };
666
+ }
667
+ if (gate.kind === "on" && preflight !== undefined && preflight.kind === "proceed") {
668
+ /* A RENEW ADVANCES THE FENCING COUNTER, and that is the whole of what
669
+ makes another environment's staleness judgement safe under clock skew
670
+ (criterion 5). The observer sees an increment, never a timestamp. */
671
+ const published = sharedCommit(gate.ctx, preflight, "held", nowMs, durationSeconds);
672
+ if (!published.ok) {
673
+ /* THE LOCAL LAYER IS ROLLED BACK, exactly as acquire's failure arm
674
+ does it and through the SAME primitive. src/lock.ts:537 states the
675
+ invariant: the two layers agree, or the local one is undone by the
676
+ caller. Until this round `acquireLease` was the only one of the
677
+ three publishing callers that honoured it, so a failed renew
678
+ reported failure while the lease file had ALREADY been extended
679
+ under a new token, and a failed release reported failure while the
680
+ lease file had already been DELETED. */
681
+ const line = `shared exclusion refused renew: ${published.reason}${await restoreLocal(lockPath, { kind: "present", raw: renderLease(lease), lease }, observed)}`;
682
+ return {
683
+ ok: false,
684
+ reason: line,
685
+ shared: { line, envId: gate.ctx.envId },
686
+ };
687
+ }
688
+ return {
689
+ ok: true,
690
+ lease,
691
+ shared: {
692
+ line: `${preflight.line}; register now ${published.sha}`,
693
+ envId: gate.ctx.envId,
694
+ },
695
+ };
696
+ }
697
+ return { ok: true, lease };
698
+ }
699
+ /**
700
+ * Release the lease held by holderId. Expiry does not block a release
701
+ * (an expired former holder may clean up its own lease), but a holder
702
+ * mismatch refuses, so a losing holder can never remove the winner's
703
+ * lease; and the compare-and-swap in the primitive means a release
704
+ * staged before a takeover completes loses to it.
705
+ */
706
+ export async function releaseLease(lockPath, holderId, options = {}) {
707
+ const nowMs = options.nowMs ?? Date.now();
708
+ const gate = sharedGate(lockPath, options.fleetRoot);
709
+ if (gate.kind === "invalid") {
710
+ return invalidDeclarationOutcome(gate.reason);
711
+ }
712
+ let preflight;
713
+ if (gate.kind === "on") {
714
+ /* CRITERION 8. A release by a non-holder is refused HERE, before any
715
+ write, so the register sha is byte-identical before and after. The
716
+ refusal is the register's own comparison, not a local one: a clone
717
+ whose local lease says it holds the fleet still loses to a register
718
+ that names another environment. */
719
+ preflight = sharedPreflight(gate.ctx, "release", false, nowMs);
720
+ if (preflight.kind === "refused") {
721
+ return {
722
+ ok: false,
723
+ reason: preflight.line,
724
+ shared: sharedNote(gate.ctx, preflight),
725
+ };
726
+ }
727
+ }
728
+ const observed = options.observed ?? observeLease(lockPath);
729
+ if (observed.kind === "absent") {
730
+ return { ok: false, reason: "release refused: no lease present" };
731
+ }
732
+ if (observed.lease === undefined) {
733
+ return {
734
+ ok: false,
735
+ reason: `lease file ${lockPath} is corrupt; inspect it manually`,
736
+ };
737
+ }
738
+ if (observed.lease.holderId !== holderId) {
739
+ return {
740
+ ok: false,
741
+ reason: `release refused: lease is held by ${observed.lease.holderId}, not ${holderId}`,
742
+ };
743
+ }
744
+ const result = await applyLeaseMutation(lockPath, observed, null, randomUUID());
745
+ if (!result.won) {
746
+ if (result.claimTimeout === true) {
747
+ return { ok: false, reason: result.reason, claimTimeout: true };
748
+ }
749
+ return { ok: false, reason: `release ${result.reason}` };
750
+ }
751
+ if (gate.kind === "on" && preflight !== undefined && preflight.kind === "proceed") {
752
+ const published = sharedCommit(gate.ctx, preflight, "free", nowMs, observed.lease.durationSeconds);
753
+ if (!published.ok) {
754
+ /* See renewLease above: the local lease was REMOVED before this
755
+ publish was attempted, and the holdership guard keys on that file's
756
+ PRESENCE, so leaving it removed reopens the dual-writer window
757
+ M1-P4 criterion 12 closed. */
758
+ const line = `shared exclusion could not publish the release: ${published.reason}${await restoreLocal(lockPath, { kind: "absent" }, observed)}`;
759
+ return {
760
+ ok: false,
761
+ reason: line,
762
+ shared: { line, envId: gate.ctx.envId },
763
+ };
764
+ }
765
+ return {
766
+ ok: true,
767
+ lease: null,
768
+ shared: {
769
+ line: `${preflight.line}; register now ${published.sha}`,
770
+ envId: gate.ctx.envId,
771
+ },
772
+ };
773
+ }
774
+ return { ok: true, lease: null };
775
+ }
776
+ /** Report the lock state; reading only, never mutating (always safe). */
777
+ export function leaseStatus(lockPath, nowMs = Date.now()) {
778
+ const observed = observeLease(lockPath);
779
+ if (observed.kind === "absent") {
780
+ return { state: "free" };
781
+ }
782
+ if (observed.lease === undefined) {
783
+ return {
784
+ state: "corrupt",
785
+ detail: `lease file ${lockPath} does not parse as a lease`,
786
+ };
787
+ }
788
+ return {
789
+ state: isExpired(observed.lease, nowMs) ? "expired" : "held",
790
+ lease: observed.lease,
791
+ };
792
+ }