@cat-factory/executor-harness 1.72.0 → 1.76.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 +21 -8
- package/dist/agent-runner.js +167 -55
- package/dist/agent.js +63 -5
- package/dist/captured-command.js +16 -0
- package/dist/coding-agent.js +44 -2
- package/dist/dependency-install.js +245 -0
- package/dist/git.js +45 -0
- package/dist/job.js +10 -3
- package/dist/process-exit.js +18 -0
- package/dist/runner.js +91 -23
- package/dist/validation-checks.js +6 -2
- package/package.json +4 -4
- package/src/agent-runner.ts +203 -58
- package/src/agent.ts +73 -5
- package/src/captured-command.ts +17 -0
- package/src/coding-agent.ts +57 -2
- package/src/dependency-install.ts +333 -0
- package/src/git.ts +52 -0
- package/src/job.ts +24 -3
- package/src/process-exit.ts +19 -0
- package/src/runner.ts +124 -37
- package/src/validation-checks.ts +6 -2
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { relative } from 'node:path';
|
|
2
|
+
import { fencedOutput, runCapturedCommand } from './captured-command.js';
|
|
3
|
+
import { excludePathsFromGit, listUntrackedPaths } from './git.js';
|
|
4
|
+
import { loadRunnerLimits } from './runner.js';
|
|
5
|
+
/**
|
|
6
|
+
* How much of a failed install's output the agent is shown. Smaller than the validation loop's
|
|
7
|
+
* repair budget (16k) on purpose: a repair prompt has to carry the whole failure because fixing
|
|
8
|
+
* it IS the task, whereas this note only has to let the agent decide whether to install
|
|
9
|
+
* something itself. The tail is where a package manager puts its actual error.
|
|
10
|
+
*/
|
|
11
|
+
export const DEPENDENCY_INSTALL_TAIL_CHARS = 4_000;
|
|
12
|
+
/**
|
|
13
|
+
* The share of the JOB's whole wall-clock ceiling (`JOB_MAX_DURATION_MS`) the install may consume
|
|
14
|
+
* before the watchdog kills it. The install is SETUP: it runs before the agent's first turn, so
|
|
15
|
+
* every second it takes is a second the work itself does not get, and a wedged package manager
|
|
16
|
+
* that ran to a fixed 20-minute watchdog on a shortened job could leave the agent with almost
|
|
17
|
+
* nothing. A third leaves the run two thirds of its budget in the worst case.
|
|
18
|
+
*/
|
|
19
|
+
const DEPENDENCY_INSTALL_JOB_SHARE = 1 / 3;
|
|
20
|
+
/**
|
|
21
|
+
* A floor under the DERIVED ceiling (never under an explicit override, which tests legitimately
|
|
22
|
+
* set to milliseconds): a drastically shortened job would otherwise compute a share so small that
|
|
23
|
+
* no install could ever finish inside it, turning every run's setup into a guaranteed timeout.
|
|
24
|
+
*/
|
|
25
|
+
const DEPENDENCY_INSTALL_CEILING_FLOOR_MS = 30_000;
|
|
26
|
+
/** The default watchdog — the share above at the default 60-minute job ceiling. */
|
|
27
|
+
const DEPENDENCY_INSTALL_TIMEOUT_DEFAULT_MS = 20 * 60_000;
|
|
28
|
+
/**
|
|
29
|
+
* The per-install watchdog: the longest the install may run before it is killed and reported as
|
|
30
|
+
* failed. Generous (20 min at the defaults) because a cold monorepo install on a slow registry
|
|
31
|
+
* legitimately takes many minutes.
|
|
32
|
+
*
|
|
33
|
+
* DERIVED from the configured job ceiling rather than hardcoded against the default one, the same
|
|
34
|
+
* way `git.ts` derives its per-command timeout from the configured inactivity window: a constant
|
|
35
|
+
* sized against a default silently breaks its own invariant the moment an operator changes that
|
|
36
|
+
* default. An explicit `DEPENDENCY_INSTALL_TIMEOUT_MS` is honoured but still CLAMPED — the point
|
|
37
|
+
* of the share is that no configuration lets setup eat the run, and an override that could exceed
|
|
38
|
+
* the job's own ceiling would only ever be killed later by a watchdog that fails the whole job
|
|
39
|
+
* instead of degrading to a note.
|
|
40
|
+
*/
|
|
41
|
+
export function dependencyInstallTimeoutMs(env = process.env) {
|
|
42
|
+
const configured = Number(env.DEPENDENCY_INSTALL_TIMEOUT_MS);
|
|
43
|
+
const requested = Number.isFinite(configured) && configured > 0
|
|
44
|
+
? Math.floor(configured)
|
|
45
|
+
: DEPENDENCY_INSTALL_TIMEOUT_DEFAULT_MS;
|
|
46
|
+
const ceiling = Math.max(DEPENDENCY_INSTALL_CEILING_FLOOR_MS, Math.floor(loadRunnerLimits(env).maxDurationMs * DEPENDENCY_INSTALL_JOB_SHARE));
|
|
47
|
+
return Math.min(requested, ceiling);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* How often the install feeds the run's inactivity watchdog. Well under `JOB_INACTIVITY_MS`
|
|
51
|
+
* (default 10 min); matches the validation loop's and the frontend stand-up's heartbeat, which
|
|
52
|
+
* exist for exactly the same reason.
|
|
53
|
+
*/
|
|
54
|
+
export function dependencyInstallHeartbeatMs() {
|
|
55
|
+
const n = Number(process.env.DEPENDENCY_INSTALL_HEARTBEAT_MS);
|
|
56
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 30_000;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Parse the optional DEPENDENCY INSTALL envelope off the job body. A missing/blank command
|
|
60
|
+
* returns `undefined`, so a malformed body degrades to the exact pre-feature behaviour (no
|
|
61
|
+
* install phase, the agent starts against the bare clone) rather than failing a good run.
|
|
62
|
+
*
|
|
63
|
+
* Lives with the feature rather than in `job.ts`, following the same rule the two pre-PR
|
|
64
|
+
* verification phases do: each phase owns its own job-body parser next to the code that consumes
|
|
65
|
+
* it, and `job.ts` stays the job SHAPE plus the generic assembly.
|
|
66
|
+
*/
|
|
67
|
+
export function parseDependencyInstallSpec(value) {
|
|
68
|
+
if (typeof value !== 'object' || value === null)
|
|
69
|
+
return undefined;
|
|
70
|
+
const raw = value.command;
|
|
71
|
+
const command = typeof raw === 'string' ? raw.trim() : '';
|
|
72
|
+
return command ? { command } : undefined;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Run the declared install against `cwd` and return what happened. Never throws and never fails
|
|
76
|
+
* the job: every failure shape ({@link runCapturedCommand} maps a timeout to 124, a spawn error
|
|
77
|
+
* to 127, an abort to 130) comes back as a non-zero outcome the caller turns into a prompt note.
|
|
78
|
+
*
|
|
79
|
+
* The output tail is kept ONLY for a failure. A successful install prints tens of thousands of
|
|
80
|
+
* uninteresting lines, and the agent needs to know that it succeeded, not what it resolved.
|
|
81
|
+
*/
|
|
82
|
+
export async function runDependencyInstall(args) {
|
|
83
|
+
const { cwd, spec, logger, opts } = args;
|
|
84
|
+
logger.info('dependencies: installing', { command: spec.command });
|
|
85
|
+
const heartbeat = setInterval(() => opts.onActivity?.(), dependencyInstallHeartbeatMs());
|
|
86
|
+
heartbeat.unref?.();
|
|
87
|
+
try {
|
|
88
|
+
const run = await runCapturedCommand({
|
|
89
|
+
cwd,
|
|
90
|
+
command: spec.command,
|
|
91
|
+
timeoutMs: dependencyInstallTimeoutMs(),
|
|
92
|
+
reportTailChars: DEPENDENCY_INSTALL_TAIL_CHARS,
|
|
93
|
+
logLabel: 'dependencies',
|
|
94
|
+
logger,
|
|
95
|
+
opts,
|
|
96
|
+
});
|
|
97
|
+
logger.info('dependencies: install finished', {
|
|
98
|
+
exitCode: run.exitCode,
|
|
99
|
+
durationMs: run.durationMs,
|
|
100
|
+
});
|
|
101
|
+
return {
|
|
102
|
+
command: spec.command,
|
|
103
|
+
exitCode: run.exitCode,
|
|
104
|
+
passed: run.passed,
|
|
105
|
+
...(run.passed ? {} : run.outputTail ? { outputTail: run.outputTail } : {}),
|
|
106
|
+
durationMs: run.durationMs,
|
|
107
|
+
...(run.timedOut ? { timedOut: true } : {}),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
finally {
|
|
111
|
+
clearInterval(heartbeat);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* THE entry point: run the phase for a mode that has a checkout, and hand back the note to fold
|
|
116
|
+
* into the agent's prompt (or `undefined` when the service declared no install, which is every
|
|
117
|
+
* dispatch today that never configured one).
|
|
118
|
+
*
|
|
119
|
+
* Everything a caller could get wrong lives here rather than at six call sites: the phase marker,
|
|
120
|
+
* the best-effort run, keeping the installed tree out of the agent's commits, and naming WHERE
|
|
121
|
+
* the install ran when that is not where the agent will be standing. A mode supplies only its
|
|
122
|
+
* three directories.
|
|
123
|
+
*/
|
|
124
|
+
export async function prepopulateDependencies(args) {
|
|
125
|
+
const { spec, installDir, repoDir, agentDir, logger, opts } = args;
|
|
126
|
+
if (!spec)
|
|
127
|
+
return undefined;
|
|
128
|
+
opts.onPhase?.('dependencies');
|
|
129
|
+
// Taken BEFORE the install and diffed after, so what gets excluded is what the install itself
|
|
130
|
+
// materialised — not whatever the checkout already carried.
|
|
131
|
+
const untrackedBefore = new Set(await snapshotUntracked(repoDir, opts.signal));
|
|
132
|
+
// Never rejects — every failure shape comes back as a non-zero outcome — so a caller needs no
|
|
133
|
+
// unwinding and the run continues either way. A FAILED install is snapshotted too: a partial
|
|
134
|
+
// tree is just as untracked as a complete one.
|
|
135
|
+
const outcome = await runDependencyInstall({ cwd: installDir, spec, logger, opts });
|
|
136
|
+
const untrackedAfter = await snapshotUntracked(repoDir, opts.signal);
|
|
137
|
+
const added = untrackedAfter.filter((path) => !untrackedBefore.has(path));
|
|
138
|
+
await excludeInstalledArtifacts(repoDir, added, logger, opts.signal);
|
|
139
|
+
return buildDependencyInstallNote(outcome, installScope(agentDir, installDir));
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Fold the note into a prompt. Trivial, and deliberately not inlined: it is applied on EVERY
|
|
143
|
+
* agent pass — including the validation and reproduction REPAIR passes, which start a fresh
|
|
144
|
+
* agent that would otherwise never learn the tree is already installed and would spend a repair
|
|
145
|
+
* round reinstalling it.
|
|
146
|
+
*/
|
|
147
|
+
export function withDependencyNote(userPrompt, note) {
|
|
148
|
+
return note ? `${userPrompt}\n\n${note}` : userPrompt;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* How the note names the checkout the install ran in: `undefined` when the agent will be standing
|
|
152
|
+
* in it (so it reads "this checkout"), otherwise the path from the agent's cwd. The multi-repo
|
|
153
|
+
* layout runs the agent at the workspace ROOT and a conflict resolution at the repo root, while
|
|
154
|
+
* the install belongs to a sibling checkout or a service subtree respectively — "this checkout"
|
|
155
|
+
* in either case points the agent at a directory with no dependency tree of its own.
|
|
156
|
+
*
|
|
157
|
+
* Separators are normalised because the note is prose an agent reads, and the local NATIVE
|
|
158
|
+
* transport runs this on the developer's own Windows host.
|
|
159
|
+
*/
|
|
160
|
+
function installScope(agentDir, installDir) {
|
|
161
|
+
const rel = relative(agentDir, installDir).replaceAll('\\', '/');
|
|
162
|
+
return rel === '' ? undefined : rel;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Keep whatever the install materialised out of the agent's commits.
|
|
166
|
+
*
|
|
167
|
+
* A dependency tree is untracked, and the agent's own `git add -A` does not know it did not put
|
|
168
|
+
* it there — nor does the conflict-resolution flow, which stages the whole tree to complete its
|
|
169
|
+
* merge commit. A repo that ships a `.gitignore` covering `node_modules` is fine without this;
|
|
170
|
+
* one that does not (a fresh service, a language whose convention is looser) would open a pull
|
|
171
|
+
* request containing tens of thousands of vendored files. So the paths the install ADDED are
|
|
172
|
+
* excluded locally, exactly as the harness already does for its own sentinel files.
|
|
173
|
+
*
|
|
174
|
+
* Only what the install added: a snapshot diff, never a list of well-known directory names. A
|
|
175
|
+
* name list is a guess that is both incomplete (every ecosystem has its own) and unsafe (it would
|
|
176
|
+
* exclude a `target/` directory the agent legitimately authored). Best-effort — a git hiccup here
|
|
177
|
+
* must not fail a run whose install succeeded — and the paths are LOGGED, since silently ignoring
|
|
178
|
+
* part of a checkout is exactly the kind of thing a later run's author needs to be able to see.
|
|
179
|
+
*/
|
|
180
|
+
async function excludeInstalledArtifacts(repoDir, paths, logger, signal) {
|
|
181
|
+
if (paths.length === 0)
|
|
182
|
+
return;
|
|
183
|
+
try {
|
|
184
|
+
await excludePathsFromGit(repoDir, paths, signal);
|
|
185
|
+
logger.info('dependencies: excluded installed artifacts from git', { paths });
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
logger.warn('dependencies: could not exclude installed artifacts from git', {
|
|
189
|
+
paths,
|
|
190
|
+
error: error instanceof Error ? error.message : String(error),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* The checkout's untracked paths, or an empty list when they cannot be read.
|
|
196
|
+
*
|
|
197
|
+
* Best-effort by design: a directory that is not a git checkout (or a git that failed) must
|
|
198
|
+
* degrade to "nothing to exclude" rather than failing a phase whose whole disposition is that it
|
|
199
|
+
* never fails a run. Degrading on the BEFORE read is the safe direction too — an unreadable
|
|
200
|
+
* snapshot makes every post-install path look new, so the exclusion errs towards protecting the
|
|
201
|
+
* commit rather than towards letting a dependency tree into it. Directories are collapsed by
|
|
202
|
+
* {@link listUntrackedPaths}, so a `node_modules` of 40k files is one entry, not 40k.
|
|
203
|
+
*/
|
|
204
|
+
async function snapshotUntracked(repoDir, signal) {
|
|
205
|
+
return listUntrackedPaths(repoDir, signal).catch(() => []);
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* The note folded into the agent's prompt describing the checkout it is about to work in.
|
|
209
|
+
*
|
|
210
|
+
* Stated in BOTH directions on purpose. On success the agent is told the tree is ready, which is
|
|
211
|
+
* what stops it spending turns re-running an install that already ran (and, on a repo whose
|
|
212
|
+
* install is slow, spending most of its budget there). On failure it is told plainly what failed
|
|
213
|
+
* and that it may install what it needs itself — an agent that merely finds no `node_modules` and
|
|
214
|
+
* no explanation concludes the environment is offline and works around a gap that isn't there.
|
|
215
|
+
*
|
|
216
|
+
* `scope` names the checkout the install ran in and is set ONLY when that is not the agent's own
|
|
217
|
+
* working directory — the multi-repo layout runs the agent at the workspace root while the install
|
|
218
|
+
* belongs to the primary service's sibling directory. Saying "this checkout" there would point the
|
|
219
|
+
* agent at a root that has no dependency tree of its own.
|
|
220
|
+
*/
|
|
221
|
+
export function buildDependencyInstallNote(outcome, scope) {
|
|
222
|
+
const subject = scope ? `The \`${scope}/\` checkout's` : "This checkout's";
|
|
223
|
+
if (outcome.passed) {
|
|
224
|
+
return [
|
|
225
|
+
`${subject} dependencies have already been installed for you (\`${outcome.command}\`), so the`,
|
|
226
|
+
'installed packages are present on disk. Read them directly to confirm what a dependency',
|
|
227
|
+
'actually exposes rather than inferring its API from the manifest, and do NOT re-run the',
|
|
228
|
+
'install unless you change the dependency manifest.',
|
|
229
|
+
].join('\n');
|
|
230
|
+
}
|
|
231
|
+
const reason = outcome.timedOut
|
|
232
|
+
? `timed out after ${Math.round(outcome.durationMs / 1000)}s`
|
|
233
|
+
: `exited ${outcome.exitCode}`;
|
|
234
|
+
return [
|
|
235
|
+
`${subject} dependencies could NOT be installed for you: \`${outcome.command}\` ${reason}.`,
|
|
236
|
+
'The installed packages are therefore missing or incomplete. You have network access, so you',
|
|
237
|
+
'may install what you need yourself if it helps — but treat the failure below as a fact about',
|
|
238
|
+
'the environment, not as a defect to fix as part of this task, and do not change the project’s',
|
|
239
|
+
'dependency manifests to work around it.',
|
|
240
|
+
// Fenced so the captured output cannot be read as instructions — and fenced through the
|
|
241
|
+
// shared helper, because a package manager prints backticks often enough that a fixed
|
|
242
|
+
// three-tick fence would close mid-tail and spill the rest of this note's prose.
|
|
243
|
+
...(outcome.outputTail ? ['', fencedOutput(outcome.outputTail)] : []),
|
|
244
|
+
].join('\n');
|
|
245
|
+
}
|
package/dist/git.js
CHANGED
|
@@ -424,6 +424,22 @@ export async function listUntrackedFiles(dir, signal) {
|
|
|
424
424
|
.map((line) => line.replace(/\r$/, '').trim())
|
|
425
425
|
.filter((path) => path !== '');
|
|
426
426
|
}
|
|
427
|
+
/**
|
|
428
|
+
* The untracked, non-ignored paths in the working tree with whole untracked DIRECTORIES
|
|
429
|
+
* collapsed to a single `dir/` entry (`--directory`), rather than every file beneath them.
|
|
430
|
+
*
|
|
431
|
+
* The sibling {@link listUntrackedFiles} answers "what did the agent forget to commit", where
|
|
432
|
+
* every individual file is the point. This one answers "what appeared in the tree", where it is
|
|
433
|
+
* emphatically not: a dependency install leaves tens of thousands of files under one directory,
|
|
434
|
+
* and enumerating them would cost a multi-megabyte listing to learn a single name.
|
|
435
|
+
*/
|
|
436
|
+
export async function listUntrackedPaths(dir, signal) {
|
|
437
|
+
const out = await git(['ls-files', '--others', '--exclude-standard', '--directory', '--no-empty-directory'], { cwd: dir, signal });
|
|
438
|
+
return out
|
|
439
|
+
.split('\n')
|
|
440
|
+
.map((line) => line.replace(/\r$/, '').trim())
|
|
441
|
+
.filter((path) => path !== '');
|
|
442
|
+
}
|
|
427
443
|
/**
|
|
428
444
|
* Locally exclude `pattern` from this checkout via `.git/info/exclude` — a per-clone
|
|
429
445
|
* ignore that never lands in the repo (unlike a `.gitignore`). Used for the harness's
|
|
@@ -441,6 +457,35 @@ export async function excludeFromGit(dir, pattern, signal) {
|
|
|
441
457
|
void signal;
|
|
442
458
|
}
|
|
443
459
|
}
|
|
460
|
+
/**
|
|
461
|
+
* Locally exclude LITERAL paths — never patterns — from this checkout, in ONE write.
|
|
462
|
+
*
|
|
463
|
+
* The sibling {@link excludeFromGit} takes an author-written pattern for a known sentinel. These
|
|
464
|
+
* paths instead come from the FILESYSTEM (what a dependency install left behind), so two things
|
|
465
|
+
* differ. Each is escaped, because a directory named `pkg[1]` read as a gitignore character class
|
|
466
|
+
* excludes something else entirely and, being a no-op on the real path, fails silently. And they
|
|
467
|
+
* are appended together, because a per-path append would cost one file write per entry to build
|
|
468
|
+
* a list that is already known in full.
|
|
469
|
+
*
|
|
470
|
+
* Anchored: `ls-files` reports repo-root-relative paths and a gitignore pattern containing a
|
|
471
|
+
* slash is root-anchored, which is what makes `packages/api/node_modules/` exclude that service's
|
|
472
|
+
* tree and not a same-named directory elsewhere. Best-effort, exactly like its sibling.
|
|
473
|
+
*/
|
|
474
|
+
export async function excludePathsFromGit(dir, paths, signal) {
|
|
475
|
+
if (paths.length === 0)
|
|
476
|
+
return;
|
|
477
|
+
// Escape every gitignore metacharacter, plus a leading `#` (comment) or `!` (negation) which
|
|
478
|
+
// are only special in that position.
|
|
479
|
+
const escaped = paths.map((p) => p.replace(/[[\]*?\\]/g, '\\$&').replace(/^([#!])/, '\\$1'));
|
|
480
|
+
try {
|
|
481
|
+
const excludePath = join(dir, '.git', 'info', 'exclude');
|
|
482
|
+
await appendFile(excludePath, `\n${escaped.join('\n')}\n`, 'utf8');
|
|
483
|
+
}
|
|
484
|
+
catch {
|
|
485
|
+
// A missing .git/info/exclude (worktree layout) or write error is non-fatal.
|
|
486
|
+
void signal;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
444
489
|
/** Whether the branch advanced past `baseSha` via commits (the agent's own + any safety-net commit). */
|
|
445
490
|
export async function branchHasCommitsSince(dir, baseSha, signal) {
|
|
446
491
|
return (await headCommit(dir, signal)) !== baseSha;
|
package/dist/job.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { parseValidationChecksSpec, } from './validation-checks.js';
|
|
2
2
|
import { parseReproductionSpec, } from './reproduction-proof.js';
|
|
3
|
+
import { parseDependencyInstallSpec } from './dependency-install.js';
|
|
3
4
|
import { parseMcpServerSpecs, parseSkillSpecs, } from './agent-capabilities.js';
|
|
4
5
|
function str(value, path) {
|
|
5
6
|
if (typeof value !== 'string' || value.length === 0) {
|
|
@@ -309,8 +310,12 @@ export function parsePackageRegistries(value, env = process.env) {
|
|
|
309
310
|
if (!allowed.has(host)) {
|
|
310
311
|
throw new Error(`Invalid job: 'packageRegistries[${i}].host' '${host}' is not an allowed npm registry host`);
|
|
311
312
|
}
|
|
312
|
-
|
|
313
|
-
|
|
313
|
+
// An EMPTY scope list is valid and deliberate: the entry then only authenticates its
|
|
314
|
+
// host, leaving every package to resolve from the default registry unless a dependency
|
|
315
|
+
// pins this one itself. Mapping a scope is all-or-nothing, so a workspace mixing private
|
|
316
|
+
// and public packages under one scope must be able to skip it.
|
|
317
|
+
if (!Array.isArray(entry.scopes)) {
|
|
318
|
+
throw new Error(`Invalid job: 'packageRegistries[${i}].scopes' must be an array`);
|
|
314
319
|
}
|
|
315
320
|
const scopes = entry.scopes.map((scope, j) => {
|
|
316
321
|
const s = str(scope, `packageRegistries[${i}].scopes[${j}]`).trim();
|
|
@@ -607,6 +612,7 @@ export function parseAgentJob(input) {
|
|
|
607
612
|
validation: parseValidationSpec(o.validation),
|
|
608
613
|
validationChecks: parseValidationChecksSpec(o.validationChecks),
|
|
609
614
|
reproduction: parseReproductionSpec(o.reproduction),
|
|
615
|
+
dependencyInstall: parseDependencyInstallSpec(o.dependencyInstall),
|
|
610
616
|
reviewPrNumber: posInt(o.reviewPrNumber),
|
|
611
617
|
});
|
|
612
618
|
assertAllowedHost(job.repo.cloneUrl, 'repo.cloneUrl');
|
|
@@ -663,7 +669,7 @@ function parseAgentPrSpec(raw) {
|
|
|
663
669
|
* literal doesn't blow the complexity budget; behaviour is byte-identical (spread order preserved).
|
|
664
670
|
*/
|
|
665
671
|
function assembleAgentJob(o, mode, agentField, parts) {
|
|
666
|
-
const { output, pr, infra, peerRepos, referenceRepos, referenceBranches, bootstrap, contextFiles, packageRegistries, skills, mcpServers, testSecrets, guardLimits, validation, validationChecks, reproduction, reviewPrNumber, } = parts;
|
|
672
|
+
const { output, pr, infra, peerRepos, referenceRepos, referenceBranches, bootstrap, contextFiles, packageRegistries, skills, mcpServers, testSecrets, guardLimits, validation, validationChecks, reproduction, dependencyInstall, reviewPrNumber, } = parts;
|
|
667
673
|
const repo = (o.repo ?? {});
|
|
668
674
|
return {
|
|
669
675
|
jobId: str(o.jobId, 'jobId'),
|
|
@@ -693,6 +699,7 @@ function assembleAgentJob(o, mode, agentField, parts) {
|
|
|
693
699
|
...(validation ? { validation } : {}),
|
|
694
700
|
...(validationChecks ? { validationChecks } : {}),
|
|
695
701
|
...(reproduction ? { reproduction } : {}),
|
|
702
|
+
...(dependencyInstall ? { dependencyInstall } : {}),
|
|
696
703
|
};
|
|
697
704
|
}
|
|
698
705
|
/**
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// A deliberate COPY of kernel's `describeProcessExit` (`shared/process-exit.logic.ts`). The
|
|
2
|
+
// container image is built from `src/` plus typescript alone, so the harness can carry no runtime
|
|
3
|
+
// dependency on a workspace package — the same constraint that forces `src/host-markdown.ts` to be
|
|
4
|
+
// a copy. `test/process-exit.conformity.test.ts` pins the two to identical output, so change one
|
|
5
|
+
// and you must change the other.
|
|
6
|
+
//
|
|
7
|
+
// Kernel's module carries the rationale in full; the short version is that a `null` exit code
|
|
8
|
+
// means a SIGNAL killed the process, and telling that apart from the process's own non-zero exit
|
|
9
|
+
// is the first fork in the road when diagnosing a dead agent run.
|
|
10
|
+
/**
|
|
11
|
+
* How a child process ended: its own exit code, or the signal that killed it.
|
|
12
|
+
*
|
|
13
|
+
* @example describeProcessExit(1, null) // 'exited with code 1'
|
|
14
|
+
* @example describeProcessExit(null, 'SIGKILL') // 'killed by SIGKILL'
|
|
15
|
+
*/
|
|
16
|
+
export function describeProcessExit(code, signal) {
|
|
17
|
+
return code === null ? `killed by ${signal ?? 'signal'}` : `exited with code ${code}`;
|
|
18
|
+
}
|
package/dist/runner.js
CHANGED
|
@@ -171,15 +171,20 @@ export class JobRegistry {
|
|
|
171
171
|
killReason ??= 'max-duration';
|
|
172
172
|
controller.abort(new Error('max duration exceeded'));
|
|
173
173
|
}, this.limits.maxDurationMs);
|
|
174
|
+
// When the run was last heard from — the agent's own output, or a synthetic keep-alive beat
|
|
175
|
+
// from an activity-silent phase (see `silenceClause`, which is careful not to claim more than
|
|
176
|
+
// that). Unset until the first of either, which is both the cold-start watchdog's "has it
|
|
177
|
+
// spoken yet" test and, on a failure, the difference between a run that died mid-work and one
|
|
178
|
+
// that never got going at all.
|
|
179
|
+
let lastActivityAt;
|
|
174
180
|
// ADR 0026 D4: a one-shot cold-start watchdog. If the job produces no activity within
|
|
175
181
|
// `coldStartMs`, record a structured diagnostic (a likely onboarding/auth wedge) so it
|
|
176
182
|
// is legible early — it does NOT abort the run (the inactivity watchdog still owns
|
|
177
183
|
// that). Cleared the moment the first activity arrives.
|
|
178
|
-
let sawActivity = false;
|
|
179
184
|
let coldStart;
|
|
180
185
|
if (this.limits.coldStartMs > 0) {
|
|
181
186
|
coldStart = setTimeout(() => {
|
|
182
|
-
if (
|
|
187
|
+
if (lastActivityAt !== undefined)
|
|
183
188
|
return;
|
|
184
189
|
const secs = Math.round(this.limits.coldStartMs / 1000);
|
|
185
190
|
const message = `agent produced no output ${secs}s after start; possible onboarding/auth wedge (phase: ${phase})`;
|
|
@@ -188,11 +193,10 @@ export class JobRegistry {
|
|
|
188
193
|
}, this.limits.coldStartMs);
|
|
189
194
|
}
|
|
190
195
|
const heartbeat = () => {
|
|
191
|
-
if (
|
|
192
|
-
sawActivity = true;
|
|
196
|
+
if (lastActivityAt === undefined)
|
|
193
197
|
clearTimeout(coldStart);
|
|
194
|
-
|
|
195
|
-
entry.heartbeatAt =
|
|
198
|
+
lastActivityAt = Date.now();
|
|
199
|
+
entry.heartbeatAt = lastActivityAt;
|
|
196
200
|
resetInactivity();
|
|
197
201
|
};
|
|
198
202
|
resetInactivity();
|
|
@@ -255,7 +259,16 @@ export class JobRegistry {
|
|
|
255
259
|
// breadcrumb names where it hung (markPhase below would otherwise overwrite it).
|
|
256
260
|
const failedInPhase = phase;
|
|
257
261
|
markPhase('failed');
|
|
258
|
-
const { message, cause, detail } = this.describeFailure(
|
|
262
|
+
const { message, cause, detail } = this.describeFailure({
|
|
263
|
+
killReason,
|
|
264
|
+
error,
|
|
265
|
+
phase: failedInPhase,
|
|
266
|
+
lastTool,
|
|
267
|
+
phaseTimingsMs,
|
|
268
|
+
lastActivityAt,
|
|
269
|
+
startedAt: entry.startedAt,
|
|
270
|
+
coldStart: entry.coldStart,
|
|
271
|
+
});
|
|
259
272
|
entry.state = 'failed';
|
|
260
273
|
entry.error = message;
|
|
261
274
|
entry.failureCause = cause;
|
|
@@ -283,38 +296,93 @@ export class JobRegistry {
|
|
|
283
296
|
* breadcrumb of where they hung, no longer a regex-stable phrase; a thrown error keeps its own
|
|
284
297
|
* message and its structured cause when tagged (a git op → `git`, an upstream API call → `api`),
|
|
285
298
|
* else `agent`. All strings are credential-scrubbed.
|
|
299
|
+
*
|
|
300
|
+
* `detail` is where the evidence the harness already holds but the one-line `error` has no room
|
|
301
|
+
* for lands: the phase breakdown, the {@link failureBreadcrumb} (last completed tool + how long
|
|
302
|
+
* the run had been silent), and the cold-start diagnostic when that watchdog recorded one. It is
|
|
303
|
+
* the only one of the three that reaches the run's failure record, so a diagnostic that isn't
|
|
304
|
+
* folded in here is effectively invisible outside the container log.
|
|
286
305
|
*/
|
|
287
|
-
describeFailure(
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
// "last completed tool" so the reader knows the stuck call may be the next, unfinished one.
|
|
291
|
-
const breadcrumb = lastTool
|
|
292
|
-
? `last completed tool ${lastTool.name} ${Math.round((Date.now() - lastTool.at) / 1000)}s ago`
|
|
293
|
-
: 'no tool had completed yet';
|
|
294
|
-
const phaseBreakdown = Object.entries(phaseTimingsMs)
|
|
306
|
+
describeFailure(ctx) {
|
|
307
|
+
const breadcrumb = failureBreadcrumb(ctx);
|
|
308
|
+
const phaseBreakdown = Object.entries(ctx.phaseTimingsMs)
|
|
295
309
|
.map(([p, ms]) => `${p}=${Math.round(ms / 1000)}s`)
|
|
296
310
|
.join(', ');
|
|
297
|
-
|
|
311
|
+
const cold = ctx.coldStart ? ` Cold start: ${ctx.coldStart.message}.` : '';
|
|
312
|
+
if (ctx.killReason === 'inactivity') {
|
|
298
313
|
return {
|
|
299
|
-
message: redactSecrets(`${inactivityAbortMessage(this.limits.inactivityMs)} (likely hung in ${phase} phase; ${breadcrumb})`),
|
|
314
|
+
message: redactSecrets(`${inactivityAbortMessage(this.limits.inactivityMs)} (likely hung in ${ctx.phase} phase; ${breadcrumb})`),
|
|
300
315
|
cause: 'inactivity-timeout',
|
|
301
|
-
detail: redactSecrets(`Phase timings: ${phaseBreakdown || '(none)'}. ${breadcrumb}
|
|
316
|
+
detail: redactSecrets(`Phase timings: ${phaseBreakdown || '(none)'}. ${breadcrumb}.${cold}`),
|
|
302
317
|
};
|
|
303
318
|
}
|
|
304
|
-
if (killReason === 'max-duration') {
|
|
319
|
+
if (ctx.killReason === 'max-duration') {
|
|
305
320
|
return {
|
|
306
321
|
message: redactSecrets(maxDurationAbortMessage(this.limits.maxDurationMs)),
|
|
307
322
|
cause: 'max-duration',
|
|
308
|
-
detail: redactSecrets(`Phase timings: ${phaseBreakdown || '(none)'}. ${breadcrumb}
|
|
323
|
+
detail: redactSecrets(`Phase timings: ${phaseBreakdown || '(none)'}. ${breadcrumb}.${cold}`),
|
|
309
324
|
};
|
|
310
325
|
}
|
|
311
|
-
const raw = error instanceof Error ? error.message : String(error);
|
|
326
|
+
const raw = ctx.error instanceof Error ? ctx.error.message : String(ctx.error);
|
|
312
327
|
// A thrown error tagged with a structured cause (a git op / an upstream API call) keeps
|
|
313
328
|
// it; an untagged throw is a generic agent failure.
|
|
314
329
|
return {
|
|
315
330
|
message: redactSecrets(raw),
|
|
316
|
-
cause: failureCauseOf(error) ?? 'agent',
|
|
317
|
-
detail: redactSecrets(`${phaseBreakdown ? `Phase timings: ${phaseBreakdown}. ` : ''}Failed in ${phase} phase; ${breadcrumb}
|
|
331
|
+
cause: failureCauseOf(ctx.error) ?? 'agent',
|
|
332
|
+
detail: redactSecrets(`${phaseBreakdown ? `Phase timings: ${phaseBreakdown}. ` : ''}Failed in ${ctx.phase} phase; ${breadcrumb}.${cold}`),
|
|
318
333
|
};
|
|
319
334
|
}
|
|
320
335
|
}
|
|
336
|
+
/**
|
|
337
|
+
* How long a run must have been quiet before the breadcrumb calls it out. Well above a slow
|
|
338
|
+
* model turn or a long tool call, so this fires on a genuine stall rather than on normal
|
|
339
|
+
* think time.
|
|
340
|
+
*/
|
|
341
|
+
const SILENCE_BREADCRUMB_MS = 30_000;
|
|
342
|
+
/**
|
|
343
|
+
* Where the job was, and how quiet it had gone, when it failed.
|
|
344
|
+
*
|
|
345
|
+
* The silence half matters because the exit status alone cannot distinguish a crash from a
|
|
346
|
+
* stall: an agent CLI that gives up on a failing upstream request exits NON-ZERO with nothing
|
|
347
|
+
* on stderr, which reads exactly like a crash — while its phase timing (minutes) and its
|
|
348
|
+
* silence (all of them) say "it never got an answer". Omitted when the run was producing
|
|
349
|
+
* output right up to the failure (the common case, where it is noise), and for an inactivity
|
|
350
|
+
* kill, whose own message already states the window it waited out.
|
|
351
|
+
*/
|
|
352
|
+
function failureBreadcrumb(ctx) {
|
|
353
|
+
const now = Date.now();
|
|
354
|
+
// `lastTool` is the last tool that COMPLETED (a span is emitted on tool end), so when the
|
|
355
|
+
// hang is inside a still-running tool the breadcrumb points at the prior one — worded
|
|
356
|
+
// "last completed tool" so the reader knows the stuck call may be the next, unfinished one.
|
|
357
|
+
const tool = ctx.lastTool
|
|
358
|
+
? `last completed tool ${ctx.lastTool.name} ${Math.round((now - ctx.lastTool.at) / 1000)}s ago`
|
|
359
|
+
: 'no tool had completed yet';
|
|
360
|
+
return [tool, silenceClause(ctx, now)].filter(Boolean).join(', ');
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* The silence half of {@link failureBreadcrumb}; empty when silence isn't part of the story —
|
|
364
|
+
* which includes the fast failures (a missing env var, a git auth rejection) where the run was
|
|
365
|
+
* never going to have spoken yet and saying so would be pure noise.
|
|
366
|
+
*
|
|
367
|
+
* What it measures is the ACTIVITY channel, which carries the agent's own output plus the
|
|
368
|
+
* synthetic keep-alive beats the activity-silent phases feed the inactivity watchdog (dependency
|
|
369
|
+
* install, pre-PR validation, the reproduction proof, the frontend stand-up). So the wording
|
|
370
|
+
* claims no more than the channel supports — "no activity", not "no agent output": a run whose
|
|
371
|
+
* install phase beat every 30s and then died has been heard from, even though the agent itself
|
|
372
|
+
* never spoke. The window's origin is the job start, so it spans the `starting`/`clone` phases
|
|
373
|
+
* too; the phase breakdown sits beside it in the same `detail` for the reader who needs the
|
|
374
|
+
* split.
|
|
375
|
+
*
|
|
376
|
+
* Making this say "the AGENT last spoke" specifically would mean separating real output from
|
|
377
|
+
* liveness beats at the {@link RunOptions} seam, which is a change to what the cold-start and
|
|
378
|
+
* inactivity watchdogs fire on — deliberately not folded into this diagnostic-only fix.
|
|
379
|
+
*/
|
|
380
|
+
function silenceClause(ctx, now) {
|
|
381
|
+
if (ctx.killReason === 'inactivity')
|
|
382
|
+
return '';
|
|
383
|
+
const silentMs = now - (ctx.lastActivityAt ?? ctx.startedAt);
|
|
384
|
+
if (silentMs < SILENCE_BREADCRUMB_MS)
|
|
385
|
+
return '';
|
|
386
|
+
const secs = Math.round(silentMs / 1000);
|
|
387
|
+
return ctx.lastActivityAt === undefined ? `no activity at all in ${secs}s` : `silent for ${secs}s`;
|
|
388
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { runCapturedCommand } from './captured-command.js';
|
|
1
|
+
import { fencedOutput, runCapturedCommand } from './captured-command.js';
|
|
2
2
|
/**
|
|
3
3
|
* The ceiling the harness clamps a body-supplied `validationChecks.maxAttempts` to, and the
|
|
4
4
|
* default it applies when the body omits one.
|
|
@@ -171,7 +171,11 @@ untrackedFiles = []) {
|
|
|
171
171
|
const reason = o.timedOut
|
|
172
172
|
? `timed out after ${Math.round((o.durationMs ?? 0) / 1000)}s`
|
|
173
173
|
: `exited ${o.exitCode}`;
|
|
174
|
-
|
|
174
|
+
// Fenced through the shared helper: a failing lint or test routinely prints backticks
|
|
175
|
+
// (a rule quoting a template literal, a fixture echoing a fenced snippet), and a fixed
|
|
176
|
+
// three-tick fence closes on the first such run — spilling the rest of the failure, and
|
|
177
|
+
// the repair INSTRUCTIONS below it, into what the model reads as prose.
|
|
178
|
+
return `### ${o.label} — ${reason}\n\n${fencedOutput(`$ ${o.command}\n${body}`)}`;
|
|
175
179
|
})
|
|
176
180
|
.join('\n\n');
|
|
177
181
|
const remaining = report.maxAttempts - report.attempts;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/executor-harness",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.76.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",
|
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
"hono": "^4.12.32",
|
|
27
27
|
"typescript": "7.0.2",
|
|
28
28
|
"vitest": "^4.1.10",
|
|
29
|
-
"@cat-factory/kernel": "0.
|
|
30
|
-
"@cat-factory/server": "0.
|
|
31
|
-
"@cat-factory/spend": "0.12.
|
|
29
|
+
"@cat-factory/kernel": "0.187.0",
|
|
30
|
+
"@cat-factory/server": "0.174.0",
|
|
31
|
+
"@cat-factory/spend": "0.12.117"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
34
|
"build": "tsc -p tsconfig.json",
|