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