@cat-factory/executor-harness 1.52.2 → 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.
- package/README.md +48 -1
- package/dist/agent-runner.js +14 -11
- package/dist/agent.js +96 -43
- package/dist/coding-agent.js +107 -18
- package/dist/frontend-infra.js +9 -2
- package/dist/job.js +48 -1
- package/dist/package-registries.js +78 -13
- package/dist/pi-workspace.js +24 -8
- package/dist/pi.js +4 -3
- package/dist/runner.js +3 -0
- package/dist/validation-checks.js +300 -0
- package/package.json +3 -3
- package/src/agent-runner.ts +25 -13
- package/src/agent.ts +107 -42
- package/src/coding-agent.ts +134 -8
- package/src/frontend-infra.ts +10 -3
- package/src/job.ts +66 -0
- package/src/package-registries.ts +95 -15
- package/src/pi-workspace.ts +27 -8
- package/src/pi.ts +4 -3
- package/src/runner.ts +29 -0
- package/src/validation-checks.ts +395 -0
package/README.md
CHANGED
|
@@ -59,12 +59,56 @@ The implementation job (`POST /run`) is the canonical sequence:
|
|
|
59
59
|
Pi reads and concatenates both), and point Pi at the Worker's LLM proxy via
|
|
60
60
|
`~/.pi/agent/models.json` (provider `proxy`, `api: openai-completions`),
|
|
61
61
|
3. **run Pi** non-interactively (`pi -p --mode json --model proxy/<model> --approve`),
|
|
62
|
-
4. **
|
|
62
|
+
4. **validate** the checkout, when the job body carries `validationChecks` — the service's
|
|
63
|
+
configured check commands (install/lint/test/build) run with `sh -c` in the checkout, and
|
|
64
|
+
while they fail and the attempt budget remains the agent is re-run with the captured output
|
|
65
|
+
as its instruction (see [pre-PR validation](../../../docs/initiatives/pre-pr-validation.md)),
|
|
66
|
+
5. **commit, push** a branch and **open a PR**, returning `{ prUrl, branch, summary }` — but
|
|
67
|
+
ONLY if step 4 ended green. A spent budget returns an error result with the validation report
|
|
68
|
+
and opens no PR. Absent `validationChecks`, step 4 does not happen at all.
|
|
63
69
|
|
|
64
70
|
Bootstrap differs at the ends — it may start from an empty dir, and **resets
|
|
65
71
|
history to one commit and force-pushes** the default branch instead of opening a
|
|
66
72
|
PR. Blueprint **commits onto a branch** (no history reset) and returns the tree.
|
|
67
73
|
|
|
74
|
+
## Per-job state: never a process- or HOME-global
|
|
75
|
+
|
|
76
|
+
A job's staging state (the tester's secrets, private-registry auth, a repo-sourced Claude
|
|
77
|
+
Skill) must be scoped to that job, not written into `process.env` or the home directory.
|
|
78
|
+
|
|
79
|
+
In a container those two ARE per-job — one job per process, and `HOME` belongs to that
|
|
80
|
+
container — so a global was a safe place to stage. The **local native transport** breaks both
|
|
81
|
+
assumptions: one long-lived host process serves every concurrent `ambientAuth` job, on the
|
|
82
|
+
**developer's own home**. A global there is shared mutable state across siblings, and writing
|
|
83
|
+
(or clearing) a dotfile destroys a file the developer owns.
|
|
84
|
+
|
|
85
|
+
So per-job values ride explicit **child env** (`RunOptions.agentEnv` →
|
|
86
|
+
`SubscriptionRunOptions.extraEnv`, merged over the inherited env at spawn) and per-job files go
|
|
87
|
+
under a per-job directory:
|
|
88
|
+
|
|
89
|
+
| State | Container | Native (`ambientAuth`) |
|
|
90
|
+
| -------------------- | -------------------------------------------- | ------------------------------------------------------------------------- |
|
|
91
|
+
| Tester secrets | child env | child env (same path — the old `process.env` set/restore is gone) |
|
|
92
|
+
| Private-registry auth | `~/.npmrc`; cleared when a job has no entries | per-job `.npmrc` + `npm_config_userconfig`, seeded from the developer's; theirs is never written or removed |
|
|
93
|
+
| Repo-sourced Claude Skill | installed into the isolated `CLAUDE_CONFIG_DIR` | not installed — read from the checkout's `.cat-context/skill/`, like codex |
|
|
94
|
+
|
|
95
|
+
Two consequences worth knowing:
|
|
96
|
+
|
|
97
|
+
- **The skill's PROMPT follows the same split.** A native install gets a short pointer; every
|
|
98
|
+
checkout-reading case (Pi, codex, ambient claude-code) gets the instructions folded in plus a
|
|
99
|
+
pointer to `.cat-context/skill/`. That decision is the backend's `renderSkillForHarness`, which
|
|
100
|
+
keys off `ambientAuth` as well as the harness — rendering an ambient run as an install would
|
|
101
|
+
point the agent at a skill that is nowhere on disk.
|
|
102
|
+
- **`npm_config_userconfig` reaches less than `~/.npmrc` did.** npm and pnpm honour it; yarn does
|
|
103
|
+
not. And it only reaches processes that are handed the job env, so anything the HARNESS itself
|
|
104
|
+
spawns (the frontend stand-up's install/build, a ralph validation command) is passed
|
|
105
|
+
`RunOptions.agentEnv` explicitly rather than relying on inheritance.
|
|
106
|
+
|
|
107
|
+
When you add per-job state, put it in one of those two places. `~/.pi/*` and
|
|
108
|
+
`~/.config/rpiv-web-tools` remain HOME-global, which is fine only because the Pi harness never
|
|
109
|
+
runs natively (the native router sends `ambientAuth` jobs — Claude/Codex only — to the host
|
|
110
|
+
process and everything else to a container).
|
|
111
|
+
|
|
68
112
|
## No secrets in the image
|
|
69
113
|
|
|
70
114
|
The image (built from the `Dockerfile`, base `node:26-trixie-slim`) contains
|
|
@@ -86,8 +130,10 @@ Kimi / DeepSeek) and meters spend. The provider key never enters the container.
|
|
|
86
130
|
| `src/bootstrap.ts` | The `/bootstrap` handler (clone-or-empty → adapt → reinit + force-push). |
|
|
87
131
|
| `src/blueprint.ts` | The `/blueprint` handler (decompose → render `blueprints/` → commit on branch). |
|
|
88
132
|
| `src/embed.ts` | Bundled assets/templates written into the workspace. |
|
|
133
|
+
| `src/package-registries.ts` | Private-registry (npm) auth: renders the job's allowlisted entries into an npmrc — the user `~/.npmrc` in a container, a per-job file pointed at by `npm_config_userconfig` for a native job. |
|
|
89
134
|
| `src/agent-runner.ts` | The subscription-harness runners (`runClaudeCode` / `runCodex`) — talk direct to the vendor with a leased OAuth token, lift per-turn usage/telemetry off the CLI event stream. |
|
|
90
135
|
| `src/transcript-retention.ts` | Lifts the CLI session transcripts (`projects/` / `sessions/`) out of the isolated, credential-bearing config home before it is deleted, and prunes them on a TTL (debugging artifact retention). |
|
|
136
|
+
| `src/validation-checks.ts` | Pre-PR validation: runs the job's check commands in the checkout (bounded, secret-scrubbed capture, per-command watchdog) and drives the retry-until-green loop that gates the PR. Generic — keyed off the job body, never the agent kind. |
|
|
91
137
|
| `src/logger.ts` | Structured logging. |
|
|
92
138
|
|
|
93
139
|
## Runner lifecycle knobs
|
|
@@ -100,6 +146,7 @@ runner):
|
|
|
100
146
|
| `PORT` | `8080` | HTTP port the harness listens on. |
|
|
101
147
|
| `JOB_MAX_DURATION_MS` | `3600000` (60m) | Hard ceiling on a job's wall-clock time; force-fails after. |
|
|
102
148
|
| `JOB_INACTIVITY_MS` | `600000` (10m) | Kills a hung agent that produces no output for this long. |
|
|
149
|
+
| `VALIDATION_COMMAND_TIMEOUT_MS` | `900000` (15m) | Per-command watchdog for a pre-PR validation check; a timeout counts as a failure (exit 124) so one hung command can't wedge the loop. |
|
|
103
150
|
| `HARNESS_TRANSCRIPT_TTL_MS` | `259200000` (3d) | How long lifted subscription-CLI session transcripts are kept before the retention sweep prunes them. |
|
|
104
151
|
| `HARNESS_TRANSCRIPT_ROOT` | `<tmpdir>/cf-agent-transcripts` | Where retained session transcripts are moved to (one dir per run). Meaningful only on a reused (warm-pool) container; a per-run container is torn down with the job. The TTL sweep deletes only dirs it created (each carries a `.cf-retained` marker), so pointing this at a shared directory never touches unrelated content — though a dedicated dir is still recommended. An override on a different filesystem than the config home falls back to copy-then-remove. |
|
|
105
152
|
|
package/dist/agent-runner.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
3
|
-
import {
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { claudeAssistantContent, claudeCallUsage, isObject, numberOf, redactBody, } from './claude-stream.js';
|
|
6
6
|
import { createCallMetricPublisher, publishCallMetric, } from './pi.js';
|
|
@@ -323,14 +323,14 @@ export async function runClaudeCode(opts) {
|
|
|
323
323
|
await assertOnboardingKeysCurrent(configHome, process.env.CLAUDE_CLI_VERSION, opts.log);
|
|
324
324
|
}
|
|
325
325
|
// Repo-sourced Claude Skill (slice 2): install it as a native skill under the config dir's
|
|
326
|
-
// `skills/<name>/` so the CLI discovers and can invoke it.
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
await writeNativeSkill(
|
|
326
|
+
// `skills/<name>/` so the CLI discovers and can invoke it. ONLY into the isolated per-run config
|
|
327
|
+
// home — never the developer's own `~/.claude` (ambient/native mode), where it would persist in
|
|
328
|
+
// their personal setup after the run and two concurrent jobs carrying same-named skills from
|
|
329
|
+
// different repos would clobber each other. An ambient run reads the skill from the checkout
|
|
330
|
+
// instead (`.cat-context/skill/`, materialised by the caller). Best-effort: a write failure must
|
|
331
|
+
// not wedge the run — the prompt still names the skill.
|
|
332
|
+
if (opts.skill && configHome) {
|
|
333
|
+
await writeNativeSkill(join(configHome, 'skills'), opts.skill).catch(() => { });
|
|
334
334
|
}
|
|
335
335
|
const env = buildClaudeEnv(opts, configHome);
|
|
336
336
|
// ADR 0026 D3 (path corrected by ADR 0027 Defect A): while the run is live, tail the CLI's
|
|
@@ -402,9 +402,12 @@ export async function runClaudeCode(opts) {
|
|
|
402
402
|
* keep its cyclomatic complexity down; behaviour is a straight move of the original expression.
|
|
403
403
|
*/
|
|
404
404
|
function buildClaudeEnv(opts, configHome) {
|
|
405
|
+
// The job-scoped env rides along in BOTH modes; the credential/config vars below are what
|
|
406
|
+
// ambient mode drops (the developer's own logged-in `~/.claude` is used instead).
|
|
405
407
|
if (opts.ambientAuth)
|
|
406
|
-
return {};
|
|
408
|
+
return { ...opts.extraEnv };
|
|
407
409
|
return {
|
|
410
|
+
...opts.extraEnv,
|
|
408
411
|
CLAUDE_CONFIG_DIR: configHome,
|
|
409
412
|
...(opts.subscriptionBaseUrl
|
|
410
413
|
? {
|
|
@@ -574,7 +577,7 @@ export async function runCodex(opts) {
|
|
|
574
577
|
opts.model,
|
|
575
578
|
'-',
|
|
576
579
|
],
|
|
577
|
-
}, prompt, opts, codexHome ? { CODEX_HOME: codexHome } : {}, opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [], onEvent);
|
|
580
|
+
}, prompt, opts, { ...opts.extraEnv, ...(codexHome ? { CODEX_HOME: codexHome } : {}) }, opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [], onEvent);
|
|
578
581
|
// Fallback for a CLI/version that never emits per-turn `last_token_usage`: record a
|
|
579
582
|
// single call from the cumulative total + final text so the run is still observable.
|
|
580
583
|
if (calls.length === 0 && (usage || summary)) {
|
package/dist/agent.js
CHANGED
|
@@ -8,6 +8,7 @@ import { configurePackageRegistries } from './package-registries.js';
|
|
|
8
8
|
import { captureRedactedOutput, redactSecrets, registerKnownSecrets } from './redact.js';
|
|
9
9
|
import { cloneRepo, commitAll, conflictDiff, fetchPullRequestHead, fetchReferenceBranches, hasAgentChanges, headCommit, inferVcsProvider, mergeBranch, openPullRequest, prepareExistingCheckout, pushBranch, reinitAndPush, unmergedPaths, } from './git.js';
|
|
10
10
|
import { makeDirClaimer, noChangesReason, runCodingAgent, runMultiRepoCoding, } from './coding-agent.js';
|
|
11
|
+
import { validationFailureMessage } from './validation-checks.js';
|
|
11
12
|
import { acquireRepoCheckout, agentNeverActed, agentOutputTail, NEVER_ACTED_CAUSE, runAgentInWorkspace, unusableFinalAnswerCause, withWorkspace, } from './pi-workspace.js';
|
|
12
13
|
import { diagnosticsSuffix, resolveStructuredOutput, } from './structured-output.js';
|
|
13
14
|
import { log } from './logger.js';
|
|
@@ -96,12 +97,12 @@ async function standUpInfra(dir, infra, signal, logger) {
|
|
|
96
97
|
* `package.json` / `outputDir` / `mocks/` all live under the service subtree, so installing,
|
|
97
98
|
* building, serving and seeding WireMock from the root would target the wrong directory.
|
|
98
99
|
*/
|
|
99
|
-
async function manageInfra(dir, workDir, infra,
|
|
100
|
+
async function manageInfra(dir, workDir, infra, opts, logger) {
|
|
100
101
|
if (infra.kind === 'frontend') {
|
|
101
102
|
// `onActivity` feeds the inactivity watchdog through the frontend build/serve stand-up,
|
|
102
103
|
// which (unlike docker-compose's 5-min-capped `up`) can run past the inactivity window.
|
|
103
104
|
// Runs in `workDir` so a monorepo frontend builds/serves from its own package subtree.
|
|
104
|
-
const fe = await standUpFrontend(workDir, infra,
|
|
105
|
+
const fe = await standUpFrontend(workDir, infra, opts, logger);
|
|
105
106
|
return {
|
|
106
107
|
...(fe.note ? { note: fe.note } : {}),
|
|
107
108
|
...(fe.serveUrl ? { serveUrl: fe.serveUrl } : {}),
|
|
@@ -109,7 +110,7 @@ async function manageInfra(dir, workDir, infra, signal, onActivity, logger) {
|
|
|
109
110
|
cleanup: () => tearDownFrontend(fe.processes, logger),
|
|
110
111
|
};
|
|
111
112
|
}
|
|
112
|
-
const standUp = await standUpInfra(dir, infra, signal, logger);
|
|
113
|
+
const standUp = await standUpInfra(dir, infra, opts.signal, logger);
|
|
113
114
|
return {
|
|
114
115
|
...(standUp.note ? { note: standUp.note } : {}),
|
|
115
116
|
...(standUp.record ? { record: standUp.record } : {}),
|
|
@@ -238,14 +239,39 @@ function mergeEffort(result, effortReport) {
|
|
|
238
239
|
}
|
|
239
240
|
/** Run one generic agent job end to end, dispatching on `mode`. */
|
|
240
241
|
export async function handleAgent(job, opts = {}) {
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
await
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
242
|
+
// An `ambientAuth` job runs in the SHARED native host process on the developer's own HOME
|
|
243
|
+
// (see `LocalProcessRunnerTransport`), so anything this job would otherwise write to a
|
|
244
|
+
// process- or HOME-global gets a per-job directory instead — it can't corrupt the
|
|
245
|
+
// developer's files, and concurrent jobs can't race on them.
|
|
246
|
+
const scopeDir = job.ambientAuth ? await mkdtemp(join(tmpdir(), 'cf-jobenv-')) : undefined;
|
|
247
|
+
try {
|
|
248
|
+
// Private-registry auth first, before any mode runs: every mode with a checkout may
|
|
249
|
+
// install dependencies (the agent's own shell and the frontend-infra stand-up both
|
|
250
|
+
// inherit this env, so they all read the written npmrc). In a container a job with no
|
|
251
|
+
// entries clears any stale ~/.npmrc from a prior job on a reused (warm-pool) container.
|
|
252
|
+
const registryEnv = await configurePackageRegistries(job.packageRegistries, scopeDir ? { isolatedDir: scopeDir } : {});
|
|
253
|
+
const scoped = withAgentEnv(opts, registryEnv);
|
|
254
|
+
if (job.mode === 'preview')
|
|
255
|
+
return await runPreviewMode(job, scoped);
|
|
256
|
+
return job.mode === 'coding'
|
|
257
|
+
? await runCodingMode(job, scoped)
|
|
258
|
+
: await runExploreMode(job, scoped);
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
if (scopeDir)
|
|
262
|
+
await rm(scopeDir, { recursive: true, force: true }).catch(() => { });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Layer extra child-process env onto a job's {@link RunOptions}. The agent CLI is spawned with
|
|
267
|
+
* `{...process.env, ...agentEnv}`, so this is how per-job values reach the agent (and the shell
|
|
268
|
+
* tools it spawns) WITHOUT mutating the harness's own `process.env` — which is shared by every
|
|
269
|
+
* concurrent job when the harness runs as a native host process. Empty `env` ⇒ `opts` unchanged.
|
|
270
|
+
*/
|
|
271
|
+
function withAgentEnv(opts, env) {
|
|
272
|
+
if (Object.keys(env).length === 0)
|
|
273
|
+
return opts;
|
|
274
|
+
return { ...opts, agentEnv: { ...opts.agentEnv, ...env } };
|
|
249
275
|
}
|
|
250
276
|
/**
|
|
251
277
|
* Decide a preview stand-up's outcome from its result (pure, so the success/failure boundary
|
|
@@ -300,7 +326,7 @@ async function runPreviewMode(job, opts) {
|
|
|
300
326
|
logger.info('agent(preview): building + serving', {
|
|
301
327
|
serviceDirectory: job.repo.serviceDirectory,
|
|
302
328
|
});
|
|
303
|
-
const fe = await standUpFrontend(workDir, infra, opts
|
|
329
|
+
const fe = await standUpFrontend(workDir, infra, opts, logger);
|
|
304
330
|
const infraSetupFields = fe.record
|
|
305
331
|
? { infraSetup: fe.record }
|
|
306
332
|
: {};
|
|
@@ -332,30 +358,23 @@ async function runPreviewMode(job, opts) {
|
|
|
332
358
|
}
|
|
333
359
|
}
|
|
334
360
|
/**
|
|
335
|
-
*
|
|
336
|
-
*
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
340
|
-
*
|
|
361
|
+
* Build the env carrying the tester's sensitive secrets, so the agent's shell tools (spawned as
|
|
362
|
+
* child processes that inherit it) can read `$KEY` — the out-of-band delivery channel. Each value
|
|
363
|
+
* is registered for redaction so it can't leak into captured output/logs. Reserved/toolchain env
|
|
364
|
+
* names were already dropped at parse. No secrets ⇒ an empty env.
|
|
365
|
+
*
|
|
366
|
+
* Returned as EXPLICIT child env rather than written onto `process.env`: a process-global
|
|
367
|
+
* set/restore is only safe when the process runs one job, which the native host-process transport
|
|
368
|
+
* breaks (it serves every concurrent ambient job from one process). There, two overlapping tester
|
|
369
|
+
* runs would read each other's secrets, and whichever finished first would delete the other's
|
|
370
|
+
* mid-run. Scoping them to the spawn env makes the delivery correct under concurrency and drops
|
|
371
|
+
* the restore step entirely.
|
|
341
372
|
*/
|
|
342
|
-
function
|
|
373
|
+
export function testSecretEnv(secrets) {
|
|
343
374
|
if (!secrets?.length)
|
|
344
|
-
return
|
|
375
|
+
return {};
|
|
345
376
|
registerKnownSecrets(secrets.map((s) => s.value));
|
|
346
|
-
|
|
347
|
-
for (const { key, value } of secrets) {
|
|
348
|
-
previous.set(key, process.env[key]);
|
|
349
|
-
process.env[key] = value;
|
|
350
|
-
}
|
|
351
|
-
return () => {
|
|
352
|
-
for (const [key, prior] of previous) {
|
|
353
|
-
if (prior === undefined)
|
|
354
|
-
delete process.env[key];
|
|
355
|
-
else
|
|
356
|
-
process.env[key] = prior;
|
|
357
|
-
}
|
|
358
|
-
};
|
|
377
|
+
return Object.fromEntries(secrets.map(({ key, value }) => [key, value]));
|
|
359
378
|
}
|
|
360
379
|
/**
|
|
361
380
|
* Read-only exploration: clone `branch`, run the agent making no edits, and return its
|
|
@@ -442,9 +461,7 @@ async function runExploreMode(job, opts) {
|
|
|
442
461
|
// The run-mode guidance itself lives in the backend-composed system/user prompt; the
|
|
443
462
|
// harness only manages the lifecycle + this dynamic stand-up note.
|
|
444
463
|
const infra = job.infra;
|
|
445
|
-
const managed = infra
|
|
446
|
-
? await manageInfra(dir, workDir, infra, opts.signal, opts.onActivity, logger)
|
|
447
|
-
: undefined;
|
|
464
|
+
const managed = infra ? await manageInfra(dir, workDir, infra, opts, logger) : undefined;
|
|
448
465
|
// Fold the stand-up outcome into the agent prompt: a stand-up problem (build/compose
|
|
449
466
|
// failure) is flagged as a concern; a frontend serve URL points the UI tester at the
|
|
450
467
|
// app it just built + served (the backend env resolution already reached the harness).
|
|
@@ -458,10 +475,10 @@ async function runExploreMode(job, opts) {
|
|
|
458
475
|
const infraSetupFields = managed?.record
|
|
459
476
|
? { infraSetup: managed.record }
|
|
460
477
|
: {};
|
|
461
|
-
//
|
|
462
|
-
// shell can read them as `$KEY
|
|
463
|
-
//
|
|
464
|
-
const
|
|
478
|
+
// Hand the tester's sensitive secrets to the agent's child process (out of band) so its
|
|
479
|
+
// shell can read them as `$KEY`. Scoped to this job's env, so a concurrent job in the same
|
|
480
|
+
// harness process never sees them. A no-op for non-tester runs (no `testSecrets`).
|
|
481
|
+
const agentOpts = withAgentEnv(opts, testSecretEnv(job.testSecrets));
|
|
465
482
|
try {
|
|
466
483
|
opts.onPhase?.('agent');
|
|
467
484
|
logger.info('agent(explore): running agent', { serviceDirectory });
|
|
@@ -484,11 +501,10 @@ async function runExploreMode(job, opts) {
|
|
|
484
501
|
webSearchProxy: job.webSearch,
|
|
485
502
|
contextFiles: job.contextFiles,
|
|
486
503
|
guardLimits: job.guardLimits,
|
|
487
|
-
},
|
|
504
|
+
}, agentOpts);
|
|
488
505
|
return mergeEffort(await finalizeExploreResult(job, { summary, stats, stderrTail, usage, callMetrics, runDiag }, { infra, infraSetupFields, logger, signal: opts.signal }), effortReport);
|
|
489
506
|
}
|
|
490
507
|
finally {
|
|
491
|
-
restoreSecrets();
|
|
492
508
|
if (managed)
|
|
493
509
|
await managed.cleanup();
|
|
494
510
|
}
|
|
@@ -787,6 +803,11 @@ function buildSingleRepoCodingSpec(job, pushBranch) {
|
|
|
787
803
|
},
|
|
788
804
|
}
|
|
789
805
|
: {}),
|
|
806
|
+
// Pre-PR validation: the service's check commands, run against the checkout BEFORE the PR
|
|
807
|
+
// opens with failures fed back to the agent (see docs/initiatives/pre-pr-validation.md).
|
|
808
|
+
// Forwarded straight off the job body — the loop is generic machinery keyed on the data, not
|
|
809
|
+
// on the agent kind.
|
|
810
|
+
...(job.validationChecks ? { validationChecks: job.validationChecks } : {}),
|
|
790
811
|
};
|
|
791
812
|
}
|
|
792
813
|
/**
|
|
@@ -797,12 +818,38 @@ function buildSingleRepoCodingSpec(job, pushBranch) {
|
|
|
797
818
|
*/
|
|
798
819
|
async function runSingleRepoCoding(job, opts) {
|
|
799
820
|
const pushBranch = job.pushBranch ?? job.newBranch ?? job.branch;
|
|
800
|
-
const { summary, stats, stderrTail, pushed, usage, callMetrics, validation, effortReport } = await runCodingAgent(buildSingleRepoCodingSpec(job, pushBranch), opts);
|
|
821
|
+
const { summary, stats, stderrTail, pushed, usage, callMetrics, validation, validationReport, effortReport, } = await runCodingAgent(buildSingleRepoCodingSpec(job, pushBranch), opts);
|
|
801
822
|
// Ralph loop: the harness-computed validation verdict, forwarded onto the coding result as
|
|
802
823
|
// `ralphVerdict` so the backend's `toRunResult` lifts it onto `AgentRunResult.ralphVerdict`.
|
|
803
824
|
const ralphVerdict = validation ? { ralphVerdict: validation } : {};
|
|
804
825
|
// The agent's effort self-assessment, spread onto every result path below (mirrors ralphVerdict).
|
|
805
826
|
const effort = effortReport ? { effortReport } : {};
|
|
827
|
+
// The pre-PR validation report, spread onto every result path below: on the passing path it is
|
|
828
|
+
// the captured proof the checkout was green when the PR opened; on the exhausted path it is the
|
|
829
|
+
// evidence behind the failure below. Absent when the service configured no checks.
|
|
830
|
+
const validationFields = validationReport ? { validationReport } : {};
|
|
831
|
+
// Pre-PR validation spent its attempt budget with the checkout still red. FAIL the job — do
|
|
832
|
+
// NOT open a pull request, and do not pretend the push succeeded as a deliverable. The work is
|
|
833
|
+
// still on the branch (a retry resumes on it); the report carries each failing command's exit
|
|
834
|
+
// code and captured output so the step's failure detail says exactly what broke.
|
|
835
|
+
if (validationReport && !validationReport.passed) {
|
|
836
|
+
return {
|
|
837
|
+
// The work IS on the branch (the loop only runs for a pass that produced some, and the
|
|
838
|
+
// harness pushes it) — a retry resumes on top of it. `error` is what marks the job failed;
|
|
839
|
+
// reporting `pushed: false` here would misdescribe the branch state in the harness's own
|
|
840
|
+
// result for no benefit.
|
|
841
|
+
pushed,
|
|
842
|
+
branch: pushBranch,
|
|
843
|
+
summary,
|
|
844
|
+
stats,
|
|
845
|
+
error: validationFailureMessage(validationReport),
|
|
846
|
+
failureCause: 'agent',
|
|
847
|
+
...(usage ? { usage } : {}),
|
|
848
|
+
...(callMetrics ? { callMetrics } : {}),
|
|
849
|
+
...validationFields,
|
|
850
|
+
...effort,
|
|
851
|
+
};
|
|
852
|
+
}
|
|
806
853
|
if (!pushed) {
|
|
807
854
|
// A no-op: a failure for the implementer, a clean non-event for the fixers.
|
|
808
855
|
if (job.noChangesIsError === false) {
|
|
@@ -814,6 +861,7 @@ async function runSingleRepoCoding(job, opts) {
|
|
|
814
861
|
...(usage ? { usage } : {}),
|
|
815
862
|
...(callMetrics ? { callMetrics } : {}),
|
|
816
863
|
...ralphVerdict,
|
|
864
|
+
...validationFields,
|
|
817
865
|
...effort,
|
|
818
866
|
};
|
|
819
867
|
}
|
|
@@ -826,6 +874,7 @@ async function runSingleRepoCoding(job, opts) {
|
|
|
826
874
|
failureCause: 'no-changes',
|
|
827
875
|
...(usage ? { usage } : {}),
|
|
828
876
|
...(callMetrics ? { callMetrics } : {}),
|
|
877
|
+
...validationFields,
|
|
829
878
|
...effort,
|
|
830
879
|
};
|
|
831
880
|
}
|
|
@@ -859,6 +908,7 @@ async function runSingleRepoCoding(job, opts) {
|
|
|
859
908
|
stats,
|
|
860
909
|
...(usage ? { usage } : {}),
|
|
861
910
|
...(callMetrics ? { callMetrics } : {}),
|
|
911
|
+
...validationFields,
|
|
862
912
|
...effort,
|
|
863
913
|
};
|
|
864
914
|
}
|
|
@@ -871,6 +921,7 @@ async function runSingleRepoCoding(job, opts) {
|
|
|
871
921
|
failureCause: 'no-changes',
|
|
872
922
|
...(usage ? { usage } : {}),
|
|
873
923
|
...(callMetrics ? { callMetrics } : {}),
|
|
924
|
+
...validationFields,
|
|
874
925
|
...effort,
|
|
875
926
|
};
|
|
876
927
|
}
|
|
@@ -883,6 +934,7 @@ async function runSingleRepoCoding(job, opts) {
|
|
|
883
934
|
...(usage ? { usage } : {}),
|
|
884
935
|
...(callMetrics ? { callMetrics } : {}),
|
|
885
936
|
...ralphVerdict,
|
|
937
|
+
...validationFields,
|
|
886
938
|
...effort,
|
|
887
939
|
};
|
|
888
940
|
}
|
|
@@ -894,6 +946,7 @@ async function runSingleRepoCoding(job, opts) {
|
|
|
894
946
|
...(usage ? { usage } : {}),
|
|
895
947
|
...(callMetrics ? { callMetrics } : {}),
|
|
896
948
|
...ralphVerdict,
|
|
949
|
+
...validationFields,
|
|
897
950
|
...effort,
|
|
898
951
|
};
|
|
899
952
|
}
|
package/dist/coding-agent.js
CHANGED
|
@@ -8,6 +8,7 @@ import { FOLLOW_UPS_FILENAME, FollowUpTailer } from './follow-ups.js';
|
|
|
8
8
|
import { EFFORT_REPORT_FILE } from './effort.js';
|
|
9
9
|
import { acquireRepoCheckout, agentNeverActed, agentOutputTail, runAgentInWorkspace, withWorkspace, } from './pi-workspace.js';
|
|
10
10
|
import { log } from './logger.js';
|
|
11
|
+
import { runValidationLoop, } from './validation-checks.js';
|
|
11
12
|
/**
|
|
12
13
|
* How often the harness checkpoints the agent's work mid-run by pushing the branch.
|
|
13
14
|
* A per-run container can be evicted at any moment; pushing the agent's commits
|
|
@@ -128,28 +129,57 @@ export async function runCodingAgent(spec, opts = {}) {
|
|
|
128
129
|
}, followUpPollIntervalMs());
|
|
129
130
|
followUpTick.unref?.();
|
|
130
131
|
}
|
|
132
|
+
// One agent pass over this checkout, parameterised only by the prompt — so the pre-PR
|
|
133
|
+
// validation loop below can re-run the agent with a repair instruction without
|
|
134
|
+
// re-deriving (or drifting from) the dispatch's own settings.
|
|
135
|
+
const runAgentPass = (userPrompt) => runAgentInWorkspace({
|
|
136
|
+
dir: workDir,
|
|
137
|
+
systemPrompt: spec.systemPrompt,
|
|
138
|
+
userPrompt,
|
|
139
|
+
model: spec.model,
|
|
140
|
+
harness: spec.harness,
|
|
141
|
+
subscriptionToken: spec.subscriptionToken,
|
|
142
|
+
subscriptionBaseUrl: spec.subscriptionBaseUrl,
|
|
143
|
+
ambientAuth: spec.ambientAuth,
|
|
144
|
+
proxyBaseUrl: spec.proxyBaseUrl,
|
|
145
|
+
sessionToken: spec.sessionToken,
|
|
146
|
+
serviceDirectory,
|
|
147
|
+
webToolsGuidance: spec.webToolsGuidance,
|
|
148
|
+
webSearchProxy: spec.webSearchProxy,
|
|
149
|
+
guardLimits: spec.guardLimits,
|
|
150
|
+
...(spec.skill ? { skill: spec.skill } : {}),
|
|
151
|
+
}, opts);
|
|
131
152
|
let outcome;
|
|
132
153
|
try {
|
|
133
154
|
opts.onPhase?.('agent');
|
|
134
155
|
logger.info('coding-agent: running agent', { serviceDirectory });
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
156
|
+
let agentRun = await runAgentPass(spec.userPrompt);
|
|
157
|
+
// PRE-PR VALIDATION: run the service's configured checks against the checkout and, while
|
|
158
|
+
// they fail and budget remains, hand the captured output back to the agent and run it
|
|
159
|
+
// again. Sits BETWEEN the agent and the finalize/push/PR step so a red checkout never
|
|
160
|
+
// reaches `openPullRequest` — the whole point of the feature. Keyed purely off the job
|
|
161
|
+
// body carrying checks (no agent-kind switch); absent ⇒ this is a no-op and the flow
|
|
162
|
+
// below is byte-for-byte what it was.
|
|
163
|
+
const validationChecks = spec.validationChecks;
|
|
164
|
+
let validationReport;
|
|
165
|
+
if (validationChecks && (await producedWork(dir, spec, baseSha, resumed, opts))) {
|
|
166
|
+
validationReport = await runValidationLoop({
|
|
167
|
+
workDir,
|
|
168
|
+
spec: validationChecks,
|
|
169
|
+
logger,
|
|
170
|
+
opts,
|
|
171
|
+
runAgentPass,
|
|
172
|
+
onAgentPass: (run) => {
|
|
173
|
+
agentRun = mergeAgentPasses(agentRun, run);
|
|
174
|
+
},
|
|
175
|
+
// The checks run against the WORKING TREE, but only tracked edits are staged for the
|
|
176
|
+
// push — so a repair round can go green on a new file the PR would never contain.
|
|
177
|
+
// Name those files in the next repair prompt so the agent adds them.
|
|
178
|
+
listUncommittedNewFiles: () => listUntrackedFiles(workDir, opts.signal),
|
|
179
|
+
});
|
|
180
|
+
}
|
|
152
181
|
outcome = await finalizeCodingRun({
|
|
182
|
+
validationReport,
|
|
153
183
|
dir,
|
|
154
184
|
spec,
|
|
155
185
|
logger,
|
|
@@ -284,7 +314,7 @@ async function prepareCodingCheckout(dir, spec, logger, opts) {
|
|
|
284
314
|
* {@link runCodingAgent} so its body stays small; returns the built {@link CodingAgentOutcome}.
|
|
285
315
|
*/
|
|
286
316
|
async function finalizeCodingRun(args) {
|
|
287
|
-
const { dir, spec, logger, opts, baseSha, resumed, workDir, checkpoint, followUpTick, followUpTailer, pushWorkOnce, inFlightPush, agentRun, } = args;
|
|
317
|
+
const { validationReport, dir, spec, logger, opts, baseSha, resumed, workDir, checkpoint, followUpTick, followUpTailer, pushWorkOnce, inFlightPush, agentRun, } = args;
|
|
288
318
|
const { signal } = opts;
|
|
289
319
|
const { summary, stats, stderrTail, usage, callMetrics, effortReport } = agentRun;
|
|
290
320
|
let outcome;
|
|
@@ -368,8 +398,63 @@ async function finalizeCodingRun(args) {
|
|
|
368
398
|
if (spec.validation) {
|
|
369
399
|
outcome.validation = await runRalphValidation(workDir, spec.validation, logger, opts);
|
|
370
400
|
}
|
|
401
|
+
// Pre-PR validation: the loop already ran (before this finalize, so a red checkout never
|
|
402
|
+
// reaches the PR-opening caller); attach its verdict for the caller to gate on and for the
|
|
403
|
+
// backend to record on the step.
|
|
404
|
+
if (validationReport)
|
|
405
|
+
outcome.validationReport = validationReport;
|
|
371
406
|
return outcome;
|
|
372
407
|
}
|
|
408
|
+
/**
|
|
409
|
+
* Whether this pass produced anything worth VALIDATING — i.e. the branch advanced past its
|
|
410
|
+
* pre-run tip (or the run resumed an earlier one's pushed work). Gates the pre-PR validation
|
|
411
|
+
* loop, for two reasons: a run that changed nothing has nothing to check, and its real failure
|
|
412
|
+
* is "the agent produced no file changes" — reporting a red BASE branch instead would blame the
|
|
413
|
+
* run for a pre-existing condition it never touched (and burn the whole repair budget re-running
|
|
414
|
+
* an agent that already declined to act).
|
|
415
|
+
*
|
|
416
|
+
* Commits forgotten edits to tracked files first, exactly as {@link finalizeCodingRun} does, so
|
|
417
|
+
* an agent that edited-but-didn't-commit still counts as work. That call is idempotent, so
|
|
418
|
+
* finalize repeating it later is a no-op. Uncommitted NEW files are invisible here — but they
|
|
419
|
+
* are equally invisible to finalize, so a run whose only product is an uncommitted new file is
|
|
420
|
+
* a no-op on both paths, and the checks would have nothing to gate anyway.
|
|
421
|
+
*/
|
|
422
|
+
async function producedWork(dir, spec, baseSha, resumed, opts) {
|
|
423
|
+
await commitTrackedEdits(dir, spec.commitMessage, opts.signal);
|
|
424
|
+
return resumed || (await branchHasCommitsSince(dir, baseSha, opts.signal));
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Fold a pre-PR validation REPAIR pass's run into the accumulated agent outcome, so a looped run
|
|
428
|
+
* reports what every round actually spent rather than only the first. Counts and telemetry are
|
|
429
|
+
* summed/concatenated; the single-valued fields (the summary the backend renders, the effort
|
|
430
|
+
* report, the diagnostics that judge the FINAL answer) take the LATEST pass, which is the one
|
|
431
|
+
* whose state the PR is opened from.
|
|
432
|
+
*/
|
|
433
|
+
function mergeAgentPasses(previous, next) {
|
|
434
|
+
return {
|
|
435
|
+
...next,
|
|
436
|
+
stats: {
|
|
437
|
+
toolCalls: (previous.stats?.toolCalls ?? 0) + (next.stats?.toolCalls ?? 0),
|
|
438
|
+
assistantChars: (previous.stats?.assistantChars ?? 0) + (next.stats?.assistantChars ?? 0),
|
|
439
|
+
},
|
|
440
|
+
...(previous.usage || next.usage
|
|
441
|
+
? {
|
|
442
|
+
usage: {
|
|
443
|
+
inputTokens: (previous.usage?.inputTokens ?? 0) + (next.usage?.inputTokens ?? 0),
|
|
444
|
+
outputTokens: (previous.usage?.outputTokens ?? 0) + (next.usage?.outputTokens ?? 0),
|
|
445
|
+
},
|
|
446
|
+
}
|
|
447
|
+
: {}),
|
|
448
|
+
...(previous.callMetrics || next.callMetrics
|
|
449
|
+
? { callMetrics: [...(previous.callMetrics ?? []), ...(next.callMetrics ?? [])] }
|
|
450
|
+
: {}),
|
|
451
|
+
// The repair pass's own effort report wins when it wrote one; otherwise keep the first
|
|
452
|
+
// pass's rather than losing the assessment entirely.
|
|
453
|
+
...((next.effortReport ?? previous.effortReport)
|
|
454
|
+
? { effortReport: next.effortReport ?? previous.effortReport }
|
|
455
|
+
: {}),
|
|
456
|
+
};
|
|
457
|
+
}
|
|
373
458
|
/**
|
|
374
459
|
* The Ralph-loop validation watchdog: the longest a completion command may run before it is
|
|
375
460
|
* killed and treated as a failure (a hung `pnpm test` must never block the loop forever).
|
|
@@ -401,6 +486,10 @@ async function runRalphValidation(cwd, validation, logger, opts) {
|
|
|
401
486
|
cwd,
|
|
402
487
|
detached: spawnDetached,
|
|
403
488
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
489
|
+
// The job's own env (see `RunOptions.agentEnv`): a validation command typically installs
|
|
490
|
+
// before it tests, and this is spawned by the HARNESS rather than the agent, so it does not
|
|
491
|
+
// otherwise inherit the job's private-registry npmrc pointer on the native path.
|
|
492
|
+
env: { ...process.env, ...opts.agentEnv },
|
|
404
493
|
});
|
|
405
494
|
// Keep only the tail; guard against unbounded buffering on a chatty command.
|
|
406
495
|
const capture = (chunk) => {
|
package/dist/frontend-infra.js
CHANGED
|
@@ -56,7 +56,8 @@ function guardProcess(child, label, logger) {
|
|
|
56
56
|
* the agent as a prompt note (and captured on the record) rather than failing the job — the
|
|
57
57
|
* agent then reports the gap as a concern. Every path returns the processes to tear down.
|
|
58
58
|
*/
|
|
59
|
-
export async function standUpFrontend(dir, infra,
|
|
59
|
+
export async function standUpFrontend(dir, infra, run, logger = log) {
|
|
60
|
+
const { signal, onActivity } = run;
|
|
60
61
|
const startedAt = Date.now();
|
|
61
62
|
const processes = [];
|
|
62
63
|
// The frontend app's directory: the checkout root, or a monorepo subdirectory when the config
|
|
@@ -94,6 +95,11 @@ export async function standUpFrontend(dir, infra, signal, onActivity, logger = l
|
|
|
94
95
|
};
|
|
95
96
|
};
|
|
96
97
|
const buildEnv = (infra.envInjection ?? DEFAULTS.envInjection) === 'build' ? (infra.env ?? {}) : {};
|
|
98
|
+
// The job's own env (see `RunOptions.agentEnv`) — today the private-registry npmrc pointer.
|
|
99
|
+
// The stand-up is spawned by the HARNESS, not by the agent, so it does not inherit whatever the
|
|
100
|
+
// agent's CLI child was given: without this the install here would miss the job's registry auth
|
|
101
|
+
// on the native path, where the npmrc is per-job rather than the process's `~/.npmrc`.
|
|
102
|
+
const jobEnv = run.agentEnv ?? {};
|
|
97
103
|
try {
|
|
98
104
|
// 1) Install dependencies.
|
|
99
105
|
const install = installCommand(infra);
|
|
@@ -103,6 +109,7 @@ export async function standUpFrontend(dir, infra, signal, onActivity, logger = l
|
|
|
103
109
|
signal,
|
|
104
110
|
timeout: 8 * 60_000,
|
|
105
111
|
maxBuffer: 16 * 1024 * 1024,
|
|
112
|
+
env: { ...process.env, ...jobEnv },
|
|
106
113
|
});
|
|
107
114
|
pushOutput(installed.stdout, installed.stderr);
|
|
108
115
|
// 2) Build (build-time env injected here; runtime injection writes a shim after).
|
|
@@ -114,7 +121,7 @@ export async function standUpFrontend(dir, infra, signal, onActivity, logger = l
|
|
|
114
121
|
signal,
|
|
115
122
|
timeout: 12 * 60_000,
|
|
116
123
|
maxBuffer: 16 * 1024 * 1024,
|
|
117
|
-
env: { ...process.env, ...buildEnv },
|
|
124
|
+
env: { ...process.env, ...jobEnv, ...buildEnv },
|
|
118
125
|
});
|
|
119
126
|
pushOutput(built.stdout, built.stderr);
|
|
120
127
|
// Runtime injection: write a `window.env` shim into the build output the app can load
|