@cat-factory/executor-harness 1.106.0 → 1.110.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/README.md CHANGED
@@ -128,6 +128,14 @@ from the backend over the SAME container session token the run already holds for
128
128
  this needs no extra credential. The FILE NAMES are the backend's, never derived here: the name is
129
129
  how the agent learns the view name, and the platform pairs its capture against that name later.
130
130
 
131
+ A job for a kind that BUILDS a screen carries the same wire shape under `designImages`, downloaded
132
+ into `.cat-context/design-renders/` instead. Same transfer, opposite instruction: those are the
133
+ design to build, not the views to capture, which is why they get their own directory (a tester
134
+ reading the builder's handful would take it for the complete list of views to capture). The prompt
135
+ naming them is composed by the BACKEND, since only it knows whether this harness/model pair can be
136
+ shown an image at all and which views the run was not sent, so the harness speaks up only to
137
+ CORRECT that list when a picture did not land.
138
+
131
139
  `omitted` carries the views the backend's cap dropped. They are stated to the agent beside the
132
140
  transfers that failed, since from where it stands both are a view to capture with nothing to compare
133
141
  against. This parser keeps a higher backstop of its own against a body claiming more files than any
@@ -255,6 +263,8 @@ Kimi / DeepSeek) and meters spend. The provider key never enters the container.
255
263
  | `src/validation-checks.ts` | Pre-PR validation: runs the job's check commands in the checkout (bounded, secret-scrubbed capture, per-command watchdog) and drives the retry-until-green loop that gates the PR. Generic: keyed off the job body, never the agent kind. |
256
264
  | `src/reproduction-proof.ts` | Bugfix reproduction proof: runs the job's declared reproduction command against two symmetric fresh worktrees (the pre-fix tree and the final tree) and computes red-then-green from the exit codes, with a repair loop that never fails the run. Generic: keyed off the job body, never the agent kind. |
257
265
  | `src/agent-capabilities.ts` | The agent CAPABILITIES a job body carries: the run's `skills` (a `SKILL.md` payload + resources) and its `mcpServers` (tool servers): with their defensive parsing and the per-CLI config writers (`--mcp-config` JSON for claude-code, `[mcp_servers.*]` TOML for Codex). Backend-authored data the harness only MATERIALISES: adding a skill or a tool server is a backend registration, never a harness change. |
266
+ | `src/context-images.ts` | The TRANSFER half of both image manifests: downloads a manifest's images into a subdirectory of `.cat-context/` on the run's own container session token, bounded per image and per pass, and reports what did not land. Best-effort, time-bounded and IDEMPOTENT over the checkout, so a repair round re-costs a stat rather than a transfer. Shared, because the transfer is identical for both; what differs is what the files MEAN, which is each caller's own module below. |
267
+ | `src/design-images.ts` | The task's DESIGN PICTURES: downloads the manifest a building job body carries into `.cat-context/design-renders/`, for an agent CLI that can read an image into its turn. Says NOTHING on success (the backend's prompt already names every file and its view) and speaks only to correct that list when a picture is not here, because an agent told to open a file that is absent goes looking for the design rather than for the transfer. |
258
268
  | `src/reference-screenshots.ts` | The task's REFERENCE DESIGN images: downloads the manifest a capturing job body carries into `.cat-context/reference-screenshots/` (on the run's own container session token) and composes the prompt block naming each file's view. Best-effort, time-bounded and IDEMPOTENT over the checkout, so a repair round re-costs a stat rather than a transfer. A reference that is not on disk is NAMED to the agent, whether a transfer failed or the backend's cap dropped the view, because on disk an absent file and a screen the design does not have are the same thing. Backend-authored throughout, including the file names. |
259
269
  | `src/bootstrap-mode.ts` | The repo-bootstrap MODE: clone-a-reference-or-scaffold → run the agent → refuse to push an empty tree → reinit + force-push to the pre-created target repo. |
260
270
  | `src/agent-shared.ts` | The few helpers every agent MODE shares (effort-report folding, the capability fields forwarded to `runAgentInWorkspace`). |
