@tiphys/kernel 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +56 -4
- package/assurance-modes.yaml +23 -2
- package/dist/bin/tiphys.js +86 -8
- package/dist/src/adapters/load.d.ts +202 -0
- package/dist/src/adapters/load.js +440 -0
- package/dist/src/brief.js +27 -20
- package/dist/src/checks.d.ts +720 -9
- package/dist/src/checks.js +1874 -163
- package/dist/src/cli.js +11 -0
- package/dist/src/commands/brief.js +27 -4
- package/dist/src/commands/cutover.d.ts +35 -0
- package/dist/src/commands/cutover.js +448 -0
- package/dist/src/commands/doctor.d.ts +229 -0
- package/dist/src/commands/doctor.js +968 -27
- package/dist/src/commands/init.d.ts +3 -3
- package/dist/src/commands/init.js +57 -8
- package/dist/src/commands/lock.d.ts +33 -0
- package/dist/src/commands/lock.js +117 -6
- package/dist/src/commands/next.d.ts +130 -0
- package/dist/src/commands/next.js +597 -0
- package/dist/src/commands/pool.js +12 -1
- package/dist/src/commands/resume.d.ts +1 -0
- package/dist/src/commands/resume.js +88 -0
- package/dist/src/commands/spawn.js +51 -2
- package/dist/src/commands/status.d.ts +6 -4
- package/dist/src/commands/status.js +6 -4
- package/dist/src/commands/sync.d.ts +47 -0
- package/dist/src/commands/sync.js +341 -0
- package/dist/src/commands/teardown.js +10 -2
- package/dist/src/commands/validate.js +70 -0
- package/dist/src/cutover.d.ts +584 -0
- package/dist/src/cutover.js +1444 -0
- package/dist/src/exclusion.d.ts +389 -0
- package/dist/src/exclusion.js +843 -0
- package/dist/src/exec/env.d.ts +152 -2
- package/dist/src/exec/env.js +146 -2
- package/dist/src/fleet.d.ts +172 -0
- package/dist/src/fleet.js +219 -1
- package/dist/src/gates/citations.js +7 -1
- package/dist/src/gates/coverage.d.ts +113 -22
- package/dist/src/gates/coverage.js +166 -31
- package/dist/src/gates/credentials.d.ts +159 -0
- package/dist/src/gates/credentials.js +221 -2
- package/dist/src/gates/gate-classes.d.ts +56 -0
- package/dist/src/gates/gate-classes.js +633 -0
- package/dist/src/gates/merge-preconditions.d.ts +319 -0
- package/dist/src/gates/merge-preconditions.js +932 -0
- package/dist/src/gates/red-witness.js +105 -13
- package/dist/src/gates/run.d.ts +49 -1
- package/dist/src/gates/run.js +83 -5
- package/dist/src/gates/schemas/phase-declaration.schema.json +45 -0
- package/dist/src/gates/suite.js +48 -7
- package/dist/src/hooks.d.ts +55 -3
- package/dist/src/hooks.js +69 -6
- package/dist/src/index.d.ts +31 -0
- package/dist/src/index.js +30 -0
- package/dist/src/lock.d.ts +82 -4
- package/dist/src/lock.js +314 -22
- package/dist/src/model-resolution.d.ts +159 -0
- package/dist/src/model-resolution.js +307 -0
- package/dist/src/path-identity.d.ts +32 -0
- package/dist/src/path-identity.js +38 -0
- package/dist/src/pool.d.ts +197 -1
- package/dist/src/pool.js +289 -22
- package/dist/src/roles.d.ts +31 -0
- package/dist/src/roles.js +42 -0
- package/dist/src/spawn.d.ts +307 -2
- package/dist/src/spawn.js +690 -19
- package/dist/src/status.d.ts +27 -2
- package/dist/src/status.js +34 -5
- package/dist/src/task.d.ts +295 -55
- package/dist/src/task.js +125 -123
- package/dist/src/teardown.d.ts +7 -0
- package/dist/src/teardown.js +120 -12
- package/dist/src/validate.d.ts +44 -11
- package/dist/src/validate.js +44 -34
- package/dist/src/watcher.js +1 -11
- package/dist/src/witness/run.d.ts +32 -7
- package/dist/src/witness/run.js +76 -30
- package/dist/src/witness/spec.d.ts +168 -0
- package/dist/src/witness/spec.js +240 -18
- package/dist/tsconfig.src.tsbuildinfo +1 -1
- package/gate-registry.yaml +136 -0
- package/gates.manifest.json +63 -1
- package/package.json +18 -3
- package/roles/implementer.md +3 -0
- package/schemas/README.md +1 -0
- package/schemas/assurance-modes.schema.json +1 -1
- package/schemas/charter.schema.json +19 -0
- package/schemas/cutover-state.schema.json +64 -0
- package/schemas/executor-record.schema.json +36 -0
- package/schemas/model-resolution.schema.json +362 -0
- package/schemas/verdict.schema.json +9 -3
- package/schemas/write-bypass.schema.json +69 -0
package/dist/src/lock.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { linkSync,
|
|
2
|
+
import { linkSync, renameSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import { hostname } from "node:os";
|
|
4
4
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
5
|
+
import { buildRegisterDocument, casFailureLine, casWrite, ensureEnvironmentId, fleetRootForLockPath, preflightShared, readSharedExclusion, } from "./exclusion.js";
|
|
6
|
+
import { readRegularPathIfPresent } from "./fleet.js";
|
|
5
7
|
/**
|
|
6
8
|
* Lease-based session lock (kernel plan v1, M1-P3 step 1; DR-0007; plan
|
|
7
9
|
* constraint C-2). One orchestrator per fleet: the lock file at
|
|
@@ -55,9 +57,19 @@ import { setTimeout as sleep } from "node:timers/promises";
|
|
|
55
57
|
*
|
|
56
58
|
* Exclusion domain (PR-201, DR-0007 stated honestly): the lease excludes
|
|
57
59
|
* within one filesystem and one clock, the fleet home the lock file lives
|
|
58
|
-
* in.
|
|
59
|
-
*
|
|
60
|
-
*
|
|
60
|
+
* in. Mutations of the lock file made outside this module (manual edits)
|
|
61
|
+
* are not covered by the contract.
|
|
62
|
+
*
|
|
63
|
+
* CROSS-ENVIRONMENT EXCLUSION IS A SECOND LAYER ABOVE THIS ONE (M4-P21),
|
|
64
|
+
* and it is OFF unless the fleet home declares it. When the fleet's own
|
|
65
|
+
* `package.json` carries `tiphys.sharedExclusion`, every mutation below
|
|
66
|
+
* first asks `src/exclusion.ts` for a verdict from the shared register on
|
|
67
|
+
* the fleet's git remote, and only a won verdict reaches the local lease.
|
|
68
|
+
* With the field ABSENT, `readSharedExclusion` returns before spawning
|
|
69
|
+
* anything and every path in this module behaves exactly as it did, which
|
|
70
|
+
* is the property M4-P21 criterion 1 asserts. The refusal never touches the
|
|
71
|
+
* lock file, which is what makes the fail-closed behaviour on an
|
|
72
|
+
* unreachable register observable (criterion 7): no local lease appears.
|
|
61
73
|
*
|
|
62
74
|
* Renewal discipline (PR-203): the default lease lasts 900 seconds and
|
|
63
75
|
* the holder renews at or before half-life (renewByMs). Holdership on
|
|
@@ -92,37 +104,68 @@ function parseLease(raw) {
|
|
|
92
104
|
export function renderLease(lease) {
|
|
93
105
|
return `${JSON.stringify(lease, null, 2)}\n`;
|
|
94
106
|
}
|
|
95
|
-
/**
|
|
107
|
+
/**
|
|
108
|
+
* Read the current lock file state: absent, or present with raw bytes.
|
|
109
|
+
*
|
|
110
|
+
* THE ENTRY TYPE IS ESTABLISHED BEFORE THE OPEN (T-008's shape in shipped
|
|
111
|
+
* code). A bare `readFileSync` here blocked FOREVER with zero output on a
|
|
112
|
+
* named pipe at the lease path, and took `lock status`, `lock acquire`,
|
|
113
|
+
* `lock renew` and `lock release` with it, while `tiphys doctor` returned in
|
|
114
|
+
* the same second against the same FIFO with "is a named pipe, not a regular
|
|
115
|
+
* file, so it was not opened". Two readers of one path, one of which
|
|
116
|
+
* established the type; this is now the same reader.
|
|
117
|
+
*
|
|
118
|
+
* A refusal THROWS rather than returning a fourth `ObservedLease` variant.
|
|
119
|
+
* The function already threw on every non-ENOENT error, so the contract its
|
|
120
|
+
* callers were written against is unchanged, and bin/tiphys.ts turns the
|
|
121
|
+
* throw into one diagnostic line and a nonzero exit.
|
|
122
|
+
*/
|
|
96
123
|
export function observeLease(lockPath) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
124
|
+
const read = readRegularPathIfPresent(lockPath);
|
|
125
|
+
if (read.kind === "absent") {
|
|
126
|
+
return { kind: "absent" };
|
|
100
127
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return { kind: "absent" };
|
|
104
|
-
}
|
|
105
|
-
throw error;
|
|
128
|
+
if (read.kind === "refused") {
|
|
129
|
+
throw new Error(read.reason);
|
|
106
130
|
}
|
|
107
|
-
return { kind: "present", raw, lease: parseLease(
|
|
131
|
+
return { kind: "present", raw: read.body, lease: parseLease(read.body) };
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* THE ONE EXPIRY COMPARISON IN THE KERNEL (M4-P17 criterion 2).
|
|
135
|
+
*
|
|
136
|
+
* `isExpired` below needs a whole `Lease`, and doctor's lock check does not
|
|
137
|
+
* have one: it reads the lease file defensively and holds only `holderId` and
|
|
138
|
+
* `expiresAt`, because a lease file that fails `parseLease` must still produce
|
|
139
|
+
* a diagnosis rather than nothing. Before this phase that forced doctor to
|
|
140
|
+
* carry its own `Date.parse(...) <= Date.now()`, which is a SECOND comparison
|
|
141
|
+
* of the same property, free to drift from this one. The boundary is where
|
|
142
|
+
* that drift shows: `<=` makes expiry INCLUSIVE, so a lease whose `expiresAt`
|
|
143
|
+
* is exactly the current millisecond is expired, and a second copy written
|
|
144
|
+
* with `<` disagrees for exactly one millisecond and agrees everywhere else.
|
|
145
|
+
* A disagreement that narrow is not something a reviewer finds by reading.
|
|
146
|
+
*
|
|
147
|
+
* So the comparison lives here once and both callers reach it.
|
|
148
|
+
*/
|
|
149
|
+
export function expiryHasPassed(expiresAt, nowMs) {
|
|
150
|
+
return Date.parse(expiresAt) <= nowMs;
|
|
108
151
|
}
|
|
109
152
|
export function isExpired(lease, nowMs) {
|
|
110
|
-
return
|
|
153
|
+
return expiryHasPassed(lease.expiresAt, nowMs);
|
|
111
154
|
}
|
|
112
155
|
/** The renew-by deadline (half-life of the current term), for holders. */
|
|
113
156
|
export function renewByMs(lease) {
|
|
114
157
|
return Date.parse(lease.expiresAt) - (lease.durationSeconds * 1000) / 2;
|
|
115
158
|
}
|
|
159
|
+
/** The same guarded read as observeLease, for the inside of the claim. */
|
|
116
160
|
function readCurrent(lockPath) {
|
|
117
|
-
|
|
118
|
-
|
|
161
|
+
const read = readRegularPathIfPresent(lockPath);
|
|
162
|
+
if (read.kind === "absent") {
|
|
163
|
+
return { present: false, raw: "" };
|
|
119
164
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
return { present: false, raw: "" };
|
|
123
|
-
}
|
|
124
|
-
throw error;
|
|
165
|
+
if (read.kind === "refused") {
|
|
166
|
+
throw new Error(read.reason);
|
|
125
167
|
}
|
|
168
|
+
return { present: true, raw: read.body };
|
|
126
169
|
}
|
|
127
170
|
/**
|
|
128
171
|
* Re-read the lock file and re-compare it against the state a mutation
|
|
@@ -265,6 +308,16 @@ export async function applyLeaseMutation(lockPath, observed, next, token) {
|
|
|
265
308
|
// lock path use the same discipline, which is the asymmetry the
|
|
266
309
|
// module previously left unjustified.
|
|
267
310
|
const stagePath = stagePathFor(lockPath);
|
|
311
|
+
/* THE STAGE IS OPENED FOR WRITING and is NOT guarded here, which is a
|
|
312
|
+
measurement rather than an oversight. open(2) for writing on a FIFO
|
|
313
|
+
blocks exactly as reading one does, so this looked like a member of
|
|
314
|
+
the class this round closed. It is not: the claim-held sweep above
|
|
315
|
+
(CR-202) unlinks `<lock>.stage` UNCONDITIONALLY before either branch
|
|
316
|
+
writes it, so a planted FIFO is gone by the time this line runs.
|
|
317
|
+
Measured at this head with a real mkfifo at
|
|
318
|
+
`state/orchestrator.lock.stage`: `tiphys lock acquire` exits 0 in
|
|
319
|
+
under a second and the lease is taken. A guard here would therefore
|
|
320
|
+
be code no witness can redden. */
|
|
268
321
|
writeFileSync(stagePath, next);
|
|
269
322
|
try {
|
|
270
323
|
linkSync(stagePath, lockPath);
|
|
@@ -299,6 +352,16 @@ export async function applyLeaseMutation(lockPath, observed, next, token) {
|
|
|
299
352
|
// confirmation read both still happen inside the claim, and the
|
|
300
353
|
// rename remains atomic within one directory.
|
|
301
354
|
const stagePath = stagePathFor(lockPath);
|
|
355
|
+
/* THE STAGE IS OPENED FOR WRITING and is NOT guarded here, which is a
|
|
356
|
+
measurement rather than an oversight. open(2) for writing on a FIFO
|
|
357
|
+
blocks exactly as reading one does, so this looked like a member of
|
|
358
|
+
the class this round closed. It is not: the claim-held sweep above
|
|
359
|
+
(CR-202) unlinks `<lock>.stage` UNCONDITIONALLY before either branch
|
|
360
|
+
writes it, so a planted FIFO is gone by the time this line runs.
|
|
361
|
+
Measured at this head with a real mkfifo at
|
|
362
|
+
`state/orchestrator.lock.stage`: `tiphys lock acquire` exits 0 in
|
|
363
|
+
under a second and the lease is taken. A guard here would therefore
|
|
364
|
+
be code no witness can redden. */
|
|
302
365
|
writeFileSync(stagePath, next);
|
|
303
366
|
try {
|
|
304
367
|
renameSync(stagePath, lockPath);
|
|
@@ -343,6 +406,100 @@ export async function applyLeaseMutation(lockPath, observed, next, token) {
|
|
|
343
406
|
}
|
|
344
407
|
}
|
|
345
408
|
}
|
|
409
|
+
/**
|
|
410
|
+
* Resolve the fleet home's declaration. The ONLY entry into the shared
|
|
411
|
+
* layer, and the only place a fleet root is derived: a lock path is
|
|
412
|
+
* `<root>/state/orchestrator.lock` (src/fleet.ts:37), so the root is two
|
|
413
|
+
* directories up unless a caller names it. A declaration that is present
|
|
414
|
+
* and unusable is reported rather than treated as absent, because reading a
|
|
415
|
+
* typo as "off" is the failure that would make this layer green everywhere.
|
|
416
|
+
*/
|
|
417
|
+
function sharedGate(lockPath, fleetRoot) {
|
|
418
|
+
const root = fleetRoot ?? fleetRootForLockPath(lockPath);
|
|
419
|
+
const declaration = readSharedExclusion(root);
|
|
420
|
+
if (declaration.kind === "absent") {
|
|
421
|
+
return { kind: "off" };
|
|
422
|
+
}
|
|
423
|
+
if (declaration.kind === "invalid") {
|
|
424
|
+
return { kind: "invalid", reason: declaration.reason };
|
|
425
|
+
}
|
|
426
|
+
const identity = ensureEnvironmentId(root);
|
|
427
|
+
return {
|
|
428
|
+
kind: "on",
|
|
429
|
+
ctx: { config: declaration.config, envId: identity.envId, fleetRoot: root },
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
function invalidDeclarationOutcome(reason) {
|
|
433
|
+
return {
|
|
434
|
+
ok: false,
|
|
435
|
+
reason: `shared exclusion is declared and unusable, refusing rather than running ` +
|
|
436
|
+
`local-only: ${reason}`,
|
|
437
|
+
};
|
|
438
|
+
}
|
|
439
|
+
/** Ask the shared register for a verdict, without touching anything. */
|
|
440
|
+
function sharedPreflight(ctx, intent, takeover, nowMs) {
|
|
441
|
+
return preflightShared({
|
|
442
|
+
fleetRoot: ctx.fleetRoot,
|
|
443
|
+
config: ctx.config,
|
|
444
|
+
envId: ctx.envId,
|
|
445
|
+
intent,
|
|
446
|
+
takeover,
|
|
447
|
+
nowMs,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
function sharedNote(ctx, preflight) {
|
|
451
|
+
return { line: preflight.line, envId: ctx.envId };
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Publish the won verdict to the register. Called only AFTER the local
|
|
455
|
+
* mutation succeeded, so the two layers agree or the local one is rolled
|
|
456
|
+
* back by the caller: a register entry with no local lease behind it would
|
|
457
|
+
* exclude every environment including the one that wrote it.
|
|
458
|
+
*/
|
|
459
|
+
function sharedCommit(ctx, preflight, state, nowMs, durationSeconds) {
|
|
460
|
+
const document = buildRegisterDocument({
|
|
461
|
+
state,
|
|
462
|
+
envId: ctx.envId,
|
|
463
|
+
counter: preflight.nextCounter,
|
|
464
|
+
nowMs,
|
|
465
|
+
durationSeconds,
|
|
466
|
+
ref: ctx.config.ref,
|
|
467
|
+
...(preflight.current !== undefined && state === "held" && !preflight.takingOver
|
|
468
|
+
? { acquiredAt: preflight.current.acquiredAt }
|
|
469
|
+
: {}),
|
|
470
|
+
});
|
|
471
|
+
const outcome = casWrite(ctx.fleetRoot, ctx.config, preflight.expectedSha, document);
|
|
472
|
+
if (outcome.kind === "won") {
|
|
473
|
+
return { ok: true, sha: outcome.sha };
|
|
474
|
+
}
|
|
475
|
+
return { ok: false, reason: casFailureLine(ctx.config.ref, outcome) };
|
|
476
|
+
}
|
|
477
|
+
/**
|
|
478
|
+
* UNDO A LOCAL LEASE MUTATION WHOSE REGISTER PUBLISH LOST.
|
|
479
|
+
*
|
|
480
|
+
* `current` is what the lock file holds NOW (the state this mutation left),
|
|
481
|
+
* and `restore` is the observation the mutation was decided against, whose
|
|
482
|
+
* raw bytes are written back verbatim so the restored file is BYTE-IDENTICAL
|
|
483
|
+
* to the one the command claimed not to have changed.
|
|
484
|
+
*
|
|
485
|
+
* The token handed to the primitive is the RESTORED lease's own token, not a
|
|
486
|
+
* fresh one: the primitive confirms an application by re-reading the token in
|
|
487
|
+
* the file it just wrote, and a fresh token would fail that confirmation
|
|
488
|
+
* against bytes that carry the old one.
|
|
489
|
+
*
|
|
490
|
+
* Returns the sentence appended to the failure line. A rollback that ITSELF
|
|
491
|
+
* loses is stated rather than swallowed, because "the local lease was
|
|
492
|
+
* restored" is a claim a later reader will act on.
|
|
493
|
+
*/
|
|
494
|
+
async function restoreLocal(lockPath, current, restore) {
|
|
495
|
+
if (restore.lease === undefined) {
|
|
496
|
+
return "; the local lease could NOT be restored: the observed lease does not parse";
|
|
497
|
+
}
|
|
498
|
+
const undone = await applyLeaseMutation(lockPath, current, restore.raw, restore.lease.token);
|
|
499
|
+
return undone.won
|
|
500
|
+
? "; the local lease was restored"
|
|
501
|
+
: `; the local lease could NOT be restored (${undone.reason}), so ${lockPath} and the register now disagree`;
|
|
502
|
+
}
|
|
346
503
|
function buildLease(nowMs, durationSeconds) {
|
|
347
504
|
return {
|
|
348
505
|
holderId: randomUUID(),
|
|
@@ -361,6 +518,27 @@ function buildLease(nowMs, durationSeconds) {
|
|
|
361
518
|
export async function acquireLease(lockPath, options = {}) {
|
|
362
519
|
const nowMs = options.nowMs ?? Date.now();
|
|
363
520
|
const durationSeconds = options.durationSeconds ?? DEFAULT_LEASE_DURATION_SECONDS;
|
|
521
|
+
/* THE SHARED LAYER RUNS FIRST AND MUTATES NOTHING (M4-P21 criteria 1 and
|
|
522
|
+
7). Off unless declared, so the read below is the whole cost for every
|
|
523
|
+
fleet that has not opted in. A refusal returns here, before the local
|
|
524
|
+
lease file can be created, which is the fail-closed property: an
|
|
525
|
+
unreachable register leaves no local lock behind to be mistaken for
|
|
526
|
+
exclusion that is not there. */
|
|
527
|
+
const gate = sharedGate(lockPath, options.fleetRoot);
|
|
528
|
+
if (gate.kind === "invalid") {
|
|
529
|
+
return invalidDeclarationOutcome(gate.reason);
|
|
530
|
+
}
|
|
531
|
+
let preflight;
|
|
532
|
+
if (gate.kind === "on") {
|
|
533
|
+
preflight = sharedPreflight(gate.ctx, "acquire", options.takeover === true, nowMs);
|
|
534
|
+
if (preflight.kind === "refused") {
|
|
535
|
+
return {
|
|
536
|
+
ok: false,
|
|
537
|
+
reason: preflight.line,
|
|
538
|
+
shared: sharedNote(gate.ctx, preflight),
|
|
539
|
+
};
|
|
540
|
+
}
|
|
541
|
+
}
|
|
364
542
|
const observed = options.observed ?? observeLease(lockPath);
|
|
365
543
|
if (observed.kind === "present") {
|
|
366
544
|
if (observed.lease === undefined) {
|
|
@@ -397,6 +575,31 @@ export async function acquireLease(lockPath, options = {}) {
|
|
|
397
575
|
}
|
|
398
576
|
return { ok: false, reason: `lock held (${result.reason})` };
|
|
399
577
|
}
|
|
578
|
+
if (gate.kind === "on" && preflight !== undefined && preflight.kind === "proceed") {
|
|
579
|
+
const published = sharedCommit(gate.ctx, preflight, "held", nowMs, durationSeconds);
|
|
580
|
+
if (!published.ok) {
|
|
581
|
+
/* The register refused after the local lease was written, so the local
|
|
582
|
+
lease is rolled back through the SAME mutation primitive. Leaving it
|
|
583
|
+
would make this environment believe it holds a fleet another
|
|
584
|
+
environment holds, which is the exact state this layer exists to
|
|
585
|
+
prevent. */
|
|
586
|
+
await applyLeaseMutation(lockPath, { kind: "present", raw: renderLease(lease), lease }, null, randomUUID());
|
|
587
|
+
const line = `shared exclusion refused acquire: ${published.reason}; the local lease was rolled back`;
|
|
588
|
+
return {
|
|
589
|
+
ok: false,
|
|
590
|
+
reason: line,
|
|
591
|
+
shared: { line, envId: gate.ctx.envId },
|
|
592
|
+
};
|
|
593
|
+
}
|
|
594
|
+
return {
|
|
595
|
+
ok: true,
|
|
596
|
+
lease,
|
|
597
|
+
shared: {
|
|
598
|
+
line: `${preflight.line}; register now ${published.sha}`,
|
|
599
|
+
envId: gate.ctx.envId,
|
|
600
|
+
},
|
|
601
|
+
};
|
|
602
|
+
}
|
|
400
603
|
return { ok: true, lease };
|
|
401
604
|
}
|
|
402
605
|
/**
|
|
@@ -408,6 +611,21 @@ export async function acquireLease(lockPath, options = {}) {
|
|
|
408
611
|
*/
|
|
409
612
|
export async function renewLease(lockPath, holderId, options = {}) {
|
|
410
613
|
const nowMs = options.nowMs ?? Date.now();
|
|
614
|
+
const gate = sharedGate(lockPath, options.fleetRoot);
|
|
615
|
+
if (gate.kind === "invalid") {
|
|
616
|
+
return invalidDeclarationOutcome(gate.reason);
|
|
617
|
+
}
|
|
618
|
+
let preflight;
|
|
619
|
+
if (gate.kind === "on") {
|
|
620
|
+
preflight = sharedPreflight(gate.ctx, "renew", false, nowMs);
|
|
621
|
+
if (preflight.kind === "refused") {
|
|
622
|
+
return {
|
|
623
|
+
ok: false,
|
|
624
|
+
reason: preflight.line,
|
|
625
|
+
shared: sharedNote(gate.ctx, preflight),
|
|
626
|
+
};
|
|
627
|
+
}
|
|
628
|
+
}
|
|
411
629
|
const observed = options.observed ?? observeLease(lockPath);
|
|
412
630
|
if (observed.kind === "absent") {
|
|
413
631
|
return { ok: false, reason: "renew refused: no lease present" };
|
|
@@ -446,6 +664,36 @@ export async function renewLease(lockPath, holderId, options = {}) {
|
|
|
446
664
|
}
|
|
447
665
|
return { ok: false, reason: `renew ${result.reason}` };
|
|
448
666
|
}
|
|
667
|
+
if (gate.kind === "on" && preflight !== undefined && preflight.kind === "proceed") {
|
|
668
|
+
/* A RENEW ADVANCES THE FENCING COUNTER, and that is the whole of what
|
|
669
|
+
makes another environment's staleness judgement safe under clock skew
|
|
670
|
+
(criterion 5). The observer sees an increment, never a timestamp. */
|
|
671
|
+
const published = sharedCommit(gate.ctx, preflight, "held", nowMs, durationSeconds);
|
|
672
|
+
if (!published.ok) {
|
|
673
|
+
/* THE LOCAL LAYER IS ROLLED BACK, exactly as acquire's failure arm
|
|
674
|
+
does it and through the SAME primitive. src/lock.ts:537 states the
|
|
675
|
+
invariant: the two layers agree, or the local one is undone by the
|
|
676
|
+
caller. Until this round `acquireLease` was the only one of the
|
|
677
|
+
three publishing callers that honoured it, so a failed renew
|
|
678
|
+
reported failure while the lease file had ALREADY been extended
|
|
679
|
+
under a new token, and a failed release reported failure while the
|
|
680
|
+
lease file had already been DELETED. */
|
|
681
|
+
const line = `shared exclusion refused renew: ${published.reason}${await restoreLocal(lockPath, { kind: "present", raw: renderLease(lease), lease }, observed)}`;
|
|
682
|
+
return {
|
|
683
|
+
ok: false,
|
|
684
|
+
reason: line,
|
|
685
|
+
shared: { line, envId: gate.ctx.envId },
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
return {
|
|
689
|
+
ok: true,
|
|
690
|
+
lease,
|
|
691
|
+
shared: {
|
|
692
|
+
line: `${preflight.line}; register now ${published.sha}`,
|
|
693
|
+
envId: gate.ctx.envId,
|
|
694
|
+
},
|
|
695
|
+
};
|
|
696
|
+
}
|
|
449
697
|
return { ok: true, lease };
|
|
450
698
|
}
|
|
451
699
|
/**
|
|
@@ -456,6 +704,27 @@ export async function renewLease(lockPath, holderId, options = {}) {
|
|
|
456
704
|
* staged before a takeover completes loses to it.
|
|
457
705
|
*/
|
|
458
706
|
export async function releaseLease(lockPath, holderId, options = {}) {
|
|
707
|
+
const nowMs = options.nowMs ?? Date.now();
|
|
708
|
+
const gate = sharedGate(lockPath, options.fleetRoot);
|
|
709
|
+
if (gate.kind === "invalid") {
|
|
710
|
+
return invalidDeclarationOutcome(gate.reason);
|
|
711
|
+
}
|
|
712
|
+
let preflight;
|
|
713
|
+
if (gate.kind === "on") {
|
|
714
|
+
/* CRITERION 8. A release by a non-holder is refused HERE, before any
|
|
715
|
+
write, so the register sha is byte-identical before and after. The
|
|
716
|
+
refusal is the register's own comparison, not a local one: a clone
|
|
717
|
+
whose local lease says it holds the fleet still loses to a register
|
|
718
|
+
that names another environment. */
|
|
719
|
+
preflight = sharedPreflight(gate.ctx, "release", false, nowMs);
|
|
720
|
+
if (preflight.kind === "refused") {
|
|
721
|
+
return {
|
|
722
|
+
ok: false,
|
|
723
|
+
reason: preflight.line,
|
|
724
|
+
shared: sharedNote(gate.ctx, preflight),
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
}
|
|
459
728
|
const observed = options.observed ?? observeLease(lockPath);
|
|
460
729
|
if (observed.kind === "absent") {
|
|
461
730
|
return { ok: false, reason: "release refused: no lease present" };
|
|
@@ -479,6 +748,29 @@ export async function releaseLease(lockPath, holderId, options = {}) {
|
|
|
479
748
|
}
|
|
480
749
|
return { ok: false, reason: `release ${result.reason}` };
|
|
481
750
|
}
|
|
751
|
+
if (gate.kind === "on" && preflight !== undefined && preflight.kind === "proceed") {
|
|
752
|
+
const published = sharedCommit(gate.ctx, preflight, "free", nowMs, observed.lease.durationSeconds);
|
|
753
|
+
if (!published.ok) {
|
|
754
|
+
/* See renewLease above: the local lease was REMOVED before this
|
|
755
|
+
publish was attempted, and the holdership guard keys on that file's
|
|
756
|
+
PRESENCE, so leaving it removed reopens the dual-writer window
|
|
757
|
+
M1-P4 criterion 12 closed. */
|
|
758
|
+
const line = `shared exclusion could not publish the release: ${published.reason}${await restoreLocal(lockPath, { kind: "absent" }, observed)}`;
|
|
759
|
+
return {
|
|
760
|
+
ok: false,
|
|
761
|
+
reason: line,
|
|
762
|
+
shared: { line, envId: gate.ctx.envId },
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
return {
|
|
766
|
+
ok: true,
|
|
767
|
+
lease: null,
|
|
768
|
+
shared: {
|
|
769
|
+
line: `${preflight.line}; register now ${published.sha}`,
|
|
770
|
+
envId: gate.ctx.envId,
|
|
771
|
+
},
|
|
772
|
+
};
|
|
773
|
+
}
|
|
482
774
|
return { ok: true, lease: null };
|
|
483
775
|
}
|
|
484
776
|
/** Report the lock state; reading only, never mutating (always safe). */
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import type { Fleet } from "./fleet.ts";
|
|
2
|
+
/**
|
|
3
|
+
* THE MODEL-RESOLUTION RECORD, KERNEL SIDE (kernel plan M4, M4-P7;
|
|
4
|
+
* delivery/plan/kernel-plan-m4.md:1018, discharging M4-D-06's four parts at
|
|
5
|
+
* delivery/plan/m4-intake.md:732).
|
|
6
|
+
*
|
|
7
|
+
* WHAT THIS MODULE IS AND, MORE IMPORTANTLY, WHAT IT IS NOT. It READS a
|
|
8
|
+
* record a harness adapter wrote. It holds NO vocabulary: no tier-to-model
|
|
9
|
+
* table, no model-to-family table, and no vendor name of any kind. The whole
|
|
10
|
+
* vocabulary lives in the plugin (plugin/src/vocabulary.ts) and this module
|
|
11
|
+
* dereferences a vocabulary's IDENTITY and never its CONTENT. That single
|
|
12
|
+
* property is what makes a second harness plugin possible at all: a mapping
|
|
13
|
+
* here would close off every harness that is not the one it names
|
|
14
|
+
* (delivery/plan/m4-intake.md:377), and `test/schemas.test.ts` already asserts
|
|
15
|
+
* the absence over the whole shipped surface rather than trusting this
|
|
16
|
+
* paragraph.
|
|
17
|
+
*
|
|
18
|
+
* THE ORDER OF THE CHECKS IS PART OF THE CONTRACT, and it is the release
|
|
19
|
+
* record's order (src/gates/release.ts): the SUBJECT ECHO is compared field by
|
|
20
|
+
* field BEFORE anything reads the resolved identity. A record about another
|
|
21
|
+
* task is not a record with a wrong answer, it is a record about something
|
|
22
|
+
* else, and reading its answer first is how a misattributed family token
|
|
23
|
+
* reaches a decorrelation check as though it belonged there.
|
|
24
|
+
*
|
|
25
|
+
* WHY `observed` IS NOT `attested`, stated here because the vocabulary is the
|
|
26
|
+
* thing most likely to be widened later by someone who did not read M4-P1.
|
|
27
|
+
* The probe measured that the observation channel is harness-written, that it
|
|
28
|
+
* can be EMPTY at hook time under load, and that a process at the agent's own
|
|
29
|
+
* uid can rewrite it in place so the result is byte-shaped exactly like the
|
|
30
|
+
* truth (delivery/verification/m4-prototype-probes.md:410). So `observed`
|
|
31
|
+
* ranks above `self-reported`, which costs nothing to forge, and below any
|
|
32
|
+
* notion of attestation, which would need a signer this environment has not
|
|
33
|
+
* been shown to have.
|
|
34
|
+
*/
|
|
35
|
+
/** The record's file name inside the task directory. */
|
|
36
|
+
export declare const MODEL_RESOLUTION_RECORD_NAME = "model-resolution.json";
|
|
37
|
+
/** The contract versions this kernel accepts. */
|
|
38
|
+
export declare const MODEL_RESOLUTION_CONTRACT_VERSIONS: readonly string[];
|
|
39
|
+
/**
|
|
40
|
+
* Where the record lives: `tasks/<id>/model-resolution.json`, NEVER inside the
|
|
41
|
+
* worktree (M4-P7 criterion 5, src/task.ts:31 and FM-059). The task directory
|
|
42
|
+
* sits outside the worktree precisely so the pool's dirty check needs no
|
|
43
|
+
* exemption list for the kernel's own injected files, and a record written
|
|
44
|
+
* into the worktree would be indistinguishable from the agent's own work.
|
|
45
|
+
*/
|
|
46
|
+
export declare function modelResolutionPath(fleet: Fleet, taskId: string): string;
|
|
47
|
+
/** The launch request fields the record's subject echo is compared against. */
|
|
48
|
+
export interface ModelResolutionSubject {
|
|
49
|
+
taskId: string;
|
|
50
|
+
role: string;
|
|
51
|
+
requestedTier: string;
|
|
52
|
+
}
|
|
53
|
+
export interface VocabularyIdentity {
|
|
54
|
+
id: string;
|
|
55
|
+
version: number;
|
|
56
|
+
}
|
|
57
|
+
/** What one read of the record produced. */
|
|
58
|
+
export type ModelResolutionRead = {
|
|
59
|
+
kind: "read";
|
|
60
|
+
record: Record<string, unknown>;
|
|
61
|
+
} | {
|
|
62
|
+
kind: "error";
|
|
63
|
+
reason: string;
|
|
64
|
+
};
|
|
65
|
+
/** What one acceptance decision produced. */
|
|
66
|
+
export type ModelResolutionAcceptance = {
|
|
67
|
+
kind: "accepted";
|
|
68
|
+
family: string | undefined;
|
|
69
|
+
provenance: string;
|
|
70
|
+
} | {
|
|
71
|
+
kind: "refused";
|
|
72
|
+
reason: string;
|
|
73
|
+
};
|
|
74
|
+
/** What one cross-record family comparison produced. */
|
|
75
|
+
export type FamilyComparison = {
|
|
76
|
+
kind: "compared";
|
|
77
|
+
differ: boolean;
|
|
78
|
+
families: [string, string];
|
|
79
|
+
} | {
|
|
80
|
+
kind: "refused";
|
|
81
|
+
reason: string;
|
|
82
|
+
};
|
|
83
|
+
/**
|
|
84
|
+
* Read the record for a task.
|
|
85
|
+
*
|
|
86
|
+
* AN ABSENT RECORD IS AN ERROR AND IS NEVER GREEN AND NEVER NOT-APPLICABLE
|
|
87
|
+
* (M4-P7 criterion 5). This is not a new rule and deliberately not a second
|
|
88
|
+
* implementation of one: src/gates/release.ts:609 already produces the
|
|
89
|
+
* sentence for the same hazard on the release seam, and the wording is kept
|
|
90
|
+
* in the same shape so that a reader who has met one meets the other.
|
|
91
|
+
* `test/model-resolution.test.ts` drives the real release gate to capture that
|
|
92
|
+
* sentence and derives its assertion from the capture, rather than pinning a
|
|
93
|
+
* hand-written copy of it.
|
|
94
|
+
*
|
|
95
|
+
* THE READ GOES THROUGH `readRegularFileIfPresent`, which is M2-C-6 and the
|
|
96
|
+
* mechanism-index row `reading-a-path-whose-type-is-not-established`: a named
|
|
97
|
+
* pipe at this path is a REPORTED refusal, never a blocked open.
|
|
98
|
+
*/
|
|
99
|
+
export declare function readModelResolutionRecord(path: string, writerExitCode: number): ModelResolutionRead;
|
|
100
|
+
/**
|
|
101
|
+
* The vocabulary IDENTITY of a record, and nothing else about the vocabulary.
|
|
102
|
+
*
|
|
103
|
+
* TWO FIELDS ARE READ AND THEY ARE NAMED HERE RATHER THAN SPREAD, which is
|
|
104
|
+
* the whole of criterion 4's mechanical half. A spread, an `Object.keys`, a
|
|
105
|
+
* `JSON.stringify` or a `structuredClone` of the vocabulary object would
|
|
106
|
+
* ENUMERATE it, and enumeration is dereferencing the content whatever the
|
|
107
|
+
* intent. `test/model-resolution.test.ts` hands this function a Proxy that
|
|
108
|
+
* records every property read and asserts the recorded set against these two
|
|
109
|
+
* names, so a later edit that reaches for a third reddens.
|
|
110
|
+
*/
|
|
111
|
+
export declare function vocabularyIdentity(record: Record<string, unknown>): VocabularyIdentity | undefined;
|
|
112
|
+
/**
|
|
113
|
+
* Accept or refuse one record against the launch request the kernel holds.
|
|
114
|
+
*
|
|
115
|
+
* FOUR REFUSALS, IN THIS ORDER, and the order is the point.
|
|
116
|
+
*
|
|
117
|
+
* 1. THE CONTRACT VERSION, because a record written to a contract this kernel
|
|
118
|
+
* does not know is not a record this kernel can read fields out of.
|
|
119
|
+
* 2. THE SUBJECT ECHO, FIELD BY FIELD, BEFORE THE OUTCOME IS READ. This is
|
|
120
|
+
* src/gates/schemas/release-record.schema.json:26's misattribution guard.
|
|
121
|
+
* 3. THE VOCABULARY IDENTITY, because a family token with no vocabulary is a
|
|
122
|
+
* token nobody can say the meaning of.
|
|
123
|
+
* 4. THE PROVENANCE, which is M4-P7 criterion 6 member TWO. Member one (a
|
|
124
|
+
* record claiming `observed` with no observation at all) is the schema's
|
|
125
|
+
* and is a missing field. THIS is the present-but-inconsistent half, and a
|
|
126
|
+
* schema-only guard passes it green: an observation about another task, or
|
|
127
|
+
* naming a model other than the one the record claims, is a self-report
|
|
128
|
+
* with a decoration on it.
|
|
129
|
+
*/
|
|
130
|
+
export declare function acceptModelResolution(record: Record<string, unknown>, subject: ModelResolutionSubject): ModelResolutionAcceptance;
|
|
131
|
+
/**
|
|
132
|
+
* Compare two accepted records' family tokens (M4-P7 criterion 3).
|
|
133
|
+
*
|
|
134
|
+
* TWO VOCABULARIES ARE NOT COMPARABLE AND THE REFUSAL NAMES BOTH IDS. A family
|
|
135
|
+
* token means whatever the vocabulary that minted it says it means, so
|
|
136
|
+
* comparing a token from one against a token from another is comparing two
|
|
137
|
+
* strings and calling the result a fact about models. That comparison is what
|
|
138
|
+
* `check-dual-review`'s decorrelation assertion rests on, and DR-0038 exists
|
|
139
|
+
* for exactly the environment where the two reviews come from different
|
|
140
|
+
* places, so a silent cross-vocabulary compare would make the assertion
|
|
141
|
+
* meaningless precisely where it is load-bearing.
|
|
142
|
+
*
|
|
143
|
+
* ONLY `id` IS READ HERE. The version is part of the identity a reader may
|
|
144
|
+
* print and is deliberately NOT part of the comparison: a vocabulary that
|
|
145
|
+
* added a family in v2 did not change what its v1 tokens mean, and refusing
|
|
146
|
+
* on version would turn a routine vocabulary revision into an outage.
|
|
147
|
+
*/
|
|
148
|
+
export declare function compareResolvedFamilies(left: Record<string, unknown>, right: Record<string, unknown>): FamilyComparison;
|
|
149
|
+
/**
|
|
150
|
+
* The value closeout copies into a verdict's `produced-by` (M4-P7 criterion 8).
|
|
151
|
+
*
|
|
152
|
+
* VERBATIM, AND THE FUNCTION EXISTS SO THAT "VERBATIM" IS TESTABLE. DR-0031
|
|
153
|
+
* requires a pull request to carry all its own evidence, so the family token
|
|
154
|
+
* has to reach the verdict document rather than being left in a task
|
|
155
|
+
* directory that no reviewer of the pull request can see. A copy that
|
|
156
|
+
* normalised, lowercased or prefixed the token would satisfy every reading of
|
|
157
|
+
* that sentence and would break the one comparison the token exists for.
|
|
158
|
+
*/
|
|
159
|
+
export declare function producedByFromRecord(record: Record<string, unknown>): string | undefined;
|