@cat-factory/executor-harness 1.58.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.
@@ -0,0 +1,806 @@
1
+ import { mkdtemp, rm } from 'node:fs/promises'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { runCapturedCommand } from './captured-command.js'
5
+ import { addWorktree, checkoutPathsFrom, pathsPresentAtCommit, removeWorktree } from './git.js'
6
+ import type { RunOptions } from './runner.js'
7
+ import type { Logger } from './logger.js'
8
+
9
+ // BUGFIX REPRODUCTION PROOF — the container half (see
10
+ // docs/initiatives/bugfix-reproduction-proof.md).
11
+ //
12
+ // A bugfix pull request claims a change fixes a defect. The verification report already captures
13
+ // the state of the work at the END (CI, pre-PR validation, the tester report); none of it shows
14
+ // the defect ever manifested. This module supplies the missing half: it runs the run's DECLARED
15
+ // reproduction command against TWO trees of the same clone — the pre-fix tree and the tree the PR
16
+ // will open from — and reports both exit codes with their captured output. Only RED-then-GREEN is
17
+ // proof. The verdict is computed here from exit codes, never self-reported by the model (the
18
+ // `repro-test` kind's `outcome` field has always been the model's own claim, which is exactly what
19
+ // this replaces with a captured fact).
20
+ //
21
+ // SYMMETRY IS THE SAFETY PROPERTY (the initiative's D4). A non-zero exit at the base proves
22
+ // nothing on its own: a missing toolchain, an uninstalled dependency, or an unrelated pre-existing
23
+ // breakage all produce one. Because both phases run in freshly-created worktrees with the SAME
24
+ // setup command and the SAME declared test files, an environmental defect fails BOTH — and
25
+ // red-then-red is reported as `inconclusive`, never as proof. What symmetry does NOT catch is
26
+ // red-for-the-wrong-REASON (a load error at base the fix incidentally resolves); both captured
27
+ // outputs ride the report precisely so a human can see why it was red. Do not let a later
28
+ // iteration quietly claim more than that.
29
+ //
30
+ // Generic machinery keyed purely off the JOB BODY carrying `reproduction` — there is deliberately
31
+ // no agent-kind switch anywhere in the harness. Everything is PER-JOB by construction: the
32
+ // worktree root is a fresh `mkdtemp`, and the commands, cwd and environment all arrive as
33
+ // arguments. Nothing is read from or written to `process.env` or `HOME`, because the local NATIVE
34
+ // transport serves every concurrent job from ONE host process — a shared worktree root would let
35
+ // two bugfix runs clobber each other's base trees, and the container path would never catch it
36
+ // (`reproduction-proof.concurrency.test.ts` pins this).
37
+
38
+ /** The reproduction spec as it arrives on the job body. */
39
+ export interface ReproductionSpec {
40
+ /** The command that runs EXACTLY the declared reproduction test(s), as `sh -c` in the checkout. */
41
+ command: string
42
+ /** The test file(s) that constitute the reproduction (repo-relative, already sanitized). */
43
+ testPaths: string[]
44
+ /**
45
+ * How many declared paths the ENGINE dropped while resolving this spec (over the cap, absolute,
46
+ * traversing, over-long). Echoed onto the report unchanged: a dropped path can leave the base
47
+ * tree without the reproduction, which greens it and reads as "the test does not capture the
48
+ * defect", so the omission has to travel with the verdict rather than being implied by it.
49
+ */
50
+ omittedTestPaths?: number
51
+ /** Optional command that makes a FRESH worktree runnable (a dependency install). */
52
+ setupCommand?: string
53
+ /** How many agent+verify rounds the loop may run before it settles for `inconclusive`. */
54
+ maxAttempts: number
55
+ }
56
+
57
+ /** One tree's run of the reproduction command. */
58
+ export interface ReproductionPhaseOutcome {
59
+ /** Exit code (0 = pass); 124 on watchdog timeout, 127 on spawn failure, 130 on abort. */
60
+ exitCode: number
61
+ passed: boolean
62
+ /** Bounded, secret-scrubbed tail of the command's combined stdout+stderr. */
63
+ outputTail?: string
64
+ durationMs?: number
65
+ timedOut?: boolean
66
+ /** Set when THIS phase's setup command failed, so the tree never ran the check meaningfully. */
67
+ setupFailed?: boolean
68
+ }
69
+
70
+ /** The harness-computed reproduction report — what crosses the wire onto the step. */
71
+ export interface ReproductionReport {
72
+ /**
73
+ * `reproduced` — RED on the pre-fix tree, GREEN on the final tree (the only shape that is
74
+ * proof); `inconclusive` — every other shape, recorded honestly rather than dressed up. The
75
+ * harness never emits `declared_infeasible`: a conceded run dispatches no proof at all, so the
76
+ * ENGINE mints that one from the declaration itself.
77
+ */
78
+ status: 'reproduced' | 'inconclusive'
79
+ command: string
80
+ testPaths: string[]
81
+ omittedTestPaths?: number
82
+ base?: ReproductionPhaseOutcome
83
+ /** Absent when the base run settled the verdict (a green or un-runnable base). */
84
+ final?: ReproductionPhaseOutcome
85
+ attempts: number
86
+ maxAttempts: number
87
+ /** For `inconclusive`: which shape was observed, in one line, for the report and the step card. */
88
+ note?: string
89
+ at: number
90
+ }
91
+
92
+ /**
93
+ * Per-phase output kept on the REPORT (what crosses the wire and lands in the run's persisted
94
+ * `detail` blob). Deliberately smaller than `MAX_CAPTURED_OUTPUT_CHARS` (`redact.ts`), which is
95
+ * what the AGENT sees in its repair prompt — the same split, for the same reasons, as the pre-PR
96
+ * validation report's tail (`validation-checks.ts`).
97
+ */
98
+ export const REPRODUCTION_REPORT_TAIL_CHARS = 4_000
99
+
100
+ /**
101
+ * The ceiling the harness clamps a body-supplied `reproduction.maxAttempts` to, and the default it
102
+ * applies when the body omits one.
103
+ *
104
+ * DELIBERATE DUPLICATES of `REPRODUCTION_DEFAULT_MAX_ATTEMPTS` in `@cat-factory/contracts` (and of
105
+ * the validation loop's own ceiling) — the published image takes no schema dependency, so the
106
+ * harness cannot import them. Keep them in step: a harness clamping to a DIFFERENT ceiling would
107
+ * silently cap a budget the engine was allowed to send, with nothing to flag the mismatch.
108
+ */
109
+ export const REPRODUCTION_DEFAULT_MAX_ATTEMPTS = 3
110
+ export const REPRODUCTION_MAX_ATTEMPTS_CEILING = 10
111
+
112
+ /**
113
+ * The per-command watchdog: the longest a single setup or check command may run before it is
114
+ * killed and treated as a failure, so one hung test command cannot wedge a run. Overridable via
115
+ * env for tests; defaults to 15 minutes, matching the validation loop's.
116
+ */
117
+ export function reproductionCommandTimeoutMs(): number {
118
+ const n = Number(process.env.REPRODUCTION_COMMAND_TIMEOUT_MS)
119
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 15 * 60_000
120
+ }
121
+
122
+ /**
123
+ * How often the proof feeds the run's inactivity watchdog. Well under the harness's own
124
+ * `JOB_INACTIVITY_MS` (default 10 min) so a slow install-plus-test in each of two worktrees can
125
+ * never look wedged. This is NOT optional: the job-level watchdog is TIGHTER than one command's
126
+ * own ({@link reproductionCommandTimeoutMs}, 15 min), and the harness spawns these itself rather
127
+ * than through the agent, so they emit no activity of their own — without the heartbeat a
128
+ * legitimately slow proof aborts the entire run as "inactivity" and the per-command timeout is
129
+ * unreachable at stock settings.
130
+ */
131
+ export function reproductionHeartbeatMs(): number {
132
+ const n = Number(process.env.REPRODUCTION_HEARTBEAT_MS)
133
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 30_000
134
+ }
135
+
136
+ /**
137
+ * The ceiling on the WHOLE proof phase — every attempt, both trees, setup included. Overridable
138
+ * via env; defaults to 45 minutes.
139
+ *
140
+ * The per-command watchdog bounds one command, not the phase, and the phase multiplies: a spent
141
+ * budget is `maxAttempts` × two trees × (setup + check), each of which may legitimately run for
142
+ * {@link reproductionCommandTimeoutMs}. At stock settings that is hours of container time spent
143
+ * BEFORE the pre-PR validation loop has run its own rounds, and nothing else stops it — the
144
+ * heartbeat deliberately keeps the job-level inactivity watchdog from firing, which is exactly
145
+ * what removes the accidental backstop the phase would otherwise have had.
146
+ *
147
+ * Enforced at PHASE boundaries (before each tree's run, and before each repair round) rather than
148
+ * mid-command: a command already carries its own watchdog, so the real bound is this budget plus
149
+ * at most one command's timeout. Exceeding it settles `inconclusive` with a note saying so —
150
+ * never a run failure, exactly like every other unproven shape.
151
+ */
152
+ export function reproductionTotalBudgetMs(): number {
153
+ const n = Number(process.env.REPRODUCTION_TOTAL_BUDGET_MS)
154
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 45 * 60_000
155
+ }
156
+
157
+ /**
158
+ * Parse the job body's `reproduction` envelope, or `undefined` for "no proof on this job".
159
+ *
160
+ * Lenient in exactly one direction: anything malformed yields `undefined`, so the run behaves
161
+ * byte-for-byte as it did before this feature existed. A body that names no command has nothing
162
+ * to run, and inventing one would manufacture a verdict. Test paths are re-checked here even
163
+ * though the engine already sanitized them — this is the harness's own trust boundary, and these
164
+ * paths are handed to `git checkout` against a worktree.
165
+ */
166
+ export function parseReproductionSpec(value: unknown): ReproductionSpec | undefined {
167
+ if (typeof value !== 'object' || value === null) return undefined
168
+ const o = value as Record<string, unknown>
169
+ const command = typeof o.command === 'string' ? o.command.trim() : ''
170
+ if (command === '') return undefined
171
+ const setupCommand = typeof o.setupCommand === 'string' ? o.setupCommand.trim() : ''
172
+ const declared = Array.isArray(o.testPaths) ? o.testPaths : []
173
+ const testPaths = declared
174
+ .filter((p): p is string => typeof p === 'string')
175
+ .map((p) => p.trim().replace(/\\/g, '/'))
176
+ .filter((p) => isSafeTestPath(p))
177
+ const omitted =
178
+ typeof o.omittedTestPaths === 'number' && o.omittedTestPaths > 0
179
+ ? Math.floor(o.omittedTestPaths)
180
+ : 0
181
+ // Anything this parse itself refused is an omission too — the report must not describe a
182
+ // pre-fix tree rebuilt from a shorter list than the one the count claims.
183
+ const droppedHere = declared.length - testPaths.length
184
+ const omittedTestPaths = omitted + Math.max(0, droppedHere)
185
+ const parsedAttempts =
186
+ typeof o.maxAttempts === 'number' && Number.isFinite(o.maxAttempts) && o.maxAttempts > 0
187
+ ? Math.floor(o.maxAttempts)
188
+ : undefined
189
+ return {
190
+ command,
191
+ testPaths,
192
+ ...(omittedTestPaths > 0 ? { omittedTestPaths } : {}),
193
+ ...(setupCommand ? { setupCommand } : {}),
194
+ maxAttempts: Math.min(
195
+ parsedAttempts ?? REPRODUCTION_DEFAULT_MAX_ATTEMPTS,
196
+ REPRODUCTION_MAX_ATTEMPTS_CEILING,
197
+ ),
198
+ }
199
+ }
200
+
201
+ /**
202
+ * A repo-relative path with no traversal, no root/drive anchor, no leading dash, and no git
203
+ * PATHSPEC MAGIC. Must stay in step with the engine's `isSafeTestPath`
204
+ * (`orchestration/src/modules/execution/reproductionProof.logic.ts`) — this is the harness's own
205
+ * trust boundary, not a duplicate of the engine's for its own sake.
206
+ *
207
+ * The magic exclusion is the load-bearing part and is easy to miss: these strings are handed to
208
+ * `git checkout <finalSha> -- <path>`, where `--` stops a path being read as a REVISION but does
209
+ * nothing about pathspec syntax. `:(glob)**`, `*`, `foo/*.ts` are all valid pathspecs, so a
210
+ * model-authored path containing one would apply far more of the final tree onto the pre-fix
211
+ * worktree than the declared reproduction — dragging the fix across and GREENING the base. That
212
+ * lands as an `inconclusive` reading "the check passed before your change", which is the exact
213
+ * false diagnosis this feature exists to remove, and it is under the model's control. A dropped
214
+ * path is counted as an omission, so the report says the pre-fix tree was rebuilt from an
215
+ * incomplete set rather than implying a clean verdict.
216
+ */
217
+ function isSafeTestPath(path: string): boolean {
218
+ if (path.length === 0 || path.length > REPRODUCTION_MAX_TEST_PATH_CHARS) return false
219
+ if (path.startsWith('/') || path.startsWith('~') || path.startsWith('-')) return false
220
+ // A leading `:` opens pathspec magic (`:(glob)`, `:(exclude)`, `:/`), and the wildcard
221
+ // metacharacters make any path a glob wherever they appear.
222
+ if (path.startsWith(':') || /[*?[\]]/.test(path)) return false
223
+ if (/^[a-zA-Z]:\//.test(path)) return false
224
+ return !path.split('/').includes('..')
225
+ }
226
+
227
+ /**
228
+ * Longest accepted declared test path. A DELIBERATE DUPLICATE of
229
+ * `REPRODUCTION_MAX_TEST_PATH_CHARS` in `@cat-factory/contracts` (the published image takes no
230
+ * schema dependency) — keep the two in step.
231
+ */
232
+ const REPRODUCTION_MAX_TEST_PATH_CHARS = 400
233
+
234
+ /** Everything one proof attempt needs. Every field is per-job; nothing is read from a global. */
235
+ export interface ReproductionProofArgs {
236
+ /** The agent's own checkout — the worktrees' parent clone. Never modified by the proof. */
237
+ dir: string
238
+ /** The pre-fix tree: the branch tip captured BEFORE this pass ran (never `HEAD~1`/a base ref). */
239
+ baseSha: string
240
+ /** The tree the pull request will open from (the checkout's HEAD, after committing). */
241
+ finalSha: string
242
+ /** In a monorepo, the service subdirectory the commands run in (relative to each worktree). */
243
+ serviceDirectory?: string
244
+ spec: ReproductionSpec
245
+ attempt: number
246
+ logger: Logger
247
+ opts: RunOptions
248
+ /** Epoch ms after which no further phase may START (see {@link reproductionTotalBudgetMs}). */
249
+ deadlineAt?: number
250
+ /**
251
+ * The files the PRE-FIX tree already changes relative to the PR base branch, or `undefined`
252
+ * when that could not be determined. Consulted ONLY when the base tree comes back green — see
253
+ * {@link priorWorkAtBase} for why a green base is otherwise misdiagnosed.
254
+ */
255
+ listBaseTreeChanges?: () => Promise<string[] | undefined>
256
+ }
257
+
258
+ /** One proof attempt: the report, the full tails for a repair prompt, and whether to repair. */
259
+ export interface ReproductionAttempt {
260
+ report: ReproductionReport
261
+ fullTails: Map<string, string>
262
+ /**
263
+ * Whether spending an agent repair round on this outcome can plausibly change it. An explicit
264
+ * OUTPUT of the attempt rather than something re-derived from the report, because the two
265
+ * unrepairable shapes are known only here: a broken environment (the agent cannot change a
266
+ * setup command it did not declare) and a pre-fix tree that already carries this run's own
267
+ * earlier work (nothing is wrong with the test, so "make it exercise the defect" is bad advice).
268
+ */
269
+ repairable: boolean
270
+ }
271
+
272
+ /**
273
+ * Run ONE proof attempt: create both worktrees, run the declared check in each, and compute the
274
+ * verdict from the two exit codes.
275
+ *
276
+ * The base worktree is built at `baseSha` and then has the DECLARED test files checked out of
277
+ * `finalSha` on top — the paths only, never a whole-tree checkout, which would drag the fix across
278
+ * and green the base. In the RESUMED case (a prior `repro-test` step already pushed the failing
279
+ * test onto the shared work branch, so `baseSha` already carries it) that overlay is a no-op by
280
+ * construction. Doing it unconditionally is what guarantees BOTH trees run the byte-identical
281
+ * check, which is the claim the report makes.
282
+ *
283
+ * Keeps the run's inactivity watchdog fed for the whole attempt (see
284
+ * {@link reproductionHeartbeatMs}) and always tears both worktrees down, including on a throw.
285
+ */
286
+ export async function runReproductionProof(
287
+ args: ReproductionProofArgs,
288
+ ): Promise<ReproductionAttempt> {
289
+ const { dir, baseSha, finalSha, serviceDirectory, spec, attempt, logger, opts, deadlineAt } = args
290
+ const fullTails = new Map<string, string>()
291
+ const heartbeat = setInterval(() => opts.onActivity?.(), reproductionHeartbeatMs())
292
+ heartbeat.unref?.()
293
+ // A per-job temp root: two concurrent jobs on the ONE local-native host process get disjoint
294
+ // worktree paths, so neither can see (or clobber) the other's base tree.
295
+ const root = await mkdtemp(join(tmpdir(), 'cat-repro-'))
296
+ const baseDir = join(root, 'base')
297
+ const finalDir = join(root, 'final')
298
+ /** Every return below is one of these — the invariant fields are stated once. */
299
+ const settle = (
300
+ fields: Partial<ReproductionReport>,
301
+ repairable: boolean,
302
+ ): ReproductionAttempt => ({
303
+ report: {
304
+ status: 'inconclusive',
305
+ command: spec.command,
306
+ testPaths: [...spec.testPaths],
307
+ ...(spec.omittedTestPaths ? { omittedTestPaths: spec.omittedTestPaths } : {}),
308
+ attempts: attempt,
309
+ maxAttempts: spec.maxAttempts,
310
+ ...fields,
311
+ at: Date.now(),
312
+ },
313
+ fullTails,
314
+ repairable,
315
+ })
316
+ try {
317
+ // The proof runs against COMMITTED trees, so a declared test the agent never `git add`ed is
318
+ // invisible to it — and equally invisible to the push, which is the more important half to
319
+ // tell the agent about. Report that rather than a verdict computed without the reproduction.
320
+ const present = await pathsPresentAtCommit(dir, finalSha, spec.testPaths, opts.signal)
321
+ const missing = spec.testPaths.filter((p) => !present.includes(p))
322
+ if (spec.testPaths.length > 0 && present.length === 0) {
323
+ logger.warn('reproduction: no declared test file is committed', { missing })
324
+ return settle(
325
+ {
326
+ note: `None of the declared reproduction test files are committed on the branch (${missing.join(', ')}), so the pre-fix tree could not be reconstructed.`,
327
+ },
328
+ true,
329
+ )
330
+ }
331
+ if (budgetSpent(deadlineAt)) return settle({ note: BUDGET_NOTE }, false)
332
+
333
+ await addWorktree(dir, baseDir, baseSha, opts.signal)
334
+ await checkoutPathsFrom(baseDir, finalSha, present, opts.signal)
335
+ logger.info('reproduction: base worktree ready', {
336
+ attempt,
337
+ appliedTestPaths: present.length,
338
+ missingTestPaths: missing.length,
339
+ })
340
+ const baseRun = await runPhase({
341
+ phase: 'base',
342
+ worktree: baseDir,
343
+ ...(serviceDirectory ? { serviceDirectory } : {}),
344
+ spec,
345
+ logger,
346
+ opts,
347
+ })
348
+ const base = baseRun.outcome
349
+ if (baseRun.fullTail) fullTails.set('base', baseRun.fullTail)
350
+
351
+ // A green base settles it: `reproduced` requires a RED base, so running the final tree could
352
+ // only confirm what is already not proof — and each phase costs a full setup + test run. The
353
+ // report's `final` is documented as absent in exactly this case.
354
+ if (base.passed || base.setupFailed) {
355
+ // A GREEN base is the one outcome whose meaning depends on what the pre-fix tree actually
356
+ // is, so establish that before naming a cause — see {@link priorWorkAtBase}.
357
+ const priorWork = base.passed ? await priorWorkAtBase(args, logger) : undefined
358
+ return settle(
359
+ { base, note: noteFor(base, undefined, missing, priorWork) },
360
+ // A setup failure and a base that already carries the fix are both unrepairable, for the
361
+ // same underlying reason: the agent is not what is wrong, so a repair round can only make
362
+ // things worse (here, by inviting it to weaken a reproduction test that is fine).
363
+ !base.setupFailed && !priorWork?.length,
364
+ )
365
+ }
366
+ if (budgetSpent(deadlineAt)) return settle({ base, note: BUDGET_NOTE }, false)
367
+
368
+ await addWorktree(dir, finalDir, finalSha, opts.signal)
369
+ const finalRun = await runPhase({
370
+ phase: 'final',
371
+ worktree: finalDir,
372
+ ...(serviceDirectory ? { serviceDirectory } : {}),
373
+ spec,
374
+ logger,
375
+ opts,
376
+ })
377
+ const final = finalRun.outcome
378
+ if (finalRun.fullTail) fullTails.set('final', finalRun.fullTail)
379
+ const reproduced = final.passed && !final.setupFailed
380
+ return {
381
+ report: {
382
+ status: reproduced ? 'reproduced' : 'inconclusive',
383
+ command: spec.command,
384
+ testPaths: [...spec.testPaths],
385
+ ...(spec.omittedTestPaths ? { omittedTestPaths: spec.omittedTestPaths } : {}),
386
+ base,
387
+ final,
388
+ attempts: attempt,
389
+ maxAttempts: spec.maxAttempts,
390
+ ...(reproduced && missing.length === 0
391
+ ? {}
392
+ : { note: noteFor(base, final, missing, undefined) }),
393
+ at: Date.now(),
394
+ },
395
+ fullTails,
396
+ // A timed-out or un-runnable FINAL tree is not something a repair pass fixes: the agent is
397
+ // handed a watchdog kill or a broken environment, not a failing assertion it can act on,
398
+ // and each round costs another two full tree runs to learn the same thing.
399
+ repairable: !reproduced && !final.setupFailed && !final.timedOut && !base.timedOut,
400
+ }
401
+ } finally {
402
+ clearInterval(heartbeat)
403
+ // Teardown is best-effort throughout: a proof that ran must never be lost to a failed rmdir.
404
+ await removeWorktree(dir, baseDir, opts.signal)
405
+ await removeWorktree(dir, finalDir, opts.signal)
406
+ await rm(root, { recursive: true, force: true }).catch(() => {})
407
+ }
408
+ }
409
+
410
+ /** Whether the whole-phase budget is spent (see {@link reproductionTotalBudgetMs}). */
411
+ function budgetSpent(deadlineAt: number | undefined): boolean {
412
+ return deadlineAt !== undefined && Date.now() >= deadlineAt
413
+ }
414
+
415
+ const BUDGET_NOTE =
416
+ 'The reproduction proof ran out of its time budget before it could finish, so no verdict was reached. This is a cost limit, not a statement about the fix.'
417
+
418
+ /**
419
+ * The non-test changes the PRE-FIX tree already carries relative to the PR base branch, when that
420
+ * can be determined (`undefined` when it cannot, and only ever consulted for a GREEN base).
421
+ *
422
+ * This is what makes a green base interpretable. The pre-fix tree is `baseSha` — the work branch
423
+ * as it stood when this pass STARTED — which in the designed bugfix flow is the reproduction
424
+ * step's test commit and nothing else, so green there genuinely means "the test does not
425
+ * demonstrate the defect". But a coder container that is evicted mid-run has already committed
426
+ * and checkpoint-pushed its work, and the re-dispatch RESUMES that branch: `baseSha` then carries
427
+ * this same step's own partial fix, the check legitimately passes on it, and the old diagnosis
428
+ * stated as fact that the agent's test was worthless — then spent the whole repair budget telling
429
+ * it to "make the test actually exercise the defect", which invites weakening a perfectly good
430
+ * reproduction. Non-test changes at the base are the signal that separates the two.
431
+ *
432
+ * Best-effort by construction: the probe needs the base branch and a reachable merge base, and a
433
+ * fresh clone is shallow. An unavailable answer degrades to the original diagnosis rather than
434
+ * suppressing one.
435
+ */
436
+ async function priorWorkAtBase(
437
+ args: ReproductionProofArgs,
438
+ logger: Logger,
439
+ ): Promise<string[] | undefined> {
440
+ if (!args.listBaseTreeChanges) return undefined
441
+ let changed: string[] | undefined
442
+ try {
443
+ changed = await args.listBaseTreeChanges()
444
+ } catch (error) {
445
+ logger.warn('reproduction: could not inspect the pre-fix tree’s provenance', {
446
+ error: error instanceof Error ? error.message : String(error),
447
+ })
448
+ return undefined
449
+ }
450
+ if (!changed) return undefined
451
+ const declared = new Set(args.spec.testPaths)
452
+ const priorWork = changed.filter((p) => !declared.has(p))
453
+ if (priorWork.length > 0) {
454
+ logger.info('reproduction: the pre-fix tree already carries non-test work', {
455
+ count: priorWork.length,
456
+ files: priorWork.slice(0, 10),
457
+ })
458
+ }
459
+ return priorWork
460
+ }
461
+
462
+ /**
463
+ * One line naming the shape that was observed, for the report and the step card. Every
464
+ * non-`reproduced` outcome gets one: a bare `inconclusive` with no explanation is indistinguishable
465
+ * from a rendering bug, which is how "nobody tried" creeps back in through the door this feature
466
+ * closed.
467
+ *
468
+ * `priorWork` (see {@link priorWorkAtBase}) is what stops the green-base line asserting a cause it
469
+ * cannot know. Nothing here ever states more than was measured.
470
+ */
471
+ function noteFor(
472
+ base: ReproductionPhaseOutcome,
473
+ final: ReproductionPhaseOutcome | undefined,
474
+ missing: readonly string[],
475
+ priorWork: readonly string[] | undefined,
476
+ ): string {
477
+ const incomplete = missing.length
478
+ ? ` Declared test file(s) not committed on the branch and therefore not part of the check: ${missing.join(', ')}.`
479
+ : ''
480
+ if (base.setupFailed) {
481
+ return `The setup command failed in the pre-fix worktree (exit ${base.exitCode}), so neither tree could be checked. This is an environment problem, not a verdict about the fix.${incomplete}`
482
+ }
483
+ if (base.passed) {
484
+ if (priorWork?.length) {
485
+ const shown = priorWork.slice(0, 5).join(', ')
486
+ const more = priorWork.length > 5 ? `, +${priorWork.length - 5} more` : ''
487
+ return `The declared check PASSED on the pre-fix tree, but that tree ALREADY carries non-test work committed on this branch (${shown}${more}) — most likely an earlier, interrupted pass of this same step. So this says nothing about whether the test demonstrates the defect, and no fix-free tree was available to check it against.${incomplete}`
488
+ }
489
+ return `The declared check PASSED on the pre-fix tree, so it does not demonstrate the defect.${incomplete}`
490
+ }
491
+ if (!final) {
492
+ return `The pre-fix tree was red but the final tree was never checked.${incomplete}`
493
+ }
494
+ if (final.setupFailed) {
495
+ return `The pre-fix tree was red (exit ${base.exitCode}), but the setup command failed in the final worktree (exit ${final.exitCode}), so the fix could not be checked.${incomplete}`
496
+ }
497
+ if (!final.passed) {
498
+ // Identical failures on two trees that differ only by the fix are far more often one
499
+ // environment failing both ways — a missing toolchain, an absent dependency, a collection
500
+ // error — than a fix that does nothing. Say which reading the evidence favours instead of
501
+ // offering both with equal weight and leaving the reviewer to guess.
502
+ if (base.timedOut && final.timedOut) {
503
+ return `The declared check TIMED OUT on both the pre-fix tree and the final tree, so neither run reached a verdict. The command is too slow for the proof's watchdog, or it hangs — either way this says nothing about the fix.${incomplete}`
504
+ }
505
+ const identical = base.exitCode === final.exitCode
506
+ const reading = identical
507
+ ? `Both trees failed the SAME way (exit ${base.exitCode}), which usually means the check never ran meaningfully in either — a missing dependency or setup step — rather than that the fix does nothing.`
508
+ : 'The change does not make the check pass.'
509
+ return `The declared check FAILED on both the pre-fix tree (exit ${base.exitCode}) and the final tree (exit ${final.exitCode}). ${reading}${incomplete}`
510
+ }
511
+ return `The reproduction was demonstrated (red at the pre-fix tree, green at the final tree).${incomplete}`
512
+ }
513
+
514
+ /**
515
+ * Run one tree's phase: the optional setup command, then the declared check, both in the
516
+ * worktree (offset by the monorepo service directory when the run has one).
517
+ *
518
+ * A failed setup short-circuits the check and is flagged `setupFailed`, so a broken environment is
519
+ * reported as such instead of masquerading as a red tree. The setup command runs in BOTH
520
+ * worktrees or neither — an asymmetric setup is exactly how a false `reproduced` is manufactured.
521
+ */
522
+ async function runPhase(args: {
523
+ phase: 'base' | 'final'
524
+ worktree: string
525
+ serviceDirectory?: string
526
+ spec: ReproductionSpec
527
+ logger: Logger
528
+ opts: RunOptions
529
+ }): Promise<{ outcome: ReproductionPhaseOutcome; fullTail?: string }> {
530
+ const { phase, worktree, serviceDirectory, spec, logger, opts } = args
531
+ const cwd = serviceDirectory ? join(worktree, serviceDirectory) : worktree
532
+ if (spec.setupCommand) {
533
+ logger.info('reproduction: running setup', { phase })
534
+ const setup = await runOneCommand(cwd, spec.setupCommand, phase, logger, opts)
535
+ if (!setup.outcome.passed) {
536
+ logger.warn('reproduction: setup failed', { phase, exitCode: setup.outcome.exitCode })
537
+ return {
538
+ outcome: { ...setup.outcome, setupFailed: true },
539
+ ...(setup.fullTail ? { fullTail: setup.fullTail } : {}),
540
+ }
541
+ }
542
+ }
543
+ logger.info('reproduction: running declared check', { phase })
544
+ const check = await runOneCommand(cwd, spec.command, phase, logger, opts)
545
+ logger.info('reproduction: phase finished', { phase, exitCode: check.outcome.exitCode })
546
+ return { outcome: check.outcome, ...(check.fullTail ? { fullTail: check.fullTail } : {}) }
547
+ }
548
+
549
+ /**
550
+ * Run ONE of the phase's commands through the shared {@link runCapturedCommand} seam and shape it
551
+ * as a phase outcome. The exit code is the verdict — computed by the harness, never self-reported
552
+ * by the model, which is what this whole feature replaces.
553
+ */
554
+ async function runOneCommand(
555
+ cwd: string,
556
+ command: string,
557
+ phase: 'base' | 'final',
558
+ logger: Logger,
559
+ opts: RunOptions,
560
+ ): Promise<{ outcome: ReproductionPhaseOutcome; fullTail?: string }> {
561
+ const { fullTail, ...run } = await runCapturedCommand({
562
+ cwd,
563
+ command,
564
+ timeoutMs: reproductionCommandTimeoutMs(),
565
+ reportTailChars: REPRODUCTION_REPORT_TAIL_CHARS,
566
+ logLabel: 'reproduction',
567
+ logFields: { phase },
568
+ logger,
569
+ opts,
570
+ })
571
+ return { outcome: run, ...(fullTail ? { fullTail } : {}) }
572
+ }
573
+
574
+ /**
575
+ * The repair instruction handed to the agent after a failed verification: which tree behaved how,
576
+ * the captured output, and an explicit statement of the exit condition. The FULL captured tail is
577
+ * used here (not the report's smaller bound) — the agent needs the whole failure to act on it, and
578
+ * this text never leaves the container.
579
+ *
580
+ * Deliberately prescriptive about scope, for the same reason the validation loop's prompt is: a
581
+ * loop that lets the agent "succeed" by weakening the reproduction is worse than no loop at all,
582
+ * because it launders an unverified claim into a captured "fact" — the exact failure mode this
583
+ * whole feature exists to remove.
584
+ */
585
+ export function buildReproductionRepairPrompt(
586
+ report: ReproductionReport,
587
+ fullTails: Map<string, string>,
588
+ /**
589
+ * New files the agent created but never `git add`ed, if the caller can tell. The proof runs
590
+ * against COMMITTED trees, so an unadded reproduction test is invisible to it — and to the push.
591
+ */
592
+ untrackedFiles: string[] = [],
593
+ ): string {
594
+ const remaining = report.maxAttempts - report.attempts
595
+ const diagnosis = report.base?.setupFailed
596
+ ? 'The setup command failed before either tree could be checked.'
597
+ : !report.base
598
+ ? 'The reproduction test files are not committed, so the pre-fix tree could not be reconstructed.'
599
+ : report.base.passed
600
+ ? 'The check PASSED on the pre-fix tree — the code WITHOUT your change. A test that passes before the fix does not demonstrate the bug, so it proves nothing about what you changed.'
601
+ : report.final && !report.final.passed
602
+ ? 'The check FAILED on the pre-fix tree (good — it demonstrates the bug) but ALSO on the final tree, which includes your change. Your fix does not make the reproduction pass.'
603
+ : 'The reproduction could not be demonstrated.'
604
+ const blocks = (['base', 'final'] as const)
605
+ .map((phase) => {
606
+ const outcome = phase === 'base' ? report.base : report.final
607
+ if (!outcome) return ''
608
+ const body = fullTails.get(phase) ?? outcome.outputTail ?? '(no output captured)'
609
+ const label =
610
+ phase === 'base' ? 'Pre-fix tree (without your change)' : 'Final tree (with your change)'
611
+ const reason = outcome.timedOut
612
+ ? `timed out after ${Math.round((outcome.durationMs ?? 0) / 1000)}s`
613
+ : `exited ${outcome.exitCode}`
614
+ return `### ${label} — ${reason}\n\n\`\`\`\n$ ${report.command}\n${body}\n\`\`\``
615
+ })
616
+ .filter((b) => b !== '')
617
+ .join('\n\n')
618
+ const untracked = untrackedFiles.length
619
+ ? [
620
+ '',
621
+ '## Uncommitted new files',
622
+ '',
623
+ 'These files exist in your checkout but were never added to git, so they are not on the',
624
+ 'branch — and the reproduction is checked against committed trees, so they took no part in',
625
+ 'it. `git add` each one you meant to keep (or delete it):',
626
+ '',
627
+ ...untrackedFiles.map((f) => `- ${f}`),
628
+ ]
629
+ : []
630
+ return [
631
+ 'Your change is being checked for REPRODUCTION PROOF: the declared reproduction command is run',
632
+ 'against the tree WITHOUT your change and again against the tree WITH it. It has to fail on the',
633
+ 'first and pass on the second. It did not.',
634
+ '',
635
+ diagnosis,
636
+ '',
637
+ ...(blocks ? [blocks, ''] : []),
638
+ ...untracked,
639
+ '',
640
+ '## How this is judged',
641
+ '',
642
+ `The command \`${report.command}\` is re-run against both trees when you stop. The proof succeeds`,
643
+ 'only when it fails without your change and passes with it. You have',
644
+ `${remaining} attempt(s) left; after that the pull request still opens, but it will state that the`,
645
+ 'reproduction could not be demonstrated.',
646
+ '',
647
+ '## Rules',
648
+ '',
649
+ '- Do NOT weaken, skip, delete, or relax the reproduction test to make this pass. A proof',
650
+ ' obtained that way is a failed task — it is precisely the unverified claim this check exists',
651
+ ' to catch.',
652
+ '- If the test passes without your change, make it actually exercise the defect: assert on the',
653
+ ' buggy behaviour itself, not on something incidental.',
654
+ '- If the test fails with your change too, fix the underlying defect rather than the test.',
655
+ '- Do not revert your earlier work; build on it.',
656
+ '- Commit your work, and `git add` any NEW file you create — only changes to files already',
657
+ ' tracked by git are staged for you.',
658
+ ].join('\n')
659
+ }
660
+
661
+ /**
662
+ * The reproduction-proof LOOP: verify, and while the verification fails, is repairable and budget
663
+ * remains, hand the captured output back to the agent as its next instruction and verify again.
664
+ * Returns the LAST attempt's report.
665
+ *
666
+ * A failed verification is a REPAIR, never a run failure (the initiative's D6). Exhausting the
667
+ * budget degrades to `inconclusive` and the caller opens the pull request anyway — deliberately a
668
+ * different disposition from the pre-PR validation loop, which opens nothing. A red validation
669
+ * check means the WORK is broken, so refusing the PR is right; a reproduction that could not be
670
+ * demonstrated means the EVIDENCE is weak, which is a reviewer's call, not a machine's, and
671
+ * failing the run would throw away a fix that may well be correct. The report says plainly what
672
+ * was and was not proven.
673
+ *
674
+ * Every settled attempt — including the ones that never ran a tree — is published on the job view
675
+ * (a fresh `at` per publish, which the engine's change detection relies on), so the loop is
676
+ * observable while it runs; `onAgentPass` lets the caller fold each repair pass's
677
+ * stats/usage/telemetry into the run's totals.
678
+ *
679
+ * The loop is bounded twice over: by `maxAttempts` rounds, and by the wall-clock
680
+ * {@link reproductionTotalBudgetMs} — attempts multiply two full tree runs each, and the phase's
681
+ * own heartbeat deliberately stops the job-level inactivity watchdog from ever cutting it short.
682
+ */
683
+ export async function runReproductionLoop<TRun>(args: {
684
+ dir: string
685
+ baseSha: string
686
+ /** Re-read before every attempt: a repair pass commits, so the final tree moves. */
687
+ resolveFinalSha: () => Promise<string>
688
+ serviceDirectory?: string
689
+ spec: ReproductionSpec
690
+ logger: Logger
691
+ opts: RunOptions
692
+ runAgentPass: (userPrompt: string) => Promise<TRun>
693
+ onAgentPass?: (run: TRun) => void
694
+ /** The new files left uncommitted in the checkout, folded into each repair prompt. */
695
+ listUncommittedNewFiles?: () => Promise<string[]>
696
+ /**
697
+ * The files the PRE-FIX tree already changes relative to the PR base branch (see
698
+ * `priorWorkAtBase`). Invariant across attempts — `baseSha` never moves — so it is resolved at
699
+ * most once and memoised here rather than re-probed per round.
700
+ */
701
+ listBaseTreeChanges?: () => Promise<string[] | undefined>
702
+ }): Promise<ReproductionReport> {
703
+ const { dir, baseSha, resolveFinalSha, serviceDirectory, spec, logger, opts } = args
704
+ const deadlineAt = Date.now() + reproductionTotalBudgetMs()
705
+ const listBaseTreeChanges = memoiseBaseTreeChanges(args.listBaseTreeChanges)
706
+ let attempt = 1
707
+ for (;;) {
708
+ const finalSha = await resolveFinalSha()
709
+ // A tree that never moved has nothing to prove: the "final" tree IS the pre-fix tree, so the
710
+ // check would necessarily agree with itself and the verdict would be meaningless.
711
+ if (finalSha === baseSha) {
712
+ logger.info('reproduction: final tree equals the pre-fix tree — nothing to verify', {
713
+ attempt,
714
+ })
715
+ const report: ReproductionReport = {
716
+ status: 'inconclusive',
717
+ command: spec.command,
718
+ testPaths: [...spec.testPaths],
719
+ ...(spec.omittedTestPaths ? { omittedTestPaths: spec.omittedTestPaths } : {}),
720
+ attempts: attempt,
721
+ maxAttempts: spec.maxAttempts,
722
+ note: 'The branch carries no commit beyond the pre-fix tree, so there was no change to verify a reproduction against.',
723
+ at: Date.now(),
724
+ }
725
+ // Published like every other settled attempt: a verdict that reaches the step only in the
726
+ // terminal result is invisible for as long as the job keeps running, and "the step shows no
727
+ // reproduction section" is exactly what a reader cannot distinguish from "it never ran".
728
+ opts.onReproductionProof?.(report)
729
+ return report
730
+ }
731
+ const { report, fullTails, repairable } = await runReproductionProof({
732
+ dir,
733
+ baseSha,
734
+ finalSha,
735
+ ...(serviceDirectory ? { serviceDirectory } : {}),
736
+ spec,
737
+ attempt,
738
+ logger,
739
+ opts,
740
+ deadlineAt,
741
+ ...(listBaseTreeChanges ? { listBaseTreeChanges } : {}),
742
+ })
743
+ opts.onReproductionProof?.(report)
744
+ if (report.status === 'reproduced') {
745
+ logger.info('reproduction: proved', { attempt })
746
+ return report
747
+ }
748
+ if (!repairable) {
749
+ logger.warn('reproduction: not repairable by the agent — settling', {
750
+ attempt,
751
+ note: report.note,
752
+ })
753
+ return report
754
+ }
755
+ if (attempt >= spec.maxAttempts) {
756
+ logger.warn('reproduction: attempt budget spent — recording inconclusive', {
757
+ attempt,
758
+ maxAttempts: spec.maxAttempts,
759
+ })
760
+ return report
761
+ }
762
+ if (budgetSpent(deadlineAt)) {
763
+ logger.warn('reproduction: time budget spent — recording inconclusive', { attempt })
764
+ return { ...report, note: `${report.note ? `${report.note} ` : ''}${BUDGET_NOTE}` }
765
+ }
766
+ attempt += 1
767
+ logger.info('reproduction: repairing', { nextAttempt: attempt })
768
+ opts.onPhase?.('reproduction-repair')
769
+ const untracked = await safeListUncommitted(args.listUncommittedNewFiles, logger)
770
+ const run = await args.runAgentPass(buildReproductionRepairPrompt(report, fullTails, untracked))
771
+ args.onAgentPass?.(run)
772
+ opts.onPhase?.('agent')
773
+ }
774
+ }
775
+
776
+ /**
777
+ * Memoise the pre-fix tree's provenance probe across a loop's attempts. It costs a fetch of the
778
+ * base branch and answers a question about `baseSha`, which never moves — so re-running it each
779
+ * round would be one network round-trip per attempt for an answer that cannot have changed.
780
+ */
781
+ function memoiseBaseTreeChanges(
782
+ probe: (() => Promise<string[] | undefined>) | undefined,
783
+ ): (() => Promise<string[] | undefined>) | undefined {
784
+ if (!probe) return undefined
785
+ let pending: Promise<string[] | undefined> | undefined
786
+ return () => (pending ??= probe())
787
+ }
788
+
789
+ /**
790
+ * The uncommitted-new-file list for a repair prompt, never throwing: an ADVISORY addition to the
791
+ * instruction must degrade to "no warning" rather than failing a loop that is otherwise working.
792
+ */
793
+ async function safeListUncommitted(
794
+ list: (() => Promise<string[]>) | undefined,
795
+ logger: Logger,
796
+ ): Promise<string[]> {
797
+ if (!list) return []
798
+ try {
799
+ return await list()
800
+ } catch (error) {
801
+ logger.warn('reproduction: could not list uncommitted new files', {
802
+ error: error instanceof Error ? error.message : String(error),
803
+ })
804
+ return []
805
+ }
806
+ }