@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/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
  /**
@@ -1,15 +1,29 @@
1
- import { chmod, rm, writeFile } from 'node:fs/promises';
1
+ import { chmod, readFile, rm, writeFile } from 'node:fs/promises';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { registerKnownSecrets } from './redact.js';
5
5
  // Private package-registry auth for the checkout's installs (npm private orgs,
6
- // GitHub Packages). The job's allowlisted entries are rendered into the USER
7
- // `~/.npmrc` — read by npm, pnpm and yarn v1 alike, and inherited by every child
8
- // process (the agent's own shell installs and the frontend-infra stand-up's) — so
9
- // the token never rides argv or the checkout. Written per job; a job with NO
10
- // entries removes any stale file, because warm-pool containers are reused across
11
- // jobs and must not leak a prior workspace's token.
12
- /** Where the per-job npm auth lands (the user npmrc, outside any checkout). */
6
+ // GitHub Packages). The job's allowlisted entries are rendered into an npmrc — read by
7
+ // npm, pnpm and yarn v1 alike, and inherited by every child process (the agent's own
8
+ // shell installs and the frontend-infra stand-up's) — so the token never rides argv or
9
+ // the checkout.
10
+ //
11
+ // WHERE that npmrc lands depends on whether the harness process owns its HOME:
12
+ // - container (the default): the user `~/.npmrc`. HOME belongs to that one container, so
13
+ // writing it is safe and a job with NO entries CLEARS it — warm-pool containers are
14
+ // reused across jobs and must not leak a prior workspace's token.
15
+ // - shared native host process (`ambientAuth`, the local native transport): HOME is the
16
+ // DEVELOPER's. Writing there would overwrite their own npm config, clearing there would
17
+ // DELETE it, and concurrent jobs in the one process would race on the single file. Such a
18
+ // job gets its own npmrc under a per-job directory instead, pointed at by
19
+ // `npm_config_userconfig`; the developer's file is never written and never removed.
20
+ //
21
+ // Note the isolated path trades a little reach for that safety: `~/.npmrc` is read by npm, pnpm
22
+ // and yarn v1 alike, whereas `npm_config_userconfig` is honoured by npm and pnpm but NOT by yarn
23
+ // (v1 or Berry). A yarn-based checkout on the native path therefore sees only the developer's own
24
+ // registries, not the job's. Since the alternative is overwriting the file they actually use, the
25
+ // limitation stands — a yarn repo needing private-registry auth wants the container path.
26
+ /** Where the per-job npm auth lands in a container (the user npmrc, outside any checkout). */
13
27
  export function npmrcPath() {
14
28
  return join(homedir(), '.npmrc');
15
29
  }
@@ -34,18 +48,69 @@ export function renderNpmrc(entries) {
34
48
  return `${lines.join('\n')}\n`;
35
49
  }
36
50
  /**
37
- * Write (or clear) the per-job `~/.npmrc` before the agent runs. Tokens are
38
- * registered for output redaction so a token echoed in an npm error never reaches
51
+ * Write (or clear) the job's npmrc before the agent runs, and return the env the agent's child
52
+ * process needs to find it (empty for the container default, which npm picks up from HOME).
53
+ * Tokens are registered for output redaction so a token echoed in an npm error never reaches
39
54
  * logs or stored output.
40
55
  */
