@cat-factory/executor-harness 1.66.0 → 1.70.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/agent.js CHANGED
@@ -1,16 +1,18 @@
1
1
  import { join } from 'node:path';
2
2
  import { tmpdir } from 'node:os';
3
- import { mkdir, mkdtemp, opendir, rm } from 'node:fs/promises';
3
+ import { mkdir, mkdtemp, rm } from 'node:fs/promises';
4
4
  import { execFile } from 'node:child_process';
5
5
  import { promisify } from 'node:util';
6
6
  import { standUpFrontend, tearDownFrontend } from './frontend-infra.js';
7
7
  import { configurePackageRegistries } from './package-registries.js';
8
8
  import { captureRedactedOutput, redactSecrets, registerKnownSecrets } from './redact.js';
9
- import { cloneRepo, commitAll, conflictDiff, fetchPullRequestHead, fetchReferenceBranches, hasAgentChanges, headCommit, mergeBranch, prepareExistingCheckout, pushBranch, reinitAndPush, unmergedPaths, } from './git.js';
9
+ import { cloneRepo, commitAll, conflictDiff, fetchPullRequestHead, fetchReferenceBranches, headCommit, mergeBranch, prepareExistingCheckout, pushBranch, unmergedPaths, } from './git.js';
10
10
  import { inferVcsProvider, openPullRequest } from './vcs-api.js';
11
11
  import { applyPrDescription } from './pr-description.js';
12
12
  import { makeDirClaimer, noChangesReason, runCodingAgent, runMultiRepoCoding, } from './coding-agent.js';
13
13
  import { validationFailureMessage } from './validation-checks.js';
14
+ import { agentCapabilities, mergeEffort } from './agent-shared.js';
15
+ import { runBootstrap } from './bootstrap-mode.js';
14
16
  import { acquireRepoCheckout, agentNeverActed, agentOutputTail, NEVER_ACTED_CAUSE, runAgentInWorkspace, unusableFinalAnswerCause, withWorkspace, } from './pi-workspace.js';
15
17
  import { diagnosticsSuffix, resolveStructuredOutput, } from './structured-output.js';
16
18
  import { log } from './logger.js';
@@ -181,6 +183,7 @@ async function resolveReplyCustom(job, summary, signal) {
181
183
  subscriptionToken: job.subscriptionToken,
182
184
  subscriptionBaseUrl: job.subscriptionBaseUrl,
183
185
  proxyBaseUrl: job.proxyBaseUrl,
186
+ proxyPhasePath: job.proxyPhasePath,
184
187
  sessionToken: job.sessionToken,
185
188
  model: job.model,
186
189
  jobId: job.jobId,
@@ -231,14 +234,6 @@ async function cloneServiceCheckout(dir, job, signal) {
231
234
  });
232
235
  return deriveWorkDir(dir, job.repo.serviceDirectory);
233
236
  }
234
- /**
235
- * Fold an agent's effort self-assessment (lifted from its sentinel file by `runAgentInWorkspace`)
236
- * onto its final result. Every container mode routes its result through this so the report reaches
237
- * the backend uniformly. A run that wrote no report passes through unchanged.
238
- */
239
- function mergeEffort(result, effortReport) {
240
- return effortReport ? { ...result, effortReport } : result;
241
- }
242
237
  /** Run one generic agent job end to end, dispatching on `mode`. */
243
238
  export async function handleAgent(job, opts = {}) {
244
239
  // An `ambientAuth` job runs in the SHARED native host process on the developer's own HOME
@@ -494,6 +489,7 @@ async function runExploreMode(job, opts) {
494
489
  subscriptionBaseUrl: job.subscriptionBaseUrl,
495
490
  ambientAuth: job.ambientAuth,
496
491
  proxyBaseUrl: job.proxyBaseUrl,
492
+ proxyPhasePath: job.proxyPhasePath,
497
493
  sessionToken: job.sessionToken,
498
494
  serviceDirectory,
499
495
  // Read-only: it inspects and reports, making no edits — so the no-progress
@@ -503,6 +499,7 @@ async function runExploreMode(job, opts) {
503
499
  webSearchProxy: job.webSearch,
504
500
  contextFiles: job.contextFiles,
505
501
  guardLimits: job.guardLimits,
502
+ ...agentCapabilities(job),
506
503
  }, agentOpts);
507
504
  return mergeEffort(await finalizeExploreResult(job, { summary, stats, stderrTail, usage, callMetrics, runDiag }, { infra, infraSetupFields, logger, signal: opts.signal }), effortReport);
508
505
  }
