@cat-factory/executor-harness 1.54.0 → 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,7 +59,13 @@ 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
@@ -127,6 +133,7 @@ Kimi / DeepSeek) and meters spend. The provider key never enters the container.
127
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. |
128
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. |
129
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. |
130
137
  | `src/logger.ts` | Structured logging. |
131
138
 
132
139
  ## Runner lifecycle knobs
@@ -139,6 +146,7 @@ runner):
139
146
  | `PORT` | `8080` | HTTP port the harness listens on. |
140
147
  | `JOB_MAX_DURATION_MS` | `3600000` (60m) | Hard ceiling on a job's wall-clock time; force-fails after. |
141
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. |
142
150
  | `HARNESS_TRANSCRIPT_TTL_MS` | `259200000` (3d) | How long lifted subscription-CLI session transcripts are kept before the retention sweep prunes them. |
143
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. |
144
152
 
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';
@@ -802,6 +803,11 @@ function buildSingleRepoCodingSpec(job, pushBranch) {
802
803
  },
803
804
  }
804
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 } : {}),
805
811
  };
806
812
  }
807
813
  /**
@@ -812,12 +818,38 @@ function buildSingleRepoCodingSpec(job, pushBranch) {
812
818
  */
813
819
  async function runSingleRepoCoding(job, opts) {
814
820
  const pushBranch = job.pushBranch ?? job.newBranch ?? job.branch;
815
- 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);
816
822
  // Ralph loop: the harness-computed validation verdict, forwarded onto the coding result as
817
823
  // `ralphVerdict` so the backend's `toRunResult` lifts it onto `AgentRunResult.ralphVerdict`.
818
824
  const ralphVerdict = validation ? { ralphVerdict: validation } : {};
819
825
  // The agent's effort self-assessment, spread onto every result path below (mirrors ralphVerdict).
820
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
+ }
821
853
  if (!pushed) {
822
854
  // A no-op: a failure for the implementer, a clean non-event for the fixers.
823
855
  if (job.noChangesIsError === false) {
@@ -829,6 +861,7 @@ async function runSingleRepoCoding(job, opts) {
829
861
  ...(usage ? { usage } : {}),
830
862
  ...(callMetrics ? { callMetrics } : {}),
831
863
  ...ralphVerdict,
864
+ ...validationFields,
832
865
  ...effort,
833
866
  };
834
867
  }
@@ -841,6 +874,7 @@ async function runSingleRepoCoding(job, opts) {
841
874
  failureCause: 'no-changes',
842
875
  ...(usage ? { usage } : {}),
843
876
  ...(callMetrics ? { callMetrics } : {}),
877
+ ...validationFields,
844
878
  ...effort,
845
879
  };
846
880
  }
@@ -874,6 +908,7 @@ async function runSingleRepoCoding(job, opts) {
874
908
  stats,
875
909
  ...(usage ? { usage } : {}),
876
910
  ...(callMetrics ? { callMetrics } : {}),
911
+ ...validationFields,
877
912
  ...effort,
878
913
  };
879
914
  }
@@ -886,6 +921,7 @@ async function runSingleRepoCoding(job, opts) {
886
921
  failureCause: 'no-changes',
887
922
  ...(usage ? { usage } : {}),
888
923
  ...(callMetrics ? { callMetrics } : {}),
924
+ ...validationFields,
889
925
  ...effort,
890
926
  };
891
927
  }
@@ -898,6 +934,7 @@ async function runSingleRepoCoding(job, opts) {
898
934
  ...(usage ? { usage } : {}),
899
935
  ...(callMetrics ? { callMetrics } : {}),
900
936
  ...ralphVerdict,
937
+ ...validationFields,
901
938
  ...effort,
902
939
  };
903
940
  }
@@ -909,6 +946,7 @@ async function runSingleRepoCoding(job, opts) {
909
946
  ...(usage ? { usage } : {}),
910
947
  ...(callMetrics ? { callMetrics } : {}),
911
948
  ...ralphVerdict,
949
+ ...validationFields,
912
950
  ...effort,
913
951
  };
914
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).
package/dist/job.js CHANGED
@@ -66,6 +66,39 @@ function parseValidationSpec(value) {
66
66
  ...(iteration !== undefined ? { iteration } : {}),
67
67
  };
68
68
  }
