@cat-factory/executor-harness 1.100.0 → 1.102.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
@@ -9,7 +9,8 @@ import { captureRedactedOutput, redactSecrets, registerKnownSecrets } from './re
9
9
  import { cloneRepo, commitAll, conflictDiff, fetchPullRequestHead, fetchReferenceBranches, headCommit, mergeBranch, prepareExistingCheckout, pushBranch, unmergedPaths, } from './git.js';
10
10
  import { inferVcsProvider, openPullRequest } from './vcs-api.js';
11
11
  import { applyPrDescription } from './pr-description.js';
12
- import { makeDirClaimer, noChangesReason, runCodingAgent, runMultiRepoCoding, } from './coding-agent.js';
12
+ import { makeDirClaimer } from './checkout-dir.js';
13
+ import { noChangesReason, runCodingAgent, runMultiRepoCoding } from './coding-agent.js';
13
14
  import { validationFailureMessage } from './validation-checks.js';
14
15
  import { prepopulateDependencies, withDependencyNote } from './dependency-install.js';
15
16
  import { agentCapabilities, mergeEffort } from './agent-shared.js';
@@ -0,0 +1,33 @@
1
+ import type { RepoSpec } from './job.js';
2
+ /** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
3
+ export declare function safeDirSegment(value: string): string;
4
+ /**
5
+ * A short deterministic digest of the EXACT `owner` / `name` pair, before sanitisation. FNV-1a
6
+ * over `owner\0name`; the NUL separator makes it a digest of the PAIR rather than of a
7
+ * concatenation, so `('a', 'bc')` and `('ab', 'c')` cannot share one. Hand-rolled rather than
8
+ * taken from `node:crypto` because the backend needs the identical function and runs it in
9
+ * workerd as well as on Node.
10
+ *
11
+ * MUST stay byte-identical to the backend's `checkoutDirDigest`
12
+ * (`@cat-factory/server`, `agents/harnessContract.ts`); see {@link makeDirClaimer}.
13
+ */
14
+ export declare function checkoutDirDigest(owner: string, name: string): string;
15
+ /**
16
+ * A sibling-directory allocator for a multi-repo run: returns the checkout directory name for a
17
+ * repo under the workspace root. A pure function of the pair (`owner__name__digest`), which is
18
+ * what lets this and the backend compute it independently with no shared ordering or state. Kept
19
+ * as a factory so the coding + read-only explore fan-outs share ONE scheme, and it MUST stay
20
+ * byte-identical to the backend's `siblingCheckoutDir` / `renderMultiRepoWorkspaceSection` in
21
+ * `@cat-factory/server`, which names this exact directory in the agent's prompt: the two are
22
+ * computed independently, so a divergent rule would point the agent at a directory that does not
23
+ * exist.
24
+ *
25
+ * The readable `owner__name` prefix does not identify a repo on its own, which is why the digest
26
+ * is there. {@link safeDirSegment} folds a whole class of characters onto `-`, so a GitLab
27
+ * namespace path `grp/sub` and a group literally named `grp-sub` sanitise alike; and the `__`
28
+ * join is ambiguous once a segment may contain `_`, which GitHub owners cannot but GitLab
29
+ * namespace paths can, so `('a__b', 'c')` and `('a', 'b__c')` both read as `a__b__c`. Either
30
+ * collision puts two legs on one directory, and the second one's clone then fails against a
31
+ * directory the first already filled, killing the run in the clone phase naming neither repo.
32
+ */
33
+ export declare function makeDirClaimer(): (repo: Pick<RepoSpec, 'name' | 'owner'>) => string;
@@ -0,0 +1,56 @@
1
+ // ---------------------------------------------------------------------------
2
+ // The HARNESS half of the sibling-checkout-directory contract.
3
+ //
4
+ // The harness CREATES these directories; `@cat-factory/server`'s `agents/harnessContract.ts`
5
+ // NAMES them in the agent's prompt. The image builds from this `src/` plus typescript and may
6
+ // depend on no workspace package, so the two halves are computed INDEPENDENTLY and pinned against
7
+ // each other by `test/harness-contract.conformity.test.ts`. Extracted out of `coding-agent.ts` so
8
+ // the pairing sits in one small module per side rather than buried in the agent runner: the whole
9
+ // point of the pairing is that a reader can see both halves at once.
10
+ // ---------------------------------------------------------------------------
11
+ /** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
12
+ export function safeDirSegment(value) {
13
+ return value.replace(/[^A-Za-z0-9._-]/g, '-') || '_';
14
+ }
15
+ /**
16
+ * A short deterministic digest of the EXACT `owner` / `name` pair, before sanitisation. FNV-1a
17
+ * over `owner\0name`; the NUL separator makes it a digest of the PAIR rather than of a
18
+ * concatenation, so `('a', 'bc')` and `('ab', 'c')` cannot share one. Hand-rolled rather than
19
+ * taken from `node:crypto` because the backend needs the identical function and runs it in
20
+ * workerd as well as on Node.
21
+ *
22
+ * MUST stay byte-identical to the backend's `checkoutDirDigest`
23
+ * (`@cat-factory/server`, `agents/harnessContract.ts`); see {@link makeDirClaimer}.
24
+ */
25
+ export function checkoutDirDigest(owner, name) {
26
+ const input = `${owner}\u0000${name}`;
27
+ let hash = 0x811c9dc5;
28
+ for (let i = 0; i < input.length; i += 1) {
29
+ hash ^= input.charCodeAt(i);
30
+ // The FNV prime (16777619) as shifts, with `>>> 0` folding the result back to uint32 every
31
+ // step so the arithmetic never drifts into float range and diverges between engines.
32
+ hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0;
33
+ }
34
+ return hash.toString(36).padStart(7, '0');
35
+ }
36
+ /**
37
+ * A sibling-directory allocator for a multi-repo run: returns the checkout directory name for a
38
+ * repo under the workspace root. A pure function of the pair (`owner__name__digest`), which is
39
+ * what lets this and the backend compute it independently with no shared ordering or state. Kept
40
+ * as a factory so the coding + read-only explore fan-outs share ONE scheme, and it MUST stay
41
+ * byte-identical to the backend's `siblingCheckoutDir` / `renderMultiRepoWorkspaceSection` in
42
+ * `@cat-factory/server`, which names this exact directory in the agent's prompt: the two are
43
+ * computed independently, so a divergent rule would point the agent at a directory that does not
44
+ * exist.
45
+ *
46
+ * The readable `owner__name` prefix does not identify a repo on its own, which is why the digest
47
+ * is there. {@link safeDirSegment} folds a whole class of characters onto `-`, so a GitLab
48
+ * namespace path `grp/sub` and a group literally named `grp-sub` sanitise alike; and the `__`
49
+ * join is ambiguous once a segment may contain `_`, which GitHub owners cannot but GitLab
50
+ * namespace paths can, so `('a__b', 'c')` and `('a', 'b__c')` both read as `a__b__c`. Either
51
+ * collision puts two legs on one directory, and the second one's clone then fails against a
52
+ * directory the first already filled, killing the run in the clone phase naming neither repo.
53
+ */
54
+ export function makeDirClaimer() {
55
+ return (repo) => `${safeDirSegment(repo.owner)}__${safeDirSegment(repo.name)}__${checkoutDirDigest(repo.owner, repo.name)}`;
56
+ }
@@ -219,19 +219,6 @@ export declare function runRalphValidation(repoDir: string, cwd: string, validat
219
219
  iteration?: number;
220
220
  headSha?: string;
221
221
  }>;
