@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,396 @@
1
+ import { readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { classifyEntry, readRegularFileIfPresent, readTaskMeta } from "./task.js";
4
+ /**
5
+ * Defaults, in seconds. FM-044 is a calibration starting point, not a
6
+ * measured optimum, and these are chosen so the PR-009 invariant holds
7
+ * with room to spare (900 + 15 = 915 < 1200).
8
+ */
9
+ export const DEFAULT_BASE_INTERVAL_SECONDS = 60;
10
+ export const DEFAULT_POLL_INTERVAL_SECONDS = 15;
11
+ export const DEFAULT_BACKOFF_CAP_SECONDS = 900;
12
+ export const DEFAULT_STALE_THRESHOLD_SECONDS = 1200;
13
+ /**
14
+ * Environment overrides. The watch command also carries --interval,
15
+ * --poll and --backoff-cap flags for a single invocation (the M1-P6
16
+ * harness needs the cadence short); these variables exist because the
17
+ * guard runs inside spawn, teardown and doctor, which have no watch
18
+ * flags, and because a fleet's cadence and its freshness threshold have
19
+ * to agree across all four commands.
20
+ */
21
+ export const ENV_BASE_INTERVAL = "TIPHYS_WATCH_INTERVAL_SECONDS";
22
+ export const ENV_POLL_INTERVAL = "TIPHYS_WATCH_POLL_SECONDS";
23
+ export const ENV_BACKOFF_CAP = "TIPHYS_WATCH_BACKOFF_CAP_SECONDS";
24
+ export const ENV_STALE_THRESHOLD = "TIPHYS_WATCH_STALE_SECONDS";
25
+ function secondsFromEnv(env, key, fallbackSeconds) {
26
+ const raw = env[key];
27
+ if (raw === undefined || raw === "") {
28
+ return fallbackSeconds * 1000;
29
+ }
30
+ const value = Number(raw);
31
+ if (!Number.isFinite(value) || value <= 0) {
32
+ throw new Error(`${key}="${raw}" is not a positive number of seconds`);
33
+ }
34
+ return value * 1000;
35
+ }
36
+ /**
37
+ * The PR-009 invariant, enforced wherever a cadence is built (at module
38
+ * load for the process-wide one, and again for every set of watch
39
+ * flags). The message names both sides because the operator has to know
40
+ * which of the two to change.
41
+ */
42
+ export function assertCadenceInvariant(cadence) {
43
+ const floorMs = cadence.backoffCapMs + cadence.pollIntervalMs;
44
+ if (cadence.staleThresholdMs > floorMs) {
45
+ return;
46
+ }
47
+ throw new Error(`invalid watcher cadence: stale threshold ${String(cadence.staleThresholdMs)}ms ` +
48
+ `is not strictly greater than backoff cap ${String(cadence.backoffCapMs)}ms plus ` +
49
+ `one poll interval ${String(cadence.pollIntervalMs)}ms (${String(floorMs)}ms), so a ` +
50
+ `watcher idling at maximum backoff would read as stale (PR-009)`);
51
+ }
52
+ /** Build a cadence from environment overrides, validated. */
53
+ export function loadCadence(env) {
54
+ const cadence = {
55
+ baseIntervalMs: secondsFromEnv(env, ENV_BASE_INTERVAL, DEFAULT_BASE_INTERVAL_SECONDS),
56
+ pollIntervalMs: secondsFromEnv(env, ENV_POLL_INTERVAL, DEFAULT_POLL_INTERVAL_SECONDS),
57
+ backoffCapMs: secondsFromEnv(env, ENV_BACKOFF_CAP, DEFAULT_BACKOFF_CAP_SECONDS),
58
+ staleThresholdMs: secondsFromEnv(env, ENV_STALE_THRESHOLD, DEFAULT_STALE_THRESHOLD_SECONDS),
59
+ };
60
+ assertCadenceInvariant(cadence);
61
+ return cadence;
62
+ }
63
+ /**
64
+ * Apply per-invocation overrides (the watch flags) and re-validate, so
65
+ * a short --backoff-cap or a long one can never quietly break the
66
+ * invariant the guard depends on.
67
+ */
68
+ export function withCadenceOverrides(base, overrides) {
69
+ const cadence = { ...base, ...overrides };
70
+ assertCadenceInvariant(cadence);
71
+ return cadence;
72
+ }
73
+ /**
74
+ * The process-wide cadence. Building it at module load is what makes
75
+ * criterion 12's second clause real: importing this module under a
76
+ * configuration that violates the invariant FAILS, with both values in
77
+ * the message, instead of running on with a guard that cries wolf.
78
+ */
79
+ export const CADENCE = loadCadence(process.env);
80
+ export function renderBeacon(record) {
81
+ return `${JSON.stringify(record, null, 2)}\n`;
82
+ }
83
+ /**
84
+ * Read the beacon, or undefined when it is absent, is not a regular file,
85
+ * cannot be read, or does not parse. All of those are the same thing to
86
+ * the guard: no evidence that supervision ran. That is the
87
+ * fail-toward-warning direction, and for an advisory that never blocks it
88
+ * is the right one.
89
+ *
90
+ * The type probe is INSIDE this reader (CR-520). It is not enough for
91
+ * judgeBeacon to have lstat'ed the path first: that call establishes
92
+ * PRESENCE, not TYPE, and this reader has a second caller in
93
+ * src/watcher.ts (writeBeacon) which does not lstat at all. A probe in
94
+ * front of one caller protects one caller.
95
+ */
96
+ export function readBeacon(beaconPath) {
97
+ const read = readRegularFileIfPresent(beaconPath);
98
+ if (read.kind !== "read") {
99
+ return undefined;
100
+ }
101
+ let parsed;
102
+ try {
103
+ parsed = JSON.parse(read.body);
104
+ }
105
+ catch {
106
+ return undefined;
107
+ }
108
+ const candidate = parsed;
109
+ if (typeof candidate.writtenAt !== "string" ||
110
+ Number.isNaN(Date.parse(candidate.writtenAt)) ||
111
+ typeof candidate.backoffStreak !== "number" ||
112
+ typeof candidate.intervalMs !== "number") {
113
+ return undefined;
114
+ }
115
+ return candidate;
116
+ }
117
+ /**
118
+ * How far a beacon may sit in the FUTURE before it stops counting as
119
+ * evidence. Nothing legitimate puts it there: writeBeacon stamps the
120
+ * current time and only ever bumps by a millisecond to keep the advance
121
+ * strict. A beacon well ahead of the local clock means the clock moved
122
+ * backwards under a running watcher (an NTP step, a suspend and resume,
123
+ * a session resumed on another host), and until it is rewritten from the
124
+ * present it proves nothing about whether supervision is still running.
125
+ * The allowance absorbs ordinary jitter and the monotonic bump.
126
+ */
127
+ export const BEACON_FUTURE_TOLERANCE_MS = 5000;
128
+ /**
129
+ * THE ONE CLASSIFIER OF TASK RECORDS. src/watcher.ts and this module both
130
+ * call it, and neither has a second opinion about what a task record is.
131
+ *
132
+ * This exists because the fix round wrote the same classification twice
133
+ * with different conditions, and the copies disagreed: one asked whether
134
+ * the meta path was a regular FILE, the other only that a stat succeeded,
135
+ * so a meta.json that existed as a directory was surfaced by the watcher
136
+ * and reported by the guard as nothing in flight, which is a
137
+ * counterexample to the very property that round declared (delta review
138
+ * NEW-1, and tuition T-005 on rules that fail to propagate). One property
139
+ * gets one implementation; two that agree today drift the moment someone
140
+ * edits one of them.
141
+ *
142
+ * The rules, in one place:
143
+ *
144
+ * - A TASK IS A DIRECTORY under tasks/. The entry is resolved with stat,
145
+ * so a symlink to a real task directory is a task; init's own .gitkeep
146
+ * and any other stray file is not, which is checked by TYPE and never
147
+ * by name.
148
+ * - A record that PARSES is authoritative: its status decides open or
149
+ * closed (plan constraint C-1).
150
+ * - THE RECORD IS PROBED BEFORE IT IS READ, AND THE PROBE IS NOT HERE.
151
+ * Only a path that resolves to a REGULAR FILE is ever opened; anything
152
+ * else that exists there (a directory, a FIFO, a socket, a device node,
153
+ * a symlink resolving to any of those, a dangling symlink) is classified
154
+ * as a record that cannot be read WITHOUT opening it. That ordering is
155
+ * load-bearing: opening a FIFO with no writer blocks in the kernel and
156
+ * is not an exception, so a classifier that read first would hang
157
+ * instead of classifying, and would take the guard's three callers down
158
+ * with it.
159
+ *
160
+ * The ordering is enforced INSIDE readTaskMeta (src/task.ts), not in
161
+ * front of the call below. A fix round put it here instead, which
162
+ * protected this one call site and left every other caller of
163
+ * readTaskMeta exposed; src/teardown.ts is one, and a named pipe at a
164
+ * task record hung teardown forever while this classifier reported the
165
+ * same file cleanly (CR-520, CR-521). The recorded reason for declining
166
+ * to protect the other callers was that it would mean a second reader
167
+ * beside readTaskMeta. That reason was wrong: moving the probe INTO
168
+ * readTaskMeta is still exactly one implementation of "read a task
169
+ * record", and it is the shape now shipped. What this classifier keeps
170
+ * is the finer split it genuinely needs, unreadable versus a survey that
171
+ * did not complete, computed from the same classifyEntry the reader uses.
172
+ * - A regular file that does not parse is likewise unreadable: it exists
173
+ * and it is not evidence that the task finished.
174
+ * - NOTHING at the meta.json path is not a record at all: that is the
175
+ * normal transient shape of a spawn in progress or a rollback residue.
176
+ * - Residual, stated rather than papered over: the probe and the read are
177
+ * two syscalls, so a path that changes type between them could still be
178
+ * opened as something other than a regular file. Nothing in this kernel
179
+ * writes that state. The residual is stated once, at classifyEntry.
180
+ *
181
+ * Total by construction: it never raises, because one of its two callers
182
+ * is an advisory that must not be able to take down the command it is
183
+ * advising. It does not swallow either: what it could not establish comes
184
+ * back in problems, and both callers are required to act on that.
185
+ */
186
+ export function surveyTaskRecords(fleet) {
187
+ const open = [];
188
+ const unreadable = [];
189
+ const problems = [];
190
+ let entries;
191
+ try {
192
+ entries = readdirSync(fleet.tasksDir);
193
+ }
194
+ catch (error) {
195
+ if (error.code === "ENOENT") {
196
+ // No tasks directory at all. loadFleet refuses a fleet home
197
+ // missing one, so this is only reachable if it vanished under us,
198
+ // and an absent directory holds no tasks.
199
+ return { open, unreadable, problems };
200
+ }
201
+ problems.push(`the task directory ${fleet.tasksDir} could not be listed: ${String(error)}`);
202
+ return { open, unreadable, problems };
203
+ }
204
+ for (const id of entries.sort()) {
205
+ let isTask;
206
+ try {
207
+ isTask = statSync(join(fleet.tasksDir, id)).isDirectory();
208
+ }
209
+ catch (error) {
210
+ if (error.code === "ENOENT") {
211
+ // Removed under us (a concurrent teardown), or a dangling
212
+ // symlink: no task directory here.
213
+ continue;
214
+ }
215
+ problems.push(`the task entry ${join(fleet.tasksDir, id)} could not be examined: ${String(error)}`);
216
+ continue;
217
+ }
218
+ if (!isTask) {
219
+ continue;
220
+ }
221
+ // PROBE BEFORE READ, through the shared classifier (src/task.ts
222
+ // classifyEntry) rather than through a copy of it here. This survey
223
+ // needs a FINER answer than a reader does, because it distinguishes
224
+ // "there is something unreadable at this record" from "this survey
225
+ // could not complete", and those go to different counters. It does not
226
+ // need a second opinion about what is safe to open, and the round that
227
+ // wrote the probe here rather than in the reader is why six other
228
+ // paths kept blocking (CR-520).
229
+ const recordPath = join(fleet.tasksDir, id, "meta.json");
230
+ const entry = classifyEntry(recordPath);
231
+ if (entry.kind === "absent") {
232
+ // No record here at all: a spawn in progress, or a rollback residue.
233
+ continue;
234
+ }
235
+ if (entry.kind === "unexaminable") {
236
+ problems.push(`the task record ${entry.reason}`);
237
+ continue;
238
+ }
239
+ if (entry.kind === "dangling" || entry.kind === "irregular") {
240
+ // Present as a link resolving to nothing, or present as something
241
+ // that is not a file: it exists, and it is not evidence that the
242
+ // task finished. Classified WITHOUT being opened.
243
+ unreadable.push(id);
244
+ continue;
245
+ }
246
+ const meta = readTaskMeta(fleet, id);
247
+ if (meta === undefined) {
248
+ unreadable.push(id);
249
+ continue;
250
+ }
251
+ if (meta.status === "open") {
252
+ open.push(id);
253
+ }
254
+ }
255
+ return { open, unreadable, problems };
256
+ }
257
+ /**
258
+ * THE ONE FRESHNESS THRESHOLD. The configured threshold, raised to the
259
+ * cadence the WATCHER ITSELF declared in the beacon it wrote plus one
260
+ * poll interval (CR-503). Every consumer of beacon freshness calls this,
261
+ * so a single run cannot produce two verdicts about one file.
262
+ */
263
+ export function effectiveThresholdMs(beacon, cadence) {
264
+ const declaredFloorMs = beacon === undefined ? 0 : beacon.intervalMs + cadence.pollIntervalMs;
265
+ return Math.max(cadence.staleThresholdMs, declaredFloorMs);
266
+ }
267
+ export function judgeBeacon(beaconPath, nowMs = Date.now(), cadence = CADENCE) {
268
+ // Presence and type are two questions and the shared classifier answers
269
+ // both: absent is the only arm that means "no beacon here". Anything
270
+ // else present (a dangling symlink per CR-513, a directory, a named
271
+ // pipe, an entry that cannot even be examined) exists, so it is not
272
+ // evidence of health, and readBeacon below reduces it to "unreadable"
273
+ // without opening it.
274
+ if (classifyEntry(beaconPath).kind === "absent") {
275
+ return { kind: "absent" };
276
+ }
277
+ const beacon = readBeacon(beaconPath);
278
+ if (beacon === undefined) {
279
+ return { kind: "unreadable" };
280
+ }
281
+ const thresholdMs = effectiveThresholdMs(beacon, cadence);
282
+ const ageMs = nowMs - Date.parse(beacon.writtenAt);
283
+ if (ageMs < -BEACON_FUTURE_TOLERANCE_MS) {
284
+ return { kind: "ahead", aheadMs: -ageMs, thresholdMs };
285
+ }
286
+ if (ageMs > thresholdMs) {
287
+ return { kind: "stale", ageMs, thresholdMs };
288
+ }
289
+ return { kind: "fresh", ageMs, thresholdMs };
290
+ }
291
+ /** Counts, for the guard. The classification itself is not repeated. */
292
+ export function surveyTasks(fleet) {
293
+ const survey = surveyTaskRecords(fleet);
294
+ return {
295
+ open: survey.open.length,
296
+ // A survey that could not complete is counted with the records it
297
+ // could not read: both mean "work this fleet cannot be shown to be
298
+ // free of".
299
+ unreadable: survey.unreadable.length + survey.problems.length,
300
+ };
301
+ }
302
+ /**
303
+ * The guard predicate (plan step 2). Total: it never raises, whatever
304
+ * the filesystem does, because its callers use it as an advisory.
305
+ *
306
+ * NO HEALTH FROM AN ABSENCE OF EVIDENCE. "Not stale" is only said when
307
+ * this function can point at the evidence for it: either nothing is in
308
+ * flight, or a beacon judged fresh. What counts as work in flight comes
309
+ * from surveyTaskRecords and what a beacon is evidence of comes from
310
+ * judgeBeacon; this function decides neither of those questions itself,
311
+ * so doctor and the guard cannot answer them differently.
312
+ */
313
+ export function guard(fleet, nowMs = Date.now(), cadence = CADENCE) {
314
+ const survey = surveyTasks(fleet);
315
+ const inFlight = survey.open + survey.unreadable;
316
+ const verdict = judgeBeacon(fleet.beaconPath, nowMs, cadence);
317
+ const beaconAgeMs = verdict.kind === "fresh" || verdict.kind === "stale" ? verdict.ageMs : undefined;
318
+ const thresholdSeconds = Math.round((verdict.kind === "absent" || verdict.kind === "unreadable"
319
+ ? cadence.staleThresholdMs
320
+ : verdict.thresholdMs) / 1000);
321
+ const flight = survey.unreadable === 0
322
+ ? `${String(inFlight)} open task(s)`
323
+ : `${String(inFlight)} task(s) (${String(survey.unreadable)} with an ` +
324
+ `unreadable meta.json, which is not evidence they are finished)`;
325
+ if (inFlight === 0) {
326
+ return {
327
+ inFlight,
328
+ unreadable: survey.unreadable,
329
+ beaconAgeMs,
330
+ stale: false,
331
+ detail: "no open tasks: nothing is in flight to supervise",
332
+ };
333
+ }
334
+ if (verdict.kind === "absent" || verdict.kind === "unreadable") {
335
+ return {
336
+ inFlight,
337
+ unreadable: survey.unreadable,
338
+ beaconAgeMs,
339
+ stale: true,
340
+ detail: `watcher stale: ${flight} in flight and no readable beacon at ` +
341
+ `${fleet.beaconPath}; start "tiphys watch" or schedule ` +
342
+ `"tiphys watch --once" at least every ${String(thresholdSeconds)}s`,
343
+ };
344
+ }
345
+ if (verdict.kind === "ahead") {
346
+ // CR-510: the remediation names the action that actually clears this.
347
+ // Restarting the watcher does not: writeBeacon keeps a beacon that is
348
+ // already ahead of the clock ahead of it, one millisecond per
349
+ // evaluation, so a healthy watcher cannot walk it back.
350
+ return {
351
+ inFlight,
352
+ unreadable: survey.unreadable,
353
+ beaconAgeMs,
354
+ stale: true,
355
+ detail: `watcher stale: ${flight} in flight and the beacon at ${fleet.beaconPath} is ` +
356
+ `dated ${String(Math.round(verdict.aheadMs / 1000))}s in the FUTURE, so it is ` +
357
+ `no evidence that supervision ran (the clock moved backwards under it); ` +
358
+ `remove that file and let the next evaluation write it from the present, ` +
359
+ `because restarting the watcher alone will not clear it`,
360
+ };
361
+ }
362
+ if (verdict.kind === "stale") {
363
+ return {
364
+ inFlight,
365
+ unreadable: survey.unreadable,
366
+ beaconAgeMs,
367
+ stale: true,
368
+ detail: `watcher stale: ${flight} in flight and ` +
369
+ `${fleet.beaconPath} is ${String(Math.round(verdict.ageMs / 1000))}s old ` +
370
+ `(threshold ${String(thresholdSeconds)}s); supervision may have stopped`,
371
+ };
372
+ }
373
+ return {
374
+ inFlight,
375
+ unreadable: survey.unreadable,
376
+ beaconAgeMs,
377
+ stale: false,
378
+ detail: `watcher fresh: ${flight} in flight, beacon ` +
379
+ `${String(Math.round(verdict.ageMs / 1000))}s old`,
380
+ };
381
+ }
382
+ /**
383
+ * The one call site shape spawn, teardown and doctor share: evaluate the
384
+ * guard and, when stale, write exactly one stderr line containing
385
+ * "watcher stale". It returns the report so a caller can use it, and it
386
+ * never changes what the caller does next (warn, never block).
387
+ */
388
+ export function warnIfWatcherStale(fleet, write = (text) => {
389
+ process.stderr.write(text);
390
+ }) {
391
+ const report = guard(fleet);
392
+ if (report.stale) {
393
+ write(`${report.detail}\n`);
394
+ }
395
+ return report;
396
+ }
@@ -0,0 +1,256 @@
1
+ /**
2
+ * Lease-based session lock (kernel plan v1, M1-P3 step 1; DR-0007; plan
3
+ * constraint C-2). One orchestrator per fleet: the lock file at
4
+ * state/orchestrator.lock holds a JSON lease {holderId, hostname,
5
+ * acquiredAt, expiresAt, durationSeconds, token} where holderId is an
6
+ * opaque value generated at acquire. Liveness is lease freshness only:
7
+ * this module never probes any running program in any way (FM-053), and
8
+ * the death of a holder is deliberately invisible to the lease (an
9
+ * unexpired lease excludes even when its holder is gone; recovery is the
10
+ * explicit --take-over after expiry).
11
+ *
12
+ * Mutation contract (EXT-F-01, adopted verbatim from the plan):
13
+ * - Every mutation (acquire, renew, release, takeover) goes through the
14
+ * ONE shared atomic mutation primitive, applyLeaseMutation. A mutation
15
+ * is decided against an observed lease state and applied only if the
16
+ * file still holds exactly that state; the application is confirmed by
17
+ * re-reading the unique per-mutation token it wrote. A confirmation
18
+ * showing another writer's token means the mutation lost and returns
19
+ * failure without retry. Ownership is not valid until the primitive
20
+ * completes.
21
+ * - Renew fails on an expired lease and succeeds only while holderId
22
+ * matches and expiresAt is in the future.
23
+ * - Takeover succeeds only if the observed lease is still the lease being
24
+ * replaced (compare-and-swap over the lease file content) and
25
+ * serializes with renew, release, and competing takeovers.
26
+ *
27
+ * Serialization mechanism: mutations are serialized through a claim file
28
+ * beside the lock (<lock>.mutex, created O_EXCL; FM-022's
29
+ * serialize-through-a-claim pattern), and inside that claim the primitive
30
+ * re-reads the lock, byte-compares it with the observed state, applies
31
+ * via O_EXCL create (absent lock, PR-006) or write-temp-then-rename, and
32
+ * confirms by re-reading its own token (FM-022 write-then-verify). No
33
+ * steal protocol exists on purpose (FM-058): a claim file left behind by
34
+ * a crashed mutation makes later mutations fail loudly after a bounded
35
+ * wait, naming the file for manual removal; the critical section is a
36
+ * few file operations, so this window is tiny.
37
+ *
38
+ * What the token confirmation is and is NOT (corrected per D-2; the
39
+ * previous wording here claimed a second safety net that does not
40
+ * exist). The confirmation read asserts only "my bytes are in the file
41
+ * now". That is last-writer-wins: it catches an intruder who applied
42
+ * AFTER this mutation, and it does NOT catch one who applied before and
43
+ * merely lost the race to write last. The O_EXCL claim file is
44
+ * therefore the sole serializer, and it is advisory: no handle is held
45
+ * on it and nothing checks ownership when it is unlinked. The apply is
46
+ * preceded by a second read-and-compare (stillMatches) so that a lost
47
+ * claim degrades to a clean loss instead of a double win, but that
48
+ * narrows the window rather than closing it. Deleting a live claim file
49
+ * can still produce two holders; that is why the CLI's remedy text now
50
+ * says so instead of inviting it.
51
+ *
52
+ * Exclusion domain (PR-201, DR-0007 stated honestly): the lease excludes
53
+ * within one filesystem and one clock, the fleet home the lock file lives
54
+ * in. Mutations of the lock file made outside this module (manual edits)
55
+ * are not covered by the contract.
56
+ *
57
+ * CROSS-ENVIRONMENT EXCLUSION IS A SECOND LAYER ABOVE THIS ONE (M4-P21),
58
+ * and it is OFF unless the fleet home declares it. When the fleet's own
59
+ * `package.json` carries `tiphys.sharedExclusion`, every mutation below
60
+ * first asks `src/exclusion.ts` for a verdict from the shared register on
61
+ * the fleet's git remote, and only a won verdict reaches the local lease.
62
+ * With the field ABSENT, `readSharedExclusion` returns before spawning
63
+ * anything and every path in this module behaves exactly as it did, which
64
+ * is the property M4-P21 criterion 1 asserts. The refusal never touches the
65
+ * lock file, which is what makes the fail-closed behaviour on an
66
+ * unreachable register observable (criterion 7): no local lease appears.
67
+ *
68
+ * Renewal discipline (PR-203): the default lease lasts 900 seconds and
69
+ * the holder renews at or before half-life (renewByMs). Holdership on
70
+ * mutating kernel commands (spawn, teardown) is verified against the
71
+ * lease by M1-P4.
72
+ */
73
+ export declare const DEFAULT_LEASE_DURATION_SECONDS = 900;
74
+ export interface Lease {
75
+ holderId: string;
76
+ hostname: string;
77
+ acquiredAt: string;
78
+ expiresAt: string;
79
+ durationSeconds: number;
80
+ /** Unique per-mutation witness written by the mutation that produced this content. */
81
+ token: string;
82
+ }
83
+ export type ObservedLease = {
84
+ kind: "absent";
85
+ } | {
86
+ kind: "present";
87
+ raw: string;
88
+ lease: Lease | undefined;
89
+ };
90
+ /**
91
+ * What the shared exclusion layer decided. Present only when the fleet home
92
+ * declares the layer; the CLI prints `line` verbatim, and `line` always
93
+ * names which of the two staleness bases reached the verdict, which is
94
+ * M4-P21 criterion 6.
95
+ *
96
+ * THE VERDICT IS CARRIED AS TEXT ON PURPOSE, not as a discriminated field.
97
+ * The C-2 structural inspection over this file (test/lock.test.ts:534,
98
+ * M1-P3 criterion 10) forbids a whole vocabulary of process-probing words
99
+ * from `src/lock.ts`, and it is a blunt case-insensitive grep, which is the
100
+ * property that makes it hard to defeat by accident. Naming the field after
101
+ * that vocabulary would have reddened it for a reason that has nothing to do
102
+ * with C-2, and widening the grep to let this through would weaken a guard
103
+ * this module is the whole reason for. The classification itself lives in
104
+ * `src/exclusion.ts`, which no such grep covers, and any caller needing it
105
+ * as a value reads it there.
106
+ */
107
+ export interface SharedNote {
108
+ line: string;
109
+ envId: string;
110
+ }
111
+ export type LeaseOutcome = {
112
+ ok: true;
113
+ lease: Lease;
114
+ shared?: SharedNote;
115
+ } | {
116
+ ok: true;
117
+ lease: null;
118
+ shared?: SharedNote;
119
+ } | {
120
+ ok: false;
121
+ reason: string;
122
+ shared?: SharedNote;
123
+ /**
124
+ * True when the operation failed because a mutation claim file
125
+ * was still present after the bounded wait (CR-204). A stale
126
+ * claim is NOT an active lease: reporting it as "lock held"
127
+ * sends an operator hunting for a holder that does not exist.
128
+ * Every lease operation (acquire, renew, release) sets this, and
129
+ * the CLI consumes it to emit the claim-file remedy.
130
+ */
131
+ claimTimeout?: boolean;
132
+ };
133
+ export declare function renderLease(lease: Lease): string;
134
+ /**
135
+ * Read the current lock file state: absent, or present with raw bytes.
136
+ *
137
+ * THE ENTRY TYPE IS ESTABLISHED BEFORE THE OPEN (T-008's shape in shipped
138
+ * code). A bare `readFileSync` here blocked FOREVER with zero output on a
139
+ * named pipe at the lease path, and took `lock status`, `lock acquire`,
140
+ * `lock renew` and `lock release` with it, while `tiphys doctor` returned in
141
+ * the same second against the same FIFO with "is a named pipe, not a regular
142
+ * file, so it was not opened". Two readers of one path, one of which
143
+ * established the type; this is now the same reader.
144
+ *
145
+ * A refusal THROWS rather than returning a fourth `ObservedLease` variant.
146
+ * The function already threw on every non-ENOENT error, so the contract its
147
+ * callers were written against is unchanged, and bin/tiphys.ts turns the
148
+ * throw into one diagnostic line and a nonzero exit.
149
+ */
150
+ export declare function observeLease(lockPath: string): ObservedLease;
151
+ /**
152
+ * THE ONE EXPIRY COMPARISON IN THE KERNEL (M4-P17 criterion 2).
153
+ *
154
+ * `isExpired` below needs a whole `Lease`, and doctor's lock check does not
155
+ * have one: it reads the lease file defensively and holds only `holderId` and
156
+ * `expiresAt`, because a lease file that fails `parseLease` must still produce
157
+ * a diagnosis rather than nothing. Before this phase that forced doctor to
158
+ * carry its own `Date.parse(...) <= Date.now()`, which is a SECOND comparison
159
+ * of the same property, free to drift from this one. The boundary is where
160
+ * that drift shows: `<=` makes expiry INCLUSIVE, so a lease whose `expiresAt`
161
+ * is exactly the current millisecond is expired, and a second copy written
162
+ * with `<` disagrees for exactly one millisecond and agrees everywhere else.
163
+ * A disagreement that narrow is not something a reviewer finds by reading.
164
+ *
165
+ * So the comparison lives here once and both callers reach it.
166
+ */
167
+ export declare function expiryHasPassed(expiresAt: string, nowMs: number): boolean;
168
+ export declare function isExpired(lease: Lease, nowMs: number): boolean;
169
+ /** The renew-by deadline (half-life of the current term), for holders. */
170
+ export declare function renewByMs(lease: Lease): number;
171
+ export type MutationResult = {
172
+ won: true;
173
+ } | {
174
+ won: false;
175
+ reason: string;
176
+ claimTimeout?: boolean;
177
+ };
178
+ /**
179
+ * The single staging path a lease rename goes through (CR-202). One
180
+ * fixed name is safe because staging only ever happens inside the
181
+ * mutation claim, and it makes strand cleanup deterministic.
182
+ */
183
+ export declare function stagePathFor(lockPath: string): string;
184
+ /**
185
+ * The one shared atomic mutation primitive (EXT-F-01). Applies next (new
186
+ * file content, or null to remove the lock) only if the lock file still
187
+ * holds exactly the observed state, and confirms the application by
188
+ * re-reading the mutation's own token. Loses without retry otherwise.
189
+ */
190
+ export declare function applyLeaseMutation(lockPath: string, observed: ObservedLease, next: string | null, token: string): Promise<MutationResult>;
191
+ export interface AcquireOptions {
192
+ takeover?: boolean;
193
+ durationSeconds?: number;
194
+ nowMs?: number;
195
+ /**
196
+ * The fleet home this lock belongs to. Derived from lockPath when absent;
197
+ * named explicitly only by callers whose lock path is not the fleet's
198
+ * canonical one.
199
+ */
200
+ fleetRoot?: string;
201
+ /**
202
+ * Staging seam for deterministic race witnesses: the decision is made
203
+ * against this pre-observed state instead of a fresh read, and the
204
+ * primitive then refuses if the file moved on. Normal callers omit it.
205
+ */
206
+ observed?: ObservedLease;
207
+ }
208
+ /**
209
+ * Acquire the lease, or take over an expired one when takeover is set.
210
+ * Refusals (lock held, expired without takeover, unexpired takeover)
211
+ * never mutate the file; only won mutations do.
212
+ */
213
+ export declare function acquireLease(lockPath: string, options?: AcquireOptions): Promise<LeaseOutcome>;
214
+ export interface RenewOptions {
215
+ durationSeconds?: number;
216
+ nowMs?: number;
217
+ /** See AcquireOptions.fleetRoot. */
218
+ fleetRoot?: string;
219
+ /** Staging seam for deterministic race witnesses; see AcquireOptions. */
220
+ observed?: ObservedLease;
221
+ }
222
+ /**
223
+ * Renew the lease held by holderId. Fails on an expired lease even when
224
+ * holderId matches (EXT-F-01: a paused holder whose lease expired cannot
225
+ * renew), fails on a holder mismatch, and never mutates the file on any
226
+ * failure. The new expiry strictly increases (a renew that does not
227
+ * extend the lease would be useless).
228
+ */
229
+ export declare function renewLease(lockPath: string, holderId: string, options?: RenewOptions): Promise<LeaseOutcome>;
230
+ export interface ReleaseOptions {
231
+ /** Staging seam for deterministic race witnesses; see AcquireOptions. */
232
+ observed?: ObservedLease;
233
+ /** See AcquireOptions.fleetRoot. */
234
+ fleetRoot?: string;
235
+ /** Decision clock, for the shared layer. Defaults to Date.now(). */
236
+ nowMs?: number;
237
+ }
238
+ /**
239
+ * Release the lease held by holderId. Expiry does not block a release
240
+ * (an expired former holder may clean up its own lease), but a holder
241
+ * mismatch refuses, so a losing holder can never remove the winner's
242
+ * lease; and the compare-and-swap in the primitive means a release
243
+ * staged before a takeover completes loses to it.
244
+ */
245
+ export declare function releaseLease(lockPath: string, holderId: string, options?: ReleaseOptions): Promise<LeaseOutcome>;
246
+ export type LeaseStatus = {
247
+ state: "free";
248
+ } | {
249
+ state: "held" | "expired";
250
+ lease: Lease;
251
+ } | {
252
+ state: "corrupt";
253
+ detail: string;
254
+ };
255
+ /** Report the lock state; reading only, never mutating (always safe). */
256
+ export declare function leaseStatus(lockPath: string, nowMs?: number): LeaseStatus;