@cat-factory/executor-harness 1.50.2 → 1.50.6

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.
@@ -43,100 +43,9 @@ export async function runCodingAgent(spec, opts = {}) {
43
43
  // (which differs from the cloned branch the registry bound).
44
44
  const logger = (opts.log ?? log).child({ kind: spec.kind, branch: spec.pushBranch });
45
45
  return acquireRepoCheckout({ persistent: spec.persistentCheckout === true, prefix: spec.kind, repo: spec.repo }, async (dir) => {
46
- // Resume an evicted earlier run when its work branch already exists on the
47
- // remote: clone THAT branch and continue on its commits, rather than branching
48
- // off base and redoing everything. Only the impl path (which creates a fresh
49
- // `newBranch`) can resume; the ci-fix/conflict paths already clone the PR branch.
50
- //
51
- // Resume safety relies on two invariants the dispatcher (worker) upholds, since
52
- // the harness can't see run/PR state from inside the container:
53
- // - At most ONE active run per block at a time. The work branch is deterministic
54
- // per block (`cat-factory/<blockId>`), so two concurrent runs would target the
55
- // same branch; their pushes race. A plain (non-forced) push fails safely on a
56
- // non-fast-forward rather than clobbering the other run's commits, so the worst
57
- // case is one run failing — never lost work — but the dispatcher should not
58
- // knowingly run two at once.
59
- // - Re-dispatch only NON-terminal runs (failed / evicted / stale-running), whose
60
- // branch is by definition unmerged. Resuming a branch whose PR already merged
61
- // could re-introduce merged work; that is avoided two ways: the platform deletes
62
- // the work branch when its PR merges (GitHubPullRequestMerger), so a re-run finds
63
- // no branch and starts fresh, and a `done` block is never re-dispatched anyway.
64
- const resumed = spec.newBranch != null &&
65
- (await remoteBranchExists(spec.repo.cloneUrl, spec.newBranch, spec.ghToken, signal));
66
- opts.onPhase?.('clone');
67
- if (spec.persistentCheckout) {
68
- // Reused checkout: clean-sweep + fetch + switch branch in place. A resumed branch
69
- // (or a run without `newBranch`, working directly on `cloneBranch`) already exists
70
- // on the remote, so check it out directly; otherwise (re)create `newBranch` off the
71
- // base tip — the same resume-vs-fresh decision the clone paths below make.
72
- const targetBranch = spec.newBranch ?? spec.cloneBranch;
73
- logger.info('coding-agent: preparing reused checkout', { branch: targetBranch, resumed });
74
- await prepareExistingCheckout({
75
- dir,
76
- repo: spec.repo,
77
- ghToken: spec.ghToken,
78
- branch: targetBranch,
79
- baseBranch: spec.cloneBranch,
80
- existing: resumed || spec.newBranch == null,
81
- signal,
82
- });
83
- }
84
- else if (resumed) {
85
- logger.info('coding-agent: resuming existing branch', { branch: spec.newBranch });
86
- await cloneExistingBranch({
87
- cloneUrl: spec.repo.cloneUrl,
88
- branch: spec.newBranch,
89
- ghToken: spec.ghToken,
90
- dir,
91
- signal,
92
- });
93
- }
94
- else {
95
- logger.info('coding-agent: cloning', { cloneBranch: spec.cloneBranch });
96
- await cloneRepo({
97
- repo: { ...spec.repo, baseBranch: spec.cloneBranch },
98
- ghToken: spec.ghToken,
99
- dir,
100
- signal,
101
- });
102
- if (spec.newBranch)
103
- await createBranch(dir, spec.newBranch, signal);
104
- }
105
- // Fetch any read-only reference branches into their `origin/<b>` refs so the agent can
106
- // inspect them (log/diff/show) without git network credentials of its own. Best-effort per
107
- // branch: a vanished branch is warned + skipped, never fatal. The work branch above is the
108
- // agent's HEAD; these are only readable siblings it never commits to.
109
- if (spec.referenceBranches?.length) {
110
- const fetched = await fetchReferenceBranches({
111
- dir,
112
- branches: spec.referenceBranches,
113
- ghToken: spec.ghToken,
114
- signal,
115
- onSkip: (branch, reason) => logger.warn('coding-agent: reference branch fetch skipped', { branch, reason }),
116
- });
117
- logger.info('coding-agent: fetched reference branches', {
118
- requested: spec.referenceBranches.length,
119
- fetched: fetched.length,
120
- });
121
- }
122
- // The branch tip before the agent runs this time. A FRESH run produced work iff
123
- // the branch advances past it; a RESUMED run already carries prior work, so it is
124
- // never a no-op regardless of what this pass adds. Captured BEFORE the resume base
125
- // refresh below so that refresh's merge commit counts as advancement and is pushed.
126
- const baseSha = await headCommit(dir, signal);
127
- // A resumed branch was cut from an OLDER base; merge the latest base in when the
128
- // two merge cleanly, so the agent works against current base and the PR stays
129
- // current. On a conflict this is a no-op (the run continues on the stale base — the
130
- // merge gate handles a conflicting PR downstream, as before), so it never blocks a
131
- // resume. Best-effort: any error is treated as "continue without refreshing".
132
- if (resumed) {
133
- const refreshed = await refreshFromBaseIfClean(dir, spec.cloneBranch, spec.ghToken, signal).catch(() => false);
134
- if (!refreshed) {
135
- logger.info('coding-agent: resume base refresh skipped (conflict or error)', {
136
- base: spec.cloneBranch,
137
- });
138
- }
139
- }
46
+ // Clone (or resume) the checkout, fetch any read-only reference branches, and capture the
47
+ // pre-run branch tip. See {@link prepareCodingCheckout} for the resume-safety invariants.
48
+ const { resumed, baseSha } = await prepareCodingCheckout(dir, spec, logger, opts);
140
49
  // Serialize all pushes to the work branch through a single in-flight promise.
141
50
  // A checkpoint tick and the final push (or two slow checkpoint ticks) must never
142
51
  // run `git push` to the same branch concurrently: overlapping pushes race on the
@@ -215,7 +124,7 @@ export async function runCodingAgent(spec, opts = {}) {
215
124
  try {
216
125
  opts.onPhase?.('agent');
217
126
  logger.info('coding-agent: running agent', { serviceDirectory });
218
- const { summary, stats, stderrTail, usage, callMetrics } = await runAgentInWorkspace({
127
+ const agentRun = await runAgentInWorkspace({
219
128
  dir: workDir,
220
129
  systemPrompt: spec.systemPrompt,
221
130
  userPrompt: spec.userPrompt,
@@ -232,84 +141,21 @@ export async function runCodingAgent(spec, opts = {}) {
232
141
  guardLimits: spec.guardLimits,
233
142
  ...(spec.skill ? { skill: spec.skill } : {}),
234
143
  }, opts);
235
- // Stop tailing the follow-up sentinel and flush any items written after the last
236
- // tick, so a fast final burst still reaches the job view before the run is recorded.
237
- if (followUpTick)
238
- clearInterval(followUpTick);
239
- if (followUpTailer)
240
- await followUpTailer.poll().catch(() => { });
241
- // Safety net for forgotten edits: commit changes to TRACKED files only (never
242
- // untracked scratch files/artifacts — the agent owns committing new files).
243
- await commitTrackedEdits(dir, spec.commitMessage, signal);
244
- // Stop periodic checkpoints and let any in-flight one settle BEFORE the final
245
- // push, so the two never run a concurrent `git push` to the same branch (the
246
- // final push below is then a fresh attempt whose failure is the real signal).
247
- clearInterval(checkpoint);
248
- const inflight = inFlightPush();
249
- if (inflight)
250
- await inflight.catch(() => { });
251
- // Surface (don't fail on) untracked, non-ignored files the agent left behind:
252
- // `commitTrackedEdits` only captures edits to ALREADY tracked files, so a NEW
253
- // file the agent created but forgot to commit is silently dropped. Logging it
254
- // makes that loss observable when a PR turns out to be missing a file.
255
- const leftover = await listUntrackedFiles(dir, signal);
256
- if (leftover.length > 0) {
257
- logger.warn('coding-agent: uncommitted new files left behind (not pushed)', {
258
- count: leftover.length,
259
- files: leftover.slice(0, 20),
260
- });
261
- }
262
- // A fresh run produced work iff the branch advanced past its pre-run tip. A RESUMED
263
- // run already carries prior work — UNLESS that branch turns out to have nothing ahead
264
- // of the PR base (e.g. its earlier PR was merged with a merge commit, leaving the
265
- // branch reachable from base and its best-effort delete skipped). Opening a PR for such
266
- // a branch fails with GitHub's opaque 422 "No commits between ...", so a CONFIRMED-empty
267
- // resumed branch is a no-op, not work. `undefined` (couldn't determine) keeps the prior
268
- // resume-is-work behaviour; the PR-open path then no-ops on the 422 as a backstop.
269
- const advancedThisPass = await branchHasCommitsSince(dir, baseSha, signal);
270
- let hasWork = advancedThisPass || resumed;
271
- if (resumed && !advancedThisPass) {
272
- const ahead = await branchAheadOfBase(dir, spec.repo.baseBranch, spec.ghToken, signal);
273
- if (ahead === false) {
274
- logger.info('coding-agent: resumed branch has no commits ahead of base — no-op', {
275
- base: spec.repo.baseBranch,
276
- });
277
- hasWork = false;
278
- }
279
- }
280
- if (!hasWork) {
281
- logger.info('coding-agent: no changes produced', { ...stats });
282
- outcome = {
283
- pushed: false,
284
- resumed,
285
- summary,
286
- stats,
287
- ...(stderrTail ? { stderrTail } : {}),
288
- ...(usage ? { usage } : {}),
289
- ...(callMetrics ? { callMetrics } : {}),
290
- };
291
- }
292
- else {
293
- opts.onPhase?.('push');
294
- logger.info('coding-agent: pushing', { resumed, ...stats });
295
- await pushWorkOnce();
296
- outcome = {
297
- pushed: true,
298
- resumed,
299
- summary,
300
- stats,
301
- ...(stderrTail ? { stderrTail } : {}),
302
- ...(usage ? { usage } : {}),
303
- ...(callMetrics ? { callMetrics } : {}),
304
- };
305
- }
306
- // Ralph loop: run the programmatic completion command against the pushed/committed
307
- // state and attach its verdict (exit code = the loop's authoritative done signal).
308
- // Runs regardless of whether this pass pushed — a no-op iteration must still be able
309
- // to report that the criterion is (already) met. The harness runs it, never the model.
310
- if (spec.validation) {
311
- outcome.validation = await runRalphValidation(workDir, spec.validation, logger, opts);
312
- }
144
+ outcome = await finalizeCodingRun({
145
+ dir,
146
+ spec,
147
+ logger,
148
+ opts,
149
+ baseSha,
150
+ resumed,
151
+ workDir,
152
+ checkpoint,
153
+ followUpTick,
154
+ followUpTailer,
155
+ pushWorkOnce,
156
+ inFlightPush,
157
+ agentRun,
158
+ });
313
159
  }
314
160
  finally {
315
161
  // Safety net for the throw path (the happy path already cleared these above).
@@ -320,6 +166,200 @@ export async function runCodingAgent(spec, opts = {}) {
320
166
  return outcome;
321
167
  });
322
168
  }
169
+ /**
170
+ * Clone (or RESUME an existing branch) into `dir`, fetch any read-only reference branches, and
171
+ * capture the pre-run branch tip. Extracted from {@link runCodingAgent} so its body stays small;
172
+ * returns `{ resumed, baseSha }` for the run to judge no-op vs work against.
173
+ *
174
+ * Resume an evicted earlier run when its work branch already exists on the remote: clone THAT
175
+ * branch and continue on its commits, rather than branching off base and redoing everything. Only
176
+ * the impl path (which creates a fresh `newBranch`) can resume; the ci-fix/conflict paths already
177
+ * clone the PR branch.
178
+ *
179
+ * Resume safety relies on two invariants the dispatcher (worker) upholds, since the harness can't
180
+ * see run/PR state from inside the container:
181
+ * - At most ONE active run per block at a time. The work branch is deterministic per block
182
+ * (`cat-factory/<blockId>`), so two concurrent runs would target the same branch; their pushes
183
+ * race. A plain (non-forced) push fails safely on a non-fast-forward rather than clobbering the
184
+ * other run's commits, so the worst case is one run failing — never lost work — but the
185
+ * dispatcher should not knowingly run two at once.
186
+ * - Re-dispatch only NON-terminal runs (failed / evicted / stale-running), whose branch is by
187
+ * definition unmerged. Resuming a branch whose PR already merged could re-introduce merged work;
188
+ * that is avoided two ways: the platform deletes the work branch when its PR merges
189
+ * (GitHubPullRequestMerger), so a re-run finds no branch and starts fresh, and a `done` block is
190
+ * never re-dispatched anyway.
191
+ */
192
+ async function prepareCodingCheckout(dir, spec, logger, opts) {
193
+ const { signal } = opts;
194
+ const resumed = spec.newBranch != null &&
195
+ (await remoteBranchExists(spec.repo.cloneUrl, spec.newBranch, spec.ghToken, signal));
196
+ opts.onPhase?.('clone');
197
+ if (spec.persistentCheckout) {
198
+ // Reused checkout: clean-sweep + fetch + switch branch in place. A resumed branch
199
+ // (or a run without `newBranch`, working directly on `cloneBranch`) already exists
200
+ // on the remote, so check it out directly; otherwise (re)create `newBranch` off the
201
+ // base tip — the same resume-vs-fresh decision the clone paths below make.
202
+ const targetBranch = spec.newBranch ?? spec.cloneBranch;
203
+ logger.info('coding-agent: preparing reused checkout', { branch: targetBranch, resumed });
204
+ await prepareExistingCheckout({
205
+ dir,
206
+ repo: spec.repo,
207
+ ghToken: spec.ghToken,
208
+ branch: targetBranch,
209
+ baseBranch: spec.cloneBranch,
210
+ existing: resumed || spec.newBranch == null,
211
+ signal,
212
+ });
213
+ }
214
+ else if (resumed) {
215
+ logger.info('coding-agent: resuming existing branch', { branch: spec.newBranch });
216
+ await cloneExistingBranch({
217
+ cloneUrl: spec.repo.cloneUrl,
218
+ branch: spec.newBranch,
219
+ ghToken: spec.ghToken,
220
+ dir,
221
+ signal,
222
+ });
223
+ }
224
+ else {
225
+ logger.info('coding-agent: cloning', { cloneBranch: spec.cloneBranch });
226
+ await cloneRepo({
227
+ repo: { ...spec.repo, baseBranch: spec.cloneBranch },
228
+ ghToken: spec.ghToken,
229
+ dir,
230
+ signal,
231
+ });
232
+ if (spec.newBranch)
233
+ await createBranch(dir, spec.newBranch, signal);
234
+ }
235
+ // Fetch any read-only reference branches into their `origin/<b>` refs so the agent can
236
+ // inspect them (log/diff/show) without git network credentials of its own. Best-effort per
237
+ // branch: a vanished branch is warned + skipped, never fatal. The work branch above is the
238
+ // agent's HEAD; these are only readable siblings it never commits to.
239
+ if (spec.referenceBranches?.length) {
240
+ const fetched = await fetchReferenceBranches({
241
+ dir,
242
+ branches: spec.referenceBranches,
243
+ ghToken: spec.ghToken,
244
+ signal,
245
+ onSkip: (branch, reason) => logger.warn('coding-agent: reference branch fetch skipped', { branch, reason }),
246
+ });
247
+ logger.info('coding-agent: fetched reference branches', {
248
+ requested: spec.referenceBranches.length,
249
+ fetched: fetched.length,
250
+ });
251
+ }
252
+ // The branch tip before the agent runs this time. A FRESH run produced work iff
253
+ // the branch advances past it; a RESUMED run already carries prior work, so it is
254
+ // never a no-op regardless of what this pass adds. Captured BEFORE the resume base
255
+ // refresh below so that refresh's merge commit counts as advancement and is pushed.
256
+ const baseSha = await headCommit(dir, signal);
257
+ // A resumed branch was cut from an OLDER base; merge the latest base in when the
258
+ // two merge cleanly, so the agent works against current base and the PR stays
259
+ // current. On a conflict this is a no-op (the run continues on the stale base — the
260
+ // merge gate handles a conflicting PR downstream, as before), so it never blocks a
261
+ // resume. Best-effort: any error is treated as "continue without refreshing".
262
+ if (resumed) {
263
+ const refreshed = await refreshFromBaseIfClean(dir, spec.cloneBranch, spec.ghToken, signal).catch(() => false);
264
+ if (!refreshed) {
265
+ logger.info('coding-agent: resume base refresh skipped (conflict or error)', {
266
+ base: spec.cloneBranch,
267
+ });
268
+ }
269
+ }
270
+ return { resumed, baseSha };
271
+ }
272
+ /**
273
+ * Finalize a coding run after the agent has finished: flush the follow-up tailer, safety-net commit
274
+ * forgotten tracked edits, settle any in-flight checkpoint push, decide whether the branch carries
275
+ * work, push it iff so, and (for a Ralph run) attach the validation verdict. Extracted from
276
+ * {@link runCodingAgent} so its body stays small; returns the built {@link CodingAgentOutcome}.
277
+ */
278
+ async function finalizeCodingRun(args) {
279
+ const { dir, spec, logger, opts, baseSha, resumed, workDir, checkpoint, followUpTick, followUpTailer, pushWorkOnce, inFlightPush, agentRun, } = args;
280
+ const { signal } = opts;
281
+ const { summary, stats, stderrTail, usage, callMetrics } = agentRun;
282
+ let outcome;
283
+ // Stop tailing the follow-up sentinel and flush any items written after the last
284
+ // tick, so a fast final burst still reaches the job view before the run is recorded.
285
+ if (followUpTick)
286
+ clearInterval(followUpTick);
287
+ if (followUpTailer)
288
+ await followUpTailer.poll().catch(() => { });
289
+ // Safety net for forgotten edits: commit changes to TRACKED files only (never
290
+ // untracked scratch files/artifacts — the agent owns committing new files).
291
+ await commitTrackedEdits(dir, spec.commitMessage, signal);
292
+ // Stop periodic checkpoints and let any in-flight one settle BEFORE the final
293
+ // push, so the two never run a concurrent `git push` to the same branch (the
294
+ // final push below is then a fresh attempt whose failure is the real signal).
295
+ clearInterval(checkpoint);
296
+ const inflight = inFlightPush();
297
+ if (inflight)
298
+ await inflight.catch(() => { });
299
+ // Surface (don't fail on) untracked, non-ignored files the agent left behind:
300
+ // `commitTrackedEdits` only captures edits to ALREADY tracked files, so a NEW
301
+ // file the agent created but forgot to commit is silently dropped. Logging it
302
+ // makes that loss observable when a PR turns out to be missing a file.
303
+ const leftover = await listUntrackedFiles(dir, signal);
304
+ if (leftover.length > 0) {
305
+ logger.warn('coding-agent: uncommitted new files left behind (not pushed)', {
306
+ count: leftover.length,
307
+ files: leftover.slice(0, 20),
308
+ });
309
+ }
310
+ // A fresh run produced work iff the branch advanced past its pre-run tip. A RESUMED
311
+ // run already carries prior work — UNLESS that branch turns out to have nothing ahead
312
+ // of the PR base (e.g. its earlier PR was merged with a merge commit, leaving the
313
+ // branch reachable from base and its best-effort delete skipped). Opening a PR for such
314
+ // a branch fails with GitHub's opaque 422 "No commits between ...", so a CONFIRMED-empty
315
+ // resumed branch is a no-op, not work. `undefined` (couldn't determine) keeps the prior
316
+ // resume-is-work behaviour; the PR-open path then no-ops on the 422 as a backstop.
317
+ const advancedThisPass = await branchHasCommitsSince(dir, baseSha, signal);
318
+ let hasWork = advancedThisPass || resumed;
319
+ if (resumed && !advancedThisPass) {
320
+ const ahead = await branchAheadOfBase(dir, spec.repo.baseBranch, spec.ghToken, signal);
321
+ if (ahead === false) {
322
+ logger.info('coding-agent: resumed branch has no commits ahead of base — no-op', {
323
+ base: spec.repo.baseBranch,
324
+ });
325
+ hasWork = false;
326
+ }
327
+ }
328
+ if (!hasWork) {
329
+ logger.info('coding-agent: no changes produced', { ...stats });
330
+ outcome = {
331
+ pushed: false,
332
+ resumed,
333
+ summary,
334
+ stats,
335
+ ...(stderrTail ? { stderrTail } : {}),
336
+ ...(usage ? { usage } : {}),
337
+ ...(callMetrics ? { callMetrics } : {}),
338
+ };
339
+ }
340
+ else {
341
+ opts.onPhase?.('push');
342
+ logger.info('coding-agent: pushing', { resumed, ...stats });
343
+ await pushWorkOnce();
344
+ outcome = {
345
+ pushed: true,
346
+ resumed,
347
+ summary,
348
+ stats,
349
+ ...(stderrTail ? { stderrTail } : {}),
350
+ ...(usage ? { usage } : {}),
351
+ ...(callMetrics ? { callMetrics } : {}),
352
+ };
353
+ }
354
+ // Ralph loop: run the programmatic completion command against the pushed/committed
355
+ // state and attach its verdict (exit code = the loop's authoritative done signal).
356
+ // Runs regardless of whether this pass pushed — a no-op iteration must still be able
357
+ // to report that the criterion is (already) met. The harness runs it, never the model.
358
+ if (spec.validation) {
359
+ outcome.validation = await runRalphValidation(workDir, spec.validation, logger, opts);
360
+ }
361
+ return outcome;
362
+ }
323
363
  /**
324
364
  * The Ralph-loop validation watchdog: the longest a completion command may run before it is
325
365
  * killed and treated as a failure (a hung `pnpm test` must never block the loop forever).
@@ -428,7 +468,6 @@ export function makeDirClaimer() {
428
468
  * git helpers, so the per-repo clone/commit/push/PR mechanics match the single-repo path exactly.
429
469
  */
430
470
  export async function runMultiRepoCoding(job, opts = {}) {
431
- const { signal } = opts;
432
471
  const logger = (opts.log ?? log).child({ kind: 'multi-repo', jobId: job.jobId });
433
472
  const peers = job.peerRepos ?? [];
434
473
  const references = job.referenceRepos ?? [];
@@ -481,92 +520,9 @@ export async function runMultiRepoCoding(job, opts = {}) {
481
520
  })),
482
521
  ];
483
522
  return withWorkspace('multi', async (root) => {
484
- // Clone phase: every repo into its sibling dir under the workspace root. Resume an
485
- // existing remote work branch (an evicted retry) rather than branching off base again.
486
- opts.onPhase?.('clone');
487
- for (const leg of legs) {
488
- const dir = join(root, leg.dirName);
489
- await mkdir(dir, { recursive: true });
490
- // A read-only reference leg: clone its base branch for the agent to read, and stop there —
491
- // no work branch, no resume, no base-refresh. It is skipped in the push phase, so it can
492
- // never be written to. (Kept in the loop so it lands in the same workspace root as siblings.)
493
- if (leg.readOnly) {
494
- logger.info('multi-repo: cloning read-only reference', {
495
- repo: leg.dirName,
496
- cloneBranch: leg.cloneBranch,
497
- });
498
- await cloneRepo({
499
- repo: { ...leg.repo, baseBranch: leg.cloneBranch },
500
- ghToken: leg.ghToken,
501
- dir,
502
- signal,
503
- });
504
- leg.dir = dir;
505
- continue;
506
- }
507
- leg.resumed = await remoteBranchExists(leg.repo.cloneUrl, leg.workBranch, leg.ghToken, signal);
508
- if (leg.resumed) {
509
- logger.info('multi-repo: resuming existing branch', {
510
- repo: leg.dirName,
511
- branch: leg.workBranch,
512
- });
513
- await cloneExistingBranch({
514
- cloneUrl: leg.repo.cloneUrl,
515
- branch: leg.workBranch,
516
- ghToken: leg.ghToken,
517
- dir,
518
- signal,
519
- });
520
- }
521
- else {
522
- logger.info('multi-repo: cloning', { repo: leg.dirName, cloneBranch: leg.cloneBranch });
523
- await cloneRepo({
524
- repo: { ...leg.repo, baseBranch: leg.cloneBranch },
525
- ghToken: leg.ghToken,
526
- dir,
527
- signal,
528
- });
529
- await createBranch(dir, leg.workBranch, signal);
530
- }
531
- leg.dir = dir;
532
- // The branch tip before the agent runs. Captured BEFORE the resume base refresh below so
533
- // that refresh's merge commit counts as advancement and is pushed (as in the single-repo
534
- // path). A fresh leg produced work iff its branch advances past this; a resumed leg already
535
- // carries prior work.
536
- leg.baseSha = await headCommit(dir, signal);
537
- // A resumed branch was cut from an OLDER base; merge the latest base in when the two merge
538
- // cleanly so the agent works against current base and the peer/own PRs stay current. On a
539
- // conflict this is a best-effort no-op (the merge gate handles a conflicting PR downstream),
540
- // mirroring the single-repo {@link runCodingAgent} resume refresh.
541
- if (leg.resumed) {
542
- const refreshed = await refreshFromBaseIfClean(dir, leg.cloneBranch, leg.ghToken, signal).catch(() => false);
543
- if (!refreshed) {
544
- logger.info('multi-repo: resume base refresh skipped (conflict or error)', {
545
- repo: leg.dirName,
546
- base: leg.cloneBranch,
547
- });
548
- }
549
- }
550
- }
551
- // Reference branches attach to the PRIMARY repo, so fetch them into the primary sibling
552
- // checkout's `origin/<b>` refs (best-effort per branch). The backend's reference-branches
553
- // prompt section names the primary repo's directory to run the read commands in.
554
- if (job.referenceBranches?.length) {
555
- const primaryLeg = legs.find((l) => l.primary);
556
- if (primaryLeg?.dir) {
557
- const fetched = await fetchReferenceBranches({
558
- dir: primaryLeg.dir,
559
- branches: job.referenceBranches,
560
- ghToken: primaryLeg.ghToken,
561
- signal,
562
- onSkip: (branch, reason) => logger.warn('multi-repo: reference branch fetch skipped', { branch, reason }),
563
- });
564
- logger.info('multi-repo: fetched reference branches', {
565
- requested: job.referenceBranches.length,
566
- fetched: fetched.length,
567
- });
568
- }
569
- }
523
+ // Clone (or resume) every sibling checkout under the workspace root and fetch the primary's
524
+ // reference branches. Mutates each leg's `dir`/`resumed`/`baseSha` in place.
525
+ await prepareMultiRepoCheckouts(root, legs, job, logger, opts);
570
526
  // Run the agent ONCE with its cwd at the workspace root, so it sees every sibling checkout
571
527
  // and can change them coherently. No monorepo/service-directory scoping — the multi-repo
572
528
  // note + the backend system-prompt section explain the layout.
@@ -589,67 +545,8 @@ export async function runMultiRepoCoding(job, opts = {}) {
589
545
  ...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
590
546
  multiRepo: true,
591
547
  }, opts);
592
- // Push phase: commit forgotten tracked edits, then push + open a PR for each repo the run
593
- // actually changed. A repo the agent left untouched is skipped (no branch, no PR).
594
- opts.onPhase?.('push');
595
- let primaryPushed = false;
596
- let primaryPrUrl;
597
- const peerPullRequests = [];
598
- for (const leg of legs) {
599
- // A read-only reference leg is never committed or pushed — the third layer of the read-only
600
- // guarantee (the spec carries no branch/PR, and the clone phase gave it no work branch).
601
- if (leg.readOnly)
602
- continue;
603
- await commitTrackedEdits(leg.dir, job.commitMessage ?? leg.pr?.title ?? 'Agent changes', signal);
604
- const advanced = await branchHasCommitsSince(leg.dir, leg.baseSha, signal);
605
- let hasWork = advanced || leg.resumed;
606
- if (leg.resumed && !advanced) {
607
- const ahead = await branchAheadOfBase(leg.dir, leg.repo.baseBranch, leg.ghToken, signal);
608
- if (ahead === false)
609
- hasWork = false;
610
- }
611
- const leftover = await listUntrackedFiles(leg.dir, signal);
612
- if (leftover.length > 0) {
613
- logger.warn('multi-repo: uncommitted new files left behind (not pushed)', {
614
- repo: leg.dirName,
615
- count: leftover.length,
616
- files: leftover.slice(0, 20),
617
- });
618
- }
619
- if (!hasWork) {
620
- logger.info('multi-repo: no changes for repo', { repo: leg.dirName });
621
- continue;
622
- }
623
- await pushBranch(leg.dir, leg.workBranch, leg.ghToken, signal);
624
- let prUrl = null;
625
- if (leg.pr) {
626
- prUrl = await openPullRequest({
627
- owner: leg.repo.owner,
628
- name: leg.repo.name,
629
- ghToken: leg.ghToken,
630
- head: leg.workBranch,
631
- base: leg.repo.baseBranch,
632
- pr: leg.pr,
633
- apiBase: job.githubApiBase,
634
- cloneUrl: leg.repo.cloneUrl,
635
- ...(leg.repo.provider ? { provider: leg.repo.provider } : {}),
636
- signal,
637
- });
638
- }
639
- if (leg.primary) {
640
- primaryPushed = true;
641
- if (prUrl)
642
- primaryPrUrl = prUrl;
643
- }
644
- else if (prUrl) {
645
- peerPullRequests.push({
646
- repo: `${leg.repo.owner}/${leg.repo.name}`,
647
- ...(leg.frameId ? { frameId: leg.frameId } : {}),
648
- prUrl,
649
- branch: leg.workBranch,
650
- });
651
- }
652
- }
548
+ // Commit forgotten tracked edits, then push + open a PR for each repo the run actually changed.
549
+ const { primaryPushed, primaryPrUrl, peerPullRequests } = await pushMultiRepoLegs(legs, job, logger, opts);
653
550
  const anyWork = primaryPushed || peerPullRequests.length > 0;
654
551
  if (!anyWork) {
655
552
  // Nothing changed in ANY repo. For the implementer this is a failure (as in the
@@ -693,6 +590,168 @@ export async function runMultiRepoCoding(job, opts = {}) {
693
590
  };
694
591
  });
695
592
  }
593
+ /**
594
+ * Clone phase for {@link runMultiRepoCoding}: every repo into its sibling dir under the workspace
595
+ * root. Resume an existing remote work branch (an evicted retry) rather than branching off base
596
+ * again, then fetch the primary repo's reference branches. Mutates each leg's `dir`/`resumed`/
597
+ * `baseSha` in place. Extracted so the multi-repo body stays small.
598
+ */
599
+ async function prepareMultiRepoCheckouts(root, legs, job, logger, opts) {
600
+ const { signal } = opts;
601
+ opts.onPhase?.('clone');
602
+ for (const leg of legs) {
603
+ const dir = join(root, leg.dirName);
604
+ await mkdir(dir, { recursive: true });
605
+ // A read-only reference leg: clone its base branch for the agent to read, and stop there —
606
+ // no work branch, no resume, no base-refresh. It is skipped in the push phase, so it can
607
+ // never be written to. (Kept in the loop so it lands in the same workspace root as siblings.)
608
+ if (leg.readOnly) {
609
+ logger.info('multi-repo: cloning read-only reference', {
610
+ repo: leg.dirName,
611
+ cloneBranch: leg.cloneBranch,
612
+ });
613
+ await cloneRepo({
614
+ repo: { ...leg.repo, baseBranch: leg.cloneBranch },
615
+ ghToken: leg.ghToken,
616
+ dir,
617
+ signal,
618
+ });
619
+ leg.dir = dir;
620
+ continue;
621
+ }
622
+ leg.resumed = await remoteBranchExists(leg.repo.cloneUrl, leg.workBranch, leg.ghToken, signal);
623
+ if (leg.resumed) {
624
+ logger.info('multi-repo: resuming existing branch', {
625
+ repo: leg.dirName,
626
+ branch: leg.workBranch,
627
+ });
628
+ await cloneExistingBranch({
629
+ cloneUrl: leg.repo.cloneUrl,
630
+ branch: leg.workBranch,
631
+ ghToken: leg.ghToken,
632
+ dir,
633
+ signal,
634
+ });
635
+ }
636
+ else {
637
+ logger.info('multi-repo: cloning', { repo: leg.dirName, cloneBranch: leg.cloneBranch });
638
+ await cloneRepo({
639
+ repo: { ...leg.repo, baseBranch: leg.cloneBranch },
640
+ ghToken: leg.ghToken,
641
+ dir,
642
+ signal,
643
+ });
644
+ await createBranch(dir, leg.workBranch, signal);
645
+ }
646
+ leg.dir = dir;
647
+ // The branch tip before the agent runs. Captured BEFORE the resume base refresh below so
648
+ // that refresh's merge commit counts as advancement and is pushed (as in the single-repo
649
+ // path). A fresh leg produced work iff its branch advances past this; a resumed leg already
650
+ // carries prior work.
651
+ leg.baseSha = await headCommit(dir, signal);
652
+ // A resumed branch was cut from an OLDER base; merge the latest base in when the two merge
653
+ // cleanly so the agent works against current base and the peer/own PRs stay current. On a
654
+ // conflict this is a best-effort no-op (the merge gate handles a conflicting PR downstream),
655
+ // mirroring the single-repo {@link runCodingAgent} resume refresh.
656
+ if (leg.resumed) {
657
+ const refreshed = await refreshFromBaseIfClean(dir, leg.cloneBranch, leg.ghToken, signal).catch(() => false);
658
+ if (!refreshed) {
659
+ logger.info('multi-repo: resume base refresh skipped (conflict or error)', {
660
+ repo: leg.dirName,
661
+ base: leg.cloneBranch,
662
+ });
663
+ }
664
+ }
665
+ }
666
+ // Reference branches attach to the PRIMARY repo, so fetch them into the primary sibling
667
+ // checkout's `origin/<b>` refs (best-effort per branch). The backend's reference-branches
668
+ // prompt section names the primary repo's directory to run the read commands in.
669
+ if (job.referenceBranches?.length) {
670
+ const primaryLeg = legs.find((l) => l.primary);
671
+ if (primaryLeg?.dir) {
672
+ const fetched = await fetchReferenceBranches({
673
+ dir: primaryLeg.dir,
674
+ branches: job.referenceBranches,
675
+ ghToken: primaryLeg.ghToken,
676
+ signal,
677
+ onSkip: (branch, reason) => logger.warn('multi-repo: reference branch fetch skipped', { branch, reason }),
678
+ });
679
+ logger.info('multi-repo: fetched reference branches', {
680
+ requested: job.referenceBranches.length,
681
+ fetched: fetched.length,
682
+ });
683
+ }
684
+ }
685
+ }
686
+ /**
687
+ * Push phase for {@link runMultiRepoCoding}: commit forgotten tracked edits, then push + open a PR
688
+ * for each repo the run actually changed (a repo the agent left untouched is skipped — no branch,
689
+ * no PR; a read-only reference leg is never committed or pushed). Extracted so the multi-repo body
690
+ * stays small; returns the primary's push/PR state plus the peer PRs.
691
+ */
692
+ async function pushMultiRepoLegs(legs, job, logger, opts) {
693
+ const { signal } = opts;
694
+ opts.onPhase?.('push');
695
+ let primaryPushed = false;
696
+ let primaryPrUrl;
697
+ const peerPullRequests = [];
698
+ for (const leg of legs) {
699
+ // A read-only reference leg is never committed or pushed — the third layer of the read-only
700
+ // guarantee (the spec carries no branch/PR, and the clone phase gave it no work branch).
701
+ if (leg.readOnly)
702
+ continue;
703
+ await commitTrackedEdits(leg.dir, job.commitMessage ?? leg.pr?.title ?? 'Agent changes', signal);
704
+ const advanced = await branchHasCommitsSince(leg.dir, leg.baseSha, signal);
705
+ let hasWork = advanced || leg.resumed;
706
+ if (leg.resumed && !advanced) {
707
+ const ahead = await branchAheadOfBase(leg.dir, leg.repo.baseBranch, leg.ghToken, signal);
708
+ if (ahead === false)
709
+ hasWork = false;
710
+ }
711
+ const leftover = await listUntrackedFiles(leg.dir, signal);
712
+ if (leftover.length > 0) {
713
+ logger.warn('multi-repo: uncommitted new files left behind (not pushed)', {
714
+ repo: leg.dirName,
715
+ count: leftover.length,
716
+ files: leftover.slice(0, 20),
717
+ });
718
+ }
719
+ if (!hasWork) {
720
+ logger.info('multi-repo: no changes for repo', { repo: leg.dirName });
721
+ continue;
722
+ }
723
+ await pushBranch(leg.dir, leg.workBranch, leg.ghToken, signal);
724
+ let prUrl = null;
725
+ if (leg.pr) {
726
+ prUrl = await openPullRequest({
727
+ owner: leg.repo.owner,
728
+ name: leg.repo.name,
729
+ ghToken: leg.ghToken,
730
+ head: leg.workBranch,
731
+ base: leg.repo.baseBranch,
732
+ pr: leg.pr,
733
+ apiBase: job.githubApiBase,
734
+ cloneUrl: leg.repo.cloneUrl,
735
+ ...(leg.repo.provider ? { provider: leg.repo.provider } : {}),
736
+ signal,
737
+ });
738
+ }
739
+ if (leg.primary) {
740
+ primaryPushed = true;
741
+ if (prUrl)
742
+ primaryPrUrl = prUrl;
743
+ }
744
+ else if (prUrl) {
745
+ peerPullRequests.push({
746
+ repo: `${leg.repo.owner}/${leg.repo.name}`,
747
+ ...(leg.frameId ? { frameId: leg.frameId } : {}),
748
+ prUrl,
749
+ branch: leg.workBranch,
750
+ });
751
+ }
752
+ }
753
+ return { primaryPushed, primaryPrUrl, peerPullRequests };
754
+ }
696
755
  /**
697
756
  * The "no changes" reason both coding agents report: a caller-supplied lead phrase
698
757
  * plus the shared "never acted" cause and a credential-scrubbed tail of Pi's stderr.