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