@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
package/README.md
CHANGED
|
@@ -63,9 +63,17 @@ The implementation job (`POST /run`) is the canonical sequence:
|
|
|
63
63
|
configured check commands (install/lint/test/build) run with `sh -c` in the checkout, and
|
|
64
64
|
while they fail and the attempt budget remains the agent is re-run with the captured output
|
|
65
65
|
as its instruction (see [pre-PR validation](../../../docs/initiatives/pre-pr-validation.md)),
|
|
66
|
-
5. **
|
|
66
|
+
5. **prove the reproduction**, when the job body carries `reproduction` — the declared check is
|
|
67
|
+
run against the pre-fix tree and the tree the PR will open from, in two freshly-created
|
|
68
|
+
symmetric `git worktree` checkouts, and only red-then-green is reported as proof (see
|
|
69
|
+
[bugfix reproduction proof](../../../docs/initiatives/bugfix-reproduction-proof.md)). Unlike
|
|
70
|
+
step 4 this NEVER gates the PR: a failed verification is fed back to the agent while budget
|
|
71
|
+
remains, then recorded as `inconclusive`. It runs BEFORE step 4 so validation stays the last
|
|
72
|
+
thing to touch the tree,
|
|
73
|
+
6. **commit, push** a branch and **open a PR**, returning `{ prUrl, branch, summary }` — but
|
|
67
74
|
ONLY if step 4 ended green. A spent budget returns an error result with the validation report
|
|
68
|
-
and opens no PR. Absent `validationChecks`,
|
|
75
|
+
and opens no PR. Absent `validationChecks` / `reproduction`, steps 4 and 5 do not happen at
|
|
76
|
+
all.
|
|
69
77
|
|
|
70
78
|
Bootstrap differs at the ends — it may start from an empty dir, and **resets
|
|
71
79
|
history to one commit and force-pushes** the default branch instead of opening a
|
|
@@ -133,7 +141,9 @@ Kimi / DeepSeek) and meters spend. The provider key never enters the container.
|
|
|
133
141
|
| `src/package-registries.ts` | Private-registry (npm) auth: renders the job's allowlisted entries into an npmrc — the user `~/.npmrc` in a container, a per-job file pointed at by `npm_config_userconfig` for a native job. |
|
|
134
142
|
| `src/agent-runner.ts` | The subscription-harness runners (`runClaudeCode` / `runCodex`) — talk direct to the vendor with a leased OAuth token, lift per-turn usage/telemetry off the CLI event stream. |
|
|
135
143
|
| `src/transcript-retention.ts` | Lifts the CLI session transcripts (`projects/` / `sessions/`) out of the isolated, credential-bearing config home before it is deleted, and prunes them on a TTL (debugging artifact retention). |
|
|
144
|
+
| `src/captured-command.ts` | The one way the harness runs a declared shell command on its own behalf: `sh -c` with a per-command watchdog, abort handling, conventional exit codes (124/127/130) and a scrub-then-bound output capture. Shared by both pre-PR verification phases so a fix to one cannot miss the other. |
|
|
136
145
|
| `src/validation-checks.ts` | Pre-PR validation: runs the job's check commands in the checkout (bounded, secret-scrubbed capture, per-command watchdog) and drives the retry-until-green loop that gates the PR. Generic — keyed off the job body, never the agent kind. |
|
|
146
|
+
| `src/reproduction-proof.ts` | Bugfix reproduction proof: runs the job's declared reproduction command against two symmetric fresh worktrees (the pre-fix tree and the final tree) and computes red-then-green from the exit codes, with a repair loop that never fails the run. Generic — keyed off the job body, never the agent kind. |
|
|
137
147
|
| `src/logger.ts` | Structured logging. |
|
|
138
148
|
|
|
139
149
|
## Runner lifecycle knobs
|
|
@@ -147,6 +157,9 @@ runner):
|
|
|
147
157
|
| `JOB_MAX_DURATION_MS` | `3600000` (60m) | Hard ceiling on a job's wall-clock time; force-fails after. |
|
|
148
158
|
| `JOB_INACTIVITY_MS` | `600000` (10m) | Kills a hung agent that produces no output for this long. |
|
|
149
159
|
| `VALIDATION_COMMAND_TIMEOUT_MS` | `900000` (15m) | Per-command watchdog for a pre-PR validation check; a timeout counts as a failure (exit 124) so one hung command can't wedge the loop. |
|
|
160
|
+
| `REPRODUCTION_COMMAND_TIMEOUT_MS` | `900000` (15m) | Per-command watchdog for a reproduction-proof setup or check command; a timeout counts as a failure (exit 124). |
|
|
161
|
+
| `REPRODUCTION_HEARTBEAT_MS` | `30000` (30s) | How often the reproduction proof feeds the job inactivity watchdog while it runs commands the agent is not producing output for. |
|
|
162
|
+
| `REPRODUCTION_TOTAL_BUDGET_MS` | `2700000` (45m) | Wall-clock ceiling on the WHOLE proof phase (every attempt, both trees, setup included). Attempts multiply two full tree runs each and the heartbeat above deliberately stops the inactivity watchdog from firing, so this is what bounds the phase. Checked at phase boundaries; exceeding it settles `inconclusive`, never a run failure. |
|
|
150
163
|
| `HARNESS_TRANSCRIPT_TTL_MS` | `259200000` (3d) | How long lifted subscription-CLI session transcripts are kept before the retention sweep prunes them. |
|
|
151
164
|
| `HARNESS_TRANSCRIPT_ROOT` | `<tmpdir>/cf-agent-transcripts` | Where retained session transcripts are moved to (one dir per run). Meaningful only on a reused (warm-pool) container; a per-run container is torn down with the job. The TTL sweep deletes only dirs it created (each carries a `.cf-retained` marker), so pointing this at a shared directory never touches unrelated content — though a dedicated dir is still recommended. An override on a different filesystem than the config home falls back to copy-then-remove. |
|
|
152
165
|
|
package/dist/agent.js
CHANGED
|
@@ -808,6 +808,10 @@ function buildSingleRepoCodingSpec(job, pushBranch) {
|
|
|
808
808
|
// Forwarded straight off the job body — the loop is generic machinery keyed on the data, not
|
|
809
809
|
// on the agent kind.
|
|
810
810
|
...(job.validationChecks ? { validationChecks: job.validationChecks } : {}),
|
|
811
|
+
// Bugfix reproduction proof: the declared command run against the pre-fix and final trees
|
|
812
|
+
// (see docs/initiatives/bugfix-reproduction-proof.md). Forwarded straight off the job body —
|
|
813
|
+
// like the checks above, the loop is generic machinery keyed on the data, not the agent kind.
|
|
814
|
+
...(job.reproduction ? { reproduction: job.reproduction } : {}),
|
|
811
815
|
};
|
|
812
816
|
}
|
|
813
817
|
/**
|
|
@@ -818,16 +822,20 @@ function buildSingleRepoCodingSpec(job, pushBranch) {
|
|
|
818
822
|
*/
|
|
819
823
|
async function runSingleRepoCoding(job, opts) {
|
|
820
824
|
const pushBranch = job.pushBranch ?? job.newBranch ?? job.branch;
|
|
821
|
-
const { summary, stats, stderrTail, pushed, usage, callMetrics, validation, validationReport, effortReport, } = await runCodingAgent(buildSingleRepoCodingSpec(job, pushBranch), opts);
|
|
825
|
+
const { summary, stats, stderrTail, pushed, usage, callMetrics, validation, validationReport, reproductionReport, effortReport, } = await runCodingAgent(buildSingleRepoCodingSpec(job, pushBranch), opts);
|
|
822
826
|
// Ralph loop: the harness-computed validation verdict, forwarded onto the coding result as
|
|
823
827
|
// `ralphVerdict` so the backend's `toRunResult` lifts it onto `AgentRunResult.ralphVerdict`.
|
|
824
828
|
const ralphVerdict = validation ? { ralphVerdict: validation } : {};
|
|
825
829
|
// The agent's effort self-assessment, spread onto every result path below (mirrors ralphVerdict).
|
|
826
830
|
const effort = effortReport ? { effortReport } : {};
|
|
827
|
-
// The
|
|
828
|
-
// the captured proof the checkout was green when the PR opened; on the
|
|
829
|
-
// evidence behind the failure below.
|
|
830
|
-
|
|
831
|
+
// The two PRE-PR VERIFICATION reports, spread onto every result path below. The validation one:
|
|
832
|
+
// on the passing path it is the captured proof the checkout was green when the PR opened; on the
|
|
833
|
+
// exhausted path it is the evidence behind the failure below. The reproduction one is evidence
|
|
834
|
+
// on every path — it never gates the PR. Each is absent when its phase was not configured.
|
|
835
|
+
const verificationFields = {
|
|
836
|
+
...(validationReport ? { validationReport } : {}),
|
|
837
|
+
...(reproductionReport ? { reproductionReport } : {}),
|
|
838
|
+
};
|
|
831
839
|
// Pre-PR validation spent its attempt budget with the checkout still red. FAIL the job — do
|
|
832
840
|
// NOT open a pull request, and do not pretend the push succeeded as a deliverable. The work is
|
|
833
841
|
// still on the branch (a retry resumes on it); the report carries each failing command's exit
|
|
@@ -846,7 +854,7 @@ async function runSingleRepoCoding(job, opts) {
|
|
|
846
854
|
failureCause: 'agent',
|
|
847
855
|
...(usage ? { usage } : {}),
|
|
848
856
|
...(callMetrics ? { callMetrics } : {}),
|
|
849
|
-
...
|
|
857
|
+
...verificationFields,
|
|
850
858
|
...effort,
|
|
851
859
|
};
|
|
852
860
|
}
|
|
@@ -861,7 +869,7 @@ async function runSingleRepoCoding(job, opts) {
|
|
|
861
869
|
...(usage ? { usage } : {}),
|
|
862
870
|
...(callMetrics ? { callMetrics } : {}),
|
|
863
871
|
...ralphVerdict,
|
|
864
|
-
...
|
|
872
|
+
...verificationFields,
|
|
865
873
|
...effort,
|
|
866
874
|
};
|
|
867
875
|
}
|
|
@@ -874,7 +882,7 @@ async function runSingleRepoCoding(job, opts) {
|
|
|
874
882
|
failureCause: 'no-changes',
|
|
875
883
|
...(usage ? { usage } : {}),
|
|
876
884
|
...(callMetrics ? { callMetrics } : {}),
|
|
877
|
-
...
|
|
885
|
+
...verificationFields,
|
|
878
886
|
...effort,
|
|
879
887
|
};
|
|
880
888
|
}
|
|
@@ -908,7 +916,7 @@ async function runSingleRepoCoding(job, opts) {
|
|
|
908
916
|
stats,
|
|
909
917
|
...(usage ? { usage } : {}),
|
|
910
918
|
...(callMetrics ? { callMetrics } : {}),
|
|
911
|
-
...
|
|
919
|
+
...verificationFields,
|
|
912
920
|
...effort,
|
|
913
921
|
};
|
|
914
922
|
}
|
|
@@ -921,7 +929,7 @@ async function runSingleRepoCoding(job, opts) {
|
|
|
921
929
|
failureCause: 'no-changes',
|
|
922
930
|
...(usage ? { usage } : {}),
|
|
923
931
|
...(callMetrics ? { callMetrics } : {}),
|
|
924
|
-
...
|
|
932
|
+
...verificationFields,
|
|
925
933
|
...effort,
|
|
926
934
|
};
|
|
927
935
|
}
|
|
@@ -934,7 +942,7 @@ async function runSingleRepoCoding(job, opts) {
|
|
|
934
942
|
...(usage ? { usage } : {}),
|
|
935
943
|
...(callMetrics ? { callMetrics } : {}),
|
|
936
944
|
...ralphVerdict,
|
|
937
|
-
...
|
|
945
|
+
...verificationFields,
|
|
938
946
|
...effort,
|
|
939
947
|
};
|
|
940
948
|
}
|
|
@@ -946,7 +954,7 @@ async function runSingleRepoCoding(job, opts) {
|
|
|
946
954
|
...(usage ? { usage } : {}),
|
|
947
955
|
...(callMetrics ? { callMetrics } : {}),
|
|
948
956
|
...ralphVerdict,
|
|
949
|
-
...
|
|
957
|
+
...verificationFields,
|
|
950
958
|
...effort,
|
|
951
959
|
};
|
|
952
960
|
}
|
|
@@ -0,0 +1,112 @@
|
|
|
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
|
+
// The ONE way the harness runs a declared shell command on its own behalf (rather than through
|
|
5
|
+
// the agent) and keeps a bounded, secret-scrubbed record of what it printed.
|
|
6
|
+
//
|
|
7
|
+
// Both pre-PR verification phases need exactly this — the PRE-PR VALIDATION checks
|
|
8
|
+
// (`validation-checks.ts`) and the BUGFIX REPRODUCTION PROOF (`reproduction-proof.ts`) — and they
|
|
9
|
+
// need it to behave IDENTICALLY: same watchdog semantics, same abort handling, same conventional
|
|
10
|
+
// exit codes, same scrub-then-bound pipeline. They were two near-verbatim copies; a fix applied to
|
|
11
|
+
// one of them (a redaction ordering, an exit-code convention) silently missed the other, which is
|
|
12
|
+
// the whole reason this seam exists.
|
|
13
|
+
//
|
|
14
|
+
// Everything is PER-JOB by construction: the command, the cwd and the environment all arrive as
|
|
15
|
+
// arguments and nothing is read from or written to `process.env`/`HOME`. The local NATIVE
|
|
16
|
+
// transport serves every concurrent job from ONE host process, so a global would leak one job's
|
|
17
|
+
// state into a sibling's — and the container path would never catch it.
|
|
18
|
+
/**
|
|
19
|
+
* A little slack kept in the rolling capture buffer ON TOP of {@link MAX_CAPTURED_OUTPUT_CHARS},
|
|
20
|
+
* so scrubbing sees whole secrets.
|
|
21
|
+
*
|
|
22
|
+
* The buffer discards from the FRONT as output arrives, and `redactSecrets` only runs once the
|
|
23
|
+
* command settles. Without the margin a token straddling that rolling cut would already have lost
|
|
24
|
+
* its `KEY=` prefix by scrub time and would survive as an unrecognised partial. Capturing a bit
|
|
25
|
+
* more than we keep, scrubbing, and only THEN bounding to the real limit closes that window; 512
|
|
26
|
+
* chars comfortably exceeds any single credential assignment the rules match.
|
|
27
|
+
*/
|
|
28
|
+
const CAPTURE_MARGIN_CHARS = 512;
|
|
29
|
+
/**
|
|
30
|
+
* Run ONE command as `sh -c` in `cwd`, capturing a bounded, secret-scrubbed tail of its combined
|
|
31
|
+
* stdout+stderr. The exit code is the verdict — computed here by the harness, never self-reported
|
|
32
|
+
* by the model, which is the whole point of a programmatic phase. A watchdog kills the process
|
|
33
|
+
* tree on timeout and an aborted run resolves non-zero, so a phase is never what blocks a job
|
|
34
|
+
* from settling.
|
|
35
|
+
*
|
|
36
|
+
* The child inherits the JOB's environment (`RunOptions.agentEnv` layered over the process env),
|
|
37
|
+
* not a mutated global: the harness spawns this itself rather than through the agent, so without
|
|
38
|
+
* the explicit merge a native-mode job would run without the private-registry npmrc pointer (and,
|
|
39
|
+
* had this been staged in `process.env`, against a sibling job's state).
|
|
40
|
+
*
|
|
41
|
+
* `logLabel`/`logFields` shape only the two warnings this runner emits itself (the watchdog kill
|
|
42
|
+
* and a spawn failure); the caller keeps its own start/finish logging, which knows what the
|
|
43
|
+
* command MEANS.
|
|
44
|
+
*/
|
|
45
|
+
export async function runCapturedCommand(args) {
|
|
46
|
+
const { cwd, command, timeoutMs, reportTailChars, logLabel, logFields, logger, opts } = args;
|
|
47
|
+
const startedAt = Date.now();
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
let out = '';
|
|
50
|
+
let settled = false;
|
|
51
|
+
let timedOut = false;
|
|
52
|
+
const child = spawn('sh', ['-c', command], {
|
|
53
|
+
cwd,
|
|
54
|
+
detached: spawnDetached,
|
|
55
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
56
|
+
env: { ...process.env, ...opts.agentEnv },
|
|
57
|
+
});
|
|
58
|
+
// Keep only the tail (plus the scrub margin); guard against unbounded buffering on a chatty
|
|
59
|
+
// command.
|
|
60
|
+
const capture = (chunk) => {
|
|
61
|
+
out = (out + chunk.toString('utf8')).slice(-(MAX_CAPTURED_OUTPUT_CHARS + CAPTURE_MARGIN_CHARS));
|
|
62
|
+
};
|
|
63
|
+
child.stdout?.on('data', capture);
|
|
64
|
+
child.stderr?.on('data', capture);
|
|
65
|
+
const finish = (exitCode) => {
|
|
66
|
+
if (settled)
|
|
67
|
+
return;
|
|
68
|
+
settled = true;
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
opts.signal?.removeEventListener('abort', onAbort);
|
|
71
|
+
const trimmed = out.trim();
|
|
72
|
+
// Scrub BEFORE either bound: the pattern rules need a whole assignment to match, so the
|
|
73
|
+
// margin above is trimmed away only once the secrets are already gone.
|
|
74
|
+
const scrubbed = trimmed ? redactSecrets(trimmed).slice(-MAX_CAPTURED_OUTPUT_CHARS) : '';
|
|
75
|
+
resolve({
|
|
76
|
+
exitCode,
|
|
77
|
+
passed: exitCode === 0,
|
|
78
|
+
...(scrubbed ? { outputTail: boundTail(scrubbed, reportTailChars) } : {}),
|
|
79
|
+
durationMs: Date.now() - startedAt,
|
|
80
|
+
...(timedOut ? { timedOut: true } : {}),
|
|
81
|
+
...(scrubbed ? { fullTail: scrubbed } : {}),
|
|
82
|
+
});
|
|
83
|
+
};
|
|
84
|
+
const timer = setTimeout(() => {
|
|
85
|
+
logger.warn(`${logLabel}: command timed out`, { ...logFields, timeoutMs });
|
|
86
|
+
timedOut = true;
|
|
87
|
+
killChildProcess(child, undefined, logger);
|
|
88
|
+
finish(124); // conventional timeout exit code (a non-zero fail)
|
|
89
|
+
}, timeoutMs);
|
|
90
|
+
timer.unref?.();
|
|
91
|
+
const onAbort = () => {
|
|
92
|
+
killChildProcess(child, undefined, logger);
|
|
93
|
+
finish(130); // aborted (a non-zero fail)
|
|
94
|
+
};
|
|
95
|
+
opts.signal?.addEventListener('abort', onAbort, { once: true });
|
|
96
|
+
child.on('error', (err) => {
|
|
97
|
+
logger.warn(`${logLabel}: command failed to spawn`, {
|
|
98
|
+
...logFields,
|
|
99
|
+
error: err instanceof Error ? err.message : String(err),
|
|
100
|
+
});
|
|
101
|
+
finish(127); // spawn error / command not found (a non-zero fail)
|
|
102
|
+
});
|
|
103
|
+
child.on('close', (code) => finish(code ?? 1));
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
/** Bound an already-scrubbed output tail to what a REPORT carries, saying what it dropped. */
|
|
107
|
+
export function boundTail(scrubbed, maxChars) {
|
|
108
|
+
if (scrubbed.length <= maxChars)
|
|
109
|
+
return scrubbed;
|
|
110
|
+
const trimmed = scrubbed.length - maxChars;
|
|
111
|
+
return `…(${trimmed} earlier chars trimmed)\n${scrubbed.slice(-maxChars)}`;
|
|
112
|
+
}
|
package/dist/coding-agent.js
CHANGED
|
@@ -3,12 +3,13 @@ import { join } from 'node:path';
|
|
|
3
3
|
import { spawn } from 'node:child_process';
|
|
4
4
|
import { killChildProcess, spawnDetached } from './process.js';
|
|
5
5
|
import { MAX_CAPTURED_OUTPUT_CHARS, redactSecrets } from './redact.js';
|
|
6
|
-
import { branchAheadOfBase, branchHasCommitsSince, cloneExistingBranch, cloneRepo, commitTrackedEdits, createBranch, excludeFromGit, fetchReferenceBranches, headCommit, listUntrackedFiles, openPullRequest, prepareExistingCheckout, pushBranch, refreshFromBaseIfClean, remoteBranchExists, } from './git.js';
|
|
6
|
+
import { branchAheadOfBase, changedFilesSinceBase, branchHasCommitsSince, cloneExistingBranch, cloneRepo, commitTrackedEdits, createBranch, excludeFromGit, fetchReferenceBranches, headCommit, listUntrackedFiles, openPullRequest, prepareExistingCheckout, pushBranch, refreshFromBaseIfClean, remoteBranchExists, } from './git.js';
|
|
7
7
|
import { FOLLOW_UPS_FILENAME, FollowUpTailer } from './follow-ups.js';
|
|
8
8
|
import { EFFORT_REPORT_FILE } from './effort.js';
|
|
9
9
|
import { acquireRepoCheckout, agentNeverActed, agentOutputTail, runAgentInWorkspace, withWorkspace, } from './pi-workspace.js';
|
|
10
10
|
import { log } from './logger.js';
|
|
11
11
|
import { runValidationLoop, } from './validation-checks.js';
|
|
12
|
+
import { runReproductionLoop, } from './reproduction-proof.js';
|
|
12
13
|
/**
|
|
13
14
|
* How often the harness checkpoints the agent's work mid-run by pushing the branch.
|
|
14
15
|
* A per-run container can be evicted at any moment; pushing the agent's commits
|
|
@@ -154,6 +155,55 @@ export async function runCodingAgent(spec, opts = {}) {
|
|
|
154
155
|
opts.onPhase?.('agent');
|
|
155
156
|
logger.info('coding-agent: running agent', { serviceDirectory });
|
|
156
157
|
let agentRun = await runAgentPass(spec.userPrompt);
|
|
158
|
+
const foldPass = (run) => {
|
|
159
|
+
agentRun = mergeAgentPasses(agentRun, run);
|
|
160
|
+
};
|
|
161
|
+
// The new files the agent left unadded, folded into either loop's repair prompt. Both
|
|
162
|
+
// loops judge state the push will NOT carry unless it is committed — the checks run
|
|
163
|
+
// against the working tree, the proof against committed trees — so an unadded file is
|
|
164
|
+
// exactly the thing to name. A throw degrades to "no warning" inside each loop.
|
|
165
|
+
const listUncommittedNewFiles = () => listUntrackedFiles(workDir, opts.signal);
|
|
166
|
+
// BUGFIX REPRODUCTION PROOF: run the run's declared reproduction command against the
|
|
167
|
+
// pre-fix tree and the tree the PR will open from, and record whether it was red then
|
|
168
|
+
// green. Runs BEFORE the validation loop below, deliberately: validation is the GATE
|
|
169
|
+
// ("only a green checkout opens a PR"), so it has to stay the last thing that touches the
|
|
170
|
+
// tree — otherwise a reproduction repair round could leave the checkout red behind it and
|
|
171
|
+
// the PR would open anyway. Keyed purely off the job body carrying a spec (no agent-kind
|
|
172
|
+
// switch); absent ⇒ a no-op and the flow below is byte-for-byte what it was.
|
|
173
|
+
const reproduction = spec.reproduction;
|
|
174
|
+
let reproductionReport;
|
|
175
|
+
if (reproduction && (await producedWork(dir, spec, baseSha, resumed, opts))) {
|
|
176
|
+
opts.onPhase?.('reproduction');
|
|
177
|
+
reproductionReport = await runReproductionLoop({
|
|
178
|
+
dir,
|
|
179
|
+
baseSha,
|
|
180
|
+
// Re-read per attempt: a repair pass commits, so the final tree moves under the loop.
|
|
181
|
+
// `producedWork` has already committed forgotten tracked edits, and each repair round
|
|
182
|
+
// re-commits before the next read.
|
|
183
|
+
resolveFinalSha: async () => {
|
|
184
|
+
await commitTrackedEdits(dir, spec.commitMessage, signal);
|
|
185
|
+
return headCommit(dir, signal);
|
|
186
|
+
},
|
|
187
|
+
...(serviceDirectory ? { serviceDirectory } : {}),
|
|
188
|
+
spec: reproduction,
|
|
189
|
+
logger,
|
|
190
|
+
opts,
|
|
191
|
+
runAgentPass,
|
|
192
|
+
onAgentPass: foldPass,
|
|
193
|
+
listUncommittedNewFiles,
|
|
194
|
+
// Only a RESUMED run can have a pre-fix tree that already carries work: a fresh run
|
|
195
|
+
// branched off base, so `baseSha` IS base. Wiring the probe unconditionally would buy
|
|
196
|
+
// an always-empty answer for the price of a fetch — and a fresh clone is shallow, so
|
|
197
|
+
// it could not resolve a merge base to answer with anyway. Lazy inside the loop: it
|
|
198
|
+
// only runs if a tree comes back green.
|
|
199
|
+
...(resumed
|
|
200
|
+
? {
|
|
201
|
+
listBaseTreeChanges: () => changedFilesSinceBase(dir, spec.repo.baseBranch, spec.ghToken, baseSha, opts.signal),
|
|
202
|
+
}
|
|
203
|
+
: {}),
|
|
204
|
+
});
|
|
205
|
+
opts.onPhase?.('agent');
|
|
206
|
+
}
|
|
157
207
|
// PRE-PR VALIDATION: run the service's configured checks against the checkout and, while
|
|
158
208
|
// they fail and budget remains, hand the captured output back to the agent and run it
|
|
159
209
|
// again. Sits BETWEEN the agent and the finalize/push/PR step so a red checkout never
|
|
@@ -169,17 +219,16 @@ export async function runCodingAgent(spec, opts = {}) {
|
|
|
169
219
|
logger,
|
|
170
220
|
opts,
|
|
171
221
|
runAgentPass,
|
|
172
|
-
onAgentPass:
|
|
173
|
-
agentRun = mergeAgentPasses(agentRun, run);
|
|
174
|
-
},
|
|
222
|
+
onAgentPass: foldPass,
|
|
175
223
|
// The checks run against the WORKING TREE, but only tracked edits are staged for the
|
|
176
224
|
// push — so a repair round can go green on a new file the PR would never contain.
|
|
177
225
|
// Name those files in the next repair prompt so the agent adds them.
|
|
178
|
-
listUncommittedNewFiles
|
|
226
|
+
listUncommittedNewFiles,
|
|
179
227
|
});
|
|
180
228
|
}
|
|
181
229
|
outcome = await finalizeCodingRun({
|
|
182
230
|
validationReport,
|
|
231
|
+
reproductionReport,
|
|
183
232
|
dir,
|
|
184
233
|
spec,
|
|
185
234
|
logger,
|
|
@@ -314,7 +363,7 @@ async function prepareCodingCheckout(dir, spec, logger, opts) {
|
|
|
314
363
|
* {@link runCodingAgent} so its body stays small; returns the built {@link CodingAgentOutcome}.
|
|
315
364
|
*/
|
|
316
365
|
async function finalizeCodingRun(args) {
|
|
317
|
-
const { validationReport, dir, spec, logger, opts, baseSha, resumed, workDir, checkpoint, followUpTick, followUpTailer, pushWorkOnce, inFlightPush, agentRun, } = args;
|
|
366
|
+
const { validationReport, reproductionReport, dir, spec, logger, opts, baseSha, resumed, workDir, checkpoint, followUpTick, followUpTailer, pushWorkOnce, inFlightPush, agentRun, } = args;
|
|
318
367
|
const { signal } = opts;
|
|
319
368
|
const { summary, stats, stderrTail, usage, callMetrics, effortReport } = agentRun;
|
|
320
369
|
let outcome;
|
|
@@ -403,6 +452,10 @@ async function finalizeCodingRun(args) {
|
|
|
403
452
|
// backend to record on the step.
|
|
404
453
|
if (validationReport)
|
|
405
454
|
outcome.validationReport = validationReport;
|
|
455
|
+
// The reproduction proof: attached to EVERY outcome, including a no-op or an `inconclusive`
|
|
456
|
+
// verdict. It is evidence about the change, not a gate on it — see the loop's D6 note.
|
|
457
|
+
if (reproductionReport)
|
|
458
|
+
outcome.reproductionReport = reproductionReport;
|
|
406
459
|
return outcome;
|
|
407
460
|
}
|
|
408
461
|
/**
|
package/dist/git.js
CHANGED
|
@@ -509,6 +509,40 @@ export async function branchAheadOfBase(dir, baseBranch, ghToken, signal) {
|
|
|
509
509
|
return undefined;
|
|
510
510
|
}
|
|
511
511
|
}
|
|
512
|
+
/**
|
|
513
|
+
* The files `commitish` changes relative to its merge base with the PR base branch — i.e.
|
|
514
|
+
* everything the work branch has added on top of base, `git diff --name-only <base>...<commitish>`.
|
|
515
|
+
*
|
|
516
|
+
* The BUGFIX REPRODUCTION PROOF uses this to answer the one question that decides whether a GREEN
|
|
517
|
+
* pre-fix tree means anything: does that tree ALREADY carry non-test work committed on this
|
|
518
|
+
* branch? A resumed run's `baseSha` is whatever the branch tip was when this pass started, which
|
|
519
|
+
* in the designed flow is the reproduction step's test commit — but after an eviction it is this
|
|
520
|
+
* same coder step's own interrupted work, fix included. Reporting "the check passed before your
|
|
521
|
+
* change, so it does not demonstrate the defect" in that case is simply false.
|
|
522
|
+
*
|
|
523
|
+
* `undefined` means "could not determine" (a shallow clone with no reachable merge base, a fetch
|
|
524
|
+
* failure, an unknown ref), never an empty list: the caller must degrade to its prior behaviour
|
|
525
|
+
* rather than read a failed probe as "the tree is clean".
|
|
526
|
+
*
|
|
527
|
+
* NUL-delimited so a path containing a newline (legal in git) cannot split into two entries.
|
|
528
|
+
*/
|
|
529
|
+
export async function changedFilesSinceBase(dir, baseBranch, ghToken, commitish, signal) {
|
|
530
|
+
try {
|
|
531
|
+
await git(['fetch', 'origin', `+refs/heads/${baseBranch}:refs/cat-factory/base`], {
|
|
532
|
+
cwd: dir,
|
|
533
|
+
signal,
|
|
534
|
+
env: await authEnv(ghToken),
|
|
535
|
+
});
|
|
536
|
+
const out = await git(['diff', '--name-only', '-z', `refs/cat-factory/base...${commitish}`], {
|
|
537
|
+
cwd: dir,
|
|
538
|
+
signal,
|
|
539
|
+
});
|
|
540
|
+
return out.split('\0').filter((p) => p !== '');
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
return undefined;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
512
546
|
/**
|
|
513
547
|
* Whether the checked-out branch has a real, examinable diff against
|
|
514
548
|
* `origin/<baseBranch>` — i.e. the base branch's remote-tracking ref exists (so the
|
|
@@ -565,6 +599,80 @@ export async function hasAgentChanges(dir, signal) {
|
|
|
565
599
|
export async function headCommit(dir, signal) {
|
|
566
600
|
return (await git(['rev-parse', 'HEAD'], { cwd: dir, signal })).trim();
|
|
567
601
|
}
|
|
602
|
+
/**
|
|
603
|
+
* Add a DETACHED worktree of `commitish` at `worktreePath`, sharing `dir`'s object database.
|
|
604
|
+
*
|
|
605
|
+
* The bugfix reproduction proof runs the declared check against two trees of the SAME clone (the
|
|
606
|
+
* pre-fix tree and the final tree), so a worktree is the only mechanism that gets both without a
|
|
607
|
+
* second clone, a second fetch, or disturbing the agent's own checkout — which must stay exactly
|
|
608
|
+
* as the agent left it, since the push and the PR come off it.
|
|
609
|
+
*
|
|
610
|
+
* `--detach` (rather than a branch) is deliberate: a worktree that claimed a branch would collide
|
|
611
|
+
* with the work branch checked out in `dir`, and nothing here ever commits.
|
|
612
|
+
*
|
|
613
|
+
* `worktreePath` is expected to live OUTSIDE the checkout (a per-job temp root), so the worktree's
|
|
614
|
+
* `.git` pointer file can never be swept into the agent's commit by a broad `git add -A`.
|
|
615
|
+
*/
|
|
616
|
+
export async function addWorktree(dir, worktreePath, commitish, signal) {
|
|
617
|
+
await git(['worktree', 'add', '--detach', worktreePath, commitish], { cwd: dir, signal });
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Remove a worktree previously added by {@link addWorktree} and prune the stale administrative
|
|
621
|
+
* entry, never throwing: teardown is bookkeeping, and a run whose PROOF succeeded must not fail
|
|
622
|
+
* because a temp directory could not be cleaned up. The caller still deletes the temp root, so a
|
|
623
|
+
* failure here leaks only a `.git/worktrees/<name>` record inside a container that is about to be
|
|
624
|
+
* destroyed anyway.
|
|
625
|
+
*/
|
|
626
|
+
export async function removeWorktree(dir, worktreePath, signal) {
|
|
627
|
+
try {
|
|
628
|
+
await git(['worktree', 'remove', '--force', worktreePath], { cwd: dir, signal });
|
|
629
|
+
}
|
|
630
|
+
catch {
|
|
631
|
+
// Fall through to the prune, which cleans up the record even when the directory is gone.
|
|
632
|
+
}
|
|
633
|
+
try {
|
|
634
|
+
await git(['worktree', 'prune'], { cwd: dir, signal });
|
|
635
|
+
}
|
|
636
|
+
catch {
|
|
637
|
+
// Best-effort by design (see the doc comment).
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
/**
|
|
641
|
+
* Which of `paths` actually exist in `commitish`'s tree. Used by the reproduction proof to tell a
|
|
642
|
+
* DECLARED test file that was committed from one that only ever existed as an untracked working-
|
|
643
|
+
* tree file: the proof runs against committed trees, so an unadded test is invisible to it — and
|
|
644
|
+
* equally invisible to the push, which is the point worth telling the agent about rather than
|
|
645
|
+
* reporting a verdict computed without the reproduction in it.
|
|
646
|
+
*
|
|
647
|
+
* Returns the input order/spelling of the paths that matched, so the caller can diff against its
|
|
648
|
+
* declared list to name the missing ones verbatim.
|
|
649
|
+
*/
|
|
650
|
+
export async function pathsPresentAtCommit(dir, commitish, paths, signal) {
|
|
651
|
+
if (paths.length === 0)
|
|
652
|
+
return [];
|
|
653
|
+
const out = await git(['ls-tree', '-r', '--name-only', '-z', commitish, '--', ...paths], {
|
|
654
|
+
cwd: dir,
|
|
655
|
+
signal,
|
|
656
|
+
});
|
|
657
|
+
// NUL-delimited so a path containing a newline (legal in git) can't split into two entries.
|
|
658
|
+
const present = new Set(out.split('\0').filter((p) => p !== ''));
|
|
659
|
+
return paths.filter((p) => present.has(p));
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Check `paths` out of `commitish` into `dir`'s working tree (and index), leaving every other file
|
|
663
|
+
* untouched.
|
|
664
|
+
*
|
|
665
|
+
* This is how the reproduction's declared TEST files are placed onto the pre-fix worktree, and the
|
|
666
|
+
* narrowness is the whole safety property: a whole-tree checkout would drag the FIX across too and
|
|
667
|
+
* green the base, manufacturing a "the test does not capture the defect" verdict out of a
|
|
668
|
+
* perfectly good reproduction. Only the paths the caller has already sanitized are passed, and
|
|
669
|
+
* `--` stops any of them being read as a revision.
|
|
670
|
+
*/
|
|
671
|
+
export async function checkoutPathsFrom(dir, commitish, paths, signal) {
|
|
672
|
+
if (paths.length === 0)
|
|
673
|
+
return;
|
|
674
|
+
await git(['checkout', commitish, '--', ...paths], { cwd: dir, signal });
|
|
675
|
+
}
|
|
568
676
|
/** Stage everything and commit; returns false when there was nothing to commit. */
|
|
569
677
|
export async function commitAll(dir, message, signal) {
|
|
570
678
|
await git(['add', '-A'], { cwd: dir, signal });
|
package/dist/job.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { parseValidationChecksSpec, } from './validation-checks.js';
|
|
2
|
+
import { parseReproductionSpec, } from './reproduction-proof.js';
|
|
1
3
|
function str(value, path) {
|
|
2
4
|
if (typeof value !== 'string' || value.length === 0) {
|
|
3
5
|
throw new Error(`Invalid job: '${path}' must be a non-empty string`);
|
|
@@ -66,39 +68,6 @@ function parseValidationSpec(value) {
|
|
|
66
68
|
...(iteration !== undefined ? { iteration } : {}),
|
|
67
69
|
};
|
|
68
70
|
}
|
|
69
|
-
/**
|
|
70
|
-
* Parse the optional PRE-PR VALIDATION CHECKS spec (see
|
|
71
|
-
* docs/initiatives/pre-pr-validation.md): the service's ordered `{ label, command }` pairs and
|
|
72
|
-
* the repair-round budget. Every entry needs a non-empty command; entries without one are
|
|
73
|
-
* dropped, and a spec that ends up with no usable check returns `undefined` — so a malformed
|
|
74
|
-
* body degrades to the exact pre-feature behaviour (no loop, PR opens as before) rather than
|
|
75
|
-
* failing an otherwise-good coding run. `maxAttempts` is clamped to a sane range so a bad body
|
|
76
|
-
* can't make a container loop forever.
|
|
77
|
-
*/
|
|
78
|
-
function parseValidationChecksSpec(value) {
|
|
79
|
-
if (typeof value !== 'object' || value === null)
|
|
80
|
-
return undefined;
|
|
81
|
-
const o = value;
|
|
82
|
-
if (!Array.isArray(o.checks))
|
|
83
|
-
return undefined;
|
|
84
|
-
const checks = [];
|
|
85
|
-
for (const raw of o.checks) {
|
|
86
|
-
if (typeof raw !== 'object' || raw === null)
|
|
87
|
-
continue;
|
|
88
|
-
const c = raw;
|
|
89
|
-
if (typeof c.command !== 'string' || c.command.trim() === '')
|
|
90
|
-
continue;
|
|
91
|
-
const label = typeof c.label === 'string' && c.label.trim() ? c.label.trim() : c.command;
|
|
92
|
-
checks.push({ label, command: c.command });
|
|
93
|
-
}
|
|
94
|
-
if (checks.length === 0)
|
|
95
|
-
return undefined;
|
|
96
|
-
const parsed = posInt(o.maxAttempts);
|
|
97
|
-
return {
|
|
98
|
-
checks,
|
|
99
|
-
maxAttempts: Math.min(parsed ?? VALIDATION_DEFAULT_MAX_ATTEMPTS, VALIDATION_MAX_ATTEMPTS_CEILING),
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
71
|
/**
|
|
103
72
|
* Parse the shared per-job auth fields, validating per harness: a subscription
|
|
104
73
|
* harness (`claude-code` / `codex`) requires `subscriptionToken`; the default Pi
|
|
@@ -386,18 +355,6 @@ export function parseTestSecrets(value) {
|
|
|
386
355
|
}
|
|
387
356
|
return entries;
|
|
388
357
|
}
|
|
389
|
-
/**
|
|
390
|
-
* The ceiling the harness clamps a body-supplied `validationChecks.maxAttempts` to, and the
|
|
391
|
-
* default it applies when the body omits one.
|
|
392
|
-
*
|
|
393
|
-
* DELIBERATE DUPLICATES of `VALIDATION_MAX_ATTEMPTS_CEILING` / `VALIDATION_DEFAULT_MAX_ATTEMPTS`
|
|
394
|
-
* in `@cat-factory/contracts` — the published image takes no schema dependency, so the harness
|
|
395
|
-
* cannot import them. Keep the two in step: the API validates writes against the contracts
|
|
396
|
-
* values, so a harness clamping to a DIFFERENT ceiling would silently cap a budget an operator
|
|
397
|
-
* was allowed to save, with nothing to flag the mismatch.
|
|
398
|
-
*/
|
|
399
|
-
export const VALIDATION_MAX_ATTEMPTS_CEILING = 10;
|
|
400
|
-
export const VALIDATION_DEFAULT_MAX_ATTEMPTS = 3;
|
|
401
358
|
/** Parse the coding-mode bootstrap spec, or undefined when absent. Validates the target. */
|
|
402
359
|
function parseAgentBootstrapSpec(value) {
|
|
403
360
|
if (typeof value !== 'object' || value === null)
|
|
@@ -717,6 +674,7 @@ export function parseAgentJob(input) {
|
|
|
717
674
|
guardLimits: parseGuardLimits(o.guardLimits),
|
|
718
675
|
validation: parseValidationSpec(o.validation),
|
|
719
676
|
validationChecks: parseValidationChecksSpec(o.validationChecks),
|
|
677
|
+
reproduction: parseReproductionSpec(o.reproduction),
|
|
720
678
|
reviewPrNumber: posInt(o.reviewPrNumber),
|
|
721
679
|
});
|
|
722
680
|
assertAllowedHost(job.repo.cloneUrl, 'repo.cloneUrl');
|
|
@@ -773,7 +731,7 @@ function parseAgentPrSpec(raw) {
|
|
|
773
731
|
* literal doesn't blow the complexity budget; behaviour is byte-identical (spread order preserved).
|
|
774
732
|
*/
|
|
775
733
|
function assembleAgentJob(o, mode, agentField, parts) {
|
|
776
|
-
const { output, pr, infra, peerRepos, referenceRepos, referenceBranches, bootstrap, contextFiles, packageRegistries, skill, testSecrets, guardLimits, validation, validationChecks, reviewPrNumber, } = parts;
|
|
734
|
+
const { output, pr, infra, peerRepos, referenceRepos, referenceBranches, bootstrap, contextFiles, packageRegistries, skill, testSecrets, guardLimits, validation, validationChecks, reproduction, reviewPrNumber, } = parts;
|
|
777
735
|
const repo = (o.repo ?? {});
|
|
778
736
|
return {
|
|
779
737
|
jobId: str(o.jobId, 'jobId'),
|
|
@@ -801,6 +759,7 @@ function assembleAgentJob(o, mode, agentField, parts) {
|
|
|
801
759
|
...(guardLimits ? { guardLimits } : {}),
|
|
802
760
|
...(validation ? { validation } : {}),
|
|
803
761
|
...(validationChecks ? { validationChecks } : {}),
|
|
762
|
+
...(reproduction ? { reproduction } : {}),
|
|
804
763
|
};
|
|
805
764
|
}
|
|
806
765
|
/**
|