@cat-factory/executor-harness 1.54.0 → 1.58.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 +9 -1
- package/dist/agent.js +39 -1
- package/dist/coding-agent.js +103 -18
- package/dist/job.js +48 -1
- package/dist/runner.js +3 -0
- package/dist/validation-checks.js +300 -0
- package/package.json +5 -5
- package/src/agent.ts +50 -2
- package/src/coding-agent.ts +127 -6
- package/src/job.ts +66 -0
- package/src/runner.ts +18 -0
- package/src/validation-checks.ts +395 -0
package/src/job.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { HarnessCallMetric, PiRunStats } from './pi.js'
|
|
|
2
2
|
import type { HarnessKind } from './pi-workspace.js'
|
|
3
3
|
import type { FailureCause } from './failure.js'
|
|
4
4
|
import type { EffortReport } from './effort.js'
|
|
5
|
+
import type { ValidationChecksSpec, ValidationReport } from './validation-checks.js'
|
|
5
6
|
|
|
6
7
|
// The job the Worker's ContainerAgentExecutor POSTs to /run. Kept as plain
|
|
7
8
|
// types with a hand-rolled validator so the image needs no schema dependency.
|
|
@@ -174,6 +175,38 @@ function parseValidationSpec(value: unknown): ValidationSpec | undefined {
|
|
|
174
175
|
}
|
|
175
176
|
}
|
|
176
177
|
|
|
178
|
+
/**
|
|
179
|
+
* Parse the optional PRE-PR VALIDATION CHECKS spec (see
|
|
180
|
+
* docs/initiatives/pre-pr-validation.md): the service's ordered `{ label, command }` pairs and
|
|
181
|
+
* the repair-round budget. Every entry needs a non-empty command; entries without one are
|
|
182
|
+
* dropped, and a spec that ends up with no usable check returns `undefined` — so a malformed
|
|
183
|
+
* body degrades to the exact pre-feature behaviour (no loop, PR opens as before) rather than
|
|
184
|
+
* failing an otherwise-good coding run. `maxAttempts` is clamped to a sane range so a bad body
|
|
185
|
+
* can't make a container loop forever.
|
|
186
|
+
*/
|
|
187
|
+
function parseValidationChecksSpec(value: unknown): ValidationChecksSpec | undefined {
|
|
188
|
+
if (typeof value !== 'object' || value === null) return undefined
|
|
189
|
+
const o = value as Record<string, unknown>
|
|
190
|
+
if (!Array.isArray(o.checks)) return undefined
|
|
191
|
+
const checks: { label: string; command: string }[] = []
|
|
192
|
+
for (const raw of o.checks) {
|
|
193
|
+
if (typeof raw !== 'object' || raw === null) continue
|
|
194
|
+
const c = raw as Record<string, unknown>
|
|
195
|
+
if (typeof c.command !== 'string' || c.command.trim() === '') continue
|
|
196
|
+
const label = typeof c.label === 'string' && c.label.trim() ? c.label.trim() : c.command
|
|
197
|
+
checks.push({ label, command: c.command })
|
|
198
|
+
}
|
|
199
|
+
if (checks.length === 0) return undefined
|
|
200
|
+
const parsed = posInt(o.maxAttempts)
|
|
201
|
+
return {
|
|
202
|
+
checks,
|
|
203
|
+
maxAttempts: Math.min(
|
|
204
|
+
parsed ?? VALIDATION_DEFAULT_MAX_ATTEMPTS,
|
|
205
|
+
VALIDATION_MAX_ATTEMPTS_CEILING,
|
|
206
|
+
),
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
177
210
|
/**
|
|
178
211
|
* Parse the shared per-job auth fields, validating per harness: a subscription
|
|
179
212
|
* harness (`claude-code` / `codex`) requires `subscriptionToken`; the default Pi
|
|
@@ -831,8 +864,29 @@ export interface AgentJob extends HarnessAuthFields {
|
|
|
831
864
|
* agent commits + pushes. Present only for a `ralph` iteration. See {@link ValidationSpec}.
|
|
832
865
|
*/
|
|
833
866
|
validation?: ValidationSpec
|
|
867
|
+
/**
|
|
868
|
+
* Coding mode: the service's PRE-PR VALIDATION CHECKS — commands the harness runs against the
|
|
869
|
+
* checkout after the agent settles and BEFORE opening a PR, feeding a failure back to the agent
|
|
870
|
+
* until they pass or the budget is spent. Present only on a dispatch that opens a PR and whose
|
|
871
|
+
* service configured checks; absent ⇒ the run behaves exactly as before. Deliberately keyed off
|
|
872
|
+
* job DATA, not the agent kind. See {@link ValidationChecksSpec}.
|
|
873
|
+
*/
|
|
874
|
+
validationChecks?: ValidationChecksSpec
|
|
834
875
|
}
|
|
835
876
|
|
|
877
|
+
/**
|
|
878
|
+
* The ceiling the harness clamps a body-supplied `validationChecks.maxAttempts` to, and the
|
|
879
|
+
* default it applies when the body omits one.
|
|
880
|
+
*
|
|
881
|
+
* DELIBERATE DUPLICATES of `VALIDATION_MAX_ATTEMPTS_CEILING` / `VALIDATION_DEFAULT_MAX_ATTEMPTS`
|
|
882
|
+
* in `@cat-factory/contracts` — the published image takes no schema dependency, so the harness
|
|
883
|
+
* cannot import them. Keep the two in step: the API validates writes against the contracts
|
|
884
|
+
* values, so a harness clamping to a DIFFERENT ceiling would silently cap a budget an operator
|
|
885
|
+
* was allowed to save, with nothing to flag the mismatch.
|
|
886
|
+
*/
|
|
887
|
+
export const VALIDATION_MAX_ATTEMPTS_CEILING = 10
|
|
888
|
+
export const VALIDATION_DEFAULT_MAX_ATTEMPTS = 3
|
|
889
|
+
|
|
836
890
|
/** Per-job, per-knob progress-guard overrides (see {@link AgentJob.guardLimits}). */
|
|
837
891
|
export interface GuardLimitsSpec {
|
|
838
892
|
maxToolCallsWithoutEdit?: number
|
|
@@ -876,6 +930,14 @@ export interface AgentResult {
|
|
|
876
930
|
* — the failure-class artifact the orchestrator-side provisioning logs can't capture.
|
|
877
931
|
*/
|
|
878
932
|
infraSetup?: InfraSetupRecord
|
|
933
|
+
/**
|
|
934
|
+
* The PRE-PR VALIDATION report: the outcome of running the service's configured check commands
|
|
935
|
+
* against the checkout after the agent settled and before opening a PR, plus how many repair
|
|
936
|
+
* rounds the harness spent. Present on BOTH outcomes — a passing report accompanies the opened
|
|
937
|
+
* PR (the captured proof), and a failing one accompanies the run's `error` (no PR was opened).
|
|
938
|
+
* Absent when the job carried no {@link AgentJob.validationChecks}.
|
|
939
|
+
*/
|
|
940
|
+
validationReport?: ValidationReport
|
|
879
941
|
/**
|
|
880
942
|
* Preview mode: the in-container URL the built app is served at (e.g. `http://localhost:4173`).
|
|
881
943
|
* This is NOT host-reachable on its own — the container runtime publishes the serve port to an
|
|
@@ -1282,6 +1344,7 @@ export function parseAgentJob(input: unknown): AgentJob {
|
|
|
1282
1344
|
testSecrets: parseTestSecrets(o.testSecrets),
|
|
1283
1345
|
guardLimits: parseGuardLimits(o.guardLimits),
|
|
1284
1346
|
validation: parseValidationSpec(o.validation),
|
|
1347
|
+
validationChecks: parseValidationChecksSpec(o.validationChecks),
|
|
1285
1348
|
reviewPrNumber: posInt(o.reviewPrNumber),
|
|
1286
1349
|
})
|
|
1287
1350
|
assertAllowedHost(job.repo.cloneUrl, 'repo.cloneUrl')
|
|
@@ -1319,6 +1382,7 @@ interface ParsedAgentJobParts {
|
|
|
1319
1382
|
testSecrets: ReturnType<typeof parseTestSecrets>
|
|
1320
1383
|
guardLimits: ReturnType<typeof parseGuardLimits>
|
|
1321
1384
|
validation: ReturnType<typeof parseValidationSpec>
|
|
1385
|
+
validationChecks: ReturnType<typeof parseValidationChecksSpec>
|
|
1322
1386
|
reviewPrNumber: number | undefined
|
|
1323
1387
|
}
|
|
1324
1388
|
|
|
@@ -1371,6 +1435,7 @@ function assembleAgentJob(
|
|
|
1371
1435
|
testSecrets,
|
|
1372
1436
|
guardLimits,
|
|
1373
1437
|
validation,
|
|
1438
|
+
validationChecks,
|
|
1374
1439
|
reviewPrNumber,
|
|
1375
1440
|
} = parts
|
|
1376
1441
|
const repo = (o.repo ?? {}) as Record<string, unknown>
|
|
@@ -1399,6 +1464,7 @@ function assembleAgentJob(
|
|
|
1399
1464
|
...(reviewPrNumber !== undefined ? { reviewPrNumber } : {}),
|
|
1400
1465
|
...(guardLimits ? { guardLimits } : {}),
|
|
1401
1466
|
...(validation ? { validation } : {}),
|
|
1467
|
+
...(validationChecks ? { validationChecks } : {}),
|
|
1402
1468
|
}
|
|
1403
1469
|
}
|
|
1404
1470
|
|
package/src/runner.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { redactSecrets } from './redact.js'
|
|
2
2
|
import type { FollowUpLine } from './follow-ups.js'
|
|
3
|
+
import type { ValidationReport } from './validation-checks.js'
|
|
3
4
|
import type { HarnessCallMetric, TodoProgress, ToolSpan } from './pi.js'
|
|
4
5
|
import { log, type Logger } from './logger.js'
|
|
5
6
|
import {
|
|
@@ -30,6 +31,13 @@ export interface RunOptions {
|
|
|
30
31
|
onSpan?: (span: ToolSpan) => void
|
|
31
32
|
/** Receives the forward-looking follow-up / question items the Coder streamed since the last poll. */
|
|
32
33
|
onFollowUp?: (items: FollowUpLine[]) => void
|
|
34
|
+
/**
|
|
35
|
+
* Receives each completed PRE-PR VALIDATION attempt the moment the harness finishes running
|
|
36
|
+
* the service's check commands, so the backend can surface the repair loop LIVE ("lint failed,
|
|
37
|
+
* repairing — attempt 2 of 3") instead of only in the terminal result. Latest-wins (NOT a drain
|
|
38
|
+
* buffer): a published attempt is final, and the loop republishes a whole new one per round.
|
|
39
|
+
*/
|
|
40
|
+
onValidationReport?: (report: ValidationReport) => void
|
|
33
41
|
/**
|
|
34
42
|
* Receives each per-call telemetry row the moment the agent's CLI stream yields it, so a
|
|
35
43
|
* run's model calls reach `llm_call_metrics` WHILE it runs rather than only in its terminal
|
|
@@ -162,6 +170,13 @@ export interface JobView<TResult extends JobResultBase = JobResultBase> {
|
|
|
162
170
|
* Absent on a job that produced output promptly (the overwhelming common case). Sticky once set.
|
|
163
171
|
*/
|
|
164
172
|
coldStart?: { atMs: number; message: string }
|
|
173
|
+
/**
|
|
174
|
+
* The LATEST completed pre-PR validation attempt (see `docs/initiatives/pre-pr-validation.md`).
|
|
175
|
+
* Unlike {@link spans}/{@link followUps} this is NOT drain-on-read: it is a whole-value latest
|
|
176
|
+
* publish, so re-reading it on a later poll is harmless and a dropped poll loses nothing (the
|
|
177
|
+
* next round republishes). Absent for a job whose service configured no checks.
|
|
178
|
+
*/
|
|
179
|
+
validationReport?: ValidationReport
|
|
165
180
|
}
|
|
166
181
|
|
|
167
182
|
interface JobEntry<TResult extends JobResultBase> extends JobView<TResult> {
|
|
@@ -425,6 +440,9 @@ export class JobRegistry<TJob = unknown, TResult extends JobResultBase = JobResu
|
|
|
425
440
|
onFollowUp: (items) => {
|
|
426
441
|
entry.followUpBuffer.push(...items)
|
|
427
442
|
},
|
|
443
|
+
onValidationReport: (report) => {
|
|
444
|
+
entry.validationReport = report
|
|
445
|
+
},
|
|
428
446
|
onCallMetric: (call) => {
|
|
429
447
|
// Stamp the job-scoped sequence on the metric OBJECT: the handler keeps the same
|
|
430
448
|
// instance for its terminal result, so both channels carry the same `seq` and the
|
|
@@ -0,0 +1,395 @@
|
|
|
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
|
+
// PRE-PR VALIDATION — the generic check runner (see docs/initiatives/pre-pr-validation.md).
|
|
8
|
+
//
|
|
9
|
+
// A service can declare shell commands (install / lint / test / build) that run against the
|
|
10
|
+
// CHECKOUT after the coding agent settles and BEFORE a PR is opened. This module owns running
|
|
11
|
+
// them and shaping the report; the loop that feeds a failure back to the agent lives in
|
|
12
|
+
// `coding-agent.ts`, and the decision to run at all is made purely by the JOB BODY carrying
|
|
13
|
+
// `validationChecks` — there is deliberately no agent-kind switch anywhere in the harness.
|
|
14
|
+
//
|
|
15
|
+
// Everything here is PER-JOB by construction: the commands, the cwd, and the environment all
|
|
16
|
+
// arrive as arguments. Nothing is read from or written to `process.env` or `HOME`, because the
|
|
17
|
+
// local NATIVE transport serves every concurrent job from ONE host process — a global would leak
|
|
18
|
+
// one job's config into a sibling's checks, and the container path would never catch it.
|
|
19
|
+
|
|
20
|
+
/** One configured check, as it arrives on the job body. */
|
|
21
|
+
export interface ValidationCheckSpec {
|
|
22
|
+
label: string
|
|
23
|
+
command: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The whole validation config a job carries. */
|
|
27
|
+
export interface ValidationChecksSpec {
|
|
28
|
+
checks: ValidationCheckSpec[]
|
|
29
|
+
/** How many agent+check rounds the loop may run (1 = check once, no repair round). */
|
|
30
|
+
maxAttempts: number
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** One command's outcome within an attempt. */
|
|
34
|
+
export interface ValidationCheckOutcome {
|
|
35
|
+
label: string
|
|
36
|
+
command: string
|
|
37
|
+
exitCode: number
|
|
38
|
+
passed: boolean
|
|
39
|
+
outputTail?: string
|
|
40
|
+
durationMs?: number
|
|
41
|
+
timedOut?: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One attempt's result: the REPORT that crosses the wire, plus the FULL (scrubbed, 16k) output
|
|
46
|
+
* tails kept in memory for the repair prompt. The two are deliberately separate — see
|
|
47
|
+
* {@link VALIDATION_REPORT_TAIL_CHARS}.
|
|
48
|
+
*/
|
|
49
|
+
export interface ValidationAttempt {
|
|
50
|
+
report: ValidationReport
|
|
51
|
+
/** Full scrubbed output per check label — never leaves the container. */
|
|
52
|
+
fullTails: Map<string, string>
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** One attempt's report — what the backend records on the step. */
|
|
56
|
+
export interface ValidationReport {
|
|
57
|
+
passed: boolean
|
|
58
|
+
attempts: number
|
|
59
|
+
maxAttempts: number
|
|
60
|
+
outcomes: ValidationCheckOutcome[]
|
|
61
|
+
at: number
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 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
|
|
67
|
+
* AGENT sees in its repair prompt: the agent needs the full failure to fix it, the operator needs
|
|
68
|
+
* enough to recognise it, and a chatty build must not inflate every run's stored state.
|
|
69
|
+
*/
|
|
70
|
+
export const VALIDATION_REPORT_TAIL_CHARS = 4_000
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The per-command watchdog: the longest a single check may run before it is killed and treated
|
|
74
|
+
* as a failure, so one hung `pnpm test` cannot wedge a run. Overridable via env for tests;
|
|
75
|
+
* defaults to 15 minutes (matching the ralph completion command's watchdog).
|
|
76
|
+
*/
|
|
77
|
+
export function validationCommandTimeoutMs(): number {
|
|
78
|
+
const n = Number(process.env.VALIDATION_COMMAND_TIMEOUT_MS)
|
|
79
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 15 * 60_000
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* How often the check loop feeds the run's inactivity watchdog. Well under the harness's own
|
|
84
|
+
* `JOB_INACTIVITY_MS` (default 10 min) so a single slow command can never look wedged; matches
|
|
85
|
+
* the frontend stand-up's heartbeat, which exists for exactly the same reason. Overridable via
|
|
86
|
+
* env for tests, like {@link validationCommandTimeoutMs}.
|
|
87
|
+
*/
|
|
88
|
+
export function validationHeartbeatMs(): number {
|
|
89
|
+
const n = Number(process.env.VALIDATION_HEARTBEAT_MS)
|
|
90
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 30_000
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Run every configured check IN ORDER against `cwd` and build the attempt's report.
|
|
95
|
+
*
|
|
96
|
+
* Runs all of them even after one fails, rather than short-circuiting: the agent repairing the
|
|
97
|
+
* checkout should see every problem at once instead of rediscovering the next one on the next
|
|
98
|
+
* round, which is the difference between one repair round and four. (A check whose failure makes
|
|
99
|
+
* the rest meaningless — e.g. a failed install — still costs only the cheap downstream failures.)
|
|
100
|
+
*
|
|
101
|
+
* Keeps the run's inactivity watchdog fed for the whole attempt. These commands are exactly the
|
|
102
|
+
* activity-SILENT kind — a cold `install`, a full `test` run, a `build` — and the harness spawns
|
|
103
|
+
* them itself rather than through the agent, so they emit no activity events of their own. The
|
|
104
|
+
* job-level watchdog (`JOB_INACTIVITY_MS`, default 10 min) is TIGHTER than one command's own
|
|
105
|
+
* watchdog ({@link validationCommandTimeoutMs}, default 15 min), so without this a legitimately
|
|
106
|
+
* slow check would abort the entire run as "inactivity" — mislabelling a healthy build as a
|
|
107
|
+
* wedge, and making the per-command timeout unreachable at stock settings.
|
|
108
|
+
*/
|
|
109
|
+
export async function runValidationChecks(
|
|
110
|
+
cwd: string,
|
|
111
|
+
spec: ValidationChecksSpec,
|
|
112
|
+
attempt: number,
|
|
113
|
+
logger: Logger,
|
|
114
|
+
opts: RunOptions,
|
|
115
|
+
): Promise<ValidationAttempt> {
|
|
116
|
+
const outcomes: ValidationCheckOutcome[] = []
|
|
117
|
+
const fullTails = new Map<string, string>()
|
|
118
|
+
const heartbeat = setInterval(() => opts.onActivity?.(), validationHeartbeatMs())
|
|
119
|
+
heartbeat.unref?.()
|
|
120
|
+
try {
|
|
121
|
+
for (const check of spec.checks) {
|
|
122
|
+
const { outcome, fullTail } = await runOneCheck(cwd, check, logger, opts)
|
|
123
|
+
outcomes.push(outcome)
|
|
124
|
+
if (fullTail) fullTails.set(check.label, fullTail)
|
|
125
|
+
}
|
|
126
|
+
} finally {
|
|
127
|
+
clearInterval(heartbeat)
|
|
128
|
+
}
|
|
129
|
+
const report: ValidationReport = {
|
|
130
|
+
passed: outcomes.every((o) => o.passed),
|
|
131
|
+
attempts: attempt,
|
|
132
|
+
maxAttempts: spec.maxAttempts,
|
|
133
|
+
outcomes,
|
|
134
|
+
at: Date.now(),
|
|
135
|
+
}
|
|
136
|
+
logger.info('validation: attempt finished', {
|
|
137
|
+
attempt,
|
|
138
|
+
maxAttempts: spec.maxAttempts,
|
|
139
|
+
passed: report.passed,
|
|
140
|
+
failed: outcomes.filter((o) => !o.passed).map((o) => o.label),
|
|
141
|
+
})
|
|
142
|
+
return { report, fullTails }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
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`).
|
|
155
|
+
*/
|
|
156
|
+
async function runOneCheck(
|
|
157
|
+
cwd: string,
|
|
158
|
+
check: ValidationCheckSpec,
|
|
159
|
+
logger: Logger,
|
|
160
|
+
opts: RunOptions,
|
|
161
|
+
): Promise<{ outcome: ValidationCheckOutcome; fullTail?: string }> {
|
|
162
|
+
const timeoutMs = validationCommandTimeoutMs()
|
|
163
|
+
const startedAt = Date.now()
|
|
164
|
+
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))
|
|
224
|
+
})
|
|
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)}`
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The repair instruction handed to the agent after a failed attempt: the failing commands and
|
|
236
|
+
* their captured output, plus an explicit statement of the exit condition and the remaining
|
|
237
|
+
* budget. The FULL captured tail is used here (not the report's smaller bound) — the agent needs
|
|
238
|
+
* the whole failure to fix it, and this text never leaves the container.
|
|
239
|
+
*
|
|
240
|
+
* Deliberately prescriptive about scope: a validation loop that lets the agent "fix" the failure
|
|
241
|
+
* by weakening the check is worse than no loop at all, so the prompt forbids editing the
|
|
242
|
+
* commands' configuration to make them pass.
|
|
243
|
+
*/
|
|
244
|
+
export function buildRepairPrompt(
|
|
245
|
+
report: ValidationReport,
|
|
246
|
+
fullTails: Map<string, string>,
|
|
247
|
+
/**
|
|
248
|
+
* New files the agent created but never `git add`ed, if the caller can tell. The harness only
|
|
249
|
+
* auto-stages TRACKED edits (`git add -u`), so an uncommitted new file is invisible to the push
|
|
250
|
+
* — yet fully visible to the checks, which run against the working tree. Naming them here is
|
|
251
|
+
* what stops the loop going green on work the pull request would not contain.
|
|
252
|
+
*/
|
|
253
|
+
untrackedFiles: string[] = [],
|
|
254
|
+
): string {
|
|
255
|
+
const failed = report.outcomes.filter((o) => !o.passed)
|
|
256
|
+
const blocks = failed
|
|
257
|
+
.map((o) => {
|
|
258
|
+
const body = fullTails.get(o.label) ?? o.outputTail ?? '(no output captured)'
|
|
259
|
+
const reason = o.timedOut
|
|
260
|
+
? `timed out after ${Math.round((o.durationMs ?? 0) / 1000)}s`
|
|
261
|
+
: `exited ${o.exitCode}`
|
|
262
|
+
return `### ${o.label} — ${reason}\n\n\`\`\`\n$ ${o.command}\n${body}\n\`\`\``
|
|
263
|
+
})
|
|
264
|
+
.join('\n\n')
|
|
265
|
+
const remaining = report.maxAttempts - report.attempts
|
|
266
|
+
const untracked = untrackedFiles.length
|
|
267
|
+
? [
|
|
268
|
+
'',
|
|
269
|
+
'## Uncommitted new files',
|
|
270
|
+
'',
|
|
271
|
+
'These files exist in your checkout but were never added to git, so they are NOT part of',
|
|
272
|
+
'the branch even though the checks above ran against them. `git add` each one you meant to',
|
|
273
|
+
'keep (or delete it), or the checks will pass on work the pull request will not contain:',
|
|
274
|
+
'',
|
|
275
|
+
...untrackedFiles.map((f) => `- ${f}`),
|
|
276
|
+
]
|
|
277
|
+
: []
|
|
278
|
+
return [
|
|
279
|
+
'The work you just finished does NOT pass this service’s required validation checks, so',
|
|
280
|
+
'no pull request has been opened. Fix the failures below, then stop.',
|
|
281
|
+
'',
|
|
282
|
+
blocks,
|
|
283
|
+
...untracked,
|
|
284
|
+
'',
|
|
285
|
+
'## How this is judged',
|
|
286
|
+
'',
|
|
287
|
+
`The checks above are re-run automatically against your checkout when you stop. They are the`,
|
|
288
|
+
`exit condition: a pull request opens only once every one of them exits 0. You have`,
|
|
289
|
+
`${remaining} attempt(s) left before this task fails.`,
|
|
290
|
+
'',
|
|
291
|
+
'## Rules',
|
|
292
|
+
'',
|
|
293
|
+
'- Fix the underlying problem in the code. Do NOT edit, disable, skip, or relax the checks',
|
|
294
|
+
' themselves (their scripts, configs, thresholds, ignore files, or test assertions) to make',
|
|
295
|
+
' them pass — a green check obtained that way is a failed task.',
|
|
296
|
+
'- Do not revert your earlier work; build on it.',
|
|
297
|
+
'- Commit your fixes, as you did before. `git add` any NEW file you create — only changes to',
|
|
298
|
+
' files already tracked by git are staged for you, so an unadded file is silently dropped',
|
|
299
|
+
' from the branch even though the checks can see it.',
|
|
300
|
+
].join('\n')
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The pre-PR validation LOOP: run the checks, and while they fail and budget remains, hand the
|
|
305
|
+
* captured output back to the agent as its next instruction and check again. Returns the LAST
|
|
306
|
+
* attempt's report — `passed: true` means the caller may open the PR; `passed: false` means the
|
|
307
|
+
* budget is spent and the caller must FAIL the job with this report as the evidence, opening
|
|
308
|
+
* nothing.
|
|
309
|
+
*
|
|
310
|
+
* Generic by construction: it knows nothing about agent kinds, repos or PRs — only how to run
|
|
311
|
+
* commands in a directory and how to ask for another pass. Every input (`workDir`, `spec`,
|
|
312
|
+
* `opts.agentEnv`) is per-job, so two concurrent jobs on the ONE local-native host process cannot
|
|
313
|
+
* see each other's configuration (`validation-checks.concurrency.test.ts` pins this).
|
|
314
|
+
*
|
|
315
|
+
* `onAttempt` publishes each completed attempt on the job view so the loop is observable while it
|
|
316
|
+
* runs; `onAgentPass` lets the caller fold each repair pass's stats/usage/telemetry into the run's
|
|
317
|
+
* totals, so a 3-round loop reports what all 3 rounds actually spent.
|
|
318
|
+
*/
|
|
319
|
+
export async function runValidationLoop<TRun>(args: {
|
|
320
|
+
workDir: string
|
|
321
|
+
spec: ValidationChecksSpec
|
|
322
|
+
logger: Logger
|
|
323
|
+
opts: RunOptions
|
|
324
|
+
runAgentPass: (userPrompt: string) => Promise<TRun>
|
|
325
|
+
onAgentPass?: (run: TRun) => void
|
|
326
|
+
/**
|
|
327
|
+
* Optional: the new files left uncommitted in the checkout, folded into each repair prompt.
|
|
328
|
+
* Injected rather than read here so this module stays git-agnostic (it knows only how to run
|
|
329
|
+
* commands in a directory and how to ask for another pass); the coding agent, which owns the
|
|
330
|
+
* checkout, supplies it. A throw is swallowed — a missing warning must never fail the loop.
|
|
331
|
+
*/
|
|
332
|
+
listUncommittedNewFiles?: () => Promise<string[]>
|
|
333
|
+
}): Promise<ValidationReport> {
|
|
334
|
+
const { workDir, spec, logger, opts, runAgentPass, onAgentPass, listUncommittedNewFiles } = args
|
|
335
|
+
let attempt = 1
|
|
336
|
+
for (;;) {
|
|
337
|
+
const { report, fullTails } = await runValidationChecks(workDir, spec, attempt, logger, opts)
|
|
338
|
+
opts.onValidationReport?.(report)
|
|
339
|
+
if (report.passed) {
|
|
340
|
+
logger.info('validation: checkout is green', { attempt })
|
|
341
|
+
return report
|
|
342
|
+
}
|
|
343
|
+
if (attempt >= spec.maxAttempts) {
|
|
344
|
+
logger.warn('validation: attempt budget spent — no PR will be opened', {
|
|
345
|
+
attempt,
|
|
346
|
+
maxAttempts: spec.maxAttempts,
|
|
347
|
+
})
|
|
348
|
+
return report
|
|
349
|
+
}
|
|
350
|
+
attempt += 1
|
|
351
|
+
logger.info('validation: repairing', { nextAttempt: attempt })
|
|
352
|
+
opts.onPhase?.('validation-repair')
|
|
353
|
+
const untracked = await safeListUncommitted(listUncommittedNewFiles, logger)
|
|
354
|
+
const run = await runAgentPass(buildRepairPrompt(report, fullTails, untracked))
|
|
355
|
+
onAgentPass?.(run)
|
|
356
|
+
opts.onPhase?.('agent')
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* The uncommitted-new-file list for a repair prompt, never throwing: this is an ADVISORY
|
|
362
|
+
* addition to the instruction, so a `git` hiccup must degrade to "no warning" rather than
|
|
363
|
+
* failing a loop that is otherwise working.
|
|
364
|
+
*/
|
|
365
|
+
async function safeListUncommitted(
|
|
366
|
+
list: (() => Promise<string[]>) | undefined,
|
|
367
|
+
logger: Logger,
|
|
368
|
+
): Promise<string[]> {
|
|
369
|
+
if (!list) return []
|
|
370
|
+
try {
|
|
371
|
+
return await list()
|
|
372
|
+
} catch (error) {
|
|
373
|
+
logger.warn('validation: could not list uncommitted new files', {
|
|
374
|
+
error: error instanceof Error ? error.message : String(error),
|
|
375
|
+
})
|
|
376
|
+
return []
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* The failure message for a run whose pre-PR validation never went green: which checks failed
|
|
382
|
+
* (with exit codes) and the last one's captured output. Read by the operator on the step's
|
|
383
|
+
* failure card, so it must say what broke without needing the full report opened.
|
|
384
|
+
*/
|
|
385
|
+
export function validationFailureMessage(report: ValidationReport): string {
|
|
386
|
+
const failed = report.outcomes.filter((o) => !o.passed)
|
|
387
|
+
const names = failed.map((o) => `${o.label} (exit ${o.exitCode})`).join(', ')
|
|
388
|
+
const last = failed[failed.length - 1]
|
|
389
|
+
const tail = last?.outputTail?.trim()
|
|
390
|
+
const head =
|
|
391
|
+
`pre-PR validation failed after ${report.attempts} of ${report.maxAttempts} attempt(s)` +
|
|
392
|
+
(names ? `: ${names}` : '') +
|
|
393
|
+
'. No pull request was opened.'
|
|
394
|
+
return tail ? `${head}\n\n$ ${last?.command}\n${tail}` : head
|
|
395
|
+
}
|