@cat-factory/executor-harness 1.58.0 → 1.60.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 +15 -2
- package/dist/agent.js +20 -12
- package/dist/captured-command.js +112 -0
- package/dist/coding-agent.js +59 -6
- package/dist/git.js +108 -0
- package/dist/job.js +5 -46
- package/dist/reproduction-proof.js +614 -0
- package/dist/runner.js +3 -0
- package/dist/validation-checks.js +70 -82
- package/package.json +3 -3
- package/src/agent.ts +20 -11
- package/src/captured-command.ts +144 -0
- package/src/coding-agent.ts +89 -4
- package/src/git.ts +133 -0
- package/src/job.ts +32 -46
- package/src/reproduction-proof.ts +806 -0
- package/src/runner.ts +20 -0
- package/src/validation-checks.ts +71 -81
package/src/git.ts
CHANGED
|
@@ -648,6 +648,46 @@ export async function branchAheadOfBase(
|
|
|
648
648
|
}
|
|
649
649
|
}
|
|
650
650
|
|
|
651
|
+
/**
|
|
652
|
+
* The files `commitish` changes relative to its merge base with the PR base branch — i.e.
|
|
653
|
+
* everything the work branch has added on top of base, `git diff --name-only <base>...<commitish>`.
|
|
654
|
+
*
|
|
655
|
+
* The BUGFIX REPRODUCTION PROOF uses this to answer the one question that decides whether a GREEN
|
|
656
|
+
* pre-fix tree means anything: does that tree ALREADY carry non-test work committed on this
|
|
657
|
+
* branch? A resumed run's `baseSha` is whatever the branch tip was when this pass started, which
|
|
658
|
+
* in the designed flow is the reproduction step's test commit — but after an eviction it is this
|
|
659
|
+
* same coder step's own interrupted work, fix included. Reporting "the check passed before your
|
|
660
|
+
* change, so it does not demonstrate the defect" in that case is simply false.
|
|
661
|
+
*
|
|
662
|
+
* `undefined` means "could not determine" (a shallow clone with no reachable merge base, a fetch
|
|
663
|
+
* failure, an unknown ref), never an empty list: the caller must degrade to its prior behaviour
|
|
664
|
+
* rather than read a failed probe as "the tree is clean".
|
|
665
|
+
*
|
|
666
|
+
* NUL-delimited so a path containing a newline (legal in git) cannot split into two entries.
|
|
667
|
+
*/
|
|
668
|
+
export async function changedFilesSinceBase(
|
|
669
|
+
dir: string,
|
|
670
|
+
baseBranch: string,
|
|
671
|
+
ghToken: string,
|
|
672
|
+
commitish: string,
|
|
673
|
+
signal?: AbortSignal,
|
|
674
|
+
): Promise<string[] | undefined> {
|
|
675
|
+
try {
|
|
676
|
+
await git(['fetch', 'origin', `+refs/heads/${baseBranch}:refs/cat-factory/base`], {
|
|
677
|
+
cwd: dir,
|
|
678
|
+
signal,
|
|
679
|
+
env: await authEnv(ghToken),
|
|
680
|
+
})
|
|
681
|
+
const out = await git(['diff', '--name-only', '-z', `refs/cat-factory/base...${commitish}`], {
|
|
682
|
+
cwd: dir,
|
|
683
|
+
signal,
|
|
684
|
+
})
|
|
685
|
+
return out.split('\0').filter((p) => p !== '')
|
|
686
|
+
} catch {
|
|
687
|
+
return undefined
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
651
691
|
/**
|
|
652
692
|
* Whether the checked-out branch has a real, examinable diff against
|
|
653
693
|
* `origin/<baseBranch>` — i.e. the base branch's remote-tracking ref exists (so the
|
|
@@ -708,6 +748,99 @@ export async function headCommit(dir: string, signal?: AbortSignal): Promise<str
|
|
|
708
748
|
return (await git(['rev-parse', 'HEAD'], { cwd: dir, signal })).trim()
|
|
709
749
|
}
|
|
710
750
|
|
|
751
|
+
/**
|
|
752
|
+
* Add a DETACHED worktree of `commitish` at `worktreePath`, sharing `dir`'s object database.
|
|
753
|
+
*
|
|
754
|
+
* The bugfix reproduction proof runs the declared check against two trees of the SAME clone (the
|
|
755
|
+
* pre-fix tree and the final tree), so a worktree is the only mechanism that gets both without a
|
|
756
|
+
* second clone, a second fetch, or disturbing the agent's own checkout — which must stay exactly
|
|
757
|
+
* as the agent left it, since the push and the PR come off it.
|
|
758
|
+
*
|
|
759
|
+
* `--detach` (rather than a branch) is deliberate: a worktree that claimed a branch would collide
|
|
760
|
+
* with the work branch checked out in `dir`, and nothing here ever commits.
|
|
761
|
+
*
|
|
762
|
+
* `worktreePath` is expected to live OUTSIDE the checkout (a per-job temp root), so the worktree's
|
|
763
|
+
* `.git` pointer file can never be swept into the agent's commit by a broad `git add -A`.
|
|
764
|
+
*/
|
|
765
|
+
export async function addWorktree(
|
|
766
|
+
dir: string,
|
|
767
|
+
worktreePath: string,
|
|
768
|
+
commitish: string,
|
|
769
|
+
signal?: AbortSignal,
|
|
770
|
+
): Promise<void> {
|
|
771
|
+
await git(['worktree', 'add', '--detach', worktreePath, commitish], { cwd: dir, signal })
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Remove a worktree previously added by {@link addWorktree} and prune the stale administrative
|
|
776
|
+
* entry, never throwing: teardown is bookkeeping, and a run whose PROOF succeeded must not fail
|
|
777
|
+
* because a temp directory could not be cleaned up. The caller still deletes the temp root, so a
|
|
778
|
+
* failure here leaks only a `.git/worktrees/<name>` record inside a container that is about to be
|
|
779
|
+
* destroyed anyway.
|
|
780
|
+
*/
|
|
781
|
+
export async function removeWorktree(
|
|
782
|
+
dir: string,
|
|
783
|
+
worktreePath: string,
|
|
784
|
+
signal?: AbortSignal,
|
|
785
|
+
): Promise<void> {
|
|
786
|
+
try {
|
|
787
|
+
await git(['worktree', 'remove', '--force', worktreePath], { cwd: dir, signal })
|
|
788
|
+
} catch {
|
|
789
|
+
// Fall through to the prune, which cleans up the record even when the directory is gone.
|
|
790
|
+
}
|
|
791
|
+
try {
|
|
792
|
+
await git(['worktree', 'prune'], { cwd: dir, signal })
|
|
793
|
+
} catch {
|
|
794
|
+
// Best-effort by design (see the doc comment).
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* Which of `paths` actually exist in `commitish`'s tree. Used by the reproduction proof to tell a
|
|
800
|
+
* DECLARED test file that was committed from one that only ever existed as an untracked working-
|
|
801
|
+
* tree file: the proof runs against committed trees, so an unadded test is invisible to it — and
|
|
802
|
+
* equally invisible to the push, which is the point worth telling the agent about rather than
|
|
803
|
+
* reporting a verdict computed without the reproduction in it.
|
|
804
|
+
*
|
|
805
|
+
* Returns the input order/spelling of the paths that matched, so the caller can diff against its
|
|
806
|
+
* declared list to name the missing ones verbatim.
|
|
807
|
+
*/
|
|
808
|
+
export async function pathsPresentAtCommit(
|
|
809
|
+
dir: string,
|
|
810
|
+
commitish: string,
|
|
811
|
+
paths: readonly string[],
|
|
812
|
+
signal?: AbortSignal,
|
|
813
|
+
): Promise<string[]> {
|
|
814
|
+
if (paths.length === 0) return []
|
|
815
|
+
const out = await git(['ls-tree', '-r', '--name-only', '-z', commitish, '--', ...paths], {
|
|
816
|
+
cwd: dir,
|
|
817
|
+
signal,
|
|
818
|
+
})
|
|
819
|
+
// NUL-delimited so a path containing a newline (legal in git) can't split into two entries.
|
|
820
|
+
const present = new Set(out.split('\0').filter((p) => p !== ''))
|
|
821
|
+
return paths.filter((p) => present.has(p))
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* Check `paths` out of `commitish` into `dir`'s working tree (and index), leaving every other file
|
|
826
|
+
* untouched.
|
|
827
|
+
*
|
|
828
|
+
* This is how the reproduction's declared TEST files are placed onto the pre-fix worktree, and the
|
|
829
|
+
* narrowness is the whole safety property: a whole-tree checkout would drag the FIX across too and
|
|
830
|
+
* green the base, manufacturing a "the test does not capture the defect" verdict out of a
|
|
831
|
+
* perfectly good reproduction. Only the paths the caller has already sanitized are passed, and
|
|
832
|
+
* `--` stops any of them being read as a revision.
|
|
833
|
+
*/
|
|
834
|
+
export async function checkoutPathsFrom(
|
|
835
|
+
dir: string,
|
|
836
|
+
commitish: string,
|
|
837
|
+
paths: readonly string[],
|
|
838
|
+
signal?: AbortSignal,
|
|
839
|
+
): Promise<void> {
|
|
840
|
+
if (paths.length === 0) return
|
|
841
|
+
await git(['checkout', commitish, '--', ...paths], { cwd: dir, signal })
|
|
842
|
+
}
|
|
843
|
+
|
|
711
844
|
/** Stage everything and commit; returns false when there was nothing to commit. */
|
|
712
845
|
export async function commitAll(
|
|
713
846
|
dir: string,
|
package/src/job.ts
CHANGED
|
@@ -2,7 +2,16 @@ import type { HarnessCallMetric, PiRunStats } from './pi.js'
|
|
|
2
2
|
import type { HarnessKind } from './pi-workspace.js'
|
|
3
3
|
import type { FailureCause } from './failure.js'
|
|
4
4
|
import type { EffortReport } from './effort.js'
|
|
5
|
-
import
|
|
5
|
+
import {
|
|
6
|
+
parseValidationChecksSpec,
|
|
7
|
+
type ValidationChecksSpec,
|
|
8
|
+
type ValidationReport,
|
|
9
|
+
} from './validation-checks.js'
|
|
10
|
+
import {
|
|
11
|
+
parseReproductionSpec,
|
|
12
|
+
type ReproductionReport,
|
|
13
|
+
type ReproductionSpec,
|
|
14
|
+
} from './reproduction-proof.js'
|
|
6
15
|
|
|
7
16
|
// The job the Worker's ContainerAgentExecutor POSTs to /run. Kept as plain
|
|
8
17
|
// types with a hand-rolled validator so the image needs no schema dependency.
|
|
@@ -175,38 +184,6 @@ function parseValidationSpec(value: unknown): ValidationSpec | undefined {
|
|
|
175
184
|
}
|
|
176
185
|
}
|
|
177
186
|
|
|
178
|
-
/**
|
|
179
|
-
* Parse the optional PRE-PR VALIDATION CHECKS spec (see
|
|
180
|
-
* docs/initiatives/pre-pr-validation.md): the service's ordered `{ label, command }` pairs and
|
|
181
|
-
* the repair-round budget. Every entry needs a non-empty command; entries without one are
|
|
182
|
-
* dropped, and a spec that ends up with no usable check returns `undefined` — so a malformed
|
|
183
|
-
* body degrades to the exact pre-feature behaviour (no loop, PR opens as before) rather than
|
|
184
|
-
* failing an otherwise-good coding run. `maxAttempts` is clamped to a sane range so a bad body
|
|
185
|
-
* can't make a container loop forever.
|
|
186
|
-
*/
|
|
187
|
-
function parseValidationChecksSpec(value: unknown): ValidationChecksSpec | undefined {
|
|
188
|
-
if (typeof value !== 'object' || value === null) return undefined
|
|
189
|
-
const o = value as Record<string, unknown>
|
|
190
|
-
if (!Array.isArray(o.checks)) return undefined
|
|
191
|
-
const checks: { label: string; command: string }[] = []
|
|
192
|
-
for (const raw of o.checks) {
|
|
193
|
-
if (typeof raw !== 'object' || raw === null) continue
|
|
194
|
-
const c = raw as Record<string, unknown>
|
|
195
|
-
if (typeof c.command !== 'string' || c.command.trim() === '') continue
|
|
196
|
-
const label = typeof c.label === 'string' && c.label.trim() ? c.label.trim() : c.command
|
|
197
|
-
checks.push({ label, command: c.command })
|
|
198
|
-
}
|
|
199
|
-
if (checks.length === 0) return undefined
|
|
200
|
-
const parsed = posInt(o.maxAttempts)
|
|
201
|
-
return {
|
|
202
|
-
checks,
|
|
203
|
-
maxAttempts: Math.min(
|
|
204
|
-
parsed ?? VALIDATION_DEFAULT_MAX_ATTEMPTS,
|
|
205
|
-
VALIDATION_MAX_ATTEMPTS_CEILING,
|
|
206
|
-
),
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
187
|
/**
|
|
211
188
|
* Parse the shared per-job auth fields, validating per harness: a subscription
|
|
212
189
|
* harness (`claude-code` / `codex`) requires `subscriptionToken`; the default Pi
|
|
@@ -872,21 +849,18 @@ export interface AgentJob extends HarnessAuthFields {
|
|
|
872
849
|
* job DATA, not the agent kind. See {@link ValidationChecksSpec}.
|
|
873
850
|
*/
|
|
874
851
|
validationChecks?: ValidationChecksSpec
|
|
852
|
+
/**
|
|
853
|
+
* Coding mode: the run's BUGFIX REPRODUCTION PROOF — the declared reproduction command, the
|
|
854
|
+
* test file(s) that constitute it, and an optional setup command. When set, the harness runs
|
|
855
|
+
* that command against the pre-fix tree AND the tree the PR will open from, and reports both
|
|
856
|
+
* exit codes: only red-then-green is proof. Present only on a dispatch that opens a PR and
|
|
857
|
+
* whose run carries a reproduction declaration; absent ⇒ the run behaves exactly as before.
|
|
858
|
+
* Deliberately keyed off job DATA, not the agent kind. See
|
|
859
|
+
* `docs/initiatives/bugfix-reproduction-proof.md`.
|
|
860
|
+
*/
|
|
861
|
+
reproduction?: ReproductionSpec
|
|
875
862
|
}
|
|
876
863
|
|
|
877
|
-
/**
|
|
878
|
-
* The ceiling the harness clamps a body-supplied `validationChecks.maxAttempts` to, and the
|
|
879
|
-
* default it applies when the body omits one.
|
|
880
|
-
*
|
|
881
|
-
* DELIBERATE DUPLICATES of `VALIDATION_MAX_ATTEMPTS_CEILING` / `VALIDATION_DEFAULT_MAX_ATTEMPTS`
|
|
882
|
-
* in `@cat-factory/contracts` — the published image takes no schema dependency, so the harness
|
|
883
|
-
* cannot import them. Keep the two in step: the API validates writes against the contracts
|
|
884
|
-
* values, so a harness clamping to a DIFFERENT ceiling would silently cap a budget an operator
|
|
885
|
-
* was allowed to save, with nothing to flag the mismatch.
|
|
886
|
-
*/
|
|
887
|
-
export const VALIDATION_MAX_ATTEMPTS_CEILING = 10
|
|
888
|
-
export const VALIDATION_DEFAULT_MAX_ATTEMPTS = 3
|
|
889
|
-
|
|
890
864
|
/** Per-job, per-knob progress-guard overrides (see {@link AgentJob.guardLimits}). */
|
|
891
865
|
export interface GuardLimitsSpec {
|
|
892
866
|
maxToolCallsWithoutEdit?: number
|
|
@@ -938,6 +912,14 @@ export interface AgentResult {
|
|
|
938
912
|
* Absent when the job carried no {@link AgentJob.validationChecks}.
|
|
939
913
|
*/
|
|
940
914
|
validationReport?: ValidationReport
|
|
915
|
+
/**
|
|
916
|
+
* The BUGFIX REPRODUCTION PROOF: the declared reproduction command's verdict across the pre-fix
|
|
917
|
+
* tree and the final tree, computed by the harness from exit codes. Present on every outcome of
|
|
918
|
+
* a job that carried {@link AgentJob.reproduction} — a verdict is evidence, not a gate, so an
|
|
919
|
+
* `inconclusive` one accompanies the opened PR exactly like a `reproduced` one does. Absent
|
|
920
|
+
* when the job carried no reproduction declaration.
|
|
921
|
+
*/
|
|
922
|
+
reproductionReport?: ReproductionReport
|
|
941
923
|
/**
|
|
942
924
|
* Preview mode: the in-container URL the built app is served at (e.g. `http://localhost:4173`).
|
|
943
925
|
* This is NOT host-reachable on its own — the container runtime publishes the serve port to an
|
|
@@ -1345,6 +1327,7 @@ export function parseAgentJob(input: unknown): AgentJob {
|
|
|
1345
1327
|
guardLimits: parseGuardLimits(o.guardLimits),
|
|
1346
1328
|
validation: parseValidationSpec(o.validation),
|
|
1347
1329
|
validationChecks: parseValidationChecksSpec(o.validationChecks),
|
|
1330
|
+
reproduction: parseReproductionSpec(o.reproduction),
|
|
1348
1331
|
reviewPrNumber: posInt(o.reviewPrNumber),
|
|
1349
1332
|
})
|
|
1350
1333
|
assertAllowedHost(job.repo.cloneUrl, 'repo.cloneUrl')
|
|
@@ -1383,6 +1366,7 @@ interface ParsedAgentJobParts {
|
|
|
1383
1366
|
guardLimits: ReturnType<typeof parseGuardLimits>
|
|
1384
1367
|
validation: ReturnType<typeof parseValidationSpec>
|
|
1385
1368
|
validationChecks: ReturnType<typeof parseValidationChecksSpec>
|
|
1369
|
+
reproduction: ReturnType<typeof parseReproductionSpec>
|
|
1386
1370
|
reviewPrNumber: number | undefined
|
|
1387
1371
|
}
|
|
1388
1372
|
|
|
@@ -1436,6 +1420,7 @@ function assembleAgentJob(
|
|
|
1436
1420
|
guardLimits,
|
|
1437
1421
|
validation,
|
|
1438
1422
|
validationChecks,
|
|
1423
|
+
reproduction,
|
|
1439
1424
|
reviewPrNumber,
|
|
1440
1425
|
} = parts
|
|
1441
1426
|
const repo = (o.repo ?? {}) as Record<string, unknown>
|
|
@@ -1465,6 +1450,7 @@ function assembleAgentJob(
|
|
|
1465
1450
|
...(guardLimits ? { guardLimits } : {}),
|
|
1466
1451
|
...(validation ? { validation } : {}),
|
|
1467
1452
|
...(validationChecks ? { validationChecks } : {}),
|
|
1453
|
+
...(reproduction ? { reproduction } : {}),
|
|
1468
1454
|
}
|
|
1469
1455
|
}
|
|
1470
1456
|
|