@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.
- package/AGENTS.md +663 -0
- package/LICENSE +202 -0
- package/assurance-modes.yaml +299 -0
- package/checklists/clean-room.yaml +325 -0
- package/checklists/env-failure-diagnosis.yaml +68 -0
- package/checklists/flake-playbook.yaml +68 -0
- package/checklists/hazard-review.yaml +144 -0
- package/checklists/plan-review.yaml +103 -0
- package/dist/bin/tiphys.d.ts +2 -0
- package/dist/bin/tiphys.js +92 -0
- package/dist/src/adapters/load.d.ts +202 -0
- package/dist/src/adapters/load.js +440 -0
- package/dist/src/brief.d.ts +32 -0
- package/dist/src/brief.js +66 -0
- package/dist/src/checklists.d.ts +169 -0
- package/dist/src/checklists.js +310 -0
- package/dist/src/checks.d.ts +1539 -0
- package/dist/src/checks.js +5025 -0
- package/dist/src/cli.d.ts +9 -0
- package/dist/src/cli.js +68 -0
- package/dist/src/commands/brief.d.ts +92 -0
- package/dist/src/commands/brief.js +343 -0
- package/dist/src/commands/checklist.d.ts +42 -0
- package/dist/src/commands/checklist.js +168 -0
- package/dist/src/commands/cutover.d.ts +35 -0
- package/dist/src/commands/cutover.js +448 -0
- package/dist/src/commands/doctor.d.ts +264 -0
- package/dist/src/commands/doctor.js +1546 -0
- package/dist/src/commands/gates.d.ts +9 -0
- package/dist/src/commands/gates.js +360 -0
- package/dist/src/commands/init.d.ts +30 -0
- package/dist/src/commands/init.js +177 -0
- package/dist/src/commands/lock.d.ts +34 -0
- package/dist/src/commands/lock.js +340 -0
- package/dist/src/commands/mode.d.ts +40 -0
- package/dist/src/commands/mode.js +134 -0
- package/dist/src/commands/next.d.ts +130 -0
- package/dist/src/commands/next.js +597 -0
- package/dist/src/commands/plan.d.ts +20 -0
- package/dist/src/commands/plan.js +105 -0
- package/dist/src/commands/pool.d.ts +1 -0
- package/dist/src/commands/pool.js +139 -0
- package/dist/src/commands/resume.d.ts +1 -0
- package/dist/src/commands/resume.js +88 -0
- package/dist/src/commands/spawn.d.ts +1 -0
- package/dist/src/commands/spawn.js +195 -0
- package/dist/src/commands/status.d.ts +21 -0
- package/dist/src/commands/status.js +122 -0
- package/dist/src/commands/sync.d.ts +47 -0
- package/dist/src/commands/sync.js +341 -0
- package/dist/src/commands/teardown.d.ts +1 -0
- package/dist/src/commands/teardown.js +87 -0
- package/dist/src/commands/tuition.d.ts +21 -0
- package/dist/src/commands/tuition.js +218 -0
- package/dist/src/commands/validate.d.ts +78 -0
- package/dist/src/commands/validate.js +430 -0
- package/dist/src/commands/watch.d.ts +1 -0
- package/dist/src/commands/watch.js +172 -0
- package/dist/src/cutover.d.ts +584 -0
- package/dist/src/cutover.js +1444 -0
- package/dist/src/exclusion.d.ts +389 -0
- package/dist/src/exclusion.js +843 -0
- package/dist/src/exec/env.d.ts +278 -0
- package/dist/src/exec/env.js +334 -0
- package/dist/src/fleet.d.ts +223 -0
- package/dist/src/fleet.js +298 -0
- package/dist/src/gates/adapters/http-json.d.ts +5 -0
- package/dist/src/gates/adapters/http-json.js +283 -0
- package/dist/src/gates/adapters/migrations-command.d.ts +1 -0
- package/dist/src/gates/adapters/migrations-command.js +373 -0
- package/dist/src/gates/citations.d.ts +408 -0
- package/dist/src/gates/citations.js +1169 -0
- package/dist/src/gates/coverage.d.ts +375 -0
- package/dist/src/gates/coverage.js +836 -0
- package/dist/src/gates/credentials.d.ts +233 -0
- package/dist/src/gates/credentials.js +752 -0
- package/dist/src/gates/deploy.d.ts +1 -0
- package/dist/src/gates/deploy.js +33 -0
- package/dist/src/gates/gate-classes.d.ts +56 -0
- package/dist/src/gates/gate-classes.js +633 -0
- package/dist/src/gates/manifest.d.ts +99 -0
- package/dist/src/gates/manifest.js +208 -0
- package/dist/src/gates/merge-preconditions.d.ts +319 -0
- package/dist/src/gates/merge-preconditions.js +932 -0
- package/dist/src/gates/migrations.d.ts +1 -0
- package/dist/src/gates/migrations.js +36 -0
- package/dist/src/gates/pin.d.ts +114 -0
- package/dist/src/gates/pin.js +154 -0
- package/dist/src/gates/red-witness.d.ts +22 -0
- package/dist/src/gates/red-witness.js +482 -0
- package/dist/src/gates/release.d.ts +283 -0
- package/dist/src/gates/release.js +820 -0
- package/dist/src/gates/result.d.ts +116 -0
- package/dist/src/gates/result.js +91 -0
- package/dist/src/gates/run.d.ts +614 -0
- package/dist/src/gates/run.js +1614 -0
- package/dist/src/gates/schemas/citation-config.schema.json +59 -0
- package/dist/src/gates/schemas/coverage-config.schema.json +77 -0
- package/dist/src/gates/schemas/gate-manifest.schema.json +125 -0
- package/dist/src/gates/schemas/gate-result.schema.json +160 -0
- package/dist/src/gates/schemas/phase-declaration.schema.json +87 -0
- package/dist/src/gates/schemas/release-record.schema.json +119 -0
- package/dist/src/gates/schemas/verifier-config.schema.json +101 -0
- package/dist/src/gates/schemas/witness-spec.schema.json +110 -0
- package/dist/src/gates/scope.d.ts +131 -0
- package/dist/src/gates/scope.js +1018 -0
- package/dist/src/gates/suite.d.ts +217 -0
- package/dist/src/gates/suite.js +968 -0
- package/dist/src/gates/validate.d.ts +121 -0
- package/dist/src/gates/validate.js +414 -0
- package/dist/src/hooks.d.ts +84 -0
- package/dist/src/hooks.js +125 -0
- package/dist/src/index.d.ts +31 -0
- package/dist/src/index.js +30 -0
- package/dist/src/liveness.d.ts +321 -0
- package/dist/src/liveness.js +396 -0
- package/dist/src/lock.d.ts +256 -0
- package/dist/src/lock.js +792 -0
- package/dist/src/model-resolution.d.ts +159 -0
- package/dist/src/model-resolution.js +307 -0
- package/dist/src/modes.d.ts +149 -0
- package/dist/src/modes.js +258 -0
- package/dist/src/path-identity.d.ts +34 -0
- package/dist/src/path-identity.js +48 -0
- package/dist/src/plan.d.ts +73 -0
- package/dist/src/plan.js +153 -0
- package/dist/src/pool.d.ts +326 -0
- package/dist/src/pool.js +988 -0
- package/dist/src/roles.d.ts +461 -0
- package/dist/src/roles.js +776 -0
- package/dist/src/spawn.d.ts +482 -0
- package/dist/src/spawn.js +1003 -0
- package/dist/src/status.d.ts +116 -0
- package/dist/src/status.js +148 -0
- package/dist/src/task.d.ts +504 -0
- package/dist/src/task.js +307 -0
- package/dist/src/teardown.d.ts +39 -0
- package/dist/src/teardown.js +422 -0
- package/dist/src/tuition.d.ts +159 -0
- package/dist/src/tuition.js +311 -0
- package/dist/src/validate.d.ts +263 -0
- package/dist/src/validate.js +742 -0
- package/dist/src/version.d.ts +3 -0
- package/dist/src/version.js +38 -0
- package/dist/src/watcher.d.ts +275 -0
- package/dist/src/watcher.js +849 -0
- package/dist/src/witness/run.d.ts +299 -0
- package/dist/src/witness/run.js +1373 -0
- package/dist/src/witness/spec.d.ts +270 -0
- package/dist/src/witness/spec.js +475 -0
- package/dist/tsconfig.src.tsbuildinfo +1 -0
- package/gate-registry.yaml +526 -0
- package/gates.manifest.json +257 -0
- package/package.json +72 -3
- package/role-model-config.yaml +88 -0
- package/roles/README.md +128 -0
- package/roles/_shared-dispatch-contract.md +87 -0
- package/roles/adversarial-plan-reviewer.md +80 -0
- package/roles/clean-room-reviewer.md +140 -0
- package/roles/implementer.md +463 -0
- package/roles/investigator.md +138 -0
- package/roles/plan-writer.md +95 -0
- package/schemas/README.md +82 -0
- package/schemas/assurance-modes.schema.json +264 -0
- package/schemas/charter.schema.json +185 -0
- package/schemas/checklist.schema.json +114 -0
- package/schemas/cutover-state.schema.json +64 -0
- package/schemas/decision-record.schema.json +88 -0
- package/schemas/executor-record.schema.json +36 -0
- package/schemas/final-report.schema.json +90 -0
- package/schemas/finding.schema.json +106 -0
- package/schemas/gate-registry.schema.json +260 -0
- package/schemas/mechanism-index.schema.json +94 -0
- package/schemas/model-resolution.schema.json +362 -0
- package/schemas/plan.schema.json +300 -0
- package/schemas/report.schema.json +579 -0
- package/schemas/role-brief.schema.json +105 -0
- package/schemas/role-model-config.schema.json +90 -0
- package/schemas/status-line.schema.json +40 -0
- package/schemas/tuition.schema.json +191 -0
- package/schemas/verdict.schema.json +295 -0
- package/schemas/work-history.schema.json +183 -0
- package/schemas/write-bypass.schema.json +69 -0
- package/templates/charter.example.yaml +54 -0
- package/templates/decision-record.example.yaml +27 -0
- package/templates/final-report.example.yaml +80 -0
- package/templates/plan.example.yaml +87 -0
- package/templates/report.example.yaml +236 -0
- package/templates/warnings.md +74 -0
- package/templates/work-history.example.yaml +185 -0
- package/tuition/README.md +76 -0
- package/tuition/T-001.yaml +48 -0
- package/tuition/T-002.yaml +51 -0
- package/tuition/T-003.yaml +100 -0
- package/tuition/T-004.yaml +52 -0
- package/tuition/T-005.yaml +72 -0
- package/tuition/T-006.yaml +81 -0
- package/tuition/T-007.yaml +56 -0
- package/tuition/T-008.yaml +111 -0
- package/tuition/T-009.yaml +50 -0
- package/tuition/T-015.yaml +36 -0
- package/tuition/T-016.yaml +36 -0
- package/tuition/T-017.yaml +46 -0
- package/tuition/T-018.yaml +84 -0
- package/tuition/T-021.yaml +40 -0
- package/tuition/T-022.yaml +36 -0
- package/tuition/mechanism-index.yaml +256 -0
package/dist/src/pool.js
ADDED
|
@@ -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
|
+
}
|