@cat-factory/executor-harness 1.58.0 → 1.62.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.
package/src/agent.ts CHANGED
@@ -22,16 +22,16 @@ import {
22
22
  fetchReferenceBranches,
23
23
  hasAgentChanges,
24
24
  headCommit,
25
- inferVcsProvider,
26
25
  mergeBranch,
27
- openPullRequest,
28
26
  prepareExistingCheckout,
29
27
  pushBranch,
30
28
  reinitAndPush,
31
29
  unmergedPaths,
32
30
  } from './git.js'
31
+ import { inferVcsProvider, openPullRequest } from './vcs-api.js'
33
32
  import type { PiRunStats, RunDiagnostics } from './pi.js'
34
33
  import type { EffortReport } from './effort.js'
34
+ import { applyPrDescription } from './pr-description.js'
35
35
  import {
36
36
  makeDirClaimer,
37
37
  noChangesReason,
@@ -972,6 +972,10 @@ function buildSingleRepoCodingSpec(
972
972
  // Forwarded straight off the job body — the loop is generic machinery keyed on the data, not
973
973
  // on the agent kind.
974
974
  ...(job.validationChecks ? { validationChecks: job.validationChecks } : {}),
975
+ // Bugfix reproduction proof: the declared command run against the pre-fix and final trees
976
+ // (see docs/initiatives/bugfix-reproduction-proof.md). Forwarded straight off the job body —
977
+ // like the checks above, the loop is generic machinery keyed on the data, not the agent kind.
978
+ ...(job.reproduction ? { reproduction: job.reproduction } : {}),
975
979
  }
976
980
  }
977
981
 
@@ -992,17 +996,23 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
992
996
  callMetrics,
993
997
  validation,
994
998
  validationReport,
999
+ reproductionReport,
995
1000
  effortReport,
1001
+ prDescription,
996
1002
  } = await runCodingAgent(buildSingleRepoCodingSpec(job, pushBranch), opts)
997
1003
  // Ralph loop: the harness-computed validation verdict, forwarded onto the coding result as
998
1004
  // `ralphVerdict` so the backend's `toRunResult` lifts it onto `AgentRunResult.ralphVerdict`.
999
1005
  const ralphVerdict = validation ? { ralphVerdict: validation } : {}
1000
1006
  // The agent's effort self-assessment, spread onto every result path below (mirrors ralphVerdict).
1001
1007
  const effort = effortReport ? { effortReport } : {}
1002
- // The pre-PR validation report, spread onto every result path below: on the passing path it is
1003
- // the captured proof the checkout was green when the PR opened; on the exhausted path it is the
1004
- // evidence behind the failure below. Absent when the service configured no checks.
1005
- const validationFields = validationReport ? { validationReport } : {}
1008
+ // The two PRE-PR VERIFICATION reports, spread onto every result path below. The validation one:
1009
+ // on the passing path it is the captured proof the checkout was green when the PR opened; on the
1010
+ // exhausted path it is the evidence behind the failure below. The reproduction one is evidence
1011
+ // on every path it never gates the PR. Each is absent when its phase was not configured.
1012
+ const verificationFields = {
1013
+ ...(validationReport ? { validationReport } : {}),
1014
+ ...(reproductionReport ? { reproductionReport } : {}),
1015
+ }
1006
1016
 
1007
1017
  // Pre-PR validation spent its attempt budget with the checkout still red. FAIL the job — do
1008
1018
  // NOT open a pull request, and do not pretend the push succeeded as a deliverable. The work is
@@ -1022,7 +1032,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
1022
1032
  failureCause: 'agent',
1023
1033
  ...(usage ? { usage } : {}),
1024
1034
  ...(callMetrics ? { callMetrics } : {}),
1025
- ...validationFields,
1035
+ ...verificationFields,
1026
1036
  ...effort,
1027
1037
  }
1028
1038
  }