@@ -683,6 +680,7 @@ async function runMultiRepoExplore(job, opts) {
683
680
  subscriptionBaseUrl: job.subscriptionBaseUrl,
684
681
  ambientAuth: job.ambientAuth,
685
682
  proxyBaseUrl: job.proxyBaseUrl,
683
+ proxyPhasePath: job.proxyPhasePath,
686
684
  sessionToken: job.sessionToken,
687
685
  // Read-only: no edits expected, so the no-progress guard's no-edit bound must not fire.
688
686
  expectsEdits: false,
@@ -690,6 +688,7 @@ async function runMultiRepoExplore(job, opts) {
690
688
  webSearchProxy: job.webSearch,
691
689
  ...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
692
690
  guardLimits: job.guardLimits,
691
+ ...agentCapabilities(job),
693
692
  multiRepo: true,
694
693
  }, opts);
695
694
  return mergeEffort(await finalizeExploreResult(job, {
@@ -784,6 +783,7 @@ function buildSingleRepoCodingSpec(job, pushBranch) {
784
783
  subscriptionBaseUrl: job.subscriptionBaseUrl,
785
784
  ambientAuth: job.ambientAuth,
786
785
  proxyBaseUrl: job.proxyBaseUrl,
786
+ proxyPhasePath: job.proxyPhasePath,
787
787
  sessionToken: job.sessionToken,
788
788
  commitMessage: job.commitMessage ?? job.pr?.title ?? 'Agent changes',
789
789
  webToolsGuidance: job.webToolsGuidance,
@@ -792,8 +792,8 @@ function buildSingleRepoCodingSpec(job, pushBranch) {
792
792
  ...(job.persistentCheckout ? { persistentCheckout: true } : {}),
793
793
  ...(job.streamFollowUps ? { streamFollowUps: true } : {}),
794
794
  ...(job.referenceBranches?.length ? { referenceBranches: job.referenceBranches } : {}),
795
- // Repo-sourced skill (slice 2): installed harness-aware by runAgentInWorkspace.
796
- ...(job.skill ? { skill: job.skill } : {}),
795
+ // Skills + tool servers: installed/wired harness-aware by runAgentInWorkspace.
796
+ ...agentCapabilities(job),
797
797
  // Ralph loop: run the completion command after the agent commits and report its verdict.
798
798
  ...(job.validation
799
799
  ? {
@@ -1036,9 +1036,11 @@ async function runConflictResolution(job, opts) {
1036
1036
  subscriptionBaseUrl: job.subscriptionBaseUrl,
1037
1037
  ambientAuth: job.ambientAuth,
1038
1038
  proxyBaseUrl: job.proxyBaseUrl,
1039
+ proxyPhasePath: job.proxyPhasePath,
1039
1040
  sessionToken: job.sessionToken,
1040
1041
  contextFiles: job.contextFiles,
1041
1042
  guardLimits: job.guardLimits,
1043
+ ...agentCapabilities(job),
1042
1044
  }, opts);
1043
1045
  // Never push a half-resolved tree: if any conflict markers / unmerged paths remain,
1044
1046
  // the PR would still be broken. Fail so the engine can retry / notify.
@@ -1115,133 +1117,6 @@ function unresolvedReason(unresolved, stats, stderrTail) {
1115
1117
  `(${unresolved.length} file(s) still conflicted: ${sample}).${cause}` +
1116
1118
  agentOutputTail(stderrTail));
1117
1119
  }
1118
- /**
1119
- * Repo-bootstrap coding flow (the bootstrapper): with a reference architecture, clone it →
1120
- * the agent adapts it in place per the instructions; without one (`fromScratch`), start from
1121
- * an empty directory → the agent scaffolds the new service. Either way the result's history
1122
- * is reset to a single commit and force-pushed to the SEPARATE, pre-created target repo's
1123
- * default branch. Diverges from the ordinary coding flow in pushing to a different repo with
1124
- * a reinitialised history rather than a work branch + PR on the cloned repo.
1125
- */
1126
- async function runBootstrap(job, opts) {
1127
- const { signal } = opts;
1128
- const boot = job.bootstrap;
1129
- const fromScratch = boot.fromScratch === true;
1130
- const logger = (opts.log ?? log).child({ target: `${boot.target.owner}/${boot.target.name}` });
1131
- return withWorkspace('boot', async (dir) => {
1132
- if (!fromScratch) {
1133
- opts.onPhase?.('clone');
1134
- logger.info('agent(bootstrap): cloning reference architecture', {
1135
- reference: `${job.repo.owner}/${job.repo.name}`,
1136
- });
1137
- await cloneRepo({
1138
- repo: { ...job.repo, baseBranch: job.branch },
1139
- ghToken: job.ghToken,
1140
- dir,
1141
- signal,
1142
- });
1143
- }
1144
- else {
1145
- logger.info('agent(bootstrap): scaffolding from scratch (no reference)');
1146
- }
1147
- opts.onPhase?.('agent');
1148
- logger.info('agent(bootstrap): running agent');
1149
- const { summary, stats, stderrTail, usage, callMetrics, effortReport } = await runAgentInWorkspace({
1150
- dir,
1151
- systemPrompt: job.systemPrompt,
1152
- userPrompt: job.userPrompt,
1153
- model: job.model,
1154
- harness: job.harness,
1155
- subscriptionToken: job.subscriptionToken,
1156
- subscriptionBaseUrl: job.subscriptionBaseUrl,
1157
- ambientAuth: job.ambientAuth,
1158
- proxyBaseUrl: job.proxyBaseUrl,
1159
- sessionToken: job.sessionToken,
1160
- guardLimits: job.guardLimits,
1161
- }, opts);
1162
- // Guard against a no-op run: Pi can exit cleanly having done nothing (e.g. it never
1163
- // reached the model), and a force-push would then publish an empty tree — leaving the
1164
- // run "succeeded" but the repo bare. Fail with a structured error (carrying what the
1165
- // agent did) instead of pushing nothing.
1166
- if (!(await producedRepoContent(dir, !fromScratch, signal))) {
1167
- const error = bootstrapNoOpReason(!fromScratch, stats, summary, stderrTail);
1168
- logger.error('agent(bootstrap): agent produced no content, refusing to push', { ...stats });
1169
- return mergeEffort({
1170
- summary,
1171
- stats,
1172
- error,
1173
- failureCause: 'agent',
1174
- ...(usage ? { usage } : {}),
1175
- ...(callMetrics ? { callMetrics } : {}),
1176
- }, effortReport);
1177
- }
1178
- opts.onPhase?.('push');
1179
- logger.info('agent(bootstrap): pushing bootstrapped contents', { ...stats });
1180
- // Bootstrap always resets history to one commit + force-pushes (the fresh history
1181
- // shares no ancestor with whatever boilerplate the new repo was created with).
1182
- await reinitAndPush({
1183
- dir,
1184
- target: boot.target,
1185
- ghToken: job.ghToken,
1186
- message: fromScratch
1187
- ? 'Bootstrap new repository'
1188
- : `Bootstrap from ${job.repo.owner}/${job.repo.name}`,
1189
- });
1190
- logger.info('agent(bootstrap): complete', { defaultBranch: boot.target.defaultBranch });
1191
- return mergeEffort({
1192
- defaultBranch: boot.target.defaultBranch,
1193
- summary,
1194
- stats,
1195
- ...(usage ? { usage } : {}),
1196
- ...(callMetrics ? { callMetrics } : {}),
1197
- }, effortReport);
1198
- });
1199
- }
1200
- /**
1201
- * Whether the bootstrapper actually produced repository content, so a no-op run (the agent
1202
- * never reached the model / never wrote anything) is failed rather than force-pushed as an
1203
- * empty repo. With a reference architecture, "produced content" means the agent changed the
1204
- * clone; scaffolding from scratch, it means at least one file now exists in the working
1205
- * directory. (The harness writes its prompt context to Pi's global `~/.pi/agent/AGENTS.md`,
1206
- * never into `dir`, so nothing here needs to be filtered out as harness boilerplate.)
1207
- */
1208
- export async function producedRepoContent(dir, hasReference, signal) {
1209
- if (hasReference)
1210
- return hasAgentChanges(dir, signal);
1211
- return containsAnyFile(dir);
1212
- }
1213
- /**
1214
- * Whether `dir` contains at least one regular file anywhere in its tree, walking
1215
- * depth-first and stopping at the FIRST file found — so the cost is bounded by how
1216
- * quickly a file turns up (a scaffold almost always writes a root-level file), not by
1217
- * the size of the produced tree (a full recursive `readdir` would materialise every
1218
- * entry before the check).
1219
- */
1220
- async function containsAnyFile(dir) {
1221
- const handle = await opendir(dir);
1222
- try {
1223
- for await (const entry of handle) {
1224
- if (entry.isFile())
1225
- return true;
1226
- if (entry.isDirectory() && (await containsAnyFile(join(dir, entry.name))))
1227
- return true;
1228
- }
1229
- }
1230
- catch {
1231
- // A directory that vanished mid-walk has nothing to contribute.
1232
- }
1233
- return false;
1234
- }
1235
- /** Human-readable bootstrap no-op reason, embedding what the agent did so the cause is visible. */
1236
- function bootstrapNoOpReason(hasReference, stats, summary, stderrTail) {
1237
- const what = hasReference
1238
- ? 'made no changes to the reference architecture'
1239
- : 'scaffolded no files';
1240
- const cause = agentNeverActed(stats) ? NEVER_ACTED_CAUSE : '';
1241
- return (`the bootstrapper agent ${what} ` +
1242
- `(tool calls: ${stats.toolCalls}, assistant output: ${stats.assistantChars} chars).${cause}` +
1243
- agentOutputTail(stderrTail, summary));
1244
- }
1245
1120
  /** Human-readable reason a read-only run produced no usable output. */
1246
1121
  function noOutputReason(stats, stderrTail) {
1247
1122
  const cause = agentNeverActed(stats)
@@ -0,0 +1,142 @@
1
+ import { opendir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { NEVER_ACTED_CAUSE, agentNeverActed, agentOutputTail, runAgentInWorkspace, withWorkspace, } from './pi-workspace.js';
4
+ import { cloneRepo, hasAgentChanges, reinitAndPush } from './git.js';
5
+ import { log } from './logger.js';
6
+ import { agentCapabilities, mergeEffort } from './agent-shared.js';
7
+ // ---------------------------------------------------------------------------
8
+ // The repo-BOOTSTRAP mode: adapt a reference architecture (or scaffold from scratch) into a
9
+ // pre-created empty repo and force-push it as a single commit. Extracted from `agent.ts` as a
10
+ // cohesive collaborator — it is a whole MODE with its own push semantics (a separate target repo
11
+ // and a reinitialised history, not a work branch + PR), and it shares only the small agent-run
12
+ // helpers in `agent-shared.ts` with the coding/explore flows.
13
+ // ---------------------------------------------------------------------------
14
+ /**
15
+ * Repo-bootstrap coding flow (the bootstrapper): with a reference architecture, clone it →
16
+ * the agent adapts it in place per the instructions; without one (`fromScratch`), start from
17
+ * an empty directory → the agent scaffolds the new service. Either way the result's history
18
+ * is reset to a single commit and force-pushed to the SEPARATE, pre-created target repo's
19
+ * default branch. Diverges from the ordinary coding flow in pushing to a different repo with
20
+ * a reinitialised history rather than a work branch + PR on the cloned repo.
21
+ */
22
+ export async function runBootstrap(job, opts) {
23
+ const { signal } = opts;
24
+ const boot = job.bootstrap;
25
+ const fromScratch = boot.fromScratch === true;
26
+ const logger = (opts.log ?? log).child({ target: `${boot.target.owner}/${boot.target.name}` });
27
+ return withWorkspace('boot', async (dir) => {
28
+ if (!fromScratch) {
29
+ opts.onPhase?.('clone');
30
+ logger.info('agent(bootstrap): cloning reference architecture', {
31
+ reference: `${job.repo.owner}/${job.repo.name}`,
32
+ });
33
+ await cloneRepo({
34
+ repo: { ...job.repo, baseBranch: job.branch },
35
+ ghToken: job.ghToken,
36
+ dir,
37
+ signal,
38
+ });
39
+ }
40
+ else {
41
+ logger.info('agent(bootstrap): scaffolding from scratch (no reference)');
42
+ }
43
+ opts.onPhase?.('agent');
44
+ logger.info('agent(bootstrap): running agent');
45
+ const { summary, stats, stderrTail, usage, callMetrics, effortReport } = await runAgentInWorkspace({
46
+ dir,
47
+ systemPrompt: job.systemPrompt,
48
+ userPrompt: job.userPrompt,
49
+ model: job.model,
50
+ harness: job.harness,
51
+ subscriptionToken: job.subscriptionToken,
52
+ subscriptionBaseUrl: job.subscriptionBaseUrl,
53
+ ambientAuth: job.ambientAuth,
54
+ proxyBaseUrl: job.proxyBaseUrl,
55
+ proxyPhasePath: job.proxyPhasePath,
56
+ sessionToken: job.sessionToken,
57
+ guardLimits: job.guardLimits,
58
+ ...agentCapabilities(job),
59
+ }, opts);
60
+ // Guard against a no-op run: Pi can exit cleanly having done nothing (e.g. it never
61
+ // reached the model), and a force-push would then publish an empty tree — leaving the
62
+ // run "succeeded" but the repo bare. Fail with a structured error (carrying what the
63
+ // agent did) instead of pushing nothing.
64
+ if (!(await producedRepoContent(dir, !fromScratch, signal))) {
65
+ const error = bootstrapNoOpReason(!fromScratch, stats, summary, stderrTail);
66
+ logger.error('agent(bootstrap): agent produced no content, refusing to push', { ...stats });
67
+ return mergeEffort({
68
+ summary,
69
+ stats,
70
+ error,
71
+ failureCause: 'agent',
72
+ ...(usage ? { usage } : {}),
73
+ ...(callMetrics ? { callMetrics } : {}),
74
+ }, effortReport);
75
+ }
76
+ opts.onPhase?.('push');
77
+ logger.info('agent(bootstrap): pushing bootstrapped contents', { ...stats });
78
+ // Bootstrap always resets history to one commit + force-pushes (the fresh history
79
+ // shares no ancestor with whatever boilerplate the new repo was created with).
80
+ await reinitAndPush({
81
+ dir,
82
+ target: boot.target,
83
+ ghToken: job.ghToken,
84
+ message: fromScratch
85
+ ? 'Bootstrap new repository'
86
+ : `Bootstrap from ${job.repo.owner}/${job.repo.name}`,
87
+ });
88
+ logger.info('agent(bootstrap): complete', { defaultBranch: boot.target.defaultBranch });
89
+ return mergeEffort({
90
+ defaultBranch: boot.target.defaultBranch,
91
+ summary,
92
+ stats,
93
+ ...(usage ? { usage } : {}),
94
+ ...(callMetrics ? { callMetrics } : {}),
95
+ }, effortReport);
96
+ });
97
+ }
98
+ /**
99
+ * Whether the bootstrapper actually produced repository content, so a no-op run (the agent
100
+ * never reached the model / never wrote anything) is failed rather than force-pushed as an
101
+ * empty repo. With a reference architecture, "produced content" means the agent changed the
102
+ * clone; scaffolding from scratch, it means at least one file now exists in the working
103
+ * directory. (The harness writes its prompt context to Pi's global `~/.pi/agent/AGENTS.md`,
104
+ * never into `dir`, so nothing here needs to be filtered out as harness boilerplate.)
105
+ */
106
+ export async function producedRepoContent(dir, hasReference, signal) {
107
+ if (hasReference)
108
+ return hasAgentChanges(dir, signal);
109
+ return containsAnyFile(dir);
110
+ }
111
+ /**
112
+ * Whether `dir` contains at least one regular file anywhere in its tree, walking
113
+ * depth-first and stopping at the FIRST file found — so the cost is bounded by how
114
+ * quickly a file turns up (a scaffold almost always writes a root-level file), not by
115
+ * the size of the produced tree (a full recursive `readdir` would materialise every
116
+ * entry before the check).
117
+ */
118
+ async function containsAnyFile(dir) {
119
+ const handle = await opendir(dir);
120
+ try {
121
+ for await (const entry of handle) {
122
+ if (entry.isFile())
123
+ return true;
124
+ if (entry.isDirectory() && (await containsAnyFile(join(dir, entry.name))))
125
+ return true;
126
+ }
127
+ }
128
+ catch {
129
+ // A directory that vanished mid-walk has nothing to contribute.
130
+ }
131
+ return false;
132
+ }
133
+ /** Human-readable bootstrap no-op reason, embedding what the agent did so the cause is visible. */
134
+ function bootstrapNoOpReason(hasReference, stats, summary, stderrTail) {
135
+ const what = hasReference
136
+ ? 'made no changes to the reference architecture'
137
+ : 'scaffolded no files';
138
+ const cause = agentNeverActed(stats) ? NEVER_ACTED_CAUSE : '';
139
+ return (`the bootstrapper agent ${what} ` +
140
+ `(tool calls: ${stats.toolCalls}, assistant output: ${stats.assistantChars} chars).${cause}` +
141
+ agentOutputTail(stderrTail, summary));
142
+ }
@@ -1,8 +1,6 @@
1
1
  import { mkdir } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import { spawn } from 'node:child_process';
4
- import { killChildProcess, spawnDetached } from './process.js';
5
- import { MAX_CAPTURED_OUTPUT_CHARS, redactSecrets } from './redact.js';
3
+ import { runCapturedCommand } from './captured-command.js';
6
4
  import { branchAheadOfBase, changedFilesSinceBase, branchHasCommitsSince, cloneExistingBranch, cloneRepo, commitTrackedEdits, createBranch, excludeFromGit, fetchReferenceBranches, headCommit, listUntrackedFiles, prepareExistingCheckout, pushBranch, refreshFromBaseIfClean, remoteBranchExists, } from './git.js';
7
5
  import { openPullRequest } from './vcs-api.js';
8
6
  import { FOLLOW_UPS_FILENAME, FollowUpTailer } from './follow-ups.js';
@@ -148,12 +146,14 @@ export async function runCodingAgent(spec, opts = {}) {
148
146
  subscriptionBaseUrl: spec.subscriptionBaseUrl,
149
147
  ambientAuth: spec.ambientAuth,
150
148
  proxyBaseUrl: spec.proxyBaseUrl,
149
+ proxyPhasePath: spec.proxyPhasePath,
151
150
  sessionToken: spec.sessionToken,
152
151
  serviceDirectory,
153
152
  webToolsGuidance: spec.webToolsGuidance,
154
153
  webSearchProxy: spec.webSearchProxy,
155
154
  guardLimits: spec.guardLimits,
156
- ...(spec.skill ? { skill: spec.skill } : {}),
155
+ ...(spec.skills?.length ? { skills: spec.skills } : {}),
156
+ ...(spec.mcpServers?.length ? { mcpServers: spec.mcpServers } : {}),
157
157
  }, opts);
158
158
  let outcome;
159
159
  try {
@@ -457,7 +457,7 @@ async function finalizeCodingRun(args) {
457
457
  // Runs regardless of whether this pass pushed — a no-op iteration must still be able
458
458
  // to report that the criterion is (already) met. The harness runs it, never the model.
459
459
  if (spec.validation) {
460
- outcome.validation = await runRalphValidation(workDir, spec.validation, logger, opts);
460
+ outcome.validation = await runRalphValidation(dir, workDir, spec.validation, logger, opts);
461
461
  }
462
462
  // Pre-PR validation: the loop already ran (before this finalize, so a red checkout never
463
463
  // reaches the PR-opening caller); attach its verdict for the caller to gate on and for the
@@ -525,81 +525,96 @@ function mergeAgentPasses(previous, next) {
525
525
  * killed and treated as a failure (a hung `pnpm test` must never block the loop forever).
526
526
  * Overridable via env for tests; defaults to 15 minutes.
527
527
  */
528
- function ralphValidationTimeoutMs() {
528
+ export function ralphValidationTimeoutMs() {
529
529
  const n = Number(process.env.RALPH_VALIDATION_TIMEOUT_MS);
530
530
  return Number.isFinite(n) && n > 0 ? Math.floor(n) : 15 * 60_000;
531
531
  }
532
+ /**
533
+ * How often the Ralph validation feeds the run's inactivity watchdog while its command runs.
534
+ * The command is exactly the activity-SILENT kind — a full `pnpm test`, a cold install-then-build
535
+ * — and the harness spawns it ITSELF rather than through the agent, so it emits no activity
536
+ * events of its own. `JOB_INACTIVITY_MS` (default 10 min) is TIGHTER than the command's own
537
+ * watchdog ({@link ralphValidationTimeoutMs}, default 15 min), so without this heartbeat any
538
+ * validation running past 10 minutes aborted the whole iteration as "inactivity" — mislabelling
539
+ * a healthy test suite as a wedge, and making the 15-minute watchdog unreachable at stock
540
+ * settings. The two sibling harness-run phases (pre-PR validation, reproduction proof) have
541
+ * always fed it; this one did not. Overridable via env for tests.
542
+ */
543
+ export function ralphHeartbeatMs() {
544
+ const n = Number(process.env.RALPH_VALIDATION_HEARTBEAT_MS);
545
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 30_000;
546
+ }
547
+ /**
548
+ * Bound on the validation output tail that crosses the wire. Deliberately smaller than
549
+ * `MAX_CAPTURED_OUTPUT_CHARS` (`redact.ts`) and equal to the two sibling phases' budgets, for
550
+ * the same reason: this tail is persisted on the step (and on EVERY iteration of the attempt
551
+ * log) inside the run's `detail` JSON blob, which is re-serialized on every step-progress write.
552
+ */
553
+ export const RALPH_VALIDATION_TAIL_CHARS = 4_000;
532
554
  /**
533
555
  * Ralph loop: run the programmatic completion command in the checkout and return its exit
534
- * code plus a bounded, redacted tail of its output. The EXIT CODE is the loop's authoritative
535
- * done signal (0 = the criterion is met) — computed here by the harness, never self-reported
536
- * by the model, which is the whole point of a programmatic exit condition. Runs
537
- * `sh -c <command>` in `cwd`; a watchdog kills the whole process tree on timeout (a hung
538
- * command counts as a failure so the loop is never blocked), and an aborted run resolves to a
539
- * non-zero code too. The command runs INSIDE the sandboxed run container (the same trust
540
- * boundary as the coding agent) there is no host/backend execution.
556
+ * code, a bounded + redacted tail of its output, and the work branch's HEAD it ran against.
557
+ * The EXIT CODE is the loop's authoritative done signal (0 = the criterion is met) — computed
558
+ * here by the harness, never self-reported by the model, which is the whole point of a
559
+ * programmatic exit condition. The command runs INSIDE the sandboxed run container (the same
560
+ * trust boundary as the coding agent) there is no host/backend execution.
561
+ *
562
+ * The spawn itself goes through {@link runCapturedCommand}, the ONE seam for a harness-run
563
+ * command, rather than the near-verbatim copy this used to be. That copy had drifted in two
564
+ * ways the seam exists to prevent: it scrubbed secrets AFTER the rolling truncation with no
565
+ * margin (so a credential straddling the cut lost its `KEY=` prefix and survived redaction as
566
+ * an unrecognised partial, on a tail that reaches the step, the notification and the SPA), and
567
+ * it published the full 16k capture where both siblings deliberately bound the wire tail.
568
+ *
569
+ * `headSha` is what lets the engine tell a loop that is iterating from one that is merely
570
+ * repeating: two consecutive failing iterations against an unchanged head means the agent
571
+ * committed nothing, and the loop is ended early instead of spending the rest of its budget.
572
+ * Best-effort — a head that cannot be read is simply omitted, and the engine's check fails open.
541
573
  */
542
- async function runRalphValidation(cwd, validation, logger, opts) {
543
- const timeoutMs = ralphValidationTimeoutMs();
574
+ export async function runRalphValidation(repoDir, cwd, validation, logger, opts) {
544
575
  logger.info('coding-agent(ralph): running validation command', {
545
576
  iteration: validation.iteration,
546
577
  });
547
- return new Promise((resolve) => {
548
- let out = '';
549
- let settled = false;
550
- const child = spawn('sh', ['-c', validation.command], {
578
+ // Keep the run's inactivity watchdog fed for the whole command — see `ralphHeartbeatMs`.
579
+ const heartbeat = setInterval(() => opts.onActivity?.(), ralphHeartbeatMs());
580
+ heartbeat.unref?.();
581
+ let captured;
582
+ try {
583
+ captured = await runCapturedCommand({
551
584
  cwd,
552
- detached: spawnDetached,
553
- stdio: ['ignore', 'pipe', 'pipe'],
554
- // The job's own env (see `RunOptions.agentEnv`): a validation command typically installs
555
- // before it tests, and this is spawned by the HARNESS rather than the agent, so it does not
556
- // otherwise inherit the job's private-registry npmrc pointer on the native path.
557
- env: { ...process.env, ...opts.agentEnv },
585
+ command: validation.command,
586
+ timeoutMs: ralphValidationTimeoutMs(),
587
+ reportTailChars: RALPH_VALIDATION_TAIL_CHARS,
588
+ logLabel: 'coding-agent(ralph): validation',
589
+ logFields: { iteration: validation.iteration },
590
+ logger,
591
+ opts,
558
592
  });
559
- // Keep only the tail; guard against unbounded buffering on a chatty command.
560
- const capture = (chunk) => {
561
- out = (out + chunk.toString('utf8')).slice(-MAX_CAPTURED_OUTPUT_CHARS);
562
- };
563
- child.stdout?.on('data', capture);
564
- child.stderr?.on('data', capture);
565
- const finish = (exitCode) => {
566
- if (settled)
567
- return;
568
- settled = true;
569
- clearTimeout(timer);
570
- opts.signal?.removeEventListener('abort', onAbort);
571
- const trimmed = out.trim();
572
- const tail = trimmed ? redactSecrets(trimmed) : undefined;
573
- logger.info('coding-agent(ralph): validation finished', {
574
- exitCode,
575
- iteration: validation.iteration,
576
- });
577
- resolve({
578
- validationPassed: exitCode === 0,
579
- exitCode,
580
- ...(tail ? { validationOutputTail: tail } : {}),
581
- ...(validation.iteration !== undefined ? { iteration: validation.iteration } : {}),
582
- });
583
- };
584
- const timer = setTimeout(() => {
585
- logger.warn('coding-agent(ralph): validation command timed out', { timeoutMs });
586
- killChildProcess(child, undefined, logger);
587
- finish(124); // conventional timeout exit code (a non-zero fail)
588
- }, timeoutMs);
589
- timer.unref?.();
590
- const onAbort = () => {
591
- killChildProcess(child, undefined, logger);
592
- finish(130); // aborted (a non-zero fail)
593
- };
594
- opts.signal?.addEventListener('abort', onAbort, { once: true });
595
- child.on('error', (err) => {
596
- logger.warn('coding-agent(ralph): validation command failed to spawn', {
597
- error: err instanceof Error ? err.message : String(err),
598
- });
599
- finish(127); // spawn error / command not found (a non-zero fail)
593
+ }
594
+ finally {
595
+ clearInterval(heartbeat);
596
+ }
597
+ // The commit the criterion was judged against. Read AFTER the command so a validation that
598
+ // itself commits (a formatter check that rewrites files, say) is attributed to what it left.
599
+ // Best-effort: an unreadable head only costs the engine's no-progress guard, never the
600
+ // verdict — but it is REPORTED, or a guard that quietly stopped firing leaves no trace.
601
+ const headSha = await headCommit(repoDir, opts.signal).catch((err) => {
602
+ logger.warn('coding-agent(ralph): could not read the work-branch head', {
603
+ error: err instanceof Error ? err.message : String(err),
600
604
  });
601
- child.on('close', (code) => finish(code ?? 1));
605
+ return '';
602
606
  });
607
+ logger.info('coding-agent(ralph): validation finished', {
608
+ exitCode: captured.exitCode,
609
+ iteration: validation.iteration,
610
+ });
611
+ return {
612
+ validationPassed: captured.passed,
613
+ exitCode: captured.exitCode,
614
+ ...(captured.outputTail ? { validationOutputTail: captured.outputTail } : {}),
615
+ ...(validation.iteration !== undefined ? { iteration: validation.iteration } : {}),
616
+ ...(headSha ? { headSha } : {}),
617
+ };
603
618
  }
604
619
  /** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
605
620
  export function safeDirSegment(value) {
@@ -702,11 +717,16 @@ export async function runMultiRepoCoding(job, opts = {}) {
702
717
  subscriptionBaseUrl: job.subscriptionBaseUrl,
703
718
  ambientAuth: job.ambientAuth,
704
719
  proxyBaseUrl: job.proxyBaseUrl,
720
+ proxyPhasePath: job.proxyPhasePath,
705
721
  sessionToken: job.sessionToken,
706
722
  webToolsGuidance: job.webToolsGuidance,
707
723
  webSearchProxy: job.webSearch,
708
724
  guardLimits: job.guardLimits,
709
725
  ...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
726
+ // Skills + tool servers apply to a multi-repo run exactly as to a single-repo one: they
727
+ // are properties of the AGENT KIND, not of the checkout layout.
728
+ ...(job.skills?.length ? { skills: job.skills } : {}),
729
+ ...(job.mcpServers?.length ? { mcpServers: job.mcpServers } : {}),
710
730
  multiRepo: true,
711
731
  }, opts);
712
732
  // Commit forgotten tracked edits, then push + open a PR for each repo the run actually changed.