@cat-factory/executor-harness 1.106.0 → 1.110.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,9 @@
1
1
  import { mkdir } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
3
  import { runCapturedCommand } from './captured-command.js'
4
- import { makeDirClaimer } from './checkout-dir.js'
5
4
  import type {
6
- AgentJob,
7
- AgentResult,
8
5
  HarnessAuthFields,
9
- PeerRepoSpec,
10
- ReferenceRepoSpec,
11
- ReferenceScreenshotsSpec,
6
+ ImageManifestSpec,
12
7
  RepoSpec,
13
8
  SkillSpec,
14
9
  McpServerSpec,
@@ -30,14 +25,12 @@ import {
30
25
  refreshFromBaseIfClean,
31
26
  remoteBranchExists,
32
27
  } from './git.js'
33
- import { openPullRequest } from './vcs-api.js'
34
28
  import { FOLLOW_UPS_FILENAME, FollowUpTailer } from './follow-ups.js'
35
29
  import type { HarnessCallMetric } from './pi.js'
36
30
  import type { PiRunStats } from './pi-reduction.js'
37
31
  import { EFFORT_REPORT_FILE, type EffortReport } from './effort.js'
38
32
  import {
39
33
  type AgentPrDescription,
40
- applyPrDescription,
41
34
  PR_DESCRIPTION_FILE,
42
35
  readPrDescription,
43
36
  } from './pr-description.js'
@@ -46,7 +39,6 @@ import {
46
39
  agentNeverActed,
47
40
  agentOutputTail,
48
41
  runAgentInWorkspace,
49
- withWorkspace,
50
42
  } from './pi-workspace.js'
51
43
  import type { ProgressGuardLimits } from './progress-guard.js'
52
44
  import type { RunOptions } from './runner.js'
@@ -77,7 +69,7 @@ import {
77
69
  // CI-fixer (`/ci-fix`) agents are conceptually the same job — only what they clone
78
70
  // onto and what they do with the outcome differ — so they share this whole flow
79
71
  // rather than each re-deriving (and separately bug-fixing) it. Built on the thinner
80
- // {@link withWorkspace}/{@link runAgentInWorkspace} base shared with the non-pushing
72
+ // `withWorkspace`/{@link runAgentInWorkspace} base shared with the non-pushing
81
73
  // agents (bootstrap/blueprint/merger). Mirrors their secret handling: the per-job
82
74
  // GitHub + proxy tokens arrive in the spec and live only for the job's duration.
83
75
 
@@ -188,7 +180,14 @@ export interface CodingAgentSpec extends HarnessAuthFields {
188
180
  * UI-facing kind may well be a coding one, and nothing here switches on which built-in it is.
189
181
  * Absent ⇒ none (the normal case).
190
182
  */
191
- referenceScreenshots?: ReferenceScreenshotsSpec
183
+ referenceScreenshots?: ImageManifestSpec
184
+ /**
185
+ * The PICTURES of the task's designs, downloaded into `.cat-context/design-renders/` before the
186
+ * agent's first turn. Carried here for the same reason the capture set is: what earns a run its
187
+ * pictures is the KIND's declared trait plus a harness that can read an image, and a coding kind
188
+ * is the commonest holder of both. Absent ⇒ none (the normal case).
189
+ */
190
+ designImages?: ImageManifestSpec
192
191
  }
193
192
 
194
193
  /** The outcome of a coding agent run, before each caller maps it to its own result shape. */
@@ -208,7 +207,7 @@ export interface CodingAgentOutcome {
208
207
  effortReport?: EffortReport
209
208
  /**
210
209
  * The agent-authored PR description, lifted from its sentinel file (absent when it wrote none).
211
- * The PR-opening caller folds it over the dispatch-time title/body via {@link applyPrDescription};
210
+ * The PR-opening caller folds it over the dispatch-time title/body via `applyPrDescription`;
212
211
  * absent means the fallback text, unchanged.
213
212
  */
214
213
  prDescription?: AgentPrDescription
@@ -470,6 +469,7 @@ export async function runCodingAgent(
470
469
  ...(spec.referenceScreenshots
471
470
  ? { referenceScreenshots: spec.referenceScreenshots }
472
471
  : {}),
472
+ ...(spec.designImages ? { designImages: spec.designImages } : {}),
473
473
  },
474
474
  opts,
475
475
  )
@@ -1031,449 +1031,6 @@ export async function runRalphValidation(
1031
1031
  }
1032
1032
  }
1033
1033
 
1034
- /** One repository participating in a multi-repo run: where to clone it + what to do after. */
1035
- interface RepoLeg {
1036
- repo: RepoSpec
1037
- /** Sibling directory name under the workspace root. */
1038
- dirName: string
1039
- /** Absolute checkout directory (filled during the clone phase). */
1040
- dir: string
1041
- /** Branch to clone (the repo's base). */
1042
- cloneBranch: string
1043
- /** Branch to create off the clone and push the work to (the shared `cat-factory/<block>`). */
1044
- workBranch: string
1045
- ghToken: string
1046
- pr?: { title: string; body: string }
1047
- frameId?: string
1048
- primary: boolean
1049
- /**
1050
- * A READ-ONLY reference checkout (doc-writer's `referenceRepos`): cloned at its base branch for
1051
- * the agent to read, but NEVER given a work branch, committed, or pushed. Skipped entirely in the
1052
- * push phase, so it is structurally impossible for the run to write to it. Absent ⇒ a writable leg.
1053
- */
1054
- readOnly?: boolean
1055
- /** The branch tip before the run — work iff the branch advances past it. */
1056
- baseSha: string
1057
- /** Whether an existing remote work branch was resumed (already carries prior work). */
1058
- resumed: boolean
1059
- }
1060
-
1061
- /**
1062
- * Multi-repo coding (service-connections phase 3): clone the primary repo AND every connected
1063
- * peer repo as SIBLING checkouts under one workspace root, run the agent ONCE with its cwd at
1064
- * that root (so it makes the cross-service change coherently across all of them), then commit +
1065
- * push each repo that actually changed and open one PR per dirty repo. The task's own-service PR
1066
- * is reported as `prUrl`/`branch`; the peer PRs as `peerPullRequests`.
1067
- *
1068
- * Deliberately simpler than the single-repo {@link runCodingAgent} for the first cut: NO mid-run
1069
- * checkpoint pushes (an evicted multi-repo run re-clones on retry — the deterministic work branch
1070
- * still lets it resume any commits it managed to push at the end), NO warm-pool persistent
1071
- * checkout (always ephemeral), and NO follow-up sentinel streaming. It reuses the SAME dir-scoped
1072
- * git helpers, so the per-repo clone/commit/push/PR mechanics match the single-repo path exactly.
1073
- */
1074
- export async function runMultiRepoCoding(
1075
- job: AgentJob,
1076
- opts: RunOptions = {},
1077
- ): Promise<AgentResult> {
1078
- const logger = (opts.log ?? log).child({ kind: 'multi-repo', jobId: job.jobId })
1079
- const peers: PeerRepoSpec[] = job.peerRepos ?? []
1080
- const references: ReferenceRepoSpec[] = job.referenceRepos ?? []
1081
- const primaryWorkBranch = job.pushBranch ?? job.newBranch ?? job.branch
1082
-
1083
- // Assign the sibling directory per repo via the shared deterministic allocator
1084
- // (`owner__name__digest`, matching the backend prompt's `siblingCheckoutDir`), shared with the
1085
- // read-only explore fan-out.
1086
- const claimDir = makeDirClaimer()
1087
- const legs: RepoLeg[] = [
1088
- {
1089
- repo: job.repo,
1090
- dirName: claimDir(job.repo),
1091
- dir: '',
1092
- cloneBranch: job.branch,
1093
- workBranch: primaryWorkBranch,
1094
- ghToken: job.ghToken,
1095
- ...(job.pr ? { pr: job.pr } : {}),
1096
- primary: true,
1097
- baseSha: '',
1098
- resumed: false,
1099
- },
1100
- ...peers.map((peer): RepoLeg => ({
1101
- repo: peer.repo,
1102
- dirName: claimDir(peer.repo),
1103
- dir: '',
1104
- cloneBranch: peer.repo.baseBranch,
1105
- // Coding peers always carry `newBranch` (the backend sets the shared work branch);
1106
- // fall back to the primary's for the type (read-only peers never reach this path).
1107
- workBranch: peer.newBranch ?? primaryWorkBranch,
1108
- ghToken: peer.ghToken ?? job.ghToken,
1109
- ...(peer.pr ? { pr: peer.pr } : {}),
1110
- ...(peer.frameId ? { frameId: peer.frameId } : {}),
1111
- primary: false,
1112
- baseSha: '',
1113
- resumed: false,
1114
- })),
1115
- // Read-only reference repos (doc-writer): cloned as siblings the agent reads but never writes.
1116
- // `workBranch` is set to the base only to satisfy the type — a read-only leg never branches or
1117
- // pushes (guarded by `readOnly` in both the clone and push phases below).
1118
- ...references.map((reference): RepoLeg => ({
1119
- repo: reference.repo,
1120
- dirName: claimDir(reference.repo),
1121
- dir: '',
1122
- cloneBranch: reference.repo.baseBranch,
1123
- workBranch: reference.repo.baseBranch,
1124
- ghToken: reference.ghToken ?? job.ghToken,
1125
- primary: false,
1126
- readOnly: true,
1127
- baseSha: '',
1128
- resumed: false,
1129
- })),
1130
- ]
1131
-
1132
- return withWorkspace('multi', async (root) => {
1133
- // Clone (or resume) every sibling checkout under the workspace root and fetch the primary's
1134
- // reference branches. Mutates each leg's `dir`/`resumed`/`baseSha` in place.
1135
- await prepareMultiRepoCheckouts(root, legs, job, logger, opts)
1136
-
1137
- // DEPENDENCY PREPOPULATION for the PRIMARY leg, exactly as the read-only multi-repo fan-out
1138
- // does it. The install is declared on ONE service frame (the primary repo's), so it runs in
1139
- // that leg's checkout and is never fanned out across peers, whose own frames declare configs
1140
- // this dispatch never resolved — running a `pnpm install` inside a Go checkout is not a
1141
- // degraded outcome, it is a wrong one. A cross-repo implementer needs its dependencies for
1142
- // the same reason a cross-repo investigator does; the note names the sibling directory
1143
- // because the agent itself stands at the workspace root.
1144
- //
1145
- // At the leg's checkout ROOT, not a `serviceDirectory` subtree: this layout applies no
1146
- // service-directory scoping anywhere (the agent runs at the root and the prompt explains the
1147
- // sibling checkouts), and a root install is the one that resolves a monorepo workspace whole.
1148
- const primaryLeg = legs.find((leg) => leg.primary)
1149
- const dependencyNote = primaryLeg
1150
- ? await prepopulateDependencies({
1151
- spec: job.dependencyInstall,
1152
- installDir: primaryLeg.dir,
1153
- repoDir: primaryLeg.dir,
1154
- agentDir: root,
1155
- logger,
1156
- opts,
1157
- })
1158
- : undefined
1159
-
1160
- // THE REPOS' OWN PR TEMPLATES: one per leg that will actually open a pull request, each named
1161
- // by its sibling directory so the agent knows which checkout's briefing takes which shape —
1162
- // the repos in a workspace need not share a template, or ship one at all. A read-only
1163
- // reference leg is excluded by construction: it carries no `pr`, so nothing publishes for it.
1164
- const prTemplate = await resolvePrTemplateNote({
1165
- targets: legs
1166
- .filter((leg) => leg.pr)
1167
- .map((leg) => ({
1168
- repoDir: leg.dir,
1169
- repoLabel: leg.dirName,
1170
- ...(leg.repo.provider ? { provider: leg.repo.provider } : {}),
1171
- })),
1172
- logger,
1173
- })
1174
-
1175
- // Run the agent ONCE with its cwd at the workspace root, so it sees every sibling checkout
1176
- // and can change them coherently. No monorepo/service-directory scoping — the multi-repo
1177
- // note + the backend system-prompt section explain the layout.
1178
- opts.onPhase?.('agent')
1179
- logger.info('multi-repo: running agent', { repos: legs.map((l) => l.dirName) })
1180
- const { summary, stats, stderrTail, usage, callMetrics, effortReport } =
1181
- await runAgentInWorkspace(
1182
- {
1183
- dir: root,
1184
- systemPrompt: job.systemPrompt,
1185
- userPrompt: withDependencyNote(
1186
- withPrTemplateNote(job.userPrompt, prTemplate.note),
1187
- dependencyNote,
1188
- ),
1189
- model: job.model,
1190
- harness: job.harness,
1191
- subscriptionToken: job.subscriptionToken,
1192
- subscriptionBaseUrl: job.subscriptionBaseUrl,
1193
- ambientAuth: job.ambientAuth,
1194
- proxyBaseUrl: job.proxyBaseUrl,
1195
- proxyPhasePath: job.proxyPhasePath,
1196
- sessionToken: job.sessionToken,
1197
- webToolsGuidance: job.webToolsGuidance,
1198
- webSearchProxy: job.webSearch,
1199
- guardLimits: job.guardLimits,
1200
- ...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
1201
- // Skills + tool servers apply to a multi-repo run exactly as to a single-repo one: they
1202
- // are properties of the AGENT KIND, not of the checkout layout.
1203
- ...(job.skills?.length ? { skills: job.skills } : {}),
1204
- ...(job.mcpServers?.length ? { mcpServers: job.mcpServers } : {}),
1205
- ...(job.referenceScreenshots ? { referenceScreenshots: job.referenceScreenshots } : {}),
1206
- multiRepo: true,
1207
- },
1208
- opts,
1209
- )
1210
-
1211
- // Commit forgotten tracked edits, then push + open a PR for each repo the run actually changed.
1212
- const { primaryPushed, primaryPrUrl, peerPullRequests } = await pushMultiRepoLegs(
1213
- legs,
1214
- job,
1215
- logger,
1216
- opts,
1217
- root,
1218
- prTemplate,
1219
- )
1220
-
1221
- const anyWork = primaryPushed || peerPullRequests.length > 0
1222
- if (!anyWork) {
1223
- // Nothing changed in ANY repo. For the implementer this is a failure (as in the
1224
- // single-repo path); a caller that tolerates a no-op (never the implementer today)
1225
- // gets a clean non-event.
1226
- if (job.noChangesIsError === false) {
1227
- return {
1228
- pushed: false,
1229
- branch: primaryWorkBranch,
1230
- summary,
1231
- stats,
1232
- ...(usage ? { usage } : {}),
1233
- ...(callMetrics ? { callMetrics } : {}),
1234
- ...(effortReport ? { effortReport } : {}),
1235
- }
1236
- }
1237
- return {
1238
- pushed: false,
1239
- branch: primaryWorkBranch,
1240
- summary,
1241
- stats,
1242
- error: noChangesReason(
1243
- 'the agent produced no file changes in any repository',
1244
- stats,
1245
- stderrTail,
1246
- ),
1247
- failureCause: 'no-changes',
1248
- ...(usage ? { usage } : {}),
1249
- ...(callMetrics ? { callMetrics } : {}),
1250
- ...(effortReport ? { effortReport } : {}),
1251
- }
1252
- }
1253
- logger.info('multi-repo: complete', {
1254
- primaryPushed,
1255
- primaryPrUrl: primaryPrUrl ?? null,
1256
- peers: peerPullRequests.length,
1257
- })
1258
- return {
1259
- pushed: primaryPushed,
1260
- ...(primaryPrUrl ? { prUrl: primaryPrUrl } : {}),
1261
- branch: primaryWorkBranch,
1262
- ...(peerPullRequests.length ? { peerPullRequests } : {}),
1263
- summary,
1264
- stats,
1265
- ...(usage ? { usage } : {}),
1266
- ...(callMetrics ? { callMetrics } : {}),
1267
- ...(effortReport ? { effortReport } : {}),
1268
- }
1269
- })
1270
- }
1271
-
1272
- /**
1273
- * Clone phase for {@link runMultiRepoCoding}: every repo into its sibling dir under the workspace
1274
- * root. Resume an existing remote work branch (an evicted retry) rather than branching off base
1275
- * again, then fetch the primary repo's reference branches. Mutates each leg's `dir`/`resumed`/
1276
- * `baseSha` in place. Extracted so the multi-repo body stays small.
1277
- */
1278
- async function prepareMultiRepoCheckouts(
1279
- root: string,
1280
- legs: RepoLeg[],
1281
- job: AgentJob,
1282
- logger: Logger,
1283
- opts: RunOptions,
1284
- ): Promise<void> {
1285
- const { signal } = opts
1286
- opts.onPhase?.('clone')
1287
- for (const leg of legs) {
1288
- const dir = join(root, leg.dirName)
1289
- await mkdir(dir, { recursive: true })
1290
- // A read-only reference leg: clone its base branch for the agent to read, and stop there —
1291
- // no work branch, no resume, no base-refresh. It is skipped in the push phase, so it can
1292
- // never be written to. (Kept in the loop so it lands in the same workspace root as siblings.)
1293
- if (leg.readOnly) {
1294
- logger.info('multi-repo: cloning read-only reference', {
1295
- repo: leg.dirName,
1296
- cloneBranch: leg.cloneBranch,
1297
- })
1298
- await cloneRepo({
1299
- repo: { ...leg.repo, baseBranch: leg.cloneBranch },
1300
- ghToken: leg.ghToken,
1301
- dir,
1302
- signal,
1303
- })
1304
- leg.dir = dir
1305
- continue
1306
- }
1307
- leg.resumed = await remoteBranchExists(leg.repo.cloneUrl, leg.workBranch, leg.ghToken, signal)
1308
- if (leg.resumed) {
1309
- logger.info('multi-repo: resuming existing branch', {
1310
- repo: leg.dirName,
1311
- branch: leg.workBranch,
1312
- })
1313
- await cloneExistingBranch({
1314
- cloneUrl: leg.repo.cloneUrl,
1315
- branch: leg.workBranch,
1316
- ghToken: leg.ghToken,
1317
- dir,
1318
- signal,
1319
- })
1320
- } else {
1321
- logger.info('multi-repo: cloning', { repo: leg.dirName, cloneBranch: leg.cloneBranch })
1322
- await cloneRepo({
1323
- repo: { ...leg.repo, baseBranch: leg.cloneBranch },
1324
- ghToken: leg.ghToken,
1325
- dir,
1326
- signal,
1327
- })
1328
- await createBranch(dir, leg.workBranch, signal)
1329
- }
1330
- leg.dir = dir
1331
- // Exclude the agent-authored PR-description sentinel locally (as the single-repo path does)
1332
- // so the agent's own `git add` can never stage the briefing into the PR it describes.
1333
- await excludeFromGit(dir, PR_DESCRIPTION_FILE, signal)
1334
- // The branch tip before the agent runs. Captured BEFORE the resume base refresh below so
1335
- // that refresh's merge commit counts as advancement and is pushed (as in the single-repo
1336
- // path). A fresh leg produced work iff its branch advances past this; a resumed leg already
1337
- // carries prior work.
1338
- leg.baseSha = await headCommit(dir, signal)
1339
- // A resumed branch was cut from an OLDER base; merge the latest base in when the two merge
1340
- // cleanly so the agent works against current base and the peer/own PRs stay current. On a
1341
- // conflict this is a best-effort no-op (the merge gate handles a conflicting PR downstream),
1342
- // mirroring the single-repo {@link runCodingAgent} resume refresh.
1343
- if (leg.resumed) {
1344
- const refreshed = await refreshFromBaseIfClean(
1345
- dir,
1346
- leg.cloneBranch,
1347
- leg.ghToken,
1348
- signal,
1349
- ).catch(() => false)
1350
- if (!refreshed) {
1351
- logger.info('multi-repo: resume base refresh skipped (conflict or error)', {
1352
- repo: leg.dirName,
1353
- base: leg.cloneBranch,
1354
- })
1355
- }
1356
- }
1357
- }
1358
-
1359
- // Reference branches attach to the PRIMARY repo, so fetch them into the primary sibling
1360
- // checkout's `origin/<b>` refs (best-effort per branch). The backend's reference-branches
1361
- // prompt section names the primary repo's directory to run the read commands in.
1362
- if (job.referenceBranches?.length) {
1363
- const primaryLeg = legs.find((l) => l.primary)
1364
- if (primaryLeg?.dir) {
1365
- const fetched = await fetchReferenceBranches({
1366
- dir: primaryLeg.dir,
1367
- branches: job.referenceBranches,
1368
- ghToken: primaryLeg.ghToken,
1369
- signal,
1370
- onSkip: (branch, reason) =>
1371
- logger.warn('multi-repo: reference branch fetch skipped', { branch, reason }),
1372
- })
1373
- logger.info('multi-repo: fetched reference branches', {
1374
- requested: job.referenceBranches.length,
1375
- fetched: fetched.length,
1376
- })
1377
- }
1378
- }
1379
- }
1380
-
1381
- /**
1382
- * Push phase for {@link runMultiRepoCoding}: commit forgotten tracked edits, then push + open a PR
1383
- * for each repo the run actually changed (a repo the agent left untouched is skipped — no branch,
1384
- * no PR; a read-only reference leg is never committed or pushed). Extracted so the multi-repo body
1385
- * stays small; returns the primary's push/PR state plus the peer PRs.
1386
- */
1387
- async function pushMultiRepoLegs(
1388
- legs: RepoLeg[],
1389
- job: AgentJob,
1390
- logger: Logger,
1391
- opts: RunOptions,
1392
- /** The workspace root the agent ran in — the fallback probe for the primary's briefing. */
1393
- root: string,
1394
- /** Which legs' briefings are filled templates — see the `titleFromHeading` read below. */
1395
- prTemplate: PrTemplateResolution,
1396
- ): Promise<{
1397
- primaryPushed: boolean
1398
- primaryPrUrl: string | undefined
1399
- peerPullRequests: NonNullable<AgentResult['peerPullRequests']>
1400
- }> {
1401
- const { signal } = opts
1402
- opts.onPhase?.('push')
1403
- let primaryPushed = false
1404
- let primaryPrUrl: string | undefined
1405
- const peerPullRequests: NonNullable<AgentResult['peerPullRequests']> = []
1406
- for (const leg of legs) {
1407
- // A read-only reference leg is never committed or pushed — the third layer of the read-only
1408
- // guarantee (the spec carries no branch/PR, and the clone phase gave it no work branch).
1409
- if (leg.readOnly) continue
1410
- // Lift (and remove) the agent-authored PR description for THIS repo's PR before anything
1411
- // else touches the checkout — each sibling checkout carries its own briefing for its own PR.
1412
- // The agent's cwd here is the WORKSPACE ROOT rather than any one checkout, so an agent that
1413
- // read the prompt loosely may well have written a single briefing there instead. Fall back
1414
- // to it for the PRIMARY leg only: at the root there is nothing to say which repo it
1415
- // describes, and the primary is the one the run is actually about.
1416
- //
1417
- // Per-leg `titleFromHeading`: only a leg whose OWN repo ships a template has repo-authored
1418
- // headings in its sentinel, and the legs of a workspace need not agree about that — so this
1419
- // is keyed on the leg, never on whether the run found any template at all.
1420
- const readOptions = { titleFromHeading: !prTemplate.templated.has(leg.dir) }
1421
- const agentPrDescription =
1422
- (await readPrDescription(leg.dir, readOptions)) ??
1423
- (leg.primary ? await readPrDescription(root, readOptions) : undefined)
1424
- await commitTrackedEdits(leg.dir, job.commitMessage ?? leg.pr?.title ?? 'Agent changes', signal)
1425
- const advanced = await branchHasCommitsSince(leg.dir, leg.baseSha, signal)
1426
- let hasWork = advanced || leg.resumed
1427
- if (leg.resumed && !advanced) {
1428
- const ahead = await branchAheadOfBase(leg.dir, leg.repo.baseBranch, leg.ghToken, signal)
1429
- if (ahead === false) hasWork = false
1430
- }
1431
- const leftover = await listUntrackedFiles(leg.dir, signal)
1432
- if (leftover.length > 0) {
1433
- logger.warn('multi-repo: uncommitted new files left behind (not pushed)', {
1434
- repo: leg.dirName,
1435
- count: leftover.length,
1436
- files: leftover.slice(0, 20),
1437
- })
1438
- }
1439
- if (!hasWork) {
1440
- logger.info('multi-repo: no changes for repo', { repo: leg.dirName })
1441
- continue
1442
- }
1443
- await pushBranch(leg.dir, leg.workBranch, leg.ghToken, signal)
1444
- let prUrl: string | null = null
1445
- if (leg.pr) {
1446
- prUrl = await openPullRequest({
1447
- owner: leg.repo.owner,
1448
- name: leg.repo.name,
1449
- ghToken: leg.ghToken,
1450
- head: leg.workBranch,
1451
- base: leg.repo.baseBranch,
1452
- pr: applyPrDescription(leg.pr, agentPrDescription),
1453
- // See the single-repo call site: refresh a resumed leg's already-open PR, but only
1454
- // when the text is the agent's own briefing rather than the dispatch-time fallback.
1455
- ...(agentPrDescription ? { refreshExisting: true } : {}),
1456
- apiBase: job.githubApiBase,
1457
- cloneUrl: leg.repo.cloneUrl,
1458
- ...(leg.repo.provider ? { provider: leg.repo.provider } : {}),
1459
- signal,
1460
- })
1461
- }
1462
- if (leg.primary) {
1463
- primaryPushed = true
1464
- if (prUrl) primaryPrUrl = prUrl
1465
- } else if (prUrl) {
1466
- peerPullRequests.push({
1467
- repo: `${leg.repo.owner}/${leg.repo.name}`,
1468
- ...(leg.frameId ? { frameId: leg.frameId } : {}),
1469
- prUrl,
1470
- branch: leg.workBranch,
1471
- })
1472
- }
1473
- }
1474
- return { primaryPushed, primaryPrUrl, peerPullRequests }
1475
- }
1476
-
1477
1034
  /**
1478
1035
  * The "no changes" reason both coding agents report: a caller-supplied lead phrase
1479
1036
  * plus the shared "never acted" cause and a credential-scrubbed tail of Pi's stderr.