@cat-factory/executor-harness 1.50.8 → 1.50.10

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
@@ -6,7 +6,7 @@ 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, fetchReferenceBranches, hasAgentChanges, headCommit, mergeBranch, openPullRequest, prepareExistingCheckout, pushBranch, reinitAndPush, unmergedPaths, } from './git.js';
9
+ import { cloneRepo, commitAll, conflictDiff, fetchPullRequestHead, fetchReferenceBranches, hasAgentChanges, headCommit, inferVcsProvider, mergeBranch, openPullRequest, prepareExistingCheckout, pushBranch, reinitAndPush, unmergedPaths, } from './git.js';
10
10
  import { makeDirClaimer, noChangesReason, runCodingAgent, runMultiRepoCoding, } from './coding-agent.js';
11
11
  import { acquireRepoCheckout, agentNeverActed, agentOutputTail, NEVER_ACTED_CAUSE, runAgentInWorkspace, unusableFinalAnswerCause, withWorkspace, } from './pi-workspace.js';
12
12
  import { diagnosticsSuffix, resolveStructuredOutput, } from './structured-output.js';
@@ -405,6 +405,28 @@ async function runExploreMode(job, opts) {
405
405
  fetched: fetched.length,
406
406
  });
407
407
  }
408
+ // The pr-reviewer reviews an EXISTING PR: fetch its HEAD into `origin/pr-head` so the
409
+ // read-only agent can inspect the PROPOSED code — files the PR adds (absent from this base
410
+ // checkout) and the head version of every modified file. The agent holds no git credential
411
+ // of its own, so this harness-side fetch (token out of band) is the only way the head is
412
+ // reachable; the prompt then diffs `origin/<base>...origin/pr-head`. Best-effort: on failure
413
+ // the review proceeds on the base checkout + the injected `.cat-context/pr-diff.md`.
414
+ if (job.reviewPrNumber !== undefined) {
415
+ const provider = job.repo.provider ?? inferVcsProvider(job.repo.cloneUrl);
416
+ const fetched = await fetchPullRequestHead({
417
+ dir,
418
+ number: job.reviewPrNumber,
419
+ provider,
420
+ ghToken: job.ghToken,
421
+ signal: opts.signal,
422
+ onSkip: (reason) => logger.warn('agent(explore): PR head fetch skipped', {
423
+ number: job.reviewPrNumber,
424
+ provider,
425
+ reason,
426
+ }),
427
+ });
428
+ logger.info('agent(explore): PR head fetch', { number: job.reviewPrNumber, fetched });
429
+ }
408
430
  // Optional infra stand-up (the tester): bring the service's docker-compose
409
431
  // dependencies up at the repo root for the duration of the run, tearing them down in
410
432
  // the `finally`. A stand-up failure is non-fatal — it's surfaced to the agent as a
package/dist/git.js CHANGED
@@ -691,6 +691,46 @@ export async function fetchReferenceBranches(opts) {
691
691
  await excludeFromGit(dir, `${REFERENCE_WORKTREE_DIR}/`, signal);
692
692
  return fetched;
693
693
  }
