@tiphys/kernel 0.0.0 → 0.1.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.
Files changed (181) hide show
  1. package/AGENTS.md +611 -0
  2. package/LICENSE +202 -0
  3. package/assurance-modes.yaml +278 -0
  4. package/checklists/clean-room.yaml +325 -0
  5. package/checklists/env-failure-diagnosis.yaml +68 -0
  6. package/checklists/flake-playbook.yaml +68 -0
  7. package/checklists/hazard-review.yaml +144 -0
  8. package/checklists/plan-review.yaml +103 -0
  9. package/dist/bin/tiphys.d.ts +2 -0
  10. package/dist/bin/tiphys.js +14 -0
  11. package/dist/src/brief.d.ts +32 -0
  12. package/dist/src/brief.js +59 -0
  13. package/dist/src/checklists.d.ts +169 -0
  14. package/dist/src/checklists.js +310 -0
  15. package/dist/src/checks.d.ts +828 -0
  16. package/dist/src/checks.js +3314 -0
  17. package/dist/src/cli.d.ts +9 -0
  18. package/dist/src/cli.js +57 -0
  19. package/dist/src/commands/brief.d.ts +92 -0
  20. package/dist/src/commands/brief.js +320 -0
  21. package/dist/src/commands/checklist.d.ts +42 -0
  22. package/dist/src/commands/checklist.js +168 -0
  23. package/dist/src/commands/doctor.d.ts +35 -0
  24. package/dist/src/commands/doctor.js +605 -0
  25. package/dist/src/commands/gates.d.ts +9 -0
  26. package/dist/src/commands/gates.js +360 -0
  27. package/dist/src/commands/init.d.ts +30 -0
  28. package/dist/src/commands/init.js +128 -0
  29. package/dist/src/commands/lock.d.ts +1 -0
  30. package/dist/src/commands/lock.js +229 -0
  31. package/dist/src/commands/mode.d.ts +40 -0
  32. package/dist/src/commands/mode.js +134 -0
  33. package/dist/src/commands/plan.d.ts +20 -0
  34. package/dist/src/commands/plan.js +105 -0
  35. package/dist/src/commands/pool.d.ts +1 -0
  36. package/dist/src/commands/pool.js +128 -0
  37. package/dist/src/commands/spawn.d.ts +1 -0
  38. package/dist/src/commands/spawn.js +146 -0
  39. package/dist/src/commands/status.d.ts +19 -0
  40. package/dist/src/commands/status.js +120 -0
  41. package/dist/src/commands/teardown.d.ts +1 -0
  42. package/dist/src/commands/teardown.js +79 -0
  43. package/dist/src/commands/tuition.d.ts +21 -0
  44. package/dist/src/commands/tuition.js +218 -0
  45. package/dist/src/commands/validate.d.ts +78 -0
  46. package/dist/src/commands/validate.js +360 -0
  47. package/dist/src/commands/watch.d.ts +1 -0
  48. package/dist/src/commands/watch.js +172 -0
  49. package/dist/src/exec/env.d.ts +128 -0
  50. package/dist/src/exec/env.js +190 -0
  51. package/dist/src/fleet.d.ts +51 -0
  52. package/dist/src/fleet.js +80 -0
  53. package/dist/src/gates/adapters/http-json.d.ts +5 -0
  54. package/dist/src/gates/adapters/http-json.js +283 -0
  55. package/dist/src/gates/adapters/migrations-command.d.ts +1 -0
  56. package/dist/src/gates/adapters/migrations-command.js +373 -0
  57. package/dist/src/gates/citations.d.ts +408 -0
  58. package/dist/src/gates/citations.js +1163 -0
  59. package/dist/src/gates/coverage.d.ts +284 -0
  60. package/dist/src/gates/coverage.js +701 -0
  61. package/dist/src/gates/credentials.d.ts +74 -0
  62. package/dist/src/gates/credentials.js +533 -0
  63. package/dist/src/gates/deploy.d.ts +1 -0
  64. package/dist/src/gates/deploy.js +33 -0
  65. package/dist/src/gates/manifest.d.ts +99 -0
  66. package/dist/src/gates/manifest.js +208 -0
  67. package/dist/src/gates/migrations.d.ts +1 -0
  68. package/dist/src/gates/migrations.js +36 -0
  69. package/dist/src/gates/pin.d.ts +114 -0
  70. package/dist/src/gates/pin.js +154 -0
  71. package/dist/src/gates/red-witness.d.ts +22 -0
  72. package/dist/src/gates/red-witness.js +390 -0
  73. package/dist/src/gates/release.d.ts +283 -0
  74. package/dist/src/gates/release.js +820 -0
  75. package/dist/src/gates/result.d.ts +116 -0
  76. package/dist/src/gates/result.js +91 -0
  77. package/dist/src/gates/run.d.ts +566 -0
  78. package/dist/src/gates/run.js +1536 -0
  79. package/dist/src/gates/schemas/citation-config.schema.json +59 -0
  80. package/dist/src/gates/schemas/coverage-config.schema.json +77 -0
  81. package/dist/src/gates/schemas/gate-manifest.schema.json +125 -0
  82. package/dist/src/gates/schemas/gate-result.schema.json +160 -0
  83. package/dist/src/gates/schemas/phase-declaration.schema.json +42 -0
  84. package/dist/src/gates/schemas/release-record.schema.json +119 -0
  85. package/dist/src/gates/schemas/verifier-config.schema.json +101 -0
  86. package/dist/src/gates/schemas/witness-spec.schema.json +110 -0
  87. package/dist/src/gates/scope.d.ts +131 -0
  88. package/dist/src/gates/scope.js +1018 -0
  89. package/dist/src/gates/suite.d.ts +217 -0
  90. package/dist/src/gates/suite.js +927 -0
  91. package/dist/src/gates/validate.d.ts +121 -0
  92. package/dist/src/gates/validate.js +414 -0
  93. package/dist/src/hooks.d.ts +32 -0
  94. package/dist/src/hooks.js +62 -0
  95. package/dist/src/liveness.d.ts +321 -0
  96. package/dist/src/liveness.js +396 -0
  97. package/dist/src/lock.d.ts +178 -0
  98. package/dist/src/lock.js +500 -0
  99. package/dist/src/modes.d.ts +149 -0
  100. package/dist/src/modes.js +258 -0
  101. package/dist/src/path-identity.d.ts +2 -0
  102. package/dist/src/path-identity.js +10 -0
  103. package/dist/src/plan.d.ts +73 -0
  104. package/dist/src/plan.js +153 -0
  105. package/dist/src/pool.d.ts +130 -0
  106. package/dist/src/pool.js +721 -0
  107. package/dist/src/roles.d.ts +430 -0
  108. package/dist/src/roles.js +734 -0
  109. package/dist/src/spawn.d.ts +177 -0
  110. package/dist/src/spawn.js +332 -0
  111. package/dist/src/status.d.ts +91 -0
  112. package/dist/src/status.js +119 -0
  113. package/dist/src/task.d.ts +264 -0
  114. package/dist/src/task.js +305 -0
  115. package/dist/src/teardown.d.ts +32 -0
  116. package/dist/src/teardown.js +314 -0
  117. package/dist/src/tuition.d.ts +159 -0
  118. package/dist/src/tuition.js +311 -0
  119. package/dist/src/validate.d.ts +230 -0
  120. package/dist/src/validate.js +732 -0
  121. package/dist/src/version.d.ts +3 -0
  122. package/dist/src/version.js +38 -0
  123. package/dist/src/watcher.d.ts +275 -0
  124. package/dist/src/watcher.js +859 -0
  125. package/dist/src/witness/run.d.ts +274 -0
  126. package/dist/src/witness/run.js +1327 -0
  127. package/dist/src/witness/spec.d.ts +102 -0
  128. package/dist/src/witness/spec.js +253 -0
  129. package/dist/tsconfig.src.tsbuildinfo +1 -0
  130. package/gate-registry.yaml +390 -0
  131. package/gates.manifest.json +195 -0
  132. package/package.json +57 -3
  133. package/role-model-config.yaml +88 -0
  134. package/roles/README.md +128 -0
  135. package/roles/_shared-dispatch-contract.md +87 -0
  136. package/roles/adversarial-plan-reviewer.md +80 -0
  137. package/roles/clean-room-reviewer.md +140 -0
  138. package/roles/implementer.md +460 -0
  139. package/roles/investigator.md +138 -0
  140. package/roles/plan-writer.md +95 -0
  141. package/schemas/README.md +81 -0
  142. package/schemas/assurance-modes.schema.json +264 -0
  143. package/schemas/charter.schema.json +166 -0
  144. package/schemas/checklist.schema.json +114 -0
  145. package/schemas/decision-record.schema.json +88 -0
  146. package/schemas/final-report.schema.json +90 -0
  147. package/schemas/finding.schema.json +106 -0
  148. package/schemas/gate-registry.schema.json +260 -0
  149. package/schemas/mechanism-index.schema.json +94 -0
  150. package/schemas/plan.schema.json +300 -0
  151. package/schemas/report.schema.json +579 -0
  152. package/schemas/role-brief.schema.json +105 -0
  153. package/schemas/role-model-config.schema.json +90 -0
  154. package/schemas/status-line.schema.json +40 -0
  155. package/schemas/tuition.schema.json +191 -0
  156. package/schemas/verdict.schema.json +289 -0
  157. package/schemas/work-history.schema.json +183 -0
  158. package/templates/charter.example.yaml +54 -0
  159. package/templates/decision-record.example.yaml +27 -0
  160. package/templates/final-report.example.yaml +80 -0
  161. package/templates/plan.example.yaml +87 -0
  162. package/templates/report.example.yaml +236 -0
  163. package/templates/warnings.md +74 -0
  164. package/templates/work-history.example.yaml +185 -0
  165. package/tuition/README.md +76 -0
  166. package/tuition/T-001.yaml +48 -0
  167. package/tuition/T-002.yaml +51 -0
  168. package/tuition/T-003.yaml +100 -0
  169. package/tuition/T-004.yaml +52 -0
  170. package/tuition/T-005.yaml +72 -0
  171. package/tuition/T-006.yaml +81 -0
  172. package/tuition/T-007.yaml +56 -0
  173. package/tuition/T-008.yaml +111 -0
  174. package/tuition/T-009.yaml +50 -0
  175. package/tuition/T-015.yaml +36 -0
  176. package/tuition/T-016.yaml +36 -0
  177. package/tuition/T-017.yaml +46 -0
  178. package/tuition/T-018.yaml +84 -0
  179. package/tuition/T-021.yaml +40 -0
  180. package/tuition/T-022.yaml +36 -0
  181. package/tuition/mechanism-index.yaml +256 -0
