@cat-factory/executor-harness 1.74.0 → 1.76.2

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.
@@ -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) {
@@ -611,6 +612,7 @@ export function parseAgentJob(input) {
611
612
  validation: parseValidationSpec(o.validation),
612
613
  validationChecks: parseValidationChecksSpec(o.validationChecks),
613
614
  reproduction: parseReproductionSpec(o.reproduction),
615
+ dependencyInstall: parseDependencyInstallSpec(o.dependencyInstall),
614
616
  reviewPrNumber: posInt(o.reviewPrNumber),
615
617
  });
616
618
  assertAllowedHost(job.repo.cloneUrl, 'repo.cloneUrl');
@@ -667,7 +669,7 @@ function parseAgentPrSpec(raw) {
667
669
  * literal doesn't blow the complexity budget; behaviour is byte-identical (spread order preserved).
668
670
  */
669
671
  function assembleAgentJob(o, mode, agentField, parts) {
670
- 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;
671
673
  const repo = (o.repo ?? {});
672
674
  return {
673
675
  jobId: str(o.jobId, 'jobId'),
@@ -697,6 +699,7 @@ function assembleAgentJob(o, mode, agentField, parts) {
697
699
  ...(validation ? { validation } : {}),
698
700
  ...(validationChecks ? { validationChecks } : {}),
699
701
  ...(reproduction ? { reproduction } : {}),
702
+ ...(dependencyInstall ? { dependencyInstall } : {}),
700
703
  };
701
704
  }
702
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 (sawActivity)
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 (!sawActivity) {
192
- sawActivity = true;
196
+ if (lastActivityAt === undefined)
193
197
  clearTimeout(coldStart);
194
- }
195
- entry.heartbeatAt = Date.now();
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(killReason, error, failedInPhase, lastTool, phaseTimingsMs);
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(killReason, error, phase, lastTool, phaseTimingsMs) {
288
- // `lastTool` is the last tool that COMPLETED (a span is emitted on tool end), so when the
289
- // hang is inside a still-running tool the breadcrumb points at the prior one — worded
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
- if (killReason === 'inactivity') {
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
- return `### ${o.label} ${reason}\n\n\`\`\`\n$ ${o.command}\n${body}\n\`\`\``;
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.74.0",
3
+ "version": "1.76.2",
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,15 +26,16 @@
26
26
  "hono": "^4.12.32",
27
27
  "typescript": "7.0.2",
28
28
  "vitest": "^4.1.10",
29
- "@cat-factory/kernel": "0.185.1",
30
- "@cat-factory/server": "0.172.2",
31
- "@cat-factory/spend": "0.12.115"
29
+ "@cat-factory/kernel": "0.188.0",
30
+ "@cat-factory/server": "0.175.0",
31
+ "@cat-factory/spend": "0.12.118"
32
32
  },
33
33
  "scripts": {
34
34
  "build": "tsc -p tsconfig.json",
35
35
  "typecheck": "tsc -p tsconfig.typecheck.json --noEmit",
36
36
  "start": "node dist/server.js",
37
37
  "test": "vitest run",
38
+ "test:run": "vitest run",
38
39
  "test:acceptance": "vitest run --config vitest.acceptance.config.ts",
39
40
  "image:publish": "bash scripts/publish-image.sh"
40
41
  }