@cat-factory/executor-harness 1.54.0 → 1.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,300 @@
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
+ /**
5
+ * Per-command output kept on the REPORT (what crosses the wire and lands in the run's persisted
6
+ * `detail` blob). Deliberately smaller than {@link MAX_CAPTURED_OUTPUT_CHARS}, which is what the
7
+ * AGENT sees in its repair prompt: the agent needs the full failure to fix it, the operator needs
8
+ * enough to recognise it, and a chatty build must not inflate every run's stored state.
9
+ */
10
+ export const VALIDATION_REPORT_TAIL_CHARS = 4_000;
11
+ /**
12
+ * The per-command watchdog: the longest a single check may run before it is killed and treated
13
+ * as a failure, so one hung `pnpm test` cannot wedge a run. Overridable via env for tests;
14
+ * defaults to 15 minutes (matching the ralph completion command's watchdog).
15
+ */
16
+ export function validationCommandTimeoutMs() {
17
+ const n = Number(process.env.VALIDATION_COMMAND_TIMEOUT_MS);
18
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 15 * 60_000;
19
+ }
20
+ /**
21
+ * How often the check loop feeds the run's inactivity watchdog. Well under the harness's own
22
+ * `JOB_INACTIVITY_MS` (default 10 min) so a single slow command can never look wedged; matches
23
+ * the frontend stand-up's heartbeat, which exists for exactly the same reason. Overridable via
24
+ * env for tests, like {@link validationCommandTimeoutMs}.
25
+ */
26
+ export function validationHeartbeatMs() {
27
+ const n = Number(process.env.VALIDATION_HEARTBEAT_MS);
28
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 30_000;
29
+ }
30
+ /**
31
+ * Run every configured check IN ORDER against `cwd` and build the attempt's report.
32
+ *
33
+ * Runs all of them even after one fails, rather than short-circuiting: the agent repairing the
34
+ * checkout should see every problem at once instead of rediscovering the next one on the next
35
+ * round, which is the difference between one repair round and four. (A check whose failure makes
36
+ * the rest meaningless — e.g. a failed install — still costs only the cheap downstream failures.)
37
+ *
38
+ * Keeps the run's inactivity watchdog fed for the whole attempt. These commands are exactly the
39
+ * activity-SILENT kind — a cold `install`, a full `test` run, a `build` — and the harness spawns
40
+ * them itself rather than through the agent, so they emit no activity events of their own. The
41
+ * job-level watchdog (`JOB_INACTIVITY_MS`, default 10 min) is TIGHTER than one command's own
42
+ * watchdog ({@link validationCommandTimeoutMs}, default 15 min), so without this a legitimately
43
+ * slow check would abort the entire run as "inactivity" — mislabelling a healthy build as a
44
+ * wedge, and making the per-command timeout unreachable at stock settings.
45
+ */
46
+ export async function runValidationChecks(cwd, spec, attempt, logger, opts) {
47
+ const outcomes = [];
48
+ const fullTails = new Map();
49
+ const heartbeat = setInterval(() => opts.onActivity?.(), validationHeartbeatMs());
50
+ heartbeat.unref?.();
51
+ try {
52
+ for (const check of spec.checks) {
53
+ const { outcome, fullTail } = await runOneCheck(cwd, check, logger, opts);
54
+ outcomes.push(outcome);
55
+ if (fullTail)
56
+ fullTails.set(check.label, fullTail);
57
+ }
58
+ }
59
+ finally {
60
+ clearInterval(heartbeat);
61
+ }
62
+ const report = {
63
+ passed: outcomes.every((o) => o.passed),
64
+ attempts: attempt,
65
+ maxAttempts: spec.maxAttempts,
66
+ outcomes,
67
+ at: Date.now(),
68
+ };
69
+ logger.info('validation: attempt finished', {
70
+ attempt,
71
+ maxAttempts: spec.maxAttempts,
72
+ passed: report.passed,
73
+ failed: outcomes.filter((o) => !o.passed).map((o) => o.label),
74
+ });
75
+ return { report, fullTails };
76
+ }
77
+ /**
78
+ * Run ONE check as `sh -c <command>` in `cwd`, capturing a bounded, secret-scrubbed tail of its
79
+ * combined stdout+stderr. The exit code is the verdict — computed here by the harness, never
80
+ * self-reported by the model, which is the whole point of a programmatic gate. A watchdog kills
81
+ * the process tree on timeout and an aborted run resolves non-zero, so the loop is never blocked.
82
+ *
83
+ * The child inherits the JOB's environment (`RunOptions.agentEnv` layered over the process env),
84
+ * not a mutated global: the harness spawns this itself rather than through the agent, so without
85
+ * the explicit merge a native-mode job would run its checks without the private-registry npmrc
86
+ * pointer (and against a sibling job's state, had this been staged in `process.env`).
87
+ */
88
+ async function runOneCheck(cwd, check, logger, opts) {
89
+ const timeoutMs = validationCommandTimeoutMs();
90
+ const startedAt = Date.now();
91
+ logger.info('validation: running check', { label: check.label });
92
+ return new Promise((resolve) => {
93
+ let out = '';
94
+ let settled = false;
95
+ let timedOut = false;
96
+ const child = spawn('sh', ['-c', check.command], {
97
+ cwd,
98
+ detached: spawnDetached,
99
+ stdio: ['ignore', 'pipe', 'pipe'],
100
+ env: { ...process.env, ...opts.agentEnv },
101
+ });
102
+ // Keep only the tail; guard against unbounded buffering on a chatty command.
103
+ const capture = (chunk) => {
104
+ out = (out + chunk.toString('utf8')).slice(-MAX_CAPTURED_OUTPUT_CHARS);
105
+ };
106
+ child.stdout?.on('data', capture);
107
+ child.stderr?.on('data', capture);
108
+ const finish = (exitCode) => {
109
+ if (settled)
110
+ return;
111
+ settled = true;
112
+ clearTimeout(timer);
113
+ opts.signal?.removeEventListener('abort', onAbort);
114
+ const trimmed = out.trim();
115
+ // Scrub BEFORE truncating: a token straddling the cut would otherwise survive as a
116
+ // partial, and the pattern rules need the whole assignment to match.
117
+ const scrubbed = trimmed ? redactSecrets(trimmed) : '';
118
+ logger.info('validation: check finished', { label: check.label, exitCode });
119
+ resolve({
120
+ outcome: {
121
+ label: check.label,
122
+ command: check.command,
123
+ exitCode,
124
+ passed: exitCode === 0,
125
+ ...(scrubbed ? { outputTail: tailFor(scrubbed) } : {}),
126
+ durationMs: Date.now() - startedAt,
127
+ ...(timedOut ? { timedOut: true } : {}),
128
+ },
129
+ ...(scrubbed ? { fullTail: scrubbed } : {}),
130
+ });
131
+ };
132
+ const timer = setTimeout(() => {
133
+ logger.warn('validation: check timed out', { label: check.label, timeoutMs });
134
+ timedOut = true;
135
+ killChildProcess(child, undefined, logger);
136
+ finish(124); // conventional timeout exit code (a non-zero fail)
137
+ }, timeoutMs);
138
+ timer.unref?.();
139
+ const onAbort = () => {
140
+ killChildProcess(child, undefined, logger);
141
+ finish(130); // aborted (a non-zero fail)
142
+ };
143
+ opts.signal?.addEventListener('abort', onAbort, { once: true });
144
+ child.on('error', (err) => {
145
+ logger.warn('validation: check failed to spawn', {
146
+ label: check.label,
147
+ error: err instanceof Error ? err.message : String(err),
148
+ });
149
+ finish(127); // spawn error / command not found (a non-zero fail)
150
+ });
151
+ child.on('close', (code) => finish(code ?? 1));
152
+ });
153
+ }
154
+ /** Bound an already-scrubbed output tail to what the REPORT carries. */
155
+ function tailFor(scrubbed) {
156
+ if (scrubbed.length <= VALIDATION_REPORT_TAIL_CHARS)
157
+ return scrubbed;
158
+ const trimmed = scrubbed.length - VALIDATION_REPORT_TAIL_CHARS;
159
+ return `…(${trimmed} earlier chars trimmed)\n${scrubbed.slice(-VALIDATION_REPORT_TAIL_CHARS)}`;
160
+ }
161
+ /**
162
+ * The repair instruction handed to the agent after a failed attempt: the failing commands and
163
+ * their captured output, plus an explicit statement of the exit condition and the remaining
164
+ * budget. The FULL captured tail is used here (not the report's smaller bound) — the agent needs
165
+ * the whole failure to fix it, and this text never leaves the container.
166
+ *
167
+ * Deliberately prescriptive about scope: a validation loop that lets the agent "fix" the failure
168
+ * by weakening the check is worse than no loop at all, so the prompt forbids editing the
169
+ * commands' configuration to make them pass.
170
+ */
171
+ export function buildRepairPrompt(report, fullTails,
172
+ /**
173
+ * New files the agent created but never `git add`ed, if the caller can tell. The harness only
174
+ * auto-stages TRACKED edits (`git add -u`), so an uncommitted new file is invisible to the push
175
+ * — yet fully visible to the checks, which run against the working tree. Naming them here is
176
+ * what stops the loop going green on work the pull request would not contain.
177
+ */
178
+ untrackedFiles = []) {
179
+ const failed = report.outcomes.filter((o) => !o.passed);
180
+ const blocks = failed
181
+ .map((o) => {
182
+ const body = fullTails.get(o.label) ?? o.outputTail ?? '(no output captured)';
183
+ const reason = o.timedOut
184
+ ? `timed out after ${Math.round((o.durationMs ?? 0) / 1000)}s`
185
+ : `exited ${o.exitCode}`;
186
+ return `### ${o.label} — ${reason}\n\n\`\`\`\n$ ${o.command}\n${body}\n\`\`\``;
187
+ })
188
+ .join('\n\n');
189
+ const remaining = report.maxAttempts - report.attempts;
190
+ const untracked = untrackedFiles.length
191
+ ? [
192
+ '',
193
+ '## Uncommitted new files',
194
+ '',
195
+ 'These files exist in your checkout but were never added to git, so they are NOT part of',
196
+ 'the branch even though the checks above ran against them. `git add` each one you meant to',
197
+ 'keep (or delete it), or the checks will pass on work the pull request will not contain:',
198
+ '',
199
+ ...untrackedFiles.map((f) => `- ${f}`),
200
+ ]
201
+ : [];
202
+ return [
203
+ 'The work you just finished does NOT pass this service’s required validation checks, so',
204
+ 'no pull request has been opened. Fix the failures below, then stop.',
205
+ '',
206
+ blocks,
207
+ ...untracked,
208
+ '',
209
+ '## How this is judged',
210
+ '',
211
+ `The checks above are re-run automatically against your checkout when you stop. They are the`,
212
+ `exit condition: a pull request opens only once every one of them exits 0. You have`,
213
+ `${remaining} attempt(s) left before this task fails.`,
214
+ '',
215
+ '## Rules',
216
+ '',
217
+ '- Fix the underlying problem in the code. Do NOT edit, disable, skip, or relax the checks',
218
+ ' themselves (their scripts, configs, thresholds, ignore files, or test assertions) to make',
219
+ ' them pass — a green check obtained that way is a failed task.',
220
+ '- Do not revert your earlier work; build on it.',
221
+ '- Commit your fixes, as you did before. `git add` any NEW file you create — only changes to',
222
+ ' files already tracked by git are staged for you, so an unadded file is silently dropped',
223
+ ' from the branch even though the checks can see it.',
224
+ ].join('\n');
225
+ }
226
+ /**
227
+ * The pre-PR validation LOOP: run the checks, and while they fail and budget remains, hand the
228
+ * captured output back to the agent as its next instruction and check again. Returns the LAST
229
+ * attempt's report — `passed: true` means the caller may open the PR; `passed: false` means the
230
+ * budget is spent and the caller must FAIL the job with this report as the evidence, opening
231
+ * nothing.
232
+ *
233
+ * Generic by construction: it knows nothing about agent kinds, repos or PRs — only how to run
234
+ * commands in a directory and how to ask for another pass. Every input (`workDir`, `spec`,
235
+ * `opts.agentEnv`) is per-job, so two concurrent jobs on the ONE local-native host process cannot
236
+ * see each other's configuration (`validation-checks.concurrency.test.ts` pins this).
237
+ *
238
+ * `onAttempt` publishes each completed attempt on the job view so the loop is observable while it
239
+ * runs; `onAgentPass` lets the caller fold each repair pass's stats/usage/telemetry into the run's
240
+ * totals, so a 3-round loop reports what all 3 rounds actually spent.
241
+ */
242
+ export async function runValidationLoop(args) {
243
+ const { workDir, spec, logger, opts, runAgentPass, onAgentPass, listUncommittedNewFiles } = args;
244
+ let attempt = 1;
245
+ for (;;) {
246
+ const { report, fullTails } = await runValidationChecks(workDir, spec, attempt, logger, opts);
247
+ opts.onValidationReport?.(report);
248
+ if (report.passed) {
249
+ logger.info('validation: checkout is green', { attempt });
250
+ return report;
251
+ }
252
+ if (attempt >= spec.maxAttempts) {
253
+ logger.warn('validation: attempt budget spent — no PR will be opened', {
254
+ attempt,
255
+ maxAttempts: spec.maxAttempts,
256
+ });
257
+ return report;
258
+ }
259
+ attempt += 1;
260
+ logger.info('validation: repairing', { nextAttempt: attempt });
261
+ opts.onPhase?.('validation-repair');
262
+ const untracked = await safeListUncommitted(listUncommittedNewFiles, logger);
263
+ const run = await runAgentPass(buildRepairPrompt(report, fullTails, untracked));
264
+ onAgentPass?.(run);
265
+ opts.onPhase?.('agent');
266
+ }
267
+ }
268
+ /**
269
+ * The uncommitted-new-file list for a repair prompt, never throwing: this is an ADVISORY
270
+ * addition to the instruction, so a `git` hiccup must degrade to "no warning" rather than
271
+ * failing a loop that is otherwise working.
272
+ */
273
+ async function safeListUncommitted(list, logger) {
274
+ if (!list)
275
+ return [];
276
+ try {
277
+ return await list();
278
+ }
279
+ catch (error) {
280
+ logger.warn('validation: could not list uncommitted new files', {
281
+ error: error instanceof Error ? error.message : String(error),
282
+ });
283
+ return [];
284
+ }
285
+ }
286
+ /**
287
+ * The failure message for a run whose pre-PR validation never went green: which checks failed
288
+ * (with exit codes) and the last one's captured output. Read by the operator on the step's
289
+ * failure card, so it must say what broke without needing the full report opened.
290
+ */
291
+ export function validationFailureMessage(report) {
292
+ const failed = report.outcomes.filter((o) => !o.passed);
293
+ const names = failed.map((o) => `${o.label} (exit ${o.exitCode})`).join(', ');
294
+ const last = failed[failed.length - 1];
295
+ const tail = last?.outputTail?.trim();
296
+ const head = `pre-PR validation failed after ${report.attempts} of ${report.maxAttempts} attempt(s)` +
297
+ (names ? `: ${names}` : '') +
298
+ '. No pull request was opened.';
299
+ return tail ? `${head}\n\n$ ${last?.command}\n${tail}` : head;
300
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.54.0",
3
+ "version": "1.56.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",
@@ -26,8 +26,8 @@
26
26
  "hono": "^4.12.30",
27
27
  "typescript": "7.0.2",
28
28
  "vitest": "^4.1.10",
29
- "@cat-factory/server": "0.144.4",
30
- "@cat-factory/spend": "0.12.77"
29
+ "@cat-factory/server": "0.149.0",
30
+ "@cat-factory/spend": "0.12.83"
31
31
  },
