@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.
- package/AGENTS.md +663 -0
- package/LICENSE +202 -0
- package/assurance-modes.yaml +299 -0
- package/checklists/clean-room.yaml +325 -0
- package/checklists/env-failure-diagnosis.yaml +68 -0
- package/checklists/flake-playbook.yaml +68 -0
- package/checklists/hazard-review.yaml +144 -0
- package/checklists/plan-review.yaml +103 -0
- package/dist/bin/tiphys.d.ts +2 -0
- package/dist/bin/tiphys.js +92 -0
- package/dist/src/adapters/load.d.ts +202 -0
- package/dist/src/adapters/load.js +440 -0
- package/dist/src/brief.d.ts +32 -0
- package/dist/src/brief.js +66 -0
- package/dist/src/checklists.d.ts +169 -0
- package/dist/src/checklists.js +310 -0
- package/dist/src/checks.d.ts +1539 -0
- package/dist/src/checks.js +5025 -0
- package/dist/src/cli.d.ts +9 -0
- package/dist/src/cli.js +68 -0
- package/dist/src/commands/brief.d.ts +92 -0
- package/dist/src/commands/brief.js +343 -0
- package/dist/src/commands/checklist.d.ts +42 -0
- package/dist/src/commands/checklist.js +168 -0
- package/dist/src/commands/cutover.d.ts +35 -0
- package/dist/src/commands/cutover.js +448 -0
- package/dist/src/commands/doctor.d.ts +264 -0
- package/dist/src/commands/doctor.js +1546 -0
- package/dist/src/commands/gates.d.ts +9 -0
- package/dist/src/commands/gates.js +360 -0
- package/dist/src/commands/init.d.ts +30 -0
- package/dist/src/commands/init.js +177 -0
- package/dist/src/commands/lock.d.ts +34 -0
- package/dist/src/commands/lock.js +340 -0
- package/dist/src/commands/mode.d.ts +40 -0
- package/dist/src/commands/mode.js +134 -0
- package/dist/src/commands/next.d.ts +130 -0
- package/dist/src/commands/next.js +597 -0
- package/dist/src/commands/plan.d.ts +20 -0
- package/dist/src/commands/plan.js +105 -0
- package/dist/src/commands/pool.d.ts +1 -0
- package/dist/src/commands/pool.js +139 -0
- package/dist/src/commands/resume.d.ts +1 -0
- package/dist/src/commands/resume.js +88 -0
- package/dist/src/commands/spawn.d.ts +1 -0
- package/dist/src/commands/spawn.js +195 -0
- package/dist/src/commands/status.d.ts +21 -0
- package/dist/src/commands/status.js +122 -0
- package/dist/src/commands/sync.d.ts +47 -0
- package/dist/src/commands/sync.js +341 -0
- package/dist/src/commands/teardown.d.ts +1 -0
- package/dist/src/commands/teardown.js +87 -0
- package/dist/src/commands/tuition.d.ts +21 -0
- package/dist/src/commands/tuition.js +218 -0
- package/dist/src/commands/validate.d.ts +78 -0
- package/dist/src/commands/validate.js +430 -0
- package/dist/src/commands/watch.d.ts +1 -0
- package/dist/src/commands/watch.js +172 -0
- package/dist/src/cutover.d.ts +584 -0
- package/dist/src/cutover.js +1444 -0
- package/dist/src/exclusion.d.ts +389 -0
- package/dist/src/exclusion.js +843 -0
- package/dist/src/exec/env.d.ts +278 -0
- package/dist/src/exec/env.js +334 -0
- package/dist/src/fleet.d.ts +223 -0
- package/dist/src/fleet.js +298 -0
- package/dist/src/gates/adapters/http-json.d.ts +5 -0
- package/dist/src/gates/adapters/http-json.js +283 -0
- package/dist/src/gates/adapters/migrations-command.d.ts +1 -0
- package/dist/src/gates/adapters/migrations-command.js +373 -0
- package/dist/src/gates/citations.d.ts +408 -0
- package/dist/src/gates/citations.js +1169 -0
- package/dist/src/gates/coverage.d.ts +375 -0
- package/dist/src/gates/coverage.js +836 -0
- package/dist/src/gates/credentials.d.ts +233 -0
- package/dist/src/gates/credentials.js +752 -0
- package/dist/src/gates/deploy.d.ts +1 -0
- package/dist/src/gates/deploy.js +33 -0
- package/dist/src/gates/gate-classes.d.ts +56 -0
- package/dist/src/gates/gate-classes.js +633 -0
- package/dist/src/gates/manifest.d.ts +99 -0
- package/dist/src/gates/manifest.js +208 -0
- package/dist/src/gates/merge-preconditions.d.ts +319 -0
- package/dist/src/gates/merge-preconditions.js +932 -0
- package/dist/src/gates/migrations.d.ts +1 -0
- package/dist/src/gates/migrations.js +36 -0
- package/dist/src/gates/pin.d.ts +114 -0
- package/dist/src/gates/pin.js +154 -0
- package/dist/src/gates/red-witness.d.ts +22 -0
- package/dist/src/gates/red-witness.js +482 -0
- package/dist/src/gates/release.d.ts +283 -0
- package/dist/src/gates/release.js +820 -0
- package/dist/src/gates/result.d.ts +116 -0
- package/dist/src/gates/result.js +91 -0
- package/dist/src/gates/run.d.ts +614 -0
- package/dist/src/gates/run.js +1614 -0
- package/dist/src/gates/schemas/citation-config.schema.json +59 -0
- package/dist/src/gates/schemas/coverage-config.schema.json +77 -0
- package/dist/src/gates/schemas/gate-manifest.schema.json +125 -0
- package/dist/src/gates/schemas/gate-result.schema.json +160 -0
- package/dist/src/gates/schemas/phase-declaration.schema.json +87 -0
- package/dist/src/gates/schemas/release-record.schema.json +119 -0
- package/dist/src/gates/schemas/verifier-config.schema.json +101 -0
- package/dist/src/gates/schemas/witness-spec.schema.json +110 -0
- package/dist/src/gates/scope.d.ts +131 -0
- package/dist/src/gates/scope.js +1018 -0
- package/dist/src/gates/suite.d.ts +217 -0
- package/dist/src/gates/suite.js +968 -0
- package/dist/src/gates/validate.d.ts +121 -0
- package/dist/src/gates/validate.js +414 -0
- package/dist/src/hooks.d.ts +84 -0
- package/dist/src/hooks.js +125 -0
- package/dist/src/index.d.ts +31 -0
- package/dist/src/index.js +30 -0
- package/dist/src/liveness.d.ts +321 -0
- package/dist/src/liveness.js +396 -0
- package/dist/src/lock.d.ts +256 -0
- package/dist/src/lock.js +792 -0
- package/dist/src/model-resolution.d.ts +159 -0
- package/dist/src/model-resolution.js +307 -0
- package/dist/src/modes.d.ts +149 -0
- package/dist/src/modes.js +258 -0
- package/dist/src/path-identity.d.ts +34 -0
- package/dist/src/path-identity.js +48 -0
- package/dist/src/plan.d.ts +73 -0
- package/dist/src/plan.js +153 -0
- package/dist/src/pool.d.ts +326 -0
- package/dist/src/pool.js +988 -0
- package/dist/src/roles.d.ts +461 -0
- package/dist/src/roles.js +776 -0
- package/dist/src/spawn.d.ts +482 -0
- package/dist/src/spawn.js +1003 -0
- package/dist/src/status.d.ts +116 -0
- package/dist/src/status.js +148 -0
- package/dist/src/task.d.ts +504 -0
- package/dist/src/task.js +307 -0
- package/dist/src/teardown.d.ts +39 -0
- package/dist/src/teardown.js +422 -0
- package/dist/src/tuition.d.ts +159 -0
- package/dist/src/tuition.js +311 -0
- package/dist/src/validate.d.ts +263 -0
- package/dist/src/validate.js +742 -0
- package/dist/src/version.d.ts +3 -0
- package/dist/src/version.js +38 -0
- package/dist/src/watcher.d.ts +275 -0
- package/dist/src/watcher.js +849 -0
- package/dist/src/witness/run.d.ts +299 -0
- package/dist/src/witness/run.js +1373 -0
- package/dist/src/witness/spec.d.ts +270 -0
- package/dist/src/witness/spec.js +475 -0
- package/dist/tsconfig.src.tsbuildinfo +1 -0
- package/gate-registry.yaml +526 -0
- package/gates.manifest.json +257 -0
- package/package.json +72 -3
- package/role-model-config.yaml +88 -0
- package/roles/README.md +128 -0
- package/roles/_shared-dispatch-contract.md +87 -0
- package/roles/adversarial-plan-reviewer.md +80 -0
- package/roles/clean-room-reviewer.md +140 -0
- package/roles/implementer.md +463 -0
- package/roles/investigator.md +138 -0
- package/roles/plan-writer.md +95 -0
- package/schemas/README.md +82 -0
- package/schemas/assurance-modes.schema.json +264 -0
- package/schemas/charter.schema.json +185 -0
- package/schemas/checklist.schema.json +114 -0
- package/schemas/cutover-state.schema.json +64 -0
- package/schemas/decision-record.schema.json +88 -0
- package/schemas/executor-record.schema.json +36 -0
- package/schemas/final-report.schema.json +90 -0
- package/schemas/finding.schema.json +106 -0
- package/schemas/gate-registry.schema.json +260 -0
- package/schemas/mechanism-index.schema.json +94 -0
- package/schemas/model-resolution.schema.json +362 -0
- package/schemas/plan.schema.json +300 -0
- package/schemas/report.schema.json +579 -0
- package/schemas/role-brief.schema.json +105 -0
- package/schemas/role-model-config.schema.json +90 -0
- package/schemas/status-line.schema.json +40 -0
- package/schemas/tuition.schema.json +191 -0
- package/schemas/verdict.schema.json +295 -0
- package/schemas/work-history.schema.json +183 -0
- package/schemas/write-bypass.schema.json +69 -0
- package/templates/charter.example.yaml +54 -0
- package/templates/decision-record.example.yaml +27 -0
- package/templates/final-report.example.yaml +80 -0
- package/templates/plan.example.yaml +87 -0
- package/templates/report.example.yaml +236 -0
- package/templates/warnings.md +74 -0
- package/templates/work-history.example.yaml +185 -0
- package/tuition/README.md +76 -0
- package/tuition/T-001.yaml +48 -0
- package/tuition/T-002.yaml +51 -0
- package/tuition/T-003.yaml +100 -0
- package/tuition/T-004.yaml +52 -0
- package/tuition/T-005.yaml +72 -0
- package/tuition/T-006.yaml +81 -0
- package/tuition/T-007.yaml +56 -0
- package/tuition/T-008.yaml +111 -0
- package/tuition/T-009.yaml +50 -0
- package/tuition/T-015.yaml +36 -0
- package/tuition/T-016.yaml +36 -0
- package/tuition/T-017.yaml +46 -0
- package/tuition/T-018.yaml +84 -0
- package/tuition/T-021.yaml +40 -0
- package/tuition/T-022.yaml +36 -0
- 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;
|