@cat-factory/executor-harness 1.50.2 → 1.50.4

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.
@@ -179,107 +179,9 @@ export async function runCodingAgent(
179
179
  return acquireRepoCheckout(
180
180
  { persistent: spec.persistentCheckout === true, prefix: spec.kind, repo: spec.repo },
181
181
  async (dir) => {
182
- // Resume an evicted earlier run when its work branch already exists on the
183
- // remote: clone THAT branch and continue on its commits, rather than branching
184
- // off base and redoing everything. Only the impl path (which creates a fresh
185
- // `newBranch`) can resume; the ci-fix/conflict paths already clone the PR branch.
186
- //
187
- // Resume safety relies on two invariants the dispatcher (worker) upholds, since
188
- // the harness can't see run/PR state from inside the container:
189
- // - At most ONE active run per block at a time. The work branch is deterministic
190
- // per block (`cat-factory/<blockId>`), so two concurrent runs would target the
191
- // same branch; their pushes race. A plain (non-forced) push fails safely on a
192
- // non-fast-forward rather than clobbering the other run's commits, so the worst
193
- // case is one run failing — never lost work — but the dispatcher should not
194
- // knowingly run two at once.
195
- // - Re-dispatch only NON-terminal runs (failed / evicted / stale-running), whose
196
- // branch is by definition unmerged. Resuming a branch whose PR already merged
197
- // could re-introduce merged work; that is avoided two ways: the platform deletes
198
- // the work branch when its PR merges (GitHubPullRequestMerger), so a re-run finds
199
- // no branch and starts fresh, and a `done` block is never re-dispatched anyway.
200
- const resumed =
201
- spec.newBranch != null &&
202
- (await remoteBranchExists(spec.repo.cloneUrl, spec.newBranch, spec.ghToken, signal))
203
- opts.onPhase?.('clone')
204
- if (spec.persistentCheckout) {
205
- // Reused checkout: clean-sweep + fetch + switch branch in place. A resumed branch
206
- // (or a run without `newBranch`, working directly on `cloneBranch`) already exists
207
- // on the remote, so check it out directly; otherwise (re)create `newBranch` off the
208
- // base tip — the same resume-vs-fresh decision the clone paths below make.
209
- const targetBranch = spec.newBranch ?? spec.cloneBranch
210
- logger.info('coding-agent: preparing reused checkout', { branch: targetBranch, resumed })
211
- await prepareExistingCheckout({
212
- dir,
213
- repo: spec.repo,
214
- ghToken: spec.ghToken,
215
- branch: targetBranch,
216
- baseBranch: spec.cloneBranch,
217
- existing: resumed || spec.newBranch == null,
218
- signal,
219
- })
220
- } else if (resumed) {
221
- logger.info('coding-agent: resuming existing branch', { branch: spec.newBranch })
222
- await cloneExistingBranch({
223
- cloneUrl: spec.repo.cloneUrl,
224
- branch: spec.newBranch!,
225
- ghToken: spec.ghToken,
226
- dir,
227
- signal,
228
- })
229
- } else {
230
- logger.info('coding-agent: cloning', { cloneBranch: spec.cloneBranch })
231
- await cloneRepo({
232
- repo: { ...spec.repo, baseBranch: spec.cloneBranch },
233
- ghToken: spec.ghToken,
234
- dir,
235
- signal,
236
- })
237
- if (spec.newBranch) await createBranch(dir, spec.newBranch, signal)
238
- }
239
-
240
- // Fetch any read-only reference branches into their `origin/<b>` refs so the agent can
241
- // inspect them (log/diff/show) without git network credentials of its own. Best-effort per
242
- // branch: a vanished branch is warned + skipped, never fatal. The work branch above is the
243
- // agent's HEAD; these are only readable siblings it never commits to.
244
- if (spec.referenceBranches?.length) {
245
- const fetched = await fetchReferenceBranches({
246
- dir,
247
- branches: spec.referenceBranches,
248
- ghToken: spec.ghToken,
249
- signal,
250
- onSkip: (branch, reason) =>
251
- logger.warn('coding-agent: reference branch fetch skipped', { branch, reason }),
252
- })
253
- logger.info('coding-agent: fetched reference branches', {
254
- requested: spec.referenceBranches.length,
255
- fetched: fetched.length,
256
- })
257
- }
258
-
259
- // The branch tip before the agent runs this time. A FRESH run produced work iff
260
- // the branch advances past it; a RESUMED run already carries prior work, so it is
261
- // never a no-op regardless of what this pass adds. Captured BEFORE the resume base
262
- // refresh below so that refresh's merge commit counts as advancement and is pushed.
263
- const baseSha = await headCommit(dir, signal)
264
-
265
- // A resumed branch was cut from an OLDER base; merge the latest base in when the
266
- // two merge cleanly, so the agent works against current base and the PR stays
267
- // current. On a conflict this is a no-op (the run continues on the stale base — the
268
- // merge gate handles a conflicting PR downstream, as before), so it never blocks a
269
- // resume. Best-effort: any error is treated as "continue without refreshing".
270
- if (resumed) {
271
- const refreshed = await refreshFromBaseIfClean(
272
- dir,
273
- spec.cloneBranch,
274
- spec.ghToken,
275
- signal,
276
- ).catch(() => false)
277
- if (!refreshed) {
278
- logger.info('coding-agent: resume base refresh skipped (conflict or error)', {
279
- base: spec.cloneBranch,
280
- })
281
- }
282
- }
182
+ // Clone (or resume) the checkout, fetch any read-only reference branches, and capture the
183
+ // pre-run branch tip. See {@link prepareCodingCheckout} for the resume-safety invariants.
184
+ const { resumed, baseSha } = await prepareCodingCheckout(dir, spec, logger, opts)
283
185
 
284
186
  // Serialize all pushes to the work branch through a single in-flight promise.
285
187
  // A checkpoint tick and the final push (or two slow checkpoint ticks) must never
@@ -361,7 +263,7 @@ export async function runCodingAgent(
361
263
  try {
362
264
  opts.onPhase?.('agent')
363
265
  logger.info('coding-agent: running agent', { serviceDirectory })
364
- const { summary, stats, stderrTail, usage, callMetrics } = await runAgentInWorkspace(
266
+ const agentRun = await runAgentInWorkspace(
365
267
  {
366
268
  dir: workDir,
367
269
  systemPrompt: spec.systemPrompt,
@@ -381,86 +283,21 @@ export async function runCodingAgent(
381
283
  },
382
284
  opts,
383
285
  )
384
-
385
- // Stop tailing the follow-up sentinel and flush any items written after the last
386
- // tick, so a fast final burst still reaches the job view before the run is recorded.
387
- if (followUpTick) clearInterval(followUpTick)
388
- if (followUpTailer) await followUpTailer.poll().catch(() => {})
389
-
390
- // Safety net for forgotten edits: commit changes to TRACKED files only (never
391
- // untracked scratch files/artifacts — the agent owns committing new files).
392
- await commitTrackedEdits(dir, spec.commitMessage, signal)
393
-
394
- // Stop periodic checkpoints and let any in-flight one settle BEFORE the final
395
- // push, so the two never run a concurrent `git push` to the same branch (the
396
- // final push below is then a fresh attempt whose failure is the real signal).
397
- clearInterval(checkpoint)
398
- const inflight = inFlightPush()
399
- if (inflight) await inflight.catch(() => {})
400
-
401
- // Surface (don't fail on) untracked, non-ignored files the agent left behind:
402
- // `commitTrackedEdits` only captures edits to ALREADY tracked files, so a NEW
403
- // file the agent created but forgot to commit is silently dropped. Logging it
404
- // makes that loss observable when a PR turns out to be missing a file.
405
- const leftover = await listUntrackedFiles(dir, signal)
406
- if (leftover.length > 0) {
407
- logger.warn('coding-agent: uncommitted new files left behind (not pushed)', {
408
- count: leftover.length,
409
- files: leftover.slice(0, 20),
410
- })
411
- }
412
-
413
- // A fresh run produced work iff the branch advanced past its pre-run tip. A RESUMED
414
- // run already carries prior work — UNLESS that branch turns out to have nothing ahead
415
- // of the PR base (e.g. its earlier PR was merged with a merge commit, leaving the
416
- // branch reachable from base and its best-effort delete skipped). Opening a PR for such
417
- // a branch fails with GitHub's opaque 422 "No commits between ...", so a CONFIRMED-empty
418
- // resumed branch is a no-op, not work. `undefined` (couldn't determine) keeps the prior
419
- // resume-is-work behaviour; the PR-open path then no-ops on the 422 as a backstop.
420
- const advancedThisPass = await branchHasCommitsSince(dir, baseSha, signal)
421
- let hasWork = advancedThisPass || resumed
422
- if (resumed && !advancedThisPass) {
423
- const ahead = await branchAheadOfBase(dir, spec.repo.baseBranch, spec.ghToken, signal)
424
- if (ahead === false) {
425
- logger.info('coding-agent: resumed branch has no commits ahead of base — no-op', {
426
- base: spec.repo.baseBranch,
427
- })
428
- hasWork = false
429
- }
430
- }
431
- if (!hasWork) {
432
- logger.info('coding-agent: no changes produced', { ...stats })
433
- outcome = {
434
- pushed: false,
435
- resumed,
436
- summary,
437
- stats,
438
- ...(stderrTail ? { stderrTail } : {}),
439
- ...(usage ? { usage } : {}),
440
- ...(callMetrics ? { callMetrics } : {}),
441
- }
442
- } else {
443
- opts.onPhase?.('push')
444
- logger.info('coding-agent: pushing', { resumed, ...stats })
445
- await pushWorkOnce()
446
- outcome = {
447
- pushed: true,
448
- resumed,
449
- summary,
450
- stats,
451
- ...(stderrTail ? { stderrTail } : {}),
452
- ...(usage ? { usage } : {}),
453
- ...(callMetrics ? { callMetrics } : {}),
454
- }
455
- }
456
-
457
- // Ralph loop: run the programmatic completion command against the pushed/committed
458
- // state and attach its verdict (exit code = the loop's authoritative done signal).
459
- // Runs regardless of whether this pass pushed — a no-op iteration must still be able
460
- // to report that the criterion is (already) met. The harness runs it, never the model.
461
- if (spec.validation) {
462
- outcome.validation = await runRalphValidation(workDir, spec.validation, logger, opts)
463
- }
286
+ outcome = await finalizeCodingRun({
287
+ dir,
288
+ spec,
289
+ logger,
290
+ opts,
291
+ baseSha,
292
+ resumed,
293
+ workDir,
294
+ checkpoint,
295
+ followUpTick,
296
+ followUpTailer,
297
+ pushWorkOnce,
298
+ inFlightPush,
299
+ agentRun,
300
+ })
464
301
  } finally {
465
302
  // Safety net for the throw path (the happy path already cleared these above).
466
303
  clearInterval(checkpoint)
@@ -471,6 +308,245 @@ export async function runCodingAgent(
471
308
  )
472
309
  }
473
310
 
311
+ /**
312
+ * Clone (or RESUME an existing branch) into `dir`, fetch any read-only reference branches, and
313
+ * capture the pre-run branch tip. Extracted from {@link runCodingAgent} so its body stays small;
314
+ * returns `{ resumed, baseSha }` for the run to judge no-op vs work against.
315
+ *
316
+ * Resume an evicted earlier run when its work branch already exists on the remote: clone THAT
317
+ * branch and continue on its commits, rather than branching off base and redoing everything. Only
318
+ * the impl path (which creates a fresh `newBranch`) can resume; the ci-fix/conflict paths already
319
+ * clone the PR branch.
320
+ *
321
+ * Resume safety relies on two invariants the dispatcher (worker) upholds, since the harness can't
322
+ * see run/PR state from inside the container:
323
+ * - At most ONE active run per block at a time. The work branch is deterministic per block
324
+ * (`cat-factory/<blockId>`), so two concurrent runs would target the same branch; their pushes
325
+ * race. A plain (non-forced) push fails safely on a non-fast-forward rather than clobbering the
326
+ * other run's commits, so the worst case is one run failing — never lost work — but the
327
+ * dispatcher should not knowingly run two at once.
328
+ * - Re-dispatch only NON-terminal runs (failed / evicted / stale-running), whose branch is by
329
+ * definition unmerged. Resuming a branch whose PR already merged could re-introduce merged work;
330
+ * that is avoided two ways: the platform deletes the work branch when its PR merges
331
+ * (GitHubPullRequestMerger), so a re-run finds no branch and starts fresh, and a `done` block is
332
+ * never re-dispatched anyway.
333
+ */
334
+ async function prepareCodingCheckout(
335
+ dir: string,
336
+ spec: CodingAgentSpec,
337
+ logger: Logger,
338
+ opts: RunOptions,
339
+ ): Promise<{ resumed: boolean; baseSha: string }> {
340
+ const { signal } = opts
341
+ const resumed =
342
+ spec.newBranch != null &&
343
+ (await remoteBranchExists(spec.repo.cloneUrl, spec.newBranch, spec.ghToken, signal))
344
+ opts.onPhase?.('clone')
345
+ if (spec.persistentCheckout) {
346
+ // Reused checkout: clean-sweep + fetch + switch branch in place. A resumed branch
347
+ // (or a run without `newBranch`, working directly on `cloneBranch`) already exists
348
+ // on the remote, so check it out directly; otherwise (re)create `newBranch` off the
349
+ // base tip — the same resume-vs-fresh decision the clone paths below make.
350
+ const targetBranch = spec.newBranch ?? spec.cloneBranch
351
+ logger.info('coding-agent: preparing reused checkout', { branch: targetBranch, resumed })
352
+ await prepareExistingCheckout({
353
+ dir,
354
+ repo: spec.repo,
355
+ ghToken: spec.ghToken,
356
+ branch: targetBranch,
357
+ baseBranch: spec.cloneBranch,
358
+ existing: resumed || spec.newBranch == null,
359
+ signal,
360
+ })
361
+ } else if (resumed) {
362
+ logger.info('coding-agent: resuming existing branch', { branch: spec.newBranch })
363
+ await cloneExistingBranch({
364
+ cloneUrl: spec.repo.cloneUrl,
365
+ branch: spec.newBranch!,
366
+ ghToken: spec.ghToken,
367
+ dir,
368
+ signal,
369
+ })
370
+ } else {
371
+ logger.info('coding-agent: cloning', { cloneBranch: spec.cloneBranch })
372
+ await cloneRepo({
373
+ repo: { ...spec.repo, baseBranch: spec.cloneBranch },
374
+ ghToken: spec.ghToken,
375
+ dir,
376
+ signal,
377
+ })
378
+ if (spec.newBranch) await createBranch(dir, spec.newBranch, signal)
379
+ }
380
+
381
+ // Fetch any read-only reference branches into their `origin/<b>` refs so the agent can
382
+ // inspect them (log/diff/show) without git network credentials of its own. Best-effort per
383
+ // branch: a vanished branch is warned + skipped, never fatal. The work branch above is the
384
+ // agent's HEAD; these are only readable siblings it never commits to.
385
+ if (spec.referenceBranches?.length) {
386
+ const fetched = await fetchReferenceBranches({
387
+ dir,
388
+ branches: spec.referenceBranches,
389
+ ghToken: spec.ghToken,
390
+ signal,
391
+ onSkip: (branch, reason) =>
392
+ logger.warn('coding-agent: reference branch fetch skipped', { branch, reason }),
393
+ })
394
+ logger.info('coding-agent: fetched reference branches', {
395
+ requested: spec.referenceBranches.length,
396
+ fetched: fetched.length,
397
+ })
398
+ }
399
+
400
+ // The branch tip before the agent runs this time. A FRESH run produced work iff
401
+ // the branch advances past it; a RESUMED run already carries prior work, so it is
402
+ // never a no-op regardless of what this pass adds. Captured BEFORE the resume base
403
+ // refresh below so that refresh's merge commit counts as advancement and is pushed.
404
+ const baseSha = await headCommit(dir, signal)
405
+
406
+ // A resumed branch was cut from an OLDER base; merge the latest base in when the
407
+ // two merge cleanly, so the agent works against current base and the PR stays
408
+ // current. On a conflict this is a no-op (the run continues on the stale base — the
409
+ // merge gate handles a conflicting PR downstream, as before), so it never blocks a
410
+ // resume. Best-effort: any error is treated as "continue without refreshing".
411
+ if (resumed) {
412
+ const refreshed = await refreshFromBaseIfClean(
413
+ dir,
414
+ spec.cloneBranch,
415
+ spec.ghToken,
416
+ signal,
417
+ ).catch(() => false)
418
+ if (!refreshed) {
419
+ logger.info('coding-agent: resume base refresh skipped (conflict or error)', {
420
+ base: spec.cloneBranch,
421
+ })
422
+ }
423
+ }
424
+
425
+ return { resumed, baseSha }
426
+ }
427
+
428
+ /**
429
+ * Finalize a coding run after the agent has finished: flush the follow-up tailer, safety-net commit
430
+ * forgotten tracked edits, settle any in-flight checkpoint push, decide whether the branch carries
431
+ * work, push it iff so, and (for a Ralph run) attach the validation verdict. Extracted from
432
+ * {@link runCodingAgent} so its body stays small; returns the built {@link CodingAgentOutcome}.
433
+ */
434
+ async function finalizeCodingRun(args: {
435
+ dir: string
436
+ spec: CodingAgentSpec
437
+ logger: Logger
438
+ opts: RunOptions
439
+ baseSha: string
440
+ resumed: boolean
441
+ workDir: string
442
+ checkpoint: ReturnType<typeof setInterval>
443
+ followUpTick: ReturnType<typeof setInterval> | undefined
444
+ followUpTailer: FollowUpTailer | undefined
445
+ pushWorkOnce: () => Promise<void>
446
+ inFlightPush: () => Promise<void> | null
447
+ agentRun: Awaited<ReturnType<typeof runAgentInWorkspace>>
448
+ }): Promise<CodingAgentOutcome> {
449
+ const {
450
+ dir,
451
+ spec,
452
+ logger,
453
+ opts,
454
+ baseSha,
455
+ resumed,
456
+ workDir,
457
+ checkpoint,
458
+ followUpTick,
459
+ followUpTailer,
460
+ pushWorkOnce,
461
+ inFlightPush,
462
+ agentRun,
463
+ } = args
464
+ const { signal } = opts
465
+ const { summary, stats, stderrTail, usage, callMetrics } = agentRun
466
+ let outcome: CodingAgentOutcome
467
+
468
+ // Stop tailing the follow-up sentinel and flush any items written after the last
469
+ // tick, so a fast final burst still reaches the job view before the run is recorded.
470
+ if (followUpTick) clearInterval(followUpTick)
471
+ if (followUpTailer) await followUpTailer.poll().catch(() => {})
472
+
473
+ // Safety net for forgotten edits: commit changes to TRACKED files only (never
474
+ // untracked scratch files/artifacts — the agent owns committing new files).
475
+ await commitTrackedEdits(dir, spec.commitMessage, signal)
476
+
477
+ // Stop periodic checkpoints and let any in-flight one settle BEFORE the final
478
+ // push, so the two never run a concurrent `git push` to the same branch (the
479
+ // final push below is then a fresh attempt whose failure is the real signal).
480
+ clearInterval(checkpoint)
481
+ const inflight = inFlightPush()
482
+ if (inflight) await inflight.catch(() => {})
483
+
484
+ // Surface (don't fail on) untracked, non-ignored files the agent left behind:
485
+ // `commitTrackedEdits` only captures edits to ALREADY tracked files, so a NEW
486
+ // file the agent created but forgot to commit is silently dropped. Logging it
487
+ // makes that loss observable when a PR turns out to be missing a file.
488
+ const leftover = await listUntrackedFiles(dir, signal)
489
+ if (leftover.length > 0) {
490
+ logger.warn('coding-agent: uncommitted new files left behind (not pushed)', {
491
+ count: leftover.length,
492
+ files: leftover.slice(0, 20),
493
+ })
494
+ }
495
+
496
+ // A fresh run produced work iff the branch advanced past its pre-run tip. A RESUMED
497
+ // run already carries prior work — UNLESS that branch turns out to have nothing ahead
498
+ // of the PR base (e.g. its earlier PR was merged with a merge commit, leaving the
499
+ // branch reachable from base and its best-effort delete skipped). Opening a PR for such
500
+ // a branch fails with GitHub's opaque 422 "No commits between ...", so a CONFIRMED-empty
501
+ // resumed branch is a no-op, not work. `undefined` (couldn't determine) keeps the prior
502
+ // resume-is-work behaviour; the PR-open path then no-ops on the 422 as a backstop.
503
+ const advancedThisPass = await branchHasCommitsSince(dir, baseSha, signal)
504
+ let hasWork = advancedThisPass || resumed
505
+ if (resumed && !advancedThisPass) {
506
+ const ahead = await branchAheadOfBase(dir, spec.repo.baseBranch, spec.ghToken, signal)
507
+ if (ahead === false) {
508
+ logger.info('coding-agent: resumed branch has no commits ahead of base — no-op', {
509
+ base: spec.repo.baseBranch,
510
+ })
511
+ hasWork = false
512
+ }
513
+ }
514
+ if (!hasWork) {
515
+ logger.info('coding-agent: no changes produced', { ...stats })
516
+ outcome = {
517
+ pushed: false,
518
+ resumed,
519
+ summary,
520
+ stats,
521
+ ...(stderrTail ? { stderrTail } : {}),
522
+ ...(usage ? { usage } : {}),
523
+ ...(callMetrics ? { callMetrics } : {}),
524
+ }
525
+ } else {
526
+ opts.onPhase?.('push')
527
+ logger.info('coding-agent: pushing', { resumed, ...stats })
528
+ await pushWorkOnce()
529
+ outcome = {
530
+ pushed: true,
531
+ resumed,
532
+ summary,
533
+ stats,
534
+ ...(stderrTail ? { stderrTail } : {}),
535
+ ...(usage ? { usage } : {}),
536
+ ...(callMetrics ? { callMetrics } : {}),
537
+ }
538
+ }
539
+
540
+ // Ralph loop: run the programmatic completion command against the pushed/committed
541
+ // state and attach its verdict (exit code = the loop's authoritative done signal).
542
+ // Runs regardless of whether this pass pushed — a no-op iteration must still be able
543
+ // to report that the criterion is (already) met. The harness runs it, never the model.
544
+ if (spec.validation) {
545
+ outcome.validation = await runRalphValidation(workDir, spec.validation, logger, opts)
546
+ }
547
+ return outcome
548
+ }
549
+
474
550
  /**
475
551
  * The Ralph-loop validation watchdog: the longest a completion command may run before it is
476
552
  * killed and treated as a failure (a hung `pnpm test` must never block the loop forever).
@@ -622,7 +698,6 @@ export async function runMultiRepoCoding(
622
698
  job: AgentJob,
623
699
  opts: RunOptions = {},
624
700
  ): Promise<AgentResult> {
625
- const { signal } = opts
626
701
  const logger = (opts.log ?? log).child({ kind: 'multi-repo', jobId: job.jobId })
627
702
  const peers: PeerRepoSpec[] = job.peerRepos ?? []
628
703
  const references: ReferenceRepoSpec[] = job.referenceRepos ?? []
@@ -681,98 +756,9 @@ export async function runMultiRepoCoding(
681
756
  ]
682
757
 
683
758
  return withWorkspace('multi', async (root) => {
684
- // Clone phase: every repo into its sibling dir under the workspace root. Resume an
685
- // existing remote work branch (an evicted retry) rather than branching off base again.
686
- opts.onPhase?.('clone')
687
- for (const leg of legs) {
688
- const dir = join(root, leg.dirName)
689
- await mkdir(dir, { recursive: true })
690
- // A read-only reference leg: clone its base branch for the agent to read, and stop there —
691
- // no work branch, no resume, no base-refresh. It is skipped in the push phase, so it can
692
- // never be written to. (Kept in the loop so it lands in the same workspace root as siblings.)
693
- if (leg.readOnly) {
694
- logger.info('multi-repo: cloning read-only reference', {
695
- repo: leg.dirName,
696
- cloneBranch: leg.cloneBranch,
697
- })
698
- await cloneRepo({
699
- repo: { ...leg.repo, baseBranch: leg.cloneBranch },
700
- ghToken: leg.ghToken,
701
- dir,
702
- signal,
703
- })
704
- leg.dir = dir
705
- continue
706
- }
707
- leg.resumed = await remoteBranchExists(leg.repo.cloneUrl, leg.workBranch, leg.ghToken, signal)
708
- if (leg.resumed) {
709
- logger.info('multi-repo: resuming existing branch', {
710
- repo: leg.dirName,
711
- branch: leg.workBranch,
712
- })
713
- await cloneExistingBranch({
714
- cloneUrl: leg.repo.cloneUrl,
715
- branch: leg.workBranch,
716
- ghToken: leg.ghToken,
717
- dir,
718
- signal,
719
- })
720
- } else {
721
- logger.info('multi-repo: cloning', { repo: leg.dirName, cloneBranch: leg.cloneBranch })
722
- await cloneRepo({
723
- repo: { ...leg.repo, baseBranch: leg.cloneBranch },
724
- ghToken: leg.ghToken,
725
- dir,
726
- signal,
727
- })
728
- await createBranch(dir, leg.workBranch, signal)
729
- }
730
- leg.dir = dir
731
- // The branch tip before the agent runs. Captured BEFORE the resume base refresh below so
732
- // that refresh's merge commit counts as advancement and is pushed (as in the single-repo
733
- // path). A fresh leg produced work iff its branch advances past this; a resumed leg already
734
- // carries prior work.
735
- leg.baseSha = await headCommit(dir, signal)
736
- // A resumed branch was cut from an OLDER base; merge the latest base in when the two merge
737
- // cleanly so the agent works against current base and the peer/own PRs stay current. On a
738
- // conflict this is a best-effort no-op (the merge gate handles a conflicting PR downstream),
739
- // mirroring the single-repo {@link runCodingAgent} resume refresh.
740
- if (leg.resumed) {
741
- const refreshed = await refreshFromBaseIfClean(
742
- dir,
743
- leg.cloneBranch,
744
- leg.ghToken,
745
- signal,
746
- ).catch(() => false)
747
- if (!refreshed) {
748
- logger.info('multi-repo: resume base refresh skipped (conflict or error)', {
749
- repo: leg.dirName,
750
- base: leg.cloneBranch,
751
- })
752
- }
753
- }
754
- }
755
-
756
- // Reference branches attach to the PRIMARY repo, so fetch them into the primary sibling
757
- // checkout's `origin/<b>` refs (best-effort per branch). The backend's reference-branches
758
- // prompt section names the primary repo's directory to run the read commands in.
759
- if (job.referenceBranches?.length) {
760
- const primaryLeg = legs.find((l) => l.primary)
761
- if (primaryLeg?.dir) {
762
- const fetched = await fetchReferenceBranches({
763
- dir: primaryLeg.dir,
764
- branches: job.referenceBranches,
765
- ghToken: primaryLeg.ghToken,
766
- signal,
767
- onSkip: (branch, reason) =>
768
- logger.warn('multi-repo: reference branch fetch skipped', { branch, reason }),
769
- })
770
- logger.info('multi-repo: fetched reference branches', {
771
- requested: job.referenceBranches.length,
772
- fetched: fetched.length,
773
- })
774
- }
775
- }
759
+ // Clone (or resume) every sibling checkout under the workspace root and fetch the primary's
760
+ // reference branches. Mutates each leg's `dir`/`resumed`/`baseSha` in place.
761
+ await prepareMultiRepoCheckouts(root, legs, job, logger, opts)
776
762
 
777
763
  // Run the agent ONCE with its cwd at the workspace root, so it sees every sibling checkout
778
764
  // and can change them coherently. No monorepo/service-directory scoping — the multi-repo
@@ -800,67 +786,13 @@ export async function runMultiRepoCoding(
800
786
  opts,
801
787
  )
802
788
 
803
- // Push phase: commit forgotten tracked edits, then push + open a PR for each repo the run
804
- // actually changed. A repo the agent left untouched is skipped (no branch, no PR).
805
- opts.onPhase?.('push')
806
- let primaryPushed = false
807
- let primaryPrUrl: string | undefined
808
- const peerPullRequests: NonNullable<AgentResult['peerPullRequests']> = []
809
- for (const leg of legs) {
810
- // A read-only reference leg is never committed or pushed — the third layer of the read-only
811
- // guarantee (the spec carries no branch/PR, and the clone phase gave it no work branch).
812
- if (leg.readOnly) continue
813
- await commitTrackedEdits(
814
- leg.dir,
815
- job.commitMessage ?? leg.pr?.title ?? 'Agent changes',
816
- signal,
817
- )
818
- const advanced = await branchHasCommitsSince(leg.dir, leg.baseSha, signal)
819
- let hasWork = advanced || leg.resumed
820
- if (leg.resumed && !advanced) {
821
- const ahead = await branchAheadOfBase(leg.dir, leg.repo.baseBranch, leg.ghToken, signal)
822
- if (ahead === false) hasWork = false
823
- }
824
- const leftover = await listUntrackedFiles(leg.dir, signal)
825
- if (leftover.length > 0) {
826
- logger.warn('multi-repo: uncommitted new files left behind (not pushed)', {
827
- repo: leg.dirName,
828
- count: leftover.length,
829
- files: leftover.slice(0, 20),
830
- })
831
- }
832
- if (!hasWork) {
833
- logger.info('multi-repo: no changes for repo', { repo: leg.dirName })
834
- continue
835
- }
836
- await pushBranch(leg.dir, leg.workBranch, leg.ghToken, signal)
837
- let prUrl: string | null = null
838
- if (leg.pr) {
839
- prUrl = await openPullRequest({
840
- owner: leg.repo.owner,
841
- name: leg.repo.name,
842
- ghToken: leg.ghToken,
843
- head: leg.workBranch,
844
- base: leg.repo.baseBranch,
845
- pr: leg.pr,
846
- apiBase: job.githubApiBase,
847
- cloneUrl: leg.repo.cloneUrl,
848
- ...(leg.repo.provider ? { provider: leg.repo.provider } : {}),
849
- signal,
850
- })
851
- }
852
- if (leg.primary) {
853
- primaryPushed = true
854
- if (prUrl) primaryPrUrl = prUrl
855
- } else if (prUrl) {
856
- peerPullRequests.push({
857
- repo: `${leg.repo.owner}/${leg.repo.name}`,
858
- ...(leg.frameId ? { frameId: leg.frameId } : {}),
859
- prUrl,
860
- branch: leg.workBranch,
861
- })
862
- }
863
- }
789
+ // Commit forgotten tracked edits, then push + open a PR for each repo the run actually changed.
790
+ const { primaryPushed, primaryPrUrl, peerPullRequests } = await pushMultiRepoLegs(
791
+ legs,
792
+ job,
793
+ logger,
794
+ opts,
795
+ )
864
796
 
865
797
  const anyWork = primaryPushed || peerPullRequests.length > 0
866
798
  if (!anyWork) {
@@ -910,6 +842,187 @@ export async function runMultiRepoCoding(
910
842
  })
911
843
  }
912
844
 
845
+ /**
846
+ * Clone phase for {@link runMultiRepoCoding}: every repo into its sibling dir under the workspace
847
+ * root. Resume an existing remote work branch (an evicted retry) rather than branching off base
848
+ * again, then fetch the primary repo's reference branches. Mutates each leg's `dir`/`resumed`/
849
+ * `baseSha` in place. Extracted so the multi-repo body stays small.
850
+ */
851
+ async function prepareMultiRepoCheckouts(
852
+ root: string,
853
+ legs: RepoLeg[],
854
+ job: AgentJob,
855
+ logger: Logger,
856
+ opts: RunOptions,
857
+ ): Promise<void> {
858
+ const { signal } = opts
859
+ opts.onPhase?.('clone')
860
+ for (const leg of legs) {
861
+ const dir = join(root, leg.dirName)
862
+ await mkdir(dir, { recursive: true })
863
+ // A read-only reference leg: clone its base branch for the agent to read, and stop there —
864
+ // no work branch, no resume, no base-refresh. It is skipped in the push phase, so it can
865
+ // never be written to. (Kept in the loop so it lands in the same workspace root as siblings.)
866
+ if (leg.readOnly) {
867
+ logger.info('multi-repo: cloning read-only reference', {
868
+ repo: leg.dirName,
869
+ cloneBranch: leg.cloneBranch,
870
+ })
871
+ await cloneRepo({
872
+ repo: { ...leg.repo, baseBranch: leg.cloneBranch },
873
+ ghToken: leg.ghToken,
874
+ dir,
875
+ signal,
876
+ })
877
+ leg.dir = dir
878
+ continue
879
+ }
880
+ leg.resumed = await remoteBranchExists(leg.repo.cloneUrl, leg.workBranch, leg.ghToken, signal)
881
+ if (leg.resumed) {
882
+ logger.info('multi-repo: resuming existing branch', {
883
+ repo: leg.dirName,
884
+ branch: leg.workBranch,
885
+ })
886
+ await cloneExistingBranch({
887
+ cloneUrl: leg.repo.cloneUrl,
888
+ branch: leg.workBranch,
889
+ ghToken: leg.ghToken,
890
+ dir,
891
+ signal,
892
+ })
893
+ } else {
894
+ logger.info('multi-repo: cloning', { repo: leg.dirName, cloneBranch: leg.cloneBranch })
895
+ await cloneRepo({
896
+ repo: { ...leg.repo, baseBranch: leg.cloneBranch },
897
+ ghToken: leg.ghToken,
898
+ dir,
899
+ signal,
900
+ })
901
+ await createBranch(dir, leg.workBranch, signal)
902
+ }
903
+ leg.dir = dir
904
+ // The branch tip before the agent runs. Captured BEFORE the resume base refresh below so
905
+ // that refresh's merge commit counts as advancement and is pushed (as in the single-repo
906
+ // path). A fresh leg produced work iff its branch advances past this; a resumed leg already
907
+ // carries prior work.
908
+ leg.baseSha = await headCommit(dir, signal)
909
+ // A resumed branch was cut from an OLDER base; merge the latest base in when the two merge
910
+ // cleanly so the agent works against current base and the peer/own PRs stay current. On a
911
+ // conflict this is a best-effort no-op (the merge gate handles a conflicting PR downstream),
912
+ // mirroring the single-repo {@link runCodingAgent} resume refresh.
913
+ if (leg.resumed) {
914
+ const refreshed = await refreshFromBaseIfClean(
915
+ dir,
916
+ leg.cloneBranch,
917
+ leg.ghToken,
918
+ signal,
919
+ ).catch(() => false)
920
+ if (!refreshed) {
921
+ logger.info('multi-repo: resume base refresh skipped (conflict or error)', {
922
+ repo: leg.dirName,
923
+ base: leg.cloneBranch,
924
+ })
925
+ }
926
+ }
927
+ }
928
+
929
+ // Reference branches attach to the PRIMARY repo, so fetch them into the primary sibling
930
+ // checkout's `origin/<b>` refs (best-effort per branch). The backend's reference-branches
931
+ // prompt section names the primary repo's directory to run the read commands in.
932
+ if (job.referenceBranches?.length) {
933
+ const primaryLeg = legs.find((l) => l.primary)
934
+ if (primaryLeg?.dir) {
935
+ const fetched = await fetchReferenceBranches({
936
+ dir: primaryLeg.dir,
937
+ branches: job.referenceBranches,
938
+ ghToken: primaryLeg.ghToken,
939
+ signal,
940
+ onSkip: (branch, reason) =>
941
+ logger.warn('multi-repo: reference branch fetch skipped', { branch, reason }),
942
+ })
943
+ logger.info('multi-repo: fetched reference branches', {
944
+ requested: job.referenceBranches.length,
945
+ fetched: fetched.length,
946
+ })
947
+ }
948
+ }
949
+ }
950
+
951
+ /**
952
+ * Push phase for {@link runMultiRepoCoding}: commit forgotten tracked edits, then push + open a PR
953
+ * for each repo the run actually changed (a repo the agent left untouched is skipped — no branch,
954
+ * no PR; a read-only reference leg is never committed or pushed). Extracted so the multi-repo body
955
+ * stays small; returns the primary's push/PR state plus the peer PRs.
956
+ */
957
+ async function pushMultiRepoLegs(
958
+ legs: RepoLeg[],
959
+ job: AgentJob,
960
+ logger: Logger,
961
+ opts: RunOptions,
962
+ ): Promise<{
963
+ primaryPushed: boolean
964
+ primaryPrUrl: string | undefined
965
+ peerPullRequests: NonNullable<AgentResult['peerPullRequests']>
966
+ }> {
967
+ const { signal } = opts
968
+ opts.onPhase?.('push')
969
+ let primaryPushed = false
970
+ let primaryPrUrl: string | undefined
971
+ const peerPullRequests: NonNullable<AgentResult['peerPullRequests']> = []
972
+ for (const leg of legs) {
973
+ // A read-only reference leg is never committed or pushed — the third layer of the read-only
974
+ // guarantee (the spec carries no branch/PR, and the clone phase gave it no work branch).
975
+ if (leg.readOnly) continue
976
+ await commitTrackedEdits(leg.dir, job.commitMessage ?? leg.pr?.title ?? 'Agent changes', signal)
977
+ const advanced = await branchHasCommitsSince(leg.dir, leg.baseSha, signal)
978
+ let hasWork = advanced || leg.resumed
979
+ if (leg.resumed && !advanced) {
980
+ const ahead = await branchAheadOfBase(leg.dir, leg.repo.baseBranch, leg.ghToken, signal)
981
+ if (ahead === false) hasWork = false
982
+ }
983
+ const leftover = await listUntrackedFiles(leg.dir, signal)
984
+ if (leftover.length > 0) {
985
+ logger.warn('multi-repo: uncommitted new files left behind (not pushed)', {
986
+ repo: leg.dirName,
987
+ count: leftover.length,
988
+ files: leftover.slice(0, 20),
989
+ })
990
+ }
991
+ if (!hasWork) {
992
+ logger.info('multi-repo: no changes for repo', { repo: leg.dirName })
993
+ continue
994
+ }
995
+ await pushBranch(leg.dir, leg.workBranch, leg.ghToken, signal)
996
+ let prUrl: string | null = null
997
+ if (leg.pr) {
998
+ prUrl = await openPullRequest({
999
+ owner: leg.repo.owner,
1000
+ name: leg.repo.name,
1001
+ ghToken: leg.ghToken,
1002
+ head: leg.workBranch,
1003
+ base: leg.repo.baseBranch,
1004
+ pr: leg.pr,
1005
+ apiBase: job.githubApiBase,
1006
+ cloneUrl: leg.repo.cloneUrl,
1007
+ ...(leg.repo.provider ? { provider: leg.repo.provider } : {}),
1008
+ signal,
1009
+ })
1010
+ }
1011
+ if (leg.primary) {
1012
+ primaryPushed = true
1013
+ if (prUrl) primaryPrUrl = prUrl
1014
+ } else if (prUrl) {
1015
+ peerPullRequests.push({
1016
+ repo: `${leg.repo.owner}/${leg.repo.name}`,
1017
+ ...(leg.frameId ? { frameId: leg.frameId } : {}),
1018
+ prUrl,
1019
+ branch: leg.workBranch,
1020
+ })
1021
+ }
1022
+ }
1023
+ return { primaryPushed, primaryPrUrl, peerPullRequests }
1024
+ }
1025
+
913
1026
  /**
914
1027
  * The "no changes" reason both coding agents report: a caller-supplied lead phrase
915
1028
  * plus the shared "never acted" cause and a credential-scrubbed tail of Pi's stderr.