@cat-factory/executor-harness 1.31.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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +143 -0
  3. package/dist/agent-runner.js +389 -0
  4. package/dist/agent.js +810 -0
  5. package/dist/blueprint.js +367 -0
  6. package/dist/bootstrap.js +99 -0
  7. package/dist/ci-fixer.js +46 -0
  8. package/dist/coding-agent.js +285 -0
  9. package/dist/conflict-resolver.js +138 -0
  10. package/dist/embed.js +8 -0
  11. package/dist/explore.js +74 -0
  12. package/dist/failure.js +47 -0
  13. package/dist/fixer.js +44 -0
  14. package/dist/follow-ups.js +103 -0
  15. package/dist/frontend-infra.js +283 -0
  16. package/dist/fs-utils.js +11 -0
  17. package/dist/git.js +778 -0
  18. package/dist/job.js +409 -0
  19. package/dist/logger.js +27 -0
  20. package/dist/merger.js +135 -0
  21. package/dist/on-call.js +126 -0
  22. package/dist/pi-workspace.js +237 -0
  23. package/dist/pi.js +971 -0
  24. package/dist/process.js +25 -0
  25. package/dist/redact.js +109 -0
  26. package/dist/runner.js +228 -0
  27. package/dist/server.js +135 -0
  28. package/dist/spec.js +754 -0
  29. package/dist/structured-output.js +431 -0
  30. package/dist/tester.js +191 -0
  31. package/package.json +35 -0
  32. package/src/agent-runner.ts +484 -0
  33. package/src/agent.ts +948 -0
  34. package/src/coding-agent.ts +393 -0
  35. package/src/embed.ts +32 -0
  36. package/src/failure.ts +73 -0
  37. package/src/follow-ups.ts +106 -0
  38. package/src/frontend-infra.ts +340 -0
  39. package/src/fs-utils.ts +11 -0
  40. package/src/git.ts +955 -0
  41. package/src/job.ts +766 -0
  42. package/src/logger.ts +45 -0
  43. package/src/pi-workspace.ts +348 -0
  44. package/src/pi.ts +1236 -0
  45. package/src/process.ts +33 -0
  46. package/src/redact.ts +109 -0
  47. package/src/runner.ts +384 -0
  48. package/src/server.ts +153 -0
  49. package/src/structured-output.ts +524 -0
