@cat-factory/executor-harness 1.43.8 → 1.45.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 +41 -2
- package/dist/coding-agent.js +87 -0
- package/dist/job.js +23 -0
- package/package.json +3 -3
- package/src/agent.ts +75 -32
- package/src/coding-agent.ts +117 -1
- package/src/job.ts +56 -0
package/dist/agent.js
CHANGED
|
@@ -654,6 +654,16 @@ async function runMultiRepoExplore(job, opts) {
|
|
|
654
654
|
}, { infraSetupFields: {}, logger, signal: opts.signal });
|
|
655
655
|
});
|
|
656
656
|
}
|
|
657
|
+
/**
|
|
658
|
+
* Whether a Ralph iteration ({@link AgentJob.validation} set) landed on a MULTI-REPO job (writable
|
|
659
|
+
* peer repos or read-only reference repos). The post-commit validation command is only wired into
|
|
660
|
+
* the single-repo flow, so a multi-repo run would silently skip it and degenerate the loop into a
|
|
661
|
+
* one-shot with no completion gate — multi-repo ralph is out of scope for v1 (see
|
|
662
|
+
* backend/docs/ralph-loop.md), so {@link runCodingMode} fails loudly on this instead.
|
|
663
|
+
*/
|
|
664
|
+
export function ralphUnsupportedOnMultiRepo(job) {
|
|
665
|
+
return Boolean(job.validation) && Boolean(job.peerRepos?.length || job.referenceRepos?.length);
|
|
666
|
+
}
|
|
657
667
|
/**
|
|
658
668
|
* Edit-and-push coding, dispatching on job DATA: repo-bootstrap (force-push a fresh history to a
|
|
659
669
|
* separate target repo), conflict-resolution (merge the base in, resolve, push back), multi-repo
|
|
@@ -678,7 +688,19 @@ async function runCodingMode(job, opts) {
|
|
|
678
688
|
// all of them. Keyed off job DATA, not the agent kind — set for the implementer's writable
|
|
679
689
|
// peer repos (service-connections phase 3, `peerRepos`) OR the doc-writer's READ-ONLY
|
|
680
690
|
// reference repos (`referenceRepos`, cloned but never pushed).
|
|
681
|
-
const
|
|
691
|
+
const multiRepo = Boolean(job.peerRepos?.length || job.referenceRepos?.length);
|
|
692
|
+
// Ralph loop (v1): the post-commit validation command is only wired into the single-repo
|
|
693
|
+
// flow, so a multi-repo run would silently skip it and the loop would degenerate into a
|
|
694
|
+
// one-shot with no completion gate. Multi-repo ralph is deliberately out of scope for v1
|
|
695
|
+
// (see backend/docs/ralph-loop.md), so FAIL LOUDLY rather than run a validation-less pass.
|
|
696
|
+
if (ralphUnsupportedOnMultiRepo(job)) {
|
|
697
|
+
return {
|
|
698
|
+
error: 'Ralph loop is not supported on a multi-repo task (connected service repos). ' +
|
|
699
|
+
'Its validation command runs only in the single primary-repo checkout. ' +
|
|
700
|
+
'Run the Ralph loop on a task scoped to a single repo.',
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
const result = multiRepo
|
|
682
704
|
? await runMultiRepoCoding(job, opts)
|
|
683
705
|
: await runSingleRepoCoding(job, opts);
|
|
684
706
|
// Structured coding kind (repro-test): fold the final reply's JSON onto `custom` so the
|
|
@@ -700,7 +722,7 @@ async function runCodingMode(job, opts) {
|
|
|
700
722
|
*/
|
|
701
723
|
async function runSingleRepoCoding(job, opts) {
|
|
702
724
|
const pushBranch = job.pushBranch ?? job.newBranch ?? job.branch;
|
|
703
|
-
const { summary, stats, stderrTail, pushed, usage, callMetrics } = await runCodingAgent({
|
|
725
|
+
const { summary, stats, stderrTail, pushed, usage, callMetrics, validation } = await runCodingAgent({
|
|
704
726
|
kind: 'agent',
|
|
705
727
|
jobId: job.jobId,
|
|
706
728
|
repo: job.repo,
|
|
@@ -724,7 +746,21 @@ async function runSingleRepoCoding(job, opts) {
|
|
|
724
746
|
...(job.persistentCheckout ? { persistentCheckout: true } : {}),
|
|
725
747
|
...(job.streamFollowUps ? { streamFollowUps: true } : {}),
|
|
726
748
|
...(job.referenceBranches?.length ? { referenceBranches: job.referenceBranches } : {}),
|
|
749
|
+
// Ralph loop: run the completion command after the agent commits and report its verdict.
|
|
750
|
+
...(job.validation
|
|
751
|
+
? {
|
|
752
|
+
validation: {
|
|
753
|
+
command: job.validation.command,
|
|
754
|
+
...(job.validation.iteration !== undefined
|
|
755
|
+
? { iteration: job.validation.iteration }
|
|
756
|
+
: {}),
|
|
757
|
+
},
|
|
758
|
+
}
|
|
759
|
+
: {}),
|
|
727
760
|
}, opts);
|
|
761
|
+
// Ralph loop: the harness-computed validation verdict, forwarded onto the coding result as
|
|
762
|
+
// `ralphVerdict` so the backend's `toRunResult` lifts it onto `AgentRunResult.ralphVerdict`.
|
|
763
|
+
const ralphVerdict = validation ? { ralphVerdict: validation } : {};
|
|
728
764
|
if (!pushed) {
|
|
729
765
|
// A no-op: a failure for the implementer, a clean non-event for the fixers.
|
|
730
766
|
if (job.noChangesIsError === false) {
|
|
@@ -735,6 +771,7 @@ async function runSingleRepoCoding(job, opts) {
|
|
|
735
771
|
stats,
|
|
736
772
|
...(usage ? { usage } : {}),
|
|
737
773
|
...(callMetrics ? { callMetrics } : {}),
|
|
774
|
+
...ralphVerdict,
|
|
738
775
|
};
|
|
739
776
|
}
|
|
740
777
|
return {
|
|
@@ -799,6 +836,7 @@ async function runSingleRepoCoding(job, opts) {
|
|
|
799
836
|
stats,
|
|
800
837
|
...(usage ? { usage } : {}),
|
|
801
838
|
...(callMetrics ? { callMetrics } : {}),
|
|
839
|
+
...ralphVerdict,
|
|
802
840
|
};
|
|
803
841
|
}
|
|
804
842
|
return {
|
|
@@ -808,6 +846,7 @@ async function runSingleRepoCoding(job, opts) {
|
|
|
808
846
|
stats,
|
|
809
847
|
...(usage ? { usage } : {}),
|
|
810
848
|
...(callMetrics ? { callMetrics } : {}),
|
|
849
|
+
...ralphVerdict,
|
|
811
850
|
};
|
|
812
851
|
}
|
|
813
852
|
/**
|
package/dist/coding-agent.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
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
6
|
import { branchAheadOfBase, branchHasCommitsSince, cloneExistingBranch, cloneRepo, commitTrackedEdits, createBranch, excludeFromGit, fetchReferenceBranches, headCommit, listUntrackedFiles, openPullRequest, prepareExistingCheckout, pushBranch, refreshFromBaseIfClean, remoteBranchExists, } from './git.js';
|
|
4
7
|
import { FOLLOW_UPS_FILENAME, FollowUpTailer } from './follow-ups.js';
|
|
5
8
|
import { acquireRepoCheckout, agentNeverActed, agentOutputTail, runAgentInWorkspace, withWorkspace, } from './pi-workspace.js';
|
|
@@ -299,6 +302,13 @@ export async function runCodingAgent(spec, opts = {}) {
|
|
|
299
302
|
...(callMetrics ? { callMetrics } : {}),
|
|
300
303
|
};
|
|
301
304
|
}
|
|
305
|
+
// Ralph loop: run the programmatic completion command against the pushed/committed
|
|
306
|
+
// state and attach its verdict (exit code = the loop's authoritative done signal).
|
|
307
|
+
// Runs regardless of whether this pass pushed — a no-op iteration must still be able
|
|
308
|
+
// to report that the criterion is (already) met. The harness runs it, never the model.
|
|
309
|
+
if (spec.validation) {
|
|
310
|
+
outcome.validation = await runRalphValidation(workDir, spec.validation, logger, opts);
|
|
311
|
+
}
|
|
302
312
|
}
|
|
303
313
|
finally {
|
|
304
314
|
// Safety net for the throw path (the happy path already cleared these above).
|
|
@@ -309,6 +319,83 @@ export async function runCodingAgent(spec, opts = {}) {
|
|
|
309
319
|
return outcome;
|
|
310
320
|
});
|
|
311
321
|
}
|
|
322
|
+
/**
|
|
323
|
+
* The Ralph-loop validation watchdog: the longest a completion command may run before it is
|
|
324
|
+
* killed and treated as a failure (a hung `pnpm test` must never block the loop forever).
|
|
325
|
+
* Overridable via env for tests; defaults to 15 minutes.
|
|
326
|
+
*/
|
|
327
|
+
function ralphValidationTimeoutMs() {
|
|
328
|
+
const n = Number(process.env.RALPH_VALIDATION_TIMEOUT_MS);
|
|
329
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 15 * 60_000;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Ralph loop: run the programmatic completion command in the checkout and return its exit
|
|
333
|
+
* code plus a bounded, redacted tail of its output. The EXIT CODE is the loop's authoritative
|
|
334
|
+
* done signal (0 = the criterion is met) — computed here by the harness, never self-reported
|
|
335
|
+
* by the model, which is the whole point of a programmatic exit condition. Runs
|
|
336
|
+
* `sh -c <command>` in `cwd`; a watchdog kills the whole process tree on timeout (a hung
|
|
337
|
+
* command counts as a failure so the loop is never blocked), and an aborted run resolves to a
|
|
338
|
+
* non-zero code too. The command runs INSIDE the sandboxed run container (the same trust
|
|
339
|
+
* boundary as the coding agent) — there is no host/backend execution.
|
|
340
|
+
*/
|
|
341
|
+
async function runRalphValidation(cwd, validation, logger, opts) {
|
|
342
|
+
const timeoutMs = ralphValidationTimeoutMs();
|
|
343
|
+
logger.info('coding-agent(ralph): running validation command', {
|
|
344
|
+
iteration: validation.iteration,
|
|
345
|
+
});
|
|
346
|
+
return new Promise((resolve) => {
|
|
347
|
+
let out = '';
|
|
348
|
+
let settled = false;
|
|
349
|
+
const child = spawn('sh', ['-c', validation.command], {
|
|
350
|
+
cwd,
|
|
351
|
+
detached: spawnDetached,
|
|
352
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
353
|
+
});
|
|
354
|
+
// Keep only the tail; guard against unbounded buffering on a chatty command.
|
|
355
|
+
const capture = (chunk) => {
|
|
356
|
+
out = (out + chunk.toString('utf8')).slice(-MAX_CAPTURED_OUTPUT_CHARS);
|
|
357
|
+
};
|
|
358
|
+
child.stdout?.on('data', capture);
|
|
359
|
+
child.stderr?.on('data', capture);
|
|
360
|
+
const finish = (exitCode) => {
|
|
361
|
+
if (settled)
|
|
362
|
+
return;
|
|
363
|
+
settled = true;
|
|
364
|
+
clearTimeout(timer);
|
|
365
|
+
opts.signal?.removeEventListener('abort', onAbort);
|
|
366
|
+
const trimmed = out.trim();
|
|
367
|
+
const tail = trimmed ? redactSecrets(trimmed) : undefined;
|
|
368
|
+
logger.info('coding-agent(ralph): validation finished', {
|
|
369
|
+
exitCode,
|
|
370
|
+
iteration: validation.iteration,
|
|
371
|
+
});
|
|
372
|
+
resolve({
|
|
373
|
+
validationPassed: exitCode === 0,
|
|
374
|
+
exitCode,
|
|
375
|
+
...(tail ? { validationOutputTail: tail } : {}),
|
|
376
|
+
...(validation.iteration !== undefined ? { iteration: validation.iteration } : {}),
|
|
377
|
+
});
|
|
378
|
+
};
|
|
379
|
+
const timer = setTimeout(() => {
|
|
380
|
+
logger.warn('coding-agent(ralph): validation command timed out', { timeoutMs });
|
|
381
|
+
killChildProcess(child, undefined, logger);
|
|
382
|
+
finish(124); // conventional timeout exit code (a non-zero fail)
|
|
383
|
+
}, timeoutMs);
|
|
384
|
+
timer.unref?.();
|
|
385
|
+
const onAbort = () => {
|
|
386
|
+
killChildProcess(child, undefined, logger);
|
|
387
|
+
finish(130); // aborted (a non-zero fail)
|
|
388
|
+
};
|
|
389
|
+
opts.signal?.addEventListener('abort', onAbort, { once: true });
|
|
390
|
+
child.on('error', (err) => {
|
|
391
|
+
logger.warn('coding-agent(ralph): validation command failed to spawn', {
|
|
392
|
+
error: err instanceof Error ? err.message : String(err),
|
|
393
|
+
});
|
|
394
|
+
finish(127); // spawn error / command not found (a non-zero fail)
|
|
395
|
+
});
|
|
396
|
+
child.on('close', (code) => finish(code ?? 1));
|
|
397
|
+
});
|
|
398
|
+
}
|
|
312
399
|
/** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
|
|
313
400
|
export function safeDirSegment(value) {
|
|
314
401
|
return value.replace(/[^A-Za-z0-9._-]/g, '-') || '_';
|
package/dist/job.js
CHANGED
|
@@ -45,6 +45,27 @@ function parseGuardLimits(value) {
|
|
|
45
45
|
spec.maxConsecutiveWebCalls = web;
|
|
46
46
|
return Object.keys(spec).length > 0 ? spec : undefined;
|
|
47
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* Parse the optional Ralph-loop validation spec. Requires a non-empty `command` string (the
|
|
50
|
+
* completion criterion the harness runs); `progressPath`/`iteration` are optional metadata.
|
|
51
|
+
* Returns undefined when absent or malformed (a coding run then behaves like any other — no
|
|
52
|
+
* post-commit validation). See {@link ValidationSpec}.
|
|
53
|
+
*/
|
|
54
|
+
function parseValidationSpec(value) {
|
|
55
|
+
if (typeof value !== 'object' || value === null)
|
|
56
|
+
return undefined;
|
|
57
|
+
const o = value;
|
|
58
|
+
if (typeof o.command !== 'string' || o.command.trim() === '')
|
|
59
|
+
return undefined;
|
|
60
|
+
const iteration = posInt(o.iteration);
|
|
61
|
+
return {
|
|
62
|
+
command: o.command,
|
|
63
|
+
...(typeof o.progressPath === 'string' && o.progressPath
|
|
64
|
+
? { progressPath: o.progressPath }
|
|
65
|
+
: {}),
|
|
66
|
+
...(iteration !== undefined ? { iteration } : {}),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
48
69
|
/**
|
|
49
70
|
* Parse the shared per-job auth fields, validating per harness: a subscription
|
|
50
71
|
* harness (`claude-code` / `codex`) requires `subscriptionToken`; the default Pi
|
|
@@ -589,6 +610,7 @@ export function parseAgentJob(input) {
|
|
|
589
610
|
const packageRegistries = parsePackageRegistries(o.packageRegistries);
|
|
590
611
|
const testSecrets = parseTestSecrets(o.testSecrets);
|
|
591
612
|
const guardLimits = parseGuardLimits(o.guardLimits);
|
|
613
|
+
const validation = parseValidationSpec(o.validation);
|
|
592
614
|
const job = {
|
|
593
615
|
jobId: str(o.jobId, 'jobId'),
|
|
594
616
|
mode,
|
|
@@ -623,6 +645,7 @@ export function parseAgentJob(input) {
|
|
|
623
645
|
...(o.persistentCheckout === true ? { persistentCheckout: true } : {}),
|
|
624
646
|
...(o.streamFollowUps === true ? { streamFollowUps: true } : {}),
|
|
625
647
|
...(guardLimits ? { guardLimits } : {}),
|
|
648
|
+
...(validation ? { validation } : {}),
|
|
626
649
|
};
|
|
627
650
|
assertAllowedHost(job.repo.cloneUrl, 'repo.cloneUrl');
|
|
628
651
|
if (job.githubApiBase)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/executor-harness",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.45.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.29",
|
|
27
27
|
"typescript": "7.0.2",
|
|
28
28
|
"vitest": "^4.1.10",
|
|
29
|
-
"@cat-factory/server": "0.
|
|
30
|
-
"@cat-factory/spend": "0.12.
|
|
29
|
+
"@cat-factory/server": "0.126.0",
|
|
30
|
+
"@cat-factory/spend": "0.12.37"
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
|
33
33
|
"build": "tsc -p tsconfig.json",
|
package/src/agent.ts
CHANGED
|
@@ -797,6 +797,19 @@ async function runMultiRepoExplore(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
797
797
|
})
|
|
798
798
|
}
|
|
799
799
|
|
|
800
|
+
/**
|
|
801
|
+
* Whether a Ralph iteration ({@link AgentJob.validation} set) landed on a MULTI-REPO job (writable
|
|
802
|
+
* peer repos or read-only reference repos). The post-commit validation command is only wired into
|
|
803
|
+
* the single-repo flow, so a multi-repo run would silently skip it and degenerate the loop into a
|
|
804
|
+
* one-shot with no completion gate — multi-repo ralph is out of scope for v1 (see
|
|
805
|
+
* backend/docs/ralph-loop.md), so {@link runCodingMode} fails loudly on this instead.
|
|
806
|
+
*/
|
|
807
|
+
export function ralphUnsupportedOnMultiRepo(
|
|
808
|
+
job: Pick<AgentJob, 'validation' | 'peerRepos' | 'referenceRepos'>,
|
|
809
|
+
): boolean {
|
|
810
|
+
return Boolean(job.validation) && Boolean(job.peerRepos?.length || job.referenceRepos?.length)
|
|
811
|
+
}
|
|
812
|
+
|
|
800
813
|
/**
|
|
801
814
|
* Edit-and-push coding, dispatching on job DATA: repo-bootstrap (force-push a fresh history to a
|
|
802
815
|
* separate target repo), conflict-resolution (merge the base in, resolve, push back), multi-repo
|
|
@@ -819,10 +832,22 @@ async function runCodingMode(job: AgentJob, opts: RunOptions): Promise<AgentResu
|
|
|
819
832
|
// all of them. Keyed off job DATA, not the agent kind — set for the implementer's writable
|
|
820
833
|
// peer repos (service-connections phase 3, `peerRepos`) OR the doc-writer's READ-ONLY
|
|
821
834
|
// reference repos (`referenceRepos`, cloned but never pushed).
|
|
822
|
-
const
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
835
|
+
const multiRepo = Boolean(job.peerRepos?.length || job.referenceRepos?.length)
|
|
836
|
+
// Ralph loop (v1): the post-commit validation command is only wired into the single-repo
|
|
837
|
+
// flow, so a multi-repo run would silently skip it and the loop would degenerate into a
|
|
838
|
+
// one-shot with no completion gate. Multi-repo ralph is deliberately out of scope for v1
|
|
839
|
+
// (see backend/docs/ralph-loop.md), so FAIL LOUDLY rather than run a validation-less pass.
|
|
840
|
+
if (ralphUnsupportedOnMultiRepo(job)) {
|
|
841
|
+
return {
|
|
842
|
+
error:
|
|
843
|
+
'Ralph loop is not supported on a multi-repo task (connected service repos). ' +
|
|
844
|
+
'Its validation command runs only in the single primary-repo checkout. ' +
|
|
845
|
+
'Run the Ralph loop on a task scoped to a single repo.',
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
const result = multiRepo
|
|
849
|
+
? await runMultiRepoCoding(job, opts)
|
|
850
|
+
: await runSingleRepoCoding(job, opts)
|
|
826
851
|
|
|
827
852
|
// Structured coding kind (repro-test): fold the final reply's JSON onto `custom` so the
|
|
828
853
|
// backend post-completion resolver records the outcome. Skipped on a failed run (its `error`
|
|
@@ -843,34 +868,49 @@ async function runCodingMode(job: AgentJob, opts: RunOptions): Promise<AgentResu
|
|
|
843
868
|
*/
|
|
844
869
|
async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<AgentResult> {
|
|
845
870
|
const pushBranch = job.pushBranch ?? job.newBranch ?? job.branch
|
|
846
|
-
const { summary, stats, stderrTail, pushed, usage, callMetrics } =
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
871
|
+
const { summary, stats, stderrTail, pushed, usage, callMetrics, validation } =
|
|
872
|
+
await runCodingAgent(
|
|
873
|
+
{
|
|
874
|
+
kind: 'agent',
|
|
875
|
+
jobId: job.jobId,
|
|
876
|
+
repo: job.repo,
|
|
877
|
+
cloneBranch: job.branch,
|
|
878
|
+
...(job.newBranch ? { newBranch: job.newBranch } : {}),
|
|
879
|
+
pushBranch,
|
|
880
|
+
ghToken: job.ghToken,
|
|
881
|
+
systemPrompt: job.systemPrompt,
|
|
882
|
+
userPrompt: job.userPrompt,
|
|
883
|
+
model: job.model,
|
|
884
|
+
harness: job.harness,
|
|
885
|
+
subscriptionToken: job.subscriptionToken,
|
|
886
|
+
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
887
|
+
ambientAuth: job.ambientAuth,
|
|
888
|
+
proxyBaseUrl: job.proxyBaseUrl,
|
|
889
|
+
sessionToken: job.sessionToken,
|
|
890
|
+
commitMessage: job.commitMessage ?? job.pr?.title ?? 'Agent changes',
|
|
891
|
+
webToolsGuidance: job.webToolsGuidance,
|
|
892
|
+
webSearchProxy: job.webSearch,
|
|
893
|
+
guardLimits: job.guardLimits,
|
|
894
|
+
...(job.persistentCheckout ? { persistentCheckout: true } : {}),
|
|
895
|
+
...(job.streamFollowUps ? { streamFollowUps: true } : {}),
|
|
896
|
+
...(job.referenceBranches?.length ? { referenceBranches: job.referenceBranches } : {}),
|
|
897
|
+
// Ralph loop: run the completion command after the agent commits and report its verdict.
|
|
898
|
+
...(job.validation
|
|
899
|
+
? {
|
|
900
|
+
validation: {
|
|
901
|
+
command: job.validation.command,
|
|
902
|
+
...(job.validation.iteration !== undefined
|
|
903
|
+
? { iteration: job.validation.iteration }
|
|
904
|
+
: {}),
|
|
905
|
+
},
|
|
906
|
+
}
|
|
907
|
+
: {}),
|
|
908
|
+
},
|
|
909
|
+
opts,
|
|
910
|
+
)
|
|
911
|
+
// Ralph loop: the harness-computed validation verdict, forwarded onto the coding result as
|
|
912
|
+
// `ralphVerdict` so the backend's `toRunResult` lifts it onto `AgentRunResult.ralphVerdict`.
|
|
913
|
+
const ralphVerdict = validation ? { ralphVerdict: validation } : {}
|
|
874
914
|
|
|
875
915
|
if (!pushed) {
|
|
876
916
|
// A no-op: a failure for the implementer, a clean non-event for the fixers.
|
|
@@ -882,6 +922,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
882
922
|
stats,
|
|
883
923
|
...(usage ? { usage } : {}),
|
|
884
924
|
...(callMetrics ? { callMetrics } : {}),
|
|
925
|
+
...ralphVerdict,
|
|
885
926
|
}
|
|
886
927
|
}
|
|
887
928
|
return {
|
|
@@ -951,6 +992,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
951
992
|
stats,
|
|
952
993
|
...(usage ? { usage } : {}),
|
|
953
994
|
...(callMetrics ? { callMetrics } : {}),
|
|
995
|
+
...ralphVerdict,
|
|
954
996
|
}
|
|
955
997
|
}
|
|
956
998
|
return {
|
|
@@ -960,6 +1002,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
960
1002
|
stats,
|
|
961
1003
|
...(usage ? { usage } : {}),
|
|
962
1004
|
...(callMetrics ? { callMetrics } : {}),
|
|
1005
|
+
...ralphVerdict,
|
|
963
1006
|
}
|
|
964
1007
|
}
|
|
965
1008
|
|
package/src/coding-agent.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
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
6
|
import type {
|
|
4
7
|
AgentJob,
|
|
5
8
|
AgentResult,
|
|
@@ -36,7 +39,7 @@ import {
|
|
|
36
39
|
} from './pi-workspace.js'
|
|
37
40
|
import type { ProgressGuardLimits } from './pi.js'
|
|
38
41
|
import type { RunOptions } from './runner.js'
|
|
39
|
-
import { log } from './logger.js'
|
|
42
|
+
import { log, type Logger } from './logger.js'
|
|
40
43
|
|
|
41
44
|
// The shared skeleton for the container coding agents that clone a repo, run Pi
|
|
42
45
|
// against it and push the result on a branch. The implementation (`/run`) and
|
|
@@ -92,6 +95,12 @@ export interface CodingAgentSpec extends HarnessAuthFields {
|
|
|
92
95
|
* them. Best-effort per branch. Absent/empty ⇒ none fetched.
|
|
93
96
|
*/
|
|
94
97
|
referenceBranches?: string[]
|
|
98
|
+
/**
|
|
99
|
+
* Ralph loop: run this programmatic completion command in the checkout AFTER the agent
|
|
100
|
+
* commits + pushes, capturing its exit code + a bounded output tail (the loop's exit
|
|
101
|
+
* condition — computed by the harness, never the model). Absent for every non-`ralph` run.
|
|
102
|
+
*/
|
|
103
|
+
validation?: { command: string; iteration?: number }
|
|
95
104
|
}
|
|
96
105
|
|
|
97
106
|
/** The outcome of a coding agent run, before each caller maps it to its own result shape. */
|
|
@@ -107,6 +116,17 @@ export interface CodingAgentOutcome {
|
|
|
107
116
|
usage?: { inputTokens: number; outputTokens: number }
|
|
108
117
|
/** Per-model-call telemetry from a subscription harness's CLI stream (absent for Pi). */
|
|
109
118
|
callMetrics?: HarnessCallMetric[]
|
|
119
|
+
/**
|
|
120
|
+
* Ralph loop: the verdict of the post-commit validation command (whether it exited 0, the
|
|
121
|
+
* exit code, and a bounded/redacted output tail). Present only when {@link CodingAgentSpec.validation}
|
|
122
|
+
* was set. The exit code is the loop's authoritative completion signal.
|
|
123
|
+
*/
|
|
124
|
+
validation?: {
|
|
125
|
+
validationPassed: boolean
|
|
126
|
+
exitCode: number
|
|
127
|
+
validationOutputTail?: string
|
|
128
|
+
iteration?: number
|
|
129
|
+
}
|
|
110
130
|
}
|
|
111
131
|
|
|
112
132
|
/**
|
|
@@ -425,6 +445,14 @@ export async function runCodingAgent(
|
|
|
425
445
|
...(callMetrics ? { callMetrics } : {}),
|
|
426
446
|
}
|
|
427
447
|
}
|
|
448
|
+
|
|
449
|
+
// Ralph loop: run the programmatic completion command against the pushed/committed
|
|
450
|
+
// state and attach its verdict (exit code = the loop's authoritative done signal).
|
|
451
|
+
// Runs regardless of whether this pass pushed — a no-op iteration must still be able
|
|
452
|
+
// to report that the criterion is (already) met. The harness runs it, never the model.
|
|
453
|
+
if (spec.validation) {
|
|
454
|
+
outcome.validation = await runRalphValidation(workDir, spec.validation, logger, opts)
|
|
455
|
+
}
|
|
428
456
|
} finally {
|
|
429
457
|
// Safety net for the throw path (the happy path already cleared these above).
|
|
430
458
|
clearInterval(checkpoint)
|
|
@@ -435,6 +463,94 @@ export async function runCodingAgent(
|
|
|
435
463
|
)
|
|
436
464
|
}
|
|
437
465
|
|
|
466
|
+
/**
|
|
467
|
+
* The Ralph-loop validation watchdog: the longest a completion command may run before it is
|
|
468
|
+
* killed and treated as a failure (a hung `pnpm test` must never block the loop forever).
|
|
469
|
+
* Overridable via env for tests; defaults to 15 minutes.
|
|
470
|
+
*/
|
|
471
|
+
function ralphValidationTimeoutMs(): number {
|
|
472
|
+
const n = Number(process.env.RALPH_VALIDATION_TIMEOUT_MS)
|
|
473
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 15 * 60_000
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Ralph loop: run the programmatic completion command in the checkout and return its exit
|
|
478
|
+
* code plus a bounded, redacted tail of its output. The EXIT CODE is the loop's authoritative
|
|
479
|
+
* done signal (0 = the criterion is met) — computed here by the harness, never self-reported
|
|
480
|
+
* by the model, which is the whole point of a programmatic exit condition. Runs
|
|
481
|
+
* `sh -c <command>` in `cwd`; a watchdog kills the whole process tree on timeout (a hung
|
|
482
|
+
* command counts as a failure so the loop is never blocked), and an aborted run resolves to a
|
|
483
|
+
* non-zero code too. The command runs INSIDE the sandboxed run container (the same trust
|
|
484
|
+
* boundary as the coding agent) — there is no host/backend execution.
|
|
485
|
+
*/
|
|
486
|
+
async function runRalphValidation(
|
|
487
|
+
cwd: string,
|
|
488
|
+
validation: { command: string; iteration?: number },
|
|
489
|
+
logger: Logger,
|
|
490
|
+
opts: RunOptions,
|
|
491
|
+
): Promise<{
|
|
492
|
+
validationPassed: boolean
|
|
493
|
+
exitCode: number
|
|
494
|
+
validationOutputTail?: string
|
|
495
|
+
iteration?: number
|
|
496
|
+
}> {
|
|
497
|
+
const timeoutMs = ralphValidationTimeoutMs()
|
|
498
|
+
logger.info('coding-agent(ralph): running validation command', {
|
|
499
|
+
iteration: validation.iteration,
|
|
500
|
+
})
|
|
501
|
+
return new Promise((resolve) => {
|
|
502
|
+
let out = ''
|
|
503
|
+
let settled = false
|
|
504
|
+
const child = spawn('sh', ['-c', validation.command], {
|
|
505
|
+
cwd,
|
|
506
|
+
detached: spawnDetached,
|
|
507
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
508
|
+
})
|
|
509
|
+
// Keep only the tail; guard against unbounded buffering on a chatty command.
|
|
510
|
+
const capture = (chunk: Buffer): void => {
|
|
511
|
+
out = (out + chunk.toString('utf8')).slice(-MAX_CAPTURED_OUTPUT_CHARS)
|
|
512
|
+
}
|
|
513
|
+
child.stdout?.on('data', capture)
|
|
514
|
+
child.stderr?.on('data', capture)
|
|
515
|
+
const finish = (exitCode: number): void => {
|
|
516
|
+
if (settled) return
|
|
517
|
+
settled = true
|
|
518
|
+
clearTimeout(timer)
|
|
519
|
+
opts.signal?.removeEventListener('abort', onAbort)
|
|
520
|
+
const trimmed = out.trim()
|
|
521
|
+
const tail = trimmed ? redactSecrets(trimmed) : undefined
|
|
522
|
+
logger.info('coding-agent(ralph): validation finished', {
|
|
523
|
+
exitCode,
|
|
524
|
+
iteration: validation.iteration,
|
|
525
|
+
})
|
|
526
|
+
resolve({
|
|
527
|
+
validationPassed: exitCode === 0,
|
|
528
|
+
exitCode,
|
|
529
|
+
...(tail ? { validationOutputTail: tail } : {}),
|
|
530
|
+
...(validation.iteration !== undefined ? { iteration: validation.iteration } : {}),
|
|
531
|
+
})
|
|
532
|
+
}
|
|
533
|
+
const timer = setTimeout(() => {
|
|
534
|
+
logger.warn('coding-agent(ralph): validation command timed out', { timeoutMs })
|
|
535
|
+
killChildProcess(child, undefined, logger)
|
|
536
|
+
finish(124) // conventional timeout exit code (a non-zero fail)
|
|
537
|
+
}, timeoutMs)
|
|
538
|
+
timer.unref?.()
|
|
539
|
+
const onAbort = (): void => {
|
|
540
|
+
killChildProcess(child, undefined, logger)
|
|
541
|
+
finish(130) // aborted (a non-zero fail)
|
|
542
|
+
}
|
|
543
|
+
opts.signal?.addEventListener('abort', onAbort, { once: true })
|
|
544
|
+
child.on('error', (err) => {
|
|
545
|
+
logger.warn('coding-agent(ralph): validation command failed to spawn', {
|
|
546
|
+
error: err instanceof Error ? err.message : String(err),
|
|
547
|
+
})
|
|
548
|
+
finish(127) // spawn error / command not found (a non-zero fail)
|
|
549
|
+
})
|
|
550
|
+
child.on('close', (code) => finish(code ?? 1))
|
|
551
|
+
})
|
|
552
|
+
}
|
|
553
|
+
|
|
438
554
|
/** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
|
|
439
555
|
export function safeDirSegment(value: string): string {
|
|
440
556
|
return value.replace(/[^A-Za-z0-9._-]/g, '-') || '_'
|
package/src/job.ts
CHANGED
|
@@ -153,6 +153,26 @@ function parseGuardLimits(value: unknown): GuardLimitsSpec | undefined {
|
|
|
153
153
|
return Object.keys(spec).length > 0 ? spec : undefined
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
+
/**
|
|
157
|
+
* Parse the optional Ralph-loop validation spec. Requires a non-empty `command` string (the
|
|
158
|
+
* completion criterion the harness runs); `progressPath`/`iteration` are optional metadata.
|
|
159
|
+
* Returns undefined when absent or malformed (a coding run then behaves like any other — no
|
|
160
|
+
* post-commit validation). See {@link ValidationSpec}.
|
|
161
|
+
*/
|
|
162
|
+
function parseValidationSpec(value: unknown): ValidationSpec | undefined {
|
|
163
|
+
if (typeof value !== 'object' || value === null) return undefined
|
|
164
|
+
const o = value as Record<string, unknown>
|
|
165
|
+
if (typeof o.command !== 'string' || o.command.trim() === '') return undefined
|
|
166
|
+
const iteration = posInt(o.iteration)
|
|
167
|
+
return {
|
|
168
|
+
command: o.command,
|
|
169
|
+
...(typeof o.progressPath === 'string' && o.progressPath
|
|
170
|
+
? { progressPath: o.progressPath }
|
|
171
|
+
: {}),
|
|
172
|
+
...(iteration !== undefined ? { iteration } : {}),
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
156
176
|
/**
|
|
157
177
|
* Parse the shared per-job auth fields, validating per harness: a subscription
|
|
158
178
|
* harness (`claude-code` / `codex`) requires `subscriptionToken`; the default Pi
|
|
@@ -627,6 +647,23 @@ export interface AgentOutputSpec {
|
|
|
627
647
|
* RUNNING — no agent runs and the serve is deliberately not torn down when the job returns
|
|
628
648
|
* (see {@link AgentResult.preview}).
|
|
629
649
|
*/
|
|
650
|
+
/**
|
|
651
|
+
* Coding mode (Ralph loop): the programmatic completion criterion. After the coding agent
|
|
652
|
+
* commits + pushes, the harness runs {@link command} in the checkout and reports its exit
|
|
653
|
+
* code back on {@link AgentResult.ralphVerdict} — exit 0 means the loop is done. This is the
|
|
654
|
+
* whole point of a Ralph loop's exit condition being a REAL check: the harness runs it, not
|
|
655
|
+
* the model. The command runs only inside the sandboxed run container (same trust boundary
|
|
656
|
+
* as the coding agent). Absent for every non-`ralph` coding run.
|
|
657
|
+
*/
|
|
658
|
+
export interface ValidationSpec {
|
|
659
|
+
/** The shell command the harness runs against the checkout (exit 0 = the criterion is met). */
|
|
660
|
+
command: string
|
|
661
|
+
/** Repo-relative progress-log path the agent maintains (informational; the harness doesn't write it). */
|
|
662
|
+
progressPath?: string
|
|
663
|
+
/** 1-based iteration number, echoed back on the verdict for the engine's attempt log. */
|
|
664
|
+
iteration?: number
|
|
665
|
+
}
|
|
666
|
+
|
|
630
667
|
export interface AgentJob extends HarnessAuthFields {
|
|
631
668
|
jobId: string
|
|
632
669
|
mode: AgentMode
|
|
@@ -753,6 +790,11 @@ export interface AgentJob extends HarnessAuthFields {
|
|
|
753
790
|
* killed for a kind's normal working pattern. Absent ⇒ env/default for all knobs.
|
|
754
791
|
*/
|
|
755
792
|
guardLimits?: GuardLimitsSpec
|
|
793
|
+
/**
|
|
794
|
+
* Coding mode (Ralph loop): the programmatic completion command the harness runs after the
|
|
795
|
+
* agent commits + pushes. Present only for a `ralph` iteration. See {@link ValidationSpec}.
|
|
796
|
+
*/
|
|
797
|
+
validation?: ValidationSpec
|
|
756
798
|
}
|
|
757
799
|
|
|
758
800
|
/** Per-job, per-knob progress-guard overrides (see {@link AgentJob.guardLimits}). */
|
|
@@ -809,6 +851,18 @@ export interface AgentResult {
|
|
|
809
851
|
pushed?: boolean
|
|
810
852
|
prUrl?: string
|
|
811
853
|
branch?: string
|
|
854
|
+
/**
|
|
855
|
+
* Coding mode (Ralph loop): the harness-computed verdict of the post-commit validation
|
|
856
|
+
* command — whether it exited 0, its exit code, and a bounded, redacted output tail. The
|
|
857
|
+
* engine reads this (never a model self-report) to decide whether the loop is done or must
|
|
858
|
+
* iterate again. Present only for a `ralph` iteration ({@link AgentJob.validation} set).
|
|
859
|
+
*/
|
|
860
|
+
ralphVerdict?: {
|
|
861
|
+
validationPassed: boolean
|
|
862
|
+
exitCode: number
|
|
863
|
+
validationOutputTail?: string
|
|
864
|
+
iteration?: number
|
|
865
|
+
}
|
|
812
866
|
/**
|
|
813
867
|
* Coding mode (multi-repo): the PRs opened in the connected services' PEER repos, one per
|
|
814
868
|
* repo the run actually changed (service-connections phase 3). Beside the own-service
|
|
@@ -1130,6 +1184,7 @@ export function parseAgentJob(input: unknown): AgentJob {
|
|
|
1130
1184
|
const packageRegistries = parsePackageRegistries(o.packageRegistries)
|
|
1131
1185
|
const testSecrets = parseTestSecrets(o.testSecrets)
|
|
1132
1186
|
const guardLimits = parseGuardLimits(o.guardLimits)
|
|
1187
|
+
const validation = parseValidationSpec(o.validation)
|
|
1133
1188
|
const job: AgentJob = {
|
|
1134
1189
|
jobId: str(o.jobId, 'jobId'),
|
|
1135
1190
|
mode,
|
|
@@ -1164,6 +1219,7 @@ export function parseAgentJob(input: unknown): AgentJob {
|
|
|
1164
1219
|
...(o.persistentCheckout === true ? { persistentCheckout: true } : {}),
|
|
1165
1220
|
...(o.streamFollowUps === true ? { streamFollowUps: true } : {}),
|
|
1166
1221
|
...(guardLimits ? { guardLimits } : {}),
|
|
1222
|
+
...(validation ? { validation } : {}),
|
|
1167
1223
|
}
|
|
1168
1224
|
assertAllowedHost(job.repo.cloneUrl, 'repo.cloneUrl')
|
|
1169
1225
|
if (job.githubApiBase) assertAllowedHost(job.githubApiBase, 'githubApiBase')
|