@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/pool.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
-
import { existsSync, readdirSync,
|
|
2
|
+
import { existsSync, readdirSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
5
|
+
import { metaPath, readTaskMeta } from "./task.js";
|
|
6
|
+
import { classifyPathEntry, readRegularPathIfPresent } from "./fleet.js";
|
|
7
|
+
import { pathsNameSameObject } from "./path-identity.js";
|
|
5
8
|
/**
|
|
6
9
|
* Worktree pool over a project clone (kernel plan v1, M1-P3 step 3).
|
|
7
10
|
* BUILD from the contract (plan decision D-1, FM-026): a clean disposable
|
|
@@ -44,16 +47,86 @@ export function taskBranchName(taskId) {
|
|
|
44
47
|
}
|
|
45
48
|
/** Task ids are single safe path segments. */
|
|
46
49
|
export const TASK_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
47
|
-
|
|
50
|
+
/**
|
|
51
|
+
* THE BOUND ON A NETWORK-REACHING GIT SUBPROCESS (M4-P19 fix round).
|
|
52
|
+
*
|
|
53
|
+
* `spawnSync` with no `timeout` waits for the child forever. For a local
|
|
54
|
+
* git command that is harmless: it either answers or fails. For one that
|
|
55
|
+
* opens a socket it is not, and the failure mode is not a slow command,
|
|
56
|
+
* it is a command that NEVER RETURNS. Measured by the clean-room
|
|
57
|
+
* reviewer against head abde402: a remote pointed at a TCP listener that
|
|
58
|
+
* accepts and never speaks made `tiphys pool list` and `tiphys doctor`
|
|
59
|
+
* run until killed (exit 124 under `timeout 25`), where the same fixture
|
|
60
|
+
* on the phase base exited 0 in about a second.
|
|
61
|
+
*
|
|
62
|
+
* This bound is applied to `ls-remote --symref <remote> HEAD` only, and
|
|
63
|
+
* the reason it is safe THERE and not elsewhere is a property of the
|
|
64
|
+
* command rather than a judgement about it: that invocation transfers a
|
|
65
|
+
* ref advertisement and nothing else, so a legitimate one is bounded by
|
|
66
|
+
* round-trip latency. `git fetch` (src/pool.ts, src/teardown.ts) and
|
|
67
|
+
* `git push` (src/teardown.ts) transfer objects, so their legitimate
|
|
68
|
+
* duration IS unbounded and a wall-clock cap on them would abort real
|
|
69
|
+
* work. They are left unbounded deliberately, and they are reached only
|
|
70
|
+
* from a command the operator invoked to do that work, never from a
|
|
71
|
+
* reporting path; keeping reporting paths off the network entirely is
|
|
72
|
+
* the other half of this fix (see `reconstructPoolRecord`).
|
|
73
|
+
*
|
|
74
|
+
* TIPHYS_GIT_NETWORK_TIMEOUT_MS is a TEST SEAM in the style of
|
|
75
|
+
* TIPHYS_WATCH_TEST_HOLD (src/watcher.ts) and TIPHYS_LOCK_TEST_HOLD
|
|
76
|
+
* (src/commands/lock.ts): a test cannot afford to wait out the shipped
|
|
77
|
+
* bound, and a shipped bound short enough for a test would abort a
|
|
78
|
+
* legitimate ls-remote over a slow link. A value that is not a positive
|
|
79
|
+
* integer is IGNORED rather than honoured, so a malformed environment
|
|
80
|
+
* cannot silently remove the bound.
|
|
81
|
+
*/
|
|
82
|
+
export const NETWORK_TIMEOUT_MS = 20_000;
|
|
83
|
+
/**
|
|
84
|
+
* Exported as a PURE function so the validation has a witness that does
|
|
85
|
+
* not have to wait out a twenty-second bound to observe it. The
|
|
86
|
+
* end-to-end bound is witnessed separately, against a real remote that
|
|
87
|
+
* never answers; this is the arm that says a malformed environment
|
|
88
|
+
* cannot silently switch the bound off.
|
|
89
|
+
*
|
|
90
|
+
* `Number("")` is 0 and `Number("0x10")` is 16, so neither a blank value
|
|
91
|
+
* nor a hexadecimal one is passed through: the accepted set is exactly
|
|
92
|
+
* the positive integers, and everything else falls back.
|
|
93
|
+
*/
|
|
94
|
+
export function resolveNetworkTimeoutMs(raw) {
|
|
95
|
+
if (raw === undefined) {
|
|
96
|
+
return NETWORK_TIMEOUT_MS;
|
|
97
|
+
}
|
|
98
|
+
const parsed = Number(raw);
|
|
99
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
100
|
+
return NETWORK_TIMEOUT_MS;
|
|
101
|
+
}
|
|
102
|
+
return parsed;
|
|
103
|
+
}
|
|
104
|
+
function networkTimeoutMs() {
|
|
105
|
+
return resolveNetworkTimeoutMs(process.env["TIPHYS_GIT_NETWORK_TIMEOUT_MS"]);
|
|
106
|
+
}
|
|
107
|
+
function runGit(cwd, args, timeoutMs) {
|
|
48
108
|
const result = spawnSync("git", ["-C", cwd, ...args], {
|
|
49
109
|
encoding: "utf8",
|
|
50
110
|
// The transient-contention classification below reads git's English
|
|
51
111
|
// message text, so the locale is pinned rather than inherited
|
|
52
112
|
// (U-8). Without this a translated git silently stops retrying.
|
|
53
113
|
env: { ...process.env, LC_ALL: "C", LANG: "C" },
|
|
114
|
+
...(timeoutMs === undefined ? {} : { timeout: timeoutMs }),
|
|
54
115
|
});
|
|
55
116
|
if (result.error !== undefined) {
|
|
56
|
-
|
|
117
|
+
// A killed-on-timeout child arrives here with an ETIMEDOUT error. It
|
|
118
|
+
// is named in terms rather than passed through as
|
|
119
|
+
// "Error: spawnSync git ETIMEDOUT", because the operator's remedy
|
|
120
|
+
// (an unreachable remote) is not readable from that string.
|
|
121
|
+
const timedOut = timeoutMs !== undefined &&
|
|
122
|
+
result.error.code === "ETIMEDOUT";
|
|
123
|
+
return {
|
|
124
|
+
status: null,
|
|
125
|
+
stdout: "",
|
|
126
|
+
stderr: timedOut
|
|
127
|
+
? `git ${args.join(" ")} did not answer within ${String(timeoutMs)}ms and was killed`
|
|
128
|
+
: String(result.error),
|
|
129
|
+
};
|
|
57
130
|
}
|
|
58
131
|
return {
|
|
59
132
|
status: result.status,
|
|
@@ -124,9 +197,30 @@ export function recordPath(fleet, taskId) {
|
|
|
124
197
|
export function worktreePath(fleet, taskId) {
|
|
125
198
|
return join(fleet.worktreesDir, taskId);
|
|
126
199
|
}
|
|
200
|
+
/**
|
|
201
|
+
* THE RECORD'S ENTRY TYPE IS ESTABLISHED BEFORE IT IS OPENED. A bare read
|
|
202
|
+
* here hung `tiphys pool destroy` forever with zero output against a named
|
|
203
|
+
* pipe at `worktrees/<id>.pool.json`; measured before the fix, `pool list`
|
|
204
|
+
* (which reads only the NAME) returned in the same second while
|
|
205
|
+
* `pool destroy --task t-0001` was killed at ten seconds. The two commands
|
|
206
|
+
* differ by whether this function runs, which is what makes the mechanism
|
|
207
|
+
* the open and not the command.
|
|
208
|
+
*
|
|
209
|
+
* A non-regular record is a REFUSAL rather than `undefined`: `undefined`
|
|
210
|
+
* already means "there is no record", and a caller that cannot tell that
|
|
211
|
+
* apart from "the record could not be opened" would rebuild a worktree over
|
|
212
|
+
* a record it never read.
|
|
213
|
+
*/
|
|
127
214
|
export function readPoolRecord(fleet, taskId) {
|
|
215
|
+
const read = readRegularPathIfPresent(recordPath(fleet, taskId));
|
|
216
|
+
if (read.kind === "absent") {
|
|
217
|
+
return undefined;
|
|
218
|
+
}
|
|
219
|
+
if (read.kind === "refused") {
|
|
220
|
+
throw new Error(read.reason);
|
|
221
|
+
}
|
|
128
222
|
try {
|
|
129
|
-
return JSON.parse(
|
|
223
|
+
return JSON.parse(read.body);
|
|
130
224
|
}
|
|
131
225
|
catch {
|
|
132
226
|
return undefined;
|
|
@@ -161,8 +255,17 @@ function resolveRemote(project) {
|
|
|
161
255
|
* set locally (no network); otherwise the remote's advertised default
|
|
162
256
|
* via ls-remote --symref. The clone's own HEAD is never consulted, so a
|
|
163
257
|
* detached HEAD in the clone is irrelevant.
|
|
258
|
+
*
|
|
259
|
+
* `network` IS A REQUIRED PARAMETER AND HAS NO DEFAULT (M4-P19 fix
|
|
260
|
+
* round). Every caller must state whether it is allowed to open a
|
|
261
|
+
* socket, because the two callers that exist have opposite answers and a
|
|
262
|
+
* default would hand the wrong one to whichever caller is added next
|
|
263
|
+
* without anybody having to decide. `false` stops at the local
|
|
264
|
+
* `origin/HEAD` lookup and refuses, naming the fact that the network was
|
|
265
|
+
* not consulted, so a caller reading the refusal can tell "this remote
|
|
266
|
+
* has no default branch" from "I was not permitted to ask".
|
|
164
267
|
*/
|
|
165
|
-
function resolveDefaultBranch(project, remote) {
|
|
268
|
+
function resolveDefaultBranch(project, remote, network) {
|
|
166
269
|
const local = runGit(project, [
|
|
167
270
|
"symbolic-ref",
|
|
168
271
|
"--quiet",
|
|
@@ -175,7 +278,15 @@ function resolveDefaultBranch(project, remote) {
|
|
|
175
278
|
return { ok: true, value: ref.slice(prefix.length) };
|
|
176
279
|
}
|
|
177
280
|
}
|
|
178
|
-
|
|
281
|
+
if (!network) {
|
|
282
|
+
return {
|
|
283
|
+
ok: false,
|
|
284
|
+
reason: `cannot resolve the default branch of remote ${remote} without the ` +
|
|
285
|
+
`network: ${remote}/HEAD is unset locally, and this caller reports ` +
|
|
286
|
+
`rather than writes, so it does not contact ${remote} to ask`,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
const advertised = runGit(project, ["ls-remote", "--symref", remote, "HEAD"], networkTimeoutMs());
|
|
179
290
|
if (advertised.status === 0) {
|
|
180
291
|
const match = /^ref:\s+refs\/heads\/(\S+)\s+HEAD$/m.exec(advertised.stdout);
|
|
181
292
|
if (match !== null) {
|
|
@@ -192,6 +303,69 @@ function resolveDefaultBranch(project, remote) {
|
|
|
192
303
|
`${remote}/HEAD is unset locally and ls-remote failed: ${advertised.stderr.trim()}`,
|
|
193
304
|
};
|
|
194
305
|
}
|
|
306
|
+
/**
|
|
307
|
+
* Rebuild a pool record for taskId from tasks/<id>/meta.json and git.
|
|
308
|
+
* Reads only; writes nothing anywhere, ever.
|
|
309
|
+
*/
|
|
310
|
+
export function reconstructPoolRecord(fleet, taskId, options) {
|
|
311
|
+
if (!TASK_ID_PATTERN.test(taskId)) {
|
|
312
|
+
return { kind: "absent", reason: `task id "${taskId}" is not a safe path segment` };
|
|
313
|
+
}
|
|
314
|
+
const meta = readTaskMeta(fleet, taskId);
|
|
315
|
+
if (meta === undefined) {
|
|
316
|
+
return {
|
|
317
|
+
kind: "absent",
|
|
318
|
+
reason: `no readable task meta at ${metaPath(fleet, taskId)}, so there is ` +
|
|
319
|
+
`nothing to reconstruct a pool record from`,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
const unresolved = [];
|
|
323
|
+
const details = [];
|
|
324
|
+
let remote;
|
|
325
|
+
let branch;
|
|
326
|
+
if (!existsSync(meta.project)) {
|
|
327
|
+
// Both fields live in the clone, so losing the clone loses both. They
|
|
328
|
+
// are reported together rather than one at a time, because a caller
|
|
329
|
+
// that repaired only the first would be told about the second on the
|
|
330
|
+
// next run and learn nothing it could not have been told now.
|
|
331
|
+
unresolved.push("remote", "branch");
|
|
332
|
+
details.push(`the project clone ${meta.project} recorded in meta.json is absent`);
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
const resolvedRemote = resolveRemote(meta.project);
|
|
336
|
+
if (resolvedRemote.ok) {
|
|
337
|
+
remote = resolvedRemote.value;
|
|
338
|
+
const resolvedBranch = resolveDefaultBranch(meta.project, remote, options.network);
|
|
339
|
+
if (resolvedBranch.ok) {
|
|
340
|
+
branch = resolvedBranch.value;
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
unresolved.push("branch");
|
|
344
|
+
details.push(resolvedBranch.reason);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
else {
|
|
348
|
+
unresolved.push("remote", "branch");
|
|
349
|
+
details.push(resolvedRemote.reason);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (remote === undefined || branch === undefined) {
|
|
353
|
+
return { kind: "incomplete", unresolved, detail: details.join("; ") };
|
|
354
|
+
}
|
|
355
|
+
return {
|
|
356
|
+
kind: "complete",
|
|
357
|
+
record: {
|
|
358
|
+
taskId,
|
|
359
|
+
project: meta.project,
|
|
360
|
+
remote,
|
|
361
|
+
branch,
|
|
362
|
+
baseSha: meta.baseSha,
|
|
363
|
+
branchName: meta.branch,
|
|
364
|
+
offline: meta.baseOffline,
|
|
365
|
+
createdAt: meta.createdAt,
|
|
366
|
+
},
|
|
367
|
+
};
|
|
368
|
+
}
|
|
195
369
|
/**
|
|
196
370
|
* pool create (EXT-F-03 five steps; see module doc). Returns the pool
|
|
197
371
|
* record on success.
|
|
@@ -280,7 +454,9 @@ export async function poolCreate(fleet, options) {
|
|
|
280
454
|
if (!remote.ok) {
|
|
281
455
|
return remote;
|
|
282
456
|
}
|
|
283
|
-
|
|
457
|
+
// poolCreate is a write the operator invoked and it is about to fetch
|
|
458
|
+
// from this remote anyway, so it may ask the remote for its default.
|
|
459
|
+
const branch = resolveDefaultBranch(project, remote.value, true);
|
|
284
460
|
if (!branch.ok) {
|
|
285
461
|
return branch;
|
|
286
462
|
}
|
|
@@ -381,24 +557,98 @@ export async function poolCreate(fleet, options) {
|
|
|
381
557
|
}
|
|
382
558
|
return { ok: true, value: poolRecord };
|
|
383
559
|
}
|
|
384
|
-
/**
|
|
560
|
+
/** The worktree's current HEAD SHA, or "missing" when there is no worktree. */
|
|
561
|
+
function headShaOf(fleet, taskId) {
|
|
562
|
+
const worktree = worktreePath(fleet, taskId);
|
|
563
|
+
const head = existsSync(worktree)
|
|
564
|
+
? runGit(worktree, ["rev-parse", "HEAD"])
|
|
565
|
+
: undefined;
|
|
566
|
+
return head !== undefined && head.status === 0 ? head.stdout.trim() : "missing";
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* One entry per pool record, with the worktree's current HEAD SHA, PLUS
|
|
570
|
+
* one per OPEN task that has no pool record beside it (M4-P19).
|
|
571
|
+
*
|
|
572
|
+
* The second group is what a reclaim leaves behind: tasks/ is tracked and
|
|
573
|
+
* survives, worktrees/ is gitignored and does not, so a task can be open
|
|
574
|
+
* with its record gone. Reporting only the first group makes those tasks
|
|
575
|
+
* invisible to `pool list` and to doctor, which is the state the plan
|
|
576
|
+
* calls a defect. Every such entry is marked, never silently blended in
|
|
577
|
+
* with the originals, and NOTHING here is written to disk.
|
|
578
|
+
*
|
|
579
|
+
* Closed tasks are excluded: a closed task is not in the pool, and
|
|
580
|
+
* listing every task this fleet ever finished as a missing worktree would
|
|
581
|
+
* make the report useless within a week.
|
|
582
|
+
*
|
|
583
|
+
* THIS FUNCTION OPENS NO SOCKET (M4-P19 fix round). It reads the
|
|
584
|
+
* filesystem and runs local git commands, and its reconstruction is
|
|
585
|
+
* asked for with `{ network: false }`. An entry whose default branch
|
|
586
|
+
* cannot be established from the clone alone is reported
|
|
587
|
+
* `unreconstructable (unresolved: branch)` rather than waited on. See
|
|
588
|
+
* `ReconstructOptions` for the measured hang that this closes.
|
|
589
|
+
*/
|
|
385
590
|
export function poolList(fleet) {
|
|
386
591
|
const entries = [];
|
|
592
|
+
const seen = new Set();
|
|
387
593
|
const names = readdirSync(fleet.worktreesDir)
|
|
388
594
|
.filter((name) => name.endsWith(".pool.json"))
|
|
389
595
|
.sort();
|
|
390
596
|
for (const name of names) {
|
|
391
597
|
const taskId = name.slice(0, -".pool.json".length);
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
598
|
+
seen.add(taskId);
|
|
599
|
+
entries.push({ taskId, headSha: headShaOf(fleet, taskId), origin: "record" });
|
|
600
|
+
}
|
|
601
|
+
/* AN UNLISTABLE tasks/ IS NOT AN ABSENCE OF TASKS (T-036's mechanism, and
|
|
602
|
+
src/commands/next.ts:40 states the standard: a category empty BY
|
|
603
|
+
CONSTRUCTION reported as empty BY OBSERVATION). This `catch` swallowed
|
|
604
|
+
it into `[]`, so `tiphys pool list` printed nothing and exited 0 and
|
|
605
|
+
doctor's CHECK worktrees printed the positive claim "no pool worktrees"
|
|
606
|
+
about a directory it had not read. It now reaches the caller through the
|
|
607
|
+
SAME channel the `readdirSync(fleet.worktreesDir)` above it already
|
|
608
|
+
uses, which src/commands/next.ts:334 already catches and reports into
|
|
609
|
+
its `unknown` list. */
|
|
610
|
+
const taskIds = readdirSync(fleet.tasksDir).sort();
|
|
611
|
+
for (const taskId of taskIds) {
|
|
612
|
+
if (seen.has(taskId) || !TASK_ID_PATTERN.test(taskId)) {
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
/* A task record that is PRESENT and did not READ is not a closed task.
|
|
616
|
+
`readTaskMeta` answers `undefined` for absent, unreadable and
|
|
617
|
+
unparseable alike, and dropping all three hid exactly the interrupted
|
|
618
|
+
spawn this listing exists to surface: with tasks/t-0001/meta.json
|
|
619
|
+
truncated mid-write, `pool list` printed NOTHING while doctor's CHECK
|
|
620
|
+
tasks reported the same task as open. The entry type is established
|
|
621
|
+
here rather than inferred from `readTaskMeta`'s one-word answer. */
|
|
622
|
+
const metaEntry = classifyPathEntry(metaPath(fleet, taskId));
|
|
623
|
+
const meta = readTaskMeta(fleet, taskId);
|
|
624
|
+
if (meta === undefined && metaEntry.kind !== "absent" && metaEntry.kind !== "dangling") {
|
|
625
|
+
entries.push({
|
|
626
|
+
taskId,
|
|
627
|
+
headSha: headShaOf(fleet, taskId),
|
|
628
|
+
origin: "unreconstructable",
|
|
629
|
+
unresolved: ["meta"],
|
|
630
|
+
});
|
|
631
|
+
continue;
|
|
632
|
+
}
|
|
633
|
+
if (meta === undefined || meta.status !== "open") {
|
|
634
|
+
continue;
|
|
635
|
+
}
|
|
636
|
+
// REPORTING, so NO NETWORK. `pool list` and doctor's CHECK worktrees
|
|
637
|
+
// both arrive here, and both must return. A task whose `branch`
|
|
638
|
+
// needs the network to resolve is reported `unreconstructable`,
|
|
639
|
+
// which is a true statement about what this path can establish.
|
|
640
|
+
const rebuilt = reconstructPoolRecord(fleet, taskId, { network: false });
|
|
641
|
+
if (rebuilt.kind === "absent") {
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
644
|
+
entries.push(rebuilt.kind === "complete"
|
|
645
|
+
? { taskId, headSha: headShaOf(fleet, taskId), origin: "reconstructed" }
|
|
646
|
+
: {
|
|
647
|
+
taskId,
|
|
648
|
+
headSha: headShaOf(fleet, taskId),
|
|
649
|
+
origin: "unreconstructable",
|
|
650
|
+
unresolved: rebuilt.unresolved,
|
|
651
|
+
});
|
|
402
652
|
}
|
|
403
653
|
return entries;
|
|
404
654
|
}
|
|
@@ -465,10 +715,15 @@ async function destroyGitStep(contextDir, args, worktree) {
|
|
|
465
715
|
return result;
|
|
466
716
|
}
|
|
467
717
|
/** Stage 1: gather facts. Read only; mutates nothing. */
|
|
468
|
-
async function resolveDestroy(fleet, taskId) {
|
|
718
|
+
async function resolveDestroy(fleet, taskId, reconstructed) {
|
|
469
719
|
const worktree = worktreePath(fleet, taskId);
|
|
470
720
|
const recordFile = recordPath(fleet, taskId);
|
|
471
|
-
const
|
|
721
|
+
const onDisk = readPoolRecord(fleet, taskId);
|
|
722
|
+
// The on-disk record always wins. The reconstruction is a FALLBACK, so
|
|
723
|
+
// a present-but-different record can never be overridden by one.
|
|
724
|
+
const record = onDisk ?? reconstructed;
|
|
725
|
+
// Deliberately the FILE, not the record: this is what authorizes the
|
|
726
|
+
// unlink in stage 3, and a reconstruction has no file to unlink.
|
|
472
727
|
const haveRecord = existsSync(recordFile);
|
|
473
728
|
const haveWorktree = existsSync(worktree);
|
|
474
729
|
if (!haveRecord && !haveWorktree) {
|
|
@@ -539,7 +794,19 @@ async function resolveDestroy(fleet, taskId) {
|
|
|
539
794
|
currentPath = line.slice("worktree ".length).trim();
|
|
540
795
|
}
|
|
541
796
|
else if (line.trim() === `branch refs/heads/${branchName}`) {
|
|
542
|
-
|
|
797
|
+
// IDENTITY, NOT STRING EQUALITY, and the difference is a shipped
|
|
798
|
+
// defect this comparison had until 2026-09-16 (macOS smoke job of
|
|
799
|
+
// pull request #155). `currentPath` is GIT'S spelling and `worktree`
|
|
800
|
+
// is THIS KERNEL'S: git canonicalizes every worktree path it records,
|
|
801
|
+
// so a fleet reached through a symlink makes the two sides different
|
|
802
|
+
// strings for one directory. `resolve` does not resolve symlinks, so
|
|
803
|
+
// the old comparison then reported the task's OWN worktree as a
|
|
804
|
+
// foreign worktree holding the branch, stage 2 refused the destroy
|
|
805
|
+
// that was never in danger, and spawn's rollback left the worktree,
|
|
806
|
+
// the pool record and the branch behind after a launch that never
|
|
807
|
+
// started. On macOS no unusual setup is needed to reach it: the
|
|
808
|
+
// platform puts `os.tmpdir()` behind `/var -> /private/var`.
|
|
809
|
+
if (currentPath !== undefined && !pathsNameSameObject(currentPath, worktree)) {
|
|
543
810
|
branchCheckedOutAt = currentPath;
|
|
544
811
|
}
|
|
545
812
|
}
|
|
@@ -706,7 +973,7 @@ export async function poolDestroy(fleet, options) {
|
|
|
706
973
|
return { ok: false, reason: `task id "${taskId}" is not a safe path segment` };
|
|
707
974
|
}
|
|
708
975
|
// Stage 1: read only.
|
|
709
|
-
const resolved = await resolveDestroy(fleet, taskId);
|
|
976
|
+
const resolved = await resolveDestroy(fleet, taskId, options.reconstructed);
|
|
710
977
|
if (!resolved.ok) {
|
|
711
978
|
return resolved;
|
|
712
979
|
}
|
package/dist/src/roles.d.ts
CHANGED
|
@@ -46,6 +46,37 @@ import type { Diagnostic } from "./validate.ts";
|
|
|
46
46
|
* three agree rather than trusting this comment.
|
|
47
47
|
*/
|
|
48
48
|
export declare const ROLE_IDS: readonly string[];
|
|
49
|
+
/**
|
|
50
|
+
* WHERE EACH ROLE'S BRIEF ACTUALLY SHIPS, relative to the kernel root.
|
|
51
|
+
*
|
|
52
|
+
* The vocabulary above has six members and `roles/` holds FIVE briefs. The
|
|
53
|
+
* sixth, the orchestrator's, ships as `AGENTS.md` at the package root, with
|
|
54
|
+
* `role: orchestrator` frontmatter, which roles/README.md states and
|
|
55
|
+
* package.json's `files` list carries. Until this constant existed, the
|
|
56
|
+
* composer resolved every id to `roles/<id>.md`, so
|
|
57
|
+
* `tiphys brief compose --role orchestrator` answered, against the PUBLISHED
|
|
58
|
+
* TARBALL, "role brief .../roles/orchestrator.md: ... does not exist" for a
|
|
59
|
+
* brief that is in the same tarball at a path the resolver never looked in.
|
|
60
|
+
* A consumer reads that as a broken install.
|
|
61
|
+
*
|
|
62
|
+
* This is a composition defect no per-phase review could have caught: M3-P5
|
|
63
|
+
* built the six-member resolver over `roles/<id>.md`, M3-P6 added files to
|
|
64
|
+
* that directory, and M3-P9 put the sixth brief somewhere else. Each is
|
|
65
|
+
* correct alone.
|
|
66
|
+
*
|
|
67
|
+
* DECLARED RATHER THAN DERIVED ON PURPOSE. A resolver that fell back to
|
|
68
|
+
* "look for <id>.md anywhere" would answer for a file that happened to be
|
|
69
|
+
* there, and the property wanted is the opposite: the package states where
|
|
70
|
+
* each declared role's brief IS, and test/sweep-exclusion-sync.test.ts turns
|
|
71
|
+
* a role added without one into a red test rather than a failure at use.
|
|
72
|
+
*/
|
|
73
|
+
export declare const ROLE_BRIEF_FILES: Readonly<Record<string, string>>;
|
|
74
|
+
/**
|
|
75
|
+
* The brief path for a role, relative to the kernel root, or undefined when
|
|
76
|
+
* the vocabulary declares a role the package does not ship. The second case
|
|
77
|
+
* is a REFUSAL the caller names, never a path that is then found missing.
|
|
78
|
+
*/
|
|
79
|
+
export declare function roleBriefFile(roleId: string): string | undefined;
|
|
49
80
|
/** The fence a role brief's YAML frontmatter block is delimited by. */
|
|
50
81
|
export declare const FRONTMATTER_FENCE = "---";
|
|
51
82
|
/** The include directive, on a line of its own. */
|
package/dist/src/roles.js
CHANGED
|
@@ -56,6 +56,48 @@ export const ROLE_IDS = [
|
|
|
56
56
|
"implementer",
|
|
57
57
|
"clean-room-reviewer",
|
|
58
58
|
];
|
|
59
|
+
/**
|
|
60
|
+
* WHERE EACH ROLE'S BRIEF ACTUALLY SHIPS, relative to the kernel root.
|
|
61
|
+
*
|
|
62
|
+
* The vocabulary above has six members and `roles/` holds FIVE briefs. The
|
|
63
|
+
* sixth, the orchestrator's, ships as `AGENTS.md` at the package root, with
|
|
64
|
+
* `role: orchestrator` frontmatter, which roles/README.md states and
|
|
65
|
+
* package.json's `files` list carries. Until this constant existed, the
|
|
66
|
+
* composer resolved every id to `roles/<id>.md`, so
|
|
67
|
+
* `tiphys brief compose --role orchestrator` answered, against the PUBLISHED
|
|
68
|
+
* TARBALL, "role brief .../roles/orchestrator.md: ... does not exist" for a
|
|
69
|
+
* brief that is in the same tarball at a path the resolver never looked in.
|
|
70
|
+
* A consumer reads that as a broken install.
|
|
71
|
+
*
|
|
72
|
+
* This is a composition defect no per-phase review could have caught: M3-P5
|
|
73
|
+
* built the six-member resolver over `roles/<id>.md`, M3-P6 added files to
|
|
74
|
+
* that directory, and M3-P9 put the sixth brief somewhere else. Each is
|
|
75
|
+
* correct alone.
|
|
76
|
+
*
|
|
77
|
+
* DECLARED RATHER THAN DERIVED ON PURPOSE. A resolver that fell back to
|
|
78
|
+
* "look for <id>.md anywhere" would answer for a file that happened to be
|
|
79
|
+
* there, and the property wanted is the opposite: the package states where
|
|
80
|
+
* each declared role's brief IS, and test/sweep-exclusion-sync.test.ts turns
|
|
81
|
+
* a role added without one into a red test rather than a failure at use.
|
|
82
|
+
*/
|
|
83
|
+
export const ROLE_BRIEF_FILES = {
|
|
84
|
+
orchestrator: "AGENTS.md",
|
|
85
|
+
investigator: "roles/investigator.md",
|
|
86
|
+
"plan-writer": "roles/plan-writer.md",
|
|
87
|
+
"adversarial-plan-reviewer": "roles/adversarial-plan-reviewer.md",
|
|
88
|
+
implementer: "roles/implementer.md",
|
|
89
|
+
"clean-room-reviewer": "roles/clean-room-reviewer.md",
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* The brief path for a role, relative to the kernel root, or undefined when
|
|
93
|
+
* the vocabulary declares a role the package does not ship. The second case
|
|
94
|
+
* is a REFUSAL the caller names, never a path that is then found missing.
|
|
95
|
+
*/
|
|
96
|
+
export function roleBriefFile(roleId) {
|
|
97
|
+
return Object.prototype.hasOwnProperty.call(ROLE_BRIEF_FILES, roleId)
|
|
98
|
+
? ROLE_BRIEF_FILES[roleId]
|
|
99
|
+
: undefined;
|
|
100
|
+
}
|
|
59
101
|
/** The fence a role brief's YAML frontmatter block is delimited by. */
|
|
60
102
|
export const FRONTMATTER_FENCE = "---";
|
|
61
103
|
/** The include directive, on a line of its own. */
|