694
+ /** The local tracking ref a fetched PR/MR head lands on, so the reviewer reads `origin/pr-head`. */
695
+ export const PR_HEAD_REF = 'refs/remotes/origin/pr-head';
696
+ /**
697
+ * The `git fetch` refspec that maps a PR/MR's server-side HEAD ref onto {@link PR_HEAD_REF}. A
698
+ * PR head is a synthetic ref the host maintains, NOT part of a normal clone: GitHub exposes it at
699
+ * `refs/pull/<n>/head`, GitLab at `refs/merge-requests/<n>/head`. Pure so the provider branch is
700
+ * unit-tested without a network. The leading `+` forces the update (the ref is read-only here).
701
+ */
702
+ export function pullHeadRefspec(number, provider) {
703
+ const src = provider === 'gitlab' ? `refs/merge-requests/${number}/head` : `refs/pull/${number}/head`;
704
+ return `+${src}:${PR_HEAD_REF}`;
705
+ }
706
+ /**
707
+ * Fetch the reviewed PR/MR's HEAD into {@link PR_HEAD_REF} so a read-only reviewer can inspect the
708
+ * PROPOSED code — files the PR adds (absent from the base checkout) and the head version of every
709
+ * modified file — with `git diff origin/<base>...origin/pr-head`, `git show origin/pr-head:<path>`.
710
+ * The base clone never includes the pull ref, and the container agent holds no git credential of
711
+ * its own (the token lives with the harness), so the agent's own `git fetch pull/<n>/head` fails
712
+ * on a private repo — this harness-side fetch (which carries the token out of band via GIT_ASKPASS,
713
+ * exactly like {@link fetchReferenceBranches}) is what actually makes the head reachable.
714
+ *
715
+ * Best-effort: a fetch failure (a closed/deleted PR, a host without the pull ref, a transient
716
+ * network error) is reported via `onSkip` and swallowed — the review then proceeds on the base
717
+ * checkout + the injected diff, never fails. Returns whether the head was fetched.
718
+ */
719
+ export async function fetchPullRequestHead(opts) {
720
+ const { dir, number, provider, ghToken, signal, onSkip } = opts;
721
+ try {
722
+ await git(['fetch', '--no-tags', 'origin', pullHeadRefspec(number, provider)], {
723
+ cwd: dir,
724
+ signal,
725
+ env: await authEnv(ghToken),
726
+ });
727
+ return true;
728
+ }
729
+ catch (err) {
730
+ onSkip?.(err instanceof Error ? err.message : String(err));
731
+ return false;
732
+ }
733
+ }
694
734
  /**
695
735
  * Push the work branch to origin. The remote URL carries only the username, so
696
736
  * the token is supplied here via the askpass env (never in argv).
package/dist/job.js CHANGED
@@ -684,6 +684,7 @@ export function parseAgentJob(input) {
684
684
  const testSecrets = parseTestSecrets(o.testSecrets);
685
685
  const guardLimits = parseGuardLimits(o.guardLimits);
686
686
  const validation = parseValidationSpec(o.validation);
687
+ const reviewPrNumber = posInt(o.reviewPrNumber);
687
688
  const job = {
688
689
  jobId: str(o.jobId, 'jobId'),
689
690
  mode,
@@ -715,6 +716,7 @@ export function parseAgentJob(input) {
715
716
  ...(peerRepos.length ? { peerRepos } : {}),
716
717
  ...(referenceRepos.length ? { referenceRepos } : {}),
717
718
  ...(referenceBranches.length ? { referenceBranches } : {}),
719
+ ...(reviewPrNumber !== undefined ? { reviewPrNumber } : {}),
718
720
  ...(o.noChangesIsError === false ? { noChangesIsError: false } : {}),
719
721
  ...(o.persistentCheckout === true ? { persistentCheckout: true } : {}),
720
722
  ...(o.streamFollowUps === true ? { streamFollowUps: true } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.50.8",
3
+ "version": "1.50.10",
4
4
  "description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,8 +26,8 @@
26
26
  "hono": "^4.12.30",
27
27
  "typescript": "7.0.2",
28
28
  "vitest": "^4.1.10",
29
- "@cat-factory/server": "0.140.2",
30
- "@cat-factory/spend": "0.12.68"
29
+ "@cat-factory/server": "0.140.4",
30
+ "@cat-factory/spend": "0.12.69"
31
31
  },
32
32
  "scripts": {
33
33
  "build": "tsc -p tsconfig.json",
package/src/agent.ts CHANGED
@@ -18,9 +18,11 @@ import {
18
18
  cloneRepo,
19
19
  commitAll,
20
20
  conflictDiff,
21
+ fetchPullRequestHead,
21
22
  fetchReferenceBranches,
22
23
  hasAgentChanges,
23
24
  headCommit,
25
+ inferVcsProvider,
24
26
  mergeBranch,
25
27
  openPullRequest,
26
28
  prepareExistingCheckout,
@@ -493,6 +495,30 @@ async function runExploreMode(job: AgentJob, opts: RunOptions): Promise<AgentRes
493
495
  })
494
496
  }
495
497
 
498
+ // The pr-reviewer reviews an EXISTING PR: fetch its HEAD into `origin/pr-head` so the
499
+ // read-only agent can inspect the PROPOSED code — files the PR adds (absent from this base
500
+ // checkout) and the head version of every modified file. The agent holds no git credential
501
+ // of its own, so this harness-side fetch (token out of band) is the only way the head is
502
+ // reachable; the prompt then diffs `origin/<base>...origin/pr-head`. Best-effort: on failure
503
+ // the review proceeds on the base checkout + the injected `.cat-context/pr-diff.md`.
504
+ if (job.reviewPrNumber !== undefined) {
505
+ const provider = job.repo.provider ?? inferVcsProvider(job.repo.cloneUrl)
506
+ const fetched = await fetchPullRequestHead({
507
+ dir,
508
+ number: job.reviewPrNumber,
509
+ provider,
510
+ ghToken: job.ghToken,
511
+ signal: opts.signal,
512
+ onSkip: (reason) =>
513
+ logger.warn('agent(explore): PR head fetch skipped', {
514
+ number: job.reviewPrNumber,
515
+ provider,
516
+ reason,
517
+ }),
518
+ })
519
+ logger.info('agent(explore): PR head fetch', { number: job.reviewPrNumber, fetched })
520
+ }
521
+
496
522
  // Optional infra stand-up (the tester): bring the service's docker-compose
497
523
  // dependencies up at the repo root for the duration of the run, tearing them down in
498
524
  // the `finally`. A stand-up failure is non-fatal — it's surfaced to the agent as a
package/src/git.ts CHANGED
@@ -862,6 +862,57 @@ export async function fetchReferenceBranches(opts: {
862
862
  return fetched
863
863
  }
864
864
 
865
+ /** The local tracking ref a fetched PR/MR head lands on, so the reviewer reads `origin/pr-head`. */
866
+ export const PR_HEAD_REF = 'refs/remotes/origin/pr-head'
867
+
868
+ /**
869
+ * The `git fetch` refspec that maps a PR/MR's server-side HEAD ref onto {@link PR_HEAD_REF}. A
870
+ * PR head is a synthetic ref the host maintains, NOT part of a normal clone: GitHub exposes it at
871
+ * `refs/pull/<n>/head`, GitLab at `refs/merge-requests/<n>/head`. Pure so the provider branch is
872
+ * unit-tested without a network. The leading `+` forces the update (the ref is read-only here).
873
+ */
874
+ export function pullHeadRefspec(number: number, provider: 'github' | 'gitlab'): string {
875
+ const src =
876
+ provider === 'gitlab' ? `refs/merge-requests/${number}/head` : `refs/pull/${number}/head`
877
+ return `+${src}:${PR_HEAD_REF}`
878
+ }
879
+
880
+ /**
881
+ * Fetch the reviewed PR/MR's HEAD into {@link PR_HEAD_REF} so a read-only reviewer can inspect the
882
+ * PROPOSED code — files the PR adds (absent from the base checkout) and the head version of every
883
+ * modified file — with `git diff origin/<base>...origin/pr-head`, `git show origin/pr-head:<path>`.
884
+ * The base clone never includes the pull ref, and the container agent holds no git credential of
885
+ * its own (the token lives with the harness), so the agent's own `git fetch pull/<n>/head` fails
886
+ * on a private repo — this harness-side fetch (which carries the token out of band via GIT_ASKPASS,
887
+ * exactly like {@link fetchReferenceBranches}) is what actually makes the head reachable.
888
+ *
889
+ * Best-effort: a fetch failure (a closed/deleted PR, a host without the pull ref, a transient
890
+ * network error) is reported via `onSkip` and swallowed — the review then proceeds on the base
891
+ * checkout + the injected diff, never fails. Returns whether the head was fetched.
892
+ */
893
+ export async function fetchPullRequestHead(opts: {
894
+ dir: string
895
+ number: number
896
+ provider: 'github' | 'gitlab'
897
+ ghToken: string
898
+ signal?: AbortSignal
899
+ /** Called when the fetch failed, so the caller (which owns a logger) can warn. */
900
+ onSkip?: (reason: string) => void
901
+ }): Promise<boolean> {
902
+ const { dir, number, provider, ghToken, signal, onSkip } = opts
903
+ try {
904
+ await git(['fetch', '--no-tags', 'origin', pullHeadRefspec(number, provider)], {
905
+ cwd: dir,
906
+ signal,
907
+ env: await authEnv(ghToken),
908
+ })
909
+ return true
910
+ } catch (err) {
911
+ onSkip?.(err instanceof Error ? err.message : String(err))
912
+ return false
913
+ }
914
+ }
915
+
865
916
  /**
866
917
  * Push the work branch to origin. The remote URL carries only the username, so
867
918
  * the token is supplied here via the askpass env (never in argv).
package/src/job.ts CHANGED
@@ -784,6 +784,15 @@ export interface AgentJob extends HarnessAuthFields {
784
784
  * the same primary repo. Absent ⇒ none. Consumed by the coding + explore flows.
785
785
  */