@@ -1038,7 +1048,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
1038
1048
  ...(usage ? { usage } : {}),
1039
1049
  ...(callMetrics ? { callMetrics } : {}),
1040
1050
  ...ralphVerdict,
1041
- ...validationFields,
1051
+ ...verificationFields,
1042
1052
  ...effort,
1043
1053
  }
1044
1054
  }
@@ -1051,7 +1061,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
1051
1061
  failureCause: 'no-changes',
1052
1062
  ...(usage ? { usage } : {}),
1053
1063
  ...(callMetrics ? { callMetrics } : {}),
1054
- ...validationFields,
1064
+ ...verificationFields,
1055
1065
  ...effort,
1056
1066
  }
1057
1067
  }
@@ -1064,7 +1074,11 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
1064
1074
  ghToken: job.ghToken,
1065
1075
  head: pushBranch,
1066
1076
  base: job.repo.baseBranch,
1067
- pr: job.pr,
1077
+ // The agent-authored briefing (title/body) wins field-wise over the dispatch-time text.
1078
+ pr: applyPrDescription(job.pr, prDescription),
1079
+ // A resumed run's PR is already open, so refresh it rather than lose the briefing to the
1080
+ // duplicate-PR 422 — only from a REAL briefing (see `refreshExisting` for why).
1081
+ ...(prDescription ? { refreshExisting: true } : {}),
1068
1082
  apiBase: job.githubApiBase,
1069
1083
  // The provider (set by the server from the configured backend) selects GitHub-PR vs
1070
1084
  // GitLab-MR authoritatively; the clone URL supplies the GitLab REST base + project path.
@@ -1086,7 +1100,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
1086
1100
  stats,
1087
1101
  ...(usage ? { usage } : {}),
1088
1102
  ...(callMetrics ? { callMetrics } : {}),
1089
- ...validationFields,
1103
+ ...verificationFields,
1090
1104
  ...effort,
1091
1105
  }
1092
1106
  }
@@ -1103,7 +1117,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
1103
1117
  failureCause: 'no-changes',
1104
1118
  ...(usage ? { usage } : {}),
1105
1119
  ...(callMetrics ? { callMetrics } : {}),
1106
- ...validationFields,
1120
+ ...verificationFields,
1107
1121
  ...effort,
1108
1122
  }
1109
1123
  }
@@ -1116,7 +1130,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
1116
1130
  ...(usage ? { usage } : {}),
1117
1131
  ...(callMetrics ? { callMetrics } : {}),
1118
1132
  ...ralphVerdict,
1119
- ...validationFields,
1133
+ ...verificationFields,
1120
1134
  ...effort,
1121
1135
  }
1122
1136
  }
@@ -1128,7 +1142,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
1128
1142
  ...(usage ? { usage } : {}),
1129
1143
  ...(callMetrics ? { callMetrics } : {}),
1130
1144
  ...ralphVerdict,
1131
- ...validationFields,
1145
+ ...verificationFields,
1132
1146
  ...effort,
1133
1147
  }
1134
1148
  }