222
- /** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
223
- export declare function safeDirSegment(value: string): string;
224
- /**
225
- * A sibling-directory allocator for a multi-repo run: returns the checkout directory name for a
226
- * repo under the workspace root. Deterministic (`owner__name`) and collision-free by construction
227
- * — the checkout set is deduped by `owner/name` upstream and GitHub owners contain no `_`, so the
228
- * `owner__name` join is unique per repo without a stateful collision dance. Kept as a factory so
229
- * the coding + read-only explore fan-outs share ONE scheme, and it MUST stay byte-identical to the
230
- * backend's `siblingCheckoutDir` / `renderMultiRepoWorkspaceSection` in `@cat-factory/server`
231
- * (jobBody.ts), which names this exact directory in the agent's prompt — the two are computed
232
- * independently, so a divergent rule would point the agent at a directory that does not exist.
233
- */
234
- export declare function makeDirClaimer(): (repo: Pick<RepoSpec, 'name' | 'owner'>) => string;
235
222
  /**
236
223
  * Multi-repo coding (service-connections phase 3): clone the primary repo AND every connected
237
224
  * peer repo as SIBLING checkouts under one workspace root, run the agent ONCE with its cwd at
@@ -1,6 +1,7 @@
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';
4
5
  import { branchAheadOfBase, changedFilesSinceBase, branchHasCommitsSince, cloneExistingBranch, cloneRepo, commitTrackedEdits, createBranch, excludeFromGit, fetchReferenceBranches, headCommit, listUntrackedFiles, prepareExistingCheckout, pushBranch, refreshFromBaseIfClean, remoteBranchExists, } from './git.js';
5
6
  import { openPullRequest } from './vcs-api.js';
6
7
  import { FOLLOW_UPS_FILENAME, FollowUpTailer } from './follow-ups.js';
@@ -676,23 +677,6 @@ export async function runRalphValidation(repoDir, cwd, validation, logger, opts)
676
677
  ...(headSha ? { headSha } : {}),
677
678
  };
678
679
  }
679
- /** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
680
- export function safeDirSegment(value) {
681
- return value.replace(/[^A-Za-z0-9._-]/g, '-') || '_';
682
- }
683
- /**
684
- * A sibling-directory allocator for a multi-repo run: returns the checkout directory name for a
685
- * repo under the workspace root. Deterministic (`owner__name`) and collision-free by construction
686
- * — the checkout set is deduped by `owner/name` upstream and GitHub owners contain no `_`, so the
687
- * `owner__name` join is unique per repo without a stateful collision dance. Kept as a factory so
688
- * the coding + read-only explore fan-outs share ONE scheme, and it MUST stay byte-identical to the
689
- * backend's `siblingCheckoutDir` / `renderMultiRepoWorkspaceSection` in `@cat-factory/server`
690
- * (jobBody.ts), which names this exact directory in the agent's prompt — the two are computed
691
- * independently, so a divergent rule would point the agent at a directory that does not exist.
692
- */
693
- export function makeDirClaimer() {
694
- return (repo) => `${safeDirSegment(repo.owner)}__${safeDirSegment(repo.name)}`;
695
- }
696
680
  /**
697
681
  * Multi-repo coding (service-connections phase 3): clone the primary repo AND every connected
698
682
  * peer repo as SIBLING checkouts under one workspace root, run the agent ONCE with its cwd at
@@ -711,8 +695,9 @@ export async function runMultiRepoCoding(job, opts = {}) {
711
695
  const peers = job.peerRepos ?? [];
712
696
  const references = job.referenceRepos ?? [];
713
697
  const primaryWorkBranch = job.pushBranch ?? job.newBranch ?? job.branch;
714
- // Assign the sibling directory per repo via the shared deterministic allocator (`owner__name`,
715
- // matching the backend prompt's `siblingCheckoutDir`), shared with the read-only explore fan-out.
698
+ // Assign the sibling directory per repo via the shared deterministic allocator
699
+ // (`owner__name__digest`, matching the backend prompt's `siblingCheckoutDir`), shared with the
700
+ // read-only explore fan-out.
716
701
  const claimDir = makeDirClaimer();
717
702
  const legs = [
718
703
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.100.0",
3
+ "version": "1.102.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",
@@ -30,9 +30,9 @@
30
30
  "hono": "^4.13.0",
31
31
  "typescript": "7.0.2",
32
32
  "vitest": "^4.1.10",
33
- "@cat-factory/kernel": "0.272.0",
34
- "@cat-factory/server": "0.252.0",
35
- "@cat-factory/spend": "0.15.40"
33
+ "@cat-factory/kernel": "0.273.0",
34
+ "@cat-factory/server": "0.253.0",
35
+ "@cat-factory/spend": "0.15.41"
36
36
  },
37
37
  "scripts": {
38
38
  "build": "tsc -p tsconfig.json",
package/src/agent.ts CHANGED
@@ -29,12 +29,8 @@ import {
29
29
  import { inferVcsProvider, openPullRequest } from './vcs-api.js'
30
30
  import type { PiRunStats, RunDiagnostics } from './pi-reduction.js'
31
31
  import { applyPrDescription } from './pr-description.js'
32
- import {
33
- makeDirClaimer,
34
- noChangesReason,
35
- runCodingAgent,
36
- runMultiRepoCoding,
37
- } from './coding-agent.js'
32
+ import { makeDirClaimer } from './checkout-dir.js'
33
+ import { noChangesReason, runCodingAgent, runMultiRepoCoding } from './coding-agent.js'
38
34
  import { validationFailureMessage } from './validation-checks.js'
39
35
  import { prepopulateDependencies, withDependencyNote } from './dependency-install.js'
40
36
  import { agentCapabilities, mergeEffort } from './agent-shared.js'
@@ -0,0 +1,62 @@
1
+ import type { RepoSpec } from './job.js'
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // The HARNESS half of the sibling-checkout-directory contract.
5
+ //
6
+ // The harness CREATES these directories; `@cat-factory/server`'s `agents/harnessContract.ts`
7
+ // NAMES them in the agent's prompt. The image builds from this `src/` plus typescript and may
8
+ // depend on no workspace package, so the two halves are computed INDEPENDENTLY and pinned against
9
+ // each other by `test/harness-contract.conformity.test.ts`. Extracted out of `coding-agent.ts` so
10
+ // the pairing sits in one small module per side rather than buried in the agent runner: the whole
11
+ // point of the pairing is that a reader can see both halves at once.
12
+ // ---------------------------------------------------------------------------
13
+
14
+ /** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
15
+ export function safeDirSegment(value: string): string {
16
+ return value.replace(/[^A-Za-z0-9._-]/g, '-') || '_'
17
+ }
18
+
19
+ /**
20
+ * A short deterministic digest of the EXACT `owner` / `name` pair, before sanitisation. FNV-1a
21
+ * over `owner\0name`; the NUL separator makes it a digest of the PAIR rather than of a
22
+ * concatenation, so `('a', 'bc')` and `('ab', 'c')` cannot share one. Hand-rolled rather than
23
+ * taken from `node:crypto` because the backend needs the identical function and runs it in
24
+ * workerd as well as on Node.
25
+ *
26
+ * MUST stay byte-identical to the backend's `checkoutDirDigest`
27
+ * (`@cat-factory/server`, `agents/harnessContract.ts`); see {@link makeDirClaimer}.
28
+ */
29
+ export function checkoutDirDigest(owner: string, name: string): string {
30
+ const input = `${owner}\u0000${name}`
31
+ let hash = 0x811c9dc5
32
+ for (let i = 0; i < input.length; i += 1) {
33
+ hash ^= input.charCodeAt(i)
34
+ // The FNV prime (16777619) as shifts, with `>>> 0` folding the result back to uint32 every
35
+ // step so the arithmetic never drifts into float range and diverges between engines.
36
+ hash = (hash + ((hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24))) >>> 0
37
+ }
38
+ return hash.toString(36).padStart(7, '0')
39
+ }
40
+
41
+ /**
42
+ * A sibling-directory allocator for a multi-repo run: returns the checkout directory name for a
43
+ * repo under the workspace root. A pure function of the pair (`owner__name__digest`), which is
44
+ * what lets this and the backend compute it independently with no shared ordering or state. Kept
45
+ * as a factory so the coding + read-only explore fan-outs share ONE scheme, and it MUST stay
46
+ * byte-identical to the backend's `siblingCheckoutDir` / `renderMultiRepoWorkspaceSection` in
47
+ * `@cat-factory/server`, which names this exact directory in the agent's prompt: the two are
48
+ * computed independently, so a divergent rule would point the agent at a directory that does not
49
+ * exist.
50
+ *
51
+ * The readable `owner__name` prefix does not identify a repo on its own, which is why the digest
52
+ * is there. {@link safeDirSegment} folds a whole class of characters onto `-`, so a GitLab
53
+ * namespace path `grp/sub` and a group literally named `grp-sub` sanitise alike; and the `__`
54
+ * join is ambiguous once a segment may contain `_`, which GitHub owners cannot but GitLab
55
+ * namespace paths can, so `('a__b', 'c')` and `('a', 'b__c')` both read as `a__b__c`. Either
56
+ * collision puts two legs on one directory, and the second one's clone then fails against a
57
+ * directory the first already filled, killing the run in the clone phase naming neither repo.
58
+ */
59
+ export function makeDirClaimer(): (repo: Pick<RepoSpec, 'name' | 'owner'>) => string {
60
+ return (repo) =>
61
+ `${safeDirSegment(repo.owner)}__${safeDirSegment(repo.name)}__${checkoutDirDigest(repo.owner, repo.name)}`
62
+ }
@@ -1,6 +1,7 @@
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'
4
5
  import type {
5
6
  AgentJob,
6
7
  AgentResult,
@@ -1018,25 +1019,6 @@ export async function runRalphValidation(
1018
1019
  }
1019
1020
  }