32
32
  "scripts": {
33
33
  "build": "tsc -p tsconfig.json",
package/src/agent.ts CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  runCodingAgent,
39
39
  runMultiRepoCoding,
40
40
  } from './coding-agent.js'
41
+ import { validationFailureMessage } from './validation-checks.js'
41
42
  import {
42
43
  acquireRepoCheckout,
43
44
  agentNeverActed,
@@ -966,6 +967,11 @@ function buildSingleRepoCodingSpec(
966
967
  },
967
968
  }
968
969
  : {}),
970
+ // Pre-PR validation: the service's check commands, run against the checkout BEFORE the PR
971
+ // opens with failures fed back to the agent (see docs/initiatives/pre-pr-validation.md).
972
+ // Forwarded straight off the job body — the loop is generic machinery keyed on the data, not
973
+ // on the agent kind.
974
+ ...(job.validationChecks ? { validationChecks: job.validationChecks } : {}),
969
975
  }
970
976
  }
971
977
 
@@ -977,13 +983,49 @@ function buildSingleRepoCodingSpec(
977
983
  */
978
984
  async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<AgentResult> {
979
985
  const pushBranch = job.pushBranch ?? job.newBranch ?? job.branch
980
- const { summary, stats, stderrTail, pushed, usage, callMetrics, validation, effortReport } =
981
- await runCodingAgent(buildSingleRepoCodingSpec(job, pushBranch), opts)
986
+ const {
987
+ summary,
988
+ stats,
989
+ stderrTail,
990
+ pushed,
991
+ usage,
992
+ callMetrics,
993
+ validation,
994
+ validationReport,
995
+ effortReport,
996
+ } = await runCodingAgent(buildSingleRepoCodingSpec(job, pushBranch), opts)
982
997
  // Ralph loop: the harness-computed validation verdict, forwarded onto the coding result as
983
998
  // `ralphVerdict` so the backend's `toRunResult` lifts it onto `AgentRunResult.ralphVerdict`.
984
999
  const ralphVerdict = validation ? { ralphVerdict: validation } : {}
985
1000
  // The agent's effort self-assessment, spread onto every result path below (mirrors ralphVerdict).
986
1001
  const effort = effortReport ? { effortReport } : {}
1002
+ // The pre-PR validation report, spread onto every result path below: on the passing path it is
1003
+ // the captured proof the checkout was green when the PR opened; on the exhausted path it is the
1004
+ // evidence behind the failure below. Absent when the service configured no checks.
1005
+ const validationFields = validationReport ? { validationReport } : {}
1006
+
1007
+ // Pre-PR validation spent its attempt budget with the checkout still red. FAIL the job — do
1008
+ // NOT open a pull request, and do not pretend the push succeeded as a deliverable. The work is
1009
+ // still on the branch (a retry resumes on it); the report carries each failing command's exit
1010
+ // code and captured output so the step's failure detail says exactly what broke.
1011
+ if (validationReport && !validationReport.passed) {
1012
+ return {
1013
+ // The work IS on the branch (the loop only runs for a pass that produced some, and the
1014
+ // harness pushes it) — a retry resumes on top of it. `error` is what marks the job failed;
1015
+ // reporting `pushed: false` here would misdescribe the branch state in the harness's own
1016
+ // result for no benefit.
1017
+ pushed,
1018
+ branch: pushBranch,
1019
+ summary,
1020
+ stats,
1021
+ error: validationFailureMessage(validationReport),
1022
+ failureCause: 'agent',
1023
+ ...(usage ? { usage } : {}),
1024
+ ...(callMetrics ? { callMetrics } : {}),
1025
+ ...validationFields,
1026
+ ...effort,
1027
+ }
1028
+ }
987
1029
 
988
1030
  if (!pushed) {
989
1031
  // A no-op: a failure for the implementer, a clean non-event for the fixers.
@@ -996,6 +1038,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
996
1038
  ...(usage ? { usage } : {}),
997
1039
  ...(callMetrics ? { callMetrics } : {}),
998
1040
  ...ralphVerdict,
1041
+ ...validationFields,
999
1042
  ...effort,
1000
1043
  }
1001
1044
  }
@@ -1008,6 +1051,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
1008
1051
  failureCause: 'no-changes',
1009
1052
  ...(usage ? { usage } : {}),
1010
1053
  ...(callMetrics ? { callMetrics } : {}),
1054
+ ...validationFields,
1011
1055
  ...effort,
1012
1056
  }
