@tiphys/kernel 0.0.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.
Files changed (207) hide show
  1. package/AGENTS.md +663 -0
  2. package/LICENSE +202 -0
  3. package/assurance-modes.yaml +299 -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 +92 -0
  11. package/dist/src/adapters/load.d.ts +202 -0
  12. package/dist/src/adapters/load.js +440 -0
  13. package/dist/src/brief.d.ts +32 -0
  14. package/dist/src/brief.js +66 -0
  15. package/dist/src/checklists.d.ts +169 -0
  16. package/dist/src/checklists.js +310 -0
  17. package/dist/src/checks.d.ts +1539 -0
  18. package/dist/src/checks.js +5025 -0
  19. package/dist/src/cli.d.ts +9 -0
  20. package/dist/src/cli.js +68 -0
  21. package/dist/src/commands/brief.d.ts +92 -0
  22. package/dist/src/commands/brief.js +343 -0
  23. package/dist/src/commands/checklist.d.ts +42 -0
  24. package/dist/src/commands/checklist.js +168 -0
  25. package/dist/src/commands/cutover.d.ts +35 -0
  26. package/dist/src/commands/cutover.js +448 -0
  27. package/dist/src/commands/doctor.d.ts +264 -0
  28. package/dist/src/commands/doctor.js +1546 -0
  29. package/dist/src/commands/gates.d.ts +9 -0
  30. package/dist/src/commands/gates.js +360 -0
  31. package/dist/src/commands/init.d.ts +30 -0
  32. package/dist/src/commands/init.js +177 -0
  33. package/dist/src/commands/lock.d.ts +34 -0
  34. package/dist/src/commands/lock.js +340 -0
  35. package/dist/src/commands/mode.d.ts +40 -0
  36. package/dist/src/commands/mode.js +134 -0
  37. package/dist/src/commands/next.d.ts +130 -0
  38. package/dist/src/commands/next.js +597 -0
  39. package/dist/src/commands/plan.d.ts +20 -0
  40. package/dist/src/commands/plan.js +105 -0
  41. package/dist/src/commands/pool.d.ts +1 -0
  42. package/dist/src/commands/pool.js +139 -0
  43. package/dist/src/commands/resume.d.ts +1 -0
  44. package/dist/src/commands/resume.js +88 -0
  45. package/dist/src/commands/spawn.d.ts +1 -0
  46. package/dist/src/commands/spawn.js +195 -0
  47. package/dist/src/commands/status.d.ts +21 -0
  48. package/dist/src/commands/status.js +122 -0
  49. package/dist/src/commands/sync.d.ts +47 -0
  50. package/dist/src/commands/sync.js +341 -0
  51. package/dist/src/commands/teardown.d.ts +1 -0
  52. package/dist/src/commands/teardown.js +87 -0
  53. package/dist/src/commands/tuition.d.ts +21 -0
  54. package/dist/src/commands/tuition.js +218 -0
  55. package/dist/src/commands/validate.d.ts +78 -0
  56. package/dist/src/commands/validate.js +430 -0
  57. package/dist/src/commands/watch.d.ts +1 -0
  58. package/dist/src/commands/watch.js +172 -0
  59. package/dist/src/cutover.d.ts +584 -0
  60. package/dist/src/cutover.js +1444 -0
  61. package/dist/src/exclusion.d.ts +389 -0
  62. package/dist/src/exclusion.js +843 -0
  63. package/dist/src/exec/env.d.ts +278 -0
  64. package/dist/src/exec/env.js +334 -0
  65. package/dist/src/fleet.d.ts +223 -0
  66. package/dist/src/fleet.js +298 -0
  67. package/dist/src/gates/adapters/http-json.d.ts +5 -0
  68. package/dist/src/gates/adapters/http-json.js +283 -0
  69. package/dist/src/gates/adapters/migrations-command.d.ts +1 -0
  70. package/dist/src/gates/adapters/migrations-command.js +373 -0
  71. package/dist/src/gates/citations.d.ts +408 -0
  72. package/dist/src/gates/citations.js +1169 -0
  73. package/dist/src/gates/coverage.d.ts +375 -0
  74. package/dist/src/gates/coverage.js +836 -0
  75. package/dist/src/gates/credentials.d.ts +233 -0
  76. package/dist/src/gates/credentials.js +752 -0
  77. package/dist/src/gates/deploy.d.ts +1 -0
  78. package/dist/src/gates/deploy.js +33 -0
  79. package/dist/src/gates/gate-classes.d.ts +56 -0
  80. package/dist/src/gates/gate-classes.js +633 -0
  81. package/dist/src/gates/manifest.d.ts +99 -0
  82. package/dist/src/gates/manifest.js +208 -0
  83. package/dist/src/gates/merge-preconditions.d.ts +319 -0
  84. package/dist/src/gates/merge-preconditions.js +932 -0
  85. package/dist/src/gates/migrations.d.ts +1 -0
  86. package/dist/src/gates/migrations.js +36 -0
  87. package/dist/src/gates/pin.d.ts +114 -0
  88. package/dist/src/gates/pin.js +154 -0
  89. package/dist/src/gates/red-witness.d.ts +22 -0
  90. package/dist/src/gates/red-witness.js +482 -0
  91. package/dist/src/gates/release.d.ts +283 -0
  92. package/dist/src/gates/release.js +820 -0
  93. package/dist/src/gates/result.d.ts +116 -0
  94. package/dist/src/gates/result.js +91 -0
  95. package/dist/src/gates/run.d.ts +614 -0
  96. package/dist/src/gates/run.js +1614 -0
  97. package/dist/src/gates/schemas/citation-config.schema.json +59 -0
  98. package/dist/src/gates/schemas/coverage-config.schema.json +77 -0
  99. package/dist/src/gates/schemas/gate-manifest.schema.json +125 -0
  100. package/dist/src/gates/schemas/gate-result.schema.json +160 -0
  101. package/dist/src/gates/schemas/phase-declaration.schema.json +87 -0
  102. package/dist/src/gates/schemas/release-record.schema.json +119 -0
  103. package/dist/src/gates/schemas/verifier-config.schema.json +101 -0
  104. package/dist/src/gates/schemas/witness-spec.schema.json +110 -0
  105. package/dist/src/gates/scope.d.ts +131 -0
  106. package/dist/src/gates/scope.js +1018 -0
  107. package/dist/src/gates/suite.d.ts +217 -0
  108. package/dist/src/gates/suite.js +968 -0
  109. package/dist/src/gates/validate.d.ts +121 -0
  110. package/dist/src/gates/validate.js +414 -0
  111. package/dist/src/hooks.d.ts +84 -0
  112. package/dist/src/hooks.js +125 -0
  113. package/dist/src/index.d.ts +31 -0
  114. package/dist/src/index.js +30 -0
  115. package/dist/src/liveness.d.ts +321 -0
  116. package/dist/src/liveness.js +396 -0
  117. package/dist/src/lock.d.ts +256 -0
  118. package/dist/src/lock.js +792 -0
  119. package/dist/src/model-resolution.d.ts +159 -0
  120. package/dist/src/model-resolution.js +307 -0
  121. package/dist/src/modes.d.ts +149 -0
  122. package/dist/src/modes.js +258 -0
  123. package/dist/src/path-identity.d.ts +34 -0
  124. package/dist/src/path-identity.js +48 -0
  125. package/dist/src/plan.d.ts +73 -0
  126. package/dist/src/plan.js +153 -0
  127. package/dist/src/pool.d.ts +326 -0
  128. package/dist/src/pool.js +988 -0
  129. package/dist/src/roles.d.ts +461 -0
  130. package/dist/src/roles.js +776 -0
  131. package/dist/src/spawn.d.ts +482 -0
  132. package/dist/src/spawn.js +1003 -0
  133. package/dist/src/status.d.ts +116 -0
  134. package/dist/src/status.js +148 -0
  135. package/dist/src/task.d.ts +504 -0
  136. package/dist/src/task.js +307 -0
  137. package/dist/src/teardown.d.ts +39 -0
  138. package/dist/src/teardown.js +422 -0
  139. package/dist/src/tuition.d.ts +159 -0
  140. package/dist/src/tuition.js +311 -0
  141. package/dist/src/validate.d.ts +263 -0
  142. package/dist/src/validate.js +742 -0
  143. package/dist/src/version.d.ts +3 -0
  144. package/dist/src/version.js +38 -0
  145. package/dist/src/watcher.d.ts +275 -0
  146. package/dist/src/watcher.js +849 -0
  147. package/dist/src/witness/run.d.ts +299 -0
  148. package/dist/src/witness/run.js +1373 -0
  149. package/dist/src/witness/spec.d.ts +270 -0
  150. package/dist/src/witness/spec.js +475 -0
  151. package/dist/tsconfig.src.tsbuildinfo +1 -0
  152. package/gate-registry.yaml +526 -0
  153. package/gates.manifest.json +257 -0
  154. package/package.json +72 -3
  155. package/role-model-config.yaml +88 -0
  156. package/roles/README.md +128 -0
  157. package/roles/_shared-dispatch-contract.md +87 -0
  158. package/roles/adversarial-plan-reviewer.md +80 -0
  159. package/roles/clean-room-reviewer.md +140 -0
  160. package/roles/implementer.md +463 -0
  161. package/roles/investigator.md +138 -0
  162. package/roles/plan-writer.md +95 -0
  163. package/schemas/README.md +82 -0
  164. package/schemas/assurance-modes.schema.json +264 -0
  165. package/schemas/charter.schema.json +185 -0
  166. package/schemas/checklist.schema.json +114 -0
  167. package/schemas/cutover-state.schema.json +64 -0
  168. package/schemas/decision-record.schema.json +88 -0
  169. package/schemas/executor-record.schema.json +36 -0
  170. package/schemas/final-report.schema.json +90 -0
  171. package/schemas/finding.schema.json +106 -0
  172. package/schemas/gate-registry.schema.json +260 -0
  173. package/schemas/mechanism-index.schema.json +94 -0
  174. package/schemas/model-resolution.schema.json +362 -0
  175. package/schemas/plan.schema.json +300 -0
  176. package/schemas/report.schema.json +579 -0
  177. package/schemas/role-brief.schema.json +105 -0
  178. package/schemas/role-model-config.schema.json +90 -0
  179. package/schemas/status-line.schema.json +40 -0
  180. package/schemas/tuition.schema.json +191 -0
  181. package/schemas/verdict.schema.json +295 -0
  182. package/schemas/work-history.schema.json +183 -0
  183. package/schemas/write-bypass.schema.json +69 -0
  184. package/templates/charter.example.yaml +54 -0
  185. package/templates/decision-record.example.yaml +27 -0
  186. package/templates/final-report.example.yaml +80 -0
  187. package/templates/plan.example.yaml +87 -0
  188. package/templates/report.example.yaml +236 -0
  189. package/templates/warnings.md +74 -0
  190. package/templates/work-history.example.yaml +185 -0
  191. package/tuition/README.md +76 -0
  192. package/tuition/T-001.yaml +48 -0
  193. package/tuition/T-002.yaml +51 -0
  194. package/tuition/T-003.yaml +100 -0
  195. package/tuition/T-004.yaml +52 -0
  196. package/tuition/T-005.yaml +72 -0
  197. package/tuition/T-006.yaml +81 -0
  198. package/tuition/T-007.yaml +56 -0
  199. package/tuition/T-008.yaml +111 -0
  200. package/tuition/T-009.yaml +50 -0
  201. package/tuition/T-015.yaml +36 -0
  202. package/tuition/T-016.yaml +36 -0
  203. package/tuition/T-017.yaml +46 -0
  204. package/tuition/T-018.yaml +84 -0
  205. package/tuition/T-021.yaml +40 -0
  206. package/tuition/T-022.yaml +36 -0
  207. package/tuition/mechanism-index.yaml +256 -0