@@ -0,0 +1,144 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { killChildProcess, spawnDetached } from './process.js'
3
+ import { MAX_CAPTURED_OUTPUT_CHARS, redactSecrets } from './redact.js'
4
+ import type { RunOptions } from './runner.js'
5
+ import type { Logger } from './logger.js'
6
+
7
+ // The ONE way the harness runs a declared shell command on its own behalf (rather than through
8
+ // the agent) and keeps a bounded, secret-scrubbed record of what it printed.
9
+ //
10
+ // Both pre-PR verification phases need exactly this — the PRE-PR VALIDATION checks
11
+ // (`validation-checks.ts`) and the BUGFIX REPRODUCTION PROOF (`reproduction-proof.ts`) — and they
12
+ // need it to behave IDENTICALLY: same watchdog semantics, same abort handling, same conventional
13
+ // exit codes, same scrub-then-bound pipeline. They were two near-verbatim copies; a fix applied to
14
+ // one of them (a redaction ordering, an exit-code convention) silently missed the other, which is
15
+ // the whole reason this seam exists.
16
+ //
17
+ // Everything is PER-JOB by construction: the command, the cwd and the environment all arrive as
18
+ // arguments and nothing is read from or written to `process.env`/`HOME`. The local NATIVE
19
+ // transport serves every concurrent job from ONE host process, so a global would leak one job's
20
+ // state into a sibling's — and the container path would never catch it.
21
+
22
+ /**
23
+ * A little slack kept in the rolling capture buffer ON TOP of {@link MAX_CAPTURED_OUTPUT_CHARS},
24
+ * so scrubbing sees whole secrets.
25
+ *
26
+ * The buffer discards from the FRONT as output arrives, and `redactSecrets` only runs once the
27
+ * command settles. Without the margin a token straddling that rolling cut would already have lost
28
+ * its `KEY=` prefix by scrub time and would survive as an unrecognised partial. Capturing a bit
29
+ * more than we keep, scrubbing, and only THEN bounding to the real limit closes that window; 512
30
+ * chars comfortably exceeds any single credential assignment the rules match.
31
+ */
32
+ const CAPTURE_MARGIN_CHARS = 512
33
+
34
+ /** What one harness-spawned command did, as both phases record it. */
35
+ export interface CapturedCommandResult {
36
+ /** Exit code (0 = pass); 124 on watchdog timeout, 127 on spawn failure, 130 on abort. */
37
+ exitCode: number
38
+ passed: boolean
39
+ /** Scrubbed output bounded to the caller's REPORT budget (what crosses the wire). */
40
+ outputTail?: string
41
+ durationMs: number
42
+ timedOut?: boolean
43
+ /**
44
+ * The FULL scrubbed tail (up to {@link MAX_CAPTURED_OUTPUT_CHARS}) for a repair prompt. Never
45
+ * leaves the container — the agent needs the whole failure to act on it, the wire does not.
46
+ */
47
+ fullTail?: string
48
+ }
49
+
50
+ /**
51
+ * Run ONE command as `sh -c` in `cwd`, capturing a bounded, secret-scrubbed tail of its combined
52
+ * stdout+stderr. The exit code is the verdict — computed here by the harness, never self-reported
53
+ * by the model, which is the whole point of a programmatic phase. A watchdog kills the process
54
+ * tree on timeout and an aborted run resolves non-zero, so a phase is never what blocks a job
55
+ * from settling.
56
+ *
57
+ * The child inherits the JOB's environment (`RunOptions.agentEnv` layered over the process env),
58
+ * not a mutated global: the harness spawns this itself rather than through the agent, so without
59
+ * the explicit merge a native-mode job would run without the private-registry npmrc pointer (and,
60
+ * had this been staged in `process.env`, against a sibling job's state).
61
+ *
62
+ * `logLabel`/`logFields` shape only the two warnings this runner emits itself (the watchdog kill
63
+ * and a spawn failure); the caller keeps its own start/finish logging, which knows what the
64
+ * command MEANS.
65
+ */
66
+ export async function runCapturedCommand(args: {
67
+ cwd: string
68
+ command: string
69
+ timeoutMs: number
70
+ /** Bound for {@link CapturedCommandResult.outputTail} — the caller's per-report budget. */
71
+ reportTailChars: number
72
+ logLabel: string
73
+ logFields?: Record<string, unknown>
74
+ logger: Logger
75
+ opts: RunOptions
76
+ }): Promise<CapturedCommandResult> {
77
+ const { cwd, command, timeoutMs, reportTailChars, logLabel, logFields, logger, opts } = args
78
+ const startedAt = Date.now()
79
+ return new Promise((resolve) => {
80
+ let out = ''
81
+ let settled = false
82
+ let timedOut = false
83
+ const child = spawn('sh', ['-c', command], {
84
+ cwd,
85
+ detached: spawnDetached,
86
+ stdio: ['ignore', 'pipe', 'pipe'],
87
+ env: { ...process.env, ...opts.agentEnv },
88
+ })
89
+ // Keep only the tail (plus the scrub margin); guard against unbounded buffering on a chatty
90
+ // command.
91
+ const capture = (chunk: Buffer): void => {
92
+ out = (out + chunk.toString('utf8')).slice(
93
+ -(MAX_CAPTURED_OUTPUT_CHARS + CAPTURE_MARGIN_CHARS),
94
+ )
95
+ }
96
+ child.stdout?.on('data', capture)
97
+ child.stderr?.on('data', capture)
98
+ const finish = (exitCode: number): void => {
99
+ if (settled) return
100
+ settled = true
101
+ clearTimeout(timer)
102
+ opts.signal?.removeEventListener('abort', onAbort)
103
+ const trimmed = out.trim()
104
+ // Scrub BEFORE either bound: the pattern rules need a whole assignment to match, so the
105
+ // margin above is trimmed away only once the secrets are already gone.
106
+ const scrubbed = trimmed ? redactSecrets(trimmed).slice(-MAX_CAPTURED_OUTPUT_CHARS) : ''
107
+ resolve({
108
+ exitCode,
109
+ passed: exitCode === 0,
110
+ ...(scrubbed ? { outputTail: boundTail(scrubbed, reportTailChars) } : {}),
111
+ durationMs: Date.now() - startedAt,
112
+ ...(timedOut ? { timedOut: true } : {}),
113
+ ...(scrubbed ? { fullTail: scrubbed } : {}),
114
+ })
115
+ }
116
+ const timer = setTimeout(() => {
117
+ logger.warn(`${logLabel}: command timed out`, { ...logFields, timeoutMs })
118
+ timedOut = true
119
+ killChildProcess(child, undefined, logger)
120
+ finish(124) // conventional timeout exit code (a non-zero fail)
121
+ }, timeoutMs)
122
+ timer.unref?.()
123
+ const onAbort = (): void => {
124
+ killChildProcess(child, undefined, logger)
125
+ finish(130) // aborted (a non-zero fail)
126
+ }
127
+ opts.signal?.addEventListener('abort', onAbort, { once: true })
128
+ child.on('error', (err) => {
129
+ logger.warn(`${logLabel}: command failed to spawn`, {
130
+ ...logFields,
131
+ error: err instanceof Error ? err.message : String(err),
132
+ })
133
+ finish(127) // spawn error / command not found (a non-zero fail)
134
+ })
135
+ child.on('close', (code) => finish(code ?? 1))
136
+ })
137
+ }
138
+
139
+ /** Bound an already-scrubbed output tail to what a REPORT carries, saying what it dropped. */
140
+ export function boundTail(scrubbed: string, maxChars: number): string {
141
+ if (scrubbed.length <= maxChars) return scrubbed
142
+ const trimmed = scrubbed.length - maxChars
143
+ return `…(${trimmed} earlier chars trimmed)\n${scrubbed.slice(-maxChars)}`
144
+ }
@@ -14,6 +14,7 @@ import type {
14
14
  } from './job.js'