1013
1057
  }
@@ -1042,6 +1086,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
1042
1086
  stats,
1043
1087
  ...(usage ? { usage } : {}),
1044
1088
  ...(callMetrics ? { callMetrics } : {}),
1089
+ ...validationFields,
1045
1090
  ...effort,
1046
1091
  }
1047
1092
  }
@@ -1058,6 +1103,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
1058
1103
  failureCause: 'no-changes',
1059
1104
  ...(usage ? { usage } : {}),
1060
1105
  ...(callMetrics ? { callMetrics } : {}),
1106
+ ...validationFields,
1061
1107
  ...effort,
1062
1108
  }
1063
1109
  }
@@ -1070,6 +1116,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
1070
1116
  ...(usage ? { usage } : {}),
1071
1117
  ...(callMetrics ? { callMetrics } : {}),
1072
1118
  ...ralphVerdict,
1119
+ ...validationFields,
1073
1120
  ...effort,
1074
1121
  }
1075
1122
  }
@@ -1081,6 +1128,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
1081
1128
  ...(usage ? { usage } : {}),
1082
1129
  ...(callMetrics ? { callMetrics } : {}),
1083
1130
  ...ralphVerdict,
1131
+ ...validationFields,
1084
1132
  ...effort,
1085
1133
  }
1086
1134
  }
