@tiphys/kernel 0.0.0 → 0.1.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 (181) hide show
  1. package/AGENTS.md +611 -0
  2. package/LICENSE +202 -0
  3. package/assurance-modes.yaml +278 -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 +14 -0
  11. package/dist/src/brief.d.ts +32 -0
  12. package/dist/src/brief.js +59 -0
  13. package/dist/src/checklists.d.ts +169 -0
  14. package/dist/src/checklists.js +310 -0
  15. package/dist/src/checks.d.ts +828 -0
  16. package/dist/src/checks.js +3314 -0
  17. package/dist/src/cli.d.ts +9 -0
  18. package/dist/src/cli.js +57 -0
  19. package/dist/src/commands/brief.d.ts +92 -0
  20. package/dist/src/commands/brief.js +320 -0
  21. package/dist/src/commands/checklist.d.ts +42 -0
  22. package/dist/src/commands/checklist.js +168 -0
  23. package/dist/src/commands/doctor.d.ts +35 -0
  24. package/dist/src/commands/doctor.js +605 -0
  25. package/dist/src/commands/gates.d.ts +9 -0
  26. package/dist/src/commands/gates.js +360 -0
  27. package/dist/src/commands/init.d.ts +30 -0
  28. package/dist/src/commands/init.js +128 -0
  29. package/dist/src/commands/lock.d.ts +1 -0
  30. package/dist/src/commands/lock.js +229 -0
  31. package/dist/src/commands/mode.d.ts +40 -0
  32. package/dist/src/commands/mode.js +134 -0
  33. package/dist/src/commands/plan.d.ts +20 -0
  34. package/dist/src/commands/plan.js +105 -0
  35. package/dist/src/commands/pool.d.ts +1 -0
  36. package/dist/src/commands/pool.js +128 -0
  37. package/dist/src/commands/spawn.d.ts +1 -0
  38. package/dist/src/commands/spawn.js +146 -0
  39. package/dist/src/commands/status.d.ts +19 -0
  40. package/dist/src/commands/status.js +120 -0
  41. package/dist/src/commands/teardown.d.ts +1 -0
  42. package/dist/src/commands/teardown.js +79 -0
  43. package/dist/src/commands/tuition.d.ts +21 -0
  44. package/dist/src/commands/tuition.js +218 -0
  45. package/dist/src/commands/validate.d.ts +78 -0
  46. package/dist/src/commands/validate.js +360 -0
  47. package/dist/src/commands/watch.d.ts +1 -0
  48. package/dist/src/commands/watch.js +172 -0
  49. package/dist/src/exec/env.d.ts +128 -0
  50. package/dist/src/exec/env.js +190 -0
  51. package/dist/src/fleet.d.ts +51 -0
  52. package/dist/src/fleet.js +80 -0
  53. package/dist/src/gates/adapters/http-json.d.ts +5 -0
  54. package/dist/src/gates/adapters/http-json.js +283 -0
  55. package/dist/src/gates/adapters/migrations-command.d.ts +1 -0
  56. package/dist/src/gates/adapters/migrations-command.js +373 -0
  57. package/dist/src/gates/citations.d.ts +408 -0
  58. package/dist/src/gates/citations.js +1163 -0
  59. package/dist/src/gates/coverage.d.ts +284 -0
  60. package/dist/src/gates/coverage.js +701 -0
  61. package/dist/src/gates/credentials.d.ts +74 -0
  62. package/dist/src/gates/credentials.js +533 -0
  63. package/dist/src/gates/deploy.d.ts +1 -0
  64. package/dist/src/gates/deploy.js +33 -0
  65. package/dist/src/gates/manifest.d.ts +99 -0
  66. package/dist/src/gates/manifest.js +208 -0
  67. package/dist/src/gates/migrations.d.ts +1 -0
  68. package/dist/src/gates/migrations.js +36 -0
  69. package/dist/src/gates/pin.d.ts +114 -0
  70. package/dist/src/gates/pin.js +154 -0
  71. package/dist/src/gates/red-witness.d.ts +22 -0
  72. package/dist/src/gates/red-witness.js +390 -0
  73. package/dist/src/gates/release.d.ts +283 -0
  74. package/dist/src/gates/release.js +820 -0
  75. package/dist/src/gates/result.d.ts +116 -0
  76. package/dist/src/gates/result.js +91 -0
  77. package/dist/src/gates/run.d.ts +566 -0
  78. package/dist/src/gates/run.js +1536 -0
  79. package/dist/src/gates/schemas/citation-config.schema.json +59 -0
  80. package/dist/src/gates/schemas/coverage-config.schema.json +77 -0
  81. package/dist/src/gates/schemas/gate-manifest.schema.json +125 -0
  82. package/dist/src/gates/schemas/gate-result.schema.json +160 -0
  83. package/dist/src/gates/schemas/phase-declaration.schema.json +42 -0
  84. package/dist/src/gates/schemas/release-record.schema.json +119 -0
  85. package/dist/src/gates/schemas/verifier-config.schema.json +101 -0
  86. package/dist/src/gates/schemas/witness-spec.schema.json +110 -0
  87. package/dist/src/gates/scope.d.ts +131 -0
  88. package/dist/src/gates/scope.js +1018 -0
  89. package/dist/src/gates/suite.d.ts +217 -0
  90. package/dist/src/gates/suite.js +927 -0
  91. package/dist/src/gates/validate.d.ts +121 -0
  92. package/dist/src/gates/validate.js +414 -0
  93. package/dist/src/hooks.d.ts +32 -0
  94. package/dist/src/hooks.js +62 -0
  95. package/dist/src/liveness.d.ts +321 -0
  96. package/dist/src/liveness.js +396 -0
  97. package/dist/src/lock.d.ts +178 -0
  98. package/dist/src/lock.js +500 -0
  99. package/dist/src/modes.d.ts +149 -0
  100. package/dist/src/modes.js +258 -0
  101. package/dist/src/path-identity.d.ts +2 -0
  102. package/dist/src/path-identity.js +10 -0
  103. package/dist/src/plan.d.ts +73 -0
  104. package/dist/src/plan.js +153 -0
  105. package/dist/src/pool.d.ts +130 -0
  106. package/dist/src/pool.js +721 -0
  107. package/dist/src/roles.d.ts +430 -0
  108. package/dist/src/roles.js +734 -0
  109. package/dist/src/spawn.d.ts +177 -0
  110. package/dist/src/spawn.js +332 -0
  111. package/dist/src/status.d.ts +91 -0
  112. package/dist/src/status.js +119 -0
  113. package/dist/src/task.d.ts +264 -0
  114. package/dist/src/task.js +305 -0
  115. package/dist/src/teardown.d.ts +32 -0
  116. package/dist/src/teardown.js +314 -0
  117. package/dist/src/tuition.d.ts +159 -0
  118. package/dist/src/tuition.js +311 -0
  119. package/dist/src/validate.d.ts +230 -0
  120. package/dist/src/validate.js +732 -0
  121. package/dist/src/version.d.ts +3 -0
  122. package/dist/src/version.js +38 -0
  123. package/dist/src/watcher.d.ts +275 -0
  124. package/dist/src/watcher.js +859 -0
  125. package/dist/src/witness/run.d.ts +274 -0
  126. package/dist/src/witness/run.js +1327 -0
  127. package/dist/src/witness/spec.d.ts +102 -0
  128. package/dist/src/witness/spec.js +253 -0
  129. package/dist/tsconfig.src.tsbuildinfo +1 -0
  130. package/gate-registry.yaml +390 -0
  131. package/gates.manifest.json +195 -0
  132. package/package.json +57 -3
  133. package/role-model-config.yaml +88 -0
  134. package/roles/README.md +128 -0
  135. package/roles/_shared-dispatch-contract.md +87 -0
  136. package/roles/adversarial-plan-reviewer.md +80 -0
  137. package/roles/clean-room-reviewer.md +140 -0
  138. package/roles/implementer.md +460 -0
  139. package/roles/investigator.md +138 -0
  140. package/roles/plan-writer.md +95 -0
  141. package/schemas/README.md +81 -0
  142. package/schemas/assurance-modes.schema.json +264 -0
  143. package/schemas/charter.schema.json +166 -0
  144. package/schemas/checklist.schema.json +114 -0
  145. package/schemas/decision-record.schema.json +88 -0
  146. package/schemas/final-report.schema.json +90 -0
  147. package/schemas/finding.schema.json +106 -0
  148. package/schemas/gate-registry.schema.json +260 -0
  149. package/schemas/mechanism-index.schema.json +94 -0
  150. package/schemas/plan.schema.json +300 -0
  151. package/schemas/report.schema.json +579 -0
  152. package/schemas/role-brief.schema.json +105 -0
  153. package/schemas/role-model-config.schema.json +90 -0
  154. package/schemas/status-line.schema.json +40 -0
  155. package/schemas/tuition.schema.json +191 -0
  156. package/schemas/verdict.schema.json +289 -0
  157. package/schemas/work-history.schema.json +183 -0
  158. package/templates/charter.example.yaml +54 -0
  159. package/templates/decision-record.example.yaml +27 -0
  160. package/templates/final-report.example.yaml +80 -0
  161. package/templates/plan.example.yaml +87 -0
  162. package/templates/report.example.yaml +236 -0
  163. package/templates/warnings.md +74 -0
  164. package/templates/work-history.example.yaml +185 -0
  165. package/tuition/README.md +76 -0
  166. package/tuition/T-001.yaml +48 -0
  167. package/tuition/T-002.yaml +51 -0
  168. package/tuition/T-003.yaml +100 -0
  169. package/tuition/T-004.yaml +52 -0
  170. package/tuition/T-005.yaml +72 -0
  171. package/tuition/T-006.yaml +81 -0
  172. package/tuition/T-007.yaml +56 -0
  173. package/tuition/T-008.yaml +111 -0
  174. package/tuition/T-009.yaml +50 -0
  175. package/tuition/T-015.yaml +36 -0
  176. package/tuition/T-016.yaml +36 -0
  177. package/tuition/T-017.yaml +46 -0
  178. package/tuition/T-018.yaml +84 -0
  179. package/tuition/T-021.yaml +40 -0
  180. package/tuition/T-022.yaml +36 -0
  181. 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,178 @@
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. Cross-environment exclusion for a fleet shared through a git remote
55
+ * is M4 residue and is not claimed here. Mutations of the lock file made
56
+ * outside this module (manual edits) are not covered by the contract.
57
+ *
58
+ * Renewal discipline (PR-203): the default lease lasts 900 seconds and
59
+ * the holder renews at or before half-life (renewByMs). Holdership on
60
+ * mutating kernel commands (spawn, teardown) is verified against the
61
+ * lease by M1-P4.
62
+ */
63
+ export declare const DEFAULT_LEASE_DURATION_SECONDS = 900;
64
+ export interface Lease {
65
+ holderId: string;
66
+ hostname: string;
67
+ acquiredAt: string;
68
+ expiresAt: string;
69
+ durationSeconds: number;
70
+ /** Unique per-mutation witness written by the mutation that produced this content. */
71
+ token: string;
72
+ }
73
+ export type ObservedLease = {
74
+ kind: "absent";
75
+ } | {
76
+ kind: "present";
77
+ raw: string;
78
+ lease: Lease | undefined;
79
+ };
80
+ export type LeaseOutcome = {
81
+ ok: true;
82
+ lease: Lease;
83
+ } | {
84
+ ok: true;
85
+ lease: null;
86
+ } | {
87
+ ok: false;
88
+ reason: string;
89
+ /**
90
+ * True when the operation failed because a mutation claim file
91
+ * was still present after the bounded wait (CR-204). A stale
92
+ * claim is NOT an active lease: reporting it as "lock held"
93
+ * sends an operator hunting for a holder that does not exist.
94
+ * Every lease operation (acquire, renew, release) sets this, and
95
+ * the CLI consumes it to emit the claim-file remedy.
96
+ */
97
+ claimTimeout?: boolean;
98
+ };
99
+ export declare function renderLease(lease: Lease): string;
100
+ /** Read the current lock file state: absent, or present with raw bytes. */
101
+ export declare function observeLease(lockPath: string): ObservedLease;
102
+ export declare function isExpired(lease: Lease, nowMs: number): boolean;
103
+ /** The renew-by deadline (half-life of the current term), for holders. */
104
+ export declare function renewByMs(lease: Lease): number;
105
+ export type MutationResult = {
106
+ won: true;
107
+ } | {
108
+ won: false;
109
+ reason: string;
110
+ claimTimeout?: boolean;
111
+ };
112
+ /**
113
+ * The single staging path a lease rename goes through (CR-202). One
114
+ * fixed name is safe because staging only ever happens inside the
115
+ * mutation claim, and it makes strand cleanup deterministic.
116
+ */
117
+ export declare function stagePathFor(lockPath: string): string;
118
+ /**
119
+ * The one shared atomic mutation primitive (EXT-F-01). Applies next (new
120
+ * file content, or null to remove the lock) only if the lock file still
121
+ * holds exactly the observed state, and confirms the application by
122
+ * re-reading the mutation's own token. Loses without retry otherwise.
123
+ */
124
+ export declare function applyLeaseMutation(lockPath: string, observed: ObservedLease, next: string | null, token: string): Promise<MutationResult>;
125
+ export interface AcquireOptions {
126
+ takeover?: boolean;
127
+ durationSeconds?: number;
128
+ nowMs?: number;
129
+ /**
130
+ * Staging seam for deterministic race witnesses: the decision is made
131
+ * against this pre-observed state instead of a fresh read, and the
132
+ * primitive then refuses if the file moved on. Normal callers omit it.
133
+ */
134
+ observed?: ObservedLease;
135
+ }
136
+ /**
137
+ * Acquire the lease, or take over an expired one when takeover is set.
138
+ * Refusals (lock held, expired without takeover, unexpired takeover)
139
+ * never mutate the file; only won mutations do.
140
+ */
141
+ export declare function acquireLease(lockPath: string, options?: AcquireOptions): Promise<LeaseOutcome>;
142
+ export interface RenewOptions {
143
+ durationSeconds?: number;
144
+ nowMs?: number;
145
+ /** Staging seam for deterministic race witnesses; see AcquireOptions. */
146
+ observed?: ObservedLease;
147
+ }
148
+ /**
149
+ * Renew the lease held by holderId. Fails on an expired lease even when
150
+ * holderId matches (EXT-F-01: a paused holder whose lease expired cannot
151
+ * renew), fails on a holder mismatch, and never mutates the file on any
152
+ * failure. The new expiry strictly increases (a renew that does not
153
+ * extend the lease would be useless).
154
+ */
155
+ export declare function renewLease(lockPath: string, holderId: string, options?: RenewOptions): Promise<LeaseOutcome>;
156
+ export interface ReleaseOptions {
157
+ /** Staging seam for deterministic race witnesses; see AcquireOptions. */
158
+ observed?: ObservedLease;
159
+ }
160
+ /**
161
+ * Release the lease held by holderId. Expiry does not block a release
162
+ * (an expired former holder may clean up its own lease), but a holder
163
+ * mismatch refuses, so a losing holder can never remove the winner's
164
+ * lease; and the compare-and-swap in the primitive means a release
165
+ * staged before a takeover completes loses to it.
166
+ */
167
+ export declare function releaseLease(lockPath: string, holderId: string, options?: ReleaseOptions): Promise<LeaseOutcome>;
168
+ export type LeaseStatus = {
169
+ state: "free";
170
+ } | {
171
+ state: "held" | "expired";
172
+ lease: Lease;
173
+ } | {
174
+ state: "corrupt";
175
+ detail: string;
176
+ };
177
+ /** Report the lock state; reading only, never mutating (always safe). */
178
+ export declare function leaseStatus(lockPath: string, nowMs?: number): LeaseStatus;