@cat-factory/executor-harness 1.39.3 → 1.43.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
@@ -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, hasAgentChanges, headCommit, mergeBranch, openPullRequest, prepareExistingCheckout, pushBranch, reinitAndPush, unmergedPaths, } from './git.js';
9
+ import { cloneRepo, commitAll, conflictDiff, fetchReferenceBranches, hasAgentChanges, headCommit, 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';
@@ -389,6 +389,22 @@ async function runExploreMode(job, opts) {
389
389
  logger.info('agent(explore): cloning');
390
390
  workDir = await cloneServiceCheckout(dir, job, opts.signal);
391
391
  }
392
+ // Fetch any read-only reference branches into `origin/<b>` so a read-only agent (architect /
393
+ // analysis / spec-writer) can inspect a prior-art branch without git network credentials of
394
+ // its own. Best-effort per branch; the run makes no commits regardless.
395
+ if (job.referenceBranches?.length) {
396
+ const fetched = await fetchReferenceBranches({
397
+ dir,
398
+ branches: job.referenceBranches,
399
+ ghToken: job.ghToken,
400
+ signal: opts.signal,
401
+ onSkip: (branch, reason) => logger.warn('agent(explore): reference branch fetch skipped', { branch, reason }),
402
+ });
403
+ logger.info('agent(explore): fetched reference branches', {
404
+ requested: job.referenceBranches.length,
405
+ fetched: fetched.length,
406
+ });
407
+ }
392
408
  // Optional infra stand-up (the tester): bring the service's docker-compose
393
409
  // dependencies up at the repo root for the duration of the run, tearing them down in
394
410
  // the `finally`. A stand-up failure is non-fatal — it's surfaced to the agent as a
@@ -589,6 +605,24 @@ async function runMultiRepoExplore(job, opts) {
589
605
  signal: opts.signal,
590
606
  });
591
607
  }));
608
+ // Reference branches attach to the PRIMARY repo (the first leg): fetch them into its sibling
609
+ // checkout's `origin/<b>` refs so the agent can read a prior-art branch. Best-effort per branch.
610
+ if (job.referenceBranches?.length) {
611
+ const primary = legs[0];
612
+ if (primary) {
613
+ const fetched = await fetchReferenceBranches({
614
+ dir: join(root, primary.dirName),
615
+ branches: job.referenceBranches,
616
+ ghToken: primary.ghToken,
617
+ signal: opts.signal,
618
+ onSkip: (branch, reason) => logger.warn('multi-repo-explore: reference branch fetch skipped', { branch, reason }),
619
+ });
620
+ logger.info('multi-repo-explore: fetched reference branches', {
621
+ requested: job.referenceBranches.length,
622
+ fetched: fetched.length,
623
+ });
624
+ }
625
+ }
592
626
  opts.onPhase?.('agent');
593
627
  logger.info('multi-repo-explore: running agent', { repos: legs.map((l) => l.dirName) });