@@ -42,6 +42,11 @@ import {
42
42
  import type { ProgressGuardLimits } from './pi.js'
43
43
  import type { RunOptions } from './runner.js'
44
44
  import { log, type Logger } from './logger.js'
45
+ import {
46
+ runValidationLoop,
47
+ type ValidationChecksSpec,
48
+ type ValidationReport,
49
+ } from './validation-checks.js'
45
50
 
46
51
  // The shared skeleton for the container coding agents that clone a repo, run Pi
47
52
  // against it and push the result on a branch. The implementation (`/run`) and
@@ -103,6 +108,15 @@ export interface CodingAgentSpec extends HarnessAuthFields {
103
108
  * condition — computed by the harness, never the model). Absent for every non-`ralph` run.
104
109
  */
105
110
  validation?: { command: string; iteration?: number }
111
+ /**
112
+ * PRE-PR VALIDATION: the service's configured check commands + repair-round budget. When set,
113
+ * the harness runs them against the checkout after the agent settles and, while they fail and
114
+ * budget remains, re-runs the agent with the captured output as its instruction. A red checkout
115
+ * at the end means the caller opens NO pull request and fails the job. Set only for a dispatch
116
+ * that opens a PR and whose service configured checks; absent everywhere else. See
117
+ * `docs/initiatives/pre-pr-validation.md`.
118
+ */
119
+ validationChecks?: ValidationChecksSpec
106
120
  /**
107
121
  * A repo-sourced Claude Skill to make available for this run (a `skill` step, slice 2). Threaded
108
122
  * into {@link runAgentInWorkspace}, which installs it harness-aware: natively under the ISOLATED
@@ -138,6 +152,12 @@ export interface CodingAgentOutcome {
138
152
  validationOutputTail?: string
139
153
  iteration?: number
140
154
  }
155
+ /**
156
+ * The pre-PR validation loop's LAST attempt (present only when {@link CodingAgentSpec.validationChecks}
157
+ * was set). `passed: false` means the attempt budget was spent with the checkout still red —
158
+ * the caller must open no PR and fail the job with this as the evidence.
159
+ */
160
+ validationReport?: ValidationReport
141
161
  }