@@ -0,0 +1,988 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync, readdirSync, statSync, unlinkSync, writeFileSync, } from "node:fs";
3
+ import { join, resolve } from "node:path";
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";
8
+ /**
9
+ * Worktree pool over a project clone (kernel plan v1, M1-P3 step 3).
10
+ * BUILD from the contract (plan decision D-1, FM-026): a clean disposable
11
+ * worktree per task at <fleet>/worktrees/<task-id>, parallel-safe through
12
+ * unique paths, O_EXCL record creation, and git worktree add's own
13
+ * locking. That safety is claimed only at the width M1 actually uses:
14
+ * criterion 15's two concurrent creates, which are witnessed. This
15
+ * phase's own verification measured failures above roughly six-way
16
+ * concurrency on both the fetch and the worktree add, and hardening
17
+ * for that width is deferred to M5 (see the deferral list in
18
+ * delivery/work-history/m1-p3.md). Do not read this as a guarantee at
19
+ * arbitrary concurrency. Substrate-neutral: pure filesystem and git
20
+ * (DR-0007).
21
+ *
22
+ * Base resolution is the five binding steps of EXT-F-03: resolve the
23
+ * project's configured remote and its default branch, fetch that branch,
24
+ * record the fetched base SHA in the pool record (and the CLI emits it on
25
+ * stdout; M1-P4 spawn copies it into tasks/<id>/meta.json as baseSha),
26
+ * create the task branch and worktree directly from that exact SHA, and
27
+ * on fetch failure fail rather than silently use a stale local branch,
28
+ * unless --offline was explicitly passed, in which case the last fetched
29
+ * remote-tracking SHA is used and offline: true is recorded. The clone's
30
+ * local branches are never consulted: a stale local branch is never the
31
+ * base, whether behind or ahead of the remote.
32
+ *
33
+ * The pool record lives BESIDE the worktree (worktrees/<task-id>.pool.json,
34
+ * plain JSON per D-3), never inside it, so the record can never dirty the
35
+ * destroy-time cleanliness check (FM-059: no exemption list, ever).
36
+ *
37
+ * Destroy refuses a dirty worktree (uncommitted changes or untracked
38
+ * files) unless --discard, which is reserved for the teardown scout path
39
+ * (PR-010). A transient git index.lock during destroy is retried; the
40
+ * lock file is removed only under a fail-safe staleness proof (provably
41
+ * no holder via lsof plus mtime age beyond a threshold; any uncertainty
42
+ * means leave it and fail loudly), per FM-036 and FM-051.
43
+ */
44
+ /** Task branch created by the pool at the fetched base SHA. */
45
+ export function taskBranchName(taskId) {
46
+ return `task/${taskId}`;
47
+ }
48
+ /** Task ids are single safe path segments. */
49
+ export const TASK_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
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) {
108
+ const result = spawnSync("git", ["-C", cwd, ...args], {
109
+ encoding: "utf8",
110
+ // The transient-contention classification below reads git's English
111
+ // message text, so the locale is pinned rather than inherited
112
+ // (U-8). Without this a translated git silently stops retrying.
113
+ env: { ...process.env, LC_ALL: "C", LANG: "C" },
114
+ ...(timeoutMs === undefined ? {} : { timeout: timeoutMs }),
115
+ });
116
+ if (result.error !== undefined) {
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
+ };
130
+ }
131
+ return {
132
+ status: result.status,
133
+ stdout: result.stdout ?? "",
134
+ stderr: result.stderr ?? "",
135
+ };
136
+ }
137
+ /**
138
+ * The ONE signature of transient git contention, derived from real
139
+ * captured stderr rather than from a general rule about git. Every
140
+ * alternative below was observed in a real race on git 2.43 and proved
141
+ * transient by an immediate retry succeeding:
142
+ *
143
+ * 1. A held lock file, which names the lock path: "Unable to create
144
+ * '<path>.lock': File exists." (index locks and ref locks alike).
145
+ * 2. A refused ref transaction under concurrent update, which names NO
146
+ * lock file: "cannot lock ref '<ref>': is at <sha> but expected
147
+ * <sha>". This is the dominant transient of concurrent fetches of
148
+ * the same tracking ref.
149
+ * 3. Two concurrent worktree operations colliding on a half-written
150
+ * admin file: "bad object worktrees/<id>/HEAD" on a fetch, and
151
+ * "failed to read .git/worktrees/<id>/commondir: Success" on an add.
152
+ *
153
+ * One signature is used at every call site. An earlier revision split
154
+ * these by call site, reasoning about which path was allowed to retry
155
+ * which shape; that distinction existed only to protect a rollback that
156
+ * no longer exists, and the splitting itself produced defects. If the
157
+ * add's retry finds the branch already there, create refuses with its
158
+ * existing clear message, which is the correct outcome.
159
+ *
160
+ * The bare phrases "File exists" and "cannot lock ref" must NOT be used
161
+ * on their own: permanent failures emit them too. A directory/file ref
162
+ * conflict reports "cannot lock ref 'refs/x': 'refs/x/y' exists; cannot
163
+ * create 'refs/x'", which is permanent, and retrying it only wastes
164
+ * about a second before the same error surfaces.
165
+ *
166
+ * Known trade, stated rather than hidden (U-11): the worktrees admin
167
+ * shapes also match a permanent condition (an admin HEAD holding a
168
+ * nonexistent object), indistinguishable by message, so that
169
+ * out-of-contract case burns all attempts and fails in about 1.25s
170
+ * instead of about 0.29s.
171
+ *
172
+ * Maintenance rule, learned the hard way: this classification is
173
+ * message-text-only. Do not re-derive it from a plausible general
174
+ * property of git; a previous revision assumed "a genuine transient
175
+ * always names a lock file", which is false, and parallel pool create
176
+ * began failing hard. Capture real stderr from a real race first.
177
+ */
178
+ 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/;
179
+ /** Exported for the contention-classification test. */
180
+ export function isTransientGitLockError(stderr) {
181
+ return GIT_CONTENTION.test(stderr);
182
+ }
183
+ async function runGitRetrying(cwd, args, attempts = 5) {
184
+ let result = runGit(cwd, args);
185
+ for (let attempt = 1; attempt < attempts; attempt += 1) {
186
+ if (result.status === 0 || !GIT_CONTENTION.test(result.stderr)) {
187
+ return result;
188
+ }
189
+ await sleep(100 * attempt);
190
+ result = runGit(cwd, args);
191
+ }
192
+ return result;
193
+ }
194
+ export function recordPath(fleet, taskId) {
195
+ return join(fleet.worktreesDir, `${taskId}.pool.json`);
196
+ }
197
+ export function worktreePath(fleet, taskId) {
198
+ return join(fleet.worktreesDir, taskId);
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
+ */
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
+ }
222
+ try {
223
+ return JSON.parse(read.body);
224
+ }
225
+ catch {
226
+ return undefined;
227
+ }
228
+ }
229
+ /**
230
+ * EXT-F-03 step 1: resolve the project's configured remote. origin when
231
+ * present; otherwise the single configured remote; otherwise fail.
232
+ */
233
+ function resolveRemote(project) {
234
+ const result = runGit(project, ["remote"]);
235
+ if (result.status !== 0) {
236
+ return { ok: false, reason: `${project} is not a git repository (git remote failed: ${result.stderr.trim()})` };
237
+ }
238
+ const remotes = result.stdout.split("\n").filter((line) => line !== "");
239
+ if (remotes.includes("origin")) {
240
+ return { ok: true, value: "origin" };
241
+ }
242
+ if (remotes.length === 1) {
243
+ return { ok: true, value: remotes[0] };
244
+ }
245
+ if (remotes.length === 0) {
246
+ return { ok: false, reason: `${project} has no configured remote` };
247
+ }
248
+ return {
249
+ ok: false,
250
+ reason: `${project} has ${String(remotes.length)} remotes and none is origin; cannot pick one`,
251
+ };
252
+ }
253
+ /**
254
+ * EXT-F-03 step 1: resolve the remote's default branch. origin/HEAD when
255
+ * set locally (no network); otherwise the remote's advertised default
256
+ * via ls-remote --symref. The clone's own HEAD is never consulted, so a
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".
267
+ */
268
+ function resolveDefaultBranch(project, remote, network) {
269
+ const local = runGit(project, [
270
+ "symbolic-ref",
271
+ "--quiet",
272
+ `refs/remotes/${remote}/HEAD`,
273
+ ]);
274
+ if (local.status === 0) {
275
+ const ref = local.stdout.trim();
276
+ const prefix = `refs/remotes/${remote}/`;
277
+ if (ref.startsWith(prefix)) {
278
+ return { ok: true, value: ref.slice(prefix.length) };
279
+ }
280
+ }
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());
290
+ if (advertised.status === 0) {
291
+ const match = /^ref:\s+refs\/heads\/(\S+)\s+HEAD$/m.exec(advertised.stdout);
292
+ if (match !== null) {
293
+ return { ok: true, value: match[1] };
294
+ }
295
+ return {
296
+ ok: false,
297
+ reason: `remote ${remote} did not advertise a default branch (no symref HEAD)`,
298
+ };
299
+ }
300
+ return {
301
+ ok: false,
302
+ reason: `cannot resolve the default branch of remote ${remote}: ` +
303
+ `${remote}/HEAD is unset locally and ls-remote failed: ${advertised.stderr.trim()}`,
304
+ };
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
+ }
369
+ /**
370
+ * pool create (EXT-F-03 five steps; see module doc). Returns the pool
371
+ * record on success.
372
+ *
373
+ * On failure NOTHING IS REMOVED. The pool record, the worktree
374
+ * directory and the task branch may each survive, depending on how far
375
+ * the attempt got, and the reason line names exactly which of them did
376
+ * and the command that clears them. The automatic rollback this
377
+ * docstring used to promise was deleted deliberately: it served a
378
+ * concurrent-create path M1 never enters (parallelism is off until M5)
379
+ * and produced four consecutive rounds of defects, including deleting
380
+ * state it had not validated. Failing loudly and leaving state is the
381
+ * chosen contract, not an oversight.
382
+ */
383
+ export async function poolCreate(fleet, options) {
384
+ const { taskId, offline } = options;
385
+ if (!TASK_ID_PATTERN.test(taskId)) {
386
+ return { ok: false, reason: `task id "${taskId}" is not a safe path segment` };
387
+ }
388
+ const project = resolve(options.project);
389
+ const record = recordPath(fleet, taskId);
390
+ const worktree = worktreePath(fleet, taskId);
391
+ if (existsSync(record) || existsSync(worktree)) {
392
+ return { ok: false, reason: `task id already used: ${taskId}` };
393
+ }
394
+ // CR-201: a leftover task branch would otherwise surface as a raw git
395
+ // error from deep inside worktree add. Destroy now removes the branch
396
+ // it created, so this only fires when the branch came from elsewhere.
397
+ const branchName = taskBranchName(taskId);
398
+ const existing = runGit(project, [
399
+ "rev-parse",
400
+ "--verify",
401
+ "--quiet",
402
+ `refs/heads/${branchName}`,
403
+ ]);
404
+ if (existing.status === 0) {
405
+ return {
406
+ ok: false,
407
+ reason: `branch ${branchName} already exists in ${project}; the pool creates ` +
408
+ `it fresh, so delete or rename it before re-using task id ${taskId}`,
409
+ };
410
+ }
411
+ // U-12: the opposite collision direction. A ref UNDER
412
+ // refs/heads/task/<id>/ (for example task/foo/bar when creating
413
+ // task/foo) makes the branch equally uncreatable, and the exact-ref
414
+ // and strict-prefix checks both miss it, so it used to reach git
415
+ // worktree add and surface as the raw two-line git error the
416
+ // pre-check exists to eliminate.
417
+ const nested = runGit(project, [
418
+ "for-each-ref",
419
+ "--count=1",
420
+ "--format=%(refname)",
421
+ `refs/heads/${branchName}/`,
422
+ ]);
423
+ if (nested.status === 0 && nested.stdout.trim() !== "") {
424
+ return {
425
+ ok: false,
426
+ reason: `ref ${nested.stdout.trim()} already exists in ${project} and blocks ` +
427
+ `${branchName} (a ref cannot be both a branch and a directory); ` +
428
+ `delete or rename it before using task id ${taskId}`,
429
+ };
430
+ }
431
+ // U-7: the exact ref can be absent while the branch is still
432
+ // uncreatable, because any strict prefix of refs/heads/task/<id>
433
+ // existing as a ref is a directory/file conflict. Catch it here
434
+ // instead of after a full network fetch, inside git worktree add.
435
+ const segments = branchName.split("/");
436
+ for (let i = 1; i < segments.length; i += 1) {
437
+ const prefix = segments.slice(0, i).join("/");
438
+ const conflict = runGit(project, [
439
+ "rev-parse",
440
+ "--verify",
441
+ "--quiet",
442
+ `refs/heads/${prefix}`,
443
+ ]);
444
+ if (conflict.status === 0) {
445
+ return {
446
+ ok: false,
447
+ reason: `branch ${prefix} already exists in ${project} and blocks ` +
448
+ `${branchName} (a ref cannot be both a branch and a directory); ` +
449
+ `delete or rename it before using task id ${taskId}`,
450
+ };
451
+ }
452
+ }
453
+ const remote = resolveRemote(project);
454
+ if (!remote.ok) {
455
+ return remote;
456
+ }
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);
460
+ if (!branch.ok) {
461
+ return branch;
462
+ }
463
+ const trackingRef = `refs/remotes/${remote.value}/${branch.value}`;
464
+ // EXT-F-03 step 2: fetch the default branch, force-updating exactly
465
+ // the remote-tracking ref (so a rewound remote is still mirrored).
466
+ let usedOffline = false;
467
+ const fetch = await runGitRetrying(project, [
468
+ "fetch",
469
+ remote.value,
470
+ `+refs/heads/${branch.value}:${trackingRef}`,
471
+ ]);
472
+ if (fetch.status !== 0) {
473
+ if (!offline) {
474
+ return {
475
+ ok: false,
476
+ reason: `fetch of ${remote.value}/${branch.value} failed and --offline was ` +
477
+ `not passed; refusing to base work on a stale ref: ${fetch.stderr.trim()}`,
478
+ };
479
+ }
480
+ usedOffline = true;
481
+ }
482
+ // EXT-F-03 step 3: the base SHA is the (just) fetched remote-tracking
483
+ // ref, never a local branch.
484
+ const base = runGit(project, ["rev-parse", "--verify", `${trackingRef}^{commit}`]);
485
+ if (base.status !== 0) {
486
+ return {
487
+ ok: false,
488
+ reason: usedOffline
489
+ ? `--offline was passed but ${trackingRef} has never been fetched; nothing to base on`
490
+ : `cannot resolve ${trackingRef} after fetch: ${base.stderr.trim()}`,
491
+ };
492
+ }
493
+ const baseSha = base.stdout.trim();
494
+ const poolRecord = {
495
+ taskId,
496
+ project,
497
+ remote: remote.value,
498
+ branch: branch.value,
499
+ baseSha,
500
+ branchName,
501
+ offline: usedOffline,
502
+ createdAt: new Date().toISOString(),
503
+ };
504
+ // O_EXCL record reservation: the atomic duplicate gate for concurrent
505
+ // creates with the same task id.
506
+ try {
507
+ writeFileSync(record, `${JSON.stringify(poolRecord, null, 2)}\n`, {
508
+ flag: "wx",
509
+ });
510
+ }
511
+ catch (error) {
512
+ if (error.code === "EEXIST") {
513
+ return { ok: false, reason: `task id already used: ${taskId}` };
514
+ }
515
+ throw error;
516
+ }
517
+ // EXT-F-03 step 4: task branch and worktree directly from the exact
518
+ // fetched SHA. Transient contention is retried by the one signature.
519
+ //
520
+ // There is deliberately NO automatic cleanup on failure. Parallelism
521
+ // is off until M5 (plan section 1.4), so nothing in M1 drives the
522
+ // concurrent-create path this machinery existed for, and four rounds
523
+ // of defects came out of it: a global prune that could kill another
524
+ // create, an unvalidated recursive delete of an admin directory, and
525
+ // a rollback that broke under the very contention it existed to
526
+ // handle. Failing loudly and leaving state is strictly better than
527
+ // cleanup code that deletes the wrong thing. The operator is told
528
+ // exactly what was left and exactly how to clear it.
529
+ const add = await runGitRetrying(project, [
530
+ "worktree",
531
+ "add",
532
+ "-b",
533
+ poolRecord.branchName,
534
+ worktree,
535
+ baseSha,
536
+ ]);
537
+ if (add.status !== 0) {
538
+ const leftovers = [`pool record ${record}`];
539
+ if (existsSync(worktree)) {
540
+ leftovers.push(`worktree directory ${worktree}`);
541
+ }
542
+ const branchProbe = runGit(project, [
543
+ "rev-parse",
544
+ "--verify",
545
+ "--quiet",
546
+ `refs/heads/${poolRecord.branchName}^{commit}`,
547
+ ]);
548
+ if (branchProbe.status === 0) {
549
+ leftovers.push(`branch ${poolRecord.branchName}`);
550
+ }
551
+ return {
552
+ ok: false,
553
+ reason: `git worktree add failed: ${add.stderr.trim()}; nothing was removed, ` +
554
+ `left behind: ${leftovers.join(", ")}; clear it with "tiphys pool ` +
555
+ `destroy --task ${taskId} --discard --delete-branch-force" run in the fleet home`,
556
+ };
557
+ }
558
+ return { ok: true, value: poolRecord };
559
+ }
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
+ */
590
+ export function poolList(fleet) {
591
+ const entries = [];
592
+ const seen = new Set();
593
+ const names = readdirSync(fleet.worktreesDir)
594
+ .filter((name) => name.endsWith(".pool.json"))
595
+ .sort();
596
+ for (const name of names) {
597
+ const taskId = name.slice(0, -".pool.json".length);
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
+ });
652
+ }
653
+ return entries;
654
+ }
655
+ function defaultLsof(path) {
656
+ const result = spawnSync("lsof", ["-t", "--", path], { encoding: "utf8" });
657
+ if (result.error !== undefined) {
658
+ return { available: false, exitCode: null, stdout: "" };
659
+ }
660
+ return {
661
+ available: true,
662
+ exitCode: result.status,
663
+ stdout: result.stdout ?? "",
664
+ };
665
+ }
666
+ /** Age a lock file must reach before a staleness proof is even considered. */
667
+ export const STALE_LOCK_AGE_MS = 300_000;
668
+ /**
669
+ * Fail-safe staleness proof for a git lock file (FM-036, FM-051): true
670
+ * only when the lock exists, its mtime age exceeds the threshold, and
671
+ * lsof is available and shows provably no holder (exit 1, empty stdout).
672
+ * Any uncertainty (lsof missing, erroring, or listing holders) is false:
673
+ * the lock is left in place and the operation fails loudly.
674
+ */
675
+ export function provablyStaleLock(lockFile, opts = {}) {
676
+ const nowMs = opts.nowMs ?? Date.now();
677
+ const threshold = opts.ageThresholdMs ?? STALE_LOCK_AGE_MS;
678
+ const probe = opts.runLsof ?? defaultLsof;
679
+ let mtimeMs;
680
+ try {
681
+ mtimeMs = statSync(lockFile).mtimeMs;
682
+ }
683
+ catch {
684
+ return false;
685
+ }
686
+ if (nowMs - mtimeMs <= threshold) {
687
+ return false;
688
+ }
689
+ const lsof = probe(lockFile);
690
+ if (!lsof.available || lsof.exitCode !== 1 || lsof.stdout.trim() !== "") {
691
+ return false;
692
+ }
693
+ return true;
694
+ }
695
+ async function destroyGitStep(contextDir, args, worktree) {
696
+ let result = await runGitRetrying(contextDir, args);
697
+ if (result.status !== 0 && GIT_CONTENTION.test(result.stderr)) {
698
+ // Retries exhausted on a lock signature: attempt the fail-safe
699
+ // staleness proof on the worktree's index.lock, the one transient
700
+ // lock a destroy can legitimately hit (FM-036).
701
+ const gitDir = runGit(worktree, ["rev-parse", "--absolute-git-dir"]);
702
+ if (gitDir.status === 0) {
703
+ const indexLock = join(gitDir.stdout.trim(), "index.lock");
704
+ if (provablyStaleLock(indexLock)) {
705
+ try {
706
+ unlinkSync(indexLock);
707
+ }
708
+ catch {
709
+ // Already gone; retry either way.
710
+ }
711
+ result = await runGitRetrying(contextDir, args);
712
+ }
713
+ }
714
+ }
715
+ return result;
716
+ }
717
+ /** Stage 1: gather facts. Read only; mutates nothing. */
718
+ async function resolveDestroy(fleet, taskId, reconstructed) {
719
+ const worktree = worktreePath(fleet, taskId);
720
+ const recordFile = recordPath(fleet, taskId);
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.
727
+ const haveRecord = existsSync(recordFile);
728
+ const haveWorktree = existsSync(worktree);
729
+ if (!haveRecord && !haveWorktree) {
730
+ return { ok: false, reason: `no pool worktree for task id ${taskId}` };
731
+ }
732
+ // The git context must be resolved while the worktree still exists,
733
+ // because the worktree is one of only two ways to find it.
734
+ let contextDir;
735
+ if (record !== undefined && existsSync(record.project)) {
736
+ contextDir = record.project;
737
+ }
738
+ else if (haveWorktree) {
739
+ const common = runGit(worktree, [
740
+ "rev-parse",
741
+ "--path-format=absolute",
742
+ "--git-common-dir",
743
+ ]);
744
+ if (common.status === 0 && common.stdout.trim() !== "") {
745
+ contextDir = common.stdout.trim();
746
+ }
747
+ }
748
+ if (contextDir === undefined) {
749
+ return {
750
+ ok: false,
751
+ reason: `cannot resolve the project repository for task id ${taskId}: the ` +
752
+ `pool record ${recordFile} is missing or unreadable and there is no ` +
753
+ `usable worktree. Nothing was removed; repair or delete that record ` +
754
+ `file to release the id`,
755
+ };
756
+ }
757
+ let dirty;
758
+ let dirtyProbeError;
759
+ if (haveWorktree) {
760
+ const status = await destroyGitStep(worktree, ["status", "--porcelain"], worktree);
761
+ if (status.status !== 0) {
762
+ dirtyProbeError = status.stderr.trim();
763
+ }
764
+ else {
765
+ dirty = status.stdout.trim() !== "";
766
+ }
767
+ }
768
+ const branchName = record?.branchName ?? taskBranchName(taskId);
769
+ const tip = runGit(contextDir, [
770
+ "rev-parse",
771
+ "--verify",
772
+ "--quiet",
773
+ `refs/heads/${branchName}^{commit}`,
774
+ ]);
775
+ let branchTip;
776
+ if (tip.status === 0) {
777
+ branchTip = { kind: "present", sha: tip.stdout.trim() };
778
+ }
779
+ else if (tip.status === 1) {
780
+ branchTip = { kind: "absent" };
781
+ }
782
+ else {
783
+ branchTip = {
784
+ kind: "indeterminate",
785
+ detail: tip.stderr.trim() === "" ? `git exited ${String(tip.status)}` : tip.stderr.trim(),
786
+ };
787
+ }
788
+ let branchCheckedOutAt;
789
+ const listed = runGit(contextDir, ["worktree", "list", "--porcelain"]);
790
+ if (listed.status === 0) {
791
+ let currentPath;
792
+ for (const line of listed.stdout.split("\n")) {
793
+ if (line.startsWith("worktree ")) {
794
+ currentPath = line.slice("worktree ".length).trim();
795
+ }
796
+ else if (line.trim() === `branch refs/heads/${branchName}`) {
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)) {
810
+ branchCheckedOutAt = currentPath;
811
+ }
812
+ }
813
+ }
814
+ }
815
+ return {
816
+ ok: true,
817
+ value: {
818
+ taskId,
819
+ contextDir,
820
+ record,
821
+ recordFile,
822
+ haveRecord,
823
+ worktree,
824
+ haveWorktree,
825
+ dirty,
826
+ dirtyProbeError,
827
+ branchName,
828
+ branchTip,
829
+ branchCheckedOutAt,
830
+ },
831
+ };
832
+ }
833
+ /**
834
+ * Stage 2: decide. Pure over the facts, no IO, so no gate can destroy
835
+ * anything. Returns a refusal reason, or undefined to proceed.
836
+ */
837
+ function evaluateDestroy(facts, options) {
838
+ if (facts.haveWorktree && !options.discard) {
839
+ if (facts.dirtyProbeError !== undefined) {
840
+ return `cannot verify worktree cleanliness: ${facts.dirtyProbeError}`;
841
+ }
842
+ if (facts.dirty === true) {
843
+ return (`worktree ${facts.worktree} has uncommitted changes or untracked ` +
844
+ `files; commit or land them first, or pass --discard to remove anyway`);
845
+ }
846
+ }
847
+ if (facts.branchTip.kind === "indeterminate") {
848
+ return (`cannot determine whether branch ${facts.branchName} exists in ` +
849
+ `${facts.contextDir} (${facts.branchTip.detail}); refusing to finish ` +
850
+ `destroy for task id ${facts.taskId}`);
851
+ }
852
+ if (facts.branchTip.kind === "present" &&
853
+ facts.branchCheckedOutAt !== undefined) {
854
+ // Policy, and therefore a stage-2 refusal: a true no-op. Deleting a
855
+ // branch another worktree has checked out would strand it on a
856
+ // dangling HEAD. git branch -D enforces this; update-ref, which
857
+ // stage 3 uses for its atomic compare-and-delete, does not, so the
858
+ // rule is enforced here rather than relied on downstream.
859
+ return (`cannot delete branch ${facts.branchName}: it is checked out at ` +
860
+ `${facts.branchCheckedOutAt}; remove that worktree first`);
861
+ }
862
+ if (facts.branchTip.kind === "present" && !options.deleteBranchForce) {
863
+ const baseSha = facts.record?.baseSha;
864
+ if (baseSha === undefined) {
865
+ return (`cannot verify branch ${facts.branchName} against its recorded base ` +
866
+ `(pool record missing or unreadable), tip ${facts.branchTip.sha}; land ` +
867
+ `it or pass --delete-branch-force to delete it anyway`);
868
+ }
869
+ if (facts.branchTip.sha !== baseSha) {
870
+ return (`branch ${facts.branchName} carries commits beyond its base ` +
871
+ `${baseSha} (tip ${facts.branchTip.sha}); land them or pass ` +
872
+ `--delete-branch-force to delete it anyway`);
873
+ }
874
+ }
875
+ return undefined;
876
+ }
877
+ /**
878
+ * Stage 3: perform. Reached only with every gate passed, so there is
879
+ * nothing left to refuse; failures here are operational, not policy.
880
+ */
881
+ async function applyDestroy(facts, options) {
882
+ if (facts.haveWorktree) {
883
+ // F-1: --discard means "remove anyway" (plan step 3), and git's own
884
+ // documented way to say that for a LOCKED working tree is a second
885
+ // --force. An interrupted git worktree add leaves
886
+ // .git/worktrees/<id>/locked = "initializing", which a single
887
+ // --force refuses, so the remedy this kernel prints on a failed
888
+ // create used to exit 1 and clear nothing, wedging the task id. No
889
+ // concurrency is needed to reach that state: Ctrl-C, a crash, a
890
+ // full disk or an OOM kill during a large checkout produces it.
891
+ // The default path is deliberately unchanged: without --discard
892
+ // nothing here is forced at all.
893
+ const removeArgs = options.discard
894
+ ? ["worktree", "remove", "--force", "--force", facts.worktree]
895
+ : ["worktree", "remove", facts.worktree];
896
+ const removed = await destroyGitStep(facts.contextDir, removeArgs, facts.worktree);
897
+ if (removed.status !== 0) {
898
+ return {
899
+ ok: false,
900
+ reason: `git worktree remove failed: ${removed.stderr.trim()}`,
901
+ };
902
+ }
903
+ }
904
+ else {
905
+ const pruned = await runGitRetrying(facts.contextDir, ["worktree", "prune"]);
906
+ if (pruned.status !== 0) {
907
+ return {
908
+ ok: false,
909
+ reason: `git worktree prune failed: ${pruned.stderr.trim()}`,
910
+ };
911
+ }
912
+ }
913
+ const outcome = {};
914
+ if (facts.branchTip.kind === "present") {
915
+ // Stage 2 approved a SPECIFIC tip. Rather than re-read the tip and
916
+ // then delete (two steps with a window between them, which is what
917
+ // a check-then-act race is), hand the comparison to git: with an
918
+ // old-value argument, update-ref -d deletes the ref ONLY if it is
919
+ // still exactly that sha and fails atomically otherwise. The safety
920
+ // claim is therefore enforced by the ref transaction, not by a
921
+ // recheck that can be overtaken between checking and acting.
922
+ // Deliberately NOT runGitRetrying. git reports a failed old-value
923
+ // comparison as "cannot lock ref '<ref>': is at X but expected Y",
924
+ // which the contention signature treats as transient because on a
925
+ // FETCH it means another process already advanced the ref and a
926
+ // retry succeeds. Here the same text means the branch is no longer
927
+ // what stage 2 approved, which no amount of retrying will change:
928
+ // same shape, different call site, different meaning.
929
+ const deleted = runGit(facts.contextDir, [
930
+ "update-ref",
931
+ "-d",
932
+ `refs/heads/${facts.branchName}`,
933
+ facts.branchTip.sha,
934
+ ]);
935
+ if (deleted.status !== 0) {
936
+ // The worktree is already gone by this point and cannot be put
937
+ // back, so this is a PARTIAL FAILURE, not a refusal. Enumerate
938
+ // the real remaining state rather than implying nothing changed.
939
+ const detail = deleted.stderr.trim().split("\n")[0] ?? "";
940
+ const nowTip = runGit(facts.contextDir, [
941
+ "rev-parse",
942
+ "--verify",
943
+ "--quiet",
944
+ `refs/heads/${facts.branchName}^{commit}`,
945
+ ]);
946
+ const currentTip = nowTip.status === 0 ? nowTip.stdout.trim() : "absent";
947
+ const worktreeState = facts.haveWorktree
948
+ ? `worktree ${facts.worktree} HAS BEEN REMOVED (with any git-ignored files in it) and its registration pruned`
949
+ : `no worktree directory was present`;
950
+ return {
951
+ ok: false,
952
+ reason: `partial destroy of task id ${facts.taskId}: ${worktreeState}; branch ` +
953
+ `${facts.branchName} was NOT deleted and is left at ${currentTip} ` +
954
+ `(the delete was approved for ${facts.branchTip.sha} and git refused: ` +
955
+ `${detail}); the pool record ${facts.recordFile} is left in place; ` +
956
+ `re-run "tiphys pool destroy --task ${facts.taskId} ` +
957
+ `--delete-branch-force" to finish once you have checked that branch`,
958
+ };
959
+ }
960
+ outcome.deletedBranch = facts.branchName;
961
+ // The recovery handle: without it a mistaken destroy leaves the sha
962
+ // discoverable only through git fsck --lost-found, until gc.
963
+ outcome.deletedSha = facts.branchTip.sha;
964
+ }
965
+ if (facts.haveRecord) {
966
+ unlinkSync(facts.recordFile);
967
+ }
968
+ return { ok: true, value: outcome };
969
+ }
970
+ export async function poolDestroy(fleet, options) {
971
+ const { taskId } = options;
972
+ if (!TASK_ID_PATTERN.test(taskId)) {
973
+ return { ok: false, reason: `task id "${taskId}" is not a safe path segment` };
974
+ }
975
+ // Stage 1: read only.
976
+ const resolved = await resolveDestroy(fleet, taskId, options.reconstructed);
977
+ if (!resolved.ok) {
978
+ return resolved;
979
+ }
980
+ // Stage 2: pure decision. Every refusal returns here, before stage 3
981
+ // has touched anything.
982
+ const refusal = evaluateDestroy(resolved.value, options);
983
+ if (refusal !== undefined) {
984
+ return { ok: false, reason: refusal };
985
+ }
986
+ // Stage 3: destructive, and no longer able to refuse.
987
+ return applyDestroy(resolved.value, options);
988
+ }