@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/README.md +24 -2
- package/dist/agent.js +28 -14
- package/dist/captured-command.js +112 -0
- package/dist/coding-agent.js +90 -9
- package/dist/git.js +108 -319
- package/dist/host-markdown.js +142 -0
- package/dist/job.js +5 -46
- package/dist/pr-description.js +157 -0
- package/dist/reproduction-proof.js +614 -0
- package/dist/runner.js +3 -0
- package/dist/validation-checks.js +70 -82
- package/dist/vcs-api.js +402 -0
- package/package.json +4 -3
- package/src/agent.ts +28 -14
- package/src/captured-command.ts +144 -0
- package/src/coding-agent.ts +133 -6
- package/src/git.ts +134 -385
- package/src/host-markdown.ts +155 -0
- package/src/job.ts +32 -46
- package/src/pr-description.ts +171 -0
- package/src/reproduction-proof.ts +806 -0
- package/src/runner.ts +20 -0
- package/src/validation-checks.ts +71 -81
- package/src/vcs-api.ts +512 -0
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
|
+
}
|