@cat-factory/executor-harness 1.58.0 → 1.62.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/job.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { parseValidationChecksSpec, } from './validation-checks.js';
2
+ import { parseReproductionSpec, } from './reproduction-proof.js';
1
3
  function str(value, path) {
2
4
  if (typeof value !== 'string' || value.length === 0) {
3
5
  throw new Error(`Invalid job: '${path}' must be a non-empty string`);
@@ -66,39 +68,6 @@ function parseValidationSpec(value) {
66
68
  ...(iteration !== undefined ? { iteration } : {}),
67
69
  };
68
70
  }
69
- /**
70
- * Parse the optional PRE-PR VALIDATION CHECKS spec (see
71
- * docs/initiatives/pre-pr-validation.md): the service's ordered `{ label, command }` pairs and
72
- * the repair-round budget. Every entry needs a non-empty command; entries without one are
73
- * dropped, and a spec that ends up with no usable check returns `undefined` — so a malformed
74
- * body degrades to the exact pre-feature behaviour (no loop, PR opens as before) rather than
75
- * failing an otherwise-good coding run. `maxAttempts` is clamped to a sane range so a bad body
76
- * can't make a container loop forever.
77
- */
78
- function parseValidationChecksSpec(value) {
79
- if (typeof value !== 'object' || value === null)
80
- return undefined;
81
- const o = value;
82
- if (!Array.isArray(o.checks))
83
- return undefined;
84
- const checks = [];
85
- for (const raw of o.checks) {
86
- if (typeof raw !== 'object' || raw === null)
87
- continue;
88
- const c = raw;
89
- if (typeof c.command !== 'string' || c.command.trim() === '')
90
- continue;
91
- const label = typeof c.label === 'string' && c.label.trim() ? c.label.trim() : c.command;
92
- checks.push({ label, command: c.command });
93
- }
94
- if (checks.length === 0)
95
- return undefined;
96
- const parsed = posInt(o.maxAttempts);
97
- return {
98
- checks,
99
- maxAttempts: Math.min(parsed ?? VALIDATION_DEFAULT_MAX_ATTEMPTS, VALIDATION_MAX_ATTEMPTS_CEILING),
100
- };
101
- }
102
71
  /**
103
72
  * Parse the shared per-job auth fields, validating per harness: a subscription
104
73
  * harness (`claude-code` / `codex`) requires `subscriptionToken`; the default Pi
@@ -386,18 +355,6 @@ export function parseTestSecrets(value) {
386
355
  }
387
356
  return entries;
388
357
  }
389
- /**
390
- * The ceiling the harness clamps a body-supplied `validationChecks.maxAttempts` to, and the
391
- * default it applies when the body omits one.
392
- *
393
- * DELIBERATE DUPLICATES of `VALIDATION_MAX_ATTEMPTS_CEILING` / `VALIDATION_DEFAULT_MAX_ATTEMPTS`
394
- * in `@cat-factory/contracts` — the published image takes no schema dependency, so the harness
395
- * cannot import them. Keep the two in step: the API validates writes against the contracts
396
- * values, so a harness clamping to a DIFFERENT ceiling would silently cap a budget an operator
397
- * was allowed to save, with nothing to flag the mismatch.
398
- */
399
- export const VALIDATION_MAX_ATTEMPTS_CEILING = 10;
400
- export const VALIDATION_DEFAULT_MAX_ATTEMPTS = 3;
401
358
  /** Parse the coding-mode bootstrap spec, or undefined when absent. Validates the target. */
402
359
  function parseAgentBootstrapSpec(value) {
403
360
  if (typeof value !== 'object' || value === null)
@@ -717,6 +674,7 @@ export function parseAgentJob(input) {
717
674
  guardLimits: parseGuardLimits(o.guardLimits),
718
675
  validation: parseValidationSpec(o.validation),
719
676
  validationChecks: parseValidationChecksSpec(o.validationChecks),
677
+ reproduction: parseReproductionSpec(o.reproduction),
720
678
  reviewPrNumber: posInt(o.reviewPrNumber),
721
679
  });
722
680
  assertAllowedHost(job.repo.cloneUrl, 'repo.cloneUrl');
@@ -773,7 +731,7 @@ function parseAgentPrSpec(raw) {
773
731
  * literal doesn't blow the complexity budget; behaviour is byte-identical (spread order preserved).
774
732
  */
775
733
  function assembleAgentJob(o, mode, agentField, parts) {
776
- const { output, pr, infra, peerRepos, referenceRepos, referenceBranches, bootstrap, contextFiles, packageRegistries, skill, testSecrets, guardLimits, validation, validationChecks, reviewPrNumber, } = parts;
734
+ const { output, pr, infra, peerRepos, referenceRepos, referenceBranches, bootstrap, contextFiles, packageRegistries, skill, testSecrets, guardLimits, validation, validationChecks, reproduction, reviewPrNumber, } = parts;
777
735
  const repo = (o.repo ?? {});
778
736
  return {
779
737
  jobId: str(o.jobId, 'jobId'),
@@ -801,6 +759,7 @@ function assembleAgentJob(o, mode, agentField, parts) {
801
759
  ...(guardLimits ? { guardLimits } : {}),
802
760
  ...(validation ? { validation } : {}),
803
761
  ...(validationChecks ? { validationChecks } : {}),
762
+ ...(reproduction ? { reproduction } : {}),
804
763
  };
805
764
  }
806
765
  /**
@@ -0,0 +1,157 @@
1
+ import { readFile, rm } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { inertInline, inertMarkdown, walkFences } from './host-markdown.js';
4
+ import { redactSecrets } from './redact.js';
5
+ // ---------------------------------------------------------------------------
6
+ // The agent-authored pull-request description side channel. A coding agent whose
7
+ // dispatch opens a PR is asked (via the backend-composed system prompt) to end its
8
+ // run by writing a reviewer briefing — the problem, the decisions made, what to
9
+ // look out for — to a sentinel file at the root of the checkout the PR belongs to.
10
+ // The harness reads it after the agent settles, removes it (so it never lands in a
11
+ // commit), and uses it as the PR body in place of the generic dispatch-time text
12
+ // the job body carries. Absent or unusable ⇒ the dispatch-time fallback, unchanged.
13
+ //
14
+ // The briefing is MODEL-AUTHORED text landing verbatim on a host-parsed surface, so
15
+ // it crosses `host-markdown.ts` (auto-link triggers defused, open code fences closed)
16
+ // on the way out — see that module for why a PR body is not an inert string sink.
17
+ //
18
+ // The filename is kept in sync with `PR_DESCRIPTION_FILE` in `@cat-factory/agents`
19
+ // (the executor-harness has no dependency on that package), exactly like the
20
+ // effort-report and follow-ups sentinels.
21
+ // ---------------------------------------------------------------------------
22
+ /** The sentinel file the agent writes its PR description to (relative to the checkout root). */
23
+ export const PR_DESCRIPTION_FILE = '.cat-pr-description.md';
24
+ /**
25
+ * Ceiling on the agent-authored body.
26
+ *
27
+ * The engine appends its verification report to the SAME body later, and that section carries
28
+ * its own 50,000-character ceiling (`MAX_SECTION_CHARS` in kernel's `hostMarkdown`). GitHub
29
+ * rejects a body over 65,536 with a 422, and the report publisher swallows its own failures —
30
+ * so a briefing budget that does not leave the report room would surface as a report that
31
+ * silently never publishes. 15,000 + 50,000 stays under the limit with room to join them.
32
+ */
33
+ const MAX_PR_BODY_CHARS = 15_000;
34
+ /** Ceiling on an agent-supplied title (GitHub truncates around 256; a title should be short). */
35
+ const MAX_PR_TITLE_CHARS = 160;
36
+ /** Opens the engine-managed region of a PR body (kept in sync with `kernel/domain/pr-report.ts`). */
37
+ export const PR_REPORT_MARKER_START = '<!-- cat-factory:verification-report:start -->';
38
+ /** Closes the engine-managed region of a PR body. */
39
+ export const PR_REPORT_MARKER_END = '<!-- cat-factory:verification-report:end -->';
40
+ /**
41
+ * A marker inside the agent-authored briefing would make the engine's splice treat part of the
42
+ * briefing as its own managed region and rewrite it, so any occurrence is stripped up front.
43
+ * Deliberately laxer than the exact constants above (whitespace-tolerant), so a near-miss the
44
+ * splice itself would not match cannot survive here either.
45
+ */
46
+ const MANAGED_SECTION_MARKER = /<!--\s*cat-factory:verification-report:(?:start|end)\s*-->/g;
47
+ /**
48
+ * Read + parse + REMOVE the agent's PR-description sentinel from `dir`. Lenient: returns
49
+ * undefined when the file is absent (the agent wrote none) or carries nothing usable. Never
50
+ * throws — a bad description must never fail an otherwise-good run; the caller falls back to
51
+ * the dispatch-time text.
52
+ *
53
+ * A SINGLE `# <title>` heading on the first line sets the PR title; everything after it is the
54
+ * body (see {@link splitTitle} for why a LONE heading is required). The whole text is
55
+ * secret-scrubbed, an over-budget body is truncated WITH a visible note (a silent cut would
56
+ * read as the complete briefing), and both halves are made inert for the host.
57
+ *
58
+ * On scrubbing: `redactSecrets`'s credential-assignment rule is deliberately eager, so a
59
+ * briefing sentence like "the token: handling changed" loses its next word. That is the right
60
+ * trade for a surface this public — the rule is shared with every other redaction path, and
61
+ * narrowing it so prose reads better would weaken all of them.
62
+ */
63
+ export async function readPrDescription(dir) {
64
+ const path = join(dir, PR_DESCRIPTION_FILE);
65
+ let raw;
66
+ try {
67
+ raw = await readFile(path, 'utf8');
68
+ }
69
+ catch {
70
+ return undefined; // no description written — the fallback body applies
71
+ }
72
+ // Remove it so it never lands in a commit (defence in depth; the checkout also excludes it).
73
+ await rm(path, { force: true }).catch(() => { });
74
+ const text = redactSecrets(raw).replace(MANAGED_SECTION_MARKER, '').trim();
75
+ if (!text)
76
+ return undefined;
77
+ const split = splitTitle(text);
78
+ // Cap BEFORE the escapes on both halves, so a numeric entity can never be sliced in half.
79
+ const title = split.title ? inertInline(capTitle(split.title)) : undefined;
80
+ const body = split.body ? inertMarkdown(capBody(split.body)) : undefined;
81
+ if (!title && !body)
82
+ return undefined;
83
+ return { ...(title ? { title } : {}), ...(body ? { body } : {}) };
84
+ }
85
+ /**
86
+ * Split a leading `# <title>` heading off the briefing.
87
+ *
88
+ * The heading becomes the title ONLY when it is the single level-1 heading in the whole file,
89
+ * which is exactly what the prompt asks for ("a single `# <title>` heading line"). An agent
90
+ * that instead uses `#` for its section headings — `# Problem`, `# Decisions`, entirely
91
+ * idiomatic for the briefing the prompt describes — would otherwise have its first section
92
+ * silently become the pull request's title, replacing `<block> (<pipeline>)` with the word
93
+ * "Problem". Headings inside fenced code are not headings and are skipped, or a briefing
94
+ * quoting a shell snippet (`# rebuild the image`) would lose its title to the snippet.
95
+ */
96
+ function splitTitle(text) {
97
+ const lines = text.split('\n');
98
+ const headings = [];
99
+ let index = 0;
100
+ walkFences(lines, (line, insideFence) => {
101
+ if (!insideFence && /^#\s+\S/.test(line))
102
+ headings.push(index);
103
+ index += 1;
104
+ });
105
+ if (headings.length !== 1 || headings[0] !== 0)
106
+ return { body: text };
107
+ const title = lines[0].replace(/^#\s+/, '').trim();
108
+ if (!title)
109
+ return { body: text };
110
+ return { title, body: lines.slice(1).join('\n').trim() };
111
+ }
112
+ /** Cut an over-long title at a word boundary when one is near, marking the cut. */
113
+ function capTitle(value) {
114
+ const collapsed = value.trim();
115
+ if (collapsed.length <= MAX_PR_TITLE_CHARS)
116
+ return collapsed;
117
+ const head = collapsed.slice(0, MAX_PR_TITLE_CHARS - 1);
118
+ const space = head.lastIndexOf(' ');
119
+ const kept = space > MAX_PR_TITLE_CHARS * 0.6 ? head.slice(0, space) : head;
120
+ return `${kept.trimEnd()}…`;
121
+ }
122
+ /** Cut an over-budget body, marking the cut so it is never read as the whole briefing. */
123
+ function capBody(value) {
124
+ if (value.length <= MAX_PR_BODY_CHARS)
125
+ return value;
126
+ return (value.slice(0, MAX_PR_BODY_CHARS).trimEnd() +
127
+ '\n\n_Truncated by the platform: the description exceeded the size budget._');
128
+ }
129
+ /**
130
+ * Fold an agent-authored description over the dispatch-time fallback the job body carries.
131
+ * Field-wise: the agent's title/body each win when present, so a body-only briefing keeps the
132
+ * backend-composed title and vice versa.
133
+ */
134
+ export function applyPrDescription(fallback, agent) {
135
+ if (!agent)
136
+ return fallback;
137
+ return { title: agent.title ?? fallback.title, body: agent.body ?? fallback.body };
138
+ }
139
+ /**
140
+ * The body to PATCH onto an ALREADY-OPEN pull request when a resumed run produced a fresh
141
+ * briefing: the new description followed by whatever the engine's managed verification-report
142
+ * region currently holds.
143
+ *
144
+ * Carrying the region across is what makes the refresh safe. The engine re-publishes the report
145
+ * on every step settlement, so dropping it here would usually self-heal — but "usually" is not
146
+ * a property to rest the one artefact a reviewer reads on, and a run that settles no further
147
+ * step (the work is already merged, the run failed after its push) would never restore it.
148
+ */
149
+ export function preserveManagedSection(currentBody, nextBody) {
150
+ const existing = currentBody ?? '';
151
+ const start = existing.indexOf(PR_REPORT_MARKER_START);
152
+ const end = existing.indexOf(PR_REPORT_MARKER_END);
153
+ if (start === -1 || end <= start)
154
+ return nextBody;
155
+ const region = existing.slice(start, end + PR_REPORT_MARKER_END.length);
156
+ return `${nextBody.trim()}\n\n${region}\n`;
157
+ }