@@ -241,7 +241,7 @@ export function parseSkillSpecs(value) {
241
241
  * A member is added here in the SAME change that teaches the parser the field, never ahead of it:
242
242
  * the whole value of the list is that it is the image's own honest answer.
243
243
  */
244
- export const HARNESS_BODY_CAPABILITIES = ['mcpServers', 'skills'];
244
+ export const HARNESS_BODY_CAPABILITIES = ['mcpServers', 'skills', 'designImages'];
245
245
  /**
246
246
  * A safe MCP server id: it becomes a tool-name fragment AND a TOML table key.
247
247
  *
@@ -1,4 +1,4 @@
1
- import type { AgentJob, AgentResult, McpServerSpec, ReferenceScreenshotsSpec, SkillSpec } from './job.js';
1
+ import type { AgentJob, AgentResult, McpServerSpec, ImageManifestSpec, SkillSpec } from './job.js';
2
2
  import type { EffortReport } from './effort.js';
3
3
  /**
4
4
  * Fold an agent's effort self-assessment (lifted from its sentinel file by `runAgentInWorkspace`)
@@ -16,5 +16,6 @@ export declare function mergeEffort(result: AgentResult, effortReport: EffortRep
16
16
  export declare function agentCapabilities(job: AgentJob): {
17
17
  skills?: SkillSpec[];
18
18
  mcpServers?: McpServerSpec[];
19
- referenceScreenshots?: ReferenceScreenshotsSpec;
19
+ referenceScreenshots?: ImageManifestSpec;
20
+ designImages?: ImageManifestSpec;
20
21
  };
@@ -21,5 +21,6 @@ export function agentCapabilities(job) {
21
21
  ...(job.skills?.length ? { skills: job.skills } : {}),
22
22
  ...(job.mcpServers?.length ? { mcpServers: job.mcpServers } : {}),
23
23
  ...(job.referenceScreenshots ? { referenceScreenshots: job.referenceScreenshots } : {}),
24
+ ...(job.designImages ? { designImages: job.designImages } : {}),
24
25
  };
25
26
  }
package/dist/agent.js CHANGED
@@ -10,7 +10,8 @@ import { cloneRepo, commitAll, conflictDiff, fetchPullRequestHead, fetchReferenc
10
10
  import { inferVcsProvider, openPullRequest } from './vcs-api.js';
11
11
  import { applyPrDescription } from './pr-description.js';
12
12
  import { makeDirClaimer } from './checkout-dir.js';
13
- import { noChangesReason, runCodingAgent, runMultiRepoCoding } from './coding-agent.js';
13
+ import { noChangesReason, runCodingAgent } from './coding-agent.js';
14
+ import { runMultiRepoCoding } from './multi-repo-coding.js';
14
15
  import { validationFailureMessage } from './validation-checks.js';
15
16
  import { prepopulateDependencies, withDependencyNote } from './dependency-install.js';
16
17
  import { agentCapabilities, mergeEffort } from './agent-shared.js';
@@ -1,4 +1,4 @@
1
- import type { AgentJob, AgentResult, HarnessAuthFields, ReferenceScreenshotsSpec, RepoSpec, SkillSpec, McpServerSpec } from './job.js';
1
+ import type { HarnessAuthFields, ImageManifestSpec, RepoSpec, SkillSpec, McpServerSpec } from './job.js';
2
2
  import type { HarnessCallMetric } from './pi.js';
3
3
  import type { PiRunStats } from './pi-reduction.js';
4
4
  import { type EffortReport } from './effort.js';
@@ -119,7 +119,14 @@ export interface CodingAgentSpec extends HarnessAuthFields {
119
119
  * UI-facing kind may well be a coding one, and nothing here switches on which built-in it is.
120
120
  * Absent ⇒ none (the normal case).
121
121
  */
122
- referenceScreenshots?: ReferenceScreenshotsSpec;
122
+ referenceScreenshots?: ImageManifestSpec;
123
+ /**
124
+ * The PICTURES of the task's designs, downloaded into `.cat-context/design-renders/` before the
125
+ * agent's first turn. Carried here for the same reason the capture set is: what earns a run its
126
+ * pictures is the KIND's declared trait plus a harness that can read an image, and a coding kind
127
+ * is the commonest holder of both. Absent ⇒ none (the normal case).
128
+ */
129
+ designImages?: ImageManifestSpec;
123
130
  }
124
131
  /** The outcome of a coding agent run, before each caller maps it to its own result shape. */