786
786
  referenceBranches?: string[]
787
+ /**
788
+ * Explore mode (the `pr-reviewer`): the reviewed PR/MR number. Present ⇒ after the base
789
+ * checkout the harness fetches that PR's HEAD into `origin/pr-head` (best-effort) so the
790
+ * read-only reviewer can diff/read the PROPOSED code — files the PR adds are otherwise absent
791
+ * from the base checkout, and the agent has no git credential to fetch the head itself. The
792
+ * GitHub-vs-GitLab pull ref is chosen from `repo.provider` (host-inferred when absent). Absent
793
+ * ⇒ no head fetch (every non-review run). See {@link file://./git.ts} `fetchPullRequestHead`.
794
+ */
795
+ reviewPrNumber?: number
787
796
  /**
788
797
  * Coding mode: whether a no-op run (nothing changed) is a failure. The implementer
789
798
  * fails on a no-op; the in-place fixers (ci-fix / fix-tests) treat it as a non-fatal
@@ -1277,6 +1286,7 @@ export function parseAgentJob(input: unknown): AgentJob {
1277
1286
  const testSecrets = parseTestSecrets(o.testSecrets)
1278
1287
  const guardLimits = parseGuardLimits(o.guardLimits)
1279
1288
  const validation = parseValidationSpec(o.validation)
1289
+ const reviewPrNumber = posInt(o.reviewPrNumber)
1280
1290
  const job: AgentJob = {
1281
1291
  jobId: str(o.jobId, 'jobId'),
1282
1292
  mode,
@@ -1308,6 +1318,7 @@ export function parseAgentJob(input: unknown): AgentJob {
1308
1318
  ...(peerRepos.length ? { peerRepos } : {}),
1309
1319
  ...(referenceRepos.length ? { referenceRepos } : {}),
1310
1320
  ...(referenceBranches.length ? { referenceBranches } : {}),
1321
+ ...(reviewPrNumber !== undefined ? { reviewPrNumber } : {}),
1311
1322
  ...(o.noChangesIsError === false ? { noChangesIsError: false } : {}),
1312
1323
  ...(o.persistentCheckout === true ? { persistentCheckout: true } : {}),
1313
1324
  ...(o.streamFollowUps === true ? { streamFollowUps: true } : {}),