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/LICENSE +21 -0
- package/README.md +732 -0
- package/package.json +40 -0
- package/skills/conductor-onboarding/SKILL.md +626 -0
- package/src/briefs/orchestrator.md +213 -0
- package/src/briefs/worker.md +146 -0
- package/src/cli.ts +179 -0
- package/src/config.ts +446 -0
- package/src/daemon.ts +689 -0
- package/src/escalate.ts +265 -0
- package/src/lifecycle.ts +367 -0
- package/src/omp.ts +273 -0
- package/src/orchestrator-tick.ts +432 -0
- package/src/orchestrator.ts +267 -0
- package/src/plugin.ts +605 -0
- package/src/routing.ts +160 -0
- package/src/setup.ts +644 -0
- package/src/store.ts +263 -0
- package/src/tracker/github.ts +160 -0
- package/src/types.ts +250 -0
- package/src/worker.ts +292 -0
- package/src/worktree.ts +303 -0
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
|
+
}
|
package/src/worktree.ts
ADDED
|
@@ -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
|
+
}
|