594
628
  const run = await runAgentInWorkspace({
@@ -689,6 +723,7 @@ async function runSingleRepoCoding(job, opts) {
689
723
  guardLimits: job.guardLimits,
690
724
  ...(job.persistentCheckout ? { persistentCheckout: true } : {}),
691
725
  ...(job.streamFollowUps ? { streamFollowUps: true } : {}),
726
+ ...(job.referenceBranches?.length ? { referenceBranches: job.referenceBranches } : {}),
692
727
  }, opts);
693
728
  if (!pushed) {
694
729
  // A no-op: a failure for the implementer, a clean non-event for the fixers.
@@ -1,6 +1,6 @@
1
1
  import { mkdir } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
- import { branchAheadOfBase, branchHasCommitsSince, cloneExistingBranch, cloneRepo, commitTrackedEdits, createBranch, excludeFromGit, headCommit, listUntrackedFiles, openPullRequest, prepareExistingCheckout, pushBranch, refreshFromBaseIfClean, remoteBranchExists, } from './git.js';
3
+ import { branchAheadOfBase, branchHasCommitsSince, cloneExistingBranch, cloneRepo, commitTrackedEdits, createBranch, excludeFromGit, fetchReferenceBranches, headCommit, listUntrackedFiles, openPullRequest, prepareExistingCheckout, pushBranch, refreshFromBaseIfClean, remoteBranchExists, } from './git.js';
4
4
  import { FOLLOW_UPS_FILENAME, FollowUpTailer } from './follow-ups.js';
5
5
  import { acquireRepoCheckout, agentNeverActed, agentOutputTail, runAgentInWorkspace, withWorkspace, } from './pi-workspace.js';
6
6
  import { log } from './logger.js';
@@ -99,6 +99,23 @@ export async function runCodingAgent(spec, opts = {}) {
99
99
  if (spec.newBranch)
100
100
  await createBranch(dir, spec.newBranch, signal);
101
101
  }
102
+ // Fetch any read-only reference branches into their `origin/<b>` refs so the agent can
103
+ // inspect them (log/diff/show) without git network credentials of its own. Best-effort per
104
+ // branch: a vanished branch is warned + skipped, never fatal. The work branch above is the
105
+ // agent's HEAD; these are only readable siblings it never commits to.
106
+ if (spec.referenceBranches?.length) {
107
+ const fetched = await fetchReferenceBranches({
108
+ dir,
109
+ branches: spec.referenceBranches,
110
+ ghToken: spec.ghToken,
111
+ signal,
112
+ onSkip: (branch, reason) => logger.warn('coding-agent: reference branch fetch skipped', { branch, reason }),
113
+ });
114
+ logger.info('coding-agent: fetched reference branches', {
115
+ requested: spec.referenceBranches.length,
116
+ fetched: fetched.length,
117
+ });
118
+ }
102
119
  // The branch tip before the agent runs this time. A FRESH run produced work iff
103
120
  // the branch advances past it; a RESUMED run already carries prior work, so it is
104
121
  // never a no-op regardless of what this pass adds. Captured BEFORE the resume base
@@ -443,6 +460,25 @@ export async function runMultiRepoCoding(job, opts = {}) {
443
460
  }
444
461
  }
445
462
  }
463
+ // Reference branches attach to the PRIMARY repo, so fetch them into the primary sibling
464
+ // checkout's `origin/<b>` refs (best-effort per branch). The backend's reference-branches
465
+ // prompt section names the primary repo's directory to run the read commands in.
466
+ if (job.referenceBranches?.length) {
467
+ const primaryLeg = legs.find((l) => l.primary);
468
+ if (primaryLeg?.dir) {
469
+ const fetched = await fetchReferenceBranches({
470
+ dir: primaryLeg.dir,
471
+ branches: job.referenceBranches,
472
+ ghToken: primaryLeg.ghToken,
473
+ signal,
474
+ onSkip: (branch, reason) => logger.warn('multi-repo: reference branch fetch skipped', { branch, reason }),
475
+ });
476
+ logger.info('multi-repo: fetched reference branches', {
477
+ requested: job.referenceBranches.length,
478
+ fetched: fetched.length,
479
+ });
480
+ }
481
+ }
446
482
  // Run the agent ONCE with its cwd at the workspace root, so it sees every sibling checkout
447
483
  // and can change them coherently. No monorepo/service-directory scoping — the multi-repo
448
484
  // note + the backend system-prompt section explain the layout.
package/dist/git.js CHANGED
@@ -567,6 +567,50 @@ export async function refreshFromBaseIfClean(dir, baseBranch, ghToken, signal) {
567
567
  throw err;
568
568
  }
569
569
  }
