@cat-factory/executor-harness 1.43.6 → 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 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 result = job.peerRepos?.length || job.referenceRepos?.length
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
  /**
@@ -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.43.6",
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",
@@ -24,10 +24,10 @@
24
24
  "@hono/node-server": "^2.0.8",
25
25
  "@types/node": "^26.1.1",
26
26
  "hono": "^4.12.29",
27
- "typescript": "^6.0.3",
27
+ "typescript": "7.0.2",
28
28
  "vitest": "^4.1.10",
29
- "@cat-factory/server": "0.113.8",
30
- "@cat-factory/spend": "0.12.26"
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 result =
823
- job.peerRepos?.length || job.referenceRepos?.length
824
- ? await runMultiRepoCoding(job, opts)
825
- : await runSingleRepoCoding(job, opts)
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 } = await runCodingAgent(
847
- {
848
- kind: 'agent',
849
- jobId: job.jobId,
850
- repo: job.repo,
851
- cloneBranch: job.branch,
852
- ...(job.newBranch ? { newBranch: job.newBranch } : {}),
853
- pushBranch,
854
- ghToken: job.ghToken,
855
- systemPrompt: job.systemPrompt,
856
- userPrompt: job.userPrompt,
857
- model: job.model,
858
- harness: job.harness,
859
- subscriptionToken: job.subscriptionToken,
860
- subscriptionBaseUrl: job.subscriptionBaseUrl,
861
- ambientAuth: job.ambientAuth,
862
- proxyBaseUrl: job.proxyBaseUrl,
863
- sessionToken: job.sessionToken,
864
- commitMessage: job.commitMessage ?? job.pr?.title ?? 'Agent changes',
865
- webToolsGuidance: job.webToolsGuidance,
866
- webSearchProxy: job.webSearch,
867
- guardLimits: job.guardLimits,
868
- ...(job.persistentCheckout ? { persistentCheckout: true } : {}),
869
- ...(job.streamFollowUps ? { streamFollowUps: true } : {}),
870
- ...(job.referenceBranches?.length ? { referenceBranches: job.referenceBranches } : {}),
871
- },
872
- opts,
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
 
@@ -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')