1020
1021
 
1021
- /** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
1022
- export function safeDirSegment(value: string): string {
1023
- return value.replace(/[^A-Za-z0-9._-]/g, '-') || '_'
1024
- }
1025
-
1026
- /**
1027
- * A sibling-directory allocator for a multi-repo run: returns the checkout directory name for a
1028
- * repo under the workspace root. Deterministic (`owner__name`) and collision-free by construction
1029
- * — the checkout set is deduped by `owner/name` upstream and GitHub owners contain no `_`, so the
1030
- * `owner__name` join is unique per repo without a stateful collision dance. Kept as a factory so
1031
- * the coding + read-only explore fan-outs share ONE scheme, and it MUST stay byte-identical to the
1032
- * backend's `siblingCheckoutDir` / `renderMultiRepoWorkspaceSection` in `@cat-factory/server`
1033
- * (jobBody.ts), which names this exact directory in the agent's prompt — the two are computed
1034
- * independently, so a divergent rule would point the agent at a directory that does not exist.
1035
- */
1036
- export function makeDirClaimer(): (repo: Pick<RepoSpec, 'name' | 'owner'>) => string {
1037
- return (repo) => `${safeDirSegment(repo.owner)}__${safeDirSegment(repo.name)}`
1038
- }
1039
-
1040
1022
  /** One repository participating in a multi-repo run: where to clone it + what to do after. */
1041
1023
  interface RepoLeg {
1042
1024
  repo: RepoSpec
@@ -1086,8 +1068,9 @@ export async function runMultiRepoCoding(
1086
1068
  const references: ReferenceRepoSpec[] = job.referenceRepos ?? []
1087
1069
  const primaryWorkBranch = job.pushBranch ?? job.newBranch ?? job.branch
1088
1070
 
1089
- // Assign the sibling directory per repo via the shared deterministic allocator (`owner__name`,
1090
- // matching the backend prompt's `siblingCheckoutDir`), shared with the read-only explore fan-out.
1071
+ // Assign the sibling directory per repo via the shared deterministic allocator
1072
+ // (`owner__name__digest`, matching the backend prompt's `siblingCheckoutDir`), shared with the
1073
+ // read-only explore fan-out.
1091
1074
  const claimDir = makeDirClaimer()
1092
1075
  const legs: RepoLeg[] = [
1093
1076
  {