142
162
 
143
163
  /**
@@ -271,15 +291,17 @@ export async function runCodingAgent(
271
291
  followUpTick.unref?.()
272
292
  }
273
293
 
274
- let outcome: CodingAgentOutcome
275
- try {
276
- opts.onPhase?.('agent')
277
- logger.info('coding-agent: running agent', { serviceDirectory })
278
- const agentRun = await runAgentInWorkspace(
294
+ // One agent pass over this checkout, parameterised only by the prompt — so the pre-PR
295
+ // validation loop below can re-run the agent with a repair instruction without
296
+ // re-deriving (or drifting from) the dispatch's own settings.
297
+ const runAgentPass = (
298
+ userPrompt: string,
299
+ ): Promise<Awaited<ReturnType<typeof runAgentInWorkspace>>> =>
300
+ runAgentInWorkspace(
279
301
  {
280
302
  dir: workDir,
281
303
  systemPrompt: spec.systemPrompt,
282
- userPrompt: spec.userPrompt,
304
+ userPrompt,
283
305
  model: spec.model,
284
306
  harness: spec.harness,
285
307
  subscriptionToken: spec.subscriptionToken,
@@ -295,7 +317,38 @@ export async function runCodingAgent(
295
317
  },
296
318
  opts,
297
319
  )
320
+
321
+ let outcome: CodingAgentOutcome
322
+ try {
323
+ opts.onPhase?.('agent')
324
+ logger.info('coding-agent: running agent', { serviceDirectory })
325
+ let agentRun = await runAgentPass(spec.userPrompt)
326
+ // PRE-PR VALIDATION: run the service's configured checks against the checkout and, while
327
+ // they fail and budget remains, hand the captured output back to the agent and run it
328
+ // again. Sits BETWEEN the agent and the finalize/push/PR step so a red checkout never
329
+ // reaches `openPullRequest` — the whole point of the feature. Keyed purely off the job
330
+ // body carrying checks (no agent-kind switch); absent ⇒ this is a no-op and the flow
331
+ // below is byte-for-byte what it was.
332
+ const validationChecks = spec.validationChecks
333
+ let validationReport: ValidationReport | undefined
334
+ if (validationChecks && (await producedWork(dir, spec, baseSha, resumed, opts))) {
335
+ validationReport = await runValidationLoop({
336
+ workDir,
337
+ spec: validationChecks,
338
+ logger,
339
+ opts,
340
+ runAgentPass,
341
+ onAgentPass: (run) => {
342
+ agentRun = mergeAgentPasses(agentRun, run)
343
+ },
344
+ // The checks run against the WORKING TREE, but only tracked edits are staged for the
345
+ // push — so a repair round can go green on a new file the PR would never contain.
346
+ // Name those files in the next repair prompt so the agent adds them.
347
+ listUncommittedNewFiles: () => listUntrackedFiles(workDir, opts.signal),
348
+ })
349
+ }
298
350
  outcome = await finalizeCodingRun({
351
+ validationReport,
299
352
  dir,
300
353
  spec,
301
354
  logger,
@@ -444,6 +497,8 @@ async function prepareCodingCheckout(
444
497
  * {@link runCodingAgent} so its body stays small; returns the built {@link CodingAgentOutcome}.
445
498
  */
