omp-conductor 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/src/worker.ts ADDED
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Runs exactly one omp coding session for one issue, under caps this file
3
+ * enforces itself.
4
+ *
5
+ * The caps are the point. A worker that is *asked* to respect a turn budget
6
+ * will talk itself out of it around turn 80, so the ceiling lives out here:
7
+ * count the turns, watch the clock, and abort the session. Likewise the final
8
+ * state is parsed out of the worker's own report rather than trusted — an
9
+ * unreadable report is a failure, because the alternative is unverified work
10
+ * sliding into a merge queue.
11
+ */
12
+
13
+ import { createSession, disposeSession } from "./omp.ts";
14
+ import type { Caps, RunState } from "./types.ts";
15
+
16
+ /** A PR link the worker pushed, recognised anywhere in its report. */
17
+ const PR_URL_PATTERN = /https:\/\/github\.com\/\S+\/pull\/\d+/;
18
+
19
+ /** `{{KEY}}` placeholders in a brief template. */
20
+ const PLACEHOLDER_PATTERN = /\{\{([A-Za-z0-9_]+)\}\}/g;
21
+
22
+ /**
23
+ * Which ceiling stopped a run. Only ever set alongside `state: "killed"`: the
24
+ * turn counter caught a loop, or the wall clock caught a session that was stuck
25
+ * without spending turns.
26
+ */
27
+ export type KilledBy = "turns" | "wallclock";
28
+
29
+ export interface WorkerOpts {
30
+ brief: string;
31
+ cwd: string;
32
+ caps: Caps;
33
+ /**
34
+ * Directory the harness writes this run's transcript into — a directory, not
35
+ * a file. The SDK takes no `sessionFile` input, so naming a path here would
36
+ * only name one nothing ever writes to. Omitted, the harness picks its own
37
+ * location; either way the real path comes back on {@link WorkerResult}.
38
+ */
39
+ sessionDir?: string;
40
+ /**
41
+ * Model pattern for this session, in omp's model/role syntax. Omitted leaves
42
+ * the harness to pick, which is what an unconfigured project wants.
43
+ */
44
+ model?: string;
45
+ onTurn?: (n: number) => void;
46
+ }
47
+
48
+ /**
49
+ * The one collaborator worth injecting: starting a session is the only thing
50
+ * `runWorker` does that needs a real harness. Defaulted, so production callers
51
+ * never pass it and a test can hand over a fake without a live peer dependency.
52
+ */
53
+ export interface RunWorkerDeps {
54
+ createSession: typeof createSession;
55
+ }
56
+
57
+ export interface WorkerResult {
58
+ state: RunState;
59
+ prUrl?: string;
60
+ turns: number;
61
+ spendUsd: number;
62
+ report: string;
63
+ killedBy?: KilledBy;
64
+ /**
65
+ * Transcript the session actually opened, absent if it opened none. Recorded
66
+ * per run because it is the only readable evidence left once the worktree is
67
+ * cleaned up.
68
+ */
69
+ sessionFile?: string;
70
+ /**
71
+ * Set when the harness could not honour {@link WorkerOpts.model} and used
72
+ * another. Carried out rather than swallowed: a run that quietly read dumber
73
+ * is otherwise indistinguishable from a run that was merely unlucky.
74
+ */
75
+ modelFallbackMessage?: string;
76
+ }
77
+
78
+ /**
79
+ * Fill `{{KEY}}` placeholders from `vars`.
80
+ *
81
+ * An unknown key is left verbatim rather than blanked: a brief that reads
82
+ * `Fix {{ISSUE}}` is obviously broken to whoever reads the transcript, whereas
83
+ * `Fix undefined` looks like an instruction.
84
+ */
85
+ export function renderBrief(template: string, vars: Record<string, string>): string {
86
+ return template.replace(PLACEHOLDER_PATTERN, (placeholder, key: string) => {
87
+ // Own properties only, so `{{constructor}}` cannot reach up the prototype.
88
+ if (!Object.hasOwn(vars, key)) return placeholder;
89
+ const value = vars[key];
90
+ return value === undefined ? placeholder : value;
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Read the run's outcome out of the worker's final report.
96
+ *
97
+ * Success has to be claimed explicitly (`pushed-green`); everything else,
98
+ * including an empty or unparseable report, is a failure. Defaulting the other
99
+ * way would let a session that died mid-thought be reported as merge-ready.
100
+ */
101
+ export function deriveResult(report: string): { state: RunState; prUrl?: string } {
102
+ const haystack = report.toLowerCase();
103
+ const state: RunState = haystack.includes("pushed-green")
104
+ ? "pushed-green"
105
+ : haystack.includes("ci-red")
106
+ ? "failed"
107
+ : haystack.includes("blocked")
108
+ ? "blocked"
109
+ : "failed";
110
+ const prUrl = PR_URL_PATTERN.exec(report)?.[0];
111
+ return prUrl === undefined ? { state } : { state, prUrl };
112
+ }
113
+
114
+ /**
115
+ * Is this `agent_end` the end of the run?
116
+ *
117
+ * `isTerminal: false` means the harness will resume the session for maintenance
118
+ * or async delivery, so the messages so far are a snapshot rather than a
119
+ * result: completing there truncates the worker mid-flight and reports a
120
+ * partial run as final. An absent field is terminal — that is what older
121
+ * harness builds send, and treating it as non-terminal would hang every run.
122
+ *
123
+ * Exported because it is the one seam that lets this rule be tested without
124
+ * standing up an SDK session.
125
+ */
126
+ export function shouldComplete(event: { isTerminal?: boolean }): boolean {
127
+ return event.isTerminal !== false;
128
+ }
129
+
130
+ /**
131
+ * Drive one session to completion, a cap, or a failure. Never throws for a
132
+ * failed run — a rejected `prompt()` is reported as `state: "failed"` so the
133
+ * dispatcher's retry/escalate logic has one shape to reason about.
134
+ */
135
+ export async function runWorker(
136
+ o: WorkerOpts,
137
+ deps: RunWorkerDeps = { createSession },
138
+ ): Promise<WorkerResult> {
139
+ // Read the caps once, by value: `o.caps` belongs to the caller's config.
140
+ const { workerMaxTurns, workerWallClockMs } = o.caps;
141
+
142
+ const session = await deps.createSession({
143
+ cwd: o.cwd,
144
+ ...(o.sessionDir === undefined ? {} : { sessionDir: o.sessionDir }),
145
+ ...(o.model === undefined ? {} : { model: o.model }),
146
+ });
147
+
148
+ // Every exit below reports the session's own facts the same way: the
149
+ // transcript it actually opened, and any model downgrade it announced. Read at
150
+ // return time so a session that materialises either late is still reported
151
+ // honestly.
152
+ const withSessionFacts = (result: WorkerResult): WorkerResult => {
153
+ const { sessionFile, modelFallbackMessage } = session;
154
+ return {
155
+ ...result,
156
+ ...(sessionFile === undefined ? {} : { sessionFile }),
157
+ ...(modelFallbackMessage === undefined ? {} : { modelFallbackMessage }),
158
+ };
159
+ };
160
+
161
+ let turns = 0;
162
+ let spendUsd = 0;
163
+ let report = "";
164
+ let killedBy: KilledBy | undefined;
165
+ // Bun's global timer handle; cleared on every exit path below.
166
+ let timer: Timer | undefined;
167
+ // Resolved by the first terminal `agent_end`, and by every cap kill. Only
168
+ // ever awaited when the harness has already said it is not finished.
169
+ const { promise: settled, resolve: settle } = Promise.withResolvers<void>();
170
+ // Set by a non-terminal `agent_end`: the harness will resume this session.
171
+ let resuming = false;
172
+
173
+ const clearWallClock = () => {
174
+ if (timer === undefined) return;
175
+ clearTimeout(timer);
176
+ timer = undefined;
177
+ };
178
+
179
+ const kill = (by: KilledBy) => {
180
+ if (killedBy !== undefined) return;
181
+ killedBy = by;
182
+ clearWallClock();
183
+ session.abort();
184
+ // An aborted session may never reach a terminal `agent_end`. The cap is the
185
+ // outcome now, so nothing may still be waiting for one.
186
+ settle();
187
+ };
188
+
189
+ session.on("turn_start", () => {
190
+ // The documented watchdog signal, and the honest one: `turn_start` fires
191
+ // exactly once per turn, whereas one turn can emit several assistant
192
+ // `message_end`s and would burn the cap on a run that is behaving.
193
+ turns += 1;
194
+ o.onTurn?.(turns);
195
+ if (turns > workerMaxTurns) kill("turns");
196
+ });
197
+
198
+ session.on("message_end", (event) => {
199
+ const message = field(event, "message");
200
+ if (field(message, "role") !== "assistant") return;
201
+ // Keep the newest non-empty assistant text: whatever the worker said last
202
+ // is its report, whether it finished cleanly or was cut off.
203
+ const text = reportText(field(message, "content"));
204
+ if (text !== "") report = text;
205
+ });
206
+
207
+ session.on("agent_end", (event) => {
208
+ // ponytail: cost only arrives when the harness run carries telemetry, so
209
+ // spend can legitimately read 0 and the daily-spend cap then leans on the
210
+ // turn and wall-clock ceilings. Upgrade path: pass a telemetry config
211
+ // through `createSession` once the harness exposes it on the SDK options.
212
+ const estimated = field(field(field(event, "telemetry"), "cost"), "estimatedUsd");
213
+ if (typeof estimated === "number" && Number.isFinite(estimated)) spendUsd += estimated;
214
+
215
+ // Anything that is not literally `false` — including garbage or nothing at
216
+ // all — is a finished run.
217
+ const isTerminal = field(event, "isTerminal");
218
+ if (shouldComplete(typeof isTerminal === "boolean" ? { isTerminal } : {})) {
219
+ settle();
220
+ return;
221
+ }
222
+ resuming = true;
223
+ });
224
+
225
+ // A stuck session spends no turns, so turns alone cannot detect it. The
226
+ // callback does not drop the handle itself: every exit runs `clearWallClock()`
227
+ // exactly once instead, and clearing an already-fired handle is a documented
228
+ // no-op — cheaper than assuming a fired timer holds nothing.
229
+ timer = setTimeout(() => kill("wallclock"), workerWallClockMs);
230
+
231
+ try {
232
+ await session.prompt(o.brief);
233
+ // `prompt()` returning is not the end of the run once the harness has
234
+ // announced a resume: finishing here would hand back a truncated report as
235
+ // the final result. Wait for the terminal `agent_end`, or for a cap.
236
+ if (resuming && killedBy === undefined) await settled;
237
+ } catch (cause) {
238
+ // Our own abort surfaces here on some paths; that is a kill, not a crash.
239
+ if (killedBy === undefined) {
240
+ const detail = cause instanceof Error ? cause.message : String(cause);
241
+ return withSessionFacts({
242
+ state: "failed",
243
+ turns,
244
+ spendUsd,
245
+ report: report === "" ? detail : report,
246
+ });
247
+ }
248
+ } finally {
249
+ // Runs on every exit, including the early return above: a live timer keeps
250
+ // the dispatcher process alive long after the run it was guarding.
251
+ clearWallClock();
252
+ try {
253
+ await disposeSession(session);
254
+ } catch {
255
+ // Teardown noise must not overwrite the run's actual outcome.
256
+ }
257
+ }
258
+
259
+ if (killedBy !== undefined) {
260
+ return withSessionFacts({ state: "killed", turns, spendUsd, report, killedBy });
261
+ }
262
+
263
+ const { state, prUrl } = deriveResult(report);
264
+ return withSessionFacts(
265
+ prUrl === undefined
266
+ ? { state, turns, spendUsd, report }
267
+ : { state, prUrl, turns, spendUsd, report },
268
+ );
269
+ }
270
+
271
+ /**
272
+ * Read one property off an unvalidated harness event. The event union lives in
273
+ * the peer dependency, so the worker narrows the handful of fields it reads
274
+ * instead of importing types it cannot see at build time.
275
+ */
276
+ function field(source: unknown, key: string): unknown {
277
+ if (source === null || typeof source !== "object") return undefined;
278
+ return Reflect.get(source, key);
279
+ }
280
+
281
+ /** Flatten an assistant message's content blocks to their plain text. */
282
+ function reportText(content: unknown): string {
283
+ if (typeof content === "string") return content.trim();
284
+ const blocks: readonly unknown[] = Array.isArray(content) ? content : [];
285
+ const parts: string[] = [];
286
+ for (const block of blocks) {
287
+ if (field(block, "type") !== "text") continue;
288
+ const text = field(block, "text");
289
+ if (typeof text === "string") parts.push(text);
290
+ }
291
+ return parts.join("\n").trim();
292
+ }
@@ -0,0 +1,303 @@
1
+ /**
2
+ * Checkout provisioning for one run.
3
+ *
4
+ * Every issue gets its own working tree so two workers can never see each
5
+ * other's half-finished edits. The trees are cut from a per-repo bare mirror
6
+ * that is cloned once and refreshed, rather than a fresh full clone per issue:
7
+ * a module repo's history is fetched one time and every later run pays only
8
+ * for the delta.
9
+ */
10
+
11
+ import { existsSync, mkdirSync, rmSync } from "node:fs";
12
+ import { join } from "node:path";
13
+
14
+ import type { RepoTarget } from "./types.ts";
15
+
16
+ /**
17
+ * Normal-clone fetch semantics. Deliberately *not* the mirror's own
18
+ * `+refs/*:refs/*`: see `configureMirror`.
19
+ */
20
+ const TRACKING_REFSPEC = "+refs/heads/*:refs/remotes/origin/*";
21
+
22
+ /** Matches the `user:token@` part of any URL, so it can be blanked out. */
23
+ const URL_USERINFO = /([a-zA-Z][a-zA-Z0-9+.-]*:\/\/)[^\s/@]+@/g;
24
+
25
+ /**
26
+ * The on-disk layout, in one place. Both are pure functions of config, so a
27
+ * caller that has to clean up *before* provisioning — the dispatcher on a
28
+ * retry, or reconciling orphaned trees at startup — can name a mirror or a
29
+ * tree without a network hop, and cannot drift from what `ensureMirror` and
30
+ * `addWorktree` will actually create.
31
+ */
32
+ export function mirrorPathFor(repo: RepoTarget, mirrorRoot: string): string {
33
+ return join(mirrorRoot, `${repo.name}.git`);
34
+ }
35
+
36
+ /** One tree per issue: a retry reuses the number, never the contents. */
37
+ export function worktreePathFor(workspaceRoot: string, issue: number): string {
38
+ return join(workspaceRoot, String(issue));
39
+ }
40
+
41
+ /** One git invocation, decoded. Nothing here judges the exit code. */
42
+ async function runGit(
43
+ args: string[],
44
+ cwd?: string,
45
+ ): Promise<{ code: number; stdout: string; stderr: string }> {
46
+ const proc = Bun.spawn(["git", ...args], {
47
+ cwd,
48
+ stdin: "ignore",
49
+ stdout: "pipe",
50
+ stderr: "pipe",
51
+ // An unattended dispatcher must fail loudly rather than block forever on a
52
+ // credential prompt nobody is there to answer.
53
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
54
+ });
55
+
56
+ const [stdout, stderr, code] = await Promise.all([
57
+ new Response(proc.stdout).text(),
58
+ new Response(proc.stderr).text(),
59
+ proc.exited,
60
+ ]);
61
+
62
+ return { code, stdout, stderr };
63
+ }
64
+
65
+ /**
66
+ * The one way this module runs git *for effect*. Non-zero exit throws with the
67
+ * argv, the exit code and stderr, because a bare "git failed" in a daemon log
68
+ * is worth nothing at 3am.
69
+ */
70
+ async function git(args: string[], cwd?: string): Promise<string> {
71
+ const { code, stdout, stderr } = await runGit(args, cwd);
72
+
73
+ if (code !== 0) {
74
+ const where = cwd === undefined ? "" : ` (cwd ${cwd})`;
75
+ const detail = stderr.trim() || stdout.trim() || "no output";
76
+ // A clone URL can carry a token, and it lands in both our argv and git's
77
+ // own error text; this string reaches logs and humans, so scrub it.
78
+ throw new Error(
79
+ `git ${args.join(" ")}${where} exited ${code}: ${detail}`.replace(
80
+ URL_USERINFO,
81
+ "$1***@",
82
+ ),
83
+ );
84
+ }
85
+
86
+ return stdout.trim();
87
+ }
88
+
89
+ /**
90
+ * The way this module runs git *as a question*. For plumbing like `show-ref`
91
+ * a non-zero exit is the answer "no", not a failure, so it must not throw —
92
+ * otherwise every probe needs a try/catch that also swallows real breakage.
93
+ */
94
+ async function gitSucceeds(args: string[], cwd?: string): Promise<boolean> {
95
+ const { code } = await runGit(args, cwd);
96
+ return code === 0;
97
+ }
98
+
99
+ /**
100
+ * Rewrites the two `clone --mirror` defaults that are actively dangerous for a
101
+ * cache we cut worktrees from. Applied on every `ensureMirror` so a mirror left
102
+ * by an older build heals itself instead of behaving differently forever.
103
+ *
104
+ * - `remote.origin.mirror=true` makes a bare `git push` from *any* worktree of
105
+ * this repository behave as `push --mirror`, force-updating the remote from
106
+ * our local refs and deleting whatever we do not happen to have. Worktrees
107
+ * share the mirror's config, so that footgun is pointed straight at the
108
+ * worker.
109
+ * - `fetch=+refs/*:refs/*` puts the upstream branches and our per-run branches
110
+ * in the same namespace, so a `--prune` refresh would try to delete every run
111
+ * branch that has not been pushed yet.
112
+ *
113
+ * With normal-clone semantics a refresh only ever moves `refs/remotes/origin/*`
114
+ * and cannot touch a live run.
115
+ */
116
+ async function configureMirror(mirrorPath: string): Promise<void> {
117
+ await git(
118
+ ["config", "--replace-all", "remote.origin.mirror", "false"],
119
+ mirrorPath,
120
+ );
121
+ await git(
122
+ ["config", "--replace-all", "remote.origin.fetch", TRACKING_REFSPEC],
123
+ mirrorPath,
124
+ );
125
+ }
126
+
127
+ /**
128
+ * Returns the path of the bare mirror for `repo`, cloning it on first use and
129
+ * refreshing it otherwise.
130
+ *
131
+ * ponytail: no cross-process lock. Two dispatch loops that call this for the
132
+ * same repo at the same instant can collide on git's ref locks and one will
133
+ * throw; the run is retried rather than corrupted. Upgrade path is a lockfile
134
+ * in `mirrorRoot` keyed by repo name.
135
+ *
136
+ * ponytail: if `repo.cloneUrl` embeds credentials, `git clone` persists them in
137
+ * the mirror's config, exactly as it would for a hand-run clone. Prefer an SSH
138
+ * URL or an https URL backed by a credential helper.
139
+ */
140
+ export async function ensureMirror(
141
+ repo: RepoTarget,
142
+ mirrorRoot: string,
143
+ ): Promise<string> {
144
+ mkdirSync(mirrorRoot, { recursive: true });
145
+ const mirrorPath = mirrorPathFor(repo, mirrorRoot);
146
+
147
+ if (existsSync(mirrorPath)) {
148
+ await configureMirror(mirrorPath);
149
+ await git(["remote", "update", "--prune"], mirrorPath);
150
+ return mirrorPath;
151
+ }
152
+
153
+ try {
154
+ await git(["clone", "--mirror", repo.cloneUrl, mirrorPath]);
155
+ } catch (err) {
156
+ // A half-written mirror would be seen as "present" by the next call and
157
+ // fail in a much more confusing place. Retry from scratch instead.
158
+ rmSync(mirrorPath, { recursive: true, force: true });
159
+ throw err;
160
+ }
161
+ await configureMirror(mirrorPath);
162
+ return mirrorPath;
163
+ }
164
+
165
+ /**
166
+ * Provisions `<workspaceRoot>/<issue>` as a worktree of `repo` for `branch`,
167
+ * and returns the path. On the first attempt the branch is cut from the
168
+ * upstream tip of the default branch; on a retry the preserved branch is
169
+ * reattached (see the comment on the add below).
170
+ *
171
+ * ponytail: reattachment is the only retry mode, so attempt 2 always inherits
172
+ * attempt 1's tip — including a half-finished or broken state it might rather
173
+ * start clean from. There is no "start from upstream but keep the old work"
174
+ * option because that needs somewhere safe to park the old tip first. Upgrade
175
+ * path: snapshot the branch to `refs/conductor/attempt/<issue>/<n>` before
176
+ * resetting the run branch to `base`, and surface both refs in the escalation.
177
+ */
178
+ export async function addWorktree(
179
+ repo: RepoTarget,
180
+ mirrorRoot: string,
181
+ workspaceRoot: string,
182
+ issue: number,
183
+ branch: string,
184
+ ): Promise<string> {
185
+ const mirrorPath = await ensureMirror(repo, mirrorRoot);
186
+ mkdirSync(workspaceRoot, { recursive: true });
187
+
188
+ const worktreePath = worktreePathFor(workspaceRoot, issue);
189
+ if (existsSync(worktreePath)) {
190
+ // Reusing a tree is how one worker silently inherits another attempt's
191
+ // uncommitted edits and pushes them under this issue's name.
192
+ throw new Error(
193
+ `worktree path already exists: ${worktreePath}. Refusing to reuse it — ` +
194
+ `it may hold a previous attempt's uncommitted work. Call ` +
195
+ `removeWorktree(${mirrorPath}, ${worktreePath}) first.`,
196
+ );
197
+ }
198
+
199
+ // The base ref has to be fetched by hand. The mirror's own copy of
200
+ // `refs/heads/<defaultBranch>` is whatever the last refresh left behind (and
201
+ // under normal-clone semantics is no longer refreshed at all), so branching
202
+ // off it would silently start a run on a stale tip.
203
+ const base = `refs/remotes/origin/${repo.defaultBranch}`;
204
+ await git(
205
+ [
206
+ "fetch",
207
+ "--no-tags",
208
+ "origin",
209
+ `+refs/heads/${repo.defaultBranch}:${base}`,
210
+ ],
211
+ mirrorPath,
212
+ );
213
+
214
+ // `--no-track` on the create path because the start point is a
215
+ // remote-tracking ref: without it git would set the run branch's upstream to
216
+ // the default branch, and the worker's `git push` would then argue with
217
+ // `push.default` instead of publishing the branch. The reattach path must
218
+ // *omit* the flag — git dies with "--[no-]track can only be used if a new
219
+ // branch is created" — and does not need it, since checking out an existing
220
+ // branch writes no tracking config at all.
221
+ //
222
+ // `-b` is only ever correct on the *first* attempt for an issue. Branch
223
+ // names are a deterministic function of the issue number, `removeWorktree`
224
+ // deliberately leaves the branch behind in the mirror, and `git worktree add
225
+ // -b <existing>` is a hard error — so without this probe a second attempt
226
+ // could never provision a tree and `maxAttemptsPerIssue` was fiction.
227
+ //
228
+ // The retry therefore *reattaches* the existing branch rather than doing
229
+ // either of the two easier things. A fresh `<branch>-attempt2` would break
230
+ // the fleet contract of one issue = one branch = one PR. A `branch -D` or a
231
+ // force-reset to the upstream tip would be irreversible: that branch can
232
+ // hold the only copy of work attempt 1 committed but never pushed. Attaching
233
+ // to it hands the new worker attempt 1's commits, so it can continue or
234
+ // recover them, and any destructive call stays a human's to make.
235
+ const branchExists = await gitSucceeds(
236
+ ["show-ref", "--verify", "--quiet", `refs/heads/${branch}`],
237
+ mirrorPath,
238
+ );
239
+ const addArgs = branchExists
240
+ ? ["worktree", "add", worktreePath, branch]
241
+ : ["worktree", "add", "--no-track", "-b", branch, worktreePath, base];
242
+
243
+ try {
244
+ await git(addArgs, mirrorPath);
245
+ } catch (err) {
246
+ // A hard crash can leave the branch registered to a worktree whose
247
+ // directory is long gone, and git then refuses the add as "already checked
248
+ // out". `prune` drops exactly that stale bookkeeping and never touches a
249
+ // commit, so run it and retry once. It is not conditioned on git's wording
250
+ // because that text is version- and locale-dependent; an add that is
251
+ // broken for any other reason simply fails the same way twice.
252
+ await git(["worktree", "prune"], mirrorPath);
253
+ try {
254
+ await git(addArgs, mirrorPath);
255
+ } catch (retryErr) {
256
+ const first = err instanceof Error ? err.message : String(err);
257
+ const second =
258
+ retryErr instanceof Error ? retryErr.message : String(retryErr);
259
+ throw new Error(
260
+ `failed to provision worktree ${worktreePath} for issue ${issue} on ` +
261
+ `branch ${branch} (${branchExists ? "reattaching an existing branch" : "creating a new branch"}), ` +
262
+ `both before and after \`git worktree prune\`: ${second} ` +
263
+ `(first attempt: ${first})`,
264
+ );
265
+ }
266
+ }
267
+
268
+ return worktreePath;
269
+ }
270
+
271
+ /**
272
+ * Removes a run's worktree and its registration in the mirror. Idempotent: a
273
+ * path that is already gone resolves, so cleanup can be retried and can run on
274
+ * a run that never got as far as a checkout.
275
+ *
276
+ * ponytail: the run's branch stays in the mirror's `refs/heads/*`, since it may
277
+ * be the only copy of work that has not been pushed. Mirrors therefore grow one
278
+ * ref per run; upgrade path is reaping branches that are merged or have a
279
+ * closed PR.
280
+ */
281
+ export async function removeWorktree(
282
+ mirrorPath: string,
283
+ worktreePath: string,
284
+ ): Promise<void> {
285
+ if (!existsSync(mirrorPath)) {
286
+ // No mirror means no registration left to clean up, and deleting a tree we
287
+ // can no longer prove we created is not this function's call.
288
+ return;
289
+ }
290
+
291
+ if (existsSync(worktreePath)) {
292
+ try {
293
+ await git(["worktree", "remove", "--force", worktreePath], mirrorPath);
294
+ } catch (err) {
295
+ // A path git does not recognise (hand-deleted, never registered) is fine
296
+ // for cleanup's purposes. A path that survives the attempt is not: the
297
+ // next run for this issue would trip over it.
298
+ if (existsSync(worktreePath)) throw err;
299
+ }
300
+ }
301
+
302
+ await git(["worktree", "prune"], mirrorPath);
303
+ }