@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.
@@ -0,0 +1,333 @@
1
+ import { relative } from 'node:path'
2
+ import { fencedOutput, runCapturedCommand } from './captured-command.js'
3
+ import { excludePathsFromGit, listUntrackedPaths } from './git.js'
4
+ import { loadRunnerLimits, type RunOptions } from './runner.js'
5
+ import type { Logger } from './logger.js'
6
+
7
+ // DEPENDENCY PREPOPULATION — the pre-agent install phase (see
8
+ // docs/initiatives/agent-dependency-prepopulation.md).
9
+ //
10
+ // A repo-aware agent that opens a fresh clone sees manifests, not dependencies: it can read that
11
+ // a library is depended upon but not what that library actually exposes, so it guesses at APIs,
12
+ // re-derives type shapes, or declines work it could have done. This module runs the service's
13
+ // declared install against the checkout BEFORE the agent's first turn, and tells the agent what
14
+ // happened either way.
15
+ //
16
+ // Three properties are load-bearing, and each has cost the platform a run when it was missing
17
+ // somewhere else:
18
+ //
19
+ // - BEST-EFFORT, NEVER A GATE. A private registry the deployment has no token for, a toolchain
20
+ // the image lacks, a network hiccup — none of those are the agent's fault or the run's. A
21
+ // failed install produces a NOTE the agent reads (and can act on: it may install what it
22
+ // needs itself) rather than a dead run. This is the opposite disposition from the pre-PR
23
+ // validation loop, which is a gate on purpose: an install is setup, a check is a verdict.
24
+ // - HEARTBEAT. A cold `pnpm install` is exactly the activity-SILENT phase the job inactivity
25
+ // watchdog (`JOB_INACTIVITY_MS`, default 10 min) was never meant to judge, and the harness
26
+ // spawns it itself so it emits no agent activity. Without the heartbeat a healthy install
27
+ // aborts the run as "likely hung" — the same trap `frontend-infra.ts` and `validation-checks.ts`
28
+ // each had to answer.
29
+ // - PER-JOB BY CONSTRUCTION. The command, the cwd and the environment all arrive as arguments;
30
+ // nothing is read from or written to `process.env`/`HOME`. The local NATIVE transport serves
31
+ // every concurrent job from ONE host process, so a global would leak one job's install into a
32
+ // sibling's checkout and the container path would never catch it.
33
+ //
34
+ // {@link prepopulateDependencies} is the ONE entry point every mode calls. The phase applies to
35
+ // every dispatch that gets a checkout — coding, in-place fixing, conflict resolution and the
36
+ // read-only explore kinds alike — and a mode that assembled the run/exclude/note steps itself
37
+ // would be one refactor away from quietly dropping one of them. Which is how the first cut of
38
+ // this feature shipped: three modes wired, three (multi-repo coding, conflict resolution) not,
39
+ // with nothing failing to say so.
40
+
41
+ /** The dependency-install phase as it arrives on the job body. */
42
+ export interface DependencyInstallSpec {
43
+ /** The shell command, run as `sh -c` in the checkout (the service directory for a monorepo). */
44
+ command: string
45
+ }
46
+
47
+ /** What the install did — folded into the agent's prompt, never a verdict about the run. */
48
+ export interface DependencyInstallOutcome {
49
+ command: string
50
+ exitCode: number
51
+ passed: boolean
52
+ /** Scrubbed, bounded tail of the combined output. Only kept for a FAILED install. */
53
+ outputTail?: string
54
+ durationMs: number
55
+ timedOut?: boolean
56
+ }
57
+
58
+ /**
59
+ * How much of a failed install's output the agent is shown. Smaller than the validation loop's
60
+ * repair budget (16k) on purpose: a repair prompt has to carry the whole failure because fixing
61
+ * it IS the task, whereas this note only has to let the agent decide whether to install
62
+ * something itself. The tail is where a package manager puts its actual error.
63
+ */
64
+ export const DEPENDENCY_INSTALL_TAIL_CHARS = 4_000
65
+
66
+ /**
67
+ * The share of the JOB's whole wall-clock ceiling (`JOB_MAX_DURATION_MS`) the install may consume
68
+ * before the watchdog kills it. The install is SETUP: it runs before the agent's first turn, so
69
+ * every second it takes is a second the work itself does not get, and a wedged package manager
70
+ * that ran to a fixed 20-minute watchdog on a shortened job could leave the agent with almost
71
+ * nothing. A third leaves the run two thirds of its budget in the worst case.
72
+ */
73
+ const DEPENDENCY_INSTALL_JOB_SHARE = 1 / 3
74
+
75
+ /**
76
+ * A floor under the DERIVED ceiling (never under an explicit override, which tests legitimately
77
+ * set to milliseconds): a drastically shortened job would otherwise compute a share so small that
78
+ * no install could ever finish inside it, turning every run's setup into a guaranteed timeout.
79
+ */
80
+ const DEPENDENCY_INSTALL_CEILING_FLOOR_MS = 30_000
81
+
82
+ /** The default watchdog — the share above at the default 60-minute job ceiling. */
83
+ const DEPENDENCY_INSTALL_TIMEOUT_DEFAULT_MS = 20 * 60_000
84
+
85
+ /**
86
+ * The per-install watchdog: the longest the install may run before it is killed and reported as
87
+ * failed. Generous (20 min at the defaults) because a cold monorepo install on a slow registry
88
+ * legitimately takes many minutes.
89
+ *
90
+ * DERIVED from the configured job ceiling rather than hardcoded against the default one, the same
91
+ * way `git.ts` derives its per-command timeout from the configured inactivity window: a constant
92
+ * sized against a default silently breaks its own invariant the moment an operator changes that
93
+ * default. An explicit `DEPENDENCY_INSTALL_TIMEOUT_MS` is honoured but still CLAMPED — the point
94
+ * of the share is that no configuration lets setup eat the run, and an override that could exceed
95
+ * the job's own ceiling would only ever be killed later by a watchdog that fails the whole job
96
+ * instead of degrading to a note.
97
+ */
98
+ export function dependencyInstallTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
99
+ const configured = Number(env.DEPENDENCY_INSTALL_TIMEOUT_MS)
100
+ const requested =
101
+ Number.isFinite(configured) && configured > 0
102
+ ? Math.floor(configured)
103
+ : DEPENDENCY_INSTALL_TIMEOUT_DEFAULT_MS
104
+ const ceiling = Math.max(
105
+ DEPENDENCY_INSTALL_CEILING_FLOOR_MS,
106
+ Math.floor(loadRunnerLimits(env).maxDurationMs * DEPENDENCY_INSTALL_JOB_SHARE),
107
+ )
108
+ return Math.min(requested, ceiling)
109
+ }
110
+
111
+ /**
112
+ * How often the install feeds the run's inactivity watchdog. Well under `JOB_INACTIVITY_MS`
113
+ * (default 10 min); matches the validation loop's and the frontend stand-up's heartbeat, which
114
+ * exist for exactly the same reason.
115
+ */
116
+ export function dependencyInstallHeartbeatMs(): number {
117
+ const n = Number(process.env.DEPENDENCY_INSTALL_HEARTBEAT_MS)
118
+ return Number.isFinite(n) && n > 0 ? Math.floor(n) : 30_000
119
+ }
120
+
121
+ /**
122
+ * Parse the optional DEPENDENCY INSTALL envelope off the job body. A missing/blank command
123
+ * returns `undefined`, so a malformed body degrades to the exact pre-feature behaviour (no
124
+ * install phase, the agent starts against the bare clone) rather than failing a good run.
125
+ *
126
+ * Lives with the feature rather than in `job.ts`, following the same rule the two pre-PR
127
+ * verification phases do: each phase owns its own job-body parser next to the code that consumes
128
+ * it, and `job.ts` stays the job SHAPE plus the generic assembly.
129
+ */
130
+ export function parseDependencyInstallSpec(value: unknown): DependencyInstallSpec | undefined {
131
+ if (typeof value !== 'object' || value === null) return undefined
132
+ const raw = (value as Record<string, unknown>).command
133
+ const command = typeof raw === 'string' ? raw.trim() : ''
134
+ return command ? { command } : undefined
135
+ }
136
+
137
+ /**
138
+ * Run the declared install against `cwd` and return what happened. Never throws and never fails
139
+ * the job: every failure shape ({@link runCapturedCommand} maps a timeout to 124, a spawn error
140
+ * to 127, an abort to 130) comes back as a non-zero outcome the caller turns into a prompt note.
141
+ *
142
+ * The output tail is kept ONLY for a failure. A successful install prints tens of thousands of
143
+ * uninteresting lines, and the agent needs to know that it succeeded, not what it resolved.
144
+ */
145
+ export async function runDependencyInstall(args: {
146
+ cwd: string
147
+ spec: DependencyInstallSpec
148
+ logger: Logger
149
+ opts: RunOptions
150
+ }): Promise<DependencyInstallOutcome> {
151
+ const { cwd, spec, logger, opts } = args
152
+ logger.info('dependencies: installing', { command: spec.command })
153
+ const heartbeat = setInterval(() => opts.onActivity?.(), dependencyInstallHeartbeatMs())
154
+ heartbeat.unref?.()
155
+ try {
156
+ const run = await runCapturedCommand({
157
+ cwd,
158
+ command: spec.command,
159
+ timeoutMs: dependencyInstallTimeoutMs(),
160
+ reportTailChars: DEPENDENCY_INSTALL_TAIL_CHARS,
161
+ logLabel: 'dependencies',
162
+ logger,
163
+ opts,
164
+ })
165
+ logger.info('dependencies: install finished', {
166
+ exitCode: run.exitCode,
167
+ durationMs: run.durationMs,
168
+ })
169
+ return {
170
+ command: spec.command,
171
+ exitCode: run.exitCode,
172
+ passed: run.passed,
173
+ ...(run.passed ? {} : run.outputTail ? { outputTail: run.outputTail } : {}),
174
+ durationMs: run.durationMs,
175
+ ...(run.timedOut ? { timedOut: true } : {}),
176
+ }
177
+ } finally {
178
+ clearInterval(heartbeat)
179
+ }
180
+ }
181
+
182
+ /**
183
+ * THE entry point: run the phase for a mode that has a checkout, and hand back the note to fold
184
+ * into the agent's prompt (or `undefined` when the service declared no install, which is every
185
+ * dispatch today that never configured one).
186
+ *
187
+ * Everything a caller could get wrong lives here rather than at six call sites: the phase marker,
188
+ * the best-effort run, keeping the installed tree out of the agent's commits, and naming WHERE
189
+ * the install ran when that is not where the agent will be standing. A mode supplies only its
190
+ * three directories.
191
+ */
192
+ export async function prepopulateDependencies(args: {
193
+ spec: DependencyInstallSpec | undefined
194
+ /** Where the install runs: the service subtree for a monorepo, else the checkout root. */
195
+ installDir: string
196
+ /** The git checkout whose local excludes protect the agent's commits from the installed tree. */
197
+ repoDir: string
198
+ /** The agent's own working directory, which names the install location when the two differ. */
199
+ agentDir: string
200
+ logger: Logger
201
+ opts: RunOptions
202
+ }): Promise<string | undefined> {
203
+ const { spec, installDir, repoDir, agentDir, logger, opts } = args
204
+ if (!spec) return undefined
205
+ opts.onPhase?.('dependencies')
206
+ // Taken BEFORE the install and diffed after, so what gets excluded is what the install itself
207
+ // materialised — not whatever the checkout already carried.
208
+ const untrackedBefore = new Set(await snapshotUntracked(repoDir, opts.signal))
209
+ // Never rejects — every failure shape comes back as a non-zero outcome — so a caller needs no
210
+ // unwinding and the run continues either way. A FAILED install is snapshotted too: a partial
211
+ // tree is just as untracked as a complete one.
212
+ const outcome = await runDependencyInstall({ cwd: installDir, spec, logger, opts })
213
+ const untrackedAfter = await snapshotUntracked(repoDir, opts.signal)
214
+ const added = untrackedAfter.filter((path) => !untrackedBefore.has(path))
215
+ await excludeInstalledArtifacts(repoDir, added, logger, opts.signal)
216
+ return buildDependencyInstallNote(outcome, installScope(agentDir, installDir))
217
+ }
218
+
219
+ /**
220
+ * Fold the note into a prompt. Trivial, and deliberately not inlined: it is applied on EVERY
221
+ * agent pass — including the validation and reproduction REPAIR passes, which start a fresh
222
+ * agent that would otherwise never learn the tree is already installed and would spend a repair
223
+ * round reinstalling it.
224
+ */
225
+ export function withDependencyNote(userPrompt: string, note: string | undefined): string {
226
+ return note ? `${userPrompt}\n\n${note}` : userPrompt
227
+ }
228
+
229
+ /**
230
+ * How the note names the checkout the install ran in: `undefined` when the agent will be standing
231
+ * in it (so it reads "this checkout"), otherwise the path from the agent's cwd. The multi-repo
232
+ * layout runs the agent at the workspace ROOT and a conflict resolution at the repo root, while
233
+ * the install belongs to a sibling checkout or a service subtree respectively — "this checkout"
234
+ * in either case points the agent at a directory with no dependency tree of its own.
235
+ *
236
+ * Separators are normalised because the note is prose an agent reads, and the local NATIVE
237
+ * transport runs this on the developer's own Windows host.
238
+ */
239
+ function installScope(agentDir: string, installDir: string): string | undefined {
240
+ const rel = relative(agentDir, installDir).replaceAll('\\', '/')
241
+ return rel === '' ? undefined : rel
242
+ }
243
+
244
+ /**
245
+ * Keep whatever the install materialised out of the agent's commits.
246
+ *
247
+ * A dependency tree is untracked, and the agent's own `git add -A` does not know it did not put
248
+ * it there — nor does the conflict-resolution flow, which stages the whole tree to complete its
249
+ * merge commit. A repo that ships a `.gitignore` covering `node_modules` is fine without this;
250
+ * one that does not (a fresh service, a language whose convention is looser) would open a pull
251
+ * request containing tens of thousands of vendored files. So the paths the install ADDED are
252
+ * excluded locally, exactly as the harness already does for its own sentinel files.
253
+ *
254
+ * Only what the install added: a snapshot diff, never a list of well-known directory names. A
255
+ * name list is a guess that is both incomplete (every ecosystem has its own) and unsafe (it would
256
+ * exclude a `target/` directory the agent legitimately authored). Best-effort — a git hiccup here
257
+ * must not fail a run whose install succeeded — and the paths are LOGGED, since silently ignoring
258
+ * part of a checkout is exactly the kind of thing a later run's author needs to be able to see.
259
+ */
260
+ async function excludeInstalledArtifacts(
261
+ repoDir: string,
262
+ paths: readonly string[],
263
+ logger: Logger,
264
+ signal?: AbortSignal,
265
+ ): Promise<void> {
266
+ if (paths.length === 0) return
267
+ try {
268
+ await excludePathsFromGit(repoDir, paths, signal)
269
+ logger.info('dependencies: excluded installed artifacts from git', { paths })
270
+ } catch (error) {
271
+ logger.warn('dependencies: could not exclude installed artifacts from git', {
272
+ paths,
273
+ error: error instanceof Error ? error.message : String(error),
274
+ })
275
+ }
276
+ }
277
+
278
+ /**
279
+ * The checkout's untracked paths, or an empty list when they cannot be read.
280
+ *
281
+ * Best-effort by design: a directory that is not a git checkout (or a git that failed) must
282
+ * degrade to "nothing to exclude" rather than failing a phase whose whole disposition is that it
283
+ * never fails a run. Degrading on the BEFORE read is the safe direction too — an unreadable
284
+ * snapshot makes every post-install path look new, so the exclusion errs towards protecting the
285
+ * commit rather than towards letting a dependency tree into it. Directories are collapsed by
286
+ * {@link listUntrackedPaths}, so a `node_modules` of 40k files is one entry, not 40k.
287
+ */
288
+ async function snapshotUntracked(repoDir: string, signal?: AbortSignal): Promise<string[]> {
289
+ return listUntrackedPaths(repoDir, signal).catch(() => [])
290
+ }
291
+
292
+ /**
293
+ * The note folded into the agent's prompt describing the checkout it is about to work in.
294
+ *
295
+ * Stated in BOTH directions on purpose. On success the agent is told the tree is ready, which is
296
+ * what stops it spending turns re-running an install that already ran (and, on a repo whose
297
+ * install is slow, spending most of its budget there). On failure it is told plainly what failed
298
+ * and that it may install what it needs itself — an agent that merely finds no `node_modules` and
299
+ * no explanation concludes the environment is offline and works around a gap that isn't there.
300
+ *
301
+ * `scope` names the checkout the install ran in and is set ONLY when that is not the agent's own
302
+ * working directory — the multi-repo layout runs the agent at the workspace root while the install
303
+ * belongs to the primary service's sibling directory. Saying "this checkout" there would point the
304
+ * agent at a root that has no dependency tree of its own.
305
+ */
306
+ export function buildDependencyInstallNote(
307
+ outcome: DependencyInstallOutcome,
308
+ scope?: string,
309
+ ): string {
310
+ const subject = scope ? `The \`${scope}/\` checkout's` : "This checkout's"
311
+ if (outcome.passed) {
312
+ return [
313
+ `${subject} dependencies have already been installed for you (\`${outcome.command}\`), so the`,
314
+ 'installed packages are present on disk. Read them directly to confirm what a dependency',
315
+ 'actually exposes rather than inferring its API from the manifest, and do NOT re-run the',
316
+ 'install unless you change the dependency manifest.',
317
+ ].join('\n')
318
+ }
319
+ const reason = outcome.timedOut
320
+ ? `timed out after ${Math.round(outcome.durationMs / 1000)}s`
321
+ : `exited ${outcome.exitCode}`
322
+ return [
323
+ `${subject} dependencies could NOT be installed for you: \`${outcome.command}\` ${reason}.`,
324
+ 'The installed packages are therefore missing or incomplete. You have network access, so you',
325
+ 'may install what you need yourself if it helps — but treat the failure below as a fact about',
326
+ 'the environment, not as a defect to fix as part of this task, and do not change the project’s',
327
+ 'dependency manifests to work around it.',
328
+ // Fenced so the captured output cannot be read as instructions — and fenced through the
329
+ // shared helper, because a package manager prints backticks often enough that a fixed
330
+ // three-tick fence would close mid-tail and spill the rest of this note's prose.
331
+ ...(outcome.outputTail ? ['', fencedOutput(outcome.outputTail)] : []),
332
+ ].join('\n')
333
+ }
package/src/git.ts CHANGED
@@ -536,6 +536,26 @@ export async function listUntrackedFiles(dir: string, signal?: AbortSignal): Pro
536
536
  .filter((path) => path !== '')