446
499
  async function finalizeCodingRun(args: {
500
+ /** The pre-PR validation loop's last attempt, attached to the outcome (absent when unconfigured). */
501
+ validationReport?: ValidationReport
447
502
  dir: string
448
503
  spec: CodingAgentSpec
449
504
  logger: Logger
@@ -459,6 +514,7 @@ async function finalizeCodingRun(args: {
459
514
  agentRun: Awaited<ReturnType<typeof runAgentInWorkspace>>
460
515
  }): Promise<CodingAgentOutcome> {
461
516
  const {
517
+ validationReport,
462
518
  dir,
463
519
  spec,
464
520
  logger,
@@ -558,9 +614,74 @@ async function finalizeCodingRun(args: {
558
614
  if (spec.validation) {
559
615
  outcome.validation = await runRalphValidation(workDir, spec.validation, logger, opts)
560
616
  }
617
+ // Pre-PR validation: the loop already ran (before this finalize, so a red checkout never
618
+ // reaches the PR-opening caller); attach its verdict for the caller to gate on and for the
619
+ // backend to record on the step.
620
+ if (validationReport) outcome.validationReport = validationReport
561
621
  return outcome
562
622
  }
563
623
 
624
+ /**
625
+ * Whether this pass produced anything worth VALIDATING — i.e. the branch advanced past its
626
+ * pre-run tip (or the run resumed an earlier one's pushed work). Gates the pre-PR validation
627
+ * loop, for two reasons: a run that changed nothing has nothing to check, and its real failure
628
+ * is "the agent produced no file changes" — reporting a red BASE branch instead would blame the
629
+ * run for a pre-existing condition it never touched (and burn the whole repair budget re-running
630
+ * an agent that already declined to act).
631
+ *
632
+ * Commits forgotten edits to tracked files first, exactly as {@link finalizeCodingRun} does, so
633
+ * an agent that edited-but-didn't-commit still counts as work. That call is idempotent, so
634
+ * finalize repeating it later is a no-op. Uncommitted NEW files are invisible here — but they
635
+ * are equally invisible to finalize, so a run whose only product is an uncommitted new file is
636
+ * a no-op on both paths, and the checks would have nothing to gate anyway.
637
+ */
638
+ async function producedWork(
639
+ dir: string,
640
+ spec: CodingAgentSpec,
641
+ baseSha: string,
642
+ resumed: boolean,
643
+ opts: RunOptions,
644
+ ): Promise<boolean> {
645
+ await commitTrackedEdits(dir, spec.commitMessage, opts.signal)
646
+ return resumed || (await branchHasCommitsSince(dir, baseSha, opts.signal))
647
+ }
648
+
649
+ /**
650
+ * Fold a pre-PR validation REPAIR pass's run into the accumulated agent outcome, so a looped run
651
+ * reports what every round actually spent rather than only the first. Counts and telemetry are
652
+ * summed/concatenated; the single-valued fields (the summary the backend renders, the effort
653
+ * report, the diagnostics that judge the FINAL answer) take the LATEST pass, which is the one
654
+ * whose state the PR is opened from.
655
+ */
656
+ function mergeAgentPasses<T extends Awaited<ReturnType<typeof runAgentInWorkspace>>>(
657
+ previous: T,
658
+ next: T,
659
+ ): T {
660
+ return {
661
+ ...next,
662
+ stats: {
663
+ toolCalls: (previous.stats?.toolCalls ?? 0) + (next.stats?.toolCalls ?? 0),
664
+ assistantChars: (previous.stats?.assistantChars ?? 0) + (next.stats?.assistantChars ?? 0),
665
+ },
666
+ ...(previous.usage || next.usage
667
+ ? {
668
+ usage: {
669
+ inputTokens: (previous.usage?.inputTokens ?? 0) + (next.usage?.inputTokens ?? 0),
670
+ outputTokens: (previous.usage?.outputTokens ?? 0) + (next.usage?.outputTokens ?? 0),
671
+ },
672
+ }
673
+ : {}),
674
+ ...(previous.callMetrics || next.callMetrics
675
+ ? { callMetrics: [...(previous.callMetrics ?? []), ...(next.callMetrics ?? [])] }
676
+ : {}),
677
+ // The repair pass's own effort report wins when it wrote one; otherwise keep the first
678
+ // pass's rather than losing the assessment entirely.
679
+ ...((next.effortReport ?? previous.effortReport)
680
+ ? { effortReport: next.effortReport ?? previous.effortReport }
681
+ : {}),
682
+ }
683
+ }
684
+
564
685
  /**
565
686
  * The Ralph-loop validation watchdog: the longest a completion command may run before it is
566
687
  * killed and treated as a failure (a hung `pnpm test` must never block the loop forever).