@cat-factory/executor-harness 1.56.0 → 1.60.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,9 +1,58 @@
1
- import { spawn } from 'node:child_process';
2
- import { killChildProcess, spawnDetached } from './process.js';
3
- import { MAX_CAPTURED_OUTPUT_CHARS, redactSecrets } from './redact.js';
1
+ import { runCapturedCommand } from './captured-command.js';
2
+ /**
3
+ * The ceiling the harness clamps a body-supplied `validationChecks.maxAttempts` to, and the
4
+ * default it applies when the body omits one.
5
+ *
6
+ * DELIBERATE DUPLICATES of `VALIDATION_MAX_ATTEMPTS_CEILING` / `VALIDATION_DEFAULT_MAX_ATTEMPTS`
7
+ * in `@cat-factory/contracts` — the published image takes no schema dependency, so the harness
8
+ * cannot import them. Keep the two in step: the API validates writes against the contracts
9
+ * values, so a harness clamping to a DIFFERENT ceiling would silently cap a budget an operator
10
+ * was allowed to save, with nothing to flag the mismatch.
11
+ */
12
+ export const VALIDATION_MAX_ATTEMPTS_CEILING = 10;
13
+ export const VALIDATION_DEFAULT_MAX_ATTEMPTS = 3;
14
+ /**
15
+ * Parse the optional PRE-PR VALIDATION CHECKS envelope off the job body: the service's ordered
16
+ * `{ label, command }` pairs and the repair-round budget. Every entry needs a non-empty command;
17
+ * entries without one are dropped, and a spec that ends up with no usable check returns
18
+ * `undefined` — so a malformed body degrades to the exact pre-feature behaviour (no loop, PR
19
+ * opens as before) rather than failing an otherwise-good coding run. `maxAttempts` is clamped to
20
+ * a sane range so a bad body can't make a container loop forever.
21
+ *
22
+ * Lives with the feature rather than in `job.ts` so each pre-PR verification phase owns its own
23
+ * job-body parser next to the loop that consumes it (the reproduction proof's
24
+ * `parseReproductionSpec` is the sibling); `job.ts` stays the job SHAPE plus the generic
25
+ * assembly.
26
+ */
27
+ export function parseValidationChecksSpec(value) {
28
+ if (typeof value !== 'object' || value === null)
29
+ return undefined;
30
+ const o = value;
31
+ if (!Array.isArray(o.checks))
32
+ return undefined;
33
+ const checks = [];
34
+ for (const raw of o.checks) {
35
+ if (typeof raw !== 'object' || raw === null)
36
+ continue;
37
+ const c = raw;
38
+ if (typeof c.command !== 'string' || c.command.trim() === '')
39
+ continue;
40
+ const label = typeof c.label === 'string' && c.label.trim() ? c.label.trim() : c.command;
41
+ checks.push({ label, command: c.command });
42
+ }
43
+ if (checks.length === 0)
44
+ return undefined;
45
+ const parsed = typeof o.maxAttempts === 'number' && Number.isFinite(o.maxAttempts) && o.maxAttempts > 0
46
+ ? Math.floor(o.maxAttempts)
47
+ : undefined;
48
+ return {
49
+ checks,
50
+ maxAttempts: Math.min(parsed ?? VALIDATION_DEFAULT_MAX_ATTEMPTS, VALIDATION_MAX_ATTEMPTS_CEILING),
51
+ };
52
+ }
4
53
  /**
5
54
  * Per-command output kept on the REPORT (what crosses the wire and lands in the run's persisted
6
- * `detail` blob). Deliberately smaller than {@link MAX_CAPTURED_OUTPUT_CHARS}, which is what the
55
+ * `detail` blob). Deliberately smaller than `MAX_CAPTURED_OUTPUT_CHARS` (`redact.ts`), which is what the
7
56
  * AGENT sees in its repair prompt: the agent needs the full failure to fix it, the operator needs
8
57
  * enough to recognise it, and a chatty build must not inflate every run's stored state.
9
58
  */
@@ -75,88 +124,27 @@ export async function runValidationChecks(cwd, spec, attempt, logger, opts) {
75
124
  return { report, fullTails };
76
125
  }
