@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.
- package/dist/coding-agent.js +380 -321
- package/package.json +2 -2
- package/src/coding-agent.ts +449 -336
package/src/coding-agent.ts
CHANGED
|
@@ -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
|
-
//
|
|
183
|
-
//
|
|
184
|
-
|
|
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
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
|
685
|
-
//
|
|
686
|
-
opts
|
|
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
|
-
//
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
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.
|