570
+ /**
571
+ * The directory the reference-branches prompt section suggests for a `git worktree add` checkout of
572
+ * a reference branch alongside the agent's own work. Excluded from the checkout (below) so the
573
+ * embedded worktree can never be staged into the agent's PR — mirrors the `.cat-context/` treatment
574
+ * in {@link file://./pi.ts}. Kept in step with the same literal in the backend's
575
+ * `renderReferenceBranchesSection` (a separate package, so a shared constant isn't feasible).
576
+ */
577
+ export const REFERENCE_WORKTREE_DIR = '.cat-reference';
578
+ /**
579
+ * Fetch pre-existing REFERENCE branches into their `origin/<b>` tracking refs, so the agent can
580
+ * inspect them read-only (`git log origin/<b>`, two-dot `git diff origin/<b>`,
581
+ * `git show origin/<b>:<path>`) without any git network credentials of its own. The primary
582
+ * checkout is a shallow single-branch clone, so these refs aren't present until fetched — and the
583
+ * harness (which holds the per-job token) is the only place that can reach the remote. Uses an
584
+ * explicit destination refspec (`+refs/heads/<b>:refs/remotes/origin/<b>`) and `--no-tags` so a
585
+ * reference branch's tags don't pollute the checkout. Best-effort PER branch: a fetch failure (a
586
+ * branch deleted since dispatch, a transient network error) is reported via `onSkip` and skipped,
587
+ * never fatal — a reference branch is context, not the run's starting point (contrast the WORKING
588
+ * branch, whose absence fails the dispatch loudly). Returns the branch names that fetched cleanly.
589
+ *
590
+ * On any successful fetch it locally excludes {@link REFERENCE_WORKTREE_DIR} from this checkout, so
591
+ * if the agent follows the prompt's suggested `git worktree add .cat-reference/<b>` a broad
592
+ * `git add -A` can never embed that worktree as a stray gitlink in the run's PR.
593
+ */
594
+ export async function fetchReferenceBranches(opts) {
595
+ const { dir, branches, ghToken, signal, onSkip } = opts;
596
+ if (branches.length === 0)
597
+ return [];
598
+ const env = await authEnv(ghToken);
599
+ const fetched = [];
600
+ for (const branch of branches) {
601
+ try {
602
+ await git(['fetch', '--no-tags', 'origin', `+refs/heads/${branch}:refs/remotes/origin/${branch}`], { cwd: dir, signal, env });
603
+ fetched.push(branch);
604
+ }
605
+ catch (err) {
606
+ onSkip?.(branch, err instanceof Error ? err.message : String(err));
607
+ }
608
+ }
609
+ // Keep the suggested reference-worktree dir out of the agent's commits (best-effort, per-clone).
610
+ if (fetched.length > 0)
611
+ await excludeFromGit(dir, `${REFERENCE_WORKTREE_DIR}/`, signal);
612
+ return fetched;
613
+ }
570
614
  /**
571
615
  * Push the work branch to origin. The remote URL carries only the username, so
572
616
  * the token is supplied here via the askpass env (never in argv).
package/dist/job.js CHANGED
@@ -178,6 +178,21 @@ function parseReferenceRepos(value) {
178
178
  return spec;
179
179
  });
180
180
  }
181
+ /**
182
+ * Parse the optional `referenceBranches` list: pre-existing branch NAMES of the PRIMARY repo the
183
+ * agent may read but never commit to (the apriori-branches reference mode). Unlike
184
+ * {@link parseReferenceRepos} these are not sibling repos — they are fetched into the primary
185
+ * checkout's `origin/<b>` refs (see {@link fetchReferenceBranches}). A non-string / empty entry is
186
+ * dropped; an absent list yields `[]`. Kept simple (no git-ref grammar check) because the backend
187
+ * validated the name at the write boundary and probed its existence at dispatch.
188
+ */
189
+ function parseReferenceBranches(value) {
190
+ if (value === undefined || value === null)
191
+ return [];
192
+ if (!Array.isArray(value))
193
+ throw new Error("Invalid job: 'referenceBranches' must be an array");
194
+ return value.filter((v) => typeof v === 'string' && v.length > 0);
195
+ }
181
196
  /** Parse the optional `repo.provider` discriminator (defaults to undefined ⇒ host inference). */
