@cat-factory/executor-harness 1.41.0 → 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 +36 -1
- package/dist/coding-agent.js +37 -1
- package/dist/git.js +44 -0
- package/dist/job.js +17 -0
- package/package.json +3 -3
- package/src/agent.ts +40 -0
- package/src/coding-agent.ts +48 -0
- package/src/git.ts +53 -0
- package/src/job.ts +26 -0
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.
|
package/dist/coding-agent.js
CHANGED
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/executor-harness",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
30
|
-
"@cat-factory/spend": "0.12.
|
|
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
|
)
|
package/src/coding-agent.ts
CHANGED
|
@@ -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 } : {}),
|