@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/src/runner.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { redactSecrets } from './redact.js'
2
2
  import type { FollowUpLine } from './follow-ups.js'
3
3
  import type { ValidationReport } from './validation-checks.js'
4
+ import type { ReproductionReport } from './reproduction-proof.js'
4
5
  import type { HarnessCallMetric, TodoProgress, ToolSpan } from './pi.js'
5
6
  import { log, type Logger } from './logger.js'
6
7
  import {
@@ -38,6 +39,14 @@ export interface RunOptions {
38
39
  * buffer): a published attempt is final, and the loop republishes a whole new one per round.
39
40
  */
40
41
  onValidationReport?: (report: ValidationReport) => void
42
+ /**
43
+ * Receives each completed BUGFIX REPRODUCTION PROOF attempt the moment the harness finishes
44
+ * running the declared check against both trees, so the backend can surface a failed
45
+ * verification WHILE the repair loop still runs rather than only in the terminal result.
46
+ * Latest-wins (NOT a drain buffer), exactly like {@link onValidationReport}: a published
47
+ * attempt is final, and the loop republishes a whole new one — with a fresh `at` — per round.
48
+ */
49
+ onReproductionProof?: (report: ReproductionReport) => void
41
50
  /**
42
51
  * Receives each per-call telemetry row the moment the agent's CLI stream yields it, so a
43
52
  * run's model calls reach `llm_call_metrics` WHILE it runs rather than only in its terminal
@@ -177,6 +186,14 @@ export interface JobView<TResult extends JobResultBase = JobResultBase> {
177
186
  * next round republishes). Absent for a job whose service configured no checks.
178
187
  */
179
188
  validationReport?: ValidationReport
189
+ /**
190
+ * The LATEST completed bugfix reproduction-proof attempt (see
191
+ * `docs/initiatives/bugfix-reproduction-proof.md`). Like {@link validationReport} — and unlike
192
+ * {@link spans}/{@link followUps} — this is a whole-value latest publish, not drain-on-read, so
193
+ * re-reading it on a later poll is harmless and a dropped poll loses nothing. Absent for a job
194
+ * that carried no reproduction declaration.
195
+ */
196
+ reproductionReport?: ReproductionReport
180
197
  }
181
198
 