@@ -0,0 +1,721 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
3
+ import { join, resolve } from "node:path";
4
+ import { setTimeout as sleep } from "node:timers/promises";
5
+ /**
6
+ * Worktree pool over a project clone (kernel plan v1, M1-P3 step 3).
7
+ * BUILD from the contract (plan decision D-1, FM-026): a clean disposable
8
+ * worktree per task at <fleet>/worktrees/<task-id>, parallel-safe through
9
+ * unique paths, O_EXCL record creation, and git worktree add's own
10
+ * locking. That safety is claimed only at the width M1 actually uses:
11
+ * criterion 15's two concurrent creates, which are witnessed. This
12
+ * phase's own verification measured failures above roughly six-way
13
+ * concurrency on both the fetch and the worktree add, and hardening
14
+ * for that width is deferred to M5 (see the deferral list in
15
+ * delivery/work-history/m1-p3.md). Do not read this as a guarantee at
16
+ * arbitrary concurrency. Substrate-neutral: pure filesystem and git
17
+ * (DR-0007).
18
+ *
19
+ * Base resolution is the five binding steps of EXT-F-03: resolve the
20
+ * project's configured remote and its default branch, fetch that branch,
21
+ * record the fetched base SHA in the pool record (and the CLI emits it on
22
+ * stdout; M1-P4 spawn copies it into tasks/<id>/meta.json as baseSha),
23
+ * create the task branch and worktree directly from that exact SHA, and
24
+ * on fetch failure fail rather than silently use a stale local branch,
25
+ * unless --offline was explicitly passed, in which case the last fetched
26
+ * remote-tracking SHA is used and offline: true is recorded. The clone's
27
+ * local branches are never consulted: a stale local branch is never the
28
+ * base, whether behind or ahead of the remote.
29
+ *
30
+ * The pool record lives BESIDE the worktree (worktrees/<task-id>.pool.json,
31
+ * plain JSON per D-3), never inside it, so the record can never dirty the
32
+ * destroy-time cleanliness check (FM-059: no exemption list, ever).
33
+ *
34
+ * Destroy refuses a dirty worktree (uncommitted changes or untracked
35
+ * files) unless --discard, which is reserved for the teardown scout path
36
+ * (PR-010). A transient git index.lock during destroy is retried; the
37
+ * lock file is removed only under a fail-safe staleness proof (provably
38
+ * no holder via lsof plus mtime age beyond a threshold; any uncertainty
39
+ * means leave it and fail loudly), per FM-036 and FM-051.
40
+ */
41
+ /** Task branch created by the pool at the fetched base SHA. */
42
+ export function taskBranchName(taskId) {
43
+ return `task/${taskId}`;
44
+ }
45
+ /** Task ids are single safe path segments. */
46
+ export const TASK_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
47
+ function runGit(cwd, args) {
48
+ const result = spawnSync("git", ["-C", cwd, ...args], {
49
+ encoding: "utf8",
50
+ // The transient-contention classification below reads git's English
51
+ // message text, so the locale is pinned rather than inherited
52
+ // (U-8). Without this a translated git silently stops retrying.
53
+ env: { ...process.env, LC_ALL: "C", LANG: "C" },
54
+ });
55
+ if (result.error !== undefined) {
56
+ return { status: null, stdout: "", stderr: String(result.error) };
57
+ }
58
+ return {
59
+ status: result.status,
60
+ stdout: result.stdout ?? "",
61
+ stderr: result.stderr ?? "",
62
+ };
63
+ }
64
+ /**
65
+ * The ONE signature of transient git contention, derived from real
66
+ * captured stderr rather than from a general rule about git. Every
67
+ * alternative below was observed in a real race on git 2.43 and proved
68
+ * transient by an immediate retry succeeding:
69
+ *
70
+ * 1. A held lock file, which names the lock path: "Unable to create
71
+ * '<path>.lock': File exists." (index locks and ref locks alike).
72
+ * 2. A refused ref transaction under concurrent update, which names NO
73
+ * lock file: "cannot lock ref '<ref>': is at <sha> but expected
74
+ * <sha>". This is the dominant transient of concurrent fetches of
75
+ * the same tracking ref.
76
+ * 3. Two concurrent worktree operations colliding on a half-written
77
+ * admin file: "bad object worktrees/<id>/HEAD" on a fetch, and
78
+ * "failed to read .git/worktrees/<id>/commondir: Success" on an add.
79
+ *
80
+ * One signature is used at every call site. An earlier revision split
81
+ * these by call site, reasoning about which path was allowed to retry
82
+ * which shape; that distinction existed only to protect a rollback that
83
+ * no longer exists, and the splitting itself produced defects. If the
84
+ * add's retry finds the branch already there, create refuses with its
85
+ * existing clear message, which is the correct outcome.
86
+ *
87
+ * The bare phrases "File exists" and "cannot lock ref" must NOT be used
88
+ * on their own: permanent failures emit them too. A directory/file ref
89
+ * conflict reports "cannot lock ref 'refs/x': 'refs/x/y' exists; cannot
90
+ * create 'refs/x'", which is permanent, and retrying it only wastes
91
+ * about a second before the same error surfaces.
92
+ *
93
+ * Known trade, stated rather than hidden (U-11): the worktrees admin
94
+ * shapes also match a permanent condition (an admin HEAD holding a
95
+ * nonexistent object), indistinguishable by message, so that
96
+ * out-of-contract case burns all attempts and fails in about 1.25s
97
+ * instead of about 0.29s.
98
+ *
99
+ * Maintenance rule, learned the hard way: this classification is
100
+ * message-text-only. Do not re-derive it from a plausible general
101
+ * property of git; a previous revision assumed "a genuine transient
102
+ * always names a lock file", which is false, and parallel pool create
103
+ * began failing hard. Capture real stderr from a real race first.
104
+ */
105
+ const GIT_CONTENTION = /index\.lock|Unable to create '[^']*\.lock'|cannot lock ref '[^']*': (is at [0-9a-f]+ but expected|reference already exists|reference is missing but expected)|bad object worktrees\/|failed to read .*worktrees\/[^/]*\/commondir/;
106
+ /** Exported for the contention-classification test. */
107
+ export function isTransientGitLockError(stderr) {
108
+ return GIT_CONTENTION.test(stderr);
109
+ }
110
+ async function runGitRetrying(cwd, args, attempts = 5) {
111
+ let result = runGit(cwd, args);
112
+ for (let attempt = 1; attempt < attempts; attempt += 1) {
113
+ if (result.status === 0 || !GIT_CONTENTION.test(result.stderr)) {
114
+ return result;
115
+ }
116
+ await sleep(100 * attempt);
117
+ result = runGit(cwd, args);
118
+ }
119
+ return result;
120
+ }
121
+ export function recordPath(fleet, taskId) {
122
+ return join(fleet.worktreesDir, `${taskId}.pool.json`);
123
+ }
124
+ export function worktreePath(fleet, taskId) {
125
+ return join(fleet.worktreesDir, taskId);
126
+ }
127
+ export function readPoolRecord(fleet, taskId) {
128
+ try {
129
+ return JSON.parse(readFileSync(recordPath(fleet, taskId), "utf8"));
130
+ }
131
+ catch {
132
+ return undefined;
133
+ }
134
+ }
135
+ /**
136
+ * EXT-F-03 step 1: resolve the project's configured remote. origin when
137
+ * present; otherwise the single configured remote; otherwise fail.
138
+ */
139
+ function resolveRemote(project) {
140
+ const result = runGit(project, ["remote"]);
141
+ if (result.status !== 0) {
142
+ return { ok: false, reason: `${project} is not a git repository (git remote failed: ${result.stderr.trim()})` };
143
+ }
144
+ const remotes = result.stdout.split("\n").filter((line) => line !== "");
145
+ if (remotes.includes("origin")) {
146
+ return { ok: true, value: "origin" };
147
+ }
148
+ if (remotes.length === 1) {
149
+ return { ok: true, value: remotes[0] };
150
+ }
151
+ if (remotes.length === 0) {
152
+ return { ok: false, reason: `${project} has no configured remote` };
153
+ }
154
+ return {
155
+ ok: false,
156
+ reason: `${project} has ${String(remotes.length)} remotes and none is origin; cannot pick one`,
157
+ };
158
+ }
159
+ /**
160
+ * EXT-F-03 step 1: resolve the remote's default branch. origin/HEAD when
161
+ * set locally (no network); otherwise the remote's advertised default
162
+ * via ls-remote --symref. The clone's own HEAD is never consulted, so a
163
+ * detached HEAD in the clone is irrelevant.
164
+ */
165
+ function resolveDefaultBranch(project, remote) {
166
+ const local = runGit(project, [
167
+ "symbolic-ref",
168
+ "--quiet",
169
+ `refs/remotes/${remote}/HEAD`,
170
+ ]);
171
+ if (local.status === 0) {
172
+ const ref = local.stdout.trim();
173
+ const prefix = `refs/remotes/${remote}/`;
174
+ if (ref.startsWith(prefix)) {
175
+ return { ok: true, value: ref.slice(prefix.length) };
176
+ }
177
+ }
178
+ const advertised = runGit(project, ["ls-remote", "--symref", remote, "HEAD"]);
179
+ if (advertised.status === 0) {
180
+ const match = /^ref:\s+refs\/heads\/(\S+)\s+HEAD$/m.exec(advertised.stdout);
181
+ if (match !== null) {
182
+ return { ok: true, value: match[1] };
183
+ }
184
+ return {
185
+ ok: false,
186
+ reason: `remote ${remote} did not advertise a default branch (no symref HEAD)`,
187
+ };
188
+ }
189
+ return {
190
+ ok: false,
191
+ reason: `cannot resolve the default branch of remote ${remote}: ` +
192
+ `${remote}/HEAD is unset locally and ls-remote failed: ${advertised.stderr.trim()}`,
193
+ };
194
+ }
195
+ /**
196
+ * pool create (EXT-F-03 five steps; see module doc). Returns the pool
197
+ * record on success.
198
+ *
199
+ * On failure NOTHING IS REMOVED. The pool record, the worktree
200
+ * directory and the task branch may each survive, depending on how far
201
+ * the attempt got, and the reason line names exactly which of them did
202
+ * and the command that clears them. The automatic rollback this
203
+ * docstring used to promise was deleted deliberately: it served a
204
+ * concurrent-create path M1 never enters (parallelism is off until M5)
205
+ * and produced four consecutive rounds of defects, including deleting
206
+ * state it had not validated. Failing loudly and leaving state is the
207
+ * chosen contract, not an oversight.
208
+ */
209
+ export async function poolCreate(fleet, options) {
210
+ const { taskId, offline } = options;
211
+ if (!TASK_ID_PATTERN.test(taskId)) {
212
+ return { ok: false, reason: `task id "${taskId}" is not a safe path segment` };
213
+ }
214
+ const project = resolve(options.project);
215
+ const record = recordPath(fleet, taskId);
216
+ const worktree = worktreePath(fleet, taskId);
217
+ if (existsSync(record) || existsSync(worktree)) {
218
+ return { ok: false, reason: `task id already used: ${taskId}` };
219
+ }
220
+ // CR-201: a leftover task branch would otherwise surface as a raw git
221
+ // error from deep inside worktree add. Destroy now removes the branch
222
+ // it created, so this only fires when the branch came from elsewhere.
223
+ const branchName = taskBranchName(taskId);
224
+ const existing = runGit(project, [
225
+ "rev-parse",
226
+ "--verify",
227
+ "--quiet",
228
+ `refs/heads/${branchName}`,
229
+ ]);
230
+ if (existing.status === 0) {
231
+ return {
232
+ ok: false,
233
+ reason: `branch ${branchName} already exists in ${project}; the pool creates ` +
234
+ `it fresh, so delete or rename it before re-using task id ${taskId}`,
235
+ };
236
+ }
237
+ // U-12: the opposite collision direction. A ref UNDER
238
+ // refs/heads/task/<id>/ (for example task/foo/bar when creating
239
+ // task/foo) makes the branch equally uncreatable, and the exact-ref
240
+ // and strict-prefix checks both miss it, so it used to reach git
241
+ // worktree add and surface as the raw two-line git error the
242
+ // pre-check exists to eliminate.
243
+ const nested = runGit(project, [
244
+ "for-each-ref",
245
+ "--count=1",
246
+ "--format=%(refname)",
247
+ `refs/heads/${branchName}/`,
248
+ ]);
249
+ if (nested.status === 0 && nested.stdout.trim() !== "") {
250
+ return {
251
+ ok: false,
252
+ reason: `ref ${nested.stdout.trim()} already exists in ${project} and blocks ` +
253
+ `${branchName} (a ref cannot be both a branch and a directory); ` +
254
+ `delete or rename it before using task id ${taskId}`,
255
+ };
256
+ }
257
+ // U-7: the exact ref can be absent while the branch is still
258
+ // uncreatable, because any strict prefix of refs/heads/task/<id>
259
+ // existing as a ref is a directory/file conflict. Catch it here
260
+ // instead of after a full network fetch, inside git worktree add.
261
+ const segments = branchName.split("/");
262
+ for (let i = 1; i < segments.length; i += 1) {
263
+ const prefix = segments.slice(0, i).join("/");
264
+ const conflict = runGit(project, [
265
+ "rev-parse",
266
+ "--verify",
267
+ "--quiet",
268
+ `refs/heads/${prefix}`,
269
+ ]);
270
+ if (conflict.status === 0) {
271
+ return {
272
+ ok: false,
273
+ reason: `branch ${prefix} already exists in ${project} and blocks ` +
274
+ `${branchName} (a ref cannot be both a branch and a directory); ` +
275
+ `delete or rename it before using task id ${taskId}`,
276
+ };
277
+ }
278
+ }
279
+ const remote = resolveRemote(project);
280
+ if (!remote.ok) {
281
+ return remote;
282
+ }
283
+ const branch = resolveDefaultBranch(project, remote.value);
284
+ if (!branch.ok) {
285
+ return branch;
286
+ }
287
+ const trackingRef = `refs/remotes/${remote.value}/${branch.value}`;
288
+ // EXT-F-03 step 2: fetch the default branch, force-updating exactly
289
+ // the remote-tracking ref (so a rewound remote is still mirrored).
290
+ let usedOffline = false;
291
+ const fetch = await runGitRetrying(project, [
292
+ "fetch",
293
+ remote.value,
294
+ `+refs/heads/${branch.value}:${trackingRef}`,
295
+ ]);
296
+ if (fetch.status !== 0) {
297
+ if (!offline) {
298
+ return {
299
+ ok: false,
300
+ reason: `fetch of ${remote.value}/${branch.value} failed and --offline was ` +
301
+ `not passed; refusing to base work on a stale ref: ${fetch.stderr.trim()}`,
302
+ };
303
+ }
304
+ usedOffline = true;
305
+ }
306
+ // EXT-F-03 step 3: the base SHA is the (just) fetched remote-tracking
307
+ // ref, never a local branch.
308
+ const base = runGit(project, ["rev-parse", "--verify", `${trackingRef}^{commit}`]);
309
+ if (base.status !== 0) {
310
+ return {
311
+ ok: false,
312
+ reason: usedOffline
313
+ ? `--offline was passed but ${trackingRef} has never been fetched; nothing to base on`
314
+ : `cannot resolve ${trackingRef} after fetch: ${base.stderr.trim()}`,
315
+ };
316
+ }
317
+ const baseSha = base.stdout.trim();
318
+ const poolRecord = {
319
+ taskId,
320
+ project,
321
+ remote: remote.value,
322
+ branch: branch.value,
323
+ baseSha,
324
+ branchName,
325
+ offline: usedOffline,
326
+ createdAt: new Date().toISOString(),
327
+ };
328
+ // O_EXCL record reservation: the atomic duplicate gate for concurrent
329
+ // creates with the same task id.
330
+ try {
331
+ writeFileSync(record, `${JSON.stringify(poolRecord, null, 2)}\n`, {
332
+ flag: "wx",
333
+ });
334
+ }
335
+ catch (error) {
336
+ if (error.code === "EEXIST") {
337
+ return { ok: false, reason: `task id already used: ${taskId}` };
338
+ }
339
+ throw error;
340
+ }
341
+ // EXT-F-03 step 4: task branch and worktree directly from the exact
342
+ // fetched SHA. Transient contention is retried by the one signature.
343
+ //
344
+ // There is deliberately NO automatic cleanup on failure. Parallelism
345
+ // is off until M5 (plan section 1.4), so nothing in M1 drives the
346
+ // concurrent-create path this machinery existed for, and four rounds
347
+ // of defects came out of it: a global prune that could kill another
348
+ // create, an unvalidated recursive delete of an admin directory, and
349
+ // a rollback that broke under the very contention it existed to
350
+ // handle. Failing loudly and leaving state is strictly better than
351
+ // cleanup code that deletes the wrong thing. The operator is told
352
+ // exactly what was left and exactly how to clear it.
353
+ const add = await runGitRetrying(project, [
354
+ "worktree",
355
+ "add",
356
+ "-b",
357
+ poolRecord.branchName,
358
+ worktree,
359
+ baseSha,
360
+ ]);
361
+ if (add.status !== 0) {
362
+ const leftovers = [`pool record ${record}`];
363
+ if (existsSync(worktree)) {
364
+ leftovers.push(`worktree directory ${worktree}`);
365
+ }
366
+ const branchProbe = runGit(project, [
367
+ "rev-parse",
368
+ "--verify",
369
+ "--quiet",
370
+ `refs/heads/${poolRecord.branchName}^{commit}`,
371
+ ]);
372
+ if (branchProbe.status === 0) {
373
+ leftovers.push(`branch ${poolRecord.branchName}`);
374
+ }
375
+ return {
376
+ ok: false,
377
+ reason: `git worktree add failed: ${add.stderr.trim()}; nothing was removed, ` +
378
+ `left behind: ${leftovers.join(", ")}; clear it with "tiphys pool ` +
379
+ `destroy --task ${taskId} --discard --delete-branch-force" run in the fleet home`,
380
+ };
381
+ }
382
+ return { ok: true, value: poolRecord };
383
+ }
384
+ /** One entry per pool record, with the worktree's current HEAD SHA. */
385
+ export function poolList(fleet) {
386
+ const entries = [];
387
+ const names = readdirSync(fleet.worktreesDir)
388
+ .filter((name) => name.endsWith(".pool.json"))
389
+ .sort();
390
+ for (const name of names) {
391
+ const taskId = name.slice(0, -".pool.json".length);
392
+ const worktree = worktreePath(fleet, taskId);
393
+ const head = existsSync(worktree)
394
+ ? runGit(worktree, ["rev-parse", "HEAD"])
395
+ : undefined;
396
+ entries.push({
397
+ taskId,
398
+ headSha: head !== undefined && head.status === 0
399
+ ? head.stdout.trim()
400
+ : "missing",
401
+ });
402
+ }
403
+ return entries;
404
+ }
405
+ function defaultLsof(path) {
406
+ const result = spawnSync("lsof", ["-t", "--", path], { encoding: "utf8" });
407
+ if (result.error !== undefined) {
408
+ return { available: false, exitCode: null, stdout: "" };
409
+ }
410
+ return {
411
+ available: true,
412
+ exitCode: result.status,
413
+ stdout: result.stdout ?? "",
414
+ };
415
+ }
416
+ /** Age a lock file must reach before a staleness proof is even considered. */
417
+ export const STALE_LOCK_AGE_MS = 300_000;
418
+ /**
419
+ * Fail-safe staleness proof for a git lock file (FM-036, FM-051): true
420
+ * only when the lock exists, its mtime age exceeds the threshold, and
421
+ * lsof is available and shows provably no holder (exit 1, empty stdout).
422
+ * Any uncertainty (lsof missing, erroring, or listing holders) is false:
423
+ * the lock is left in place and the operation fails loudly.
424
+ */
425
+ export function provablyStaleLock(lockFile, opts = {}) {
426
+ const nowMs = opts.nowMs ?? Date.now();
427
+ const threshold = opts.ageThresholdMs ?? STALE_LOCK_AGE_MS;
428
+ const probe = opts.runLsof ?? defaultLsof;
429
+ let mtimeMs;
430
+ try {
431
+ mtimeMs = statSync(lockFile).mtimeMs;
432
+ }
433
+ catch {
434
+ return false;
435
+ }
436
+ if (nowMs - mtimeMs <= threshold) {
437
+ return false;
438
+ }
439
+ const lsof = probe(lockFile);
440
+ if (!lsof.available || lsof.exitCode !== 1 || lsof.stdout.trim() !== "") {
441
+ return false;
442
+ }
443
+ return true;
444
+ }
445
+ async function destroyGitStep(contextDir, args, worktree) {
446
+ let result = await runGitRetrying(contextDir, args);
447
+ if (result.status !== 0 && GIT_CONTENTION.test(result.stderr)) {
448
+ // Retries exhausted on a lock signature: attempt the fail-safe
449
+ // staleness proof on the worktree's index.lock, the one transient
450
+ // lock a destroy can legitimately hit (FM-036).
451
+ const gitDir = runGit(worktree, ["rev-parse", "--absolute-git-dir"]);
452
+ if (gitDir.status === 0) {
453
+ const indexLock = join(gitDir.stdout.trim(), "index.lock");
454
+ if (provablyStaleLock(indexLock)) {
455
+ try {
456
+ unlinkSync(indexLock);
457
+ }
458
+ catch {
459
+ // Already gone; retry either way.
460
+ }
461
+ result = await runGitRetrying(contextDir, args);
462
+ }
463
+ }
464
+ }
465
+ return result;
466
+ }
467
+ /** Stage 1: gather facts. Read only; mutates nothing. */
468
+ async function resolveDestroy(fleet, taskId) {
469
+ const worktree = worktreePath(fleet, taskId);
470
+ const recordFile = recordPath(fleet, taskId);
471
+ const record = readPoolRecord(fleet, taskId);
472
+ const haveRecord = existsSync(recordFile);
473
+ const haveWorktree = existsSync(worktree);
474
+ if (!haveRecord && !haveWorktree) {
475
+ return { ok: false, reason: `no pool worktree for task id ${taskId}` };
476
+ }
477
+ // The git context must be resolved while the worktree still exists,
478
+ // because the worktree is one of only two ways to find it.
479
+ let contextDir;
480
+ if (record !== undefined && existsSync(record.project)) {
481
+ contextDir = record.project;
482
+ }
483
+ else if (haveWorktree) {
484
+ const common = runGit(worktree, [
485
+ "rev-parse",
486
+ "--path-format=absolute",
487
+ "--git-common-dir",
488
+ ]);
489
+ if (common.status === 0 && common.stdout.trim() !== "") {
490
+ contextDir = common.stdout.trim();
491
+ }
492
+ }
493
+ if (contextDir === undefined) {
494
+ return {
495
+ ok: false,
496
+ reason: `cannot resolve the project repository for task id ${taskId}: the ` +
497
+ `pool record ${recordFile} is missing or unreadable and there is no ` +
498
+ `usable worktree. Nothing was removed; repair or delete that record ` +
499
+ `file to release the id`,
500
+ };
501
+ }
502
+ let dirty;
503
+ let dirtyProbeError;
504
+ if (haveWorktree) {
505
+ const status = await destroyGitStep(worktree, ["status", "--porcelain"], worktree);
506
+ if (status.status !== 0) {
507
+ dirtyProbeError = status.stderr.trim();
508
+ }
509
+ else {
510
+ dirty = status.stdout.trim() !== "";
511
+ }
512
+ }
513
+ const branchName = record?.branchName ?? taskBranchName(taskId);
514
+ const tip = runGit(contextDir, [
515
+ "rev-parse",
516
+ "--verify",
517
+ "--quiet",
518
+ `refs/heads/${branchName}^{commit}`,
519
+ ]);
520
+ let branchTip;
521
+ if (tip.status === 0) {
522
+ branchTip = { kind: "present", sha: tip.stdout.trim() };
523
+ }
524
+ else if (tip.status === 1) {
525
+ branchTip = { kind: "absent" };
526
+ }
527
+ else {
528
+ branchTip = {
529
+ kind: "indeterminate",
530
+ detail: tip.stderr.trim() === "" ? `git exited ${String(tip.status)}` : tip.stderr.trim(),
531
+ };
532
+ }
533
+ let branchCheckedOutAt;
534
+ const listed = runGit(contextDir, ["worktree", "list", "--porcelain"]);
535
+ if (listed.status === 0) {
536
+ let currentPath;
537
+ for (const line of listed.stdout.split("\n")) {
538
+ if (line.startsWith("worktree ")) {
539
+ currentPath = line.slice("worktree ".length).trim();
540
+ }
541
+ else if (line.trim() === `branch refs/heads/${branchName}`) {
542
+ if (currentPath !== undefined && resolve(currentPath) !== resolve(worktree)) {
543
+ branchCheckedOutAt = currentPath;
544
+ }
545
+ }
546
+ }
547
+ }
548
+ return {
549
+ ok: true,
550
+ value: {
551
+ taskId,
552
+ contextDir,
553
+ record,
554
+ recordFile,
555
+ haveRecord,
556
+ worktree,
557
+ haveWorktree,
558
+ dirty,
559
+ dirtyProbeError,
560
+ branchName,
561
+ branchTip,
562
+ branchCheckedOutAt,
563
+ },
564
+ };
565
+ }
566
+ /**
567
+ * Stage 2: decide. Pure over the facts, no IO, so no gate can destroy
568
+ * anything. Returns a refusal reason, or undefined to proceed.
569
+ */
570
+ function evaluateDestroy(facts, options) {
571
+ if (facts.haveWorktree && !options.discard) {
572
+ if (facts.dirtyProbeError !== undefined) {
573
+ return `cannot verify worktree cleanliness: ${facts.dirtyProbeError}`;
574
+ }
575
+ if (facts.dirty === true) {
576
+ return (`worktree ${facts.worktree} has uncommitted changes or untracked ` +
577
+ `files; commit or land them first, or pass --discard to remove anyway`);
578
+ }
579
+ }
580
+ if (facts.branchTip.kind === "indeterminate") {
581
+ return (`cannot determine whether branch ${facts.branchName} exists in ` +
582
+ `${facts.contextDir} (${facts.branchTip.detail}); refusing to finish ` +
583
+ `destroy for task id ${facts.taskId}`);
584
+ }
585
+ if (facts.branchTip.kind === "present" &&
586
+ facts.branchCheckedOutAt !== undefined) {
587
+ // Policy, and therefore a stage-2 refusal: a true no-op. Deleting a
588
+ // branch another worktree has checked out would strand it on a
589
+ // dangling HEAD. git branch -D enforces this; update-ref, which
590
+ // stage 3 uses for its atomic compare-and-delete, does not, so the
591
+ // rule is enforced here rather than relied on downstream.
592
+ return (`cannot delete branch ${facts.branchName}: it is checked out at ` +
593
+ `${facts.branchCheckedOutAt}; remove that worktree first`);
594
+ }
595
+ if (facts.branchTip.kind === "present" && !options.deleteBranchForce) {
596
+ const baseSha = facts.record?.baseSha;
597
+ if (baseSha === undefined) {
598
+ return (`cannot verify branch ${facts.branchName} against its recorded base ` +
599
+ `(pool record missing or unreadable), tip ${facts.branchTip.sha}; land ` +
600
+ `it or pass --delete-branch-force to delete it anyway`);
601
+ }
602
+ if (facts.branchTip.sha !== baseSha) {
603
+ return (`branch ${facts.branchName} carries commits beyond its base ` +
604
+ `${baseSha} (tip ${facts.branchTip.sha}); land them or pass ` +
605
+ `--delete-branch-force to delete it anyway`);
606
+ }
607
+ }
608
+ return undefined;
609
+ }
610
+ /**
611
+ * Stage 3: perform. Reached only with every gate passed, so there is
612
+ * nothing left to refuse; failures here are operational, not policy.
613
+ */
614
+ async function applyDestroy(facts, options) {
615
+ if (facts.haveWorktree) {
616
+ // F-1: --discard means "remove anyway" (plan step 3), and git's own
617
+ // documented way to say that for a LOCKED working tree is a second
618
+ // --force. An interrupted git worktree add leaves
619
+ // .git/worktrees/<id>/locked = "initializing", which a single
620
+ // --force refuses, so the remedy this kernel prints on a failed
621
+ // create used to exit 1 and clear nothing, wedging the task id. No
622
+ // concurrency is needed to reach that state: Ctrl-C, a crash, a
623
+ // full disk or an OOM kill during a large checkout produces it.
624
+ // The default path is deliberately unchanged: without --discard
625
+ // nothing here is forced at all.
626
+ const removeArgs = options.discard
627
+ ? ["worktree", "remove", "--force", "--force", facts.worktree]
628
+ : ["worktree", "remove", facts.worktree];
629
+ const removed = await destroyGitStep(facts.contextDir, removeArgs, facts.worktree);
630
+ if (removed.status !== 0) {
631
+ return {
632
+ ok: false,
633
+ reason: `git worktree remove failed: ${removed.stderr.trim()}`,
634
+ };
635
+ }
636
+ }
637
+ else {
638
+ const pruned = await runGitRetrying(facts.contextDir, ["worktree", "prune"]);
639
+ if (pruned.status !== 0) {
640
+ return {
641
+ ok: false,
642
+ reason: `git worktree prune failed: ${pruned.stderr.trim()}`,
643
+ };
644
+ }
645
+ }
646
+ const outcome = {};
647
+ if (facts.branchTip.kind === "present") {
648
+ // Stage 2 approved a SPECIFIC tip. Rather than re-read the tip and
649
+ // then delete (two steps with a window between them, which is what
650
+ // a check-then-act race is), hand the comparison to git: with an
651
+ // old-value argument, update-ref -d deletes the ref ONLY if it is
652
+ // still exactly that sha and fails atomically otherwise. The safety
653
+ // claim is therefore enforced by the ref transaction, not by a
654
+ // recheck that can be overtaken between checking and acting.
655
+ // Deliberately NOT runGitRetrying. git reports a failed old-value
656
+ // comparison as "cannot lock ref '<ref>': is at X but expected Y",
657
+ // which the contention signature treats as transient because on a
658
+ // FETCH it means another process already advanced the ref and a
659
+ // retry succeeds. Here the same text means the branch is no longer
660
+ // what stage 2 approved, which no amount of retrying will change:
661
+ // same shape, different call site, different meaning.
662
+ const deleted = runGit(facts.contextDir, [
663
+ "update-ref",
664
+ "-d",
665
+ `refs/heads/${facts.branchName}`,
666
+ facts.branchTip.sha,
667
+ ]);
668
+ if (deleted.status !== 0) {
669
+ // The worktree is already gone by this point and cannot be put
670
+ // back, so this is a PARTIAL FAILURE, not a refusal. Enumerate
671
+ // the real remaining state rather than implying nothing changed.
672
+ const detail = deleted.stderr.trim().split("\n")[0] ?? "";
673
+ const nowTip = runGit(facts.contextDir, [
674
+ "rev-parse",
675
+ "--verify",
676
+ "--quiet",
677
+ `refs/heads/${facts.branchName}^{commit}`,
678
+ ]);
679
+ const currentTip = nowTip.status === 0 ? nowTip.stdout.trim() : "absent";
680
+ const worktreeState = facts.haveWorktree
681
+ ? `worktree ${facts.worktree} HAS BEEN REMOVED (with any git-ignored files in it) and its registration pruned`
682
+ : `no worktree directory was present`;
683
+ return {
684
+ ok: false,
685
+ reason: `partial destroy of task id ${facts.taskId}: ${worktreeState}; branch ` +
686
+ `${facts.branchName} was NOT deleted and is left at ${currentTip} ` +
687
+ `(the delete was approved for ${facts.branchTip.sha} and git refused: ` +
688
+ `${detail}); the pool record ${facts.recordFile} is left in place; ` +
689
+ `re-run "tiphys pool destroy --task ${facts.taskId} ` +
690
+ `--delete-branch-force" to finish once you have checked that branch`,
691
+ };
692
+ }
693
+ outcome.deletedBranch = facts.branchName;
694
+ // The recovery handle: without it a mistaken destroy leaves the sha
695
+ // discoverable only through git fsck --lost-found, until gc.
696
+ outcome.deletedSha = facts.branchTip.sha;
697
+ }
698
+ if (facts.haveRecord) {
699
+ unlinkSync(facts.recordFile);
700
+ }
701
+ return { ok: true, value: outcome };
702
+ }
703
+ export async function poolDestroy(fleet, options) {
704
+ const { taskId } = options;
705
+ if (!TASK_ID_PATTERN.test(taskId)) {
706
+ return { ok: false, reason: `task id "${taskId}" is not a safe path segment` };
707
+ }
708
+ // Stage 1: read only.
709
+ const resolved = await resolveDestroy(fleet, taskId);
710
+ if (!resolved.ok) {
711
+ return resolved;
712
+ }
713
+ // Stage 2: pure decision. Every refusal returns here, before stage 3
714
+ // has touched anything.
715
+ const refusal = evaluateDestroy(resolved.value, options);
716
+ if (refusal !== undefined) {
717
+ return { ok: false, reason: refusal };
718
+ }
719
+ // Stage 3: destructive, and no longer able to refuse.
720
+ return applyDestroy(resolved.value, options);
721
+ }