182
197
  function parseVcsProvider(value) {
183
198
  if (value === undefined || value === null)
@@ -568,6 +583,7 @@ export function parseAgentJob(input) {
568
583
  const infra = parseAgentInfraSpec(o.infra);
569
584
  const peerRepos = parsePeerRepos(o.peerRepos);
570
585
  const referenceRepos = parseReferenceRepos(o.referenceRepos);
586
+ const referenceBranches = parseReferenceBranches(o.referenceBranches);
571
587
  const bootstrap = parseAgentBootstrapSpec(o.bootstrap);
572
588
  const contextFiles = parseContextFiles(o.contextFiles);
573
589
  const packageRegistries = parsePackageRegistries(o.packageRegistries);
@@ -602,6 +618,7 @@ export function parseAgentJob(input) {
602
618
  ...(pr ? { pr } : {}),
603
619
  ...(peerRepos.length ? { peerRepos } : {}),
604
620
  ...(referenceRepos.length ? { referenceRepos } : {}),
621
+ ...(referenceBranches.length ? { referenceBranches } : {}),
605
622
  ...(o.noChangesIsError === false ? { noChangesIsError: false } : {}),
606
623
  ...(o.persistentCheckout === true ? { persistentCheckout: true } : {}),
607
624
  ...(o.streamFollowUps === true ? { streamFollowUps: true } : {}),
package/dist/server.js CHANGED
@@ -6,6 +6,7 @@ import { handleInline } from './inline.js';
6
6
  import { redactSecrets } from './git.js';
7
7
  import { JobRegistry, loadRunnerLimits } from './runner.js';
8
8
  import { log } from './logger.js';
9
+ import { HARNESS_VERSION } from './version.js';
9
10
  // The container's HTTP entry point. The Worker addresses one instance per run and
10
11
  // POSTs a job to /jobs (the body's `kind` selects which agent runs); the harness
11
12
  // starts that job in the background (bounded by an inactivity + max-duration
@@ -90,7 +91,14 @@ function send(res, status, body) {
90
91
  const server = createServer((req, res) => {
91
92
  void (async () => {
92
93
  if (req.method === 'GET' && req.url === '/health') {
93
- return send(res, 200, { status: 'ok' });
94
+ // Report the harness version so a backend can detect a stale/mismatched executor and
95
+ // fail loudly early (see version.ts). Unauthenticated like the rest of /health — the
96
+ // version is not a secret. An old image predating this field simply omits it, which the
97
+ // backend treats as a stale signal.
98
+ return send(res, 200, {
99
+ status: 'ok',
100
+ ...(HARNESS_VERSION ? { version: HARNESS_VERSION } : {}),
101
+ });
94
102
  }
95
103
  // All non-health endpoints are gated by the optional shared secret.
96
104
  if (!authorized(req)) {
@@ -0,0 +1,44 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ // The harness's OWN version, resolved once at module load and reported on `/health`. A
5
+ // backend reads it back over the health handshake to detect a STALE or MISMATCHED executor
6
+ // (an old image left behind by a mutable tag, an outdated native install) and fail loudly
7
+ // and early — instead of the cryptic downstream symptom a version skew otherwise produces
8
+ // (e.g. a since-removed git flag reappearing and breaking every authenticated clone/push).
9
+ //
10
+ // Resolution order, most authoritative first:
11
+ // 1. `HARNESS_VERSION` env — the Docker image can bake it; an operator can override.
12
+ // 2. the version file the image writes next to `dist/` (the image deliberately ships NO
13
+ // package.json, so it captures the version into `harness-version.txt` at build time).
14
+ // 3. `package.json` — the native/npm install and a source checkout both keep it beside
15
+ // `dist/`, so `dist/version.js` finds it one level up.
16
+ // Undefined only for an oddly-assembled runtime carrying none of the three; the backend then
17
+ // treats "no reported version" as a strong stale signal in its own right.
18
+ function readVersionFile(dir, rel) {
19
+ try {
20
+ const raw = readFileSync(join(dir, rel), 'utf8');
21
+ const value = rel.endsWith('.json') ? JSON.parse(raw).version : raw;
22
+ const trimmed = value?.trim();
23
+ return trimmed || undefined;
24
+ }
25
+ catch {
26
+ return undefined;
27
+ }
28
+ }
29
+ function resolveHarnessVersion() {
30
+ const fromEnv = process.env.HARNESS_VERSION?.trim();
31
+ if (fromEnv)
32
+ return fromEnv;
33
+ let dir;
34
+ try {
35
+ // Compiled to `dist/version.js`; the baked file / package.json sit one level up from dist.
36
+ dir = dirname(fileURLToPath(import.meta.url));
37
+ }
38
+ catch {
39
+ return undefined;
40
+ }
41
+ return readVersionFile(dir, '../harness-version.txt') ?? readVersionFile(dir, '../package.json');
42
+ }
43
+ /** The running harness version, or undefined when it cannot be determined. */
44
+ export const HARNESS_VERSION = resolveHarnessVersion();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.39.3",
3
+ "version": "1.43.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.27",
27
27
  "typescript": "^6.0.3",
28
28
  "vitest": "^4.1.9",
29
- "@cat-factory/server": "0.106.0",
30
- "@cat-factory/spend": "0.12.0"
29
+ "@cat-factory/server": "0.108.0",
30
+ "@cat-factory/spend": "0.12.10"
31
31
  },
32
32
  "scripts": {
33
33
  "build": "tsc -p tsconfig.json",
package/src/agent.ts CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  cloneRepo,
19
19
  commitAll,
20
20
  conflictDiff,
21
+ fetchReferenceBranches,
21
22
  hasAgentChanges,
22
23
  headCommit,
23
24
  mergeBranch,
@@ -474,6 +475,24 @@ async function runExploreMode(job: AgentJob, opts: RunOptions): Promise<AgentRes
474
475
  workDir = await cloneServiceCheckout(dir, job, opts.signal)
475
476
  }
476
477
 
478
+ // Fetch any read-only reference branches into `origin/<b>` so a read-only agent (architect /
479
+ // analysis / spec-writer) can inspect a prior-art branch without git network credentials of
480
+ // its own. Best-effort per branch; the run makes no commits regardless.
481
+ if (job.referenceBranches?.length) {
482
+ const fetched = await fetchReferenceBranches({
483
+ dir,
484
+ branches: job.referenceBranches,
485
+ ghToken: job.ghToken,
486
+ signal: opts.signal,
487
+ onSkip: (branch, reason) =>
488
+ logger.warn('agent(explore): reference branch fetch skipped', { branch, reason }),
489
+ })
490
+ logger.info('agent(explore): fetched reference branches', {
491
+ requested: job.referenceBranches.length,
492
+ fetched: fetched.length,
493
+ })
494
+ }
495
+
477
496
  // Optional infra stand-up (the tester): bring the service's docker-compose
478
497
  // dependencies up at the repo root for the duration of the run, tearing them down in
479
498
  // the `finally`. A stand-up failure is non-fatal — it's surfaced to the agent as a
@@ -719,6 +738,26 @@ async function runMultiRepoExplore(job: AgentJob, opts: RunOptions): Promise<Age
719
738
  }),
720
739
  )
721
740
 
741
+ // Reference branches attach to the PRIMARY repo (the first leg): fetch them into its sibling
742
+ // checkout's `origin/<b>` refs so the agent can read a prior-art branch. Best-effort per branch.
743
+ if (job.referenceBranches?.length) {
744
+ const primary = legs[0]
745
+ if (primary) {
746
+ const fetched = await fetchReferenceBranches({
747
+ dir: join(root, primary.dirName),
748
+ branches: job.referenceBranches,
749
+ ghToken: primary.ghToken,
750
+ signal: opts.signal,
751
+ onSkip: (branch, reason) =>
752
+ logger.warn('multi-repo-explore: reference branch fetch skipped', { branch, reason }),
753
+ })
754
+ logger.info('multi-repo-explore: fetched reference branches', {
755
+ requested: job.referenceBranches.length,
756
+ fetched: fetched.length,
757
+ })
758
+ }
759
+ }
760
+
722
761
  opts.onPhase?.('agent')
723
762
  logger.info('multi-repo-explore: running agent', { repos: legs.map((l) => l.dirName) })
724
763
  const run = await runAgentInWorkspace(
@@ -828,6 +867,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
828
867
  guardLimits: job.guardLimits,
829
868
  ...(job.persistentCheckout ? { persistentCheckout: true } : {}),
830
869
  ...(job.streamFollowUps ? { streamFollowUps: true } : {}),
870
+ ...(job.referenceBranches?.length ? { referenceBranches: job.referenceBranches } : {}),
831
871
  },
832
872
  opts,
833
873
  )
@@ -16,6 +16,7 @@ import {
16
16
  commitTrackedEdits,
17
17
  createBranch,
18
18
  excludeFromGit,
19
+ fetchReferenceBranches,
19
20
  headCommit,
20
21
  listUntrackedFiles,
21
22
  openPullRequest,
@@ -85,6 +86,12 @@ export interface CodingAgentSpec extends HarnessAuthFields {
85
86
  * only for the implementer (`coder`) dispatch; absent ⇒ no tailing (e.g. the CI-fixer).
86
87
  */
87
88
  streamFollowUps?: boolean
89
+ /**
90
+ * READ-ONLY reference branches of THIS repo (the apriori-branches reference mode): fetched
91
+ * into `origin/<b>` after the checkout so the agent can inspect them but never commits to
92
+ * them. Best-effort per branch. Absent/empty ⇒ none fetched.
93
+ */
94
+ referenceBranches?: string[]
88
95
  }
89
96
 
90
97
  /** The outcome of a coding agent run, before each caller maps it to its own result shape. */
@@ -202,6 +209,26 @@ export async function runCodingAgent(
202
209
  })
203
210
  if (spec.newBranch) await createBranch(dir, spec.newBranch, signal)
204
211
  }
212
+
213
+ // Fetch any read-only reference branches into their `origin/<b>` refs so the agent can
214
+ // inspect them (log/diff/show) without git network credentials of its own. Best-effort per
215
+ // branch: a vanished branch is warned + skipped, never fatal. The work branch above is the
216
+ // agent's HEAD; these are only readable siblings it never commits to.
217
+ if (spec.referenceBranches?.length) {
218
+ const fetched = await fetchReferenceBranches({
219
+ dir,
220
+ branches: spec.referenceBranches,
221
+ ghToken: spec.ghToken,
222
+ signal,
223
+ onSkip: (branch, reason) =>
224
+ logger.warn('coding-agent: reference branch fetch skipped', { branch, reason }),
225
+ })
226
+ logger.info('coding-agent: fetched reference branches', {
227
+ requested: spec.referenceBranches.length,
228
+ fetched: fetched.length,
229
+ })
230
+ }
231
+
205
232
  // The branch tip before the agent runs this time. A FRESH run produced work iff
206
233
  // the branch advances past it; a RESUMED run already carries prior work, so it is
207
234
  // never a no-op regardless of what this pass adds. Captured BEFORE the resume base
@@ -602,6 +629,27 @@ export async function runMultiRepoCoding(
602
629
  }
603
630
  }
604
631
 
632
+ // Reference branches attach to the PRIMARY repo, so fetch them into the primary sibling
633
+ // checkout's `origin/<b>` refs (best-effort per branch). The backend's reference-branches
634
+ // prompt section names the primary repo's directory to run the read commands in.
635
+ if (job.referenceBranches?.length) {
636
+ const primaryLeg = legs.find((l) => l.primary)
637
+ if (primaryLeg?.dir) {
638
+ const fetched = await fetchReferenceBranches({
639
+ dir: primaryLeg.dir,
640
+ branches: job.referenceBranches,
641
+ ghToken: primaryLeg.ghToken,
642
+ signal,
643
+ onSkip: (branch, reason) =>
644
+ logger.warn('multi-repo: reference branch fetch skipped', { branch, reason }),
645
+ })
646
+ logger.info('multi-repo: fetched reference branches', {
647
+ requested: job.referenceBranches.length,
648
+ fetched: fetched.length,
649
+ })
650
+ }
651
+ }
652
+
605
653
  // Run the agent ONCE with its cwd at the workspace root, so it sees every sibling checkout
606
654
  // and can change them coherently. No monorepo/service-directory scoping — the multi-repo
607
655
  // note + the backend system-prompt section explain the layout.
package/src/git.ts CHANGED
@@ -697,6 +697,59 @@ export async function refreshFromBaseIfClean(
697
697
  }
698
698
  }
699
699
 
700
+ /**
701
+ * The directory the reference-branches prompt section suggests for a `git worktree add` checkout of
702
+ * a reference branch alongside the agent's own work. Excluded from the checkout (below) so the
703
+ * embedded worktree can never be staged into the agent's PR — mirrors the `.cat-context/` treatment
704
+ * in {@link file://./pi.ts}. Kept in step with the same literal in the backend's
705
+ * `renderReferenceBranchesSection` (a separate package, so a shared constant isn't feasible).
706
+ */
707
+ export const REFERENCE_WORKTREE_DIR = '.cat-reference'
708
+
709
+ /**
710
+ * Fetch pre-existing REFERENCE branches into their `origin/<b>` tracking refs, so the agent can
711
+ * inspect them read-only (`git log origin/<b>`, two-dot `git diff origin/<b>`,
712
+ * `git show origin/<b>:<path>`) without any git network credentials of its own. The primary
713
+ * checkout is a shallow single-branch clone, so these refs aren't present until fetched — and the
714
+ * harness (which holds the per-job token) is the only place that can reach the remote. Uses an
715
+ * explicit destination refspec (`+refs/heads/<b>:refs/remotes/origin/<b>`) and `--no-tags` so a
716
+ * reference branch's tags don't pollute the checkout. Best-effort PER branch: a fetch failure (a
717
+ * branch deleted since dispatch, a transient network error) is reported via `onSkip` and skipped,
718
+ * never fatal — a reference branch is context, not the run's starting point (contrast the WORKING
719
+ * branch, whose absence fails the dispatch loudly). Returns the branch names that fetched cleanly.
720
+ *
721
+ * On any successful fetch it locally excludes {@link REFERENCE_WORKTREE_DIR} from this checkout, so
722
+ * if the agent follows the prompt's suggested `git worktree add .cat-reference/<b>` a broad
723
+ * `git add -A` can never embed that worktree as a stray gitlink in the run's PR.
724
+ */
725
+ export async function fetchReferenceBranches(opts: {
726
+ dir: string
727
+ branches: string[]
728
+ ghToken: string
729
+ signal?: AbortSignal
730
+ /** Called once per branch that failed to fetch, so the caller (which owns a logger) can warn. */
731
+ onSkip?: (branch: string, reason: string) => void
732
+ }): Promise<string[]> {
733
+ const { dir, branches, ghToken, signal, onSkip } = opts
734
+ if (branches.length === 0) return []
735
+ const env = await authEnv(ghToken)
736
+ const fetched: string[] = []
737
+ for (const branch of branches) {
738
+ try {
739
+ await git(
740
+ ['fetch', '--no-tags', 'origin', `+refs/heads/${branch}:refs/remotes/origin/${branch}`],
741
+ { cwd: dir, signal, env },
742
+ )
743
+ fetched.push(branch)
744
+ } catch (err) {
745
+ onSkip?.(branch, err instanceof Error ? err.message : String(err))
746
+ }
747
+ }
748
+ // Keep the suggested reference-worktree dir out of the agent's commits (best-effort, per-clone).
749
+ if (fetched.length > 0) await excludeFromGit(dir, `${REFERENCE_WORKTREE_DIR}/`, signal)
750
+ return fetched
751
+ }
752
+
700
753
  /**
701
754
  * Push the work branch to origin. The remote URL carries only the username, so
702
755
  * the token is supplied here via the askpass env (never in argv).
package/src/job.ts CHANGED
@@ -282,6 +282,20 @@ function parseReferenceRepos(value: unknown): ReferenceRepoSpec[] {
282
282
  })
283
283
  }
284
284
 
285
+ /**
286
+ * Parse the optional `referenceBranches` list: pre-existing branch NAMES of the PRIMARY repo the
287
+ * agent may read but never commit to (the apriori-branches reference mode). Unlike
288
+ * {@link parseReferenceRepos} these are not sibling repos — they are fetched into the primary
289
+ * checkout's `origin/<b>` refs (see {@link fetchReferenceBranches}). A non-string / empty entry is
290
+ * dropped; an absent list yields `[]`. Kept simple (no git-ref grammar check) because the backend
291
+ * validated the name at the write boundary and probed its existence at dispatch.
292
+ */
293
+ function parseReferenceBranches(value: unknown): string[] {
294
+ if (value === undefined || value === null) return []
295
+ if (!Array.isArray(value)) throw new Error("Invalid job: 'referenceBranches' must be an array")
296
+ return value.filter((v): v is string => typeof v === 'string' && v.length > 0)
297
+ }
298
+
285
299
  /** Parse the optional `repo.provider` discriminator (defaults to undefined ⇒ host inference). */
286
300
  function parseVcsProvider(value: unknown): 'github' | 'gitlab' | undefined {
287
301
  if (value === undefined || value === null) return undefined
@@ -697,6 +711,16 @@ export interface AgentJob extends HarnessAuthFields {
697
711
  * pushed. Absent ⇒ single-repo run. Independent of {@link peerRepos} (those are writable).
698
712
  */
699
713
  referenceRepos?: ReferenceRepoSpec[]
714
+ /**
715
+ * Pre-existing branch names of the PRIMARY repo attached to the task as READ-ONLY reference
716
+ * points (the apriori-branches reference mode). After the primary checkout the harness fetches
717
+ * each into its `origin/<b>` tracking ref (best-effort per branch) so the agent can inspect it —
718
+ * `git log origin/<b>`, two-dot `git diff origin/<b>`, `git show origin/<b>:<path>` — but never
719
+ * commits to or pushes it (that guarantee lives in the prompt guidance, not a git constraint).
720
+ * Distinct from {@link referenceRepos}: those are separate sibling repos; these are branches of
721
+ * the same primary repo. Absent ⇒ none. Consumed by the coding + explore flows.
722
+ */
723
+ referenceBranches?: string[]
700
724
  /**
701
725
  * Coding mode: whether a no-op run (nothing changed) is a failure. The implementer
702
726
  * fails on a no-op; the in-place fixers (ci-fix / fix-tests) treat it as a non-fatal
@@ -1100,6 +1124,7 @@ export function parseAgentJob(input: unknown): AgentJob {
1100
1124
  const infra = parseAgentInfraSpec(o.infra)
1101
1125
  const peerRepos = parsePeerRepos(o.peerRepos)
1102
1126
  const referenceRepos = parseReferenceRepos(o.referenceRepos)
1127
+ const referenceBranches = parseReferenceBranches(o.referenceBranches)
1103
1128
  const bootstrap = parseAgentBootstrapSpec(o.bootstrap)
1104
1129
  const contextFiles = parseContextFiles(o.contextFiles)
1105
1130
  const packageRegistries = parsePackageRegistries(o.packageRegistries)
@@ -1134,6 +1159,7 @@ export function parseAgentJob(input: unknown): AgentJob {
1134
1159
  ...(pr ? { pr } : {}),
1135
1160
  ...(peerRepos.length ? { peerRepos } : {}),
1136
1161
  ...(referenceRepos.length ? { referenceRepos } : {}),
1162
+ ...(referenceBranches.length ? { referenceBranches } : {}),
1137
1163
  ...(o.noChangesIsError === false ? { noChangesIsError: false } : {}),
1138
1164
  ...(o.persistentCheckout === true ? { persistentCheckout: true } : {}),
1139
1165
  ...(o.streamFollowUps === true ? { streamFollowUps: true } : {}),
package/src/server.ts CHANGED
@@ -6,6 +6,7 @@ import { handleInline } from './inline.js'
6
6
  import { redactSecrets } from './git.js'
7
7
  import { JobRegistry, loadRunnerLimits, type JobResultBase, type RunOptions } from './runner.js'
8
8
  import { log } from './logger.js'
9
+ import { HARNESS_VERSION } from './version.js'
9
10
 
10
11
  // The container's HTTP entry point. The Worker addresses one instance per run and
11
12
  // POSTs a job to /jobs (the body's `kind` selects which agent runs); the harness
@@ -109,7 +110,14 @@ function send(res: ServerResponse, status: number, body: unknown): void {
109
110
  const server = createServer((req, res) => {
110
111
  void (async () => {
111
112
  if (req.method === 'GET' && req.url === '/health') {
112
- return send(res, 200, { status: 'ok' })
113
+ // Report the harness version so a backend can detect a stale/mismatched executor and
114
+ // fail loudly early (see version.ts). Unauthenticated like the rest of /health — the
115
+ // version is not a secret. An old image predating this field simply omits it, which the
116
+ // backend treats as a stale signal.
117
+ return send(res, 200, {
118
+ status: 'ok',
119
+ ...(HARNESS_VERSION ? { version: HARNESS_VERSION } : {}),
120
+ })
113
121
  }
114
122
  // All non-health endpoints are gated by the optional shared secret.
115
123
  if (!authorized(req)) {
package/src/version.ts ADDED
@@ -0,0 +1,45 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+
5
+ // The harness's OWN version, resolved once at module load and reported on `/health`. A
6
+ // backend reads it back over the health handshake to detect a STALE or MISMATCHED executor
7
+ // (an old image left behind by a mutable tag, an outdated native install) and fail loudly
8
+ // and early — instead of the cryptic downstream symptom a version skew otherwise produces
9
+ // (e.g. a since-removed git flag reappearing and breaking every authenticated clone/push).
10
+ //
11
+ // Resolution order, most authoritative first:
12
+ // 1. `HARNESS_VERSION` env — the Docker image can bake it; an operator can override.
13
+ // 2. the version file the image writes next to `dist/` (the image deliberately ships NO
14
+ // package.json, so it captures the version into `harness-version.txt` at build time).
15
+ // 3. `package.json` — the native/npm install and a source checkout both keep it beside
16
+ // `dist/`, so `dist/version.js` finds it one level up.
17
+ // Undefined only for an oddly-assembled runtime carrying none of the three; the backend then
18
+ // treats "no reported version" as a strong stale signal in its own right.
19
+
20
+ function readVersionFile(dir: string, rel: string): string | undefined {
21
+ try {
22
+ const raw = readFileSync(join(dir, rel), 'utf8')
23
+ const value = rel.endsWith('.json') ? (JSON.parse(raw) as { version?: string }).version : raw
24
+ const trimmed = value?.trim()
25
+ return trimmed || undefined
26
+ } catch {
27
+ return undefined
28
+ }
29
+ }
30
+
31
+ function resolveHarnessVersion(): string | undefined {
32
+ const fromEnv = process.env.HARNESS_VERSION?.trim()
33
+ if (fromEnv) return fromEnv
34
+ let dir: string
35
+ try {
36
+ // Compiled to `dist/version.js`; the baked file / package.json sit one level up from dist.
37
+ dir = dirname(fileURLToPath(import.meta.url))
38
+ } catch {
39
+ return undefined
40
+ }
41
+ return readVersionFile(dir, '../harness-version.txt') ?? readVersionFile(dir, '../package.json')
42
+ }
43
+
44
+ /** The running harness version, or undefined when it cannot be determined. */
45
+ export const HARNESS_VERSION: string | undefined = resolveHarnessVersion()