125
132
  export interface CodingAgentOutcome {
@@ -141,7 +148,7 @@ export interface CodingAgentOutcome {
141
148
  effortReport?: EffortReport;
142
149
  /**
143
150
  * The agent-authored PR description, lifted from its sentinel file (absent when it wrote none).
144
- * The PR-opening caller folds it over the dispatch-time title/body via {@link applyPrDescription};
151
+ * The PR-opening caller folds it over the dispatch-time title/body via `applyPrDescription`;
145
152
  * absent means the fallback text, unchanged.
146
153
  */
147
154
  prDescription?: AgentPrDescription;
@@ -227,20 +234,6 @@ export declare function runRalphValidation(repoDir: string, cwd: string, validat
227
234
  iteration?: number;
228
235
  headSha?: string;
229
236
  }>;
230
- /**
231
- * Multi-repo coding (service-connections phase 3): clone the primary repo AND every connected
232
- * peer repo as SIBLING checkouts under one workspace root, run the agent ONCE with its cwd at
233
- * that root (so it makes the cross-service change coherently across all of them), then commit +
234
- * push each repo that actually changed and open one PR per dirty repo. The task's own-service PR
235
- * is reported as `prUrl`/`branch`; the peer PRs as `peerPullRequests`.
236
- *
237
- * Deliberately simpler than the single-repo {@link runCodingAgent} for the first cut: NO mid-run
238
- * checkpoint pushes (an evicted multi-repo run re-clones on retry — the deterministic work branch
239
- * still lets it resume any commits it managed to push at the end), NO warm-pool persistent
240
- * checkout (always ephemeral), and NO follow-up sentinel streaming. It reuses the SAME dir-scoped
241
- * git helpers, so the per-repo clone/commit/push/PR mechanics match the single-repo path exactly.
242
- */
243
- export declare function runMultiRepoCoding(job: AgentJob, opts?: RunOptions): Promise<AgentResult>;
244
237
  /**
245
238
  * The "no changes" reason both coding agents report: a caller-supplied lead phrase
246
239
  * plus the shared "never acted" cause and a credential-scrubbed tail of Pi's stderr.
@@ -1,13 +1,11 @@
1
1
  import { mkdir } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { runCapturedCommand } from './captured-command.js';
4
- import { makeDirClaimer } from './checkout-dir.js';
5
4
  import { branchAheadOfBase, changedFilesSinceBase, branchHasCommitsSince, cloneExistingBranch, cloneRepo, commitTrackedEdits, createBranch, excludeFromGit, fetchReferenceBranches, headCommit, listUntrackedFiles, prepareExistingCheckout, pushBranch, refreshFromBaseIfClean, remoteBranchExists, } from './git.js';
6
- import { openPullRequest } from './vcs-api.js';
7
5
  import { FOLLOW_UPS_FILENAME, FollowUpTailer } from './follow-ups.js';
8
6
  import { EFFORT_REPORT_FILE } from './effort.js';
9
- import { applyPrDescription, PR_DESCRIPTION_FILE, readPrDescription, } from './pr-description.js';
10
- import { acquireRepoCheckout, agentNeverActed, agentOutputTail, runAgentInWorkspace, withWorkspace, } from './pi-workspace.js';
7
+ import { PR_DESCRIPTION_FILE, readPrDescription, } from './pr-description.js';
8
+ import { acquireRepoCheckout, agentNeverActed, agentOutputTail, runAgentInWorkspace, } from './pi-workspace.js';
11
9
  import { log } from './logger.js';
12
10
  import { runValidationLoop, } from './validation-checks.js';
13
11
  import { runReproductionLoop, } from './reproduction-proof.js';
@@ -212,6 +210,7 @@ export async function runCodingAgent(spec, opts = {}) {
212
210
  ...(spec.referenceScreenshots
213
211
  ? { referenceScreenshots: spec.referenceScreenshots }
214
212
  : {}),
213
+ ...(spec.designImages ? { designImages: spec.designImages } : {}),
215
214
  }, opts);
216
215
  let outcome;
217
216
  try {
@@ -680,373 +679,6 @@ export async function runRalphValidation(repoDir, cwd, validation, logger, opts)
680
679
  ...(headSha ? { headSha } : {}),
681
680
  };
682
681
  }
683
- /**
684
- * Multi-repo coding (service-connections phase 3): clone the primary repo AND every connected
685
- * peer repo as SIBLING checkouts under one workspace root, run the agent ONCE with its cwd at
686
- * that root (so it makes the cross-service change coherently across all of them), then commit +
687
- * push each repo that actually changed and open one PR per dirty repo. The task's own-service PR
688
- * is reported as `prUrl`/`branch`; the peer PRs as `peerPullRequests`.
689
- *
690
- * Deliberately simpler than the single-repo {@link runCodingAgent} for the first cut: NO mid-run
691
- * checkpoint pushes (an evicted multi-repo run re-clones on retry — the deterministic work branch
692
- * still lets it resume any commits it managed to push at the end), NO warm-pool persistent
693
- * checkout (always ephemeral), and NO follow-up sentinel streaming. It reuses the SAME dir-scoped
694
- * git helpers, so the per-repo clone/commit/push/PR mechanics match the single-repo path exactly.
695
- */
696
- export async function runMultiRepoCoding(job, opts = {}) {
697
- const logger = (opts.log ?? log).child({ kind: 'multi-repo', jobId: job.jobId });
698
- const peers = job.peerRepos ?? [];
699
- const references = job.referenceRepos ?? [];
700
- const primaryWorkBranch = job.pushBranch ?? job.newBranch ?? job.branch;
701
- // Assign the sibling directory per repo via the shared deterministic allocator
702
- // (`owner__name__digest`, matching the backend prompt's `siblingCheckoutDir`), shared with the
703
- // read-only explore fan-out.
704
- const claimDir = makeDirClaimer();
705
- const legs = [
706
- {
707
- repo: job.repo,
708
- dirName: claimDir(job.repo),
709
- dir: '',
710
- cloneBranch: job.branch,
711
- workBranch: primaryWorkBranch,
712
- ghToken: job.ghToken,
713
- ...(job.pr ? { pr: job.pr } : {}),
714
- primary: true,
715
- baseSha: '',
716
- resumed: false,
717
- },
718
- ...peers.map((peer) => ({
719
- repo: peer.repo,
720
- dirName: claimDir(peer.repo),
721
- dir: '',
722
- cloneBranch: peer.repo.baseBranch,
723
- // Coding peers always carry `newBranch` (the backend sets the shared work branch);
724
- // fall back to the primary's for the type (read-only peers never reach this path).
725
- workBranch: peer.newBranch ?? primaryWorkBranch,
726
- ghToken: peer.ghToken ?? job.ghToken,
727
- ...(peer.pr ? { pr: peer.pr } : {}),
728
- ...(peer.frameId ? { frameId: peer.frameId } : {}),
729
- primary: false,
730
- baseSha: '',
731
- resumed: false,
732
- })),
733
- // Read-only reference repos (doc-writer): cloned as siblings the agent reads but never writes.
734
- // `workBranch` is set to the base only to satisfy the type — a read-only leg never branches or
735
- // pushes (guarded by `readOnly` in both the clone and push phases below).
736
- ...references.map((reference) => ({
737
- repo: reference.repo,
738
- dirName: claimDir(reference.repo),
739
- dir: '',
740
- cloneBranch: reference.repo.baseBranch,
741
- workBranch: reference.repo.baseBranch,
742
- ghToken: reference.ghToken ?? job.ghToken,
743
- primary: false,
744
- readOnly: true,
745
- baseSha: '',
746
- resumed: false,
747
- })),
748
- ];
749
- return withWorkspace('multi', async (root) => {
750
- // Clone (or resume) every sibling checkout under the workspace root and fetch the primary's
751
- // reference branches. Mutates each leg's `dir`/`resumed`/`baseSha` in place.
752
- await prepareMultiRepoCheckouts(root, legs, job, logger, opts);
753
- // DEPENDENCY PREPOPULATION for the PRIMARY leg, exactly as the read-only multi-repo fan-out
754
- // does it. The install is declared on ONE service frame (the primary repo's), so it runs in
755
- // that leg's checkout and is never fanned out across peers, whose own frames declare configs
756
- // this dispatch never resolved — running a `pnpm install` inside a Go checkout is not a
757
- // degraded outcome, it is a wrong one. A cross-repo implementer needs its dependencies for
758
- // the same reason a cross-repo investigator does; the note names the sibling directory
759
- // because the agent itself stands at the workspace root.
760
- //
761
- // At the leg's checkout ROOT, not a `serviceDirectory` subtree: this layout applies no
762
- // service-directory scoping anywhere (the agent runs at the root and the prompt explains the
763
- // sibling checkouts), and a root install is the one that resolves a monorepo workspace whole.
764
- const primaryLeg = legs.find((leg) => leg.primary);
765
- const dependencyNote = primaryLeg
766
- ? await prepopulateDependencies({
767
- spec: job.dependencyInstall,
768
- installDir: primaryLeg.dir,
769
- repoDir: primaryLeg.dir,
770
- agentDir: root,
771
- logger,
772
- opts,
773
- })
774
- : undefined;
775
- // THE REPOS' OWN PR TEMPLATES: one per leg that will actually open a pull request, each named
776
- // by its sibling directory so the agent knows which checkout's briefing takes which shape —
777
- // the repos in a workspace need not share a template, or ship one at all. A read-only
778
- // reference leg is excluded by construction: it carries no `pr`, so nothing publishes for it.
779
- const prTemplate = await resolvePrTemplateNote({
780
- targets: legs
781
- .filter((leg) => leg.pr)
782
- .map((leg) => ({
783
- repoDir: leg.dir,
784
- repoLabel: leg.dirName,
785
- ...(leg.repo.provider ? { provider: leg.repo.provider } : {}),
786
- })),
787
- logger,
788
- });
789
- // Run the agent ONCE with its cwd at the workspace root, so it sees every sibling checkout
790
- // and can change them coherently. No monorepo/service-directory scoping — the multi-repo
791
- // note + the backend system-prompt section explain the layout.
792
- opts.onPhase?.('agent');
793
- logger.info('multi-repo: running agent', { repos: legs.map((l) => l.dirName) });
794
- const { summary, stats, stderrTail, usage, callMetrics, effortReport } = await runAgentInWorkspace({
795
- dir: root,
796
- systemPrompt: job.systemPrompt,
797
- userPrompt: withDependencyNote(withPrTemplateNote(job.userPrompt, prTemplate.note), dependencyNote),
798
- model: job.model,
799
- harness: job.harness,
800
- subscriptionToken: job.subscriptionToken,
801
- subscriptionBaseUrl: job.subscriptionBaseUrl,
802
- ambientAuth: job.ambientAuth,
803
- proxyBaseUrl: job.proxyBaseUrl,
804
- proxyPhasePath: job.proxyPhasePath,
805
- sessionToken: job.sessionToken,
806
- webToolsGuidance: job.webToolsGuidance,
807
- webSearchProxy: job.webSearch,
808
- guardLimits: job.guardLimits,
809
- ...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
810
- // Skills + tool servers apply to a multi-repo run exactly as to a single-repo one: they
811
- // are properties of the AGENT KIND, not of the checkout layout.
812
- ...(job.skills?.length ? { skills: job.skills } : {}),
813
- ...(job.mcpServers?.length ? { mcpServers: job.mcpServers } : {}),
814
- ...(job.referenceScreenshots ? { referenceScreenshots: job.referenceScreenshots } : {}),
815
- multiRepo: true,
816
- }, opts);
817
- // Commit forgotten tracked edits, then push + open a PR for each repo the run actually changed.
818
- const { primaryPushed, primaryPrUrl, peerPullRequests } = await pushMultiRepoLegs(legs, job, logger, opts, root, prTemplate);
819
- const anyWork = primaryPushed || peerPullRequests.length > 0;
820
- if (!anyWork) {
821
- // Nothing changed in ANY repo. For the implementer this is a failure (as in the
822
- // single-repo path); a caller that tolerates a no-op (never the implementer today)
823
- // gets a clean non-event.
824
- if (job.noChangesIsError === false) {
825
- return {
826
- pushed: false,
827
- branch: primaryWorkBranch,
828
- summary,
829
- stats,
830
- ...(usage ? { usage } : {}),
831
- ...(callMetrics ? { callMetrics } : {}),
832
- ...(effortReport ? { effortReport } : {}),
833
- };
834
- }
835
- return {
836
- pushed: false,
837
- branch: primaryWorkBranch,
838
- summary,
839
- stats,
840
- error: noChangesReason('the agent produced no file changes in any repository', stats, stderrTail),
841
- failureCause: 'no-changes',
842
- ...(usage ? { usage } : {}),
843
- ...(callMetrics ? { callMetrics } : {}),
844
- ...(effortReport ? { effortReport } : {}),
845
- };
846
- }
847
- logger.info('multi-repo: complete', {
848
- primaryPushed,
849
- primaryPrUrl: primaryPrUrl ?? null,
850
- peers: peerPullRequests.length,
851
- });
852
- return {
853
- pushed: primaryPushed,
854
- ...(primaryPrUrl ? { prUrl: primaryPrUrl } : {}),
855
- branch: primaryWorkBranch,
856
- ...(peerPullRequests.length ? { peerPullRequests } : {}),
857
- summary,
858
- stats,
859
- ...(usage ? { usage } : {}),
860
- ...(callMetrics ? { callMetrics } : {}),
861
- ...(effortReport ? { effortReport } : {}),
862
- };
863
- });
864
- }
865
- /**
866
- * Clone phase for {@link runMultiRepoCoding}: every repo into its sibling dir under the workspace
867
- * root. Resume an existing remote work branch (an evicted retry) rather than branching off base
868
- * again, then fetch the primary repo's reference branches. Mutates each leg's `dir`/`resumed`/
869
- * `baseSha` in place. Extracted so the multi-repo body stays small.
870
- */
871
- async function prepareMultiRepoCheckouts(root, legs, job, logger, opts) {
872
- const { signal } = opts;
873
- opts.onPhase?.('clone');
874
- for (const leg of legs) {
875
- const dir = join(root, leg.dirName);
876
- await mkdir(dir, { recursive: true });
877
- // A read-only reference leg: clone its base branch for the agent to read, and stop there —
878
- // no work branch, no resume, no base-refresh. It is skipped in the push phase, so it can
879
- // never be written to. (Kept in the loop so it lands in the same workspace root as siblings.)
880
- if (leg.readOnly) {
881
- logger.info('multi-repo: cloning read-only reference', {
882
- repo: leg.dirName,
883
- cloneBranch: leg.cloneBranch,
884
- });
885
- await cloneRepo({
886
- repo: { ...leg.repo, baseBranch: leg.cloneBranch },
887
- ghToken: leg.ghToken,
888
- dir,
889
- signal,
890
- });
891
- leg.dir = dir;
892
- continue;
893
- }
894
- leg.resumed = await remoteBranchExists(leg.repo.cloneUrl, leg.workBranch, leg.ghToken, signal);
895
- if (leg.resumed) {
896
- logger.info('multi-repo: resuming existing branch', {
897
- repo: leg.dirName,
898
- branch: leg.workBranch,
899
- });
900
- await cloneExistingBranch({
901
- cloneUrl: leg.repo.cloneUrl,
902
- branch: leg.workBranch,
903
- ghToken: leg.ghToken,
904
- dir,
905
- signal,
906
- });
907
- }
908
- else {
909
- logger.info('multi-repo: cloning', { repo: leg.dirName, cloneBranch: leg.cloneBranch });
910
- await cloneRepo({
911
- repo: { ...leg.repo, baseBranch: leg.cloneBranch },
912
- ghToken: leg.ghToken,
913
- dir,
914
- signal,
915
- });
916
- await createBranch(dir, leg.workBranch, signal);
917
- }
918
- leg.dir = dir;
919
- // Exclude the agent-authored PR-description sentinel locally (as the single-repo path does)
920
- // so the agent's own `git add` can never stage the briefing into the PR it describes.
921
- await excludeFromGit(dir, PR_DESCRIPTION_FILE, signal);
922
- // The branch tip before the agent runs. Captured BEFORE the resume base refresh below so
923
- // that refresh's merge commit counts as advancement and is pushed (as in the single-repo
924
- // path). A fresh leg produced work iff its branch advances past this; a resumed leg already
925
- // carries prior work.
926
- leg.baseSha = await headCommit(dir, signal);
927
- // A resumed branch was cut from an OLDER base; merge the latest base in when the two merge
928
- // cleanly so the agent works against current base and the peer/own PRs stay current. On a
929
- // conflict this is a best-effort no-op (the merge gate handles a conflicting PR downstream),
930
- // mirroring the single-repo {@link runCodingAgent} resume refresh.
931
- if (leg.resumed) {
932
- const refreshed = await refreshFromBaseIfClean(dir, leg.cloneBranch, leg.ghToken, signal).catch(() => false);
933
- if (!refreshed) {
934
- logger.info('multi-repo: resume base refresh skipped (conflict or error)', {
935
- repo: leg.dirName,
936
- base: leg.cloneBranch,
937
- });
938
- }
939
- }
940
- }
941
- // Reference branches attach to the PRIMARY repo, so fetch them into the primary sibling
942
- // checkout's `origin/<b>` refs (best-effort per branch). The backend's reference-branches
943
- // prompt section names the primary repo's directory to run the read commands in.
944
- if (job.referenceBranches?.length) {
945
- const primaryLeg = legs.find((l) => l.primary);
946
- if (primaryLeg?.dir) {
947
- const fetched = await fetchReferenceBranches({
948
- dir: primaryLeg.dir,
949
- branches: job.referenceBranches,
950
- ghToken: primaryLeg.ghToken,
951
- signal,
952
- onSkip: (branch, reason) => logger.warn('multi-repo: reference branch fetch skipped', { branch, reason }),
953
- });
954
- logger.info('multi-repo: fetched reference branches', {
955
- requested: job.referenceBranches.length,
956
- fetched: fetched.length,
957
- });
958
- }
959
- }
960
- }
961
- /**
962
- * Push phase for {@link runMultiRepoCoding}: commit forgotten tracked edits, then push + open a PR
963
- * for each repo the run actually changed (a repo the agent left untouched is skipped — no branch,
964
- * no PR; a read-only reference leg is never committed or pushed). Extracted so the multi-repo body
965
- * stays small; returns the primary's push/PR state plus the peer PRs.
966
- */
967
- async function pushMultiRepoLegs(legs, job, logger, opts,
968
- /** The workspace root the agent ran in — the fallback probe for the primary's briefing. */
969
- root,
970
- /** Which legs' briefings are filled templates — see the `titleFromHeading` read below. */
971
- prTemplate) {
972
- const { signal } = opts;
973
- opts.onPhase?.('push');
974
- let primaryPushed = false;
975
- let primaryPrUrl;
976
- const peerPullRequests = [];
977
- for (const leg of legs) {
978
- // A read-only reference leg is never committed or pushed — the third layer of the read-only
979
- // guarantee (the spec carries no branch/PR, and the clone phase gave it no work branch).
980
- if (leg.readOnly)
981
- continue;
982
- // Lift (and remove) the agent-authored PR description for THIS repo's PR before anything
983
- // else touches the checkout — each sibling checkout carries its own briefing for its own PR.
984
- // The agent's cwd here is the WORKSPACE ROOT rather than any one checkout, so an agent that
985
- // read the prompt loosely may well have written a single briefing there instead. Fall back
986
- // to it for the PRIMARY leg only: at the root there is nothing to say which repo it
987
- // describes, and the primary is the one the run is actually about.
988
- //
989
- // Per-leg `titleFromHeading`: only a leg whose OWN repo ships a template has repo-authored
990
- // headings in its sentinel, and the legs of a workspace need not agree about that — so this
991
- // is keyed on the leg, never on whether the run found any template at all.
992
- const readOptions = { titleFromHeading: !prTemplate.templated.has(leg.dir) };
993
- const agentPrDescription = (await readPrDescription(leg.dir, readOptions)) ??
994
- (leg.primary ? await readPrDescription(root, readOptions) : undefined);
995
- await commitTrackedEdits(leg.dir, job.commitMessage ?? leg.pr?.title ?? 'Agent changes', signal);
996
- const advanced = await branchHasCommitsSince(leg.dir, leg.baseSha, signal);
997
- let hasWork = advanced || leg.resumed;
998
- if (leg.resumed && !advanced) {
999
- const ahead = await branchAheadOfBase(leg.dir, leg.repo.baseBranch, leg.ghToken, signal);
1000
- if (ahead === false)
1001
- hasWork = false;
1002
- }
1003
- const leftover = await listUntrackedFiles(leg.dir, signal);
1004
- if (leftover.length > 0) {
1005
- logger.warn('multi-repo: uncommitted new files left behind (not pushed)', {
1006
- repo: leg.dirName,
1007
- count: leftover.length,
1008
- files: leftover.slice(0, 20),
1009
- });
1010
- }
1011
- if (!hasWork) {
1012
- logger.info('multi-repo: no changes for repo', { repo: leg.dirName });
1013
- continue;
1014
- }
1015
- await pushBranch(leg.dir, leg.workBranch, leg.ghToken, signal);
1016
- let prUrl = null;
1017
- if (leg.pr) {
1018
- prUrl = await openPullRequest({
1019
- owner: leg.repo.owner,
1020
- name: leg.repo.name,
1021
- ghToken: leg.ghToken,
1022
- head: leg.workBranch,
1023
- base: leg.repo.baseBranch,
1024
- pr: applyPrDescription(leg.pr, agentPrDescription),
1025
- // See the single-repo call site: refresh a resumed leg's already-open PR, but only
1026
- // when the text is the agent's own briefing rather than the dispatch-time fallback.
1027
- ...(agentPrDescription ? { refreshExisting: true } : {}),
1028
- apiBase: job.githubApiBase,
1029
- cloneUrl: leg.repo.cloneUrl,
1030
- ...(leg.repo.provider ? { provider: leg.repo.provider } : {}),
1031
- signal,
1032
- });
1033
- }
1034
- if (leg.primary) {
1035
- primaryPushed = true;
1036
- if (prUrl)
1037
- primaryPrUrl = prUrl;
1038
- }
1039
- else if (prUrl) {
1040
- peerPullRequests.push({
1041
- repo: `${leg.repo.owner}/${leg.repo.name}`,
1042
- ...(leg.frameId ? { frameId: leg.frameId } : {}),
1043
- prUrl,
1044
- branch: leg.workBranch,
1045
- });
1046
- }
1047
- }
1048
- return { primaryPushed, primaryPrUrl, peerPullRequests };
1049
- }
1050
682
  /**
1051
683
  * The "no changes" reason both coding agents report: a caller-supplied lead phrase
1052
684
  * plus the shared "never acted" cause and a credential-scrubbed tail of Pi's stderr.
@@ -0,0 +1,38 @@
1
+ import type { ImageManifestSpec } from './job.js';
2
+ /** What a transfer pass has on disk, and what it does not. */
3
+ export interface ContextImageOutcome {
4
+ written: {
5
+ fileName: string;
6
+ view: string;
7
+ }[];
8
+ /**
9
+ * One entry per image that is NOT on disk, with the cause stated in `reason`. Covers both halves
10
+ * of that absence, because the agent's position is the same either way (this view exists and
11
+ * there is no picture of it here): a transfer that failed, and a view the backend's own cap
12
+ * dropped before this container was ever asked to fetch it.
13
+ */
14
+ missing: {
15
+ view: string;
16
+ reason: string;
17
+ }[];
18
+ /** Where the written files live, relative to the checkout root. */
19
+ dir: string;
20
+ }
21
+ /**
22
+ * Download a manifest's images into `<checkout>/.cat-context/<subdir>/` and report what landed.
23
+ *
24
+ * IDEMPOTENT, and that is load-bearing rather than an optimisation: an agent flow re-enters its
25
+ * workspace once per repair round, so this pass runs several times over one checkout. A file
26
+ * already on disk is counted and never re-fetched, which keeps a later round from spending the
27
+ * budget again AND from reporting an image as absent that pass 1 successfully delivered. A view
28
+ * that MISSED is retried, since the next round is a fresh chance at whatever was transiently down.
29
+ *
30
+ * Never throws: images are an aid, not a precondition for running, so a backend outage degrades the
31
+ * run to its textual context rather than failing it. Every miss is carried out on
32
+ * {@link ContextImageOutcome.missing} so the caller can say so in the prompt, which is the
33
+ * difference between an image the platform failed to hand over and a screen that does not exist.
34
+ */
35
+ export declare function materializeContextImages(cwd: string, subdir: string, spec: ImageManifestSpec, options?: {
36
+ signal?: AbortSignal;
37
+ fetchImpl?: typeof fetch;
38
+ }): Promise<ContextImageOutcome>;