@gr8ful/spf 0.5.1 → 0.7.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/README.md +168 -29
- package/assets/defaults/spf.config.yaml +68 -0
- package/assets/prompts/refiner/system.md +42 -6
- package/assets/prompts/refiner/user.md +46 -8
- package/assets/skill/SKILL.md +1 -0
- package/assets/skill/references/config.md +192 -5
- package/assets/templates/ts-flue-ollama.spf.config.yaml +25 -0
- package/assets/templates/ts.spf.config.yaml +12 -2
- package/dist/chains/index.d.ts +11 -0
- package/dist/chains/index.js +38 -3
- package/dist/chains/repo_chains.js +1 -0
- package/dist/chains/simple_sdlc.js +1 -1
- package/dist/chains/steps.d.ts +8 -17
- package/dist/chains/steps.js +102 -14
- package/dist/cli/commands/doctor.js +112 -3
- package/dist/cli/commands/estimate.d.ts +82 -0
- package/dist/cli/commands/estimate.js +317 -0
- package/dist/cli/commands/fanout.d.ts +40 -0
- package/dist/cli/commands/fanout.js +401 -0
- package/dist/cli/commands/init.js +23 -1
- package/dist/cli/commands/run.js +9 -2
- package/dist/cli/commands/trace.d.ts +18 -0
- package/dist/cli/commands/trace.js +22 -3
- package/dist/cli/commands/watch.d.ts +8 -0
- package/dist/cli/commands/watch.js +95 -33
- package/dist/cli/index.js +13 -1
- package/dist/cli/interview.js +9 -5
- package/dist/core/agents.d.ts +56 -0
- package/dist/core/agents.js +152 -1
- package/dist/core/data_types.d.ts +340 -5
- package/dist/core/data_types.js +185 -5
- package/dist/core/fanout.d.ts +229 -0
- package/dist/core/fanout.js +313 -0
- package/dist/core/gates.d.ts +8 -0
- package/dist/core/gates.js +48 -2
- package/dist/core/git_helper.d.ts +98 -0
- package/dist/core/git_helper.js +127 -0
- package/dist/core/issues/github_provider.d.ts +46 -6
- package/dist/core/issues/github_provider.js +120 -5
- package/dist/core/issues/jira_provider.d.ts +83 -12
- package/dist/core/issues/jira_provider.js +110 -4
- package/dist/core/issues/provider.d.ts +124 -19
- package/dist/core/issues/provider.js +24 -7
- package/dist/core/notify/channel.d.ts +1 -1
- package/dist/core/notify/notifier.d.ts +16 -0
- package/dist/core/notify/notifier.js +36 -0
- package/dist/core/otel.d.ts +64 -9
- package/dist/core/otel.js +78 -14
- package/dist/core/refine.d.ts +45 -8
- package/dist/core/refine.js +98 -24
- package/dist/core/runner.d.ts +8 -0
- package/dist/core/runner.js +7 -0
- package/dist/core/session.d.ts +25 -0
- package/dist/core/session.js +97 -28
- package/dist/core/tiering.d.ts +145 -0
- package/dist/core/tiering.js +235 -0
- package/dist/core/tracer.d.ts +10 -0
- package/dist/core/tracer.js +12 -0
- package/dist/core/watch.d.ts +174 -11
- package/dist/core/watch.js +531 -33
- package/dist/ui/server/db.d.ts +39 -0
- package/dist/ui/server/db.js +61 -0
- package/package.json +2 -1
- package/dist/test/agent_cc.test.d.ts +0 -1
- package/dist/test/agent_cc.test.js +0 -95
- package/dist/test/agent_flue.test.d.ts +0 -1
- package/dist/test/agent_flue.test.js +0 -83
- package/dist/test/chains.test.d.ts +0 -12
- package/dist/test/chains.test.js +0 -92
- package/dist/test/data_types.test.d.ts +0 -10
- package/dist/test/data_types.test.js +0 -220
- package/dist/test/env_file.test.d.ts +0 -1
- package/dist/test/env_file.test.js +0 -74
- package/dist/test/fake_asker.d.ts +0 -23
- package/dist/test/fake_asker.js +0 -30
- package/dist/test/git_helper.test.d.ts +0 -1
- package/dist/test/git_helper.test.js +0 -59
- package/dist/test/hermetic_git.d.ts +0 -1
- package/dist/test/hermetic_git.js +0 -22
- package/dist/test/init_command.test.d.ts +0 -14
- package/dist/test/init_command.test.js +0 -136
- package/dist/test/interview.test.d.ts +0 -15
- package/dist/test/interview.test.js +0 -425
- package/dist/test/notify.test.d.ts +0 -1
- package/dist/test/notify.test.js +0 -174
- package/dist/test/ollama_provider.test.d.ts +0 -1
- package/dist/test/ollama_provider.test.js +0 -103
- package/dist/test/otel.test.d.ts +0 -26
- package/dist/test/otel.test.js +0 -512
- package/dist/test/paths.test.d.ts +0 -1
- package/dist/test/paths.test.js +0 -68
- package/dist/test/refine.test.d.ts +0 -1
- package/dist/test/refine.test.js +0 -189
- package/dist/test/repo_chains.test.d.ts +0 -21
- package/dist/test/repo_chains.test.js +0 -416
- package/dist/test/signoff.test.d.ts +0 -1
- package/dist/test/signoff.test.js +0 -329
- package/dist/test/ui_server.test.d.ts +0 -7
- package/dist/test/ui_server.test.js +0 -120
- package/dist/test/watch.test.d.ts +0 -1
- package/dist/test/watch.test.js +0 -687
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-of-N: run ONE prompt N times in parallel, then let CODE pick the winner.
|
|
3
|
+
*
|
|
4
|
+
* THE SHAPE IS NOT NEW, and that is the whole argument for it. `spf watch`
|
|
5
|
+
* already runs N chains CONCURRENTLY IN ONE PROCESS — each with its own git
|
|
6
|
+
* worktree, its own adw_id, its own `Run`, all sharing the main repo's WAL
|
|
7
|
+
* SQLite through a data-dir symlink (see `core/watch.ts`'s `runIssue`,
|
|
8
|
+
* `cli/commands/watch.ts`'s `runChain` closure and its `linkDataDir`). Fan-out
|
|
9
|
+
* is that proven mechanism pointed at one prompt instead of N issues: N
|
|
10
|
+
* sibling Runs with derived adw_ids over N worktrees, followed by a
|
|
11
|
+
* DETERMINISTIC `pickBest`. "Agent proposes, code disposes" — extended to
|
|
12
|
+
* SELECTION, which is the only new idea here.
|
|
13
|
+
*
|
|
14
|
+
* THE UNIT OF ISOLATION IS THE RUN, NEVER AN AGENT CALL. Every attempt gets a
|
|
15
|
+
* fresh worktree and a fresh Run whose `repo_root` is bound once, in the Run
|
|
16
|
+
* constructor. That is what keeps `gates.resolveClaim`,
|
|
17
|
+
* `permissions.snapshot/enforce`, the quality checks and `changes.capture`
|
|
18
|
+
* judging the tree their agents actually wrote in. A per-call cwd swap would
|
|
19
|
+
* point them at the wrong tree and hand out green gates on unverified work; a
|
|
20
|
+
* `SandboxHandle`/cwd-threading design was evaluated for exactly this and
|
|
21
|
+
* REJECTED. Do not reintroduce it.
|
|
22
|
+
*
|
|
23
|
+
* SELECTION READS SQLITE, NOT RETURN VALUES. Gate reports (`tracer.gateRow`)
|
|
24
|
+
* and per-run usage (`sessions.total_tokens`/`total_cost`) already land in the
|
|
25
|
+
* shared db keyed by adw_id, so the basis below is a SELECT away. Widening
|
|
26
|
+
* `steps.runSteps`' return contract to carry metrics back was evaluated and
|
|
27
|
+
* REJECTED as needless blast radius: every chain, every step, and the watch
|
|
28
|
+
* dispatch would change to serve one new caller. `runAttempt` still returns
|
|
29
|
+
* just an exit code, exactly like `runChainDef` does.
|
|
30
|
+
*
|
|
31
|
+
* WHY THE BUDGET SHIPS WITH THIS. An n-multiplier on spend without a ceiling
|
|
32
|
+
* is a footgun — the only budget-like constant before this changeset was
|
|
33
|
+
* `JSON_FIX_ATTEMPTS = 2`. `defaults.max_run_cost`/`max_run_tokens`
|
|
34
|
+
* (`core/agents.ts`'s `assertRunBudget`) are checked before each attempt's
|
|
35
|
+
* NEXT agent call, never after — so one attempt can still overshoot the
|
|
36
|
+
* ceiling by a whole call, and a chain with only ONE agent dispatch
|
|
37
|
+
* (`scout`, `prompt`, `build`) can never trip it at all. With that caveat, N
|
|
38
|
+
* attempts are bounded by roughly N × ceiling, and each one that DOES trip
|
|
39
|
+
* it fails its phase closed with a message naming the ceiling and the spend.
|
|
40
|
+
*
|
|
41
|
+
* WINNER HANDLING KEEPS THE HUMAN ACCOUNTABLE. Each attempt commits on its own
|
|
42
|
+
* branch in its own worktree. The winner's branch AND worktree are KEPT and
|
|
43
|
+
* reported (branch + adw_id + the selection basis); the losers' worktrees and
|
|
44
|
+
* branches are deleted. SPF DOES NOT MERGE THE WINNER — the operator does.
|
|
45
|
+
* Keeping the winner's worktree is deliberate, not laziness: a chain that
|
|
46
|
+
* doesn't commit (or that failed after writing) leaves work in the tree only,
|
|
47
|
+
* and `git worktree remove --force` would destroy it. `git merge`/
|
|
48
|
+
* `git cherry-pick` from the main repo work fine against a branch that is
|
|
49
|
+
* checked out elsewhere, so nothing is blocked by keeping it.
|
|
50
|
+
*
|
|
51
|
+
* ABORT GRANULARITY, stated plainly because it is a real limit — and, as of
|
|
52
|
+
* `deps.firstSuccess`, OPT-IN rather than the default. Best-of-N exists to
|
|
53
|
+
* COMPARE N candidates; the DEFAULT (`firstSuccess` unset/false) therefore
|
|
54
|
+
* dispatches every attempt regardless of how early one succeeds, so the
|
|
55
|
+
* gates/cost/tokens comparison in `pickBest` is over the whole field the
|
|
56
|
+
* operator actually asked for, not "whatever happened to still be queued."
|
|
57
|
+
* Passing `firstSuccess: true` restores first-past-the-post: once an attempt
|
|
58
|
+
* exits 0 the race is decided and no further attempt is STARTED —
|
|
59
|
+
* `deps.runAttempt` is never called for a queued attempt, which is where the
|
|
60
|
+
* saving actually is (with `concurrency` 2 and `--n 4`, half the attempts are
|
|
61
|
+
* typically never dispatched). Either way, attempts ALREADY IN FLIGHT run to
|
|
62
|
+
* completion: `steps.runSteps` has no cancellation seam, and adding one would
|
|
63
|
+
* mean changing the driver every chain shares for the benefit of this one
|
|
64
|
+
* caller. `AttemptDispatch.isAborted()` is threaded to the runner so a
|
|
65
|
+
* dispatch that CAN check between phases does when `firstSuccess` is on, and
|
|
66
|
+
* so a test can prove the flag is honored — cooperative by design, not by
|
|
67
|
+
* accident.
|
|
68
|
+
*/
|
|
69
|
+
import path from "node:path";
|
|
70
|
+
import { removeRunWorktree, withRunWorktree } from "./git_helper.js";
|
|
71
|
+
/** Sentinel exit code meaning "never dispatched — the race was already decided". Distinct from any real chain exit code. */
|
|
72
|
+
export const ABORTED_EXIT = -1;
|
|
73
|
+
export const ZERO_METRICS = { gate_passes: 0, gate_failures: 0, cost: 0, tokens: 0 };
|
|
74
|
+
// ── naming (deterministic, so a killed run leaves cleanable debris) ──────────
|
|
75
|
+
/** `<baseAdw>-<i>` — a derived, greppable sibling of the base id. */
|
|
76
|
+
export function attemptAdwId(baseAdwId, index) {
|
|
77
|
+
return `${baseAdwId}-${index}`;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* `spf/fanout/<baseAdw>-<i>` — namespaced so the winner is obvious in
|
|
81
|
+
* `git branch` and the losers are obviously disposable. Separate from
|
|
82
|
+
* `spf-watch/*`/`spf-refine/*` (see `core/watch.ts`) because these branches
|
|
83
|
+
* are never pushed by SPF: a human merges the winner from the local ref.
|
|
84
|
+
*/
|
|
85
|
+
export function attemptBranch(baseAdwId, index) {
|
|
86
|
+
return `spf/fanout/${attemptAdwId(baseAdwId, index)}`;
|
|
87
|
+
}
|
|
88
|
+
/** The worktree leaf name. Deterministic from the adw_id, like watch's `issue-<id>`. */
|
|
89
|
+
export function attemptWorktreeName(baseAdwId, index) {
|
|
90
|
+
return `fanout-${attemptAdwId(baseAdwId, index)}`;
|
|
91
|
+
}
|
|
92
|
+
export function attemptWorktreePath(worktreesDir, baseAdwId, index) {
|
|
93
|
+
return path.join(worktreesDir, attemptWorktreeName(baseAdwId, index));
|
|
94
|
+
}
|
|
95
|
+
// ── selection: the deterministic disposer ───────────────────────────────────
|
|
96
|
+
/**
|
|
97
|
+
* The selection basis, in order. Every criterion is read from the shared
|
|
98
|
+
* SQLite or measured by this module — never asked of an agent.
|
|
99
|
+
*
|
|
100
|
+
* 1. the run SUCCEEDED (exit 0). A failed attempt is not a candidate at all.
|
|
101
|
+
* 2. fewest gate FAILURES, then most gate PASSES. Failures first because zero
|
|
102
|
+
* is the bar: an attempt that needed a correction round is worse than one
|
|
103
|
+
* that never tripped a gate, regardless of how many gates each ran. Passes
|
|
104
|
+
* break that tie in favour of the attempt that VERIFIED more.
|
|
105
|
+
* 3. lowest total cost.
|
|
106
|
+
* 4. fewest tokens.
|
|
107
|
+
* 5. lowest wall time — a LAST RESORT, deliberately below cost and tokens:
|
|
108
|
+
* every code phase runs through `spawnSync` (`core/quality.ts`), which
|
|
109
|
+
* blocks the shared event loop, so "concurrent" attempts partly serialize
|
|
110
|
+
* on exactly the phases fan-out means to compare, and `wall_ms` ends up
|
|
111
|
+
* favouring whichever attempt happened to run last by a scheduling
|
|
112
|
+
* accident rather than anything about the candidate. Cost and tokens are
|
|
113
|
+
* read from the trace and are not subject to that; wall time still
|
|
114
|
+
* breaks a genuine tie between two runs identical on both.
|
|
115
|
+
* 6. lexicographic adw_id.
|
|
116
|
+
*
|
|
117
|
+
* (6) exists to make this a TOTAL order: adw_ids are unique per attempt, so
|
|
118
|
+
* the comparator can never return 0 for two different attempts, so the winner
|
|
119
|
+
* cannot depend on which attempt happened to finish first. That is what
|
|
120
|
+
* "deterministic" has to mean here — pinned by a permutation test in
|
|
121
|
+
* `src/test/fanout.test.ts`.
|
|
122
|
+
*/
|
|
123
|
+
export function compareAttempts(a, b) {
|
|
124
|
+
const rank = (attempt) => (attempt.status === "success" ? 0 : 1);
|
|
125
|
+
return (rank(a) - rank(b) ||
|
|
126
|
+
a.gate_failures - b.gate_failures ||
|
|
127
|
+
b.gate_passes - a.gate_passes ||
|
|
128
|
+
a.cost - b.cost ||
|
|
129
|
+
a.tokens - b.tokens ||
|
|
130
|
+
a.wall_ms - b.wall_ms ||
|
|
131
|
+
(a.adw_id < b.adw_id ? -1 : a.adw_id > b.adw_id ? 1 : 0));
|
|
132
|
+
}
|
|
133
|
+
/** Which criterion actually separated the winner from its closest rival — the honest half of a printed basis. */
|
|
134
|
+
function decidingCriterion(winner, runnerUp) {
|
|
135
|
+
if (winner.gate_failures !== runnerUp.gate_failures) {
|
|
136
|
+
return `fewer gate failures (${winner.gate_failures} vs ${runnerUp.gate_failures})`;
|
|
137
|
+
}
|
|
138
|
+
if (winner.gate_passes !== runnerUp.gate_passes) {
|
|
139
|
+
return `more gate passes (${winner.gate_passes} vs ${runnerUp.gate_passes})`;
|
|
140
|
+
}
|
|
141
|
+
if (winner.cost !== runnerUp.cost) {
|
|
142
|
+
return `lower cost ($${winner.cost.toFixed(4)} vs $${runnerUp.cost.toFixed(4)})`;
|
|
143
|
+
}
|
|
144
|
+
if (winner.tokens !== runnerUp.tokens) {
|
|
145
|
+
return `fewer tokens (${winner.tokens.toLocaleString("en-US")} vs ${runnerUp.tokens.toLocaleString("en-US")})`;
|
|
146
|
+
}
|
|
147
|
+
if (winner.wall_ms !== runnerUp.wall_ms) {
|
|
148
|
+
// Named as contended, not a clean property of the candidate — see the
|
|
149
|
+
// criteria list above.
|
|
150
|
+
return `lower wall time (${(winner.wall_ms / 1000).toFixed(1)}s vs ${(runnerUp.wall_ms / 1000).toFixed(1)}s — contended under concurrency, a last resort)`;
|
|
151
|
+
}
|
|
152
|
+
return `lexicographic adw_id tiebreak (${winner.adw_id} before ${runnerUp.adw_id})`;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Pick the winner. PURE: no I/O, no clock, no filesystem — everything it
|
|
156
|
+
* needs is already on the attempts, which is what makes the selection
|
|
157
|
+
* testable and reproducible from a trace.
|
|
158
|
+
*
|
|
159
|
+
* Sorts a COPY: the caller's array keeps its index order for printing.
|
|
160
|
+
*/
|
|
161
|
+
export function pickBest(attempts) {
|
|
162
|
+
const succeeded = attempts.filter((a) => a.status === "success");
|
|
163
|
+
if (succeeded.length === 0) {
|
|
164
|
+
return { winner: null, basis: `no attempt succeeded (${attempts.length} tried) — nothing to select` };
|
|
165
|
+
}
|
|
166
|
+
const ranked = [...succeeded].sort(compareAttempts);
|
|
167
|
+
const winner = ranked[0];
|
|
168
|
+
const summary = `${winner.gate_failures} gate failure(s), ${winner.gate_passes} gate pass(es), ` +
|
|
169
|
+
`$${winner.cost.toFixed(4)}, ${(winner.wall_ms / 1000).toFixed(1)}s`;
|
|
170
|
+
if (ranked.length === 1) {
|
|
171
|
+
return {
|
|
172
|
+
winner,
|
|
173
|
+
basis: `the only attempt that succeeded of ${attempts.length} — ${summary}`,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
winner,
|
|
178
|
+
basis: `${decidingCriterion(winner, ranked[1])} over ${ranked[1].adw_id}; ${succeeded.length} of ${attempts.length} attempt(s) succeeded — ${summary}`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Run `n` attempts of one prompt, select deterministically, clean up the
|
|
183
|
+
* losers, keep the winner.
|
|
184
|
+
*
|
|
185
|
+
* Concurrency is the same inflight-budget idea as `watch.claimNewWork`,
|
|
186
|
+
* expressed as a fixed worker pool over a shared queue: at most
|
|
187
|
+
* `concurrency` attempts are ever in flight, and a worker that finds the race
|
|
188
|
+
* already decided records the remaining attempts as `skipped` WITHOUT
|
|
189
|
+
* dispatching them. Attempts are returned in index order regardless of the
|
|
190
|
+
* order they finished — the printed table must not shuffle between runs.
|
|
191
|
+
*/
|
|
192
|
+
export async function runBestOf(deps) {
|
|
193
|
+
deps.log(`fanout: chain "${deps.chainName}" x${deps.n} off ${deps.baseBranch}, concurrency ${deps.concurrency}, ` +
|
|
194
|
+
`adw_id ${attemptAdwId(deps.baseAdwId, 1)}..${attemptAdwId(deps.baseAdwId, deps.n)}` +
|
|
195
|
+
(deps.firstSuccess ? ", first-success (stops dispatching once one attempt succeeds)" : ""));
|
|
196
|
+
const queue = Array.from({ length: deps.n }, (_, i) => i + 1);
|
|
197
|
+
const attempts = [];
|
|
198
|
+
/** Flipped exactly once, by the first attempt to exit 0. Never flipped back. */
|
|
199
|
+
let decided = false;
|
|
200
|
+
const runOne = async (index) => {
|
|
201
|
+
const adwId = attemptAdwId(deps.baseAdwId, index);
|
|
202
|
+
const branch = attemptBranch(deps.baseAdwId, index);
|
|
203
|
+
const worktree = attemptWorktreePath(deps.worktreesDir, deps.baseAdwId, index);
|
|
204
|
+
const started = performance.now();
|
|
205
|
+
let exitCode = null;
|
|
206
|
+
let error = null;
|
|
207
|
+
try {
|
|
208
|
+
exitCode = await withRunWorktree(deps.repoRoot, attemptWorktreeName(deps.baseAdwId, index), {
|
|
209
|
+
worktreesDir: deps.worktreesDir,
|
|
210
|
+
branch,
|
|
211
|
+
baseBranch: deps.baseBranch,
|
|
212
|
+
linkDataDir: deps.linkDataDir,
|
|
213
|
+
git: deps.git,
|
|
214
|
+
log: deps.log,
|
|
215
|
+
}, async (wt) => {
|
|
216
|
+
const code = await deps.runAttempt({
|
|
217
|
+
index,
|
|
218
|
+
adwId,
|
|
219
|
+
cwd: wt.path,
|
|
220
|
+
branch,
|
|
221
|
+
prompt: deps.prompt,
|
|
222
|
+
isAborted: () => decided,
|
|
223
|
+
});
|
|
224
|
+
// Only a successful attempt can be selected, so only a successful
|
|
225
|
+
// attempt's tree and branch are worth carrying past this scope.
|
|
226
|
+
// A failed one is cleaned up NOW rather than lingering while its
|
|
227
|
+
// siblings finish — its trace survives in the shared SQLite
|
|
228
|
+
// regardless, which is where a post-mortem looks anyway.
|
|
229
|
+
if (code === 0)
|
|
230
|
+
wt.keep();
|
|
231
|
+
return code;
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
catch (caught) {
|
|
235
|
+
error = caught.message;
|
|
236
|
+
deps.log(`fanout: attempt ${index} (${adwId}) errored: ${error}`);
|
|
237
|
+
}
|
|
238
|
+
if (exitCode === 0 && deps.firstSuccess)
|
|
239
|
+
decided = true;
|
|
240
|
+
let metrics = ZERO_METRICS;
|
|
241
|
+
try {
|
|
242
|
+
metrics = deps.readMetrics(adwId);
|
|
243
|
+
}
|
|
244
|
+
catch (caught) {
|
|
245
|
+
// Metrics are for RANKING, never for correctness — an attempt whose
|
|
246
|
+
// rows can't be read ranks as a zero-gate, zero-cost run rather than
|
|
247
|
+
// failing the whole fan-out.
|
|
248
|
+
deps.log(`fanout: could not read metrics for ${adwId}: ${caught.message}`);
|
|
249
|
+
}
|
|
250
|
+
// `ABORTED_EXIT` is a runner saying "I declined to spend" — a sibling won
|
|
251
|
+
// between this attempt leaving the queue and its first agent call. That is
|
|
252
|
+
// a skip, not a failure: reporting it as `fail` would make a clean
|
|
253
|
+
// early-abort look like N-1 broken runs in the result table.
|
|
254
|
+
const status = exitCode === 0 ? "success" : exitCode === ABORTED_EXIT ? "skipped" : error !== null ? "error" : "fail";
|
|
255
|
+
return {
|
|
256
|
+
index,
|
|
257
|
+
adw_id: adwId,
|
|
258
|
+
branch,
|
|
259
|
+
worktree,
|
|
260
|
+
status,
|
|
261
|
+
exit_code: exitCode,
|
|
262
|
+
error,
|
|
263
|
+
wall_ms: performance.now() - started,
|
|
264
|
+
kept: false,
|
|
265
|
+
...metrics,
|
|
266
|
+
};
|
|
267
|
+
};
|
|
268
|
+
const skipped = (index) => ({
|
|
269
|
+
index,
|
|
270
|
+
adw_id: attemptAdwId(deps.baseAdwId, index),
|
|
271
|
+
branch: attemptBranch(deps.baseAdwId, index),
|
|
272
|
+
worktree: attemptWorktreePath(deps.worktreesDir, deps.baseAdwId, index),
|
|
273
|
+
status: "skipped",
|
|
274
|
+
exit_code: ABORTED_EXIT,
|
|
275
|
+
error: null,
|
|
276
|
+
wall_ms: 0,
|
|
277
|
+
kept: false,
|
|
278
|
+
...ZERO_METRICS,
|
|
279
|
+
});
|
|
280
|
+
const worker = async () => {
|
|
281
|
+
for (;;) {
|
|
282
|
+
const index = queue.shift();
|
|
283
|
+
if (index === undefined)
|
|
284
|
+
return;
|
|
285
|
+
if (decided) {
|
|
286
|
+
deps.log(`fanout: attempt ${index} skipped — an earlier attempt already succeeded`);
|
|
287
|
+
attempts.push(skipped(index));
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
attempts.push(await runOne(index));
|
|
291
|
+
}
|
|
292
|
+
};
|
|
293
|
+
const workers = Array.from({ length: Math.max(1, Math.min(deps.concurrency, deps.n)) }, () => worker());
|
|
294
|
+
await Promise.all(workers);
|
|
295
|
+
attempts.sort((a, b) => a.index - b.index);
|
|
296
|
+
const { winner, basis } = pickBest(attempts);
|
|
297
|
+
// Cleanup is the disposer's last act, and it runs AFTER selection because a
|
|
298
|
+
// loser is only knowable once every attempt has been ranked. Failed and
|
|
299
|
+
// skipped attempts own nothing by now (the scoped worktree helper already
|
|
300
|
+
// removed a failed one; a skipped one never had a tree), so this only ever
|
|
301
|
+
// touches attempts that SUCCEEDED and lost.
|
|
302
|
+
for (const attempt of attempts) {
|
|
303
|
+
if (attempt.status !== "success")
|
|
304
|
+
continue;
|
|
305
|
+
if (winner && attempt.adw_id === winner.adw_id) {
|
|
306
|
+
attempt.kept = true;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
deps.log(`fanout: discarding attempt ${attempt.index} (${attempt.adw_id}) — branch ${attempt.branch}`);
|
|
310
|
+
removeRunWorktree(deps.repoRoot, { path: attempt.worktree, branch: attempt.branch }, { git: deps.git, log: deps.log });
|
|
311
|
+
}
|
|
312
|
+
return { winner, basis, attempts };
|
|
313
|
+
}
|
package/dist/core/gates.d.ts
CHANGED
|
@@ -40,6 +40,14 @@ export declare function verdictConsistent(envelope: EnvelopeBase, _run: RunConte
|
|
|
40
40
|
*
|
|
41
41
|
* "Container" and "leaf" are derived from the graph, not asserted by the
|
|
42
42
|
* agent: a node is a container iff some other node names it as `parent`.
|
|
43
|
+
*
|
|
44
|
+
* `questions` (see `RefineQuestionSchema`'s doc comment) is the human-in-the-
|
|
45
|
+
* loop escalation path, and it is mutually exclusive with `issues`: a
|
|
46
|
+
* refinement that raises material ambiguity must publish NOTHING this round
|
|
47
|
+
* — a partial tree pinned to an unanswered question is worse than none.
|
|
48
|
+
* When `questions` is non-empty this gate checks only its own shape (unique,
|
|
49
|
+
* non-blank ids; non-blank question text) and skips every `issues` rule
|
|
50
|
+
* below, since there is no tree to validate.
|
|
43
51
|
*/
|
|
44
52
|
export declare function refinementWellFormed(envelope: EnvelopeBase, _run: RunContext): GateReport;
|
|
45
53
|
/** Gate factory: the given shell command must exit 0, run from run.repo_root. */
|
package/dist/core/gates.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import { spawnSync } from "node:child_process";
|
|
18
18
|
import { existsSync, statSync, readFileSync } from "node:fs";
|
|
19
19
|
import path from "node:path";
|
|
20
|
-
import { GateReport } from "./data_types.js";
|
|
20
|
+
import { GateReport, PRIORITY_RANK } from "./data_types.js";
|
|
21
21
|
import { operatorEnv } from "./utils.js";
|
|
22
22
|
const TAIL_CHARS = 1000; // command output kept as evidence on a failure
|
|
23
23
|
/**
|
|
@@ -141,12 +141,35 @@ const LEAF_KINDS = new Set(["story", "bug", "task"]);
|
|
|
141
141
|
*
|
|
142
142
|
* "Container" and "leaf" are derived from the graph, not asserted by the
|
|
143
143
|
* agent: a node is a container iff some other node names it as `parent`.
|
|
144
|
+
*
|
|
145
|
+
* `questions` (see `RefineQuestionSchema`'s doc comment) is the human-in-the-
|
|
146
|
+
* loop escalation path, and it is mutually exclusive with `issues`: a
|
|
147
|
+
* refinement that raises material ambiguity must publish NOTHING this round
|
|
148
|
+
* — a partial tree pinned to an unanswered question is worse than none.
|
|
149
|
+
* When `questions` is non-empty this gate checks only its own shape (unique,
|
|
150
|
+
* non-blank ids; non-blank question text) and skips every `issues` rule
|
|
151
|
+
* below, since there is no tree to validate.
|
|
144
152
|
*/
|
|
145
153
|
export function refinementWellFormed(envelope, _run) {
|
|
146
154
|
const report = new GateReport();
|
|
147
155
|
const issues = envelope.issues ?? [];
|
|
156
|
+
const questions = envelope.questions ?? [];
|
|
157
|
+
if (questions.length > 0) {
|
|
158
|
+
report.check("issues empty while escalating", issues.length === 0, issues.length === 0
|
|
159
|
+
? "no issues published alongside the questions"
|
|
160
|
+
: `${issues.length} issue(s) published alongside ${questions.length} question(s) — escalating means publishing nothing this round`);
|
|
161
|
+
const seenIds = new Set();
|
|
162
|
+
for (const q of questions) {
|
|
163
|
+
const blank = !q.id.trim();
|
|
164
|
+
const dup = !blank && seenIds.has(q.id);
|
|
165
|
+
seenIds.add(q.id);
|
|
166
|
+
report.check(`question ${JSON.stringify(q.id)}`, !blank && !dup, blank ? "question id is blank" : dup ? "duplicate question id — every question needs a unique id within this round" : "id ok");
|
|
167
|
+
report.check(`question ${JSON.stringify(q.id)}.question`, q.question.trim().length > 0, q.question.trim().length > 0 ? "has text" : "question text is blank");
|
|
168
|
+
}
|
|
169
|
+
return report;
|
|
170
|
+
}
|
|
148
171
|
if (issues.length === 0) {
|
|
149
|
-
report.check("issues", false, "a refinement produced no issues at all — decompose the spec into at least one leaf");
|
|
172
|
+
report.check("issues", false, "a refinement produced no issues at all and raised no questions — decompose the spec into at least one leaf, or escalate what's ambiguous");
|
|
150
173
|
return report;
|
|
151
174
|
}
|
|
152
175
|
const byKey = new Map();
|
|
@@ -229,6 +252,29 @@ export function refinementWellFormed(envelope, _run) {
|
|
|
229
252
|
}
|
|
230
253
|
const leafCount = issues.filter((i) => !childKeys.has(i.key)).length;
|
|
231
254
|
report.check("has leaves", leafCount > 0, leafCount > 0 ? `${leafCount} leaf issue(s)` : "every node is a container — nothing here is independently workable");
|
|
255
|
+
// Monotonicity: no node may be MORE urgent than its own parent — p0 < p1 <
|
|
256
|
+
// p2 < p3, so "more urgent" is a lower rank. Only checked against `parent`
|
|
257
|
+
// (the containment edge), never `blocked_by` (a real dependency can easily
|
|
258
|
+
// be less urgent than the thing it blocks — a p3 prefactor gating a p0
|
|
259
|
+
// feature is normal, not a mistake). A violation here is a decomposition
|
|
260
|
+
// mistake worth a correction round-trip (this gate's `retries: 1` —
|
|
261
|
+
// steps.ts's `refine()`), not a hard block: a p0 story wearing a p3
|
|
262
|
+
// feature's parent almost always means the feature was mis-scored, not the
|
|
263
|
+
// story. The spec's OWN priority ceiling is enforced separately, in
|
|
264
|
+
// `core/refine.ts`'s `publish()` — this gate only knows about the tree,
|
|
265
|
+
// never the spec issue it came from.
|
|
266
|
+
for (const issue of issues) {
|
|
267
|
+
if (!issue.parent)
|
|
268
|
+
continue;
|
|
269
|
+
const parent = byKey.get(issue.parent);
|
|
270
|
+
if (!parent)
|
|
271
|
+
continue; // already reported above as an unresolved parent
|
|
272
|
+
const childRank = PRIORITY_RANK[issue.priority] ?? 2;
|
|
273
|
+
const parentRank = PRIORITY_RANK[parent.priority] ?? 2;
|
|
274
|
+
report.check(`${issue.key}.priority`, childRank >= parentRank, childRank >= parentRank
|
|
275
|
+
? `${issue.priority} — no more urgent than parent ${JSON.stringify(issue.parent)} (${parent.priority})`
|
|
276
|
+
: `${issue.priority} is more urgent than parent ${JSON.stringify(issue.parent)}'s ${parent.priority} — a container's priority is a ceiling for everything under it`);
|
|
277
|
+
}
|
|
232
278
|
return report;
|
|
233
279
|
}
|
|
234
280
|
/** Gate factory: the given shell command must exit 0, run from run.repo_root. */
|
|
@@ -86,3 +86,101 @@ export interface GitHandle {
|
|
|
86
86
|
}
|
|
87
87
|
/** Every operation this returns is bound to `repoRoot` — never `process.cwd()`. */
|
|
88
88
|
export declare function makeGit(repoRoot: string): GitHandle;
|
|
89
|
+
export interface RunWorktreeSpec {
|
|
90
|
+
/** Absolute path of the worktree's working directory. */
|
|
91
|
+
path: string;
|
|
92
|
+
/** The branch created at `path`. Deleting it requires removing the worktree first. */
|
|
93
|
+
branch: string;
|
|
94
|
+
}
|
|
95
|
+
export interface RunWorktreeOptions {
|
|
96
|
+
/** Absolute. OUTSIDE the repo — a worktree inside it would be walked by the repo's own tooling. */
|
|
97
|
+
worktreesDir: string;
|
|
98
|
+
/** Created by `git worktree add -b`, so it must NOT already exist (see the cleanup-first note below). */
|
|
99
|
+
branch: string;
|
|
100
|
+
/** Branch name only (`"main"`), not a remote ref — the remote prefix is applied by `resolveStartPoint`. */
|
|
101
|
+
baseBranch: string;
|
|
102
|
+
/** Default "origin". */
|
|
103
|
+
remote?: string;
|
|
104
|
+
/**
|
|
105
|
+
* Default true: fetch `remote/baseBranch` before branching, so a long-lived
|
|
106
|
+
* process doesn't fork every run off a stale base. Unlike watch's inlined
|
|
107
|
+
* `git.fetch(...)`, a FAILED fetch here is not fatal — see `resolveStartPoint`.
|
|
108
|
+
*/
|
|
109
|
+
fetchBase?: boolean;
|
|
110
|
+
/** Overrides the whole base resolution — an explicit ref (sha, tag, local branch) to branch from. */
|
|
111
|
+
startPoint?: string;
|
|
112
|
+
/** Wire `<worktree>/.spf/data` to the MAIN repo's data dir. Injected, never done here — see the section comment. */
|
|
113
|
+
linkDataDir?: (worktreePath: string) => void;
|
|
114
|
+
/** Bound to the MAIN repo root. Injected so a unit test needs no real repo; defaults to `makeGit(repoRoot)`. */
|
|
115
|
+
git?: GitHandle;
|
|
116
|
+
log?: (message: string) => void;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Create `<worktreesDir>/<name>` on a fresh `branch` off the resolved base,
|
|
120
|
+
* and wire its data dir. Returns the spec its caller must eventually hand to
|
|
121
|
+
* `removeRunWorktree`.
|
|
122
|
+
*
|
|
123
|
+
* CLEARS A STALE WORKTREE DIRECTORY FIRST, but only when it's safe to: the
|
|
124
|
+
* path is deterministic from `name`, so the only way it can already exist is
|
|
125
|
+
* a previous attempt that never reached its own cleanup (killed mid-run,
|
|
126
|
+
* crashed, machine restart) — OR a previous attempt whose kept payload lives
|
|
127
|
+
* only in that tree's uncommitted edits (see `cli/commands/fanout.ts`'s
|
|
128
|
+
* winner-handling note: a chain with no commit step leaves its work in the
|
|
129
|
+
* worktree, not on the branch). `git worktree remove --force` deletes
|
|
130
|
+
* modified/untracked files with no confirmation and exit 0, so blindly
|
|
131
|
+
* force-removing here would silently destroy a kept winner the moment a
|
|
132
|
+
* `--adw-id` retry reuses its deterministic name. Refuse instead, loudly,
|
|
133
|
+
* before touching anything. `worktreeRemove` is a no-op when there is
|
|
134
|
+
* nothing to remove, so a genuinely stale (clean) leftover is still cleared
|
|
135
|
+
* exactly as before. (Same reasoning as `core/watch.ts`'s `runIssue`, which
|
|
136
|
+
* clears its own worktree the same way — modulo this same guard.)
|
|
137
|
+
*
|
|
138
|
+
* ALSO REFUSES A PRE-EXISTING BRANCH up front, for the same reason and the
|
|
139
|
+
* same actionable-error idea: the fan-out winner's branch is handed to a
|
|
140
|
+
* human to merge and can share this exact deterministic name across separate
|
|
141
|
+
* invocations (an explicit `--adw-id` retry), so it is never deleted here —
|
|
142
|
+
* see `removeRunWorktree` for the only place that happens, once an attempt
|
|
143
|
+
* is known to be discarded. Checking first turns `git worktree add -b`'s raw
|
|
144
|
+
* `fatal: a branch named '...' already exists` (which every attempt would
|
|
145
|
+
* otherwise hit identically, for the least helpful reason) into one message
|
|
146
|
+
* naming the collision and the way out.
|
|
147
|
+
*/
|
|
148
|
+
export declare function createRunWorktree(repoRoot: string, name: string, opts: RunWorktreeOptions): RunWorktreeSpec;
|
|
149
|
+
/**
|
|
150
|
+
* Remove a run worktree and delete its branch. NEVER throws — cleanup runs on
|
|
151
|
+
* the failure path too, and a cleanup error must not replace the real error
|
|
152
|
+
* that got us here.
|
|
153
|
+
*
|
|
154
|
+
* Order matters and is not cosmetic: git refuses to delete a branch that is
|
|
155
|
+
* checked out in a worktree, so the worktree goes first. A caller that wants
|
|
156
|
+
* to KEEP the branch (the fan-out winner) must simply not call this.
|
|
157
|
+
*/
|
|
158
|
+
export declare function removeRunWorktree(repoRoot: string, worktree: RunWorktreeSpec, opts?: {
|
|
159
|
+
git?: GitHandle;
|
|
160
|
+
log?: (message: string) => void;
|
|
161
|
+
}): void;
|
|
162
|
+
/** What the callback of `withRunWorktree` is handed. */
|
|
163
|
+
export interface RunWorktreeHandle extends RunWorktreeSpec {
|
|
164
|
+
/**
|
|
165
|
+
* Make this worktree and its branch SURVIVE the scope.
|
|
166
|
+
*
|
|
167
|
+
* The whole point of the scoped form: a caller that cannot decide the fate
|
|
168
|
+
* of a tree until it sees the result (fan-out keeps a successful attempt's
|
|
169
|
+
* branch so it can still be selected and, if it wins, handed to a human;
|
|
170
|
+
* a failed attempt's tree is worthless the moment it fails) says so from
|
|
171
|
+
* inside, once it knows. Not calling it is the default, and the default is
|
|
172
|
+
* cleanup.
|
|
173
|
+
*/
|
|
174
|
+
keep(): void;
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Run `fn` against a fresh run worktree, cleaning it up afterwards unless
|
|
178
|
+
* `wt.keep()` was called — including when `fn` throws, which is the case a
|
|
179
|
+
* hand-rolled create/remove pair reliably gets wrong.
|
|
180
|
+
*
|
|
181
|
+
* `opts` sits BEFORE the callback (a small departure from the usual
|
|
182
|
+
* `(a, b, fn)` shape) so the required options can't be forgotten behind an
|
|
183
|
+
* optional trailing argument, and so the callback stays the last thing you
|
|
184
|
+
* read — the JS convention that matters here.
|
|
185
|
+
*/
|
|
186
|
+
export declare function withRunWorktree<T>(repoRoot: string, name: string, opts: RunWorktreeOptions, fn: (worktree: RunWorktreeHandle) => Promise<T>): Promise<T>;
|
package/dist/core/git_helper.js
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* and conflating them is exactly the anchor bug this factory exists to close.
|
|
11
11
|
*/
|
|
12
12
|
import { spawnSync } from "node:child_process";
|
|
13
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
13
14
|
import path from "node:path";
|
|
14
15
|
function git(args, cwd) {
|
|
15
16
|
const result = spawnSync("git", args, { cwd, encoding: "utf-8" });
|
|
@@ -153,3 +154,129 @@ export function makeGit(repoRoot) {
|
|
|
153
154
|
},
|
|
154
155
|
};
|
|
155
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* Where the base of a new run worktree comes from.
|
|
159
|
+
*
|
|
160
|
+
* A fetch failure is logged, not thrown: `spf fanout` is a local, interactive
|
|
161
|
+
* command that must still work on a repo with no remote, offline, or behind a
|
|
162
|
+
* proxy — and `refExists` below then falls back to the LOCAL base branch,
|
|
163
|
+
* which is the honest answer in all three cases. (Watch differs on purpose:
|
|
164
|
+
* a daemon that silently starts branching off a stale local base for hours is
|
|
165
|
+
* worse than a loud failure, so it keeps its unguarded `git.fetch`.)
|
|
166
|
+
*/
|
|
167
|
+
function resolveStartPoint(git, opts) {
|
|
168
|
+
if (opts.startPoint)
|
|
169
|
+
return opts.startPoint;
|
|
170
|
+
const remote = opts.remote ?? "origin";
|
|
171
|
+
if (opts.fetchBase !== false) {
|
|
172
|
+
try {
|
|
173
|
+
git.fetch(remote, opts.baseBranch);
|
|
174
|
+
}
|
|
175
|
+
catch (error) {
|
|
176
|
+
// First line only: `git fetch`'s own error is the underlying command's
|
|
177
|
+
// full multiline stderr, and an offline/local-only repo hits this on
|
|
178
|
+
// EVERY attempt — five lines of "'origin' does not appear to be a git
|
|
179
|
+
// repository" repeated N times drowns out everything else fan-out prints.
|
|
180
|
+
const firstLine = error.message.split("\n")[0];
|
|
181
|
+
opts.log?.(`worktree: fetch ${remote} ${opts.baseBranch} failed (${firstLine}) — using the local base`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const remoteRef = `${remote}/${opts.baseBranch}`;
|
|
185
|
+
return git.refExists(remoteRef) ? remoteRef : opts.baseBranch;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Create `<worktreesDir>/<name>` on a fresh `branch` off the resolved base,
|
|
189
|
+
* and wire its data dir. Returns the spec its caller must eventually hand to
|
|
190
|
+
* `removeRunWorktree`.
|
|
191
|
+
*
|
|
192
|
+
* CLEARS A STALE WORKTREE DIRECTORY FIRST, but only when it's safe to: the
|
|
193
|
+
* path is deterministic from `name`, so the only way it can already exist is
|
|
194
|
+
* a previous attempt that never reached its own cleanup (killed mid-run,
|
|
195
|
+
* crashed, machine restart) — OR a previous attempt whose kept payload lives
|
|
196
|
+
* only in that tree's uncommitted edits (see `cli/commands/fanout.ts`'s
|
|
197
|
+
* winner-handling note: a chain with no commit step leaves its work in the
|
|
198
|
+
* worktree, not on the branch). `git worktree remove --force` deletes
|
|
199
|
+
* modified/untracked files with no confirmation and exit 0, so blindly
|
|
200
|
+
* force-removing here would silently destroy a kept winner the moment a
|
|
201
|
+
* `--adw-id` retry reuses its deterministic name. Refuse instead, loudly,
|
|
202
|
+
* before touching anything. `worktreeRemove` is a no-op when there is
|
|
203
|
+
* nothing to remove, so a genuinely stale (clean) leftover is still cleared
|
|
204
|
+
* exactly as before. (Same reasoning as `core/watch.ts`'s `runIssue`, which
|
|
205
|
+
* clears its own worktree the same way — modulo this same guard.)
|
|
206
|
+
*
|
|
207
|
+
* ALSO REFUSES A PRE-EXISTING BRANCH up front, for the same reason and the
|
|
208
|
+
* same actionable-error idea: the fan-out winner's branch is handed to a
|
|
209
|
+
* human to merge and can share this exact deterministic name across separate
|
|
210
|
+
* invocations (an explicit `--adw-id` retry), so it is never deleted here —
|
|
211
|
+
* see `removeRunWorktree` for the only place that happens, once an attempt
|
|
212
|
+
* is known to be discarded. Checking first turns `git worktree add -b`'s raw
|
|
213
|
+
* `fatal: a branch named '...' already exists` (which every attempt would
|
|
214
|
+
* otherwise hit identically, for the least helpful reason) into one message
|
|
215
|
+
* naming the collision and the way out.
|
|
216
|
+
*/
|
|
217
|
+
export function createRunWorktree(repoRoot, name, opts) {
|
|
218
|
+
const git = opts.git ?? makeGit(repoRoot);
|
|
219
|
+
const worktreePath = path.join(opts.worktreesDir, name);
|
|
220
|
+
if (existsSync(worktreePath)) {
|
|
221
|
+
const status = spawnSync("git", ["status", "--porcelain"], { cwd: worktreePath, encoding: "utf-8" });
|
|
222
|
+
if (status.status === 0 && status.stdout.trim() !== "") {
|
|
223
|
+
throw new Error(`${worktreePath} already exists and has uncommitted changes — refusing to remove it (it may be a ` +
|
|
224
|
+
`previous run's kept winner). Inspect or move it by hand, then retry, or use a fresh --adw-id.`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
try {
|
|
228
|
+
git.worktreeRemove(worktreePath);
|
|
229
|
+
}
|
|
230
|
+
catch (error) {
|
|
231
|
+
opts.log?.(`worktree: cleanup warning for ${worktreePath}: ${error.message}`);
|
|
232
|
+
}
|
|
233
|
+
if (git.refExists(opts.branch)) {
|
|
234
|
+
throw new Error(`branch ${opts.branch} already exists — delete it (\`git branch -D ${opts.branch}\`) once you no longer ` +
|
|
235
|
+
`need it, or use a fresh --adw-id`);
|
|
236
|
+
}
|
|
237
|
+
mkdirSync(opts.worktreesDir, { recursive: true });
|
|
238
|
+
git.worktreeAdd(worktreePath, opts.branch, resolveStartPoint(git, opts));
|
|
239
|
+
opts.linkDataDir?.(worktreePath);
|
|
240
|
+
return { path: worktreePath, branch: opts.branch };
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Remove a run worktree and delete its branch. NEVER throws — cleanup runs on
|
|
244
|
+
* the failure path too, and a cleanup error must not replace the real error
|
|
245
|
+
* that got us here.
|
|
246
|
+
*
|
|
247
|
+
* Order matters and is not cosmetic: git refuses to delete a branch that is
|
|
248
|
+
* checked out in a worktree, so the worktree goes first. A caller that wants
|
|
249
|
+
* to KEEP the branch (the fan-out winner) must simply not call this.
|
|
250
|
+
*/
|
|
251
|
+
export function removeRunWorktree(repoRoot, worktree, opts = {}) {
|
|
252
|
+
const git = opts.git ?? makeGit(repoRoot);
|
|
253
|
+
try {
|
|
254
|
+
git.worktreeRemove(worktree.path);
|
|
255
|
+
git.deleteLocalBranch(worktree.branch);
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
opts.log?.(`worktree: cleanup warning for ${worktree.path}: ${error.message}`);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Run `fn` against a fresh run worktree, cleaning it up afterwards unless
|
|
263
|
+
* `wt.keep()` was called — including when `fn` throws, which is the case a
|
|
264
|
+
* hand-rolled create/remove pair reliably gets wrong.
|
|
265
|
+
*
|
|
266
|
+
* `opts` sits BEFORE the callback (a small departure from the usual
|
|
267
|
+
* `(a, b, fn)` shape) so the required options can't be forgotten behind an
|
|
268
|
+
* optional trailing argument, and so the callback stays the last thing you
|
|
269
|
+
* read — the JS convention that matters here.
|
|
270
|
+
*/
|
|
271
|
+
export async function withRunWorktree(repoRoot, name, opts, fn) {
|
|
272
|
+
const spec = createRunWorktree(repoRoot, name, opts);
|
|
273
|
+
let kept = false;
|
|
274
|
+
const handle = { ...spec, keep: () => void (kept = true) };
|
|
275
|
+
try {
|
|
276
|
+
return await fn(handle);
|
|
277
|
+
}
|
|
278
|
+
finally {
|
|
279
|
+
if (!kept)
|
|
280
|
+
removeRunWorktree(repoRoot, spec, { git: opts.git, log: opts.log });
|
|
281
|
+
}
|
|
282
|
+
}
|