@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.
- package/README.md +15 -2
- package/dist/agent.js +20 -12
- package/dist/captured-command.js +112 -0
- package/dist/coding-agent.js +59 -6
- package/dist/git.js +108 -0
- package/dist/job.js +5 -46
- package/dist/reproduction-proof.js +614 -0
- package/dist/runner.js +3 -0
- package/dist/validation-checks.js +70 -82
- package/package.json +3 -3
- package/src/agent.ts +20 -11
- package/src/captured-command.ts +144 -0
- package/src/coding-agent.ts +89 -4
- package/src/git.ts +133 -0
- package/src/job.ts +32 -46
- package/src/reproduction-proof.ts +806 -0
- package/src/runner.ts +20 -0
- package/src/validation-checks.ts +71 -81
|
@@ -0,0 +1,614 @@
|
|
|
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
|
+
/**
|
|
7
|
+
* Per-phase output kept on the REPORT (what crosses the wire and lands in the run's persisted
|
|
8
|
+
* `detail` blob). Deliberately smaller than `MAX_CAPTURED_OUTPUT_CHARS` (`redact.ts`), which is
|
|
9
|
+
* what the AGENT sees in its repair prompt — the same split, for the same reasons, as the pre-PR
|
|
10
|
+
* validation report's tail (`validation-checks.ts`).
|
|
11
|
+
*/
|
|
12
|
+
export const REPRODUCTION_REPORT_TAIL_CHARS = 4_000;
|
|
13
|
+
/**
|
|
14
|
+
* The ceiling the harness clamps a body-supplied `reproduction.maxAttempts` to, and the default it
|
|
15
|
+
* applies when the body omits one.
|
|
16
|
+
*
|
|
17
|
+
* DELIBERATE DUPLICATES of `REPRODUCTION_DEFAULT_MAX_ATTEMPTS` in `@cat-factory/contracts` (and of
|
|
18
|
+
* the validation loop's own ceiling) — the published image takes no schema dependency, so the
|
|
19
|
+
* harness cannot import them. Keep them in step: a harness clamping to a DIFFERENT ceiling would
|
|
20
|
+
* silently cap a budget the engine was allowed to send, with nothing to flag the mismatch.
|
|
21
|
+
*/
|
|
22
|
+
export const REPRODUCTION_DEFAULT_MAX_ATTEMPTS = 3;
|
|
23
|
+
export const REPRODUCTION_MAX_ATTEMPTS_CEILING = 10;
|
|
24
|
+
/**
|
|
25
|
+
* The per-command watchdog: the longest a single setup or check command may run before it is
|
|
26
|
+
* killed and treated as a failure, so one hung test command cannot wedge a run. Overridable via
|
|
27
|
+
* env for tests; defaults to 15 minutes, matching the validation loop's.
|
|
28
|
+
*/
|
|
29
|
+
export function reproductionCommandTimeoutMs() {
|
|
30
|
+
const n = Number(process.env.REPRODUCTION_COMMAND_TIMEOUT_MS);
|
|
31
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 15 * 60_000;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* How often the proof feeds the run's inactivity watchdog. Well under the harness's own
|
|
35
|
+
* `JOB_INACTIVITY_MS` (default 10 min) so a slow install-plus-test in each of two worktrees can
|
|
36
|
+
* never look wedged. This is NOT optional: the job-level watchdog is TIGHTER than one command's
|
|
37
|
+
* own ({@link reproductionCommandTimeoutMs}, 15 min), and the harness spawns these itself rather
|
|
38
|
+
* than through the agent, so they emit no activity of their own — without the heartbeat a
|
|
39
|
+
* legitimately slow proof aborts the entire run as "inactivity" and the per-command timeout is
|
|
40
|
+
* unreachable at stock settings.
|
|
41
|
+
*/
|
|
42
|
+
export function reproductionHeartbeatMs() {
|
|
43
|
+
const n = Number(process.env.REPRODUCTION_HEARTBEAT_MS);
|
|
44
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 30_000;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The ceiling on the WHOLE proof phase — every attempt, both trees, setup included. Overridable
|
|
48
|
+
* via env; defaults to 45 minutes.
|
|
49
|
+
*
|
|
50
|
+
* The per-command watchdog bounds one command, not the phase, and the phase multiplies: a spent
|
|
51
|
+
* budget is `maxAttempts` × two trees × (setup + check), each of which may legitimately run for
|
|
52
|
+
* {@link reproductionCommandTimeoutMs}. At stock settings that is hours of container time spent
|
|
53
|
+
* BEFORE the pre-PR validation loop has run its own rounds, and nothing else stops it — the
|
|
54
|
+
* heartbeat deliberately keeps the job-level inactivity watchdog from firing, which is exactly
|
|
55
|
+
* what removes the accidental backstop the phase would otherwise have had.
|
|
56
|
+
*
|
|
57
|
+
* Enforced at PHASE boundaries (before each tree's run, and before each repair round) rather than
|
|
58
|
+
* mid-command: a command already carries its own watchdog, so the real bound is this budget plus
|
|
59
|
+
* at most one command's timeout. Exceeding it settles `inconclusive` with a note saying so —
|
|
60
|
+
* never a run failure, exactly like every other unproven shape.
|
|
61
|
+
*/
|
|
62
|
+
export function reproductionTotalBudgetMs() {
|
|
63
|
+
const n = Number(process.env.REPRODUCTION_TOTAL_BUDGET_MS);
|
|
64
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 45 * 60_000;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Parse the job body's `reproduction` envelope, or `undefined` for "no proof on this job".
|
|
68
|
+
*
|
|
69
|
+
* Lenient in exactly one direction: anything malformed yields `undefined`, so the run behaves
|
|
70
|
+
* byte-for-byte as it did before this feature existed. A body that names no command has nothing
|
|
71
|
+
* to run, and inventing one would manufacture a verdict. Test paths are re-checked here even
|
|
72
|
+
* though the engine already sanitized them — this is the harness's own trust boundary, and these
|
|
73
|
+
* paths are handed to `git checkout` against a worktree.
|
|
74
|
+
*/
|
|
75
|
+
export function parseReproductionSpec(value) {
|
|
76
|
+
if (typeof value !== 'object' || value === null)
|
|
77
|
+
return undefined;
|
|
78
|
+
const o = value;
|
|
79
|
+
const command = typeof o.command === 'string' ? o.command.trim() : '';
|
|
80
|
+
if (command === '')
|
|
81
|
+
return undefined;
|
|
82
|
+
const setupCommand = typeof o.setupCommand === 'string' ? o.setupCommand.trim() : '';
|
|
83
|
+
const declared = Array.isArray(o.testPaths) ? o.testPaths : [];
|
|
84
|
+
const testPaths = declared
|
|
85
|
+
.filter((p) => typeof p === 'string')
|
|
86
|
+
.map((p) => p.trim().replace(/\\/g, '/'))
|
|
87
|
+
.filter((p) => isSafeTestPath(p));
|
|
88
|
+
const omitted = typeof o.omittedTestPaths === 'number' && o.omittedTestPaths > 0
|
|
89
|
+
? Math.floor(o.omittedTestPaths)
|
|
90
|
+
: 0;
|
|
91
|
+
// Anything this parse itself refused is an omission too — the report must not describe a
|
|
92
|
+
// pre-fix tree rebuilt from a shorter list than the one the count claims.
|
|
93
|
+
const droppedHere = declared.length - testPaths.length;
|
|
94
|
+
const omittedTestPaths = omitted + Math.max(0, droppedHere);
|
|
95
|
+
const parsedAttempts = typeof o.maxAttempts === 'number' && Number.isFinite(o.maxAttempts) && o.maxAttempts > 0
|
|
96
|
+
? Math.floor(o.maxAttempts)
|
|
97
|
+
: undefined;
|
|
98
|
+
return {
|
|
99
|
+
command,
|
|
100
|
+
testPaths,
|
|
101
|
+
...(omittedTestPaths > 0 ? { omittedTestPaths } : {}),
|
|
102
|
+
...(setupCommand ? { setupCommand } : {}),
|
|
103
|
+
maxAttempts: Math.min(parsedAttempts ?? REPRODUCTION_DEFAULT_MAX_ATTEMPTS, REPRODUCTION_MAX_ATTEMPTS_CEILING),
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* A repo-relative path with no traversal, no root/drive anchor, no leading dash, and no git
|
|
108
|
+
* PATHSPEC MAGIC. Must stay in step with the engine's `isSafeTestPath`
|
|
109
|
+
* (`orchestration/src/modules/execution/reproductionProof.logic.ts`) — this is the harness's own
|
|
110
|
+
* trust boundary, not a duplicate of the engine's for its own sake.
|
|
111
|
+
*
|
|
112
|
+
* The magic exclusion is the load-bearing part and is easy to miss: these strings are handed to
|
|
113
|
+
* `git checkout <finalSha> -- <path>`, where `--` stops a path being read as a REVISION but does
|
|
114
|
+
* nothing about pathspec syntax. `:(glob)**`, `*`, `foo/*.ts` are all valid pathspecs, so a
|
|
115
|
+
* model-authored path containing one would apply far more of the final tree onto the pre-fix
|
|
116
|
+
* worktree than the declared reproduction — dragging the fix across and GREENING the base. That
|
|
117
|
+
* lands as an `inconclusive` reading "the check passed before your change", which is the exact
|
|
118
|
+
* false diagnosis this feature exists to remove, and it is under the model's control. A dropped
|
|
119
|
+
* path is counted as an omission, so the report says the pre-fix tree was rebuilt from an
|
|
120
|
+
* incomplete set rather than implying a clean verdict.
|
|
121
|
+
*/
|
|
122
|
+
function isSafeTestPath(path) {
|
|
123
|
+
if (path.length === 0 || path.length > REPRODUCTION_MAX_TEST_PATH_CHARS)
|
|
124
|
+
return false;
|
|
125
|
+
if (path.startsWith('/') || path.startsWith('~') || path.startsWith('-'))
|
|
126
|
+
return false;
|
|
127
|
+
// A leading `:` opens pathspec magic (`:(glob)`, `:(exclude)`, `:/`), and the wildcard
|
|
128
|
+
// metacharacters make any path a glob wherever they appear.
|
|
129
|
+
if (path.startsWith(':') || /[*?[\]]/.test(path))
|
|
130
|
+
return false;
|
|
131
|
+
if (/^[a-zA-Z]:\//.test(path))
|
|
132
|
+
return false;
|
|
133
|
+
return !path.split('/').includes('..');
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Longest accepted declared test path. A DELIBERATE DUPLICATE of
|
|
137
|
+
* `REPRODUCTION_MAX_TEST_PATH_CHARS` in `@cat-factory/contracts` (the published image takes no
|
|
138
|
+
* schema dependency) — keep the two in step.
|
|
139
|
+
*/
|
|
140
|
+
const REPRODUCTION_MAX_TEST_PATH_CHARS = 400;
|
|
141
|
+
/**
|
|
142
|
+
* Run ONE proof attempt: create both worktrees, run the declared check in each, and compute the
|
|
143
|
+
* verdict from the two exit codes.
|
|
144
|
+
*
|
|
145
|
+
* The base worktree is built at `baseSha` and then has the DECLARED test files checked out of
|
|
146
|
+
* `finalSha` on top — the paths only, never a whole-tree checkout, which would drag the fix across
|
|
147
|
+
* and green the base. In the RESUMED case (a prior `repro-test` step already pushed the failing
|
|
148
|
+
* test onto the shared work branch, so `baseSha` already carries it) that overlay is a no-op by
|
|
149
|
+
* construction. Doing it unconditionally is what guarantees BOTH trees run the byte-identical
|
|
150
|
+
* check, which is the claim the report makes.
|
|
151
|
+
*
|
|
152
|
+
* Keeps the run's inactivity watchdog fed for the whole attempt (see
|
|
153
|
+
* {@link reproductionHeartbeatMs}) and always tears both worktrees down, including on a throw.
|
|
154
|
+
*/
|
|
155
|
+
export async function runReproductionProof(args) {
|
|
156
|
+
const { dir, baseSha, finalSha, serviceDirectory, spec, attempt, logger, opts, deadlineAt } = args;
|
|
157
|
+
const fullTails = new Map();
|
|
158
|
+
const heartbeat = setInterval(() => opts.onActivity?.(), reproductionHeartbeatMs());
|
|
159
|
+
heartbeat.unref?.();
|
|
160
|
+
// A per-job temp root: two concurrent jobs on the ONE local-native host process get disjoint
|
|
161
|
+
// worktree paths, so neither can see (or clobber) the other's base tree.
|
|
162
|
+
const root = await mkdtemp(join(tmpdir(), 'cat-repro-'));
|
|
163
|
+
const baseDir = join(root, 'base');
|
|
164
|
+
const finalDir = join(root, 'final');
|
|
165
|
+
/** Every return below is one of these — the invariant fields are stated once. */
|
|
166
|
+
const settle = (fields, repairable) => ({
|
|
167
|
+
report: {
|
|
168
|
+
status: 'inconclusive',
|
|
169
|
+
command: spec.command,
|
|
170
|
+
testPaths: [...spec.testPaths],
|
|
171
|
+
...(spec.omittedTestPaths ? { omittedTestPaths: spec.omittedTestPaths } : {}),
|
|
172
|
+
attempts: attempt,
|
|
173
|
+
maxAttempts: spec.maxAttempts,
|
|
174
|
+
...fields,
|
|
175
|
+
at: Date.now(),
|
|
176
|
+
},
|
|
177
|
+
fullTails,
|
|
178
|
+
repairable,
|
|
179
|
+
});
|
|
180
|
+
try {
|
|
181
|
+
// The proof runs against COMMITTED trees, so a declared test the agent never `git add`ed is
|
|
182
|
+
// invisible to it — and equally invisible to the push, which is the more important half to
|
|
183
|
+
// tell the agent about. Report that rather than a verdict computed without the reproduction.
|
|
184
|
+
const present = await pathsPresentAtCommit(dir, finalSha, spec.testPaths, opts.signal);
|
|
185
|
+
const missing = spec.testPaths.filter((p) => !present.includes(p));
|
|
186
|
+
if (spec.testPaths.length > 0 && present.length === 0) {
|
|
187
|
+
logger.warn('reproduction: no declared test file is committed', { missing });
|
|
188
|
+
return settle({
|
|
189
|
+
note: `None of the declared reproduction test files are committed on the branch (${missing.join(', ')}), so the pre-fix tree could not be reconstructed.`,
|
|
190
|
+
}, true);
|
|
191
|
+
}
|
|
192
|
+
if (budgetSpent(deadlineAt))
|
|
193
|
+
return settle({ note: BUDGET_NOTE }, false);
|
|
194
|
+
await addWorktree(dir, baseDir, baseSha, opts.signal);
|
|
195
|
+
await checkoutPathsFrom(baseDir, finalSha, present, opts.signal);
|
|
196
|
+
logger.info('reproduction: base worktree ready', {
|
|
197
|
+
attempt,
|
|
198
|
+
appliedTestPaths: present.length,
|
|
199
|
+
missingTestPaths: missing.length,
|
|
200
|
+
});
|
|
201
|
+
const baseRun = await runPhase({
|
|
202
|
+
phase: 'base',
|
|
203
|
+
worktree: baseDir,
|
|
204
|
+
...(serviceDirectory ? { serviceDirectory } : {}),
|
|
205
|
+
spec,
|
|
206
|
+
logger,
|
|
207
|
+
opts,
|
|
208
|
+
});
|
|
209
|
+
const base = baseRun.outcome;
|
|
210
|
+
if (baseRun.fullTail)
|
|
211
|
+
fullTails.set('base', baseRun.fullTail);
|
|
212
|
+
// A green base settles it: `reproduced` requires a RED base, so running the final tree could
|
|
213
|
+
// only confirm what is already not proof — and each phase costs a full setup + test run. The
|
|
214
|
+
// report's `final` is documented as absent in exactly this case.
|
|
215
|
+
if (base.passed || base.setupFailed) {
|
|
216
|
+
// A GREEN base is the one outcome whose meaning depends on what the pre-fix tree actually
|
|
217
|
+
// is, so establish that before naming a cause — see {@link priorWorkAtBase}.
|
|
218
|
+
const priorWork = base.passed ? await priorWorkAtBase(args, logger) : undefined;
|
|
219
|
+
return settle({ base, note: noteFor(base, undefined, missing, priorWork) },
|
|
220
|
+
// A setup failure and a base that already carries the fix are both unrepairable, for the
|
|
221
|
+
// same underlying reason: the agent is not what is wrong, so a repair round can only make
|
|
222
|
+
// things worse (here, by inviting it to weaken a reproduction test that is fine).
|
|
223
|
+
!base.setupFailed && !priorWork?.length);
|
|
224
|
+
}
|
|
225
|
+
if (budgetSpent(deadlineAt))
|
|
226
|
+
return settle({ base, note: BUDGET_NOTE }, false);
|
|
227
|
+
await addWorktree(dir, finalDir, finalSha, opts.signal);
|
|
228
|
+
const finalRun = await runPhase({
|
|
229
|
+
phase: 'final',
|
|
230
|
+
worktree: finalDir,
|
|
231
|
+
...(serviceDirectory ? { serviceDirectory } : {}),
|
|
232
|
+
spec,
|
|
233
|
+
logger,
|
|
234
|
+
opts,
|
|
235
|
+
});
|
|
236
|
+
const final = finalRun.outcome;
|
|
237
|
+
if (finalRun.fullTail)
|
|
238
|
+
fullTails.set('final', finalRun.fullTail);
|
|
239
|
+
const reproduced = final.passed && !final.setupFailed;
|
|
240
|
+
return {
|
|
241
|
+
report: {
|
|
242
|
+
status: reproduced ? 'reproduced' : 'inconclusive',
|
|
243
|
+
command: spec.command,
|
|
244
|
+
testPaths: [...spec.testPaths],
|
|
245
|
+
...(spec.omittedTestPaths ? { omittedTestPaths: spec.omittedTestPaths } : {}),
|
|
246
|
+
base,
|
|
247
|
+
final,
|
|
248
|
+
attempts: attempt,
|
|
249
|
+
maxAttempts: spec.maxAttempts,
|
|
250
|
+
...(reproduced && missing.length === 0
|
|
251
|
+
? {}
|
|
252
|
+
: { note: noteFor(base, final, missing, undefined) }),
|
|
253
|
+
at: Date.now(),
|
|
254
|
+
},
|
|
255
|
+
fullTails,
|
|
256
|
+
// A timed-out or un-runnable FINAL tree is not something a repair pass fixes: the agent is
|
|
257
|
+
// handed a watchdog kill or a broken environment, not a failing assertion it can act on,
|
|
258
|
+
// and each round costs another two full tree runs to learn the same thing.
|
|
259
|
+
repairable: !reproduced && !final.setupFailed && !final.timedOut && !base.timedOut,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
clearInterval(heartbeat);
|
|
264
|
+
// Teardown is best-effort throughout: a proof that ran must never be lost to a failed rmdir.
|
|
265
|
+
await removeWorktree(dir, baseDir, opts.signal);
|
|
266
|
+
await removeWorktree(dir, finalDir, opts.signal);
|
|
267
|
+
await rm(root, { recursive: true, force: true }).catch(() => { });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/** Whether the whole-phase budget is spent (see {@link reproductionTotalBudgetMs}). */
|
|
271
|
+
function budgetSpent(deadlineAt) {
|
|
272
|
+
return deadlineAt !== undefined && Date.now() >= deadlineAt;
|
|
273
|
+
}
|
|
274
|
+
const BUDGET_NOTE = '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.';
|
|
275
|
+
/**
|
|
276
|
+
* The non-test changes the PRE-FIX tree already carries relative to the PR base branch, when that
|
|
277
|
+
* can be determined (`undefined` when it cannot, and only ever consulted for a GREEN base).
|
|
278
|
+
*
|
|
279
|
+
* This is what makes a green base interpretable. The pre-fix tree is `baseSha` — the work branch
|
|
280
|
+
* as it stood when this pass STARTED — which in the designed bugfix flow is the reproduction
|
|
281
|
+
* step's test commit and nothing else, so green there genuinely means "the test does not
|
|
282
|
+
* demonstrate the defect". But a coder container that is evicted mid-run has already committed
|
|
283
|
+
* and checkpoint-pushed its work, and the re-dispatch RESUMES that branch: `baseSha` then carries
|
|
284
|
+
* this same step's own partial fix, the check legitimately passes on it, and the old diagnosis
|
|
285
|
+
* stated as fact that the agent's test was worthless — then spent the whole repair budget telling
|
|
286
|
+
* it to "make the test actually exercise the defect", which invites weakening a perfectly good
|
|
287
|
+
* reproduction. Non-test changes at the base are the signal that separates the two.
|
|
288
|
+
*
|
|
289
|
+
* Best-effort by construction: the probe needs the base branch and a reachable merge base, and a
|
|
290
|
+
* fresh clone is shallow. An unavailable answer degrades to the original diagnosis rather than
|
|
291
|
+
* suppressing one.
|
|
292
|
+
*/
|
|
293
|
+
async function priorWorkAtBase(args, logger) {
|
|
294
|
+
if (!args.listBaseTreeChanges)
|
|
295
|
+
return undefined;
|
|
296
|
+
let changed;
|
|
297
|
+
try {
|
|
298
|
+
changed = await args.listBaseTreeChanges();
|
|
299
|
+
}
|
|
300
|
+
catch (error) {
|
|
301
|
+
logger.warn('reproduction: could not inspect the pre-fix tree’s provenance', {
|
|
302
|
+
error: error instanceof Error ? error.message : String(error),
|
|
303
|
+
});
|
|
304
|
+
return undefined;
|
|
305
|
+
}
|
|
306
|
+
if (!changed)
|
|
307
|
+
return undefined;
|
|
308
|
+
const declared = new Set(args.spec.testPaths);
|
|
309
|
+
const priorWork = changed.filter((p) => !declared.has(p));
|
|
310
|
+
if (priorWork.length > 0) {
|
|
311
|
+
logger.info('reproduction: the pre-fix tree already carries non-test work', {
|
|
312
|
+
count: priorWork.length,
|
|
313
|
+
files: priorWork.slice(0, 10),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
return priorWork;
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* One line naming the shape that was observed, for the report and the step card. Every
|
|
320
|
+
* non-`reproduced` outcome gets one: a bare `inconclusive` with no explanation is indistinguishable
|
|
321
|
+
* from a rendering bug, which is how "nobody tried" creeps back in through the door this feature
|
|
322
|
+
* closed.
|
|
323
|
+
*
|
|
324
|
+
* `priorWork` (see {@link priorWorkAtBase}) is what stops the green-base line asserting a cause it
|
|
325
|
+
* cannot know. Nothing here ever states more than was measured.
|
|
326
|
+
*/
|
|
327
|
+
function noteFor(base, final, missing, priorWork) {
|
|
328
|
+
const incomplete = missing.length
|
|
329
|
+
? ` Declared test file(s) not committed on the branch and therefore not part of the check: ${missing.join(', ')}.`
|
|
330
|
+
: '';
|
|
331
|
+
if (base.setupFailed) {
|
|
332
|
+
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}`;
|
|
333
|
+
}
|
|
334
|
+
if (base.passed) {
|
|
335
|
+
if (priorWork?.length) {
|
|
336
|
+
const shown = priorWork.slice(0, 5).join(', ');
|
|
337
|
+
const more = priorWork.length > 5 ? `, +${priorWork.length - 5} more` : '';
|
|
338
|
+
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}`;
|
|
339
|
+
}
|
|
340
|
+
return `The declared check PASSED on the pre-fix tree, so it does not demonstrate the defect.${incomplete}`;
|
|
341
|
+
}
|
|
342
|
+
if (!final) {
|
|
343
|
+
return `The pre-fix tree was red but the final tree was never checked.${incomplete}`;
|
|
344
|
+
}
|
|
345
|
+
if (final.setupFailed) {
|
|
346
|
+
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}`;
|
|
347
|
+
}
|
|
348
|
+
if (!final.passed) {
|
|
349
|
+
// Identical failures on two trees that differ only by the fix are far more often one
|
|
350
|
+
// environment failing both ways — a missing toolchain, an absent dependency, a collection
|
|
351
|
+
// error — than a fix that does nothing. Say which reading the evidence favours instead of
|
|
352
|
+
// offering both with equal weight and leaving the reviewer to guess.
|
|
353
|
+
if (base.timedOut && final.timedOut) {
|
|
354
|
+
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}`;
|
|
355
|
+
}
|
|
356
|
+
const identical = base.exitCode === final.exitCode;
|
|
357
|
+
const reading = identical
|
|
358
|
+
? `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.`
|
|
359
|
+
: 'The change does not make the check pass.';
|
|
360
|
+
return `The declared check FAILED on both the pre-fix tree (exit ${base.exitCode}) and the final tree (exit ${final.exitCode}). ${reading}${incomplete}`;
|
|
361
|
+
}
|
|
362
|
+
return `The reproduction was demonstrated (red at the pre-fix tree, green at the final tree).${incomplete}`;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Run one tree's phase: the optional setup command, then the declared check, both in the
|
|
366
|
+
* worktree (offset by the monorepo service directory when the run has one).
|
|
367
|
+
*
|
|
368
|
+
* A failed setup short-circuits the check and is flagged `setupFailed`, so a broken environment is
|
|
369
|
+
* reported as such instead of masquerading as a red tree. The setup command runs in BOTH
|
|
370
|
+
* worktrees or neither — an asymmetric setup is exactly how a false `reproduced` is manufactured.
|
|
371
|
+
*/
|
|
372
|
+
async function runPhase(args) {
|
|
373
|
+
const { phase, worktree, serviceDirectory, spec, logger, opts } = args;
|
|
374
|
+
const cwd = serviceDirectory ? join(worktree, serviceDirectory) : worktree;
|
|
375
|
+
if (spec.setupCommand) {
|
|
376
|
+
logger.info('reproduction: running setup', { phase });
|
|
377
|
+
const setup = await runOneCommand(cwd, spec.setupCommand, phase, logger, opts);
|
|
378
|
+
if (!setup.outcome.passed) {
|
|
379
|
+
logger.warn('reproduction: setup failed', { phase, exitCode: setup.outcome.exitCode });
|
|
380
|
+
return {
|
|
381
|
+
outcome: { ...setup.outcome, setupFailed: true },
|
|
382
|
+
...(setup.fullTail ? { fullTail: setup.fullTail } : {}),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
logger.info('reproduction: running declared check', { phase });
|
|
387
|
+
const check = await runOneCommand(cwd, spec.command, phase, logger, opts);
|
|
388
|
+
logger.info('reproduction: phase finished', { phase, exitCode: check.outcome.exitCode });
|
|
389
|
+
return { outcome: check.outcome, ...(check.fullTail ? { fullTail: check.fullTail } : {}) };
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Run ONE of the phase's commands through the shared {@link runCapturedCommand} seam and shape it
|
|
393
|
+
* as a phase outcome. The exit code is the verdict — computed by the harness, never self-reported
|
|
394
|
+
* by the model, which is what this whole feature replaces.
|
|
395
|
+
*/
|
|
396
|
+
async function runOneCommand(cwd, command, phase, logger, opts) {
|
|
397
|
+
const { fullTail, ...run } = await runCapturedCommand({
|
|
398
|
+
cwd,
|
|
399
|
+
command,
|
|
400
|
+
timeoutMs: reproductionCommandTimeoutMs(),
|
|
401
|
+
reportTailChars: REPRODUCTION_REPORT_TAIL_CHARS,
|
|
402
|
+
logLabel: 'reproduction',
|
|
403
|
+
logFields: { phase },
|
|
404
|
+
logger,
|
|
405
|
+
opts,
|
|
406
|
+
});
|
|
407
|
+
return { outcome: run, ...(fullTail ? { fullTail } : {}) };
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* The repair instruction handed to the agent after a failed verification: which tree behaved how,
|
|
411
|
+
* the captured output, and an explicit statement of the exit condition. The FULL captured tail is
|
|
412
|
+
* used here (not the report's smaller bound) — the agent needs the whole failure to act on it, and
|
|
413
|
+
* this text never leaves the container.
|
|
414
|
+
*
|
|
415
|
+
* Deliberately prescriptive about scope, for the same reason the validation loop's prompt is: a
|
|
416
|
+
* loop that lets the agent "succeed" by weakening the reproduction is worse than no loop at all,
|
|
417
|
+
* because it launders an unverified claim into a captured "fact" — the exact failure mode this
|
|
418
|
+
* whole feature exists to remove.
|
|
419
|
+
*/
|
|
420
|
+
export function buildReproductionRepairPrompt(report, fullTails,
|
|
421
|
+
/**
|
|
422
|
+
* New files the agent created but never `git add`ed, if the caller can tell. The proof runs
|
|
423
|
+
* against COMMITTED trees, so an unadded reproduction test is invisible to it — and to the push.
|
|
424
|
+
*/
|
|
425
|
+
untrackedFiles = []) {
|
|
426
|
+
const remaining = report.maxAttempts - report.attempts;
|
|
427
|
+
const diagnosis = report.base?.setupFailed
|
|
428
|
+
? 'The setup command failed before either tree could be checked.'
|
|
429
|
+
: !report.base
|
|
430
|
+
? 'The reproduction test files are not committed, so the pre-fix tree could not be reconstructed.'
|
|
431
|
+
: report.base.passed
|
|
432
|
+
? '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.'
|
|
433
|
+
: report.final && !report.final.passed
|
|
434
|
+
? '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.'
|
|
435
|
+
: 'The reproduction could not be demonstrated.';
|
|
436
|
+
const blocks = ['base', 'final']
|
|
437
|
+
.map((phase) => {
|
|
438
|
+
const outcome = phase === 'base' ? report.base : report.final;
|
|
439
|
+
if (!outcome)
|
|
440
|
+
return '';
|
|
441
|
+
const body = fullTails.get(phase) ?? outcome.outputTail ?? '(no output captured)';
|
|
442
|
+
const label = phase === 'base' ? 'Pre-fix tree (without your change)' : 'Final tree (with your change)';
|
|
443
|
+
const reason = outcome.timedOut
|
|
444
|
+
? `timed out after ${Math.round((outcome.durationMs ?? 0) / 1000)}s`
|
|
445
|
+
: `exited ${outcome.exitCode}`;
|
|
446
|
+
return `### ${label} — ${reason}\n\n\`\`\`\n$ ${report.command}\n${body}\n\`\`\``;
|
|
447
|
+
})
|
|
448
|
+
.filter((b) => b !== '')
|
|
449
|
+
.join('\n\n');
|
|
450
|
+
const untracked = untrackedFiles.length
|
|
451
|
+
? [
|
|
452
|
+
'',
|
|
453
|
+
'## Uncommitted new files',
|
|
454
|
+
'',
|
|
455
|
+
'These files exist in your checkout but were never added to git, so they are not on the',
|
|
456
|
+
'branch — and the reproduction is checked against committed trees, so they took no part in',
|
|
457
|
+
'it. `git add` each one you meant to keep (or delete it):',
|
|
458
|
+
'',
|
|
459
|
+
...untrackedFiles.map((f) => `- ${f}`),
|
|
460
|
+
]
|
|
461
|
+
: [];
|
|
462
|
+
return [
|
|
463
|
+
'Your change is being checked for REPRODUCTION PROOF: the declared reproduction command is run',
|
|
464
|
+
'against the tree WITHOUT your change and again against the tree WITH it. It has to fail on the',
|
|
465
|
+
'first and pass on the second. It did not.',
|
|
466
|
+
'',
|
|
467
|
+
diagnosis,
|
|
468
|
+
'',
|
|
469
|
+
...(blocks ? [blocks, ''] : []),
|
|
470
|
+
...untracked,
|
|
471
|
+
'',
|
|
472
|
+
'## How this is judged',
|
|
473
|
+
'',
|
|
474
|
+
`The command \`${report.command}\` is re-run against both trees when you stop. The proof succeeds`,
|
|
475
|
+
'only when it fails without your change and passes with it. You have',
|
|
476
|
+
`${remaining} attempt(s) left; after that the pull request still opens, but it will state that the`,
|
|
477
|
+
'reproduction could not be demonstrated.',
|
|
478
|
+
'',
|
|
479
|
+
'## Rules',
|
|
480
|
+
'',
|
|
481
|
+
'- Do NOT weaken, skip, delete, or relax the reproduction test to make this pass. A proof',
|
|
482
|
+
' obtained that way is a failed task — it is precisely the unverified claim this check exists',
|
|
483
|
+
' to catch.',
|
|
484
|
+
'- If the test passes without your change, make it actually exercise the defect: assert on the',
|
|
485
|
+
' buggy behaviour itself, not on something incidental.',
|
|
486
|
+
'- If the test fails with your change too, fix the underlying defect rather than the test.',
|
|
487
|
+
'- Do not revert your earlier work; build on it.',
|
|
488
|
+
'- Commit your work, and `git add` any NEW file you create — only changes to files already',
|
|
489
|
+
' tracked by git are staged for you.',
|
|
490
|
+
].join('\n');
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* The reproduction-proof LOOP: verify, and while the verification fails, is repairable and budget
|
|
494
|
+
* remains, hand the captured output back to the agent as its next instruction and verify again.
|
|
495
|
+
* Returns the LAST attempt's report.
|
|
496
|
+
*
|
|
497
|
+
* A failed verification is a REPAIR, never a run failure (the initiative's D6). Exhausting the
|
|
498
|
+
* budget degrades to `inconclusive` and the caller opens the pull request anyway — deliberately a
|
|
499
|
+
* different disposition from the pre-PR validation loop, which opens nothing. A red validation
|
|
500
|
+
* check means the WORK is broken, so refusing the PR is right; a reproduction that could not be
|
|
501
|
+
* demonstrated means the EVIDENCE is weak, which is a reviewer's call, not a machine's, and
|
|
502
|
+
* failing the run would throw away a fix that may well be correct. The report says plainly what
|
|
503
|
+
* was and was not proven.
|
|
504
|
+
*
|
|
505
|
+
* Every settled attempt — including the ones that never ran a tree — is published on the job view
|
|
506
|
+
* (a fresh `at` per publish, which the engine's change detection relies on), so the loop is
|
|
507
|
+
* observable while it runs; `onAgentPass` lets the caller fold each repair pass's
|
|
508
|
+
* stats/usage/telemetry into the run's totals.
|
|
509
|
+
*
|
|
510
|
+
* The loop is bounded twice over: by `maxAttempts` rounds, and by the wall-clock
|
|
511
|
+
* {@link reproductionTotalBudgetMs} — attempts multiply two full tree runs each, and the phase's
|
|
512
|
+
* own heartbeat deliberately stops the job-level inactivity watchdog from ever cutting it short.
|
|
513
|
+
*/
|
|
514
|
+
export async function runReproductionLoop(args) {
|
|
515
|
+
const { dir, baseSha, resolveFinalSha, serviceDirectory, spec, logger, opts } = args;
|
|
516
|
+
const deadlineAt = Date.now() + reproductionTotalBudgetMs();
|
|
517
|
+
const listBaseTreeChanges = memoiseBaseTreeChanges(args.listBaseTreeChanges);
|
|
518
|
+
let attempt = 1;
|
|
519
|
+
for (;;) {
|
|
520
|
+
const finalSha = await resolveFinalSha();
|
|
521
|
+
// A tree that never moved has nothing to prove: the "final" tree IS the pre-fix tree, so the
|
|
522
|
+
// check would necessarily agree with itself and the verdict would be meaningless.
|
|
523
|
+
if (finalSha === baseSha) {
|
|
524
|
+
logger.info('reproduction: final tree equals the pre-fix tree — nothing to verify', {
|
|
525
|
+
attempt,
|
|
526
|
+
});
|
|
527
|
+
const report = {
|
|
528
|
+
status: 'inconclusive',
|
|
529
|
+
command: spec.command,
|
|
530
|
+
testPaths: [...spec.testPaths],
|
|
531
|
+
...(spec.omittedTestPaths ? { omittedTestPaths: spec.omittedTestPaths } : {}),
|
|
532
|
+
attempts: attempt,
|
|
533
|
+
maxAttempts: spec.maxAttempts,
|
|
534
|
+
note: 'The branch carries no commit beyond the pre-fix tree, so there was no change to verify a reproduction against.',
|
|
535
|
+
at: Date.now(),
|
|
536
|
+
};
|
|
537
|
+
// Published like every other settled attempt: a verdict that reaches the step only in the
|
|
538
|
+
// terminal result is invisible for as long as the job keeps running, and "the step shows no
|
|
539
|
+
// reproduction section" is exactly what a reader cannot distinguish from "it never ran".
|
|
540
|
+
opts.onReproductionProof?.(report);
|
|
541
|
+
return report;
|
|
542
|
+
}
|
|
543
|
+
const { report, fullTails, repairable } = await runReproductionProof({
|
|
544
|
+
dir,
|
|
545
|
+
baseSha,
|
|
546
|
+
finalSha,
|
|
547
|
+
...(serviceDirectory ? { serviceDirectory } : {}),
|
|
548
|
+
spec,
|
|
549
|
+
attempt,
|
|
550
|
+
logger,
|
|
551
|
+
opts,
|
|
552
|
+
deadlineAt,
|
|
553
|
+
...(listBaseTreeChanges ? { listBaseTreeChanges } : {}),
|
|
554
|
+
});
|
|
555
|
+
opts.onReproductionProof?.(report);
|
|
556
|
+
if (report.status === 'reproduced') {
|
|
557
|
+
logger.info('reproduction: proved', { attempt });
|
|
558
|
+
return report;
|
|
559
|
+
}
|
|
560
|
+
if (!repairable) {
|
|
561
|
+
logger.warn('reproduction: not repairable by the agent — settling', {
|
|
562
|
+
attempt,
|
|
563
|
+
note: report.note,
|
|
564
|
+
});
|
|
565
|
+
return report;
|
|
566
|
+
}
|
|
567
|
+
if (attempt >= spec.maxAttempts) {
|
|
568
|
+
logger.warn('reproduction: attempt budget spent — recording inconclusive', {
|
|
569
|
+
attempt,
|
|
570
|
+
maxAttempts: spec.maxAttempts,
|
|
571
|
+
});
|
|
572
|
+
return report;
|
|
573
|
+
}
|
|
574
|
+
if (budgetSpent(deadlineAt)) {
|
|
575
|
+
logger.warn('reproduction: time budget spent — recording inconclusive', { attempt });
|
|
576
|
+
return { ...report, note: `${report.note ? `${report.note} ` : ''}${BUDGET_NOTE}` };
|
|
577
|
+
}
|
|
578
|
+
attempt += 1;
|
|
579
|
+
logger.info('reproduction: repairing', { nextAttempt: attempt });
|
|
580
|
+
opts.onPhase?.('reproduction-repair');
|
|
581
|
+
const untracked = await safeListUncommitted(args.listUncommittedNewFiles, logger);
|
|
582
|
+
const run = await args.runAgentPass(buildReproductionRepairPrompt(report, fullTails, untracked));
|
|
583
|
+
args.onAgentPass?.(run);
|
|
584
|
+
opts.onPhase?.('agent');
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
/**
|
|
588
|
+
* Memoise the pre-fix tree's provenance probe across a loop's attempts. It costs a fetch of the
|
|
589
|
+
* base branch and answers a question about `baseSha`, which never moves — so re-running it each
|
|
590
|
+
* round would be one network round-trip per attempt for an answer that cannot have changed.
|
|
591
|
+
*/
|
|
592
|
+
function memoiseBaseTreeChanges(probe) {
|
|
593
|
+
if (!probe)
|
|
594
|
+
return undefined;
|
|
595
|
+
let pending;
|
|
596
|
+
return () => (pending ??= probe());
|
|
597
|
+
}
|
|
598
|
+
/**
|
|
599
|
+
* The uncommitted-new-file list for a repair prompt, never throwing: an ADVISORY addition to the
|
|
600
|
+
* instruction must degrade to "no warning" rather than failing a loop that is otherwise working.
|
|
601
|
+
*/
|
|
602
|
+
async function safeListUncommitted(list, logger) {
|
|
603
|
+
if (!list)
|
|
604
|
+
return [];
|
|
605
|
+
try {
|
|
606
|
+
return await list();
|
|
607
|
+
}
|
|
608
|
+
catch (error) {
|
|
609
|
+
logger.warn('reproduction: could not list uncommitted new files', {
|
|
610
|
+
error: error instanceof Error ? error.message : String(error),
|
|
611
|
+
});
|
|
612
|
+
return [];
|
|
613
|
+
}
|
|
614
|
+
}
|
package/dist/runner.js
CHANGED
|
@@ -216,6 +216,9 @@ export class JobRegistry {
|
|
|
216
216
|
onValidationReport: (report) => {
|
|
217
217
|
entry.validationReport = report;
|
|
218
218
|
},
|
|
219
|
+
onReproductionProof: (report) => {
|
|
220
|
+
entry.reproductionReport = report;
|
|
221
|
+
},
|
|
219
222
|
onCallMetric: (call) => {
|
|
220
223
|
// Stamp the job-scoped sequence on the metric OBJECT: the handler keeps the same
|
|
221
224
|
// instance for its terminal result, so both channels carry the same `seq` and the
|