@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
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { readRegularFileIfPresent, taskDir } from "./task.js";
|
|
3
|
+
/**
|
|
4
|
+
* THE MODEL-RESOLUTION RECORD, KERNEL SIDE (kernel plan M4, M4-P7;
|
|
5
|
+
* delivery/plan/kernel-plan-m4.md:1018, discharging M4-D-06's four parts at
|
|
6
|
+
* delivery/plan/m4-intake.md:732).
|
|
7
|
+
*
|
|
8
|
+
* WHAT THIS MODULE IS AND, MORE IMPORTANTLY, WHAT IT IS NOT. It READS a
|
|
9
|
+
* record a harness adapter wrote. It holds NO vocabulary: no tier-to-model
|
|
10
|
+
* table, no model-to-family table, and no vendor name of any kind. The whole
|
|
11
|
+
* vocabulary lives in the plugin (plugin/src/vocabulary.ts) and this module
|
|
12
|
+
* dereferences a vocabulary's IDENTITY and never its CONTENT. That single
|
|
13
|
+
* property is what makes a second harness plugin possible at all: a mapping
|
|
14
|
+
* here would close off every harness that is not the one it names
|
|
15
|
+
* (delivery/plan/m4-intake.md:377), and `test/schemas.test.ts` already asserts
|
|
16
|
+
* the absence over the whole shipped surface rather than trusting this
|
|
17
|
+
* paragraph.
|
|
18
|
+
*
|
|
19
|
+
* THE ORDER OF THE CHECKS IS PART OF THE CONTRACT, and it is the release
|
|
20
|
+
* record's order (src/gates/release.ts): the SUBJECT ECHO is compared field by
|
|
21
|
+
* field BEFORE anything reads the resolved identity. A record about another
|
|
22
|
+
* task is not a record with a wrong answer, it is a record about something
|
|
23
|
+
* else, and reading its answer first is how a misattributed family token
|
|
24
|
+
* reaches a decorrelation check as though it belonged there.
|
|
25
|
+
*
|
|
26
|
+
* WHY `observed` IS NOT `attested`, stated here because the vocabulary is the
|
|
27
|
+
* thing most likely to be widened later by someone who did not read M4-P1.
|
|
28
|
+
* The probe measured that the observation channel is harness-written, that it
|
|
29
|
+
* can be EMPTY at hook time under load, and that a process at the agent's own
|
|
30
|
+
* uid can rewrite it in place so the result is byte-shaped exactly like the
|
|
31
|
+
* truth (delivery/verification/m4-prototype-probes.md:410). So `observed`
|
|
32
|
+
* ranks above `self-reported`, which costs nothing to forge, and below any
|
|
33
|
+
* notion of attestation, which would need a signer this environment has not
|
|
34
|
+
* been shown to have.
|
|
35
|
+
*/
|
|
36
|
+
/** The record's file name inside the task directory. */
|
|
37
|
+
export const MODEL_RESOLUTION_RECORD_NAME = "model-resolution.json";
|
|
38
|
+
/** The contract versions this kernel accepts. */
|
|
39
|
+
export const MODEL_RESOLUTION_CONTRACT_VERSIONS = ["1"];
|
|
40
|
+
/**
|
|
41
|
+
* Where the record lives: `tasks/<id>/model-resolution.json`, NEVER inside the
|
|
42
|
+
* worktree (M4-P7 criterion 5, src/task.ts:31 and FM-059). The task directory
|
|
43
|
+
* sits outside the worktree precisely so the pool's dirty check needs no
|
|
44
|
+
* exemption list for the kernel's own injected files, and a record written
|
|
45
|
+
* into the worktree would be indistinguishable from the agent's own work.
|
|
46
|
+
*/
|
|
47
|
+
export function modelResolutionPath(fleet, taskId) {
|
|
48
|
+
return join(taskDir(fleet, taskId), MODEL_RESOLUTION_RECORD_NAME);
|
|
49
|
+
}
|
|
50
|
+
function asRecord(value) {
|
|
51
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
52
|
+
? value
|
|
53
|
+
: undefined;
|
|
54
|
+
}
|
|
55
|
+
function stringAt(container, field) {
|
|
56
|
+
const value = container?.[field];
|
|
57
|
+
return typeof value === "string" ? value : undefined;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Read the record for a task.
|
|
61
|
+
*
|
|
62
|
+
* AN ABSENT RECORD IS AN ERROR AND IS NEVER GREEN AND NEVER NOT-APPLICABLE
|
|
63
|
+
* (M4-P7 criterion 5). This is not a new rule and deliberately not a second
|
|
64
|
+
* implementation of one: src/gates/release.ts:609 already produces the
|
|
65
|
+
* sentence for the same hazard on the release seam, and the wording is kept
|
|
66
|
+
* in the same shape so that a reader who has met one meets the other.
|
|
67
|
+
* `test/model-resolution.test.ts` drives the real release gate to capture that
|
|
68
|
+
* sentence and derives its assertion from the capture, rather than pinning a
|
|
69
|
+
* hand-written copy of it.
|
|
70
|
+
*
|
|
71
|
+
* THE READ GOES THROUGH `readRegularFileIfPresent`, which is M2-C-6 and the
|
|
72
|
+
* mechanism-index row `reading-a-path-whose-type-is-not-established`: a named
|
|
73
|
+
* pipe at this path is a REPORTED refusal, never a blocked open.
|
|
74
|
+
*/
|
|
75
|
+
export function readModelResolutionRecord(path, writerExitCode) {
|
|
76
|
+
const read = readRegularFileIfPresent(path);
|
|
77
|
+
if (read.kind === "absent") {
|
|
78
|
+
return {
|
|
79
|
+
kind: "error",
|
|
80
|
+
reason: `fail-closed rule 1: adapter exited ${String(writerExitCode)} without ` +
|
|
81
|
+
`writing a model-resolution record at ${path}; exit 0 with no ` +
|
|
82
|
+
`record is error, not success`,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
if (read.kind === "refused") {
|
|
86
|
+
return { kind: "error", reason: read.reason };
|
|
87
|
+
}
|
|
88
|
+
let parsed;
|
|
89
|
+
try {
|
|
90
|
+
parsed = JSON.parse(read.body);
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
return {
|
|
94
|
+
kind: "error",
|
|
95
|
+
reason: `${path} is not readable as JSON: ${String(error).replace(/\s+/g, " ").trim()}`,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
const record = asRecord(parsed);
|
|
99
|
+
if (record === undefined) {
|
|
100
|
+
return { kind: "error", reason: `${path} does not hold a JSON object` };
|
|
101
|
+
}
|
|
102
|
+
return { kind: "read", record };
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* The vocabulary IDENTITY of a record, and nothing else about the vocabulary.
|
|
106
|
+
*
|
|
107
|
+
* TWO FIELDS ARE READ AND THEY ARE NAMED HERE RATHER THAN SPREAD, which is
|
|
108
|
+
* the whole of criterion 4's mechanical half. A spread, an `Object.keys`, a
|
|
109
|
+
* `JSON.stringify` or a `structuredClone` of the vocabulary object would
|
|
110
|
+
* ENUMERATE it, and enumeration is dereferencing the content whatever the
|
|
111
|
+
* intent. `test/model-resolution.test.ts` hands this function a Proxy that
|
|
112
|
+
* records every property read and asserts the recorded set against these two
|
|
113
|
+
* names, so a later edit that reaches for a third reddens.
|
|
114
|
+
*/
|
|
115
|
+
export function vocabularyIdentity(record) {
|
|
116
|
+
const resolved = asRecord(record["resolved"]);
|
|
117
|
+
const vocabulary = asRecord(resolved?.["vocabulary"]);
|
|
118
|
+
if (vocabulary === undefined) {
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
const id = vocabulary["id"];
|
|
122
|
+
const version = vocabulary["version"];
|
|
123
|
+
if (typeof id !== "string" || typeof version !== "number") {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
return { id, version };
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Accept or refuse one record against the launch request the kernel holds.
|
|
130
|
+
*
|
|
131
|
+
* FOUR REFUSALS, IN THIS ORDER, and the order is the point.
|
|
132
|
+
*
|
|
133
|
+
* 1. THE CONTRACT VERSION, because a record written to a contract this kernel
|
|
134
|
+
* does not know is not a record this kernel can read fields out of.
|
|
135
|
+
* 2. THE SUBJECT ECHO, FIELD BY FIELD, BEFORE THE OUTCOME IS READ. This is
|
|
136
|
+
* src/gates/schemas/release-record.schema.json:26's misattribution guard.
|
|
137
|
+
* 3. THE VOCABULARY IDENTITY, because a family token with no vocabulary is a
|
|
138
|
+
* token nobody can say the meaning of.
|
|
139
|
+
* 4. THE PROVENANCE, which is M4-P7 criterion 6 member TWO. Member one (a
|
|
140
|
+
* record claiming `observed` with no observation at all) is the schema's
|
|
141
|
+
* and is a missing field. THIS is the present-but-inconsistent half, and a
|
|
142
|
+
* schema-only guard passes it green: an observation about another task, or
|
|
143
|
+
* naming a model other than the one the record claims, is a self-report
|
|
144
|
+
* with a decoration on it.
|
|
145
|
+
*/
|
|
146
|
+
export function acceptModelResolution(record, subject) {
|
|
147
|
+
const contractVersion = stringAt(record, "contractVersion");
|
|
148
|
+
if (contractVersion === undefined || !MODEL_RESOLUTION_CONTRACT_VERSIONS.includes(contractVersion)) {
|
|
149
|
+
return {
|
|
150
|
+
kind: "refused",
|
|
151
|
+
reason: `the record declares contract version ${String(contractVersion)}, and this ` +
|
|
152
|
+
`kernel accepts ${MODEL_RESOLUTION_CONTRACT_VERSIONS.join(", ")}`,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
const echo = asRecord(record["subject"]);
|
|
156
|
+
if (echo === undefined) {
|
|
157
|
+
return {
|
|
158
|
+
kind: "refused",
|
|
159
|
+
reason: "the record carries no subject echo, so what it is about could not be established",
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
const echoed = [
|
|
163
|
+
["taskId", stringAt(echo, "taskId")],
|
|
164
|
+
["role", stringAt(echo, "role")],
|
|
165
|
+
["requestedTier", stringAt(echo, "requestedTier")],
|
|
166
|
+
];
|
|
167
|
+
for (const [field, value] of echoed) {
|
|
168
|
+
if (value !== subject[field]) {
|
|
169
|
+
return {
|
|
170
|
+
kind: "refused",
|
|
171
|
+
reason: `the record's subject echo says ${field} is ${String(value)} and the ` +
|
|
172
|
+
`launch request says ${subject[field]}, so this record is about a ` +
|
|
173
|
+
`different subject`,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const identity = vocabularyIdentity(record);
|
|
178
|
+
if (identity === undefined) {
|
|
179
|
+
return {
|
|
180
|
+
kind: "refused",
|
|
181
|
+
reason: "the record carries no resolved.vocabulary identity, so its family token " +
|
|
182
|
+
"belongs to no stated vocabulary and cannot be compared with any other",
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
const resolved = asRecord(record["resolved"]);
|
|
186
|
+
const provenance = stringAt(resolved, "provenance");
|
|
187
|
+
const family = stringAt(resolved, "family");
|
|
188
|
+
const model = stringAt(resolved, "model");
|
|
189
|
+
if (provenance === "observed") {
|
|
190
|
+
const observation = asRecord(resolved?.["observation"]);
|
|
191
|
+
if (observation === undefined) {
|
|
192
|
+
return {
|
|
193
|
+
kind: "refused",
|
|
194
|
+
reason: "the record claims provenance observed and carries no observation, so " +
|
|
195
|
+
"the claim is a self-report wearing the word observed",
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
const observedTask = stringAt(observation, "taskId");
|
|
199
|
+
if (observedTask !== subject.taskId) {
|
|
200
|
+
return {
|
|
201
|
+
kind: "refused",
|
|
202
|
+
reason: `the record claims provenance observed and its observation is about ` +
|
|
203
|
+
`task ${String(observedTask)}, while the echoed request is about task ` +
|
|
204
|
+
`${subject.taskId}, so the observation contradicts the subject it is ` +
|
|
205
|
+
`offered as evidence for`,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
const observedModel = stringAt(observation, "model");
|
|
209
|
+
if (observedModel !== model) {
|
|
210
|
+
return {
|
|
211
|
+
kind: "refused",
|
|
212
|
+
reason: `the record claims model ${String(model)} with provenance observed and ` +
|
|
213
|
+
`its observation saw ${String(observedModel)}, so the claimed identity ` +
|
|
214
|
+
`is not the observed one`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
else if (provenance === "self-reported") {
|
|
219
|
+
if (resolved?.["observation"] !== undefined) {
|
|
220
|
+
return {
|
|
221
|
+
kind: "refused",
|
|
222
|
+
reason: "the record ranks itself self-reported and carries an observation, so " +
|
|
223
|
+
"it claims evidence it also says it does not have",
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
else if (provenance === "unresolved") {
|
|
228
|
+
if (family !== undefined || model !== undefined) {
|
|
229
|
+
return {
|
|
230
|
+
kind: "refused",
|
|
231
|
+
reason: "the record reports the identity unresolved and still names a family " +
|
|
232
|
+
"or a model, which is the quiet fallback to the forgeable value that " +
|
|
233
|
+
"an unresolved observation exists to prevent",
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
return {
|
|
239
|
+
kind: "refused",
|
|
240
|
+
reason: `the record declares provenance ${String(provenance)}, which this kernel has no rule for`,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
return { kind: "accepted", family, provenance: provenance };
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Compare two accepted records' family tokens (M4-P7 criterion 3).
|
|
247
|
+
*
|
|
248
|
+
* TWO VOCABULARIES ARE NOT COMPARABLE AND THE REFUSAL NAMES BOTH IDS. A family
|
|
249
|
+
* token means whatever the vocabulary that minted it says it means, so
|
|
250
|
+
* comparing a token from one against a token from another is comparing two
|
|
251
|
+
* strings and calling the result a fact about models. That comparison is what
|
|
252
|
+
* `check-dual-review`'s decorrelation assertion rests on, and DR-0038 exists
|
|
253
|
+
* for exactly the environment where the two reviews come from different
|
|
254
|
+
* places, so a silent cross-vocabulary compare would make the assertion
|
|
255
|
+
* meaningless precisely where it is load-bearing.
|
|
256
|
+
*
|
|
257
|
+
* ONLY `id` IS READ HERE. The version is part of the identity a reader may
|
|
258
|
+
* print and is deliberately NOT part of the comparison: a vocabulary that
|
|
259
|
+
* added a family in v2 did not change what its v1 tokens mean, and refusing
|
|
260
|
+
* on version would turn a routine vocabulary revision into an outage.
|
|
261
|
+
*/
|
|
262
|
+
export function compareResolvedFamilies(left, right) {
|
|
263
|
+
const leftIdentity = vocabularyIdentity(left);
|
|
264
|
+
const rightIdentity = vocabularyIdentity(right);
|
|
265
|
+
if (leftIdentity === undefined || rightIdentity === undefined) {
|
|
266
|
+
return {
|
|
267
|
+
kind: "refused",
|
|
268
|
+
reason: "one of the two records carries no resolved.vocabulary identity, so the " +
|
|
269
|
+
"two family tokens belong to no stated common vocabulary",
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
if (leftIdentity.id !== rightIdentity.id) {
|
|
273
|
+
return {
|
|
274
|
+
kind: "refused",
|
|
275
|
+
reason: `the two records name different vocabularies, ${leftIdentity.id} and ` +
|
|
276
|
+
`${rightIdentity.id}, so their family tokens are not comparable and no ` +
|
|
277
|
+
`decorrelation claim can be made from them`,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
const leftFamily = stringAt(asRecord(left["resolved"]), "family");
|
|
281
|
+
const rightFamily = stringAt(asRecord(right["resolved"]), "family");
|
|
282
|
+
if (leftFamily === undefined || rightFamily === undefined) {
|
|
283
|
+
return {
|
|
284
|
+
kind: "refused",
|
|
285
|
+
reason: "one of the two records names no resolved family, so there is nothing to " +
|
|
286
|
+
"compare; an unresolved identity is not a difference and not a match",
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
kind: "compared",
|
|
291
|
+
differ: leftFamily !== rightFamily,
|
|
292
|
+
families: [leftFamily, rightFamily],
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* The value closeout copies into a verdict's `produced-by` (M4-P7 criterion 8).
|
|
297
|
+
*
|
|
298
|
+
* VERBATIM, AND THE FUNCTION EXISTS SO THAT "VERBATIM" IS TESTABLE. DR-0031
|
|
299
|
+
* requires a pull request to carry all its own evidence, so the family token
|
|
300
|
+
* has to reach the verdict document rather than being left in a task
|
|
301
|
+
* directory that no reviewer of the pull request can see. A copy that
|
|
302
|
+
* normalised, lowercased or prefixed the token would satisfy every reading of
|
|
303
|
+
* that sentence and would break the one comparison the token exists for.
|
|
304
|
+
*/
|
|
305
|
+
export function producedByFromRecord(record) {
|
|
306
|
+
return stringAt(asRecord(record["resolved"]), "family");
|
|
307
|
+
}
|
|
@@ -1,2 +1,34 @@
|
|
|
1
1
|
/** Compare two existing paths by their canonical filesystem identity. */
|
|
2
2
|
export declare function pathsIdentifySameObject(left: string, right: string): boolean;
|
|
3
|
+
/**
|
|
4
|
+
* Decide whether two paths NAME THE SAME filesystem object when at least
|
|
5
|
+
* one of them was produced by ANOTHER PROGRAM rather than composed here.
|
|
6
|
+
*
|
|
7
|
+
* THE MECHANISM THIS EXISTS FOR, measured 2026-09-16 on the macOS smoke
|
|
8
|
+
* job of pull request #155. `path.resolve` normalizes `.`, `..` and
|
|
9
|
+
* relative segments and does NOT resolve symlinks, so two spellings of one
|
|
10
|
+
* directory stay unequal as strings. Programs the kernel shells out to do
|
|
11
|
+
* not preserve the caller's spelling: git canonicalizes every worktree
|
|
12
|
+
* path it records (`git worktree add /link/wt` then `git worktree list
|
|
13
|
+
* --porcelain` reports `/real/wt`, measured on Linux), and node reports
|
|
14
|
+
* the canonical path of a test file in its reporter's `file` field. So a
|
|
15
|
+
* kernel-composed path compared by string against such a value answers
|
|
16
|
+
* "different object" for the same object, silently, and every decision
|
|
17
|
+
* taken on that answer is wrong in the direction that does nothing.
|
|
18
|
+
*
|
|
19
|
+
* On macOS the symlink is supplied by the platform and needs no unusual
|
|
20
|
+
* setup: `/tmp` is a symlink to `/private/tmp` and `os.tmpdir()` returns a
|
|
21
|
+
* path under `/var/folders`, where `/var` is a symlink to `/private/var`.
|
|
22
|
+
* The CLI is accidentally immune because `process.cwd()` is already
|
|
23
|
+
* canonical, so only a caller that hands a path in makes this reachable,
|
|
24
|
+
* which is exactly what a library consumer does.
|
|
25
|
+
*
|
|
26
|
+
* The string comparison is tried FIRST and kept, rather than replaced, for
|
|
27
|
+
* two reasons: it answers without touching the filesystem in the common
|
|
28
|
+
* case, and it still gives the right answer for two paths that do not
|
|
29
|
+
* exist, where `realpathSync` can only raise. This function therefore says
|
|
30
|
+
* "same" strictly more often than `resolve(left) === resolve(right)` does,
|
|
31
|
+
* and never says "same" about two objects that are genuinely different:
|
|
32
|
+
* equal canonical paths ARE one object.
|
|
33
|
+
*/
|
|
34
|
+
export declare function pathsNameSameObject(left: string, right: string): boolean;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { realpathSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
2
3
|
/** Compare two existing paths by their canonical filesystem identity. */
|
|
3
4
|
export function pathsIdentifySameObject(left, right) {
|
|
4
5
|
try {
|
|
@@ -8,3 +9,40 @@ export function pathsIdentifySameObject(left, right) {
|
|
|
8
9
|
return false;
|
|
9
10
|
}
|
|
10
11
|
}
|
|
12
|
+
/**
|
|
13
|
+
* Decide whether two paths NAME THE SAME filesystem object when at least
|
|
14
|
+
* one of them was produced by ANOTHER PROGRAM rather than composed here.
|
|
15
|
+
*
|
|
16
|
+
* THE MECHANISM THIS EXISTS FOR, measured 2026-09-16 on the macOS smoke
|
|
17
|
+
* job of pull request #155. `path.resolve` normalizes `.`, `..` and
|
|
18
|
+
* relative segments and does NOT resolve symlinks, so two spellings of one
|
|
19
|
+
* directory stay unequal as strings. Programs the kernel shells out to do
|
|
20
|
+
* not preserve the caller's spelling: git canonicalizes every worktree
|
|
21
|
+
* path it records (`git worktree add /link/wt` then `git worktree list
|
|
22
|
+
* --porcelain` reports `/real/wt`, measured on Linux), and node reports
|
|
23
|
+
* the canonical path of a test file in its reporter's `file` field. So a
|
|
24
|
+
* kernel-composed path compared by string against such a value answers
|
|
25
|
+
* "different object" for the same object, silently, and every decision
|
|
26
|
+
* taken on that answer is wrong in the direction that does nothing.
|
|
27
|
+
*
|
|
28
|
+
* On macOS the symlink is supplied by the platform and needs no unusual
|
|
29
|
+
* setup: `/tmp` is a symlink to `/private/tmp` and `os.tmpdir()` returns a
|
|
30
|
+
* path under `/var/folders`, where `/var` is a symlink to `/private/var`.
|
|
31
|
+
* The CLI is accidentally immune because `process.cwd()` is already
|
|
32
|
+
* canonical, so only a caller that hands a path in makes this reachable,
|
|
33
|
+
* which is exactly what a library consumer does.
|
|
34
|
+
*
|
|
35
|
+
* The string comparison is tried FIRST and kept, rather than replaced, for
|
|
36
|
+
* two reasons: it answers without touching the filesystem in the common
|
|
37
|
+
* case, and it still gives the right answer for two paths that do not
|
|
38
|
+
* exist, where `realpathSync` can only raise. This function therefore says
|
|
39
|
+
* "same" strictly more often than `resolve(left) === resolve(right)` does,
|
|
40
|
+
* and never says "same" about two objects that are genuinely different:
|
|
41
|
+
* equal canonical paths ARE one object.
|
|
42
|
+
*/
|
|
43
|
+
export function pathsNameSameObject(left, right) {
|
|
44
|
+
if (resolve(left) === resolve(right)) {
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
return pathsIdentifySameObject(left, right);
|
|
48
|
+
}
|
package/dist/src/pool.d.ts
CHANGED
|
@@ -56,11 +56,154 @@ export type PoolResult<T> = {
|
|
|
56
56
|
ok: false;
|
|
57
57
|
reason: string;
|
|
58
58
|
};
|
|
59
|
+
/**
|
|
60
|
+
* THE BOUND ON A NETWORK-REACHING GIT SUBPROCESS (M4-P19 fix round).
|
|
61
|
+
*
|
|
62
|
+
* `spawnSync` with no `timeout` waits for the child forever. For a local
|
|
63
|
+
* git command that is harmless: it either answers or fails. For one that
|
|
64
|
+
* opens a socket it is not, and the failure mode is not a slow command,
|
|
65
|
+
* it is a command that NEVER RETURNS. Measured by the clean-room
|
|
66
|
+
* reviewer against head abde402: a remote pointed at a TCP listener that
|
|
67
|
+
* accepts and never speaks made `tiphys pool list` and `tiphys doctor`
|
|
68
|
+
* run until killed (exit 124 under `timeout 25`), where the same fixture
|
|
69
|
+
* on the phase base exited 0 in about a second.
|
|
70
|
+
*
|
|
71
|
+
* This bound is applied to `ls-remote --symref <remote> HEAD` only, and
|
|
72
|
+
* the reason it is safe THERE and not elsewhere is a property of the
|
|
73
|
+
* command rather than a judgement about it: that invocation transfers a
|
|
74
|
+
* ref advertisement and nothing else, so a legitimate one is bounded by
|
|
75
|
+
* round-trip latency. `git fetch` (src/pool.ts, src/teardown.ts) and
|
|
76
|
+
* `git push` (src/teardown.ts) transfer objects, so their legitimate
|
|
77
|
+
* duration IS unbounded and a wall-clock cap on them would abort real
|
|
78
|
+
* work. They are left unbounded deliberately, and they are reached only
|
|
79
|
+
* from a command the operator invoked to do that work, never from a
|
|
80
|
+
* reporting path; keeping reporting paths off the network entirely is
|
|
81
|
+
* the other half of this fix (see `reconstructPoolRecord`).
|
|
82
|
+
*
|
|
83
|
+
* TIPHYS_GIT_NETWORK_TIMEOUT_MS is a TEST SEAM in the style of
|
|
84
|
+
* TIPHYS_WATCH_TEST_HOLD (src/watcher.ts) and TIPHYS_LOCK_TEST_HOLD
|
|
85
|
+
* (src/commands/lock.ts): a test cannot afford to wait out the shipped
|
|
86
|
+
* bound, and a shipped bound short enough for a test would abort a
|
|
87
|
+
* legitimate ls-remote over a slow link. A value that is not a positive
|
|
88
|
+
* integer is IGNORED rather than honoured, so a malformed environment
|
|
89
|
+
* cannot silently remove the bound.
|
|
90
|
+
*/
|
|
91
|
+
export declare const NETWORK_TIMEOUT_MS = 20000;
|
|
92
|
+
/**
|
|
93
|
+
* Exported as a PURE function so the validation has a witness that does
|
|
94
|
+
* not have to wait out a twenty-second bound to observe it. The
|
|
95
|
+
* end-to-end bound is witnessed separately, against a real remote that
|
|
96
|
+
* never answers; this is the arm that says a malformed environment
|
|
97
|
+
* cannot silently switch the bound off.
|
|
98
|
+
*
|
|
99
|
+
* `Number("")` is 0 and `Number("0x10")` is 16, so neither a blank value
|
|
100
|
+
* nor a hexadecimal one is passed through: the accepted set is exactly
|
|
101
|
+
* the positive integers, and everything else falls back.
|
|
102
|
+
*/
|
|
103
|
+
export declare function resolveNetworkTimeoutMs(raw: string | undefined): number;
|
|
59
104
|
/** Exported for the contention-classification test. */
|
|
60
105
|
export declare function isTransientGitLockError(stderr: string): boolean;
|
|
61
106
|
export declare function recordPath(fleet: Fleet, taskId: string): string;
|
|
62
107
|
export declare function worktreePath(fleet: Fleet, taskId: string): string;
|
|
108
|
+
/**
|
|
109
|
+
* THE RECORD'S ENTRY TYPE IS ESTABLISHED BEFORE IT IS OPENED. A bare read
|
|
110
|
+
* here hung `tiphys pool destroy` forever with zero output against a named
|
|
111
|
+
* pipe at `worktrees/<id>.pool.json`; measured before the fix, `pool list`
|
|
112
|
+
* (which reads only the NAME) returned in the same second while
|
|
113
|
+
* `pool destroy --task t-0001` was killed at ten seconds. The two commands
|
|
114
|
+
* differ by whether this function runs, which is what makes the mechanism
|
|
115
|
+
* the open and not the command.
|
|
116
|
+
*
|
|
117
|
+
* A non-regular record is a REFUSAL rather than `undefined`: `undefined`
|
|
118
|
+
* already means "there is no record", and a caller that cannot tell that
|
|
119
|
+
* apart from "the record could not be opened" would rebuild a worktree over
|
|
120
|
+
* a record it never read.
|
|
121
|
+
*/
|
|
63
122
|
export declare function readPoolRecord(fleet: Fleet, taskId: string): PoolRecord | undefined;
|
|
123
|
+
/**
|
|
124
|
+
* POST-RECLAIM RECONSTRUCTION (M4-D-12, kernel plan M4 section M4-P19).
|
|
125
|
+
*
|
|
126
|
+
* THE RULE, and it is the whole design: RECONSTRUCT FOR REPORTING, NEVER
|
|
127
|
+
* FOR DESTRUCTION. A reconstructed record exists in memory for the life
|
|
128
|
+
* of one command and is NEVER written to worktrees/<id>.pool.json, so a
|
|
129
|
+
* later reader can never mistake a reconstruction for an original.
|
|
130
|
+
*
|
|
131
|
+
* THE DEFECT IT CLOSES, measured rather than assumed. tasks/<id>/meta.json
|
|
132
|
+
* is TRACKED and survives a reclaim; worktrees/<id>.pool.json cannot,
|
|
133
|
+
* because it sits beside the worktree BY DESIGN so it can never dirty the
|
|
134
|
+
* destroy-time cleanliness check (see the module header above, FM-059).
|
|
135
|
+
* Teardown then refuses without a pool record and says so in terms
|
|
136
|
+
* (src/teardown.ts), so the plan's stated fallback of "recovery is manual
|
|
137
|
+
* teardown" does not work post-reclaim: the manual path is itself blocked.
|
|
138
|
+
*
|
|
139
|
+
* WHAT IS DERIVED AND WHAT IS NOT. meta.json carries six of the eight
|
|
140
|
+
* PoolRecord fields directly (taskId, project, baseSha, branchName,
|
|
141
|
+
* offline, createdAt). It carries NEITHER `remote` NOR `branch`, which are
|
|
142
|
+
* the project's configured remote and that remote's default branch. Those
|
|
143
|
+
* two are re-derived from git through the SAME two resolvers poolCreate
|
|
144
|
+
* uses, so a reconstruction is a repeat of the original derivation and
|
|
145
|
+
* never a remembered value.
|
|
146
|
+
*
|
|
147
|
+
* WHEN EITHER OF THOSE TWO CANNOT BE DERIVED THE RESULT IS INCOMPLETE AND
|
|
148
|
+
* NAMES THE FIELD. It is never filled with a plausible default. "origin"
|
|
149
|
+
* and "main" are right often enough to look harmless and wrong often
|
|
150
|
+
* enough to destroy work: a guessed default branch sends the landed-ness
|
|
151
|
+
* judgement at a ref that is not the project's default, and teardown's
|
|
152
|
+
* authorization to delete a task branch comes from exactly that judgement
|
|
153
|
+
* (V-1, the defect src/spawn.ts was rewritten to prevent). An unresolvable
|
|
154
|
+
* field is therefore a refusal, not a gap to fill.
|
|
155
|
+
*/
|
|
156
|
+
export type ReconstructResult =
|
|
157
|
+
/** Every field derived. Safe to report, and safe to pass to a GATED path. */
|
|
158
|
+
{
|
|
159
|
+
kind: "complete";
|
|
160
|
+
record: PoolRecord;
|
|
161
|
+
}
|
|
162
|
+
/** meta.json read, but git could not answer for the named fields. */
|
|
163
|
+
| {
|
|
164
|
+
kind: "incomplete";
|
|
165
|
+
unresolved: string[];
|
|
166
|
+
detail: string;
|
|
167
|
+
}
|
|
168
|
+
/** No readable task meta, so there is nothing to reconstruct from. */
|
|
169
|
+
| {
|
|
170
|
+
kind: "absent";
|
|
171
|
+
reason: string;
|
|
172
|
+
};
|
|
173
|
+
/**
|
|
174
|
+
* What a caller of `reconstructPoolRecord` is permitted to do to answer.
|
|
175
|
+
*
|
|
176
|
+
* THE FIELD IS REQUIRED AND THE TYPE IS WHY (M4-P19 fix round). The
|
|
177
|
+
* reconstruction rebuilds `branch` through `resolveDefaultBranch`, which
|
|
178
|
+
* falls back to `git ls-remote` when `<remote>/HEAD` is unset locally,
|
|
179
|
+
* and that fallback opens a socket. `<remote>/HEAD` unset is not an
|
|
180
|
+
* exotic state: it is the NORMAL state of a clone made by `git init` +
|
|
181
|
+
* `git remote add` + `git fetch`, which is how this kernel's own tests
|
|
182
|
+
* and fixtures build one. So the fallback is reached on ordinary fleets,
|
|
183
|
+
* and a caller that must return, such as `pool list` or `doctor`, must
|
|
184
|
+
* not reach it.
|
|
185
|
+
*
|
|
186
|
+
* Measured against head abde402, where this option did not exist and
|
|
187
|
+
* every caller got the network: with the remote pointed at a TCP
|
|
188
|
+
* listener that accepts and never speaks, `tiphys pool list` and
|
|
189
|
+
* `tiphys doctor` ran until killed. Making the decision a required field
|
|
190
|
+
* rather than a defaulted one is the part of the fix that survives the
|
|
191
|
+
* next caller: adding one without choosing does not compile.
|
|
192
|
+
*/
|
|
193
|
+
export interface ReconstructOptions {
|
|
194
|
+
/**
|
|
195
|
+
* True only for a caller the operator invoked to CHANGE something and
|
|
196
|
+
* which may therefore wait on a remote. False for every reporting
|
|
197
|
+
* path, which then reports `unreconstructable (unresolved: branch)`
|
|
198
|
+
* rather than blocking.
|
|
199
|
+
*/
|
|
200
|
+
network: boolean;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Rebuild a pool record for taskId from tasks/<id>/meta.json and git.
|
|
204
|
+
* Reads only; writes nothing anywhere, ever.
|
|
205
|
+
*/
|
|
206
|
+
export declare function reconstructPoolRecord(fleet: Fleet, taskId: string, options: ReconstructOptions): ReconstructResult;
|
|
64
207
|
export interface CreateOptions {
|
|
65
208
|
taskId: string;
|
|
66
209
|
project: string;
|
|
@@ -81,11 +224,46 @@ export interface CreateOptions {
|
|
|
81
224
|
* chosen contract, not an oversight.
|
|
82
225
|
*/
|
|
83
226
|
export declare function poolCreate(fleet: Fleet, options: CreateOptions): Promise<PoolResult<PoolRecord>>;
|
|
227
|
+
/**
|
|
228
|
+
* Where an entry's pool record came from. `record` is an original read
|
|
229
|
+
* from worktrees/<id>.pool.json; the other two exist only after a reclaim
|
|
230
|
+
* has taken worktrees/ with it, and are computed fresh on every call.
|
|
231
|
+
*/
|
|
232
|
+
export type PoolEntryOrigin = "record" | "reconstructed" | "unreconstructable";
|
|
84
233
|
export interface PoolListEntry {
|
|
85
234
|
taskId: string;
|
|
86
235
|
headSha: string;
|
|
236
|
+
origin: PoolEntryOrigin;
|
|
237
|
+
/**
|
|
238
|
+
* Set only for `unreconstructable`: what this listing could not establish.
|
|
239
|
+
* Usually the PoolRecord fields git could not answer for; `meta` means the
|
|
240
|
+
* task record itself was present and did not read, which is a different
|
|
241
|
+
* state from a task that is closed and from one that is not there.
|
|
242
|
+
*/
|
|
243
|
+
unresolved?: string[];
|
|
87
244
|
}
|
|
88
|
-
/**
|
|
245
|
+
/**
|
|
246
|
+
* One entry per pool record, with the worktree's current HEAD SHA, PLUS
|
|
247
|
+
* one per OPEN task that has no pool record beside it (M4-P19).
|
|
248
|
+
*
|
|
249
|
+
* The second group is what a reclaim leaves behind: tasks/ is tracked and
|
|
250
|
+
* survives, worktrees/ is gitignored and does not, so a task can be open
|
|
251
|
+
* with its record gone. Reporting only the first group makes those tasks
|
|
252
|
+
* invisible to `pool list` and to doctor, which is the state the plan
|
|
253
|
+
* calls a defect. Every such entry is marked, never silently blended in
|
|
254
|
+
* with the originals, and NOTHING here is written to disk.
|
|
255
|
+
*
|
|
256
|
+
* Closed tasks are excluded: a closed task is not in the pool, and
|
|
257
|
+
* listing every task this fleet ever finished as a missing worktree would
|
|
258
|
+
* make the report useless within a week.
|
|
259
|
+
*
|
|
260
|
+
* THIS FUNCTION OPENS NO SOCKET (M4-P19 fix round). It reads the
|
|
261
|
+
* filesystem and runs local git commands, and its reconstruction is
|
|
262
|
+
* asked for with `{ network: false }`. An entry whose default branch
|
|
263
|
+
* cannot be established from the clone alone is reported
|
|
264
|
+
* `unreconstructable (unresolved: branch)` rather than waited on. See
|
|
265
|
+
* `ReconstructOptions` for the measured hang that this closes.
|
|
266
|
+
*/
|
|
89
267
|
export declare function poolList(fleet: Fleet): PoolListEntry[];
|
|
90
268
|
export interface LsofProbe {
|
|
91
269
|
available: boolean;
|
|
@@ -120,6 +298,24 @@ export interface DestroyOptions {
|
|
|
120
298
|
* than assumed.
|
|
121
299
|
*/
|
|
122
300
|
deleteBranchForce: boolean;
|
|
301
|
+
/**
|
|
302
|
+
* An IN-MEMORY reconstructed record (M4-P19), used ONLY when no record
|
|
303
|
+
* exists on disk. It is never written: `haveRecord` still comes from
|
|
304
|
+
* the file, so nothing here creates worktrees/<id>.pool.json and
|
|
305
|
+
* nothing unlinks a file that is not there.
|
|
306
|
+
*
|
|
307
|
+
* WHY THIS EXISTS AT ALL, since the whole design is "never for
|
|
308
|
+
* destruction". Without it, stage 2's base-sha gate has no base to
|
|
309
|
+
* compare against and returns its "pool record missing or unreadable"
|
|
310
|
+
* refusal, so the post-reclaim path would be blocked at the one gate
|
|
311
|
+
* that was ALREADY going to do the right thing. Passing the
|
|
312
|
+
* reconstruction in makes that gate WORK rather than abstain, which is
|
|
313
|
+
* strictly safer than the alternative of relaxing it. The caller is
|
|
314
|
+
* responsible for having derived it (src/teardown.ts's
|
|
315
|
+
* --from-reconstructed path is the only one), and `pool destroy` on
|
|
316
|
+
* the command line never sets it.
|
|
317
|
+
*/
|
|
318
|
+
reconstructed?: PoolRecord;
|
|
123
319
|
}
|
|
124
320
|
/** What a completed destroy removed, for the operator's record. */
|
|
125
321
|
export interface DestroyOutcome {
|