41
- export async function configurePackageRegistries(entries) {
56
+ export async function configurePackageRegistries(entries, scope = {}) {
57
+ const hasEntries = Boolean(entries?.length);
58
+ if (scope.isolatedDir) {
59
+ // A job with no entries needs no file at all: emitting no override leaves the developer's
60
+ // own `~/.npmrc` in effect (their private registries keep working) — and, crucially, leaves
61
+ // it ALONE. Clearing a stale file is a container concern; here nothing stale can exist,
62
+ // because the per-job dir is created and removed with the job.
63
+ if (!hasEntries)
64
+ return {};
65
+ const path = join(scope.isolatedDir, '.npmrc');
66
+ await writeIsolatedNpmrc(path, entries);
67
+ return { npm_config_userconfig: path };
68
+ }
42
69
  const path = npmrcPath();
43
- if (!entries || entries.length === 0) {
70
+ if (!hasEntries) {
44
71
  await rm(path, { force: true });
45
- return;
72
+ return {};
46
73
  }
47
74
  registerKnownSecrets(entries.map((entry) => entry.token));
48
75
  await writeFile(path, renderNpmrc(entries), { mode: 0o600 });
49
76
  // writeFile's mode only applies on create — tighten an existing file too.
50
77
  await chmod(path, 0o600);
78
+ return {};
79
+ }
80
+ /**
81
+ * Write the per-job npmrc, seeded from the developer's own `~/.npmrc` when they have one so
82
+ * their unrelated settings (a corporate registry, a proxy) keep working for this run. The job's
83
+ * lines are APPENDED, and npm resolves the last occurrence of a key, so the job's entries win on
84
+ * any host they both configure. Copying their file into a 0600 temp adds no exposure: an ambient
85
+ * run already has the developer's full file access by definition.
86
+ *
87
+ * The seeded credentials are registered for redaction alongside the job's own. The job's tokens
88
+ * were always registered; the developer's were not, because before this path existed their file
89
+ * was overwritten and no credential of theirs was in play during the run. Now that theirs is in
90
+ * effect, an npm error echoing one must be scrubbed on exactly the same terms.
91
+ */
92
+ async function writeIsolatedNpmrc(path, entries) {
93
+ registerKnownSecrets(entries.map((entry) => entry.token));
94
+ // Best-effort: no personal npmrc (or an unreadable one) just means the job's entries stand alone.
95
+ const inherited = await readFile(npmrcPath(), 'utf8').catch(() => '');
96
+ registerKnownSecrets(npmrcCredentials(inherited));
97
+ const prefix = inherited && !inherited.endsWith('\n') ? `${inherited}\n` : inherited;
98
+ await writeFile(path, `${prefix}${renderNpmrc(entries)}`, { mode: 0o600 });
99
+ await chmod(path, 0o600);
100
+ }
101
+ /**
102
+ * The credential VALUES in npmrc content: the three keys npm accepts a secret under, on any host
103
+ * line. Used to register a seeded (developer-owned) file's tokens for redaction. An `${ENV_VAR}`
104
+ * reference is not itself a secret — npm expands it at read time — so it is skipped rather than
105
+ * registered as a literal to scrub.
106
+ */
107
+ export function npmrcCredentials(content) {
108
+ const found = [];
109
+ for (const line of content.split(/\r?\n/)) {
110
+ const match = /^\s*(?:.*:)?_(?:authToken|auth|password)\s*=\s*(.+?)\s*$/.exec(line);
111
+ const value = match?.[1]?.replace(/^["']|["']$/g, '');
112
+ if (value && !/^\$\{.*\}$/.test(value))
113
+ found.push(value);
114
+ }
115
+ return found;
51
116
  }
@@ -118,11 +118,13 @@ export async function runAgentInWorkspace(spec, opts = {}) {
118
118
  // harness paths; kept out of the agent's commits via a local git exclude entry.
119
119
  const contextFiles = spec.contextFiles ?? [];
120
120
  await materializeContextFiles(spec.dir, contextFiles);
121
- // Repo-sourced skill (slice 2): claude-code installs it natively (written by the runner into the
122
- // config dir), so it reads from there. Every other harness (Pi/codex) reads the checkout, so
123
- // materialise the skill's resources under `.cat-context/skill/` (its instructions are folded
124
- // into the prompt by the backend). A resource-free skill is a no-op here.
125
- if (spec.skill && spec.harness !== 'claude-code') {
121
+ // Repo-sourced skill (slice 2): claude-code installs it natively into its ISOLATED config dir,
122
+ // so it reads from there. Everything else reads the checkout, so materialise the skill's
123
+ // resources under `.cat-context/skill/` (its instructions are folded into the prompt by the
124
+ // backend) Pi, codex, and AMBIENT claude-code, which has no isolated config dir to install
125
+ // into (the runner refuses to write a repo's skill into the developer's own `~/.claude`; see
126
+ // `runClaudeCode`). A resource-free skill is a no-op here.
127
+ if (spec.skill && !installsSkillNatively(spec)) {
126
128
  await materializeSkillResources(spec.dir, spec.skill);
127
129
  }
128
130
  // Subscription harnesses (Claude Code / Codex) authenticate with the leased
@@ -144,6 +146,7 @@ export async function runAgentInWorkspace(spec, opts = {}) {
144
146
  subscriptionBaseUrl: spec.subscriptionBaseUrl,
145
147
  ...(spec.ambientAuth ? { ambientAuth: true } : {}),
146
148
  ...(spec.skill ? { skill: spec.skill } : {}),
149
+ ...(opts.agentEnv ? { extraEnv: opts.agentEnv } : {}),
147
150
  signal: opts.signal,
148
151
  onActivity: opts.onActivity,
149
152
  onProgress: opts.onProgress,
@@ -169,9 +172,11 @@ export async function runAgentInWorkspace(spec, opts = {}) {
169
172
  // container env, which `webSearchConfigFromEnv` autodetects.
170
173
  // The proxy vars are handed to Pi's child via `extraEnv` (not the harness's own
171
174
  // process.env), so detection runs against the same merged view the extension sees.
172
- const extraEnv = spec.webSearchProxy
173
- ? webSearchProxyEnv(proxyBaseUrl, sessionToken)
174
- : {};
175
+ const extraEnv = {
176
+ ...(spec.webSearchProxy ? webSearchProxyEnv(proxyBaseUrl, sessionToken) : {}),
177
+ // Per-job env (tester secrets, a private-registry npmrc pointer) — see `RunOptions.agentEnv`.
178
+ ...opts.agentEnv,
179
+ };
175
180
  const webSearch = webSearchConfigFromEnv({ ...process.env, ...extraEnv });
176
181
  if (webSearch)
177
182
  await writeWebToolsConfig(webSearch);
@@ -201,6 +206,17 @@ export async function runAgentInWorkspace(spec, opts = {}) {
201
206
  });
202
207
  return withEffortReport(spec.dir, piOutcome);
203
208
  }
209
+ /**
210
+ * Whether the claude-code runner will install this run's repo-sourced skill natively (into the
211
+ * CLI's config dir) rather than the caller materialising it into the checkout. True ONLY for a
212
+ * leased-credential claude-code run, which gets a throwaway per-run config home. An AMBIENT run
213
+ * uses the developer's own `~/.claude`, which the runner will not write a repo's skill into —
214
+ * it would outlive the run in their personal setup, and two concurrent jobs carrying same-named
215
+ * skills from different repos would overwrite each other's.
216
+ */
217
+ export function installsSkillNatively(spec) {
218
+ return spec.harness === 'claude-code' && !spec.ambientAuth;
219
+ }
204
220
  /**
205
221
  * Lift the agent's effort self-assessment off its sentinel file in `dir` and fold it onto the
206
222
  * run outcome. Shared by both harness paths so EVERY container agent's effort report is captured
package/dist/pi.js CHANGED
@@ -210,9 +210,10 @@ export async function materializeContextFiles(cwd, files) {
210
210
  export const SKILL_CONTEXT_SUBDIR = 'skill';
211
211
  /**
212
212
  * Materialise a repo-sourced skill's RESOURCE files under `.cat-context/skill/` in the checkout
213
- * (repo-sourced Claude Skills, slice 2) — the Pi/codex path, whose agents read the checkout rather
214
- * than a native `~/.claude/skills` dir (the skill's instructions are folded into their prompt by
215
- * the backend). Resource sub-paths were sanitized at the job boundary (no traversal), so nested
213
+ * (repo-sourced Claude Skills, slice 2) — the path for every run that does NOT get a native
214
+ * install: Pi, codex, and ambient claude-code (no isolated `CLAUDE_CONFIG_DIR` to install into).
215
+ * Their agents read the checkout, and the skill's instructions are folded into their prompt by the
216
+ * backend (`renderSkillForHarness`, which keys off ambient auth as well as the harness). Resource sub-paths were sanitized at the job boundary (no traversal), so nested
216
217
  * dirs are created as needed. Kept out of the agent's commits via the same `.cat-context/` git
217
218
  * exclude entry. A skill with no resource bodies is a no-op.
218
219
  */
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
@@ -0,0 +1,300 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { killChildProcess, spawnDetached } from './process.js';
3
+ import { MAX_CAPTURED_OUTPUT_CHARS, redactSecrets } from './redact.js';
4
+ /**
5
+ * Per-command output kept on the REPORT (what crosses the wire and lands in the run's persisted
6
+ * `detail` blob). Deliberately smaller than {@link MAX_CAPTURED_OUTPUT_CHARS}, which is what the
7
+ * AGENT sees in its repair prompt: the agent needs the full failure to fix it, the operator needs
8
+ * enough to recognise it, and a chatty build must not inflate every run's stored state.
9
+ */
10
+ export const VALIDATION_REPORT_TAIL_CHARS = 4_000;
11
+ /**
12
+ * The per-command watchdog: the longest a single check may run before it is killed and treated
13
+ * as a failure, so one hung `pnpm test` cannot wedge a run. Overridable via env for tests;
14
+ * defaults to 15 minutes (matching the ralph completion command's watchdog).
15
+ */
16
+ export function validationCommandTimeoutMs() {
17
+ const n = Number(process.env.VALIDATION_COMMAND_TIMEOUT_MS);
18
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 15 * 60_000;
19
+ }
20
+ /**
21
+ * How often the check loop feeds the run's inactivity watchdog. Well under the harness's own
22
+ * `JOB_INACTIVITY_MS` (default 10 min) so a single slow command can never look wedged; matches
23
+ * the frontend stand-up's heartbeat, which exists for exactly the same reason. Overridable via
24
+ * env for tests, like {@link validationCommandTimeoutMs}.
25
+ */
26
+ export function validationHeartbeatMs() {
27
+ const n = Number(process.env.VALIDATION_HEARTBEAT_MS);
28
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 30_000;
29
+ }
30
+ /**
31
+ * Run every configured check IN ORDER against `cwd` and build the attempt's report.
32
+ *
33
+ * Runs all of them even after one fails, rather than short-circuiting: the agent repairing the
34
+ * checkout should see every problem at once instead of rediscovering the next one on the next
35
+ * round, which is the difference between one repair round and four. (A check whose failure makes
36
+ * the rest meaningless — e.g. a failed install — still costs only the cheap downstream failures.)
37
+ *
38
+ * Keeps the run's inactivity watchdog fed for the whole attempt. These commands are exactly the
39
+ * activity-SILENT kind — a cold `install`, a full `test` run, a `build` — and the harness spawns
40
+ * them itself rather than through the agent, so they emit no activity events of their own. The
41
+ * job-level watchdog (`JOB_INACTIVITY_MS`, default 10 min) is TIGHTER than one command's own
42
+ * watchdog ({@link validationCommandTimeoutMs}, default 15 min), so without this a legitimately
43
+ * slow check would abort the entire run as "inactivity" — mislabelling a healthy build as a
44
+ * wedge, and making the per-command timeout unreachable at stock settings.
45
+ */
46
+ export async function runValidationChecks(cwd, spec, attempt, logger, opts) {
47
+ const outcomes = [];
48
+ const fullTails = new Map();
49
+ const heartbeat = setInterval(() => opts.onActivity?.(), validationHeartbeatMs());
50
+ heartbeat.unref?.();
51
+ try {
52
+ for (const check of spec.checks) {
53
+ const { outcome, fullTail } = await runOneCheck(cwd, check, logger, opts);
54
+ outcomes.push(outcome);
55
+ if (fullTail)
56
+ fullTails.set(check.label, fullTail);
57
+ }
58
+ }
59
+ finally {
60
+ clearInterval(heartbeat);
61
+ }
62
+ const report = {
63
+ passed: outcomes.every((o) => o.passed),
64
+ attempts: attempt,
65
+ maxAttempts: spec.maxAttempts,
66
+ outcomes,
67
+ at: Date.now(),
68
+ };
69
+ logger.info('validation: attempt finished', {
70
+ attempt,
71
+ maxAttempts: spec.maxAttempts,
72
+ passed: report.passed,
73
+ failed: outcomes.filter((o) => !o.passed).map((o) => o.label),
74
+ });
75
+ return { report, fullTails };
76
+ }
77
+ /**
78
+ * Run ONE check as `sh -c <command>` in `cwd`, capturing a bounded, secret-scrubbed tail of its
79
+ * combined stdout+stderr. The exit code is the verdict — computed here by the harness, never
80
+ * self-reported by the model, which is the whole point of a programmatic gate. A watchdog kills
81
+ * the process tree on timeout and an aborted run resolves non-zero, so the loop is never blocked.
82
+ *
83
+ * The child inherits the JOB's environment (`RunOptions.agentEnv` layered over the process env),
84
+ * not a mutated global: the harness spawns this itself rather than through the agent, so without
85
+ * the explicit merge a native-mode job would run its checks without the private-registry npmrc
86
+ * pointer (and against a sibling job's state, had this been staged in `process.env`).
87
+ */
88
+ async function runOneCheck(cwd, check, logger, opts) {
89
+ const timeoutMs = validationCommandTimeoutMs();
90
+ const startedAt = Date.now();
91
+ logger.info('validation: running check', { label: check.label });
92
+ return new Promise((resolve) => {
93
+ let out = '';
94
+ let settled = false;
95
+ let timedOut = false;
96
+ const child = spawn('sh', ['-c', check.command], {
97
+ cwd,
98
+ detached: spawnDetached,
99
+ stdio: ['ignore', 'pipe', 'pipe'],
100
+ env: { ...process.env, ...opts.agentEnv },
101
+ });
102
+ // Keep only the tail; guard against unbounded buffering on a chatty command.
103
+ const capture = (chunk) => {
104
+ out = (out + chunk.toString('utf8')).slice(-MAX_CAPTURED_OUTPUT_CHARS);
105
+ };
106
+ child.stdout?.on('data', capture);
107
+ child.stderr?.on('data', capture);
108
+ const finish = (exitCode) => {
109
+ if (settled)
110
+ return;
111
+ settled = true;
112
+ clearTimeout(timer);
113
+ opts.signal?.removeEventListener('abort', onAbort);
114
+ const trimmed = out.trim();
115
+ // Scrub BEFORE truncating: a token straddling the cut would otherwise survive as a
116
+ // partial, and the pattern rules need the whole assignment to match.
117
+ const scrubbed = trimmed ? redactSecrets(trimmed) : '';
118
+ logger.info('validation: check finished', { label: check.label, exitCode });
119
+ resolve({
120
+ outcome: {
121
+ label: check.label,
122
+ command: check.command,
123
+ exitCode,
124
+ passed: exitCode === 0,
125
+ ...(scrubbed ? { outputTail: tailFor(scrubbed) } : {}),
126
+ durationMs: Date.now() - startedAt,
127
+ ...(timedOut ? { timedOut: true } : {}),
128
+ },
129
+ ...(scrubbed ? { fullTail: scrubbed } : {}),
130
+ });
131
+ };
132
+ const timer = setTimeout(() => {
133
+ logger.warn('validation: check timed out', { label: check.label, timeoutMs });
134
+ timedOut = true;
135
+ killChildProcess(child, undefined, logger);
136
+ finish(124); // conventional timeout exit code (a non-zero fail)
137
+ }, timeoutMs);
138
+ timer.unref?.();
139
+ const onAbort = () => {
140
+ killChildProcess(child, undefined, logger);
141
+ finish(130); // aborted (a non-zero fail)
142
+ };
143
+ opts.signal?.addEventListener('abort', onAbort, { once: true });
144
+ child.on('error', (err) => {
145
+ logger.warn('validation: check failed to spawn', {
146
+ label: check.label,
147
+ error: err instanceof Error ? err.message : String(err),
148
+ });
149
+ finish(127); // spawn error / command not found (a non-zero fail)
150
+ });
151
+ child.on('close', (code) => finish(code ?? 1));
152
+ });
153
+ }
154
+ /** Bound an already-scrubbed output tail to what the REPORT carries. */
155
+ function tailFor(scrubbed) {
156
+ if (scrubbed.length <= VALIDATION_REPORT_TAIL_CHARS)
157
+ return scrubbed;
158
+ const trimmed = scrubbed.length - VALIDATION_REPORT_TAIL_CHARS;
159
+ return `…(${trimmed} earlier chars trimmed)\n${scrubbed.slice(-VALIDATION_REPORT_TAIL_CHARS)}`;
160
+ }
161
+ /**
162
+ * The repair instruction handed to the agent after a failed attempt: the failing commands and
163
+ * their captured output, plus an explicit statement of the exit condition and the remaining
164
+ * budget. The FULL captured tail is used here (not the report's smaller bound) — the agent needs
165
+ * the whole failure to fix it, and this text never leaves the container.
166
+ *
167
+ * Deliberately prescriptive about scope: a validation loop that lets the agent "fix" the failure
168
+ * by weakening the check is worse than no loop at all, so the prompt forbids editing the
169
+ * commands' configuration to make them pass.
170
+ */
171
+ export function buildRepairPrompt(report, fullTails,
172
+ /**
173
+ * New files the agent created but never `git add`ed, if the caller can tell. The harness only
174
+ * auto-stages TRACKED edits (`git add -u`), so an uncommitted new file is invisible to the push
175
+ * — yet fully visible to the checks, which run against the working tree. Naming them here is
176
+ * what stops the loop going green on work the pull request would not contain.
177
+ */
178
+ untrackedFiles = []) {
179
+ const failed = report.outcomes.filter((o) => !o.passed);
180
+ const blocks = failed
181
+ .map((o) => {
182
+ const body = fullTails.get(o.label) ?? o.outputTail ?? '(no output captured)';
183
+ const reason = o.timedOut
184
+ ? `timed out after ${Math.round((o.durationMs ?? 0) / 1000)}s`
185
+ : `exited ${o.exitCode}`;
186
+ return `### ${o.label} — ${reason}\n\n\`\`\`\n$ ${o.command}\n${body}\n\`\`\``;
187
+ })
188
+ .join('\n\n');
189
+ const remaining = report.maxAttempts - report.attempts;
190
+ const untracked = untrackedFiles.length
191
+ ? [
192
+ '',
193
+ '## Uncommitted new files',
194
+ '',
195
+ 'These files exist in your checkout but were never added to git, so they are NOT part of',
196
+ 'the branch even though the checks above ran against them. `git add` each one you meant to',
197
+ 'keep (or delete it), or the checks will pass on work the pull request will not contain:',
198
+ '',
199
+ ...untrackedFiles.map((f) => `- ${f}`),
200
+ ]
201
+ : [];
202
+ return [
203
+ 'The work you just finished does NOT pass this service’s required validation checks, so',
204
+ 'no pull request has been opened. Fix the failures below, then stop.',
205
+ '',
206
+ blocks,
207
+ ...untracked,
208
+ '',
209
+ '## How this is judged',
210
+ '',
211
+ `The checks above are re-run automatically against your checkout when you stop. They are the`,
212
+ `exit condition: a pull request opens only once every one of them exits 0. You have`,
213
+ `${remaining} attempt(s) left before this task fails.`,
214
+ '',
215
+ '## Rules',
216
+ '',
217
+ '- Fix the underlying problem in the code. Do NOT edit, disable, skip, or relax the checks',
218
+ ' themselves (their scripts, configs, thresholds, ignore files, or test assertions) to make',
219
+ ' them pass — a green check obtained that way is a failed task.',
220
+ '- Do not revert your earlier work; build on it.',
221
+ '- Commit your fixes, as you did before. `git add` any NEW file you create — only changes to',
222
+ ' files already tracked by git are staged for you, so an unadded file is silently dropped',
223
+ ' from the branch even though the checks can see it.',
224
+ ].join('\n');
225
+ }
226
+ /**
227
+ * The pre-PR validation LOOP: run the checks, and while they fail and budget remains, hand the
228
+ * captured output back to the agent as its next instruction and check again. Returns the LAST
229
+ * attempt's report — `passed: true` means the caller may open the PR; `passed: false` means the
230
+ * budget is spent and the caller must FAIL the job with this report as the evidence, opening
231
+ * nothing.
232
+ *
233
+ * Generic by construction: it knows nothing about agent kinds, repos or PRs — only how to run
234
+ * commands in a directory and how to ask for another pass. Every input (`workDir`, `spec`,
235
+ * `opts.agentEnv`) is per-job, so two concurrent jobs on the ONE local-native host process cannot
236
+ * see each other's configuration (`validation-checks.concurrency.test.ts` pins this).
237
+ *
238
+ * `onAttempt` publishes each completed attempt on the job view so the loop is observable while it
239
+ * runs; `onAgentPass` lets the caller fold each repair pass's stats/usage/telemetry into the run's
240
+ * totals, so a 3-round loop reports what all 3 rounds actually spent.
241
+ */
242
+ export async function runValidationLoop(args) {
243
+ const { workDir, spec, logger, opts, runAgentPass, onAgentPass, listUncommittedNewFiles } = args;
244
+ let attempt = 1;
245
+ for (;;) {
246
+ const { report, fullTails } = await runValidationChecks(workDir, spec, attempt, logger, opts);
247
+ opts.onValidationReport?.(report);
248
+ if (report.passed) {
249
+ logger.info('validation: checkout is green', { attempt });
250
+ return report;
251
+ }
252
+ if (attempt >= spec.maxAttempts) {
253
+ logger.warn('validation: attempt budget spent — no PR will be opened', {
254
+ attempt,
255
+ maxAttempts: spec.maxAttempts,
256
+ });
257
+ return report;
258
+ }
259
+ attempt += 1;
260
+ logger.info('validation: repairing', { nextAttempt: attempt });
261
+ opts.onPhase?.('validation-repair');
262
+ const untracked = await safeListUncommitted(listUncommittedNewFiles, logger);
263
+ const run = await runAgentPass(buildRepairPrompt(report, fullTails, untracked));
264
+ onAgentPass?.(run);
265
+ opts.onPhase?.('agent');
266
+ }
267
+ }
268
+ /**
269
+ * The uncommitted-new-file list for a repair prompt, never throwing: this is an ADVISORY
270
+ * addition to the instruction, so a `git` hiccup must degrade to "no warning" rather than
271
+ * failing a loop that is otherwise working.
272
+ */
273
+ async function safeListUncommitted(list, logger) {
274
+ if (!list)
275
+ return [];
276
+ try {
277
+ return await list();
278
+ }
279
+ catch (error) {
280
+ logger.warn('validation: could not list uncommitted new files', {
281
+ error: error instanceof Error ? error.message : String(error),
282
+ });
283
+ return [];
284
+ }
285
+ }
286
+ /**
287
+ * The failure message for a run whose pre-PR validation never went green: which checks failed
288
+ * (with exit codes) and the last one's captured output. Read by the operator on the step's
289
+ * failure card, so it must say what broke without needing the full report opened.
290
+ */
291
+ export function validationFailureMessage(report) {
292
+ const failed = report.outcomes.filter((o) => !o.passed);
293
+ const names = failed.map((o) => `${o.label} (exit ${o.exitCode})`).join(', ');
294
+ const last = failed[failed.length - 1];
295
+ const tail = last?.outputTail?.trim();
296
+ const head = `pre-PR validation failed after ${report.attempts} of ${report.maxAttempts} attempt(s)` +
297
+ (names ? `: ${names}` : '') +
298
+ '. No pull request was opened.';
299
+ return tail ? `${head}\n\n$ ${last?.command}\n${tail}` : head;
300
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.52.2",
3
+ "version": "1.56.0",
4
4
  "description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,8 +26,8 @@
26
26
  "hono": "^4.12.30",
27
27
  "typescript": "7.0.2",
28
28
  "vitest": "^4.1.10",
29
- "@cat-factory/server": "0.144.1",
30
- "@cat-factory/spend": "0.12.77"
29
+ "@cat-factory/server": "0.149.0",
30
+ "@cat-factory/spend": "0.12.83"
31
31
  },
32
32
  "scripts": {
33
33
  "build": "tsc -p tsconfig.json",