package/dist/git.js ADDED
@@ -0,0 +1,778 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { appendFile, chmod, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ import { pathExists } from './fs-utils.js';
7
+ import { redactSecrets } from './redact.js';
8
+ import { loadRunnerLimits } from './runner.js';
9
+ import { HarnessFailure } from './failure.js';
10
+ // Re-exported so existing importers that pull `redactSecrets` from this module keep
11
+ // working; the single source of truth now lives in ./redact.js.
12
+ export { redactSecrets } from './redact.js';
13
+ const exec = promisify(execFile);
14
+ // Git + GitHub helpers. The installation token is NEVER placed in a clone/remote
15
+ // URL or in any git argv. Instead git authenticates over HTTPS via a GIT_ASKPASS
16
+ // helper: the plain `https://x-access-token@host/...` remote (username only, no
17
+ // secret) is used everywhere, and the token is handed to git out-of-band through
18
+ // an environment variable the helper reads. That keeps the token out of process
19
+ // listings and out of any command string Node echoes into an error/cmd field.
20
+ const GIT_AUTHOR = 'cat-factory[bot]';
21
+ const GIT_EMAIL = 'cat-factory[bot]@users.noreply.github.com';
22
+ // Per-git-command wall-clock ceiling. A single git op (clone/push over a flaky
23
+ // network) must not hang the job indefinitely; the job's overall watchdog
24
+ // (see runner.ts) is the outer bound, this stops one wedged command first.
25
+ //
26
+ // INVARIANT: this MUST stay STRICTLY BELOW the inactivity watchdog
27
+ // (`RunnerLimits.inactivityMs`). Git emits no Pi activity events while it runs, so a
28
+ // slow clone/push races both timers; if they were equal the job could fail with the
29
+ // misleading "no agent activity … likely hung" instead of a clear "git timed out".
30
+ // Staying under that window means git always loses the race and surfaces its own
31
+ // accurate reason.
32
+ //
33
+ // Rather than hardcode a constant against the *default* watchdog (which silently
34
+ // breaks the invariant when an operator lowers `JOB_INACTIVITY_MS`), we DERIVE the
35
+ // ceiling from the actually-configured window: a fixed margin below it, floored so a
36
+ // tiny window can't yield a non-positive timeout. At the 10-min default this resolves
37
+ // to the same 7 min as before; at a lowered 5-min window it tracks down to 2 min.
38
+ const GIT_TIMEOUT_MARGIN_MS = 3 * 60_000;
39
+ const GIT_TIMEOUT_FLOOR_MS = 60_000;
40
+ const GIT_TIMEOUT_MS = Math.max(GIT_TIMEOUT_FLOOR_MS, loadRunnerLimits().inactivityMs - GIT_TIMEOUT_MARGIN_MS);
41
+ /** Wrap an error so its message/stack carry no credentials. */
42
+ function redactError(err) {
43
+ if (err instanceof Error) {
44
+ const redacted = new Error(redactSecrets(err.message));
45
+ if (err.stack)
46
+ redacted.stack = redactSecrets(err.stack);
47
+ return redacted;
48
+ }
49
+ return new Error(redactSecrets(String(err)));
50
+ }
51
+ /**
52
+ * Build the remote URL git uses. Only the username (`x-access-token`) is embedded
53
+ * — never the token — so the token never appears in argv. The token is supplied
54
+ * separately via {@link authEnv} and read by the GIT_ASKPASS helper.
55
+ *
56
+ * The `x-access-token` username is host-neutral: GitHub keys auth off the token (password)
57
+ * and ignores the username, and GitLab likewise accepts ANY non-blank username with a PAT as
58
+ * the password — so the same embedded username authenticates github.com and gitlab.com alike.
59
+ */
60
+ export function authenticatedCloneUrl(cloneUrl) {
61
+ // https://github.com/owner/name.git → https://x-access-token@github.com/...
62
+ // (no secret in the URL). file:// and other local URLs are left untouched.
63
+ return cloneUrl.replace(/^https:\/\//, 'https://x-access-token@');
64
+ }
65
+ /** Drop any `user[:pass]@` userinfo from a URL so two clone URLs can be compared by repo. */
66
+ function withoutUserinfo(url) {
67
+ return url.replace(/^([a-z]+:\/\/)[^@/]*@/i, '$1');
68
+ }
69
+ // A tiny askpass helper that prints the token git asks for. Created once per
70
+ // process and reused; the token itself is passed per-command via the env (below),
71
+ // never baked into the script.
72
+ let askpassPathPromise;
73
+ function ensureAskpass() {
74
+ askpassPathPromise ??= (async () => {
75
+ const dir = await mkdtemp(join(tmpdir(), 'git-askpass-'));
76
+ const path = join(dir, 'askpass.sh');
77
+ // git invokes this with the prompt as argv[1]; we only ever return the token
78
+ // (the username is already in the remote URL, so git only asks for the
79
+ // password). The token comes from the env, never from argv.
80
+ await writeFile(path, '#!/bin/sh\nexec printf %s "$GIT_ASKPASS_TOKEN"\n', 'utf8');
81
+ await chmod(path, 0o700);
82
+ return path;
83
+ })();
84
+ return askpassPathPromise;
85
+ }
86
+ /** Child-process env that lets git authenticate with `ghToken` without it touching argv. */
87
+ async function authEnv(ghToken) {
88
+ return {
89
+ ...process.env,
90
+ GIT_ASKPASS: await ensureAskpass(),
91
+ GIT_ASKPASS_TOKEN: ghToken,
92
+ // Never fall back to an interactive prompt (which would hang the job).
93
+ GIT_TERMINAL_PROMPT: '0',
94
+ };
95
+ }
96
+ /**
97
+ * Run one git command. `signal` (the job watchdog's) and a per-command timeout
98
+ * both abort a wedged process, so neither a hung clone nor a stalled push can
99
+ * keep the container running forever. Any failure is re-thrown with its message
100
+ * and stack scrubbed of credentials.
101
+ */
102
+ async function git(args, opts = {}) {
103
+ try {
104
+ const { stdout } = await exec('git', args, {
105
+ ...(opts.cwd ? { cwd: opts.cwd } : {}),
106
+ maxBuffer: 16 * 1024 * 1024,
107
+ timeout: GIT_TIMEOUT_MS,
108
+ ...(opts.env ? { env: opts.env } : {}),
109
+ ...(opts.signal ? { signal: opts.signal } : {}),
110
+ });
111
+ return stdout;
112
+ }
113
+ catch (err) {
114
+ // Tag the failure as `git` so the registry's catch records the real cause instead of
115
+ // the generic `agent`. A watchdog abort still wins: `describeFailure` keys off
116
+ // `killReason` first, so an abort during a git op keeps the timeout message/cause.
117
+ const redacted = redactError(err);
118
+ const failure = new HarnessFailure('git', redacted.message);
119
+ if (redacted.stack)
120
+ failure.stack = redacted.stack;
121
+ throw failure;
122
+ }
123
+ }
124
+ /** Clone `repo`'s base branch (shallow by default) into `dir` and set commit identity. */
125
+ export async function cloneRepo(opts) {
126
+ const url = authenticatedCloneUrl(opts.repo.cloneUrl);
127
+ const cloneArgs = opts.full
128
+ ? ['clone', '--branch', opts.repo.baseBranch, url, opts.dir]
129
+ : ['clone', '--depth', '1', '--branch', opts.repo.baseBranch, url, opts.dir];
130
+ await git(cloneArgs, { signal: opts.signal, env: await authEnv(opts.ghToken) });
131
+ await git(['config', 'user.name', GIT_AUTHOR], { cwd: opts.dir, signal: opts.signal });
132
+ await git(['config', 'user.email', GIT_EMAIL], { cwd: opts.dir, signal: opts.signal });
133
+ }
134
+ /** Create and switch to the work branch. */
135
+ export async function createBranch(dir, branch, signal) {
136
+ await git(['checkout', '-b', branch], { cwd: dir, signal });
137
+ }
138
+ /**
139
+ * Whether `branch` already exists on the remote — i.e. an earlier (possibly
140
+ * evicted) run of this task already pushed work to it, so a re-dispatch should
141
+ * RESUME on it (clone it, continue on its commits) rather than branch off base and
142
+ * start over. Uses `git ls-remote` (no checkout); the token is supplied out of band.
143
+ */
144
+ export async function remoteBranchExists(cloneUrl, branch, ghToken, signal) {
145
+ const url = authenticatedCloneUrl(cloneUrl);
146
+ const out = await git(['ls-remote', '--heads', url, branch], {
147
+ signal,
148
+ env: await authEnv(ghToken),
149
+ });
150
+ return out.trim() !== '';
151
+ }
152
+ /**
153
+ * Clone an EXISTING work branch (full history) into `dir` and check it out — used
154
+ * to resume a task whose earlier run already pushed commits to this branch, so the
155
+ * agent continues on top of that work instead of redoing it.
156
+ */
157
+ export async function cloneExistingBranch(opts) {
158
+ const url = authenticatedCloneUrl(opts.cloneUrl);
159
+ await git(['clone', '--branch', opts.branch, '--single-branch', url, opts.dir], {
160
+ signal: opts.signal,
161
+ env: await authEnv(opts.ghToken),
162
+ });
163
+ await git(['config', 'user.name', GIT_AUTHOR], { cwd: opts.dir, signal: opts.signal });
164
+ await git(['config', 'user.email', GIT_EMAIL], { cwd: opts.dir, signal: opts.signal });
165
+ }
166
+ /**
167
+ * The directory-name globs the clean sweep PRESERVES — dependency caches that are
168
+ * expensive to rebuild (node_modules, language toolchain caches). Keeping them is the
169
+ * whole point of reusing a checkout: a `git clean -ffdx` would otherwise wipe them and
170
+ * force a reinstall every run. Configurable via `HARNESS_CLEAN_KEEP` (comma-separated).
171
+ */
172
+ export function cleanKeepPatterns(env = process.env) {
173
+ const raw = env.HARNESS_CLEAN_KEEP ?? 'node_modules,.venv,target,.gradle,.pnpm-store';
174
+ return raw
175
+ .split(',')
176
+ .map((s) => s.trim())
177
+ .filter((s) => s !== '');
178
+ }
179
+ /**
180
+ * Reset a REUSED checkout to a pristine state before the next job runs in it: hard-reset
181
+ * tracked files and remove every untracked/ignored file EXCEPT the preserved dependency
182
+ * caches (see {@link cleanKeepPatterns}). This is what guarantees a prior run's garbage —
183
+ * stray scratch files, half-written edits, stale build output — never contaminates the
184
+ * next run that reuses the same persistent checkout. A fresh clone never needs it.
185
+ *
186
+ * Submodules: when `.gitmodules` is present we use a single `-f` (which makes `git clean`
187
+ * skip nested git repositories, i.e. the submodule worktrees) and reset/refresh the
188
+ * submodules explicitly; otherwise `-ff` also nukes any stray nested repo the agent left.
189
+ */
190
+ export async function cleanSweep(dir, ghToken, signal, env = process.env) {
191
+ await git(['reset', '--hard'], { cwd: dir, signal });
192
+ const hasSubmodules = await pathExists(join(dir, '.gitmodules'));
193
+ if (hasSubmodules) {
194
+ await git(['submodule', 'foreach', '--recursive', 'git reset --hard'], {
195
+ cwd: dir,
196
+ signal,
197
+ }).catch(() => { });
198
+ }
199
+ const keep = cleanKeepPatterns(env).flatMap((p) => ['-e', p]);
200
+ // `-ffdx` (or `-fdx` with submodules) removes untracked + ignored files and dirs; the
201
+ // `-e` excludes keep the dependency caches. Tracked files were already hard-reset above.
202
+ await git(['clean', hasSubmodules ? '-fdx' : '-ffdx', ...keep], { cwd: dir, signal });
203
+ if (hasSubmodules) {
204
+ await git(['submodule', 'update', '--init', '--recursive'], {
205
+ cwd: dir,
206
+ signal,
207
+ env: await authEnv(ghToken),
208
+ }).catch(() => { });
209
+ }
210
+ }
211
+ /**
212
+ * The `origin` remote URL (without credentials) of the checkout at `dir`, or undefined
213
+ * when it isn't a git repo / has no origin. Used to detect a persistent checkout dir that
214
+ * somehow holds a DIFFERENT repo than the one we're about to prepare (it never should —
215
+ * the dir is keyed per repo — but a stale dir from a prior layout would be a silent
216
+ * cross-repo bleed, so we re-clone rather than reuse).
217
+ */
218
+ export async function checkoutRemoteUrl(dir, signal) {
219
+ try {
220
+ return (await git(['remote', 'get-url', 'origin'], { cwd: dir, signal })).trim() || undefined;
221
+ }
222
+ catch {
223
+ return undefined;
224
+ }
225
+ }
226
+ /**
227
+ * Prepare a REUSED (persistent) checkout at `dir` so the agent runs against a clean tree
228
+ * on the right branch — the persistent-checkout analogue of {@link cloneRepo} +
229
+ * {@link cloneExistingBranch}. On the FIRST use of a per-repo dir there's no `.git` yet, so
230
+ * it clones once (full history, so a later merger/conflict step reusing the dir can diff
231
+ * against the base); afterwards it reuses the dir in place: clean sweep → re-point origin →
232
+ * fetch → check out `branch`. When `existing` is true `branch` is fetched and checked out
233
+ * directly (resume / base branch); otherwise `branch` is (re)created off `baseBranch`'s tip
234
+ * (a fresh work branch). Only the local transport sets `persistentCheckout`, so every other
235
+ * runtime keeps the fresh-clone path untouched.
236
+ */
237
+ export async function prepareExistingCheckout(opts) {
238
+ const { dir, repo, ghToken, branch, baseBranch, existing, signal } = opts;
239
+ const cloneUrl = authenticatedCloneUrl(repo.cloneUrl);
240
+ // First use of this per-repo dir, or a stale dir holding a DIFFERENT repo → clone fresh
241
+ // (full history, so a later merger/conflict step reusing the dir can diff against base).
242
+ const currentRemote = (await pathExists(join(dir, '.git')))
243
+ ? await checkoutRemoteUrl(dir, signal)
244
+ : undefined;
245
+ if (!currentRemote || withoutUserinfo(currentRemote) !== withoutUserinfo(cloneUrl)) {
246
+ await rm(dir, { recursive: true, force: true });
247
+ await cloneRepo({ repo: { ...repo, baseBranch }, ghToken, dir, full: true, signal });
248
+ }
249
+ const env = await authEnv(ghToken);
250
+ await cleanSweep(dir, ghToken, signal);
251
+ // Re-point origin in case the stored URL drifted (idempotent; carries no secret).
252
+ await git(['remote', 'set-url', 'origin', cloneUrl], { cwd: dir, signal });
253
+ const fetchRef = existing ? branch : baseBranch;
254
+ // Fetch the target ref AND the base into their tracking refs in ONE command, with explicit
255
+ // destination refspecs. The checkout below then reads `origin/<fetchRef>` directly rather
256
+ // than FETCH_HEAD: FETCH_HEAD only ever holds the LAST fetched ref, so a second base fetch
257
+ // would clobber it and a resumed work branch (base != branch) would be reset to the BASE
258
+ // tip — silently discarding the resumed commits. Keeping `origin/<baseBranch>` fresh also
259
+ // matters for the downstream merger/diff; a missing base diverges from a fresh full clone,
260
+ // so this is NOT best-effort (a failure surfaces rather than leaving a stale base ref).
261
+ const refspecs = [`+${fetchRef}:refs/remotes/origin/${fetchRef}`];
262
+ if (baseBranch !== fetchRef)
263
+ refspecs.push(`+${baseBranch}:refs/remotes/origin/${baseBranch}`);
264
+ await git(['fetch', 'origin', ...refspecs], { cwd: dir, signal, env });
265
+ // `-f`: the clean sweep deliberately PRESERVES dependency caches (node_modules/target/…)
266
+ // as untracked files; if one collides with a path the target branch TRACKS, a plain
267
+ // checkout aborts ("untracked working tree files would be overwritten"). Force overwrites
268
+ // only the in-the-way files, leaving the other kept caches intact.
269
+ await git(['checkout', '-f', '-B', branch, `refs/remotes/origin/${fetchRef}`], {
270
+ cwd: dir,
271
+ signal,
272
+ });
273
+ await git(['config', 'user.name', GIT_AUTHOR], { cwd: dir, signal });
274
+ await git(['config', 'user.email', GIT_EMAIL], { cwd: dir, signal });
275
+ }
276
+ /**
277
+ * Commit edits the agent left UNCOMMITTED — but only to files git already tracks
278
+ * (`git add -u`), never new untracked files. The agent owns commit selection (it
279
+ * alone knows which new files are part of the solution vs scratch scripts/artifacts
280
+ * it created while exploring), so this is just a safety net that captures forgotten
281
+ * edits to existing files without ever sweeping in junk a blanket `git add -A`
282
+ * would. Returns false when there was nothing tracked to commit.
283
+ */
284
+ export async function commitTrackedEdits(dir, message, signal) {
285
+ await git(['add', '-u'], { cwd: dir, signal });
286
+ // Only consider staged (tracked) changes — untracked files are deliberately ignored.
287
+ const staged = await git(['diff', '--cached', '--name-only'], { cwd: dir, signal });
288
+ if (staged.trim() === '')
289
+ return false;
290
+ await git(['commit', '-m', message], { cwd: dir, signal });
291
+ return true;
292
+ }
293
+ /**
294
+ * The untracked, non-ignored files left in the working tree (`git ls-files --others
295
+ * --exclude-standard`). The harness deliberately never blanket-stages new files (the
296
+ * agent owns commit selection), so this is exactly what {@link commitTrackedEdits}
297
+ * does NOT capture — a NEW file the agent created but forgot to commit. The caller
298
+ * surfaces it as a warning so that silent loss is at least observable in the logs.
299
+ */
300
+ export async function listUntrackedFiles(dir, signal) {
301
+ const out = await git(['ls-files', '--others', '--exclude-standard'], { cwd: dir, signal });
302
+ return out
303
+ .split('\n')
304
+ .map((line) => line.replace(/\r$/, '').trim())
305
+ .filter((path) => path !== '');
306
+ }
307
+ /**
308
+ * Locally exclude `pattern` from this checkout via `.git/info/exclude` — a per-clone
309
+ * ignore that never lands in the repo (unlike a `.gitignore`). Used for the harness's
310
+ * follow-up sentinel file so the agent's own `git add` can never stage it and it never
311
+ * surfaces as an untracked-leftover warning or in the PR. Best-effort: a failure here
312
+ * just means the sentinel might show as untracked (logged, not pushed), never fatal.
313
+ */
314
+ export async function excludeFromGit(dir, pattern, signal) {
315
+ try {
316
+ const excludePath = join(dir, '.git', 'info', 'exclude');
317
+ await appendFile(excludePath, `\n${pattern}\n`, 'utf8');
318
+ }
319
+ catch {
320
+ // A missing .git/info/exclude (worktree layout) or write error is non-fatal.
321
+ void signal;
322
+ }
323
+ }
324
+ /** Whether the branch advanced past `baseSha` via commits (the agent's own + any safety-net commit). */
325
+ export async function branchHasCommitsSince(dir, baseSha, signal) {
326
+ return (await headCommit(dir, signal)) !== baseSha;
327
+ }
328
+ /**
329
+ * Whether the checked-out branch has a real, examinable diff against
330
+ * `origin/<baseBranch>` — i.e. the base branch's remote-tracking ref exists (so the
331
+ * merge base resolves) AND there are changes between that merge base and HEAD. The
332
+ * merger uses this to refuse to score a PR it could not actually inspect (a missing
333
+ * base ref or an empty diff) instead of emitting bogus low scores that would
334
+ * auto-merge. Returns false on ANY git error (e.g. an unknown ref). Requires a
335
+ * {@link cloneRepo} with `full: true` so `origin/<baseBranch>` and the merge base exist.
336
+ */
337
+ export async function hasDiffAgainstBase(dir, baseBranch, signal) {
338
+ try {
339
+ const stat = await git(['diff', '--stat', `origin/${baseBranch}...HEAD`], { cwd: dir, signal });
340
+ return stat.trim() !== '';
341
+ }
342
+ catch {
343
+ return false;
344
+ }
345
+ }
346
+ /**
347
+ * Parse the paths out of `git status --porcelain` (v1) output. Each line is
348
+ * `XY <path>`, or `XY <old> -> <new>` for a rename/copy (we keep the new path);
349
+ * git quotes paths with special characters, which we unquote. Blank lines are
350
+ * skipped. Pure so the no-op detection can be tested without spawning git.
351
+ */
352
+ export function changedPathsFromPorcelain(status) {
353
+ const paths = [];
354
+ for (const raw of status.split('\n')) {
355
+ const line = raw.replace(/\r$/, '');
356
+ if (line.trim() === '')
357
+ continue;
358
+ let path = line.slice(3);
359
+ const arrow = path.indexOf(' -> ');
360
+ if (arrow !== -1)
361
+ path = path.slice(arrow + 4);
362
+ path = path.trim().replace(/^"(.*)"$/, '$1');
363
+ if (path)
364
+ paths.push(path);
365
+ }
366
+ return paths;
367
+ }
368
+ /**
369
+ * Whether the agent changed anything in a cloned checkout. Stages the working
370
+ * tree and inspects the porcelain status: an empty result means the bootstrapper
371
+ * made no adaptation — a no-op we must not pass off as a successful push. (The
372
+ * harness writes its prompt context to Pi's global `~/.pi/agent/AGENTS.md`, never
373
+ * into the checkout, so every change reported here is a genuine agent edit.)
374
+ */
375
+ export async function hasAgentChanges(dir, signal) {
376
+ await git(['add', '-A'], { cwd: dir, signal });
377
+ const status = await git(['status', '--porcelain'], { cwd: dir, signal });
378
+ return changedPathsFromPorcelain(status).length > 0;
379
+ }
380
+ /** The commit SHA at `dir`'s HEAD — captured right after clone as the base tip. */
381
+ export async function headCommit(dir, signal) {
382
+ return (await git(['rev-parse', 'HEAD'], { cwd: dir, signal })).trim();
383
+ }
384
+ /** Stage everything and commit; returns false when there was nothing to commit. */
385
+ export async function commitAll(dir, message, signal) {
386
+ await git(['add', '-A'], { cwd: dir, signal });
387
+ const status = await git(['status', '--porcelain'], { cwd: dir, signal });
388
+ if (status.trim() === '')
389
+ return false;
390
+ await git(['commit', '-m', message], { cwd: dir, signal });
391
+ return true;
392
+ }
393
+ /** Paths git still reports as unmerged (conflict stage entries) in the working tree. */
394
+ export async function unmergedPaths(dir, signal) {
395
+ const out = await git(['diff', '--name-only', '--diff-filter=U'], { cwd: dir, signal });
396
+ return out
397
+ .split('\n')
398
+ .map((line) => line
399
+ .replace(/\r$/, '')
400
+ .trim()
401
+ .replace(/^"(.*)"$/, '$1'))
402
+ .filter((path) => path !== '');
403
+ }
404
+ /**
405
+ * The conflict hunks for the given unmerged `paths`: `git diff` over exactly those
406
+ * files, which for an unmerged entry renders the combined diff carrying the
407
+ * `<<<<<<<` / `=======` / `>>>>>>>` markers each side contributed. Handed to the
408
+ * conflict-resolver agent so it sees the actual conflicts instead of having to
409
+ * rediscover them. Capped to `maxChars` total (a note is appended on truncation) so a
410
+ * huge conflict can't blow up the prompt. Returns '' when there are no paths.
411
+ */
412
+ export async function conflictDiff(dir, paths, signal, maxChars = 24_000) {
413
+ if (paths.length === 0)
414
+ return '';
415
+ const out = await git(['diff', '--', ...paths], { cwd: dir, signal });
416
+ if (out.length <= maxChars)
417
+ return out;
418
+ return `${out.slice(0, maxChars)}\n\n[diff truncated at ${maxChars} characters — open the files directly to see the remaining conflicts]`;
419
+ }
420
+ /**
421
+ * Merge `origin/<baseBranch>` into the current branch (no fast-forward squash, no
422
+ * editor). Returns `true` for a clean merge (or an already-up-to-date no-op) and
423
+ * `false` when the merge left conflicts in the working tree — the expected case the
424
+ * conflict-resolver agent then fixes, NOT an error. Any other git failure (e.g. an
425
+ * unknown ref) is re-thrown. Requires a {@link cloneRepo} with `full: true` so the
426
+ * merge base and `origin/<baseBranch>` are present.
427
+ */
428
+ export async function mergeBranch(dir, baseBranch, signal) {
429
+ try {
430
+ await git(['merge', '--no-edit', `origin/${baseBranch}`], { cwd: dir, signal });
431
+ return true;
432
+ }
433
+ catch (err) {
434
+ // A merge conflict exits non-zero and leaves unmerged paths; distinguish it
435
+ // from a genuine failure (which leaves none) so only real errors propagate.
436
+ if ((await unmergedPaths(dir, signal)).length > 0)
437
+ return false;
438
+ throw err;
439
+ }
440
+ }
441
+ /**
442
+ * Bring a RESUMED work branch up to the latest `baseBranch` when (and only when) the
443
+ * two merge cleanly. A resumed branch was cut from an older base, so without this the
444
+ * agent continues against a stale base and the eventual PR can carry avoidable
445
+ * conflicts. Fetches the base (the single-branch resume clone doesn't have it),
446
+ * attempts `git merge --no-edit`, and on a conflict ABORTS — leaving the branch
447
+ * exactly as it was so the run proceeds on the stale base (the CI/merge gate handles
448
+ * a genuinely conflicting PR downstream, as before). Returns whether base was merged
449
+ * in. Best-effort: callers treat a thrown/false result as "continue without refresh".
450
+ */
451
+ export async function refreshFromBaseIfClean(dir, baseBranch, ghToken, signal) {
452
+ await git(['fetch', 'origin', baseBranch], { cwd: dir, signal, env: await authEnv(ghToken) });
453
+ try {
454
+ await git(['merge', '--no-edit', 'FETCH_HEAD'], { cwd: dir, signal });
455
+ return true;
456
+ }
457
+ catch (err) {
458
+ if ((await unmergedPaths(dir, signal)).length > 0) {
459
+ // Conflict — undo the half-done merge and keep the branch on its old base.
460
+ await git(['merge', '--abort'], { cwd: dir, signal }).catch(() => { });
461
+ return false;
462
+ }
463
+ throw err;
464
+ }
465
+ }
466
+ /**
467
+ * Push the work branch to origin. The remote URL carries only the username, so
468
+ * the token is supplied here via the askpass env (never in argv).
469
+ */
470
+ export async function pushBranch(dir, branch, ghToken, signal) {
471
+ await git(['push', '-u', 'origin', branch], {
472
+ cwd: dir,
473
+ signal,
474
+ env: await authEnv(ghToken),
475
+ });
476
+ }
477
+ /**
478
+ * Reset the working tree's git history to a single bootstrap commit and push it
479
+ * to the target repository's default branch. Wiping `.git` before re-initialising
480
+ * means the new repo starts clean — it inherits the bootstrapped *contents* of the
481
+ * reference architecture, not its commit history.
482
+ *
483
+ * The push is forced: the fresh single-commit history shares no ancestor with
484
+ * whatever GitHub prepopulated when the user created the repo (a README,
485
+ * .gitignore and/or license picked on the new-repo page), so a fast-forward is
486
+ * impossible. The Worker pre-flights that the target is empty or holds only that
487
+ * boilerplate, so overwriting it is safe and intended.
488
+ */
489
+ export async function reinitAndPush(opts) {
490
+ await rm(join(opts.dir, '.git'), { recursive: true, force: true });
491
+ await git(['init'], { cwd: opts.dir });
492
+ // Start the history on the target's default branch (init may default to master).
493
+ await git(['checkout', '-b', opts.target.defaultBranch], { cwd: opts.dir });
494
+ await git(['config', 'user.name', GIT_AUTHOR], { cwd: opts.dir });
495
+ await git(['config', 'user.email', GIT_EMAIL], { cwd: opts.dir });
496
+ await git(['add', '-A'], { cwd: opts.dir });
497
+ await git(['commit', '-m', opts.message], { cwd: opts.dir });
498
+ const url = authenticatedCloneUrl(opts.target.cloneUrl);
499
+ await git(['remote', 'add', 'origin', url], { cwd: opts.dir });
500
+ await git(['push', '--force', '-u', 'origin', opts.target.defaultBranch], {
501
+ cwd: opts.dir,
502
+ env: await authEnv(opts.ghToken),
503
+ });
504
+ }
505
+ /**
506
+ * The VCS host a clone URL points at. The harness is otherwise provider-agnostic (its git
507
+ * auth is a host-neutral GIT_ASKPASS credential), but the "open the PR/MR" REST call is not:
508
+ * GitHub and GitLab have different endpoints, so infer which to call from the host. GitHub is
509
+ * the default; a host of `gitlab.com` or one in the `gitlab.*` / `*.gitlab.*` family (covering
510
+ * self-managed instances named that way) is treated as GitLab.
511
+ */
512
+ export function inferVcsProvider(cloneUrl) {
513
+ let host = '';
514
+ try {
515
+ host = new URL(cloneUrl).host.toLowerCase();
516
+ }
517
+ catch {
518
+ return 'github';
519
+ }
520
+ if (host === 'gitlab.com' || host.startsWith('gitlab.') || host.includes('.gitlab.')) {
521
+ return 'gitlab';
522
+ }
523
+ return 'github';
524
+ }
525
+ /** The GitLab REST v4 base for a clone URL's host, e.g. `https://gitlab.com/api/v4`. */
526
+ export function gitlabApiBaseFromCloneUrl(cloneUrl) {
527
+ const u = new URL(cloneUrl);
528
+ return `${u.protocol}//${u.host}/api/v4`;
529
+ }
530
+ /**
531
+ * The URL-encoded GitLab project path from a clone URL — the full namespace path (so subgroups
532
+ * survive), with the trailing `.git` stripped, e.g.
533
+ * `https://gitlab.com/group/sub/proj.git` → `group%2Fsub%2Fproj`.
534
+ */
535
+ export function gitlabProjectPath(cloneUrl) {
536
+ const path = new URL(cloneUrl).pathname.replace(/^\/+/, '').replace(/\.git$/, '');
537
+ return encodeURIComponent(path);
538
+ }
539
+ /** The abort reason as an Error (the watchdog aborts with one), or a generic fallback. */
540
+ function abortError(signal) {
541
+ return signal.reason instanceof Error ? signal.reason : new Error('aborted');
542
+ }
543
+ /** Whether a thrown fetch error is an AbortError (caller-initiated, never retried). */
544
+ function isAbortError(err) {
545
+ return err instanceof Error && err.name === 'AbortError';
546
+ }
547
+ /**
548
+ * Parse a `Retry-After` header into ms, bounded so it can't stall the job. Accepts BOTH
549
+ * forms the spec allows: integer delay-seconds (`120`) and an HTTP-date (`Wed, 21 Oct 2026
550
+ * 07:28:00 GMT`); the latter is turned into a delay from now. A past/zero/unparseable value
551
+ * yields undefined so the caller falls back to exponential backoff.
552
+ */
553
+ function retryAfterMs(res) {
554
+ const raw = res.headers.get('retry-after');
555
+ if (!raw)
556
+ return undefined;
557
+ const secs = Number(raw);
558
+ if (Number.isFinite(secs)) {
559
+ return secs > 0 ? Math.min(secs * 1000, MAX_RETRY_AFTER_MS) : undefined;
560
+ }
561
+ const at = Date.parse(raw);
562
+ if (Number.isNaN(at))
563
+ return undefined;
564
+ const ms = at - Date.now();
565
+ return ms > 0 ? Math.min(ms, MAX_RETRY_AFTER_MS) : undefined;
566
+ }
567
+ /** Sleep `ms`, rejecting immediately (with the abort reason) if `signal` aborts meanwhile. */
568
+ function abortableDelay(ms, signal) {
569
+ return new Promise((resolve, reject) => {
570
+ if (signal?.aborted)
571
+ return reject(abortError(signal));
572
+ const onAbort = () => {
573
+ clearTimeout(timer);
574
+ reject(abortError(signal));
575
+ };
576
+ const timer = setTimeout(() => {
577
+ signal?.removeEventListener('abort', onAbort);
578
+ resolve();
579
+ }, ms);
580
+ signal?.addEventListener('abort', onAbort, { once: true });
581
+ });
582
+ }
583
+ const MAX_RETRY_AFTER_MS = 8_000;
584
+ const RETRY_BASE_MS = 500;
585
+ const RETRY_MAX_DELAY_MS = 4_000;
586
+ /**
587
+ * Run a single HTTP request with bounded retry for TRANSIENT failures, so a momentary
588
+ * upstream blip (a 5xx, a 429 rate-limit, or a dropped connection) no longer fails an
589
+ * otherwise-complete run on its very last step (opening the PR/MR). Up to 3 attempts
590
+ * (2 retries) with exponential backoff + jitter (honoring a `Retry-After` on a 429),
591
+ * every wait abort-aware so the inactivity/max-duration watchdog still cancels promptly.
592
+ *
593
+ * ONLY transient failures retry: a `>=500`/`429` response, or a network-level fetch
594
+ * rejection. A 4xx (incl. the 422/409 "already exists" the callers treat as success) is
595
+ * returned to the caller unretried, and a caller abort is rethrown at once. The response
596
+ * body is never read here, so the caller's existing status handling is unchanged.
597
+ */
598
+ async function withApiRetry(fn, opts = {}) {
599
+ const maxAttempts = opts.attempts ?? 3;
600
+ let lastError;
601
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
602
+ if (opts.signal?.aborted)
603
+ throw abortError(opts.signal);
604
+ let res;
605
+ try {
606
+ res = await fn();
607
+ }
608
+ catch (err) {
609
+ // A caller/watchdog abort is terminal; a network error is transient → retry.
610
+ if (isAbortError(err) || opts.signal?.aborted)
611
+ throw err;
612
+ lastError = err;
613
+ }
614
+ if (res) {
615
+ const transient = res.status >= 500 || res.status === 429;
616
+ if (!transient || attempt >= maxAttempts)
617
+ return res;
618
+ const after = retryAfterMs(res);
619
+ // Discard the unread body before retrying so the connection can be reused.
620
+ await res.body?.cancel().catch(() => { });
621
+ await abortableDelay(after ?? backoffMs(attempt), opts.signal);
622
+ continue;
623
+ }
624
+ if (attempt >= maxAttempts)
625
+ break;
626
+ await abortableDelay(backoffMs(attempt), opts.signal);
627
+ }
628
+ // Exhausted on a network-level rejection (no HTTP response): an upstream API failure.
629
+ const message = lastError instanceof Error ? lastError.message : 'API request failed after retries';
630
+ throw new HarnessFailure('api', redactSecrets(message));
631
+ }
632
+ /** Exponential backoff (base 500ms, capped 4s) with up to 25% positive jitter. */
633
+ function backoffMs(attempt) {
634
+ const base = Math.min(RETRY_MAX_DELAY_MS, RETRY_BASE_MS * 2 ** (attempt - 1));
635
+ return base + Math.floor(base * 0.25 * Math.random());
636
+ }
637
+ /**
638
+ * Open a PR (GitHub) or merge request (GitLab) for the pushed branch; returns its web URL.
639
+ * The provider is chosen from the EXPLICIT `opts.provider` when the dispatcher set it,
640
+ * falling back to host inference from the clone URL only when it didn't — so a self-managed
641
+ * GitLab whose host isn't named `gitlab.*` still opens an MR instead of being misrouted to
642
+ * GitHub's API. The GitHub path is unchanged.
643
+ */
644
+ export async function openPullRequest(opts) {
645
+ const provider = opts.provider ?? (opts.cloneUrl ? inferVcsProvider(opts.cloneUrl) : 'github');
646
+ if (provider === 'gitlab') {
647
+ if (!opts.cloneUrl) {
648
+ throw new Error('Cannot open a GitLab merge request without the repo clone URL');
649
+ }
650
+ return openGitLabMergeRequest({ ...opts, cloneUrl: opts.cloneUrl });
651
+ }
652
+ const apiBase = opts.apiBase ?? 'https://api.github.com';
653
+ const path = `${encodeURIComponent(opts.owner)}/${encodeURIComponent(opts.name)}`;
654
+ const res = await withApiRetry(() => fetch(`${apiBase}/repos/${path}/pulls`, {
655
+ method: 'POST',
656
+ headers: {
657
+ authorization: `Bearer ${opts.ghToken}`,
658
+ accept: 'application/vnd.github+json',
659
+ 'user-agent': 'cat-factory-executor',
660
+ 'x-github-api-version': '2022-11-28',
661
+ 'content-type': 'application/json',
662
+ },
663
+ body: JSON.stringify({
664
+ title: opts.pr.title,
665
+ head: opts.head,
666
+ base: opts.base,
667
+ body: opts.pr.body,
668
+ }),
669
+ // Bound on the watchdog so a hung GitHub call can't stall the job.
670
+ ...(opts.signal ? { signal: opts.signal } : {}),
671
+ }), { signal: opts.signal });
672
+ if (!res.ok) {
673
+ const detail = await res.text().catch(() => '');
674
+ // A resumed run pushes to a branch that already has an open PR; GitHub answers
675
+ // 422 "A pull request already exists". That's success for us — return the
676
+ // existing PR's url rather than failing the resumed run.
677
+ if (res.status === 422 && /pull request already exists/i.test(detail)) {
678
+ const existing = await findOpenPullRequestUrl(opts);
679
+ if (existing)
680
+ return existing;
681
+ }
682
+ throw new HarnessFailure('api', redactSecrets(`Failed to open PR (HTTP ${res.status}): ${detail.slice(0, 300)}`));
683
+ }
684
+ const body = (await res.json());
685
+ if (!body.html_url)
686
+ throw new HarnessFailure('api', 'GitHub did not return a PR url');
687
+ return body.html_url;
688
+ }
689
+ /** GitLab API headers for the PAT (the `PRIVATE-TOKEN` auth GitLab uses). */
690
+ function gitlabHeaders(token) {
691
+ return {
692
+ 'private-token': token,
693
+ accept: 'application/json',
694
+ 'user-agent': 'cat-factory-executor',
695
+ 'content-type': 'application/json',
696
+ };
697
+ }
698
+ /**
699
+ * Open a GitLab merge request (the analogue of {@link openPullRequest} for GitLab). The REST
700
+ * base + project path are derived from the clone URL's host, so it works for gitlab.com and a
701
+ * self-managed instance alike. `head`→`source_branch`, `base`→`target_branch`. On a duplicate
702
+ * (a resumed run whose branch already has an open MR — GitLab answers 409) the existing MR's
703
+ * web URL is returned instead of failing the run, mirroring the GitHub 422 handling.
704
+ */
705
+ async function openGitLabMergeRequest(opts) {
706
+ const apiBase = gitlabApiBaseFromCloneUrl(opts.cloneUrl);
707
+ const project = gitlabProjectPath(opts.cloneUrl);
708
+ const res = await withApiRetry(() => fetch(`${apiBase}/projects/${project}/merge_requests`, {
709
+ method: 'POST',
710
+ headers: gitlabHeaders(opts.ghToken),
711
+ body: JSON.stringify({
712
+ source_branch: opts.head,
713
+ target_branch: opts.base,
714
+ title: opts.pr.title,
715
+ description: opts.pr.body,
716
+ }),
717
+ ...(opts.signal ? { signal: opts.signal } : {}),
718
+ }), { signal: opts.signal });
719
+ if (!res.ok) {
720
+ const detail = await res.text().catch(() => '');
721
+ // GitLab returns 409 (sometimes 400) when an open MR already exists for this source
722
+ // branch; that is success for a resumed run — return the existing MR's url.
723
+ if ((res.status === 409 || res.status === 400) &&
724
+ /already exists|open merge request/i.test(detail)) {
725
+ const existing = await findOpenMergeRequestUrl(apiBase, project, opts);
726
+ if (existing)
727
+ return existing;
728
+ }
729
+ throw new HarnessFailure('api', redactSecrets(`Failed to open merge request (HTTP ${res.status}): ${detail.slice(0, 300)}`));
730
+ }
731
+ const body = (await res.json());
732
+ if (!body.web_url)
733
+ throw new HarnessFailure('api', 'GitLab did not return a merge request url');
734
+ return body.web_url;
735
+ }
736
+ /** Find the open GitLab MR for `opts.head`→`opts.base`, returning its web_url or undefined. */
737
+ async function findOpenMergeRequestUrl(apiBase, project, opts) {
738
+ // Filter by BOTH branches: a source branch can have open MRs to several targets, so the
739
+ // source alone could match an MR against a different base than the one we just tried to open.
740
+ const query = new URLSearchParams({
741
+ source_branch: opts.head,
742
+ target_branch: opts.base,
743
+ state: 'opened',
744
+ });
745
+ const res = await fetch(`${apiBase}/projects/${project}/merge_requests?${query}`, {
746
+ headers: gitlabHeaders(opts.ghToken),
747
+ ...(opts.signal ? { signal: opts.signal } : {}),
748
+ });
749
+ if (!res.ok)
750
+ return undefined;
751
+ const list = (await res.json().catch(() => []));
752
+ return Array.isArray(list) && list[0]?.web_url ? list[0].web_url : undefined;
753
+ }
754
+ /** Find the open PR for `opts.head` on `opts.base`, returning its html_url or undefined. */
755
+ async function findOpenPullRequestUrl(opts) {
756
+ const apiBase = opts.apiBase ?? 'https://api.github.com';
757
+ // Encode the ref-derived query params: a branch/owner containing `&` or `#` would
758
+ // otherwise split the query string or inject an unintended parameter.
759
+ const query = new URLSearchParams({
760
+ head: `${opts.owner}:${opts.head}`,
761
+ base: opts.base,
762
+ state: 'open',
763
+ });
764
+ const path = `${encodeURIComponent(opts.owner)}/${encodeURIComponent(opts.name)}`;
765
+ const res = await fetch(`${apiBase}/repos/${path}/pulls?${query}`, {
766
+ headers: {
767
+ authorization: `Bearer ${opts.ghToken}`,
768
+ accept: 'application/vnd.github+json',
769
+ 'user-agent': 'cat-factory-executor',
770
+ 'x-github-api-version': '2022-11-28',
771
+ },
772
+ ...(opts.signal ? { signal: opts.signal } : {}),
773
+ });
774
+ if (!res.ok)
775
+ return undefined;
776
+ const list = (await res.json().catch(() => []));
777
+ return Array.isArray(list) && list[0]?.html_url ? list[0].html_url : undefined;
778
+ }