@cat-factory/executor-harness 1.100.0 → 1.104.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 +27 -0
- package/dist/agent-shared.d.ts +7 -5
- package/dist/agent-shared.js +6 -4
- package/dist/agent.js +2 -1
- package/dist/checkout-dir.d.ts +33 -0
- package/dist/checkout-dir.js +56 -0
- package/dist/coding-agent.d.ts +9 -14
- package/dist/coding-agent.js +8 -19
- package/dist/context-manifests.d.ts +69 -0
- package/dist/context-manifests.js +110 -0
- package/dist/job.d.ts +9 -12
- package/dist/job.js +4 -40
- package/dist/pi-workspace.d.ts +7 -1
- package/dist/pi-workspace.js +18 -1
- package/dist/pi.d.ts +20 -0
- package/dist/pi.js +21 -13
- package/dist/reference-screenshots.d.ts +71 -0
- package/dist/reference-screenshots.js +261 -0
- package/package.json +4 -4
- package/src/agent-shared.ts +14 -5
- package/src/agent.ts +2 -6
- package/src/checkout-dir.ts +62 -0
- package/src/coding-agent.ts +17 -21
- package/src/context-manifests.ts +156 -0
- package/src/job.ts +22 -48
- package/src/pi-workspace.ts +25 -2
- package/src/pi.ts +29 -11
- package/src/reference-screenshots.ts +295 -0
package/dist/pi-workspace.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises';
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { deliverReferenceScreenshots } from './reference-screenshots.js';
|
|
4
5
|
import { readEffortReport } from './effort.js';
|
|
5
6
|
import { log } from './logger.js';
|
|
6
7
|
import { CONTEXT_DIR, materializeContextFiles, materializeSkillResources, phasedProxyBaseUrl, runPi, webSearchConfigFromEnv, webSearchProxyEnv, writeAgentsContext, writePiModelsConfig, writeWebToolsConfig, } from './pi.js';
|
|
@@ -142,6 +143,21 @@ export async function runAgentInWorkspace(spec, opts = {}) {
|
|
|
142
143
|
// harness paths; kept out of the agent's commits via a local git exclude entry.
|
|
143
144
|
const contextFiles = spec.contextFiles ?? [];
|
|
144
145
|
await materializeContextFiles(spec.dir, contextFiles);
|
|
146
|
+
// The task's reference designs, fetched into `.cat-context/reference-screenshots/` for the kinds
|
|
147
|
+
// that capture views. Delivered here (beside the linked context, before either harness path
|
|
148
|
+
// branches) so the Pi and subscription runs are handed the SAME directory and the SAME view
|
|
149
|
+
// names; a per-path copy is how one of them would end up silently without it.
|
|
150
|
+
//
|
|
151
|
+
// This runs once per PASS, not once per job: a coding flow re-enters its workspace for every
|
|
152
|
+
// repair round. That is safe because the delivery is idempotent over the checkout (a file
|
|
153
|
+
// already on disk is counted, never re-fetched), so a later round costs a stat per reference and
|
|
154
|
+
// cannot report a view an earlier round successfully delivered as absent. A view that MISSED is
|
|
155
|
+
// retried, which is the behaviour worth having: the next round is a fresh chance at a blob
|
|
156
|
+
// backend that was briefly down.
|
|
157
|
+
const referenceGuidance = await deliverReferenceScreenshots(spec.dir, spec.referenceScreenshots, {
|
|
158
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
159
|
+
log: opts.log ?? log,
|
|
160
|
+
});
|
|
145
161
|
// Skills: claude-code installs them natively into its ISOLATED config dir, so it reads from
|
|
146
162
|
// there. Everything else reads the checkout, so materialise each skill's resources under
|
|
147
163
|
// `.cat-context/skill/<name>/` (their instructions are folded into the prompt by the backend) —
|
|
@@ -164,7 +180,7 @@ export async function runAgentInWorkspace(spec, opts = {}) {
|
|
|
164
180
|
const subOutcome = await runSubscriptionHarness(spec.harness, {
|
|
165
181
|
cwd: spec.dir,
|
|
166
182
|
model: spec.model,
|
|
167
|
-
systemPrompt: subscriptionSystemPrompt(spec.systemPrompt, contextFiles)
|
|
183
|
+
systemPrompt: `${subscriptionSystemPrompt(spec.systemPrompt, contextFiles)}${referenceGuidance}`,
|
|
168
184
|
userPrompt: spec.userPrompt,
|
|
169
185
|
...(spec.subscriptionToken ? { subscriptionToken: spec.subscriptionToken } : {}),
|
|
170
186
|
subscriptionBaseUrl: spec.subscriptionBaseUrl,
|
|
@@ -235,6 +251,7 @@ export async function runAgentInWorkspace(spec, opts = {}) {
|
|
|
235
251
|
serviceDirectory: spec.serviceDirectory,
|
|
236
252
|
contextFiles,
|
|
237
253
|
hasBlueprints,
|
|
254
|
+
...(referenceGuidance ? { referenceGuidance } : {}),
|
|
238
255
|
...(spec.multiRepo ? { multiRepo: true } : {}),
|
|
239
256
|
});
|
|
240
257
|
// Pi's calls are metered server-side by the LLM proxy, which sees only an HTTP request — so
|
package/dist/pi.d.ts
CHANGED
|
@@ -71,6 +71,13 @@ export declare function writeAgentsContext(systemPrompt: string, opts?: {
|
|
|
71
71
|
* every turn) pointing at files that don't exist. Absent/false ⇒ the note is omitted.
|
|
72
72
|
*/
|
|
73
73
|
hasBlueprints?: boolean;
|
|
74
|
+
/**
|
|
75
|
+
* The reference-design block composed by `referenceScreenshotGuidance`: which files the run
|
|
76
|
+
* was handed and which it could not fetch. Composed by the caller (it is the only side that
|
|
77
|
+
* knows what actually landed on disk) and appended verbatim. Absent/'' ⇒ nothing is said,
|
|
78
|
+
* which is the normal case: only a capturing kind is sent references at all.
|
|
79
|
+
*/
|
|
80
|
+
referenceGuidance?: string;
|
|
74
81
|
}): Promise<void>;
|
|
75
82
|
/** Directory in the checkout where linked-context files are materialised (see CONTEXT_DIR in agents). */
|
|
76
83
|
export declare const CONTEXT_DIR = ".cat-context";
|
|
@@ -88,6 +95,19 @@ export interface ContextFileInfo {
|
|
|
88
95
|
* (a scaffold-from-scratch checkout has no `.git` yet — the files just stay untracked).
|
|
89
96
|
*/
|
|
90
97
|
export declare function materializeContextFiles(cwd: string, files: ContextFileInfo[]): Promise<void>;
|
|
98
|
+
/**
|
|
99
|
+
* Add the LOCAL git exclude entry for {@link CONTEXT_DIR}, so nothing the harness materialises
|
|
100
|
+
* there can be committed into the agent's PR by a `git add -A`.
|
|
101
|
+
*
|
|
102
|
+
* The exclude pattern has no leading slash, so it matches `.cat-context/` at any depth, covering
|
|
103
|
+
* the monorepo case where cwd is a service subdirectory below the repo root. Best-effort: a
|
|
104
|
+
* scaffold-from-scratch checkout has no `.git` yet, and the files then simply stay untracked.
|
|
105
|
+
*
|
|
106
|
+
* One helper rather than a copy per materialiser: every writer into that directory owes the same
|
|
107
|
+
* exclude, and a new one that forgot it would leak the platform's own files into a customer's
|
|
108
|
+
* repository with nothing failing.
|
|
109
|
+
*/
|
|
110
|
+
export declare function excludeContextDir(cwd: string): Promise<void>;
|
|
91
111
|
/** Subdirectory of {@link CONTEXT_DIR} where a skill's resources are materialised, per skill. */
|
|
92
112
|
export declare const SKILL_CONTEXT_SUBDIR = "skill";
|
|
93
113
|
/**
|
package/dist/pi.js
CHANGED
|
@@ -197,7 +197,11 @@ export async function writeAgentsContext(systemPrompt, opts = {}) {
|
|
|
197
197
|
// (see the note above `writeAgentsContext`): it comes solely from the backend `spec-aware`
|
|
198
198
|
// trait, so a spec-aware run no longer carries it twice.
|
|
199
199
|
const blueprint = opts.hasBlueprints ? BLUEPRINT_GUIDANCE : '';
|
|
200
|
-
|
|
200
|
+
// The reference designs the harness downloaded for a capturing kind, listed with their view
|
|
201
|
+
// names (and the ones that could not be fetched). Last, beside the linked-context list it is the
|
|
202
|
+
// sibling of: both point the agent at files already on disk.
|
|
203
|
+
const references = opts.referenceGuidance ?? '';
|
|
204
|
+
await writeFile(join(dir, 'AGENTS.md'), `${systemPrompt}${blueprint}${TODO_GUIDANCE}${monorepo}${multiRepo}${webTools}${context}${references}`, 'utf8');
|
|
201
205
|
}
|
|
202
206
|
/** The MULTI-REPO mechanics note appended to AGENTS.md when a run spans sibling checkouts. */
|
|
203
207
|
const MULTI_REPO_GUIDANCE = `
|
|
@@ -247,9 +251,21 @@ export async function materializeContextFiles(cwd, files) {
|
|
|
247
251
|
await mkdir(dir, { recursive: true });
|
|
248
252
|
for (const f of files)
|
|
249
253
|
await writeFile(join(dir, f.path), f.content, 'utf8');
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
254
|
+
await excludeContextDir(cwd);
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Add the LOCAL git exclude entry for {@link CONTEXT_DIR}, so nothing the harness materialises
|
|
258
|
+
* there can be committed into the agent's PR by a `git add -A`.
|
|
259
|
+
*
|
|
260
|
+
* The exclude pattern has no leading slash, so it matches `.cat-context/` at any depth, covering
|
|
261
|
+
* the monorepo case where cwd is a service subdirectory below the repo root. Best-effort: a
|
|
262
|
+
* scaffold-from-scratch checkout has no `.git` yet, and the files then simply stay untracked.
|
|
263
|
+
*
|
|
264
|
+
* One helper rather than a copy per materialiser: every writer into that directory owes the same
|
|
265
|
+
* exclude, and a new one that forgot it would leak the platform's own files into a customer's
|
|
266
|
+
* repository with nothing failing.
|
|
267
|
+
*/
|
|
268
|
+
export async function excludeContextDir(cwd) {
|
|
253
269
|
const gitRoot = await findGitRoot(cwd);
|
|
254
270
|
if (!gitRoot)
|
|
255
271
|
return;
|
|
@@ -289,15 +305,7 @@ export async function materializeSkillResources(cwd, skills) {
|
|
|
289
305
|
await writeFile(dest, r.content, 'utf8');
|
|
290
306
|
}
|
|
291
307
|
}
|
|
292
|
-
|
|
293
|
-
if (!gitRoot)
|
|
294
|
-
return;
|
|
295
|
-
try {
|
|
296
|
-
await appendFile(join(gitRoot, '.git', 'info', 'exclude'), `\n${CONTEXT_DIR}/\n`, 'utf8');
|
|
297
|
-
}
|
|
298
|
-
catch {
|
|
299
|
-
// No writable .git/info; the files simply stay untracked.
|
|
300
|
-
}
|
|
308
|
+
await excludeContextDir(cwd);
|
|
301
309
|
}
|
|
302
310
|
/** Walk up from `dir` (bounded) to the directory containing a `.git` folder, or null. */
|
|
303
311
|
async function findGitRoot(dir) {
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ReferenceScreenshotsSpec } from './job.js';
|
|
2
|
+
import type { Logger } from './logger.js';
|
|
3
|
+
/** Subdirectory of {@link CONTEXT_DIR} the reference designs are written to. */
|
|
4
|
+
export declare const REFERENCE_SCREENSHOT_SUBDIR = "reference-screenshots";
|
|
5
|
+
/** What the pass has on disk, and what it does not. */
|
|
6
|
+
export interface ReferenceScreenshotOutcome {
|
|
7
|
+
written: {
|
|
8
|
+
fileName: string;
|
|
9
|
+
view: string;
|
|
10
|
+
}[];
|
|
11
|
+
/**
|
|
12
|
+
* One entry per reference that is NOT on disk, with the cause stated in `reason`. Covers both
|
|
13
|
+
* halves of that absence, because the agent's job is the same either way (capture the view under
|
|
14
|
+
* its own name, with nothing to compare against): a transfer that failed, and a view the cap
|
|
15
|
+
* dropped before this container was ever asked to fetch it.
|
|
16
|
+
*/
|
|
17
|
+
missing: {
|
|
18
|
+
view: string;
|
|
19
|
+
reason: string;
|
|
20
|
+
}[];
|
|
21
|
+
/** Where the written files live, relative to the checkout root. */
|
|
22
|
+
dir: string;
|
|
23
|
+
}
|
|
24
|
+
/** The relative directory the references are written to (what the prompt points the agent at). */
|
|
25
|
+
export declare const REFERENCE_SCREENSHOT_DIR = ".cat-context/reference-screenshots";
|
|
26
|
+
/**
|
|
27
|
+
* Download the manifest's images into the checkout and report what landed.
|
|
28
|
+
*
|
|
29
|
+
* IDEMPOTENT, and that is load-bearing rather than an optimisation: an agent flow re-enters its
|
|
30
|
+
* workspace once per repair round, so this pass runs several times over one checkout. A file
|
|
31
|
+
* already on disk is counted and never re-fetched, which keeps a later round from spending the
|
|
32
|
+
* budget again AND from reporting a view as absent that pass 1 successfully delivered. A view that
|
|
33
|
+
* MISSED is retried, since the next round is a fresh chance at whatever was transiently down.
|
|
34
|
+
*
|
|
35
|
+
* Never throws: references are an aid to a comparison, not a precondition for running, so a
|
|
36
|
+
* backend outage degrades a UI run to "name your own views" (the documented fallback) rather than
|
|
37
|
+
* failing it. Every miss is carried out on {@link ReferenceScreenshotOutcome.missing} so the caller
|
|
38
|
+
* can say so in the prompt, which is the difference between a design the platform failed to hand
|
|
39
|
+
* over and one that has no such screen.
|
|
40
|
+
*/
|
|
41
|
+
export declare function materializeReferenceScreenshots(cwd: string, spec: ReferenceScreenshotsSpec, options?: {
|
|
42
|
+
signal?: AbortSignal;
|
|
43
|
+
fetchImpl?: typeof fetch;
|
|
44
|
+
}): Promise<ReferenceScreenshotOutcome>;
|
|
45
|
+
/**
|
|
46
|
+
* The whole delivery in one call: download the manifest (when there is one) and answer the prompt
|
|
47
|
+
* block naming what landed, reporting any miss to the operator on the way.
|
|
48
|
+
*
|
|
49
|
+
* One entry point so an agent-running flow cannot end up doing half of it. A miss is stated to the
|
|
50
|
+
* AGENT in its prompt (it still has to capture that view) AND logged here, because a reference that
|
|
51
|
+
* never arrives is otherwise invisible in the run's output: the gallery simply pairs against
|
|
52
|
+
* nothing, months later, with no line anywhere saying why.
|
|
53
|
+
*/
|
|
54
|
+
export declare function deliverReferenceScreenshots(cwd: string, spec: ReferenceScreenshotsSpec | undefined, options: {
|
|
55
|
+
signal?: AbortSignal;
|
|
56
|
+
log: Logger;
|
|
57
|
+
fetchImpl?: typeof fetch;
|
|
58
|
+
}): Promise<string>;
|
|
59
|
+
/**
|
|
60
|
+
* The prompt block naming what the agent was handed, or '' when the pass produced nothing at all.
|
|
61
|
+
*
|
|
62
|
+
* States the MISSES beside the files, because the whole point of writing this directory is that
|
|
63
|
+
* the tester captures the same views the gate will pair against: a reference that did not arrive
|
|
64
|
+
* is a view the agent should still capture (under that name) rather than one that does not exist.
|
|
65
|
+
*
|
|
66
|
+
* The "on disk" sentence is bound to the files that ARE on disk, and appears only with them. A
|
|
67
|
+
* block that asserts a populated directory when the pass wrote nothing (every transfer failed, or
|
|
68
|
+
* the directory could not be created at all) sends the agent looking for a path that may not even
|
|
69
|
+
* exist, and reads as a platform bug at exactly the moment the platform is already degraded.
|
|
70
|
+
*/
|
|
71
|
+
export declare function referenceScreenshotGuidance(outcome: ReferenceScreenshotOutcome): string;
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { mkdir, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { CONTEXT_DIR, excludeContextDir } from './pi.js';
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// REFERENCE DESIGNS on disk: download the images the backend resolved for this task into
|
|
6
|
+
// `.cat-context/reference-screenshots/`, the directory the UI-tester prompt has always named and
|
|
7
|
+
// nothing wrote.
|
|
8
|
+
//
|
|
9
|
+
// The harness MATERIALISES and never decides: which artifact is the reference for which view, and
|
|
10
|
+
// what each file is called, are backend answers that ride the job body. What lives here is the
|
|
11
|
+
// transfer and its failure reporting: a reference the container could not fetch is NAMED to the
|
|
12
|
+
// agent rather than silently missing, because an absent file and a design that has no such screen
|
|
13
|
+
// look identical on disk.
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
/** Subdirectory of {@link CONTEXT_DIR} the reference designs are written to. */
|
|
16
|
+
export const REFERENCE_SCREENSHOT_SUBDIR = 'reference-screenshots';
|
|
17
|
+
/** Per-image ceiling, matching the platform's own upload ceiling (16 MiB). */
|
|
18
|
+
const MAX_REFERENCE_BYTES = 16 * 1024 * 1024;
|
|
19
|
+
/** Per-image request timeout. */
|
|
20
|
+
const REQUEST_TIMEOUT_MS = 20_000;
|
|
21
|
+
/**
|
|
22
|
+
* Wall-clock ceiling on the WHOLE pass.
|
|
23
|
+
*
|
|
24
|
+
* Downloading is activity-silent from the watchdog's point of view (no agent stream, no output),
|
|
25
|
+
* and `JOB_INACTIVITY_MS` (10 min) is what kills a job that stops producing. Rather than heartbeat
|
|
26
|
+
* a transfer that should take seconds, the pass is bounded far below that: a slow or wedged blob
|
|
27
|
+
* backend costs the run its references (stated to the agent) instead of costing it the run.
|
|
28
|
+
*/
|
|
29
|
+
const TOTAL_BUDGET_MS = 90_000;
|
|
30
|
+
/** How many images are fetched at once. Small on purpose: this is a shared blob backend. */
|
|
31
|
+
const CONCURRENCY = 4;
|
|
32
|
+
/** The cause reported for a view the backend resolved but never sent this job a file for. */
|
|
33
|
+
const OMITTED_REASON = 'not sent to this container (reference limit)';
|
|
34
|
+
/** The relative directory the references are written to (what the prompt points the agent at). */
|
|
35
|
+
export const REFERENCE_SCREENSHOT_DIR = `${CONTEXT_DIR}/${REFERENCE_SCREENSHOT_SUBDIR}`;
|
|
36
|
+
/**
|
|
37
|
+
* Download the manifest's images into the checkout and report what landed.
|
|
38
|
+
*
|
|
39
|
+
* IDEMPOTENT, and that is load-bearing rather than an optimisation: an agent flow re-enters its
|
|
40
|
+
* workspace once per repair round, so this pass runs several times over one checkout. A file
|
|
41
|
+
* already on disk is counted and never re-fetched, which keeps a later round from spending the
|
|
42
|
+
* budget again AND from reporting a view as absent that pass 1 successfully delivered. A view that
|
|
43
|
+
* MISSED is retried, since the next round is a fresh chance at whatever was transiently down.
|
|
44
|
+
*
|
|
45
|
+
* Never throws: references are an aid to a comparison, not a precondition for running, so a
|
|
46
|
+
* backend outage degrades a UI run to "name your own views" (the documented fallback) rather than
|
|
47
|
+
* failing it. Every miss is carried out on {@link ReferenceScreenshotOutcome.missing} so the caller
|
|
48
|
+
* can say so in the prompt, which is the difference between a design the platform failed to hand
|
|
49
|
+
* over and one that has no such screen.
|
|
50
|
+
*/
|
|
51
|
+
export async function materializeReferenceScreenshots(cwd, spec, options = {}) {
|
|
52
|
+
const dir = join(cwd, CONTEXT_DIR, REFERENCE_SCREENSHOT_SUBDIR);
|
|
53
|
+
const outcome = {
|
|
54
|
+
written: [],
|
|
55
|
+
// The backend's own dropped views are missing before a single byte is fetched, and for a cause
|
|
56
|
+
// no transfer could have changed.
|
|
57
|
+
missing: spec.omitted.map((view) => ({ view, reason: OMITTED_REASON })),
|
|
58
|
+
dir: REFERENCE_SCREENSHOT_DIR,
|
|
59
|
+
};
|
|
60
|
+
try {
|
|
61
|
+
await mkdir(dir, { recursive: true });
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
// Nowhere to write: report every reference as missed rather than half of them, since none of
|
|
65
|
+
// them can land and the cause is the same for all.
|
|
66
|
+
for (const file of spec.files)
|
|
67
|
+
outcome.missing.push({ view: file.view, reason: describe(error) });
|
|
68
|
+
return sortByManifest(outcome, spec);
|
|
69
|
+
}
|
|
70
|
+
const deadline = Date.now() + TOTAL_BUDGET_MS;
|
|
71
|
+
const queue = [...spec.files];
|
|
72
|
+
const workers = Array.from({ length: Math.min(CONCURRENCY, queue.length) }, async () => {
|
|
73
|
+
for (;;) {
|
|
74
|
+
const file = queue.shift();
|
|
75
|
+
if (!file)
|
|
76
|
+
return;
|
|
77
|
+
// An earlier pass over this same checkout already delivered it. Checked before the budget so
|
|
78
|
+
// a fully-delivered set costs one stat per file and no network at all, however long an
|
|
79
|
+
// earlier round took.
|
|
80
|
+
if (await alreadyOnDisk(dir, file.fileName)) {
|
|
81
|
+
outcome.written.push({ fileName: file.fileName, view: file.view });
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (Date.now() >= deadline) {
|
|
85
|
+
outcome.missing.push({ view: file.view, reason: 'reference download budget exhausted' });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const failure = await downloadOne(dir, spec, file, options);
|
|
89
|
+
if (failure)
|
|
90
|
+
outcome.missing.push({ view: file.view, reason: failure });
|
|
91
|
+
else
|
|
92
|
+
outcome.written.push({ fileName: file.fileName, view: file.view });
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
await Promise.all(workers);
|
|
96
|
+
// Even a partial set must not reach the agent's PR (same rule as every other context file).
|
|
97
|
+
await excludeContextDir(cwd);
|
|
98
|
+
return sortByManifest(outcome, spec);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Order both lists the way the BACKEND composed the set (its own gallery order) rather than the
|
|
102
|
+
* order the transfers happened to finish in, so the list the agent reads is stable across rounds.
|
|
103
|
+
* The dropped views trail the sent ones, having no position in the manifest to sort by.
|
|
104
|
+
*/
|
|
105
|
+
function sortByManifest(outcome, spec) {
|
|
106
|
+
const rank = new Map(spec.files.map((file, index) => [file.view, index]));
|
|
107
|
+
const at = (view) => rank.get(view) ?? Number.MAX_SAFE_INTEGER;
|
|
108
|
+
outcome.written.sort((a, b) => at(a.view) - at(b.view));
|
|
109
|
+
outcome.missing.sort((a, b) => at(a.view) - at(b.view));
|
|
110
|
+
return outcome;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Whether a previous pass over this checkout already wrote this reference.
|
|
114
|
+
*
|
|
115
|
+
* Non-empty is the test, not mere existence: a zero-length file is what a half-written transfer
|
|
116
|
+
* leaves behind, and treating it as delivered would hand the agent a blank image it reads as a
|
|
117
|
+
* design with nothing on the screen (the same case {@link downloadOne} refuses to write).
|
|
118
|
+
*/
|
|
119
|
+
async function alreadyOnDisk(dir, fileName) {
|
|
120
|
+
try {
|
|
121
|
+
return (await stat(join(dir, fileName))).size > 0;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
// silent-catch-ok: absence is the ordinary answer here (first pass over the checkout), and any
|
|
125
|
+
// other stat failure is answered the same way — by attempting the download.
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* The whole delivery in one call: download the manifest (when there is one) and answer the prompt
|
|
131
|
+
* block naming what landed, reporting any miss to the operator on the way.
|
|
132
|
+
*
|
|
133
|
+
* One entry point so an agent-running flow cannot end up doing half of it. A miss is stated to the
|
|
134
|
+
* AGENT in its prompt (it still has to capture that view) AND logged here, because a reference that
|
|
135
|
+
* never arrives is otherwise invisible in the run's output: the gallery simply pairs against
|
|
136
|
+
* nothing, months later, with no line anywhere saying why.
|
|
137
|
+
*/
|
|
138
|
+
export async function deliverReferenceScreenshots(cwd, spec, options) {
|
|
139
|
+
if (!spec)
|
|
140
|
+
return '';
|
|
141
|
+
const outcome = await materializeReferenceScreenshots(cwd, spec, options);
|
|
142
|
+
if (outcome.missing.length) {
|
|
143
|
+
options.log.warn('agent: some reference designs are not on disk', {
|
|
144
|
+
written: outcome.written.length,
|
|
145
|
+
missing: outcome.missing.length,
|
|
146
|
+
reasons: outcome.missing.map((file) => file.reason).slice(0, 5),
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
return referenceScreenshotGuidance(outcome);
|
|
150
|
+
}
|
|
151
|
+
/** Fetch and write one reference, answering a failure reason or undefined on success. */
|
|
152
|
+
async function downloadOne(dir, spec, file, options) {
|
|
153
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
154
|
+
const timeout = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
|
155
|
+
const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
|
|
156
|
+
try {
|
|
157
|
+
const response = await fetchImpl(`${spec.url}/${encodeURIComponent(file.artifactId)}`, {
|
|
158
|
+
headers: { authorization: `Bearer ${spec.token}` },
|
|
159
|
+
signal,
|
|
160
|
+
});
|
|
161
|
+
if (!response.ok)
|
|
162
|
+
return `HTTP ${response.status}`;
|
|
163
|
+
const bytes = await readBounded(response, MAX_REFERENCE_BYTES);
|
|
164
|
+
if (bytes === 'too-large')
|
|
165
|
+
return 'reference exceeds size limit';
|
|
166
|
+
// A zero-length body is a miss, not a file: written out it would be an image the agent opens,
|
|
167
|
+
// finds empty, and reads as a design with nothing on the screen.
|
|
168
|
+
if (!bytes.byteLength)
|
|
169
|
+
return 'empty response';
|
|
170
|
+
await writeFile(join(dir, file.fileName), bytes);
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
return describe(error);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Read a response body, refusing one that goes past `limit` WITHOUT buffering all of it first.
|
|
179
|
+
*
|
|
180
|
+
* The ceiling has to bound the transfer and not just the write. Buffering the whole body and then
|
|
181
|
+
* measuring it means an oversized (or endless) response is already resident, times the pass's
|
|
182
|
+
* concurrency, by the time it is rejected — which is the container's memory, in a run whose whole
|
|
183
|
+
* point is that it has not started working yet. So the declared length is refused up front where
|
|
184
|
+
* it is honest, and the stream is counted as it arrives and cancelled the moment it crosses the
|
|
185
|
+
* line, which is what makes a chunked or lying body cost no more than a truthful one.
|
|
186
|
+
*/
|
|
187
|
+
async function readBounded(response, limit) {
|
|
188
|
+
const declared = Number(response.headers.get('content-length'));
|
|
189
|
+
if (Number.isFinite(declared) && declared > limit)
|
|
190
|
+
return 'too-large';
|
|
191
|
+
const body = response.body;
|
|
192
|
+
if (!body) {
|
|
193
|
+
// No stream to count (a mocked or already-buffered response): fall back to measuring after the
|
|
194
|
+
// fact, which is sound because there is nothing left to stop arriving.
|
|
195
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
196
|
+
return bytes.byteLength > limit ? 'too-large' : bytes;
|
|
197
|
+
}
|
|
198
|
+
const reader = body.getReader();
|
|
199
|
+
const chunks = [];
|
|
200
|
+
let total = 0;
|
|
201
|
+
try {
|
|
202
|
+
for (;;) {
|
|
203
|
+
const { done, value } = await reader.read();
|
|
204
|
+
if (done)
|
|
205
|
+
break;
|
|
206
|
+
total += value.byteLength;
|
|
207
|
+
if (total > limit) {
|
|
208
|
+
await reader.cancel();
|
|
209
|
+
return 'too-large';
|
|
210
|
+
}
|
|
211
|
+
chunks.push(value);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
finally {
|
|
215
|
+
reader.releaseLock();
|
|
216
|
+
}
|
|
217
|
+
const bytes = new Uint8Array(total);
|
|
218
|
+
let offset = 0;
|
|
219
|
+
for (const chunk of chunks) {
|
|
220
|
+
bytes.set(chunk, offset);
|
|
221
|
+
offset += chunk.byteLength;
|
|
222
|
+
}
|
|
223
|
+
return bytes;
|
|
224
|
+
}
|
|
225
|
+
/** A one-line cause for a failed transfer (never the token, which only rides a header). */
|
|
226
|
+
function describe(error) {
|
|
227
|
+
return error instanceof Error ? error.message : String(error);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* The prompt block naming what the agent was handed, or '' when the pass produced nothing at all.
|
|
231
|
+
*
|
|
232
|
+
* States the MISSES beside the files, because the whole point of writing this directory is that
|
|
233
|
+
* the tester captures the same views the gate will pair against: a reference that did not arrive
|
|
234
|
+
* is a view the agent should still capture (under that name) rather than one that does not exist.
|
|
235
|
+
*
|
|
236
|
+
* The "on disk" sentence is bound to the files that ARE on disk, and appears only with them. A
|
|
237
|
+
* block that asserts a populated directory when the pass wrote nothing (every transfer failed, or
|
|
238
|
+
* the directory could not be created at all) sends the agent looking for a path that may not even
|
|
239
|
+
* exist, and reads as a platform bug at exactly the moment the platform is already degraded.
|
|
240
|
+
*/
|
|
241
|
+
export function referenceScreenshotGuidance(outcome) {
|
|
242
|
+
if (!outcome.written.length && !outcome.missing.length)
|
|
243
|
+
return '';
|
|
244
|
+
const onDisk = outcome.written.length
|
|
245
|
+
? `\n\nThese are on disk, one file per view:\n${outcome.written
|
|
246
|
+
.map((file) => `- \`${outcome.dir}/${file.fileName}\`: ${file.view}`)
|
|
247
|
+
.join('\n')}`
|
|
248
|
+
: '';
|
|
249
|
+
const absent = outcome.missing.length
|
|
250
|
+
? `\n\nThese views have NO reference image in this container, for the reason given. Capture them
|
|
251
|
+
anyway, under exactly these names. There is simply nothing here to compare against:\n${outcome.missing
|
|
252
|
+
.map((file) => `- ${file.view}: NOT on disk (${file.reason})`)
|
|
253
|
+
.join('\n')}`
|
|
254
|
+
: '';
|
|
255
|
+
return `
|
|
256
|
+
|
|
257
|
+
## Reference designs (capture these views)
|
|
258
|
+
Capture the views named below and name each screenshot's \`view\` EXACTLY as given, so the platform
|
|
259
|
+
can pair your capture with its reference. Capture any other view the task needs under a name of
|
|
260
|
+
your own.${onDisk}${absent}`;
|
|
261
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/executor-harness",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.104.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.
|
|
34
|
-
"@cat-factory/server": "0.
|
|
35
|
-
"@cat-factory/spend": "0.15.
|
|
33
|
+
"@cat-factory/kernel": "0.275.0",
|
|
34
|
+
"@cat-factory/server": "0.255.0",
|
|
35
|
+
"@cat-factory/spend": "0.15.43"
|
|
36
36
|
},
|
|
37
37
|
"scripts": {
|
|
38
38
|
"build": "tsc -p tsconfig.json",
|
package/src/agent-shared.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
AgentJob,
|
|
3
|
+
AgentResult,
|
|
4
|
+
McpServerSpec,
|
|
5
|
+
ReferenceScreenshotsSpec,
|
|
6
|
+
SkillSpec,
|
|
7
|
+
} from './job.js'
|
|
2
8
|
import type { EffortReport } from './effort.js'
|
|
3
9
|
|
|
4
10
|
// Small helpers shared by every agent MODE (explore / coding / bootstrap / preview). They live
|
|
@@ -18,17 +24,20 @@ export function mergeEffort(
|
|
|
18
24
|
}
|
|
19
25
|
|
|
20
26
|
/**
|
|
21
|
-
* The agent-capability fields (skills
|
|
22
|
-
* {@link runAgentInWorkspace}. One helper rather than a per-flow spread, so a flow
|
|
23
|
-
* be the one that drops a kind's declared playbook
|
|
24
|
-
* (the agent simply works without it) and would only show
|
|
27
|
+
* The agent-capability fields (skills, tool servers, reference designs) every agent-running flow
|
|
28
|
+
* forwards to {@link runAgentInWorkspace}. One helper rather than a per-flow spread, so a flow
|
|
29
|
+
* cannot silently be the one that drops a kind's declared playbook, tool server or reference
|
|
30
|
+
* gallery: the failure mode is invisible (the agent simply works without it) and would only show
|
|
31
|
+
* up as degraded output.
|
|
25
32
|
*/
|
|
26
33
|
export function agentCapabilities(job: AgentJob): {
|
|
27
34
|
skills?: SkillSpec[]
|
|
28
35
|
mcpServers?: McpServerSpec[]
|
|
36
|
+
referenceScreenshots?: ReferenceScreenshotsSpec
|
|
29
37
|
} {
|
|
30
38
|
return {
|
|
31
39
|
...(job.skills?.length ? { skills: job.skills } : {}),
|
|
32
40
|
...(job.mcpServers?.length ? { mcpServers: job.mcpServers } : {}),
|
|
41
|
+
...(job.referenceScreenshots ? { referenceScreenshots: job.referenceScreenshots } : {}),
|
|
33
42
|
}
|
|
34
43
|
}
|
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
|
-
|
|
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
|
+
}
|