@cat-factory/executor-harness 1.108.0 → 1.110.2
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/agent.js +3 -18
- package/dist/coding-agent.d.ts +2 -16
- package/dist/coding-agent.js +2 -372
- package/dist/job.d.ts +12 -3
- package/dist/job.js +5 -2
- package/dist/json-reply.d.ts +12 -0
- package/dist/json-reply.js +114 -0
- package/dist/multi-repo-coding.d.ts +16 -0
- package/dist/multi-repo-coding.js +379 -0
- package/package.json +4 -4
- package/src/agent.ts +3 -18
- package/src/coding-agent.ts +2 -454
- package/src/job.ts +16 -4
- package/src/json-reply.ts +112 -0
- package/src/multi-repo-coding.ts +483 -0
package/src/coding-agent.ts
CHANGED
|
@@ -1,13 +1,8 @@
|
|
|
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
6
|
ImageManifestSpec,
|
|
12
7
|
RepoSpec,
|
|
13
8
|
SkillSpec,
|
|
@@ -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
|
-
//
|
|
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
|
|
|
@@ -215,7 +207,7 @@ export interface CodingAgentOutcome {
|
|
|
215
207
|
effortReport?: EffortReport
|
|
216
208
|
/**
|
|
217
209
|
* The agent-authored PR description, lifted from its sentinel file (absent when it wrote none).
|
|
218
|
-
* The PR-opening caller folds it over the dispatch-time title/body via
|
|
210
|
+
* The PR-opening caller folds it over the dispatch-time title/body via `applyPrDescription`;
|
|
219
211
|
* absent means the fallback text, unchanged.
|
|
220
212
|
*/
|
|
221
213
|
prDescription?: AgentPrDescription
|
|
@@ -1039,450 +1031,6 @@ export async function runRalphValidation(
|
|
|
1039
1031
|
}
|
|
1040
1032
|
}
|
|
1041
1033
|
|
|
1042
|
-
/** One repository participating in a multi-repo run: where to clone it + what to do after. */
|
|
1043
|
-
interface RepoLeg {
|
|
1044
|
-
repo: RepoSpec
|
|
1045
|
-
/** Sibling directory name under the workspace root. */
|
|
1046
|
-
dirName: string
|
|
1047
|
-
/** Absolute checkout directory (filled during the clone phase). */
|
|
1048
|
-
dir: string
|
|
1049
|
-
/** Branch to clone (the repo's base). */
|
|
1050
|
-
cloneBranch: string
|
|
1051
|
-
/** Branch to create off the clone and push the work to (the shared `cat-factory/<block>`). */
|
|
1052
|
-
workBranch: string
|
|
1053
|
-
ghToken: string
|
|
1054
|
-
pr?: { title: string; body: string }
|
|
1055
|
-
frameId?: string
|
|
1056
|
-
primary: boolean
|
|
1057
|
-
/**
|
|
1058
|
-
* A READ-ONLY reference checkout (doc-writer's `referenceRepos`): cloned at its base branch for
|
|
1059
|
-
* the agent to read, but NEVER given a work branch, committed, or pushed. Skipped entirely in the
|
|
1060
|
-
* push phase, so it is structurally impossible for the run to write to it. Absent ⇒ a writable leg.
|
|
1061
|
-
*/
|
|
1062
|
-
readOnly?: boolean
|
|
1063
|
-
/** The branch tip before the run — work iff the branch advances past it. */
|
|
1064
|
-
baseSha: string
|
|
1065
|
-
/** Whether an existing remote work branch was resumed (already carries prior work). */
|
|
1066
|
-
resumed: boolean
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
/**
|
|
1070
|
-
* Multi-repo coding (service-connections phase 3): clone the primary repo AND every connected
|
|
1071
|
-
* peer repo as SIBLING checkouts under one workspace root, run the agent ONCE with its cwd at
|
|
1072
|
-
* that root (so it makes the cross-service change coherently across all of them), then commit +
|
|
1073
|
-
* push each repo that actually changed and open one PR per dirty repo. The task's own-service PR
|
|
1074
|
-
* is reported as `prUrl`/`branch`; the peer PRs as `peerPullRequests`.
|
|
1075
|
-
*
|
|
1076
|
-
* Deliberately simpler than the single-repo {@link runCodingAgent} for the first cut: NO mid-run
|
|
1077
|
-
* checkpoint pushes (an evicted multi-repo run re-clones on retry — the deterministic work branch
|
|
1078
|
-
* still lets it resume any commits it managed to push at the end), NO warm-pool persistent
|
|
1079
|
-
* checkout (always ephemeral), and NO follow-up sentinel streaming. It reuses the SAME dir-scoped
|
|
1080
|
-
* git helpers, so the per-repo clone/commit/push/PR mechanics match the single-repo path exactly.
|
|
1081
|
-
*/
|
|
1082
|
-
export async function runMultiRepoCoding(
|
|
1083
|
-
job: AgentJob,
|
|
1084
|
-
opts: RunOptions = {},
|
|
1085
|
-
): Promise<AgentResult> {
|
|
1086
|
-
const logger = (opts.log ?? log).child({ kind: 'multi-repo', jobId: job.jobId })
|
|
1087
|
-
const peers: PeerRepoSpec[] = job.peerRepos ?? []
|
|
1088
|
-
const references: ReferenceRepoSpec[] = job.referenceRepos ?? []
|
|
1089
|
-
const primaryWorkBranch = job.pushBranch ?? job.newBranch ?? job.branch
|
|
1090
|
-
|
|
1091
|
-
// Assign the sibling directory per repo via the shared deterministic allocator
|
|
1092
|
-
// (`owner__name__digest`, matching the backend prompt's `siblingCheckoutDir`), shared with the
|
|
1093
|
-
// read-only explore fan-out.
|
|
1094
|
-
const claimDir = makeDirClaimer()
|
|
1095
|
-
const legs: RepoLeg[] = [
|
|
1096
|
-
{
|
|
1097
|
-
repo: job.repo,
|
|
1098
|
-
dirName: claimDir(job.repo),
|
|
1099
|
-
dir: '',
|
|
1100
|
-
cloneBranch: job.branch,
|
|
1101
|
-
workBranch: primaryWorkBranch,
|
|
1102
|
-
ghToken: job.ghToken,
|
|
1103
|
-
...(job.pr ? { pr: job.pr } : {}),
|
|
1104
|
-
primary: true,
|
|
1105
|
-
baseSha: '',
|
|
1106
|
-
resumed: false,
|
|
1107
|
-
},
|
|
1108
|
-
...peers.map((peer): RepoLeg => ({
|
|
1109
|
-
repo: peer.repo,
|
|
1110
|
-
dirName: claimDir(peer.repo),
|
|
1111
|
-
dir: '',
|
|
1112
|
-
cloneBranch: peer.repo.baseBranch,
|
|
1113
|
-
// Coding peers always carry `newBranch` (the backend sets the shared work branch);
|
|
1114
|
-
// fall back to the primary's for the type (read-only peers never reach this path).
|
|
1115
|
-
workBranch: peer.newBranch ?? primaryWorkBranch,
|
|
1116
|
-
ghToken: peer.ghToken ?? job.ghToken,
|
|
1117
|
-
...(peer.pr ? { pr: peer.pr } : {}),
|
|
1118
|
-
...(peer.frameId ? { frameId: peer.frameId } : {}),
|
|
1119
|
-
primary: false,
|
|
1120
|
-
baseSha: '',
|
|
1121
|
-
resumed: false,
|
|
1122
|
-
})),
|
|
1123
|
-
// Read-only reference repos (doc-writer): cloned as siblings the agent reads but never writes.
|
|
1124
|
-
// `workBranch` is set to the base only to satisfy the type — a read-only leg never branches or
|
|
1125
|
-
// pushes (guarded by `readOnly` in both the clone and push phases below).
|
|
1126
|
-
...references.map((reference): RepoLeg => ({
|
|
1127
|
-
repo: reference.repo,
|
|
1128
|
-
dirName: claimDir(reference.repo),
|
|
1129
|
-
dir: '',
|
|
1130
|
-
cloneBranch: reference.repo.baseBranch,
|
|
1131
|
-
workBranch: reference.repo.baseBranch,
|
|
1132
|
-
ghToken: reference.ghToken ?? job.ghToken,
|
|
1133
|
-
primary: false,
|
|
1134
|
-
readOnly: true,
|
|
1135
|
-
baseSha: '',
|
|
1136
|
-
resumed: false,
|
|
1137
|
-
})),
|
|
1138
|
-
]
|
|
1139
|
-
|
|
1140
|
-
return withWorkspace('multi', async (root) => {
|
|
1141
|
-
// Clone (or resume) every sibling checkout under the workspace root and fetch the primary's
|
|
1142
|
-
// reference branches. Mutates each leg's `dir`/`resumed`/`baseSha` in place.
|
|
1143
|
-
await prepareMultiRepoCheckouts(root, legs, job, logger, opts)
|
|
1144
|
-
|
|
1145
|
-
// DEPENDENCY PREPOPULATION for the PRIMARY leg, exactly as the read-only multi-repo fan-out
|
|
1146
|
-
// does it. The install is declared on ONE service frame (the primary repo's), so it runs in
|
|
1147
|
-
// that leg's checkout and is never fanned out across peers, whose own frames declare configs
|
|
1148
|
-
// this dispatch never resolved — running a `pnpm install` inside a Go checkout is not a
|
|
1149
|
-
// degraded outcome, it is a wrong one. A cross-repo implementer needs its dependencies for
|
|
1150
|
-
// the same reason a cross-repo investigator does; the note names the sibling directory
|
|
1151
|
-
// because the agent itself stands at the workspace root.
|
|
1152
|
-
//
|
|
1153
|
-
// At the leg's checkout ROOT, not a `serviceDirectory` subtree: this layout applies no
|
|
1154
|
-
// service-directory scoping anywhere (the agent runs at the root and the prompt explains the
|
|
1155
|
-
// sibling checkouts), and a root install is the one that resolves a monorepo workspace whole.
|
|
1156
|
-
const primaryLeg = legs.find((leg) => leg.primary)
|
|
1157
|
-
const dependencyNote = primaryLeg
|
|
1158
|
-
? await prepopulateDependencies({
|
|
1159
|
-
spec: job.dependencyInstall,
|
|
1160
|
-
installDir: primaryLeg.dir,
|
|
1161
|
-
repoDir: primaryLeg.dir,
|
|
1162
|
-
agentDir: root,
|
|
1163
|
-
logger,
|
|
1164
|
-
opts,
|
|
1165
|
-
})
|
|
1166
|
-
: undefined
|
|
1167
|
-
|
|
1168
|
-
// THE REPOS' OWN PR TEMPLATES: one per leg that will actually open a pull request, each named
|
|
1169
|
-
// by its sibling directory so the agent knows which checkout's briefing takes which shape —
|
|
1170
|
-
// the repos in a workspace need not share a template, or ship one at all. A read-only
|
|
1171
|
-
// reference leg is excluded by construction: it carries no `pr`, so nothing publishes for it.
|
|
1172
|
-
const prTemplate = await resolvePrTemplateNote({
|
|
1173
|
-
targets: legs
|
|
1174
|
-
.filter((leg) => leg.pr)
|
|
1175
|
-
.map((leg) => ({
|
|
1176
|
-
repoDir: leg.dir,
|
|
1177
|
-
repoLabel: leg.dirName,
|
|
1178
|
-
...(leg.repo.provider ? { provider: leg.repo.provider } : {}),
|
|
1179
|
-
})),
|
|
1180
|
-
logger,
|
|
1181
|
-
})
|
|
1182
|
-
|
|
1183
|
-
// Run the agent ONCE with its cwd at the workspace root, so it sees every sibling checkout
|
|
1184
|
-
// and can change them coherently. No monorepo/service-directory scoping — the multi-repo
|
|
1185
|
-
// note + the backend system-prompt section explain the layout.
|
|
1186
|
-
opts.onPhase?.('agent')
|
|
1187
|
-
logger.info('multi-repo: running agent', { repos: legs.map((l) => l.dirName) })
|
|
1188
|
-
const { summary, stats, stderrTail, usage, callMetrics, effortReport } =
|
|
1189
|
-
await runAgentInWorkspace(
|
|
1190
|
-
{
|
|
1191
|
-
dir: root,
|
|
1192
|
-
systemPrompt: job.systemPrompt,
|
|
1193
|
-
userPrompt: withDependencyNote(
|
|
1194
|
-
withPrTemplateNote(job.userPrompt, prTemplate.note),
|
|
1195
|
-
dependencyNote,
|
|
1196
|
-
),
|
|
1197
|
-
model: job.model,
|
|
1198
|
-
harness: job.harness,
|
|
1199
|
-
subscriptionToken: job.subscriptionToken,
|
|
1200
|
-
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
1201
|
-
ambientAuth: job.ambientAuth,
|
|
1202
|
-
proxyBaseUrl: job.proxyBaseUrl,
|
|
1203
|
-
proxyPhasePath: job.proxyPhasePath,
|
|
1204
|
-
sessionToken: job.sessionToken,
|
|
1205
|
-
webToolsGuidance: job.webToolsGuidance,
|
|
1206
|
-
webSearchProxy: job.webSearch,
|
|
1207
|
-
guardLimits: job.guardLimits,
|
|
1208
|
-
...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
|
|
1209
|
-
// Skills + tool servers apply to a multi-repo run exactly as to a single-repo one: they
|
|
1210
|
-
// are properties of the AGENT KIND, not of the checkout layout.
|
|
1211
|
-
...(job.skills?.length ? { skills: job.skills } : {}),
|
|
1212
|
-
...(job.mcpServers?.length ? { mcpServers: job.mcpServers } : {}),
|
|
1213
|
-
...(job.referenceScreenshots ? { referenceScreenshots: job.referenceScreenshots } : {}),
|
|
1214
|
-
...(job.designImages ? { designImages: job.designImages } : {}),
|
|
1215
|
-
multiRepo: true,
|
|
1216
|
-
},
|
|
1217
|
-
opts,
|
|
1218
|
-
)
|
|
1219
|
-
|
|
1220
|
-
// Commit forgotten tracked edits, then push + open a PR for each repo the run actually changed.
|
|
1221
|
-
const { primaryPushed, primaryPrUrl, peerPullRequests } = await pushMultiRepoLegs(
|
|
1222
|
-
legs,
|
|
1223
|
-
job,
|
|
1224
|
-
logger,
|
|
1225
|
-
opts,
|
|
1226
|
-
root,
|
|
1227
|
-
prTemplate,
|
|
1228
|
-
)
|
|
1229
|
-
|
|
1230
|
-
const anyWork = primaryPushed || peerPullRequests.length > 0
|
|
1231
|
-
if (!anyWork) {
|
|
1232
|
-
// Nothing changed in ANY repo. For the implementer this is a failure (as in the
|
|
1233
|
-
// single-repo path); a caller that tolerates a no-op (never the implementer today)
|
|
1234
|
-
// gets a clean non-event.
|
|
1235
|
-
if (job.noChangesIsError === false) {
|
|
1236
|
-
return {
|
|
1237
|
-
pushed: false,
|
|
1238
|
-
branch: primaryWorkBranch,
|
|
1239
|
-
summary,
|
|
1240
|
-
stats,
|
|
1241
|
-
...(usage ? { usage } : {}),
|
|
1242
|
-
...(callMetrics ? { callMetrics } : {}),
|
|
1243
|
-
...(effortReport ? { effortReport } : {}),
|
|
1244
|
-
}
|
|
1245
|
-
}
|
|
1246
|
-
return {
|
|
1247
|
-
pushed: false,
|
|
1248
|
-
branch: primaryWorkBranch,
|
|
1249
|
-
summary,
|
|
1250
|
-
stats,
|
|
1251
|
-
error: noChangesReason(
|
|
1252
|
-
'the agent produced no file changes in any repository',
|
|
1253
|
-
stats,
|
|
1254
|
-
stderrTail,
|
|
1255
|
-
),
|
|
1256
|
-
failureCause: 'no-changes',
|
|
1257
|
-
...(usage ? { usage } : {}),
|
|
1258
|
-
...(callMetrics ? { callMetrics } : {}),
|
|
1259
|
-
...(effortReport ? { effortReport } : {}),
|
|
1260
|
-
}
|
|
1261
|
-
}
|
|
1262
|
-
logger.info('multi-repo: complete', {
|
|
1263
|
-
primaryPushed,
|
|
1264
|
-
primaryPrUrl: primaryPrUrl ?? null,
|
|
1265
|
-
peers: peerPullRequests.length,
|
|
1266
|
-
})
|
|
1267
|
-
return {
|
|
1268
|
-
pushed: primaryPushed,
|
|
1269
|
-
...(primaryPrUrl ? { prUrl: primaryPrUrl } : {}),
|
|
1270
|
-
branch: primaryWorkBranch,
|
|
1271
|
-
...(peerPullRequests.length ? { peerPullRequests } : {}),
|
|
1272
|
-
summary,
|
|
1273
|
-
stats,
|
|
1274
|
-
...(usage ? { usage } : {}),
|
|
1275
|
-
...(callMetrics ? { callMetrics } : {}),
|
|
1276
|
-
...(effortReport ? { effortReport } : {}),
|
|
1277
|
-
}
|
|
1278
|
-
})
|
|
1279
|
-
}
|
|
1280
|
-
|
|
1281
|
-
/**
|
|
1282
|
-
* Clone phase for {@link runMultiRepoCoding}: every repo into its sibling dir under the workspace
|
|
1283
|
-
* root. Resume an existing remote work branch (an evicted retry) rather than branching off base
|
|
1284
|
-
* again, then fetch the primary repo's reference branches. Mutates each leg's `dir`/`resumed`/
|
|
1285
|
-
* `baseSha` in place. Extracted so the multi-repo body stays small.
|
|
1286
|
-
*/
|
|
1287
|
-
async function prepareMultiRepoCheckouts(
|
|
1288
|
-
root: string,
|
|
1289
|
-
legs: RepoLeg[],
|
|
1290
|
-
job: AgentJob,
|
|
1291
|
-
logger: Logger,
|
|
1292
|
-
opts: RunOptions,
|
|
1293
|
-
): Promise<void> {
|
|
1294
|
-
const { signal } = opts
|
|
1295
|
-
opts.onPhase?.('clone')
|
|
1296
|
-
for (const leg of legs) {
|
|
1297
|
-
const dir = join(root, leg.dirName)
|
|
1298
|
-
await mkdir(dir, { recursive: true })
|
|
1299
|
-
// A read-only reference leg: clone its base branch for the agent to read, and stop there —
|
|
1300
|
-
// no work branch, no resume, no base-refresh. It is skipped in the push phase, so it can
|
|
1301
|
-
// never be written to. (Kept in the loop so it lands in the same workspace root as siblings.)
|
|
1302
|
-
if (leg.readOnly) {
|
|
1303
|
-
logger.info('multi-repo: cloning read-only reference', {
|
|
1304
|
-
repo: leg.dirName,
|
|
1305
|
-
cloneBranch: leg.cloneBranch,
|
|
1306
|
-
})
|
|
1307
|
-
await cloneRepo({
|
|
1308
|
-
repo: { ...leg.repo, baseBranch: leg.cloneBranch },
|
|
1309
|
-
ghToken: leg.ghToken,
|
|
1310
|
-
dir,
|
|
1311
|
-
signal,
|
|
1312
|
-
})
|
|
1313
|
-
leg.dir = dir
|
|
1314
|
-
continue
|
|
1315
|
-
}
|
|
1316
|
-
leg.resumed = await remoteBranchExists(leg.repo.cloneUrl, leg.workBranch, leg.ghToken, signal)
|
|
1317
|
-
if (leg.resumed) {
|
|
1318
|
-
logger.info('multi-repo: resuming existing branch', {
|
|
1319
|
-
repo: leg.dirName,
|
|
1320
|
-
branch: leg.workBranch,
|
|
1321
|
-
})
|
|
1322
|
-
await cloneExistingBranch({
|
|
1323
|
-
cloneUrl: leg.repo.cloneUrl,
|
|
1324
|
-
branch: leg.workBranch,
|
|
1325
|
-
ghToken: leg.ghToken,
|
|
1326
|
-
dir,
|
|
1327
|
-
signal,
|
|
1328
|
-
})
|
|
1329
|
-
} else {
|
|
1330
|
-
logger.info('multi-repo: cloning', { repo: leg.dirName, cloneBranch: leg.cloneBranch })
|
|
1331
|
-
await cloneRepo({
|
|
1332
|
-
repo: { ...leg.repo, baseBranch: leg.cloneBranch },
|
|
1333
|
-
ghToken: leg.ghToken,
|
|
1334
|
-
dir,
|
|
1335
|
-
signal,
|
|
1336
|
-
})
|
|
1337
|
-
await createBranch(dir, leg.workBranch, signal)
|
|
1338
|
-
}
|
|
1339
|
-
leg.dir = dir
|
|
1340
|
-
// Exclude the agent-authored PR-description sentinel locally (as the single-repo path does)
|
|
1341
|
-
// so the agent's own `git add` can never stage the briefing into the PR it describes.
|
|
1342
|
-
await excludeFromGit(dir, PR_DESCRIPTION_FILE, signal)
|
|
1343
|
-
// The branch tip before the agent runs. Captured BEFORE the resume base refresh below so
|
|
1344
|
-
// that refresh's merge commit counts as advancement and is pushed (as in the single-repo
|
|
1345
|
-
// path). A fresh leg produced work iff its branch advances past this; a resumed leg already
|
|
1346
|
-
// carries prior work.
|
|
1347
|
-
leg.baseSha = await headCommit(dir, signal)
|
|
1348
|
-
// A resumed branch was cut from an OLDER base; merge the latest base in when the two merge
|
|
1349
|
-
// cleanly so the agent works against current base and the peer/own PRs stay current. On a
|
|
1350
|
-
// conflict this is a best-effort no-op (the merge gate handles a conflicting PR downstream),
|
|
1351
|
-
// mirroring the single-repo {@link runCodingAgent} resume refresh.
|
|
1352
|
-
if (leg.resumed) {
|
|
1353
|
-
const refreshed = await refreshFromBaseIfClean(
|
|
1354
|
-
dir,
|
|
1355
|
-
leg.cloneBranch,
|
|
1356
|
-
leg.ghToken,
|
|
1357
|
-
signal,
|
|
1358
|
-
).catch(() => false)
|
|
1359
|
-
if (!refreshed) {
|
|
1360
|
-
logger.info('multi-repo: resume base refresh skipped (conflict or error)', {
|
|
1361
|
-
repo: leg.dirName,
|
|
1362
|
-
base: leg.cloneBranch,
|
|
1363
|
-
})
|
|
1364
|
-
}
|
|
1365
|
-
}
|
|
1366
|
-
}
|
|
1367
|
-
|
|
1368
|
-
// Reference branches attach to the PRIMARY repo, so fetch them into the primary sibling
|
|
1369
|
-
// checkout's `origin/<b>` refs (best-effort per branch). The backend's reference-branches
|
|
1370
|
-
// prompt section names the primary repo's directory to run the read commands in.
|
|
1371
|
-
if (job.referenceBranches?.length) {
|
|
1372
|
-
const primaryLeg = legs.find((l) => l.primary)
|
|
1373
|
-
if (primaryLeg?.dir) {
|
|
1374
|
-
const fetched = await fetchReferenceBranches({
|
|
1375
|
-
dir: primaryLeg.dir,
|
|
1376
|
-
branches: job.referenceBranches,
|
|
1377
|
-
ghToken: primaryLeg.ghToken,
|
|
1378
|
-
signal,
|
|
1379
|
-
onSkip: (branch, reason) =>
|
|
1380
|
-
logger.warn('multi-repo: reference branch fetch skipped', { branch, reason }),
|
|
1381
|
-
})
|
|
1382
|
-
logger.info('multi-repo: fetched reference branches', {
|
|
1383
|
-
requested: job.referenceBranches.length,
|
|
1384
|
-
fetched: fetched.length,
|
|
1385
|
-
})
|
|
1386
|
-
}
|
|
1387
|
-
}
|
|
1388
|
-
}
|
|
1389
|
-
|
|
1390
|
-
/**
|
|
1391
|
-
* Push phase for {@link runMultiRepoCoding}: commit forgotten tracked edits, then push + open a PR
|
|
1392
|
-
* for each repo the run actually changed (a repo the agent left untouched is skipped — no branch,
|
|
1393
|
-
* no PR; a read-only reference leg is never committed or pushed). Extracted so the multi-repo body
|
|
1394
|
-
* stays small; returns the primary's push/PR state plus the peer PRs.
|
|
1395
|
-
*/
|
|
1396
|
-
async function pushMultiRepoLegs(
|
|
1397
|
-
legs: RepoLeg[],
|
|
1398
|
-
job: AgentJob,
|
|
1399
|
-
logger: Logger,
|
|
1400
|
-
opts: RunOptions,
|
|
1401
|
-
/** The workspace root the agent ran in — the fallback probe for the primary's briefing. */
|
|
1402
|
-
root: string,
|
|
1403
|
-
/** Which legs' briefings are filled templates — see the `titleFromHeading` read below. */
|
|
1404
|
-
prTemplate: PrTemplateResolution,
|
|
1405
|
-
): Promise<{
|
|
1406
|
-
primaryPushed: boolean
|
|
1407
|
-
primaryPrUrl: string | undefined
|
|
1408
|
-
peerPullRequests: NonNullable<AgentResult['peerPullRequests']>
|
|
1409
|
-
}> {
|
|
1410
|
-
const { signal } = opts
|
|
1411
|
-
opts.onPhase?.('push')
|
|
1412
|
-
let primaryPushed = false
|
|
1413
|
-
let primaryPrUrl: string | undefined
|
|
1414
|
-
const peerPullRequests: NonNullable<AgentResult['peerPullRequests']> = []
|
|
1415
|
-
for (const leg of legs) {
|
|
1416
|
-
// A read-only reference leg is never committed or pushed — the third layer of the read-only
|
|
1417
|
-
// guarantee (the spec carries no branch/PR, and the clone phase gave it no work branch).
|
|
1418
|
-
if (leg.readOnly) continue
|
|
1419
|
-
// Lift (and remove) the agent-authored PR description for THIS repo's PR before anything
|
|
1420
|
-
// else touches the checkout — each sibling checkout carries its own briefing for its own PR.
|
|
1421
|
-
// The agent's cwd here is the WORKSPACE ROOT rather than any one checkout, so an agent that
|
|
1422
|
-
// read the prompt loosely may well have written a single briefing there instead. Fall back
|
|
1423
|
-
// to it for the PRIMARY leg only: at the root there is nothing to say which repo it
|
|
1424
|
-
// describes, and the primary is the one the run is actually about.
|
|
1425
|
-
//
|
|
1426
|
-
// Per-leg `titleFromHeading`: only a leg whose OWN repo ships a template has repo-authored
|
|
1427
|
-
// headings in its sentinel, and the legs of a workspace need not agree about that — so this
|
|
1428
|
-
// is keyed on the leg, never on whether the run found any template at all.
|
|
1429
|
-
const readOptions = { titleFromHeading: !prTemplate.templated.has(leg.dir) }
|
|
1430
|
-
const agentPrDescription =
|
|
1431
|
-
(await readPrDescription(leg.dir, readOptions)) ??
|
|
1432
|
-
(leg.primary ? await readPrDescription(root, readOptions) : undefined)
|
|
1433
|
-
await commitTrackedEdits(leg.dir, job.commitMessage ?? leg.pr?.title ?? 'Agent changes', signal)
|
|
1434
|
-
const advanced = await branchHasCommitsSince(leg.dir, leg.baseSha, signal)
|
|
1435
|
-
let hasWork = advanced || leg.resumed
|
|
1436
|
-
if (leg.resumed && !advanced) {
|
|
1437
|
-
const ahead = await branchAheadOfBase(leg.dir, leg.repo.baseBranch, leg.ghToken, signal)
|
|
1438
|
-
if (ahead === false) hasWork = false
|
|
1439
|
-
}
|
|
1440
|
-
const leftover = await listUntrackedFiles(leg.dir, signal)
|
|
1441
|
-
if (leftover.length > 0) {
|
|
1442
|
-
logger.warn('multi-repo: uncommitted new files left behind (not pushed)', {
|
|
1443
|
-
repo: leg.dirName,
|
|
1444
|
-
count: leftover.length,
|
|
1445
|
-
files: leftover.slice(0, 20),
|
|
1446
|
-
})
|
|
1447
|
-
}
|
|
1448
|
-
if (!hasWork) {
|
|
1449
|
-
logger.info('multi-repo: no changes for repo', { repo: leg.dirName })
|
|
1450
|
-
continue
|
|
1451
|
-
}
|
|
1452
|
-
await pushBranch(leg.dir, leg.workBranch, leg.ghToken, signal)
|
|
1453
|
-
let prUrl: string | null = null
|
|
1454
|
-
if (leg.pr) {
|
|
1455
|
-
prUrl = await openPullRequest({
|
|
1456
|
-
owner: leg.repo.owner,
|
|
1457
|
-
name: leg.repo.name,
|
|
1458
|
-
ghToken: leg.ghToken,
|
|
1459
|
-
head: leg.workBranch,
|
|
1460
|
-
base: leg.repo.baseBranch,
|
|
1461
|
-
pr: applyPrDescription(leg.pr, agentPrDescription),
|
|
1462
|
-
// See the single-repo call site: refresh a resumed leg's already-open PR, but only
|
|
1463
|
-
// when the text is the agent's own briefing rather than the dispatch-time fallback.
|
|
1464
|
-
...(agentPrDescription ? { refreshExisting: true } : {}),
|
|
1465
|
-
apiBase: job.githubApiBase,
|
|
1466
|
-
cloneUrl: leg.repo.cloneUrl,
|
|
1467
|
-
...(leg.repo.provider ? { provider: leg.repo.provider } : {}),
|
|
1468
|
-
signal,
|
|
1469
|
-
})
|
|
1470
|
-
}
|
|
1471
|
-
if (leg.primary) {
|
|
1472
|
-
primaryPushed = true
|
|
1473
|
-
if (prUrl) primaryPrUrl = prUrl
|
|
1474
|
-
} else if (prUrl) {
|
|
1475
|
-
peerPullRequests.push({
|
|
1476
|
-
repo: `${leg.repo.owner}/${leg.repo.name}`,
|
|
1477
|
-
...(leg.frameId ? { frameId: leg.frameId } : {}),
|
|
1478
|
-
prUrl,
|
|
1479
|
-
branch: leg.workBranch,
|
|
1480
|
-
})
|
|
1481
|
-
}
|
|
1482
|
-
}
|
|
1483
|
-
return { primaryPushed, primaryPrUrl, peerPullRequests }
|
|
1484
|
-
}
|
|
1485
|
-
|
|
1486
1034
|
/**
|
|
1487
1035
|
* The "no changes" reason both coding agents report: a caller-supplied lead phrase
|
|
1488
1036
|
* plus the shared "never acted" cause and a credential-scrubbed tail of Pi's stderr.
|
package/src/job.ts
CHANGED
|
@@ -123,8 +123,13 @@ export interface PrSpec {
|
|
|
123
123
|
*/
|
|
124
124
|
export interface PeerRepoSpec {
|
|
125
125
|
repo: RepoSpec
|
|
126
|
-
/**
|
|
127
|
-
|
|
126
|
+
/**
|
|
127
|
+
* The involved service frames this repo resolved from, echoed back on the peer PR verbatim.
|
|
128
|
+
* More than one when the peer is a monorepo hosting several of the run's involved services:
|
|
129
|
+
* they share this ONE checkout, its work branch and its pull request. Opaque to the harness,
|
|
130
|
+
* which decides no frame attribution of its own.
|
|
131
|
+
*/
|
|
132
|
+
frameIds?: string[]
|
|
128
133
|
/**
|
|
129
134
|
* The work branch to create off the peer's base and push (the shared `cat-factory/<block>`).
|
|
130
135
|
* Present for a COING fan-out (coder / ci-fixer). Absent for a READ-ONLY explore fan-out
|
|
@@ -319,7 +324,10 @@ function parsePeerRepos(value: unknown): PeerRepoSpec[] {
|
|
|
319
324
|
if (e.cloneBranch !== undefined) {
|
|
320
325
|
spec.cloneBranch = str(e.cloneBranch, `peerRepos[${i}].cloneBranch`)
|
|
321
326
|
}
|
|
322
|
-
if (
|
|
327
|
+
if (Array.isArray(e.frameIds)) {
|
|
328
|
+
const frameIds = e.frameIds.filter((f): f is string => typeof f === 'string' && !!f)
|
|
329
|
+
if (frameIds.length) spec.frameIds = frameIds
|
|
330
|
+
}
|
|
323
331
|
if (typeof e.ghToken === 'string' && e.ghToken) spec.ghToken = e.ghToken
|
|
324
332
|
if (typeof e.pr === 'object' && e.pr !== null) {
|
|
325
333
|
const p = e.pr as Record<string, unknown>
|
|
@@ -986,8 +994,12 @@ export interface AgentResult {
|
|
|
986
994
|
* repo the run actually changed (service-connections phase 3). Beside the own-service
|
|
987
995
|
* `prUrl`/`branch`; the backend lifts these onto the block's `peerPullRequests`. Absent for
|
|
988
996
|
* a single-repo run.
|
|
997
|
+
*
|
|
998
|
+
* `frameIds` is the dispatch's own attribution echoed back untouched (see
|
|
999
|
+
* {@link PeerRepoSpec.frameIds}): one entry per repo, carrying every involved frame that
|
|
1000
|
+
* repo hosts.
|
|
989
1001
|
*/
|
|
990
|
-
peerPullRequests?: { repo: string;
|
|
1002
|
+
peerPullRequests?: { repo: string; frameIds?: string[]; prUrl: string; branch: string }[]
|
|
991
1003
|
/** Coding mode (bootstrap): the default branch the bootstrapped contents were pushed to. */
|
|
992
1004
|
defaultBranch?: string
|
|
993
1005
|
error?: string
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Reading a JSON object out of an agent's final message.
|
|
2
|
+
//
|
|
3
|
+
// This is the harness half of a pair: the engine reads the SAME reply again with kernel's
|
|
4
|
+
// `extractJson` (see `CompanionController.parseContainerVerdict`). The harness reads it FIRST, and
|
|
5
|
+
// what it fails to read costs a real, billed repair completion (`resolveStructuredOutput`), so the
|
|
6
|
+
// two must agree about which replies are READABLE AT ALL — a shape only kernel accepts is a model
|
|
7
|
+
// call the run pays for and nobody needed. The container image is built from `src/` plus typescript
|
|
8
|
+
// alone, so that agreement cannot be had by importing kernel: the control-character repair below is
|
|
9
|
+
// a deliberate COPY, pinned by `test/json-reply.conformity.test.ts` exactly like `host-markdown.ts`.
|
|
10
|
+
//
|
|
11
|
+
// WHICH object each half picks can still differ (kernel scans forward from every bracket; this half
|
|
12
|
+
// takes the outermost `{…}` span, which is what its caller's one-object contract wants), so the
|
|
13
|
+
// conformity suite pins readability, not identity.
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Extract the JSON object from an agent's final message, tolerating a fence and surrounding prose.
|
|
17
|
+
* Throws when the reply holds no readable JSON.
|
|
18
|
+
*
|
|
19
|
+
* A reply that is valid JSON except for RAW control characters inside a string literal is REPAIRED
|
|
20
|
+
* rather than refused, and — as in kernel — only in a SECOND pass, after the reply has been tried
|
|
21
|
+
* as written. A model asked to lay a field out over several lines (a review verdict written as
|
|
22
|
+
* blocks) writes the layout and drops the `\n` escape, which is worth recovering; recovering it
|
|
23
|
+
* before the reply has been read as written is not, because a repair makes text parse that was
|
|
24
|
+
* meant to be skipped.
|
|
25
|
+
*/
|
|
26
|
+
export function extractJsonObject(text: string): unknown {
|
|
27
|
+
const trimmed = text.trim()
|
|
28
|
+
const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/i.exec(trimmed)
|
|
29
|
+
const body = fenced ? (fenced[1] ?? '') : trimmed
|
|
30
|
+
const asWritten = parseWholeOrSpan(body)
|
|
31
|
+
if (asWritten !== undefined) return asWritten
|
|
32
|
+
// No raw control character ⇒ the repair pass would hand `JSON.parse` the same bytes again.
|
|
33
|
+
if (hasRawControlChar(body)) {
|
|
34
|
+
const repaired = parseWholeOrSpan(escapeControlCharsInStrings(body))
|
|
35
|
+
if (repaired !== undefined) return repaired
|
|
36
|
+
}
|
|
37
|
+
throw new Error('agent did not return a JSON object')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Parse `source`, else its outermost `{…}` span (the object inside the model's prose). Undefined
|
|
42
|
+
* when neither parses — a value `JSON.parse` itself can never return, so `null` stays a result.
|
|
43
|
+
*/
|
|
44
|
+
function parseWholeOrSpan(source: string): unknown {
|
|
45
|
+
const whole = parseOrUndefined(source)
|
|
46
|
+
if (whole !== undefined) return whole
|
|
47
|
+
const start = source.indexOf('{')
|
|
48
|
+
const end = source.lastIndexOf('}')
|
|
49
|
+
if (start === -1 || end === -1 || end <= start) return undefined
|
|
50
|
+
return parseOrUndefined(source.slice(start, end + 1))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function parseOrUndefined(json: string): unknown {
|
|
54
|
+
try {
|
|
55
|
+
return JSON.parse(json)
|
|
56
|
+
} catch {
|
|
57
|
+
return undefined
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whether `text` holds any raw control character: the cheap gate on attempting a repair at all. */
|
|
62
|
+
function hasRawControlChar(text: string): boolean {
|
|
63
|
+
for (let i = 0; i < text.length; i++) {
|
|
64
|
+
if (text.charCodeAt(i) < 0x20) return true
|
|
65
|
+
}
|
|
66
|
+
return false
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The control characters JSON gives a short escape; the rest go to `\uXXXX`. */
|
|
70
|
+
const CONTROL_ESCAPES: Record<string, string> = {
|
|
71
|
+
'\n': '\\n',
|
|
72
|
+
'\r': '\\r',
|
|
73
|
+
'\t': '\\t',
|
|
74
|
+
'\b': '\\b',
|
|
75
|
+
'\f': '\\f',
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Re-escape raw control characters that sit INSIDE a JSON string literal. Only characters inside a
|
|
80
|
+
* string are rewritten, so the structural whitespace between tokens keeps its meaning and a
|
|
81
|
+
* genuinely broken reply still fails to parse. Copied from kernel's `llm-output.ts`.
|
|
82
|
+
*/
|
|
83
|
+
function escapeControlCharsInStrings(json: string): string {
|
|
84
|
+
let out = ''
|
|
85
|
+
let copiedTo = 0
|
|
86
|
+
let inString = false
|
|
87
|
+
let escaped = false
|
|
88
|
+
for (let i = 0; i < json.length; i++) {
|
|
89
|
+
const ch = json[i]!
|
|
90
|
+
if (!inString) {
|
|
91
|
+
if (ch === '"') inString = true
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
if (escaped) {
|
|
95
|
+
escaped = false
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
if (ch === '\\') {
|
|
99
|
+
escaped = true
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
if (ch === '"') {
|
|
103
|
+
inString = false
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
if (json.charCodeAt(i) >= 0x20) continue
|
|
107
|
+
const escape = CONTROL_ESCAPES[ch] ?? `\\u${json.charCodeAt(i).toString(16).padStart(4, '0')}`
|
|
108
|
+
out += json.slice(copiedTo, i) + escape
|
|
109
|
+
copiedTo = i + 1
|
|
110
|
+
}
|
|
111
|
+
return out + json.slice(copiedTo)
|
|
112
|
+
}
|