@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
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
|
package/src/validation-checks.ts
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
|
147
|
-
*
|
|
148
|
-
*
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
/**
|