@cat-factory/executor-harness 1.56.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/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 type { ValidationChecksSpec, ValidationReport } from './validation-checks.js'
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