537
537
  }
538
538
 
539
+ /**
540
+ * The untracked, non-ignored paths in the working tree with whole untracked DIRECTORIES
541
+ * collapsed to a single `dir/` entry (`--directory`), rather than every file beneath them.
542
+ *
543
+ * The sibling {@link listUntrackedFiles} answers "what did the agent forget to commit", where
544
+ * every individual file is the point. This one answers "what appeared in the tree", where it is
545
+ * emphatically not: a dependency install leaves tens of thousands of files under one directory,
546
+ * and enumerating them would cost a multi-megabyte listing to learn a single name.
547
+ */
548
+ export async function listUntrackedPaths(dir: string, signal?: AbortSignal): Promise<string[]> {
549
+ const out = await git(
550
+ ['ls-files', '--others', '--exclude-standard', '--directory', '--no-empty-directory'],
551
+ { cwd: dir, signal },
552
+ )
553
+ return out
554
+ .split('\n')
555
+ .map((line) => line.replace(/\r$/, '').trim())
556
+ .filter((path) => path !== '')
557
+ }
558
+
539
559
  /**
540
560
  * Locally exclude `pattern` from this checkout via `.git/info/exclude` — a per-clone
541
561
  * ignore that never lands in the repo (unlike a `.gitignore`). Used for the harness's
@@ -557,6 +577,38 @@ export async function excludeFromGit(
557
577
  }
558
578
  }
559
579
 
580
+ /**
581
+ * Locally exclude LITERAL paths — never patterns — from this checkout, in ONE write.
582
+ *
583
+ * The sibling {@link excludeFromGit} takes an author-written pattern for a known sentinel. These
584
+ * paths instead come from the FILESYSTEM (what a dependency install left behind), so two things
585
+ * differ. Each is escaped, because a directory named `pkg[1]` read as a gitignore character class
586
+ * excludes something else entirely and, being a no-op on the real path, fails silently. And they
587
+ * are appended together, because a per-path append would cost one file write per entry to build
588
+ * a list that is already known in full.
589
+ *
590
+ * Anchored: `ls-files` reports repo-root-relative paths and a gitignore pattern containing a
591
+ * slash is root-anchored, which is what makes `packages/api/node_modules/` exclude that service's
592
+ * tree and not a same-named directory elsewhere. Best-effort, exactly like its sibling.
593
+ */
594
+ export async function excludePathsFromGit(
595
+ dir: string,
596
+ paths: readonly string[],
597
+ signal?: AbortSignal,
598
+ ): Promise<void> {
599
+ if (paths.length === 0) return
600
+ // Escape every gitignore metacharacter, plus a leading `#` (comment) or `!` (negation) which
601
+ // are only special in that position.
602
+ const escaped = paths.map((p) => p.replace(/[[\]*?\\]/g, '\\$&').replace(/^([#!])/, '\\$1'))
603
+ try {
604
+ const excludePath = join(dir, '.git', 'info', 'exclude')
605
+ await appendFile(excludePath, `\n${escaped.join('\n')}\n`, 'utf8')
606
+ } catch {
607
+ // A missing .git/info/exclude (worktree layout) or write error is non-fatal.
608
+ void signal
609
+ }
610
+ }
611
+
560
612
  /** Whether the branch advanced past `baseSha` via commits (the agent's own + any safety-net commit). */
561
613
  export async function branchHasCommitsSince(
562
614
  dir: string,
package/src/job.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  type ReproductionReport,
13
13
  type ReproductionSpec,
14
14
  } from './reproduction-proof.js'
15
+ import { parseDependencyInstallSpec, type DependencyInstallSpec } from './dependency-install.js'
15
16
  import {
16
17
  parseMcpServerSpecs,
17
18
  parseSkillSpecs,
@@ -420,7 +421,7 @@ export interface PackageRegistrySpec {
420
421
  ecosystem: 'npm'
421
422
  /** Registry host, e.g. `registry.npmjs.org` — allowlisted, never a full URL. */
422
423
  host: string
423
- /** npm scopes (`@org`) routed to this registry. */
424
+ /** npm scopes (`@org`) routed to this registry; EMPTY ⇒ authenticate the host only. */
424
425
  scopes: string[]
425
426
  token: string
426
427
  }
@@ -468,8 +469,12 @@ export function parsePackageRegistries(
468
469
  `Invalid job: 'packageRegistries[${i}].host' '${host}' is not an allowed npm registry host`,
469
470
  )
470
471
  }
471
- if (!Array.isArray(entry.scopes) || entry.scopes.length === 0) {
472
- throw new Error(`Invalid job: 'packageRegistries[${i}].scopes' must be a non-empty array`)
472
+ // An EMPTY scope list is valid and deliberate: the entry then only authenticates its
473
+ // host, leaving every package to resolve from the default registry unless a dependency
474
+ // pins this one itself. Mapping a scope is all-or-nothing, so a workspace mixing private
475
+ // and public packages under one scope must be able to skip it.
476
+ if (!Array.isArray(entry.scopes)) {
477
+ throw new Error(`Invalid job: 'packageRegistries[${i}].scopes' must be an array`)
473
478
  }
474
479
  const scopes = entry.scopes.map((scope, j) => {
475
480
  const s = str(scope, `packageRegistries[${i}].scopes[${j}]`).trim()
@@ -885,6 +890,18 @@ export interface AgentJob extends HarnessAuthFields {
885
890
  * `docs/initiatives/bugfix-reproduction-proof.md`.
886
891
  */
887
892
  reproduction?: ReproductionSpec
893
+ /**
894
+ * DEPENDENCY PREPOPULATION: the service's install command, run against the checkout BEFORE the
895
+ * agent's first turn so it reads a tree whose dependencies are present rather than inferring
896
+ * them from a manifest. Applies to EVERY mode that gets a checkout (explore as well as coding)
897
+ * — unlike {@link validationChecks}, which is a pre-PR gate — because an agent reading or
898
+ * reviewing a tree needs its dependencies as much as one changing it.
899
+ *
900
+ * Best-effort: a failure becomes a note in the agent's prompt, never a failed job. Absent ⇒ the
901
+ * run behaves exactly as before. Deliberately keyed off job DATA, not the agent kind. See
902
+ * `docs/initiatives/agent-dependency-prepopulation.md`.
903
+ */
904
+ dependencyInstall?: DependencyInstallSpec
888
905
  }
889
906
 
890
907
  /** Per-job, per-knob progress-guard overrides (see {@link AgentJob.guardLimits}). */
@@ -1309,6 +1326,7 @@ export function parseAgentJob(input: unknown): AgentJob {
1309
1326
  validation: parseValidationSpec(o.validation),
1310
1327
  validationChecks: parseValidationChecksSpec(o.validationChecks),
1311
1328
  reproduction: parseReproductionSpec(o.reproduction),
1329
+ dependencyInstall: parseDependencyInstallSpec(o.dependencyInstall),
1312
1330
  reviewPrNumber: posInt(o.reviewPrNumber),
1313
1331
  })
1314
1332
  assertAllowedHost(job.repo.cloneUrl, 'repo.cloneUrl')
@@ -1349,6 +1367,7 @@ interface ParsedAgentJobParts {
1349
1367
  validation: ReturnType<typeof parseValidationSpec>
1350
1368
  validationChecks: ReturnType<typeof parseValidationChecksSpec>
1351
1369
  reproduction: ReturnType<typeof parseReproductionSpec>
1370
+ dependencyInstall: ReturnType<typeof parseDependencyInstallSpec>
1352
1371
  reviewPrNumber: number | undefined
1353
1372
  }
1354
1373
 
@@ -1404,6 +1423,7 @@ function assembleAgentJob(
1404
1423
  validation,
1405
1424
  validationChecks,
1406
1425
  reproduction,
1426
+ dependencyInstall,
1407
1427
  reviewPrNumber,
1408
1428
  } = parts
1409
1429
  const repo = (o.repo ?? {}) as Record<string, unknown>
@@ -1435,6 +1455,7 @@ function assembleAgentJob(
1435
1455
  ...(validation ? { validation } : {}),
1436
1456
  ...(validationChecks ? { validationChecks } : {}),
1437
1457
  ...(reproduction ? { reproduction } : {}),
1458
+ ...(dependencyInstall ? { dependencyInstall } : {}),
1438
1459
  }
1439
1460
  }
1440
1461
 
@@ -0,0 +1,19 @@
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
+ /**
12
+ * How a child process ended: its own exit code, or the signal that killed it.
13
+ *
14
+ * @example describeProcessExit(1, null) // 'exited with code 1'
15
+ * @example describeProcessExit(null, 'SIGKILL') // 'killed by SIGKILL'
16
+ */
17
+ export function describeProcessExit(code: number | null, signal: NodeJS.Signals | null): string {
18
+ return code === null ? `killed by ${signal ?? 'signal'}` : `exited with code ${code}`
19
+ }