@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.
- 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 +4 -1
- package/dist/process-exit.js +18 -0
- package/dist/runner.js +91 -23
- package/dist/validation-checks.js +6 -2
- package/package.json +5 -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 +17 -0
- package/src/process-exit.ts +19 -0
- package/src/runner.ts +124 -37
- package/src/validation-checks.ts +6 -2
|
@@ -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,
|
|
@@ -889,6 +890,18 @@ export interface AgentJob extends HarnessAuthFields {
|
|
|
889
890
|
* `docs/initiatives/bugfix-reproduction-proof.md`.
|
|
890
891
|
*/
|
|
891
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
|
|
892
905
|
}
|
|
893
906
|
|
|
894
907
|
/** Per-job, per-knob progress-guard overrides (see {@link AgentJob.guardLimits}). */
|
|
@@ -1313,6 +1326,7 @@ export function parseAgentJob(input: unknown): AgentJob {
|
|
|
1313
1326
|
validation: parseValidationSpec(o.validation),
|
|
1314
1327
|
validationChecks: parseValidationChecksSpec(o.validationChecks),
|
|
1315
1328
|
reproduction: parseReproductionSpec(o.reproduction),
|
|
1329
|
+
dependencyInstall: parseDependencyInstallSpec(o.dependencyInstall),
|
|
1316
1330
|
reviewPrNumber: posInt(o.reviewPrNumber),
|
|
1317
1331
|
})
|
|
1318
1332
|
assertAllowedHost(job.repo.cloneUrl, 'repo.cloneUrl')
|
|
@@ -1353,6 +1367,7 @@ interface ParsedAgentJobParts {
|
|
|
1353
1367
|
validation: ReturnType<typeof parseValidationSpec>
|
|
1354
1368
|
validationChecks: ReturnType<typeof parseValidationChecksSpec>
|
|
1355
1369
|
reproduction: ReturnType<typeof parseReproductionSpec>
|
|
1370
|
+
dependencyInstall: ReturnType<typeof parseDependencyInstallSpec>
|
|
1356
1371
|
reviewPrNumber: number | undefined
|
|
1357
1372
|
}
|
|
1358
1373
|
|
|
@@ -1408,6 +1423,7 @@ function assembleAgentJob(
|
|
|
1408
1423
|
validation,
|
|
1409
1424
|
validationChecks,
|
|
1410
1425
|
reproduction,
|
|
1426
|
+
dependencyInstall,
|
|
1411
1427
|
reviewPrNumber,
|
|
1412
1428
|
} = parts
|
|
1413
1429
|
const repo = (o.repo ?? {}) as Record<string, unknown>
|
|
@@ -1439,6 +1455,7 @@ function assembleAgentJob(
|
|
|
1439
1455
|
...(validation ? { validation } : {}),
|
|
1440
1456
|
...(validationChecks ? { validationChecks } : {}),
|
|
1441
1457
|
...(reproduction ? { reproduction } : {}),
|
|
1458
|
+
...(dependencyInstall ? { dependencyInstall } : {}),
|
|
1442
1459
|
}
|
|
1443
1460
|
}
|
|
1444
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
|
+
}
|