182
199
  interface JobEntry<TResult extends JobResultBase> extends JobView<TResult> {
@@ -443,6 +460,9 @@ export class JobRegistry<TJob = unknown, TResult extends JobResultBase = JobResu
443
460
  onValidationReport: (report) => {
444
461
  entry.validationReport = report
445
462
  },
463
+ onReproductionProof: (report) => {
464
+ entry.reproductionReport = report
465
+ },
446
466
  onCallMetric: (call) => {
447
467
  // Stamp the job-scoped sequence on the metric OBJECT: the handler keeps the same
448
468
  // instance for its terminal result, so both channels carry the same `seq` and the
@@ -1,6 +1,4 @@
1
- import { spawn } from 'node:child_process'
2
- import { killChildProcess, spawnDetached } from './process.js'
3
- import { MAX_CAPTURED_OUTPUT_CHARS, redactSecrets } from './redact.js'
1
+ import { runCapturedCommand } from './captured-command.js'
4
2
  import type { RunOptions } from './runner.js'
5
3
  import type { Logger } from './logger.js'
6
4
 
@@ -52,6 +50,58 @@ export interface ValidationAttempt {
52
50
  fullTails: Map<string, string>
53
51
  }
54
52
 
53
+ /**
54
+ * The ceiling the harness clamps a body-supplied `validationChecks.maxAttempts` to, and the
55
+ * default it applies when the body omits one.
56
+ *
57
+ * DELIBERATE DUPLICATES of `VALIDATION_MAX_ATTEMPTS_CEILING` / `VALIDATION_DEFAULT_MAX_ATTEMPTS`
58
+ * in `@cat-factory/contracts` — the published image takes no schema dependency, so the harness
59
+ * cannot import them. Keep the two in step: the API validates writes against the contracts
60
+ * values, so a harness clamping to a DIFFERENT ceiling would silently cap a budget an operator
61
+ * was allowed to save, with nothing to flag the mismatch.
62
+ */
63
+ export const VALIDATION_MAX_ATTEMPTS_CEILING = 10
64
+ export const VALIDATION_DEFAULT_MAX_ATTEMPTS = 3
65
+
66
+ /**
67
+ * Parse the optional PRE-PR VALIDATION CHECKS envelope off the job body: the service's ordered
68
+ * `{ label, command }` pairs and the repair-round budget. Every entry needs a non-empty command;
69
+ * entries without one are dropped, and a spec that ends up with no usable check returns
70
+ * `undefined` — so a malformed body degrades to the exact pre-feature behaviour (no loop, PR
71
+ * opens as before) rather than failing an otherwise-good coding run. `maxAttempts` is clamped to
72
+ * a sane range so a bad body can't make a container loop forever.
73
+ *
74
+ * Lives with the feature rather than in `job.ts` so each pre-PR verification phase owns its own
75
+ * job-body parser next to the loop that consumes it (the reproduction proof's
76
+ * `parseReproductionSpec` is the sibling); `job.ts` stays the job SHAPE plus the generic
77
+ * assembly.
78
+ */
79
+ export function parseValidationChecksSpec(value: unknown): ValidationChecksSpec | undefined {
80
+ if (typeof value !== 'object' || value === null) return undefined
81
+ const o = value as Record<string, unknown>
82
+ if (!Array.isArray(o.checks)) return undefined
83
+ const checks: ValidationCheckSpec[] = []
84
+ for (const raw of o.checks) {
85
+ if (typeof raw !== 'object' || raw === null) continue
86
+ const c = raw as Record<string, unknown>
87
+ if (typeof c.command !== 'string' || c.command.trim() === '') continue
88
+ const label = typeof c.label === 'string' && c.label.trim() ? c.label.trim() : c.command
89
+ checks.push({ label, command: c.command })
90
+ }
91
+ if (checks.length === 0) return undefined
92
+ const parsed =
93
+ typeof o.maxAttempts === 'number' && Number.isFinite(o.maxAttempts) && o.maxAttempts > 0
94
+ ? Math.floor(o.maxAttempts)
95
+ : undefined
96
+ return {
97
+ checks,
98
+ maxAttempts: Math.min(
99
+ parsed ?? VALIDATION_DEFAULT_MAX_ATTEMPTS,
100
+ VALIDATION_MAX_ATTEMPTS_CEILING,
101
+ ),
102
+ }
103
+ }
104
+
55
105
  /** One attempt's report — what the backend records on the step. */
56
106
  export interface ValidationReport {
57
107
  passed: boolean
@@ -63,7 +113,7 @@ export interface ValidationReport {
63
113
 
64
114
  /**
65
115
  * Per-command output kept on the REPORT (what crosses the wire and lands in the run's persisted
66
- * `detail` blob). Deliberately smaller than {@link MAX_CAPTURED_OUTPUT_CHARS}, which is what the
116
+ * `detail` blob). Deliberately smaller than `MAX_CAPTURED_OUTPUT_CHARS` (`redact.ts`), which is what the
67
117
  * AGENT sees in its repair prompt: the agent needs the full failure to fix it, the operator needs
68
118
  * enough to recognise it, and a chatty build must not inflate every run's stored state.
69
119
  */
@@ -143,15 +193,9 @@ export async function runValidationChecks(
143
193
  }
144
194
 
145
195
  /**
146
- * Run ONE check as `sh -c <command>` in `cwd`, capturing a bounded, secret-scrubbed tail of its
147
- * combined stdout+stderr. The exit code is the verdict — computed here by the harness, never
148
- * self-reported by the model, which is the whole point of a programmatic gate. A watchdog kills
149
- * the process tree on timeout and an aborted run resolves non-zero, so the loop is never blocked.
150
- *
151
- * The child inherits the JOB's environment (`RunOptions.agentEnv` layered over the process env),
152
- * not a mutated global: the harness spawns this itself rather than through the agent, so without
153
- * the explicit merge a native-mode job would run its checks without the private-registry npmrc
154
- * pointer (and against a sibling job's state, had this been staged in `process.env`).
196
+ * Run ONE check through the shared {@link runCapturedCommand} seam and shape it as a check
197
+ * outcome. The exit code is the verdict — computed by the harness, never self-reported by the
198
+ * model, which is the whole point of a programmatic gate.
155
199
  */
156
200
  async function runOneCheck(
157
201
  cwd: string,
@@ -159,76 +203,22 @@ async function runOneCheck(
159
203
  logger: Logger,
160
204
  opts: RunOptions,
161
205
  ): Promise<{ outcome: ValidationCheckOutcome; fullTail?: string }> {
162
- const timeoutMs = validationCommandTimeoutMs()
163
- const startedAt = Date.now()
164
206
  logger.info('validation: running check', { label: check.label })
165
- return new Promise((resolve) => {
166
- let out = ''
167
- let settled = false
168
- let timedOut = false
169
- const child = spawn('sh', ['-c', check.command], {
170
- cwd,
171
- detached: spawnDetached,
172
- stdio: ['ignore', 'pipe', 'pipe'],
173
- env: { ...process.env, ...opts.agentEnv },
174
- })
175
- // Keep only the tail; guard against unbounded buffering on a chatty command.
176
- const capture = (chunk: Buffer): void => {
177
- out = (out + chunk.toString('utf8')).slice(-MAX_CAPTURED_OUTPUT_CHARS)
178
- }
179
- child.stdout?.on('data', capture)
180
- child.stderr?.on('data', capture)
181
- const finish = (exitCode: number): void => {
182
- if (settled) return
183
- settled = true
184
- clearTimeout(timer)
185
- opts.signal?.removeEventListener('abort', onAbort)
186
- const trimmed = out.trim()
187
- // Scrub BEFORE truncating: a token straddling the cut would otherwise survive as a
188
- // partial, and the pattern rules need the whole assignment to match.
189
- const scrubbed = trimmed ? redactSecrets(trimmed) : ''
190
- logger.info('validation: check finished', { label: check.label, exitCode })
191
- resolve({
192
- outcome: {
193
- label: check.label,
194
- command: check.command,
195
- exitCode,
196
- passed: exitCode === 0,
197
- ...(scrubbed ? { outputTail: tailFor(scrubbed) } : {}),
198
- durationMs: Date.now() - startedAt,
199
- ...(timedOut ? { timedOut: true } : {}),
200
- },
201
- ...(scrubbed ? { fullTail: scrubbed } : {}),
202
- })
203
- }
204
- const timer = setTimeout(() => {
205
- logger.warn('validation: check timed out', { label: check.label, timeoutMs })
206
- timedOut = true
207
- killChildProcess(child, undefined, logger)
208
- finish(124) // conventional timeout exit code (a non-zero fail)
209
- }, timeoutMs)
210
- timer.unref?.()
211
- const onAbort = (): void => {
212
- killChildProcess(child, undefined, logger)
213
- finish(130) // aborted (a non-zero fail)
214
- }
215
- opts.signal?.addEventListener('abort', onAbort, { once: true })
216
- child.on('error', (err) => {
217
- logger.warn('validation: check failed to spawn', {
218
- label: check.label,
219
- error: err instanceof Error ? err.message : String(err),
220
- })
221
- finish(127) // spawn error / command not found (a non-zero fail)
222
- })
223
- child.on('close', (code) => finish(code ?? 1))
207
+ const { fullTail, ...run } = await runCapturedCommand({
208
+ cwd,
209
+ command: check.command,
210
+ timeoutMs: validationCommandTimeoutMs(),
211
+ reportTailChars: VALIDATION_REPORT_TAIL_CHARS,
212
+ logLabel: 'validation',
213
+ logFields: { label: check.label },
214
+ logger,
215
+ opts,
224
216
  })
225
- }
226
-
227
- /** Bound an already-scrubbed output tail to what the REPORT carries. */
228
- function tailFor(scrubbed: string): string {
229
- if (scrubbed.length <= VALIDATION_REPORT_TAIL_CHARS) return scrubbed
230
- const trimmed = scrubbed.length - VALIDATION_REPORT_TAIL_CHARS
231
- return `…(${trimmed} earlier chars trimmed)\n${scrubbed.slice(-VALIDATION_REPORT_TAIL_CHARS)}`
217
+ logger.info('validation: check finished', { label: check.label, exitCode: run.exitCode })
218
+ return {
219
+ outcome: { label: check.label, command: check.command, ...run },
220
+ ...(fullTail ? { fullTail } : {}),
221
+ }
232
222
  }
233
223
 
234
224
  /**