77
126
  /**
78
- * Run ONE check as `sh -c <command>` in `cwd`, capturing a bounded, secret-scrubbed tail of its
79
- * combined stdout+stderr. The exit code is the verdict — computed here by the harness, never
80
- * self-reported by the model, which is the whole point of a programmatic gate. A watchdog kills
81
- * the process tree on timeout and an aborted run resolves non-zero, so the loop is never blocked.
82
- *
83
- * The child inherits the JOB's environment (`RunOptions.agentEnv` layered over the process env),
84
- * not a mutated global: the harness spawns this itself rather than through the agent, so without
85
- * the explicit merge a native-mode job would run its checks without the private-registry npmrc
86
- * pointer (and against a sibling job's state, had this been staged in `process.env`).
127
+ * Run ONE check through the shared {@link runCapturedCommand} seam and shape it as a check
128
+ * outcome. The exit code is the verdict — computed by the harness, never self-reported by the
129
+ * model, which is the whole point of a programmatic gate.
87
130
  */
88
131
  async function runOneCheck(cwd, check, logger, opts) {
89
- const timeoutMs = validationCommandTimeoutMs();
90
- const startedAt = Date.now();
91
132
  logger.info('validation: running check', { label: check.label });
92
- return new Promise((resolve) => {
93
- let out = '';
94
- let settled = false;
95
- let timedOut = false;
96
- const child = spawn('sh', ['-c', check.command], {
97
- cwd,
98
- detached: spawnDetached,
99
- stdio: ['ignore', 'pipe', 'pipe'],
100
- env: { ...process.env, ...opts.agentEnv },
101
- });
102
- // Keep only the tail; guard against unbounded buffering on a chatty command.
103
- const capture = (chunk) => {
104
- out = (out + chunk.toString('utf8')).slice(-MAX_CAPTURED_OUTPUT_CHARS);
105
- };
106
- child.stdout?.on('data', capture);
107
- child.stderr?.on('data', capture);
108
- const finish = (exitCode) => {
109
- if (settled)
110
- return;
111
- settled = true;
112
- clearTimeout(timer);
113
- opts.signal?.removeEventListener('abort', onAbort);
114
- const trimmed = out.trim();
115
- // Scrub BEFORE truncating: a token straddling the cut would otherwise survive as a
116
- // partial, and the pattern rules need the whole assignment to match.
117
- const scrubbed = trimmed ? redactSecrets(trimmed) : '';
118
- logger.info('validation: check finished', { label: check.label, exitCode });
119
- resolve({
120
- outcome: {
121
- label: check.label,
122
- command: check.command,
123
- exitCode,
124
- passed: exitCode === 0,
125
- ...(scrubbed ? { outputTail: tailFor(scrubbed) } : {}),
126
- durationMs: Date.now() - startedAt,
127
- ...(timedOut ? { timedOut: true } : {}),
128
- },
129
- ...(scrubbed ? { fullTail: scrubbed } : {}),
130
- });
131
- };
132
- const timer = setTimeout(() => {
133
- logger.warn('validation: check timed out', { label: check.label, timeoutMs });
134
- timedOut = true;
135
- killChildProcess(child, undefined, logger);
136
- finish(124); // conventional timeout exit code (a non-zero fail)
137
- }, timeoutMs);
138
- timer.unref?.();
139
- const onAbort = () => {
140
- killChildProcess(child, undefined, logger);
141
- finish(130); // aborted (a non-zero fail)
142
- };
143
- opts.signal?.addEventListener('abort', onAbort, { once: true });
144
- child.on('error', (err) => {
145
- logger.warn('validation: check failed to spawn', {
146
- label: check.label,
147
- error: err instanceof Error ? err.message : String(err),
148
- });
149
- finish(127); // spawn error / command not found (a non-zero fail)
150
- });
151
- child.on('close', (code) => finish(code ?? 1));
133
+ const { fullTail, ...run } = await runCapturedCommand({
134
+ cwd,
135
+ command: check.command,
136
+ timeoutMs: validationCommandTimeoutMs(),
137
+ reportTailChars: VALIDATION_REPORT_TAIL_CHARS,
138
+ logLabel: 'validation',
139
+ logFields: { label: check.label },
140
+ logger,
141
+ opts,
152
142
  });
153
- }
154
- /** Bound an already-scrubbed output tail to what the REPORT carries. */
155
- function tailFor(scrubbed) {
156
- if (scrubbed.length <= VALIDATION_REPORT_TAIL_CHARS)
157
- return scrubbed;
158
- const trimmed = scrubbed.length - VALIDATION_REPORT_TAIL_CHARS;
159
- return `…(${trimmed} earlier chars trimmed)\n${scrubbed.slice(-VALIDATION_REPORT_TAIL_CHARS)}`;
143
+ logger.info('validation: check finished', { label: check.label, exitCode: run.exitCode });
144
+ return {
145
+ outcome: { label: check.label, command: check.command, ...run },
146
+ ...(fullTail ? { fullTail } : {}),
147
+ };
160
148
  }
161
149
  /**
162
150
  * The repair instruction handed to the agent after a failed attempt: the failing commands and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.56.0",
3
+ "version": "1.60.0",
4
4
  "description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -21,13 +21,13 @@
21
21
  "access": "public"
22
22
  },
23
23
  "devDependencies": {
24
- "@hono/node-server": "^2.0.10",
24
+ "@hono/node-server": "^2.0.12",
25
25
  "@types/node": "^26.1.1",
26
- "hono": "^4.12.30",
26
+ "hono": "^4.12.32",
27
27
  "typescript": "7.0.2",
28
28
  "vitest": "^4.1.10",
29
- "@cat-factory/server": "0.149.0",
30
- "@cat-factory/spend": "0.12.83"
29
+ "@cat-factory/server": "0.154.0",
30
+ "@cat-factory/spend": "0.12.90"
31
31
  },
32
32
  "scripts": {
33
33
  "build": "tsc -p tsconfig.json",
package/src/agent.ts CHANGED
@@ -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,6 +996,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
992
996
  callMetrics,
993
997
  validation,
994
998
  validationReport,
999
+ reproductionReport,
995
1000
  effortReport,
996
1001
  } = await runCodingAgent(buildSingleRepoCodingSpec(job, pushBranch), opts)
997
1002
  // Ralph loop: the harness-computed validation verdict, forwarded onto the coding result as
@@ -999,10 +1004,14 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
999
1004
  const ralphVerdict = validation ? { ralphVerdict: validation } : {}
1000
1005
  // The agent's effort self-assessment, spread onto every result path below (mirrors ralphVerdict).
1001
1006
  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 } : {}
1007
+ // The two PRE-PR VERIFICATION reports, spread onto every result path below. The validation one:
1008
+ // on the passing path it is the captured proof the checkout was green when the PR opened; on the
1009
+ // exhausted path it is the evidence behind the failure below. The reproduction one is evidence
1010
+ // on every path it never gates the PR. Each is absent when its phase was not configured.
1011
+ const verificationFields = {
1012
+ ...(validationReport ? { validationReport } : {}),
1013
+ ...(reproductionReport ? { reproductionReport } : {}),
1014
+ }
1006
1015
 
1007
1016
  // Pre-PR validation spent its attempt budget with the checkout still red. FAIL the job — do
1008
1017
  // NOT open a pull request, and do not pretend the push succeeded as a deliverable. The work is
@@ -1022,7 +1031,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
1022
1031
  failureCause: 'agent',
1023
1032
  ...(usage ? { usage } : {}),
1024
1033
  ...(callMetrics ? { callMetrics } : {}),
1025
- ...validationFields,
1034
+ ...verificationFields,
1026
1035
  ...effort,
1027
1036
  }
1028
1037
  }
@@ -1038,7 +1047,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
1038
1047
  ...(usage ? { usage } : {}),
1039
1048
  ...(callMetrics ? { callMetrics } : {}),
1040
1049
  ...ralphVerdict,
1041
- ...validationFields,
1050
+ ...verificationFields,
1042
1051
  ...effort,
1043
1052
  }
1044
1053
  }
@@ -1051,7 +1060,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
1051
1060
  failureCause: 'no-changes',
1052
1061
  ...(usage ? { usage } : {}),
1053
1062
  ...(callMetrics ? { callMetrics } : {}),
1054
- ...validationFields,
1063
+ ...verificationFields,
1055
1064
  ...effort,
1056
1065
  }
1057
1066
  }
@@ -1086,7 +1095,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
1086
1095
  stats,
1087
1096
  ...(usage ? { usage } : {}),
1088
1097
  ...(callMetrics ? { callMetrics } : {}),
1089
- ...validationFields,
1098
+ ...verificationFields,
1090
1099
  ...effort,
1091
1100
  }
1092
1101
  }
@@ -1103,7 +1112,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
1103
1112
  failureCause: 'no-changes',
1104
1113
  ...(usage ? { usage } : {}),
1105
1114
  ...(callMetrics ? { callMetrics } : {}),
1106
- ...validationFields,
1115
+ ...verificationFields,
1107
1116
  ...effort,
1108
1117
  }
1109
1118
  }
@@ -1116,7 +1125,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
1116
1125
  ...(usage ? { usage } : {}),
1117
1126
  ...(callMetrics ? { callMetrics } : {}),
1118
1127
  ...ralphVerdict,
1119
- ...validationFields,
1128
+ ...verificationFields,
1120
1129
  ...effort,
1121
1130
  }
1122
1131
  }
@@ -1128,7 +1137,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
1128
1137
  ...(usage ? { usage } : {}),
1129
1138
  ...(callMetrics ? { callMetrics } : {}),
1130
1139
  ...ralphVerdict,
1131
- ...validationFields,
1140
+ ...verificationFields,
1132
1141
  ...effort,
1133
1142
  }
1134
1143
  }
@@ -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,
@@ -47,6 +48,11 @@ import {
47
48
  type ValidationChecksSpec,
48
49
  type ValidationReport,
49
50
  } from './validation-checks.js'
51
+ import {
52
+ runReproductionLoop,
53
+ type ReproductionReport,
54
+ type ReproductionSpec,
55
+ } from './reproduction-proof.js'
50
56
 
51
57
  // The shared skeleton for the container coding agents that clone a repo, run Pi
52
58
  // against it and push the result on a branch. The implementation (`/run`) and
@@ -117,6 +123,16 @@ export interface CodingAgentSpec extends HarnessAuthFields {
117
123
  * `docs/initiatives/pre-pr-validation.md`.
118
124
  */
119
125
  validationChecks?: ValidationChecksSpec
126
+ /**
127
+ * BUGFIX REPRODUCTION PROOF: the run's declared reproduction command + test files. When set, the
128
+ * harness runs that command against the pre-fix tree AND the tree the PR will open from, feeding
129
+ * a failed verification back to the agent while budget remains, and attaches the verdict to the
130
+ * outcome. Unlike {@link validationChecks} it NEVER gates the pull request — an unproven
131
+ * reproduction is weak evidence, which is a reviewer's call, not a machine's. Set only for a
132
+ * dispatch that opens a PR and whose run carries a declaration. See
133
+ * `docs/initiatives/bugfix-reproduction-proof.md`.
134
+ */
135
+ reproduction?: ReproductionSpec
120
136
  /**
121
137
  * A repo-sourced Claude Skill to make available for this run (a `skill` step, slice 2). Threaded
122
138
  * into {@link runAgentInWorkspace}, which installs it harness-aware: natively under the ISOLATED
@@ -158,6 +174,12 @@ export interface CodingAgentOutcome {
158
174
  * the caller must open no PR and fail the job with this as the evidence.
159
175
  */
160
176
  validationReport?: ValidationReport
177
+ /**
178
+ * The bugfix reproduction proof's LAST attempt (present only when
179
+ * {@link CodingAgentSpec.reproduction} was set). Evidence, never a gate: `inconclusive` is
180
+ * attached to a perfectly successful run and the PR still opens.
181
+ */
182
+ reproductionReport?: ReproductionReport
161
183
  }
162
184
 
163
185
  /**
@@ -323,6 +345,64 @@ export async function runCodingAgent(
323
345
  opts.onPhase?.('agent')
324
346
  logger.info('coding-agent: running agent', { serviceDirectory })
325
347
  let agentRun = await runAgentPass(spec.userPrompt)
348
+ const foldPass = (run: typeof agentRun): void => {
349
+ agentRun = mergeAgentPasses(agentRun, run)
350
+ }
351
+ // The new files the agent left unadded, folded into either loop's repair prompt. Both
352
+ // loops judge state the push will NOT carry unless it is committed — the checks run
353
+ // against the working tree, the proof against committed trees — so an unadded file is
354
+ // exactly the thing to name. A throw degrades to "no warning" inside each loop.
355
+ const listUncommittedNewFiles = (): Promise<string[]> =>
356
+ listUntrackedFiles(workDir, opts.signal)
357
+
358
+ // BUGFIX REPRODUCTION PROOF: run the run's declared reproduction command against the
359
+ // pre-fix tree and the tree the PR will open from, and record whether it was red then
360
+ // green. Runs BEFORE the validation loop below, deliberately: validation is the GATE
361
+ // ("only a green checkout opens a PR"), so it has to stay the last thing that touches the
362
+ // tree — otherwise a reproduction repair round could leave the checkout red behind it and
363
+ // the PR would open anyway. Keyed purely off the job body carrying a spec (no agent-kind
364
+ // switch); absent ⇒ a no-op and the flow below is byte-for-byte what it was.
365
+ const reproduction = spec.reproduction
366
+ let reproductionReport: ReproductionReport | undefined
367
+ if (reproduction && (await producedWork(dir, spec, baseSha, resumed, opts))) {
368
+ opts.onPhase?.('reproduction')
369
+ reproductionReport = await runReproductionLoop({
370
+ dir,
371
+ baseSha,
372
+ // Re-read per attempt: a repair pass commits, so the final tree moves under the loop.
373
+ // `producedWork` has already committed forgotten tracked edits, and each repair round
374
+ // re-commits before the next read.
375
+ resolveFinalSha: async () => {
376
+ await commitTrackedEdits(dir, spec.commitMessage, signal)
377
+ return headCommit(dir, signal)
378
+ },
379
+ ...(serviceDirectory ? { serviceDirectory } : {}),
380
+ spec: reproduction,
381
+ logger,
382
+ opts,
383
+ runAgentPass,
384
+ onAgentPass: foldPass,
385
+ listUncommittedNewFiles,
386
+ // Only a RESUMED run can have a pre-fix tree that already carries work: a fresh run
387
+ // branched off base, so `baseSha` IS base. Wiring the probe unconditionally would buy
388
+ // an always-empty answer for the price of a fetch — and a fresh clone is shallow, so
389
+ // it could not resolve a merge base to answer with anyway. Lazy inside the loop: it
390
+ // only runs if a tree comes back green.
391
+ ...(resumed
392
+ ? {
393
+ listBaseTreeChanges: () =>
394
+ changedFilesSinceBase(
395
+ dir,
396
+ spec.repo.baseBranch,
397
+ spec.ghToken,
398
+ baseSha,
399
+ opts.signal,
400
+ ),
401
+ }
402
+ : {}),
403
+ })
404
+ opts.onPhase?.('agent')
405
+ }
326
406
  // PRE-PR VALIDATION: run the service's configured checks against the checkout and, while
327
407
  // they fail and budget remains, hand the captured output back to the agent and run it
328
408
  // again. Sits BETWEEN the agent and the finalize/push/PR step so a red checkout never
@@ -338,17 +418,16 @@ export async function runCodingAgent(
338
418
  logger,
339
419
  opts,
340
420
  runAgentPass,
341
- onAgentPass: (run) => {
342
- agentRun = mergeAgentPasses(agentRun, run)
343
- },
421
+ onAgentPass: foldPass,
344
422
  // The checks run against the WORKING TREE, but only tracked edits are staged for the
345
423
  // push — so a repair round can go green on a new file the PR would never contain.
346
424
  // Name those files in the next repair prompt so the agent adds them.
347
- listUncommittedNewFiles: () => listUntrackedFiles(workDir, opts.signal),
425
+ listUncommittedNewFiles,
348
426
  })
349
427
  }
350
428
  outcome = await finalizeCodingRun({
351
429
  validationReport,
430
+ reproductionReport,
352
431
  dir,
353
432
  spec,
354
433
  logger,
@@ -499,6 +578,8 @@ async function prepareCodingCheckout(
499
578
  async function finalizeCodingRun(args: {
500
579
  /** The pre-PR validation loop's last attempt, attached to the outcome (absent when unconfigured). */
501
580
  validationReport?: ValidationReport
581
+ /** The reproduction proof's last attempt, attached to the outcome (absent when unconfigured). */
582
+ reproductionReport?: ReproductionReport
502
583
  dir: string
503
584
  spec: CodingAgentSpec
504
585
  logger: Logger
@@ -515,6 +596,7 @@ async function finalizeCodingRun(args: {
515
596
  }): Promise<CodingAgentOutcome> {
516
597
  const {
517
598
  validationReport,
599
+ reproductionReport,
518
600
  dir,
519
601
  spec,
520
602
  logger,
@@ -618,6 +700,9 @@ async function finalizeCodingRun(args: {
618
700
  // reaches the PR-opening caller); attach its verdict for the caller to gate on and for the
619
701
  // backend to record on the step.
620
702
  if (validationReport) outcome.validationReport = validationReport
703
+ // The reproduction proof: attached to EVERY outcome, including a no-op or an `inconclusive`
704
+ // verdict. It is evidence about the change, not a gate on it — see the loop's D6 note.
705
+ if (reproductionReport) outcome.reproductionReport = reproductionReport
621
706
  return outcome
622
707
  }
623
708