69
+ /**
70
+ * Parse the optional PRE-PR VALIDATION CHECKS spec (see
71
+ * docs/initiatives/pre-pr-validation.md): the service's ordered `{ label, command }` pairs and
72
+ * the repair-round budget. Every entry needs a non-empty command; entries without one are
73
+ * dropped, and a spec that ends up with no usable check returns `undefined` — so a malformed
74
+ * body degrades to the exact pre-feature behaviour (no loop, PR opens as before) rather than
75
+ * failing an otherwise-good coding run. `maxAttempts` is clamped to a sane range so a bad body
76
+ * can't make a container loop forever.
77
+ */
78
+ function parseValidationChecksSpec(value) {
79
+ if (typeof value !== 'object' || value === null)
80
+ return undefined;
81
+ const o = value;
82
+ if (!Array.isArray(o.checks))
83
+ return undefined;
84
+ const checks = [];
85
+ for (const raw of o.checks) {
86
+ if (typeof raw !== 'object' || raw === null)
87
+ continue;
88
+ const c = raw;
89
+ if (typeof c.command !== 'string' || c.command.trim() === '')
90
+ continue;
91
+ const label = typeof c.label === 'string' && c.label.trim() ? c.label.trim() : c.command;
92
+ checks.push({ label, command: c.command });
93
+ }
94
+ if (checks.length === 0)
95
+ return undefined;
96
+ const parsed = posInt(o.maxAttempts);
97
+ return {
98
+ checks,
99
+ maxAttempts: Math.min(parsed ?? VALIDATION_DEFAULT_MAX_ATTEMPTS, VALIDATION_MAX_ATTEMPTS_CEILING),
100
+ };
101
+ }
69
102
  /**
70
103
  * Parse the shared per-job auth fields, validating per harness: a subscription
71
104
  * harness (`claude-code` / `codex`) requires `subscriptionToken`; the default Pi
@@ -353,6 +386,18 @@ export function parseTestSecrets(value) {
353
386
  }
354
387
  return entries;
355
388
  }
389
+ /**
390
+ * The ceiling the harness clamps a body-supplied `validationChecks.maxAttempts` to, and the
391
+ * default it applies when the body omits one.
392
+ *
393
+ * DELIBERATE DUPLICATES of `VALIDATION_MAX_ATTEMPTS_CEILING` / `VALIDATION_DEFAULT_MAX_ATTEMPTS`
394
+ * in `@cat-factory/contracts` — the published image takes no schema dependency, so the harness
395
+ * cannot import them. Keep the two in step: the API validates writes against the contracts
396
+ * values, so a harness clamping to a DIFFERENT ceiling would silently cap a budget an operator
397
+ * was allowed to save, with nothing to flag the mismatch.
398
+ */
399
+ export const VALIDATION_MAX_ATTEMPTS_CEILING = 10;
400
+ export const VALIDATION_DEFAULT_MAX_ATTEMPTS = 3;
356
401
  /** Parse the coding-mode bootstrap spec, or undefined when absent. Validates the target. */
357
402
  function parseAgentBootstrapSpec(value) {
358
403
  if (typeof value !== 'object' || value === null)
@@ -671,6 +716,7 @@ export function parseAgentJob(input) {
671
716
  testSecrets: parseTestSecrets(o.testSecrets),
672
717
  guardLimits: parseGuardLimits(o.guardLimits),
673
718
  validation: parseValidationSpec(o.validation),
719
+ validationChecks: parseValidationChecksSpec(o.validationChecks),
674
720
  reviewPrNumber: posInt(o.reviewPrNumber),
675
721
  });
676
722
  assertAllowedHost(job.repo.cloneUrl, 'repo.cloneUrl');
@@ -727,7 +773,7 @@ function parseAgentPrSpec(raw) {
727
773
  * literal doesn't blow the complexity budget; behaviour is byte-identical (spread order preserved).
728
774
  */
729
775
  function assembleAgentJob(o, mode, agentField, parts) {
730
- const { output, pr, infra, peerRepos, referenceRepos, referenceBranches, bootstrap, contextFiles, packageRegistries, skill, testSecrets, guardLimits, validation, reviewPrNumber, } = parts;
776
+ const { output, pr, infra, peerRepos, referenceRepos, referenceBranches, bootstrap, contextFiles, packageRegistries, skill, testSecrets, guardLimits, validation, validationChecks, reviewPrNumber, } = parts;
731
777
  const repo = (o.repo ?? {});
732
778
  return {
733
779
  jobId: str(o.jobId, 'jobId'),
@@ -754,6 +800,7 @@ function assembleAgentJob(o, mode, agentField, parts) {
754
800
  ...(reviewPrNumber !== undefined ? { reviewPrNumber } : {}),
755
801
  ...(guardLimits ? { guardLimits } : {}),
756
802
  ...(validation ? { validation } : {}),
803
+ ...(validationChecks ? { validationChecks } : {}),
757
804
  };
758
805
  }
759
806
  /**
package/dist/runner.js CHANGED
@@ -213,6 +213,9 @@ export class JobRegistry {
213
213
  onFollowUp: (items) => {
214
214
  entry.followUpBuffer.push(...items);
215
215
  },
216
+ onValidationReport: (report) => {
217
+ entry.validationReport = report;
218
+ },
216
219
  onCallMetric: (call) => {
217
220
  // Stamp the job-scoped sequence on the metric OBJECT: the handler keeps the same
218
221
  // instance for its terminal result, so both channels carry the same `seq` and the