15
15
  import {
16
16
  branchAheadOfBase,
17
+ changedFilesSinceBase,
17
18
  branchHasCommitsSince,
18
19
  cloneExistingBranch,
19
20
  cloneRepo,
@@ -23,15 +24,21 @@ import {
23
24
  fetchReferenceBranches,
24
25
  headCommit,
25
26
  listUntrackedFiles,
26
- openPullRequest,
27
27
  prepareExistingCheckout,
28
28
  pushBranch,
29
29
  refreshFromBaseIfClean,
30
30
  remoteBranchExists,
31
31
  } from './git.js'
32
+ import { openPullRequest } from './vcs-api.js'
32
33
  import { FOLLOW_UPS_FILENAME, FollowUpTailer } from './follow-ups.js'
33
34
  import type { HarnessCallMetric, PiRunStats } from './pi.js'
34
35
  import { EFFORT_REPORT_FILE, type EffortReport } from './effort.js'
36
+ import {
37
+ type AgentPrDescription,
38
+ applyPrDescription,
39
+ PR_DESCRIPTION_FILE,
40
+ readPrDescription,
41
+ } from './pr-description.js'
35
42
  import {
36
43
  acquireRepoCheckout,
37
44
  agentNeverActed,
@@ -47,6 +54,11 @@ import {
47
54
  type ValidationChecksSpec,
48
55
  type ValidationReport,
49
56
  } from './validation-checks.js'
57
+ import {
58
+ runReproductionLoop,
59
+ type ReproductionReport,
60
+ type ReproductionSpec,
61
+ } from './reproduction-proof.js'
50
62
 
51
63
  // The shared skeleton for the container coding agents that clone a repo, run Pi
52
64
  // against it and push the result on a branch. The implementation (`/run`) and
@@ -117,6 +129,16 @@ export interface CodingAgentSpec extends HarnessAuthFields {
117
129
  * `docs/initiatives/pre-pr-validation.md`.
118
130
  */
119
131
  validationChecks?: ValidationChecksSpec
132
+ /**
133
+ * BUGFIX REPRODUCTION PROOF: the run's declared reproduction command + test files. When set, the
134
+ * harness runs that command against the pre-fix tree AND the tree the PR will open from, feeding
135
+ * a failed verification back to the agent while budget remains, and attaches the verdict to the
136
+ * outcome. Unlike {@link validationChecks} it NEVER gates the pull request — an unproven
137
+ * reproduction is weak evidence, which is a reviewer's call, not a machine's. Set only for a
138
+ * dispatch that opens a PR and whose run carries a declaration. See
139
+ * `docs/initiatives/bugfix-reproduction-proof.md`.
140
+ */
141
+ reproduction?: ReproductionSpec
120
142
  /**
121
143
  * A repo-sourced Claude Skill to make available for this run (a `skill` step, slice 2). Threaded
122
144
  * into {@link runAgentInWorkspace}, which installs it harness-aware: natively under the ISOLATED
@@ -141,6 +163,12 @@ export interface CodingAgentOutcome {
141
163
  callMetrics?: HarnessCallMetric[]
142
164
  /** The agent's effort self-assessment, lifted from its sentinel file (absent when it wrote none). */
143
165
  effortReport?: EffortReport
166
+ /**
167
+ * The agent-authored PR description, lifted from its sentinel file (absent when it wrote none).
168
+ * The PR-opening caller folds it over the dispatch-time title/body via {@link applyPrDescription};
169
+ * absent means the fallback text, unchanged.
170
+ */
171
+ prDescription?: AgentPrDescription
144
172
  /**
145
173
  * Ralph loop: the verdict of the post-commit validation command (whether it exited 0, the
146
174
  * exit code, and a bounded/redacted output tail). Present only when {@link CodingAgentSpec.validation}
@@ -158,6 +186,12 @@ export interface CodingAgentOutcome {
158
186
  * the caller must open no PR and fail the job with this as the evidence.
159
187
  */
160
188
  validationReport?: ValidationReport
189
+ /**
190
+ * The bugfix reproduction proof's LAST attempt (present only when
191
+ * {@link CodingAgentSpec.reproduction} was set). Evidence, never a gate: `inconclusive` is
192
+ * attached to a perfectly successful run and the PR still opens.
193
+ */
194
+ reproductionReport?: ReproductionReport
161
195
  }
162
196
 
163
197
  /**
@@ -273,6 +307,9 @@ export async function runCodingAgent(
273
307
  // but that cannot un-stage a mid-run commit; the per-clone exclude is what prevents it. A bare
274
308
  // filename pattern matches the file in any subdirectory, so it covers a monorepo `workDir` too.
275
309
  await excludeFromGit(dir, EFFORT_REPORT_FILE, signal)
310
+ // Same treatment for the agent-authored PR-description sentinel: excluded locally so the
311
+ // agent's own `git add` can never stage the briefing into the PR it describes.
312
+ await excludeFromGit(dir, PR_DESCRIPTION_FILE, signal)
276
313
 
277
314
  // Follow-up companion: tail the Coder's sentinel file and stream new items out on the
278
315
  // job view. Locally exclude it from git first so the agent's own `git add` can never
@@ -323,6 +360,64 @@ export async function runCodingAgent(
323
360
  opts.onPhase?.('agent')
324
361
  logger.info('coding-agent: running agent', { serviceDirectory })
325
362
  let agentRun = await runAgentPass(spec.userPrompt)
363
+ const foldPass = (run: typeof agentRun): void => {
364
+ agentRun = mergeAgentPasses(agentRun, run)
365
+ }
366
+ // The new files the agent left unadded, folded into either loop's repair prompt. Both
367
+ // loops judge state the push will NOT carry unless it is committed — the checks run
368
+ // against the working tree, the proof against committed trees — so an unadded file is
369
+ // exactly the thing to name. A throw degrades to "no warning" inside each loop.
370
+ const listUncommittedNewFiles = (): Promise<string[]> =>
371
+ listUntrackedFiles(workDir, opts.signal)
372
+
373
+ // BUGFIX REPRODUCTION PROOF: run the run's declared reproduction command against the
374
+ // pre-fix tree and the tree the PR will open from, and record whether it was red then
375
+ // green. Runs BEFORE the validation loop below, deliberately: validation is the GATE
376
+ // ("only a green checkout opens a PR"), so it has to stay the last thing that touches the
377
+ // tree — otherwise a reproduction repair round could leave the checkout red behind it and
378
+ // the PR would open anyway. Keyed purely off the job body carrying a spec (no agent-kind
379
+ // switch); absent ⇒ a no-op and the flow below is byte-for-byte what it was.
380
+ const reproduction = spec.reproduction
381
+ let reproductionReport: ReproductionReport | undefined
382
+ if (reproduction && (await producedWork(dir, spec, baseSha, resumed, opts))) {
383
+ opts.onPhase?.('reproduction')
384
+ reproductionReport = await runReproductionLoop({
385
+ dir,
386
+ baseSha,
387
+ // Re-read per attempt: a repair pass commits, so the final tree moves under the loop.
388
+ // `producedWork` has already committed forgotten tracked edits, and each repair round
389
+ // re-commits before the next read.
390
+ resolveFinalSha: async () => {
391
+ await commitTrackedEdits(dir, spec.commitMessage, signal)
392
+ return headCommit(dir, signal)
393
+ },
394
+ ...(serviceDirectory ? { serviceDirectory } : {}),
395
+ spec: reproduction,
396
+ logger,
397
+ opts,
398
+ runAgentPass,
399
+ onAgentPass: foldPass,
400
+ listUncommittedNewFiles,
401
+ // Only a RESUMED run can have a pre-fix tree that already carries work: a fresh run
402
+ // branched off base, so `baseSha` IS base. Wiring the probe unconditionally would buy
403
+ // an always-empty answer for the price of a fetch — and a fresh clone is shallow, so
404
+ // it could not resolve a merge base to answer with anyway. Lazy inside the loop: it
405
+ // only runs if a tree comes back green.
406
+ ...(resumed
407
+ ? {
408
+ listBaseTreeChanges: () =>
409
+ changedFilesSinceBase(
410
+ dir,
411
+ spec.repo.baseBranch,
412
+ spec.ghToken,
413
+ baseSha,
414
+ opts.signal,
415
+ ),
416
+ }
417
+ : {}),
418
+ })
419
+ opts.onPhase?.('agent')
420
+ }
326
421
  // PRE-PR VALIDATION: run the service's configured checks against the checkout and, while
327
422
  // they fail and budget remains, hand the captured output back to the agent and run it
328
423
  // again. Sits BETWEEN the agent and the finalize/push/PR step so a red checkout never
@@ -338,17 +433,16 @@ export async function runCodingAgent(
338
433
  logger,
339
434
  opts,
340
435
  runAgentPass,
341
- onAgentPass: (run) => {
342
- agentRun = mergeAgentPasses(agentRun, run)
343
- },
436
+ onAgentPass: foldPass,
344
437
  // The checks run against the WORKING TREE, but only tracked edits are staged for the
345
438
  // push — so a repair round can go green on a new file the PR would never contain.
346
439
  // Name those files in the next repair prompt so the agent adds them.
347
- listUncommittedNewFiles: () => listUntrackedFiles(workDir, opts.signal),
440
+ listUncommittedNewFiles,
348
441
  })
349
442
  }
350
443
  outcome = await finalizeCodingRun({
351
444
  validationReport,
445
+ reproductionReport,
352
446
  dir,
353
447
  spec,
354
448
  logger,
@@ -499,6 +593,8 @@ async function prepareCodingCheckout(
499
593
  async function finalizeCodingRun(args: {
500
594
  /** The pre-PR validation loop's last attempt, attached to the outcome (absent when unconfigured). */
501
595
  validationReport?: ValidationReport
596
+ /** The reproduction proof's last attempt, attached to the outcome (absent when unconfigured). */
597
+ reproductionReport?: ReproductionReport
502
598
  dir: string
503
599
  spec: CodingAgentSpec
504
600
  logger: Logger
@@ -515,6 +611,7 @@ async function finalizeCodingRun(args: {
515
611
  }): Promise<CodingAgentOutcome> {
516
612
  const {
517
613
  validationReport,
614
+ reproductionReport,
518
615
  dir,
519
616
  spec,
520
617
  logger,
@@ -542,6 +639,14 @@ async function finalizeCodingRun(args: {
542
639
  // untracked scratch files/artifacts — the agent owns committing new files).
543
640
  await commitTrackedEdits(dir, spec.commitMessage, signal)
544
641
 
642
+ // The agent-authored PR description, read AFTER the validation loop (a repair round may have
643
+ // changed what the briefing should say) and removed so it never lingers in the checkout. The
644
+ // prompt asks for it at the top level of the checkout; a monorepo agent working in a service
645
+ // subdirectory may drop it in its cwd instead, so probe the checkout root first, then the cwd.
646
+ const prDescription =
647
+ (await readPrDescription(dir)) ??
648
+ (workDir !== dir ? await readPrDescription(workDir) : undefined)
649
+
545
650
  // Stop periodic checkpoints and let any in-flight one settle BEFORE the final
546
651
  // push, so the two never run a concurrent `git push` to the same branch (the
547
652
  // final push below is then a fresh attempt whose failure is the real signal).
@@ -604,6 +709,7 @@ async function finalizeCodingRun(args: {
604
709
  ...(usage ? { usage } : {}),
605
710
  ...(callMetrics ? { callMetrics } : {}),
606
711
  ...(effortReport ? { effortReport } : {}),
712
+ ...(prDescription ? { prDescription } : {}),
607
713
  }
608
714
  }
609
715
 
@@ -618,6 +724,9 @@ async function finalizeCodingRun(args: {
618
724
  // reaches the PR-opening caller); attach its verdict for the caller to gate on and for the
619
725
  // backend to record on the step.
620
726
  if (validationReport) outcome.validationReport = validationReport
727
+ // The reproduction proof: attached to EVERY outcome, including a no-op or an `inconclusive`
728
+ // verdict. It is evidence about the change, not a gate on it — see the loop's D6 note.
729
+ if (reproductionReport) outcome.reproductionReport = reproductionReport
621
730
  return outcome
622
731
  }
623
732
 
@@ -932,6 +1041,7 @@ export async function runMultiRepoCoding(
932
1041
  job,
933
1042
  logger,
934
1043
  opts,
1044
+ root,
935
1045
  )
936
1046
 
937
1047
  const anyWork = primaryPushed || peerPullRequests.length > 0
@@ -1044,6 +1154,9 @@ async function prepareMultiRepoCheckouts(
1044
1154
  await createBranch(dir, leg.workBranch, signal)
1045
1155
  }
1046
1156
  leg.dir = dir
1157
+ // Exclude the agent-authored PR-description sentinel locally (as the single-repo path does)
1158
+ // so the agent's own `git add` can never stage the briefing into the PR it describes.
1159
+ await excludeFromGit(dir, PR_DESCRIPTION_FILE, signal)
1047
1160
  // The branch tip before the agent runs. Captured BEFORE the resume base refresh below so
1048
1161
  // that refresh's merge commit counts as advancement and is pushed (as in the single-repo
1049
1162
  // path). A fresh leg produced work iff its branch advances past this; a resumed leg already
@@ -1102,6 +1215,8 @@ async function pushMultiRepoLegs(
1102
1215
  job: AgentJob,
1103
1216
  logger: Logger,
1104
1217
  opts: RunOptions,
1218
+ /** The workspace root the agent ran in — the fallback probe for the primary's briefing. */
1219
+ root: string,
1105
1220
  ): Promise<{
1106
1221
  primaryPushed: boolean
1107
1222
  primaryPrUrl: string | undefined
@@ -1116,6 +1231,15 @@ async function pushMultiRepoLegs(
1116
1231
  // A read-only reference leg is never committed or pushed — the third layer of the read-only
1117
1232
  // guarantee (the spec carries no branch/PR, and the clone phase gave it no work branch).
1118
1233
  if (leg.readOnly) continue
1234
+ // Lift (and remove) the agent-authored PR description for THIS repo's PR before anything
1235
+ // else touches the checkout — each sibling checkout carries its own briefing for its own PR.
1236
+ // The agent's cwd here is the WORKSPACE ROOT rather than any one checkout, so an agent that
1237
+ // read the prompt loosely may well have written a single briefing there instead. Fall back
1238
+ // to it for the PRIMARY leg only: at the root there is nothing to say which repo it
1239
+ // describes, and the primary is the one the run is actually about.
1240
+ const agentPrDescription =
1241
+ (await readPrDescription(leg.dir)) ??
1242
+ (leg.primary ? await readPrDescription(root) : undefined)
1119
1243
  await commitTrackedEdits(leg.dir, job.commitMessage ?? leg.pr?.title ?? 'Agent changes', signal)
1120
1244
  const advanced = await branchHasCommitsSince(leg.dir, leg.baseSha, signal)
1121
1245
  let hasWork = advanced || leg.resumed
@@ -1144,7 +1268,10 @@ async function pushMultiRepoLegs(
1144
1268
  ghToken: leg.ghToken,
1145
1269
  head: leg.workBranch,
1146
1270
  base: leg.repo.baseBranch,
1147
- pr: leg.pr,
1271
+ pr: applyPrDescription(leg.pr, agentPrDescription),
1272
+ // See the single-repo call site: refresh a resumed leg's already-open PR, but only
1273
+ // when the text is the agent's own briefing rather than the dispatch-time fallback.
1274
+ ...(agentPrDescription ? { refreshExisting: true } : {}),
1148
1275
  apiBase: job.githubApiBase,
1149
1276
  cloneUrl: leg.repo.cloneUrl,
1150
1277
  ...(leg.repo.provider ? { provider: leg.repo.provider } : {}),