@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 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. **commit, push** a branch and **open a PR**, returning `{ prUrl, branch, summary }`.
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
 
@@ -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 { homedir, tmpdir } from 'node:os';
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. Written to the isolated per-run
327
- // config home when present, else the developer's `~/.claude` (ambient/native mode). Best-effort:
328
- // a write failure must not wedge the run the prompt still names the skill.
329
- if (opts.skill) {
330
- const skillsRoot = configHome
331
- ? join(configHome, 'skills')
332
- : join(homedir(), '.claude', 'skills');
333
- await writeNativeSkill(skillsRoot, opts.skill).catch(() => { });
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, signal, onActivity, logger) {
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, signal, onActivity, logger);
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
- // Private-registry auth first, before any mode runs: every mode with a checkout may
242
- // install dependencies (the agent's own shell and the frontend-infra stand-up both
243
- // inherit `HOME`, so they all read the written ~/.npmrc). A job with no entries
244
- // clears any stale ~/.npmrc from a prior job on a reused (warm-pool) container.
245
- await configurePackageRegistries(job.packageRegistries);
246
- if (job.mode === 'preview')
247
- return runPreviewMode(job, opts);
248
- return job.mode === 'coding' ? runCodingMode(job, opts) : runExploreMode(job, opts);
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.signal, opts.onActivity, logger);
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
- * Inject the tester's sensitive secrets into the PROCESS environment so the agent's shell tools
336
- * (spawned as child processes that inherit this env) can read `$KEY` — the out-of-band delivery
337
- * channel. Each value is registered for redaction so it can't leak into captured output/logs.
338
- * Returns a restore closure that puts the environment back afterward (warm-pool hygiene, so a
339
- * later job on a reused container never inherits a prior run's secrets). Reserved/toolchain env
340
- * names were already dropped at parse. A no-op when there are no secrets.
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 applyTestSecrets(secrets) {
373
+ export function testSecretEnv(secrets) {
343
374
  if (!secrets?.length)
344
- return () => { };
375
+ return {};
345
376
  registerKnownSecrets(secrets.map((s) => s.value));
346
- const previous = new Map();
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
- // Inject the tester's sensitive secrets into the environment (out of band) so the agent's
462
- // shell can read them as `$KEY`; restore afterwards so a reused (warm-pool) container never
463
- // leaks them to a later job. A no-op for non-tester runs (no `testSecrets`).
464
- const restoreSecrets = applyTestSecrets(job.testSecrets);
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
- }, opts);
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
  }
@@ -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
- const agentRun = await runAgentInWorkspace({
136
- dir: workDir,
137
- systemPrompt: spec.systemPrompt,
138
- userPrompt: spec.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);
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) => {
@@ -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, signal, onActivity, logger = log) {
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