@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
|
@@ -23,8 +23,8 @@ export declare const KERNEL_PACKAGE_NAME = "@tiphys/kernel";
|
|
|
23
23
|
export declare const MACHINE_IDENTITY_NAME = "Tiphys Fleet";
|
|
24
24
|
export declare const MACHINE_IDENTITY_EMAIL = "fleet@tiphys.invalid";
|
|
25
25
|
/**
|
|
26
|
-
* tiphys init <dir
|
|
27
|
-
* (kernel plan v1, M1-P2 step 2). Substrate-neutral: pure
|
|
28
|
-
* git (DR-0007).
|
|
26
|
+
* tiphys init <dir> [--shared-exclusion]: create a fleet home in an empty or
|
|
27
|
+
* absent directory (kernel plan v1, M1-P2 step 2). Substrate-neutral: pure
|
|
28
|
+
* filesystem and git (DR-0007).
|
|
29
29
|
*/
|
|
30
30
|
export declare function cmdInit(args: string[]): number;
|
|
@@ -2,7 +2,9 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import { existsSync, mkdirSync, readdirSync, statSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { EX_USAGE } from "../cli.js";
|
|
5
|
+
import { DEFAULT_SHARED_REF, DEFAULT_SHARED_REMOTE, SHARED_EXCLUSION_FIELD, } from "../exclusion.js";
|
|
5
6
|
import { FLEET_DIRS, FLEET_IGNORED } from "../fleet.js";
|
|
7
|
+
import { DURABLE_STATUS_DIR } from "../status.js";
|
|
6
8
|
import { readOwnVersion } from "../version.js";
|
|
7
9
|
/**
|
|
8
10
|
* THE PUBLISHED KERNEL NAME (DR-0008, decided 2026-08-05: public npmjs under
|
|
@@ -34,8 +36,17 @@ export const MACHINE_IDENTITY_EMAIL = "fleet@tiphys.invalid";
|
|
|
34
36
|
* the durable layout (SC-002) would not survive a clone of the fleet repo.
|
|
35
37
|
* The ignored ephemera (state/, worktrees/, projects/) get no keep file:
|
|
36
38
|
* they are recreated locally and are deliberately not repository content.
|
|
39
|
+
*
|
|
40
|
+
* `status` JOINED THIS LIST AT M4-P18 AND IT IS THE HALF OF M4-D-13 THAT IS
|
|
41
|
+
* VISIBLE IN THE LAYOUT. The status pointer used to live beside its stream
|
|
42
|
+
* under the ignored `state/` prefix, where the sentence that says where the
|
|
43
|
+
* pipeline stands could be neither committed nor pushed; the split moved it
|
|
44
|
+
* here. It is created at init rather than on first emit so that a fleet home
|
|
45
|
+
* carries the durable directory from its bootstrap commit, and so a CLONE of
|
|
46
|
+
* one carries it too: `tiphys resume` rebuilds the EPHEMERAL three and does
|
|
47
|
+
* not, and must not, fabricate durable content (src/commands/resume.ts).
|
|
37
48
|
*/
|
|
38
|
-
const DURABLE_KEEP_DIRS = ["charter", "decisions", "tasks"];
|
|
49
|
+
const DURABLE_KEEP_DIRS = ["charter", "decisions", "tasks", DURABLE_STATUS_DIR];
|
|
39
50
|
function runGit(cwd, args, extraEnv) {
|
|
40
51
|
const result = spawnSync("git", ["-C", cwd, ...args], {
|
|
41
52
|
encoding: "utf8",
|
|
@@ -44,14 +55,29 @@ function runGit(cwd, args, extraEnv) {
|
|
|
44
55
|
return { status: result.status, stderr: result.stderr ?? "" };
|
|
45
56
|
}
|
|
46
57
|
/**
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
58
|
+
* THE OPT-IN FLAG FOR THE SHARED EXCLUSION REGISTER (M4-P21 criterion 1).
|
|
59
|
+
*
|
|
60
|
+
* The layer is DECLARED, never inferred, and the declaration lives in the
|
|
61
|
+
* fleet home's own `package.json` because that file is already the fleet's
|
|
62
|
+
* owner-controlled pin file. Without this flag `init` writes exactly the
|
|
63
|
+
* package.json it wrote before, with no `tiphys` section at all, so a fleet
|
|
64
|
+
* created today behaves exactly as it did and `src/exclusion.ts` returns
|
|
65
|
+
* before spawning anything. That default is the H-D carve-out in the plan's
|
|
66
|
+
* hazard table: a fleet that cannot reach a remote is not forced to switch
|
|
67
|
+
* the layer off globally, because it was never on.
|
|
68
|
+
*/
|
|
69
|
+
const SHARED_EXCLUSION_FLAG = "--shared-exclusion";
|
|
70
|
+
/**
|
|
71
|
+
* tiphys init <dir> [--shared-exclusion]: create a fleet home in an empty or
|
|
72
|
+
* absent directory (kernel plan v1, M1-P2 step 2). Substrate-neutral: pure
|
|
73
|
+
* filesystem and git (DR-0007).
|
|
50
74
|
*/
|
|
51
75
|
export function cmdInit(args) {
|
|
52
|
-
const
|
|
76
|
+
const rest = args.filter((arg) => arg !== SHARED_EXCLUSION_FLAG);
|
|
77
|
+
const sharedExclusion = args.length !== rest.length;
|
|
78
|
+
const [dir, ...extra] = rest;
|
|
53
79
|
if (dir === undefined || extra.length > 0) {
|
|
54
|
-
process.stderr.write(
|
|
80
|
+
process.stderr.write(`usage: tiphys init <dir> [${SHARED_EXCLUSION_FLAG}]\n`);
|
|
55
81
|
return EX_USAGE;
|
|
56
82
|
}
|
|
57
83
|
const root = resolve(dir);
|
|
@@ -65,7 +91,14 @@ export function cmdInit(args) {
|
|
|
65
91
|
const fleetMarkers = new Set([...FLEET_DIRS, "backlog.md", ".git"]);
|
|
66
92
|
const looksInitialized = entries.some((entry) => fleetMarkers.has(entry));
|
|
67
93
|
if (looksInitialized) {
|
|
68
|
-
|
|
94
|
+
/* THE REMEDY TOKEN IS THE POINT OF THIS LINE (M4-P16 criterion 6).
|
|
95
|
+
The exit code was already 1 and already correct, so a reader who
|
|
96
|
+
got here learned only that init refused. A CLONE of a fleet home
|
|
97
|
+
lands here too, because the marker set contains `.git`, and that
|
|
98
|
+
reader's actual next step is `tiphys resume`, which rebuilds the
|
|
99
|
+
three gitignored directories the clone does not carry. Naming it
|
|
100
|
+
here is the difference between a refusal and an instruction. */
|
|
101
|
+
process.stderr.write(`tiphys init: ${root} is already initialized; run tiphys resume to rebuild the ephemeral directories a clone does not carry\n`);
|
|
69
102
|
}
|
|
70
103
|
else {
|
|
71
104
|
process.stderr.write(`tiphys init: ${root} is not empty and not a fleet home, refusing\n`);
|
|
@@ -76,7 +109,7 @@ export function cmdInit(args) {
|
|
|
76
109
|
else {
|
|
77
110
|
mkdirSync(root, { recursive: true });
|
|
78
111
|
}
|
|
79
|
-
for (const name of FLEET_DIRS) {
|
|
112
|
+
for (const name of [...FLEET_DIRS, DURABLE_STATUS_DIR]) {
|
|
80
113
|
mkdirSync(join(root, name), { recursive: true });
|
|
81
114
|
}
|
|
82
115
|
for (const name of DURABLE_KEEP_DIRS) {
|
|
@@ -101,6 +134,19 @@ export function cmdInit(args) {
|
|
|
101
134
|
[KERNEL_PACKAGE_NAME]: readOwnVersion(),
|
|
102
135
|
},
|
|
103
136
|
};
|
|
137
|
+
if (sharedExclusion) {
|
|
138
|
+
/* The declared form is written out in full rather than as `true` so the
|
|
139
|
+
owner can see, and edit, the remote and the ref this fleet's shared
|
|
140
|
+
lease lives on. The ref is a BRANCH because only refs/heads/* is
|
|
141
|
+
pushable against the remote this kernel is built for (CLAUDE.md
|
|
142
|
+
standing warning 14, re-measured for M4-P21). */
|
|
143
|
+
fleetPackageJson["tiphys"] = {
|
|
144
|
+
sharedExclusion: {
|
|
145
|
+
remote: DEFAULT_SHARED_REMOTE,
|
|
146
|
+
ref: DEFAULT_SHARED_REF,
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
104
150
|
writeFileSync(join(root, "package.json"), `${JSON.stringify(fleetPackageJson, null, 2)}\n`);
|
|
105
151
|
writeFileSync(join(root, ".gitignore"), `${FLEET_IGNORED.join("\n")}\n`);
|
|
106
152
|
const steps = [
|
|
@@ -124,5 +170,8 @@ export function cmdInit(args) {
|
|
|
124
170
|
}
|
|
125
171
|
}
|
|
126
172
|
process.stdout.write(`initialized fleet home at ${root}\n`);
|
|
173
|
+
if (sharedExclusion) {
|
|
174
|
+
process.stdout.write(`declared ${SHARED_EXCLUSION_FIELD} on ${DEFAULT_SHARED_REMOTE} at ${DEFAULT_SHARED_REF}\n`);
|
|
175
|
+
}
|
|
127
176
|
return 0;
|
|
128
177
|
}
|
|
@@ -1 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE DECISION-CLOCK SEAM (M4-P21 criteria 4 and 5). When
|
|
3
|
+
* TIPHYS_LOCK_TEST_NOW_MS carries a finite number of milliseconds, every
|
|
4
|
+
* mutating subcommand decides against that instant instead of `Date.now()`,
|
|
5
|
+
* by feeding the EXISTING `nowMs` option the lease library already takes
|
|
6
|
+
* (src/lock.ts:568). It exists because the two clock-skew witnesses must
|
|
7
|
+
* move ONE environment's clock ten minutes without touching a system clock,
|
|
8
|
+
* which the plan requires by name, and because criterion 6 is about what the
|
|
9
|
+
* COMMAND prints, so the skew has to reach the command rather than only the
|
|
10
|
+
* library. Inert unless the variable is set, exactly like the hold point
|
|
11
|
+
* above, and a value that does not parse is a loud refusal rather than a
|
|
12
|
+
* silent fall back to the real clock: a seam that quietly ignores its input
|
|
13
|
+
* would make a skew witness green while measuring no skew at all.
|
|
14
|
+
*
|
|
15
|
+
* AND IT IS GATED, BECAUSE IT SHIPS. `judgeByCounter` (src/exclusion.ts:553)
|
|
16
|
+
* computes `unchangedForMs = nowMs - previous.firstSeenMs`, so the module's
|
|
17
|
+
* promised "two readings of ONE clock" become one reading of a clock the
|
|
18
|
+
* CALLER supplies. Measured in the published CLI before this gate existed:
|
|
19
|
+
* the honest challenger was refused with "fencing counter 1 has stood still
|
|
20
|
+
* for 0ms of the 900000ms this environment requires", and the SAME command
|
|
21
|
+
* with this one variable set an hour ahead took a live lease over instantly
|
|
22
|
+
* and still printed `signal=counter`. A captured witness of that run was
|
|
23
|
+
* indistinguishable from an honest one.
|
|
24
|
+
*
|
|
25
|
+
* TWO CHANGES, and they are different in kind. The ALLOWANCE
|
|
26
|
+
* (TIPHYS_ALLOW_TEST_CLOCK=1) makes reaching the seam a declared act rather
|
|
27
|
+
* than a side effect of one environment variable. The LABEL makes the run
|
|
28
|
+
* SAY SO: `injectedClockSuffix` below is appended to every verdict line the
|
|
29
|
+
* shared layer prints, so the output of a measurement can never be quoted as
|
|
30
|
+
* the output of a real judgement. An allowance alone would leave the second
|
|
31
|
+
* hole open, which is why both are here.
|
|
32
|
+
*/
|
|
33
|
+
export declare const TEST_CLOCK_ALLOWANCE = "TIPHYS_ALLOW_TEST_CLOCK";
|
|
1
34
|
export declare function cmdLock(args: string[]): Promise<number>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
3
3
|
import { EX_USAGE } from "../cli.js";
|
|
4
|
-
import { loadFleet } from "../fleet.js";
|
|
4
|
+
import { loadFleet, refuseOpenPathForWrite } from "../fleet.js";
|
|
5
5
|
import { acquireLease, leaseStatus, observeLease, releaseLease, renewLease, } from "../lock.js";
|
|
6
6
|
/**
|
|
7
7
|
* tiphys lock <acquire [--take-over] [--duration <seconds>] | renew
|
|
@@ -49,6 +49,16 @@ async function maybeHoldForTest(lockPath) {
|
|
|
49
49
|
}
|
|
50
50
|
const observed = observeLease(lockPath);
|
|
51
51
|
const nowMs = Date.now();
|
|
52
|
+
/* THE MARKER PATHS ARE DERIVED FROM A CALLER-SUPPLIED PATH, so their entry
|
|
53
|
+
type is established before either is opened for writing. A named pipe at
|
|
54
|
+
`<barrier>.observed` blocked this seam forever with zero output, which is
|
|
55
|
+
the same class this round closed in the lease and brief readers; the seam
|
|
56
|
+
is inert unless the variable is set, which changes how it is REACHED and
|
|
57
|
+
not what it does once reached. */
|
|
58
|
+
const observedRefusal = refuseOpenPathForWrite(`${barrier}.observed`);
|
|
59
|
+
if (observedRefusal !== undefined) {
|
|
60
|
+
throw new Error(`lock test hold point: ${observedRefusal}`);
|
|
61
|
+
}
|
|
52
62
|
writeFileSync(`${barrier}.observed`, "");
|
|
53
63
|
const startNs = process.hrtime.bigint();
|
|
54
64
|
const limitNs = BigInt(HOLD_WAIT_LIMIT_MS) * 1000000n;
|
|
@@ -72,9 +82,102 @@ async function maybeHoldForTest(lockPath) {
|
|
|
72
82
|
}
|
|
73
83
|
// Record that the hold really held, and why the wait ended, so the
|
|
74
84
|
// witness can assert the interleave rather than assume it.
|
|
85
|
+
/* Re-established immediately before this second write rather than once
|
|
86
|
+
per call: the two writes are separated by a wait of up to thirty seconds
|
|
87
|
+
and the path is derived from one the CALLER supplied. */
|
|
88
|
+
const releasedRefusal = refuseOpenPathForWrite(`${barrier}.released`);
|
|
89
|
+
if (releasedRefusal !== undefined) {
|
|
90
|
+
throw new Error(`lock test hold point: ${releasedRefusal}`);
|
|
91
|
+
}
|
|
75
92
|
writeFileSync(`${barrier}.released`, `held after ${String(waitedMs)}ms (monotonic), barrier observed\n`);
|
|
76
93
|
return { observed, nowMs };
|
|
77
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* THE DECISION-CLOCK SEAM (M4-P21 criteria 4 and 5). When
|
|
97
|
+
* TIPHYS_LOCK_TEST_NOW_MS carries a finite number of milliseconds, every
|
|
98
|
+
* mutating subcommand decides against that instant instead of `Date.now()`,
|
|
99
|
+
* by feeding the EXISTING `nowMs` option the lease library already takes
|
|
100
|
+
* (src/lock.ts:568). It exists because the two clock-skew witnesses must
|
|
101
|
+
* move ONE environment's clock ten minutes without touching a system clock,
|
|
102
|
+
* which the plan requires by name, and because criterion 6 is about what the
|
|
103
|
+
* COMMAND prints, so the skew has to reach the command rather than only the
|
|
104
|
+
* library. Inert unless the variable is set, exactly like the hold point
|
|
105
|
+
* above, and a value that does not parse is a loud refusal rather than a
|
|
106
|
+
* silent fall back to the real clock: a seam that quietly ignores its input
|
|
107
|
+
* would make a skew witness green while measuring no skew at all.
|
|
108
|
+
*
|
|
109
|
+
* AND IT IS GATED, BECAUSE IT SHIPS. `judgeByCounter` (src/exclusion.ts:553)
|
|
110
|
+
* computes `unchangedForMs = nowMs - previous.firstSeenMs`, so the module's
|
|
111
|
+
* promised "two readings of ONE clock" become one reading of a clock the
|
|
112
|
+
* CALLER supplies. Measured in the published CLI before this gate existed:
|
|
113
|
+
* the honest challenger was refused with "fencing counter 1 has stood still
|
|
114
|
+
* for 0ms of the 900000ms this environment requires", and the SAME command
|
|
115
|
+
* with this one variable set an hour ahead took a live lease over instantly
|
|
116
|
+
* and still printed `signal=counter`. A captured witness of that run was
|
|
117
|
+
* indistinguishable from an honest one.
|
|
118
|
+
*
|
|
119
|
+
* TWO CHANGES, and they are different in kind. The ALLOWANCE
|
|
120
|
+
* (TIPHYS_ALLOW_TEST_CLOCK=1) makes reaching the seam a declared act rather
|
|
121
|
+
* than a side effect of one environment variable. The LABEL makes the run
|
|
122
|
+
* SAY SO: `injectedClockSuffix` below is appended to every verdict line the
|
|
123
|
+
* shared layer prints, so the output of a measurement can never be quoted as
|
|
124
|
+
* the output of a real judgement. An allowance alone would leave the second
|
|
125
|
+
* hole open, which is why both are here.
|
|
126
|
+
*/
|
|
127
|
+
export const TEST_CLOCK_ALLOWANCE = "TIPHYS_ALLOW_TEST_CLOCK";
|
|
128
|
+
function testClockMs() {
|
|
129
|
+
const raw = process.env.TIPHYS_LOCK_TEST_NOW_MS;
|
|
130
|
+
if (raw === undefined || raw === "") {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
if (process.env[TEST_CLOCK_ALLOWANCE] !== "1") {
|
|
134
|
+
throw new Error(`lock test clock: TIPHYS_LOCK_TEST_NOW_MS is set and ` +
|
|
135
|
+
`${TEST_CLOCK_ALLOWANCE}=1 is not, so this run would have decided ` +
|
|
136
|
+
`cross-environment exclusion against a clock its caller supplied ` +
|
|
137
|
+
`while printing the same verdict an honest run prints; set ` +
|
|
138
|
+
`${TEST_CLOCK_ALLOWANCE}=1 to declare that this run is a measurement, ` +
|
|
139
|
+
`or unset TIPHYS_LOCK_TEST_NOW_MS`);
|
|
140
|
+
}
|
|
141
|
+
const parsed = Number(raw);
|
|
142
|
+
if (!Number.isFinite(parsed)) {
|
|
143
|
+
throw new Error(`lock test clock: TIPHYS_LOCK_TEST_NOW_MS=${raw} does not parse as a ` +
|
|
144
|
+
`number of milliseconds; this run would have measured the real clock ` +
|
|
145
|
+
`and is not evidence`);
|
|
146
|
+
}
|
|
147
|
+
return parsed;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* What a verdict line gains when the decision clock was injected. Empty on
|
|
151
|
+
* every honest run, so no shipped output changes for anyone who does not set
|
|
152
|
+
* the variable.
|
|
153
|
+
*/
|
|
154
|
+
function injectedClockSuffix() {
|
|
155
|
+
return process.env.TIPHYS_LOCK_TEST_NOW_MS === undefined ||
|
|
156
|
+
process.env.TIPHYS_LOCK_TEST_NOW_MS === ""
|
|
157
|
+
? ""
|
|
158
|
+
: "(injected-clock)";
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* Emit the shared exclusion layer's verdict line. One line, and it always
|
|
162
|
+
* names the SIGNAL that reached the verdict (criterion 6): `signal=counter`
|
|
163
|
+
* where the register was reachable and its fencing counter decided, or
|
|
164
|
+
* `signal=clock` where it was not and the command therefore refused rather
|
|
165
|
+
* than deciding cross-environment exclusion on a local clock.
|
|
166
|
+
*/
|
|
167
|
+
function reportShared(outcome) {
|
|
168
|
+
if (outcome.shared !== undefined) {
|
|
169
|
+
process.stdout.write(`${labelClock(outcome.shared.line)}\n`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Mark every `signal=<basis>` token in a verdict line when this run's
|
|
174
|
+
* decision clock was injected. The token is what criterion 6 asks the
|
|
175
|
+
* command to print, so it is the token that has to carry the caveat.
|
|
176
|
+
*/
|
|
177
|
+
function labelClock(line) {
|
|
178
|
+
const suffix = injectedClockSuffix();
|
|
179
|
+
return suffix === "" ? line : line.replace(/signal=(counter|clock)/g, `signal=$1${suffix}`);
|
|
180
|
+
}
|
|
78
181
|
function usageError(message) {
|
|
79
182
|
if (message !== undefined) {
|
|
80
183
|
process.stderr.write(`tiphys lock: ${message}\n`);
|
|
@@ -99,7 +202,7 @@ function failure(outcome) {
|
|
|
99
202
|
const remedy = outcome.claimTimeout === true
|
|
100
203
|
? "; a crashed mutation can leave this file behind, but deleting it while a mutation is genuinely in flight can produce two lock holders, so confirm no tiphys process is running against this fleet before removing it"
|
|
101
204
|
: "";
|
|
102
|
-
process.stderr.write(`tiphys lock: ${outcome.reason}${remedy}\n`);
|
|
205
|
+
process.stderr.write(`tiphys lock: ${labelClock(outcome.reason)}${remedy}\n`);
|
|
103
206
|
return 1;
|
|
104
207
|
}
|
|
105
208
|
function parseFlags(rest) {
|
|
@@ -158,7 +261,7 @@ export async function cmdLock(args) {
|
|
|
158
261
|
takeover: flags.takeover,
|
|
159
262
|
durationSeconds: flags.durationSeconds,
|
|
160
263
|
observed: held?.observed,
|
|
161
|
-
nowMs: held?.nowMs,
|
|
264
|
+
nowMs: testClockMs() ?? held?.nowMs,
|
|
162
265
|
});
|
|
163
266
|
if (!outcome.ok) {
|
|
164
267
|
return failure(outcome);
|
|
@@ -168,6 +271,7 @@ export async function cmdLock(args) {
|
|
|
168
271
|
throw new Error("unreachable: acquire produced no lease");
|
|
169
272
|
}
|
|
170
273
|
process.stdout.write(`acquired ${lease.holderId} expires ${lease.expiresAt}\n`);
|
|
274
|
+
reportShared(outcome);
|
|
171
275
|
return 0;
|
|
172
276
|
}
|
|
173
277
|
case "renew": {
|
|
@@ -178,7 +282,7 @@ export async function cmdLock(args) {
|
|
|
178
282
|
const outcome = await renewLease(lockPath, flags.holder, {
|
|
179
283
|
durationSeconds: flags.durationSeconds,
|
|
180
284
|
observed: held?.observed,
|
|
181
|
-
nowMs: held?.nowMs,
|
|
285
|
+
nowMs: testClockMs() ?? held?.nowMs,
|
|
182
286
|
});
|
|
183
287
|
if (!outcome.ok) {
|
|
184
288
|
return failure(outcome);
|
|
@@ -188,6 +292,7 @@ export async function cmdLock(args) {
|
|
|
188
292
|
throw new Error("unreachable: renew produced no lease");
|
|
189
293
|
}
|
|
190
294
|
process.stdout.write(`renewed ${lease.holderId} expires ${lease.expiresAt}\n`);
|
|
295
|
+
reportShared(outcome);
|
|
191
296
|
return 0;
|
|
192
297
|
}
|
|
193
298
|
case "release": {
|
|
@@ -195,13 +300,19 @@ export async function cmdLock(args) {
|
|
|
195
300
|
return usageError("release requires --holder <id> and no other flags");
|
|
196
301
|
}
|
|
197
302
|
const held = await maybeHoldForTest(lockPath);
|
|
198
|
-
const
|
|
303
|
+
const releaseOptions = {
|
|
199
304
|
observed: held?.observed,
|
|
200
|
-
}
|
|
305
|
+
};
|
|
306
|
+
const releaseClock = testClockMs();
|
|
307
|
+
if (releaseClock !== undefined) {
|
|
308
|
+
releaseOptions.nowMs = releaseClock;
|
|
309
|
+
}
|
|
310
|
+
const outcome = await releaseLease(lockPath, flags.holder, releaseOptions);
|
|
201
311
|
if (!outcome.ok) {
|
|
202
312
|
return failure(outcome);
|
|
203
313
|
}
|
|
204
314
|
process.stdout.write(`released ${flags.holder}\n`);
|
|
315
|
+
reportShared(outcome);
|
|
205
316
|
return 0;
|
|
206
317
|
}
|
|
207
318
|
case "status": {
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { Fleet } from "../fleet.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Work remains. DISTINCT FROM 1, which is the command failing, and from 0,
|
|
4
|
+
* which is every in-flight category empty (criterion 1).
|
|
5
|
+
*/
|
|
6
|
+
export declare const EXIT_WORK_REMAINS = 3;
|
|
7
|
+
/**
|
|
8
|
+
* WHAT THIS COMMAND CANNOT SEE, as a NAMED LIST (criterion 4).
|
|
9
|
+
*
|
|
10
|
+
* Printed unconditionally. It is not derived from a probe and it does not
|
|
11
|
+
* shorten when something is unreachable, because a list that shrinks when the
|
|
12
|
+
* network is gone tells the reader that fewer things are unknown at exactly
|
|
13
|
+
* the moment more of them are.
|
|
14
|
+
*/
|
|
15
|
+
export declare const CANNOT_SEE: readonly string[];
|
|
16
|
+
/** The heading the cannot-see block is printed under. */
|
|
17
|
+
export declare const CANNOT_SEE_HEADING = "cannot see (this command has no network; an absence here is not evidence of absence):";
|
|
18
|
+
/** How a branch turned out to have been delivered, or why that is not settled. */
|
|
19
|
+
export type Delivery = {
|
|
20
|
+
kind: "delivered";
|
|
21
|
+
how: "ancestor" | "squash" | "patch-equivalent";
|
|
22
|
+
} | {
|
|
23
|
+
kind: "open";
|
|
24
|
+
} | {
|
|
25
|
+
kind: "unknown";
|
|
26
|
+
detail: string;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* THE DELIVERED-ELSEWHERE PREDICATE (criterion 3).
|
|
30
|
+
*
|
|
31
|
+
* `git branch --merged` reports a squash-merged branch as UNMERGED, because
|
|
32
|
+
* the squash commit's sha is not the branch tip and the branch tip is not an
|
|
33
|
+
* ancestor of the base. This process squash-merges every phase, so under the
|
|
34
|
+
* naive implementation EVERY delivered branch reads as open forever and the
|
|
35
|
+
* stop condition never goes green.
|
|
36
|
+
*
|
|
37
|
+
* THE CITATION THAT STOOD HERE UNTIL ROUND 1 WAS WRONG, AND IT WAS WRONG IN
|
|
38
|
+
* THE SILENT DIRECTION. It read `delivery/STATE.md:54`, which resolves, which
|
|
39
|
+
* is why no gate reddened; the line it resolves to is about two merge blockers
|
|
40
|
+
* and says nothing about `git branch --merged`. Measured:
|
|
41
|
+
* `grep -n 'branch --merged' delivery/STATE.md` returns nothing, at this head
|
|
42
|
+
* and at the merge base, so the sentence is absent rather than moved. The
|
|
43
|
+
* claim itself is measured in this repository and the measurement is the
|
|
44
|
+
* citation now: witness/captures/next-delivered-elsewhere-git.txt records
|
|
45
|
+
* `git branch --merged main` listing neither the squash-merged branch nor the
|
|
46
|
+
* one delivered through another pull request. The same wrong citation is in
|
|
47
|
+
* the plan at delivery/plan/kernel-plan-m4.md:3239 and in this phase's work
|
|
48
|
+
* history; the work history is corrected, and the plan is an owner-approved
|
|
49
|
+
* document this phase does not edit, so it is raised rather than changed.
|
|
50
|
+
*
|
|
51
|
+
* THREE ARMS, AND THE THIRD IS WHY THIS IS NOT JUST A CALL TO `landedness`.
|
|
52
|
+
*
|
|
53
|
+
* 1. ANCESTOR. An ordinary merge or a fast-forward. `landedness` arm one.
|
|
54
|
+
* 2. CONTENT. Merging the branch into the base changes no tree, so the base
|
|
55
|
+
* already carries everything the branch did however it got there. This is
|
|
56
|
+
* MEMBER A, the squash merge, and it is `landedness` arm two.
|
|
57
|
+
* 3. PATCH EQUIVALENCE. Every commit the branch carries has an equivalent
|
|
58
|
+
* patch already upstream, found by patch id rather than by sha. This is
|
|
59
|
+
* MEMBER B: commits that landed inside ANOTHER branch's pull request, so
|
|
60
|
+
* they appear on the base interleaved with commits the branch never had.
|
|
61
|
+
*
|
|
62
|
+
* ARM 3 IS NOT REDUNDANT WITH ARM 2, and the case that separates them is the
|
|
63
|
+
* ordinary one. Once the base moves on and edits the same region again, a
|
|
64
|
+
* three-way merge of the delivered branch CONFLICTS, so arm 2 answers `open`
|
|
65
|
+
* for a branch whose every commit is demonstrably already upstream. Arm 3
|
|
66
|
+
* answers it by patch id, which survives the later edit.
|
|
67
|
+
*
|
|
68
|
+
* ARM 2 IS NOT REDUNDANT WITH ARM 3 EITHER. A squash collapses N commits into
|
|
69
|
+
* one, whose patch id is the id of the COMBINED diff and therefore equals no
|
|
70
|
+
* individual commit's. For any branch of more than one commit, arm 3 sees only
|
|
71
|
+
* unmatched commits and answers `open`.
|
|
72
|
+
*
|
|
73
|
+
* REUSE, NOT A SECOND IMPLEMENTATION (T-005). Arms 1 and 2 are
|
|
74
|
+
* `landedness` at src/teardown.ts:123, the predicate `tiphys teardown` already
|
|
75
|
+
* refuses on. A second copy here would be a second thing to keep true.
|
|
76
|
+
*
|
|
77
|
+
* INCONCLUSIVE IS NOT `open` AND IT IS NOT `delivered`. A git failure returns
|
|
78
|
+
* `unknown`, which the caller counts as work remaining, because the one answer
|
|
79
|
+
* this predicate must never invent is a confident one.
|
|
80
|
+
*/
|
|
81
|
+
export declare function deliveredElsewhere(contextDir: string, branchRef: string, baseRef: string): Delivery;
|
|
82
|
+
/** One thing that is in flight, in the category that found it. */
|
|
83
|
+
export interface InFlightItem {
|
|
84
|
+
category: "task" | "worktree" | "branch";
|
|
85
|
+
what: string;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Everything in flight in a fleet home, plus every candidate left unread, plus
|
|
89
|
+
* the disclosures a reader needs to check the judgement rather than trust it.
|
|
90
|
+
*
|
|
91
|
+
* `notes` DOES NOT COUNT TOWARD THE EXIT CODE and that is deliberate. It
|
|
92
|
+
* carries facts about HOW a judgement was made (which base ref each project's
|
|
93
|
+
* branches were compared against, and by which rule that base was chosen), not
|
|
94
|
+
* facts about whether work remains. Putting them in `unknown` would hold the
|
|
95
|
+
* exit at `EXIT_WORK_REMAINS` forever; leaving them out is what let a clone
|
|
96
|
+
* whose `origin/HEAD` points at a feature branch judge every branch against
|
|
97
|
+
* that branch with nothing in the output saying so.
|
|
98
|
+
*/
|
|
99
|
+
export interface InFlight {
|
|
100
|
+
items: InFlightItem[];
|
|
101
|
+
unknown: string[];
|
|
102
|
+
notes: string[];
|
|
103
|
+
}
|
|
104
|
+
export declare function baseRefOf(projectDir: string): {
|
|
105
|
+
ok: true;
|
|
106
|
+
ref: string;
|
|
107
|
+
how: string;
|
|
108
|
+
doubt?: string;
|
|
109
|
+
} | {
|
|
110
|
+
ok: false;
|
|
111
|
+
reason: string;
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* Every in-flight category in a fleet home, in dependency order: the task
|
|
115
|
+
* record, the checkout it owns, and the branch that outlives both.
|
|
116
|
+
*/
|
|
117
|
+
export declare function collectInFlight(fleet: Fleet): InFlight;
|
|
118
|
+
/**
|
|
119
|
+
* THE ONE NEXT ACTION (criterion 1). Exactly one, chosen by the first
|
|
120
|
+
* non-empty category in the order above, so the answer is derived from the
|
|
121
|
+
* report rather than picked.
|
|
122
|
+
*/
|
|
123
|
+
export declare function nextAction(report: InFlight): string;
|
|
124
|
+
/** The cannot-see block. Unconditional, and it probes nothing to build. */
|
|
125
|
+
export declare function cannotSeeBlock(): string[];
|
|
126
|
+
/** The whole report, as lines, with exactly one `next action:` line. */
|
|
127
|
+
export declare function renderReport(fleet: Fleet, report: InFlight): string[];
|
|
128
|
+
/** Work remains whenever anything is in flight OR anything could not be read. */
|
|
129
|
+
export declare function exitCodeFor(report: InFlight): number;
|
|
130
|
+
export declare function cmdNext(args: string[]): number;
|