@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
package/README.md
CHANGED
|
@@ -63,21 +63,30 @@ The implementation job (`POST /run`) is the canonical sequence:
|
|
|
63
63
|
stays distinguishable from the first pass's in telemetry; without that flag the plain path is
|
|
64
64
|
used and the calls are recorded as unattributed
|
|
65
65
|
(see [token-burn instrumentation](../../../docs/initiatives/token-burn-instrumentation.md)),
|
|
66
|
-
3. **
|
|
67
|
-
|
|
66
|
+
3. **prepopulate dependencies**, when the job body carries `dependencyInstall` — the
|
|
67
|
+
service's install command is run with `sh -c` in the checkout BEFORE the agent starts, so
|
|
68
|
+
it reads real installed packages instead of inferring a library's capabilities from a
|
|
69
|
+
manifest entry. Best-effort and never a gate: the outcome (success or the captured
|
|
70
|
+
failure) is folded into the agent's prompt — on EVERY pass, including the repair passes of
|
|
71
|
+
steps 5 and 6, which start a fresh agent — and the run continues either way. Whatever the
|
|
72
|
+
install materialises is excluded from git first, so no later `git add -A` can sweep a
|
|
73
|
+
dependency tree into the pull request (see
|
|
74
|
+
[dependency prepopulation](../../../docs/initiatives/agent-dependency-prepopulation.md)),
|
|
75
|
+
4. **run Pi** non-interactively (`pi -p --mode json --model proxy/<model> --approve`),
|
|
76
|
+
5. **validate** the checkout, when the job body carries `validationChecks` — the service's
|
|
68
77
|
configured check commands (install/lint/test/build) run with `sh -c` in the checkout, and
|
|
69
78
|
while they fail and the attempt budget remains the agent is re-run with the captured output
|
|
70
79
|
as its instruction (see [pre-PR validation](../../../docs/initiatives/pre-pr-validation.md)),
|
|
71
|
-
|
|
80
|
+
6. **prove the reproduction**, when the job body carries `reproduction` — the declared check is
|
|
72
81
|
run against the pre-fix tree and the tree the PR will open from, in two freshly-created
|
|
73
82
|
symmetric `git worktree` checkouts, and only red-then-green is reported as proof (see
|
|
74
83
|
[bugfix reproduction proof](../../../docs/initiatives/bugfix-reproduction-proof.md)). Unlike
|
|
75
|
-
step
|
|
76
|
-
remains, then recorded as `inconclusive`. It runs BEFORE step
|
|
84
|
+
step 5 this NEVER gates the PR: a failed verification is fed back to the agent while budget
|
|
85
|
+
remains, then recorded as `inconclusive`. It runs BEFORE step 5 so validation stays the last
|
|
77
86
|
thing to touch the tree,
|
|
78
|
-
|
|
79
|
-
ONLY if step
|
|
80
|
-
and opens no PR. Absent `validationChecks` / `reproduction`, steps
|
|
87
|
+
7. **commit, push** a branch and **open a PR**, returning `{ prUrl, branch, summary }` — but
|
|
88
|
+
ONLY if step 5 ended green. A spent budget returns an error result with the validation report
|
|
89
|
+
and opens no PR. Absent `validationChecks` / `reproduction`, steps 5 and 6 do not happen at
|
|
81
90
|
all. The PR's description prefers the agent-authored reviewer briefing over the generic
|
|
82
91
|
dispatch-time text the job body carries: a PR-opening agent is prompted to write one to the
|
|
83
92
|
`.cat-pr-description.md` sentinel at the checkout root (one per sibling repo in a multi-repo
|
|
@@ -186,6 +195,7 @@ Kimi / DeepSeek) and meters spend. The provider key never enters the container.
|
|
|
186
195
|
| `src/agent-runner.ts` | The subscription-harness runners (`runClaudeCode` / `runCodex`) — talk direct to the vendor with a leased OAuth token, lift per-turn usage/telemetry off the CLI event stream. |
|
|
187
196
|
| `src/transcript-retention.ts` | Lifts the CLI session transcripts (`projects/` / `sessions/`) out of the isolated, credential-bearing config home before it is deleted, and prunes them on a TTL (debugging artifact retention). |
|
|
188
197
|
| `src/captured-command.ts` | The one way the harness runs a declared shell command on its own behalf: `sh -c` with a per-command watchdog, abort handling, conventional exit codes (124/127/130) and a scrub-then-bound output capture. Shared by both pre-PR verification phases so a fix to one cannot miss the other. |
|
|
198
|
+
| `src/dependency-install.ts` | Dependency prepopulation: `prepopulateDependencies` is the ONE seam every checkout-having mode calls — it runs the service's install command before the agent's first turn, excludes what the install materialised from git so no `git add -A` can sweep a dependency tree into the PR, and builds the prompt note describing the outcome. Best-effort — every failure shape becomes a note, never a failed job. Generic — keyed off the job body, never the agent kind. |
|
|
189
199
|
| `src/validation-checks.ts` | Pre-PR validation: runs the job's check commands in the checkout (bounded, secret-scrubbed capture, per-command watchdog) and drives the retry-until-green loop that gates the PR. Generic — keyed off the job body, never the agent kind. |
|
|
190
200
|
| `src/reproduction-proof.ts` | Bugfix reproduction proof: runs the job's declared reproduction command against two symmetric fresh worktrees (the pre-fix tree and the final tree) and computes red-then-green from the exit codes, with a repair loop that never fails the run. Generic — keyed off the job body, never the agent kind. |
|
|
191
201
|
| `src/agent-capabilities.ts` | The agent CAPABILITIES a job body carries — the run's `skills` (a `SKILL.md` payload + resources) and its `mcpServers` (tool servers) — with their defensive parsing and the per-CLI config writers (`--mcp-config` JSON for claude-code, `[mcp_servers.*]` TOML for Codex). Backend-authored data the harness only MATERIALISES: adding a skill or a tool server is a backend registration, never a harness change. |
|
|
@@ -203,6 +213,9 @@ runner):
|
|
|
203
213
|
| `PORT` | `8080` | HTTP port the harness listens on. |
|
|
204
214
|
| `JOB_MAX_DURATION_MS` | `3600000` (60m) | Hard ceiling on a job's wall-clock time; force-fails after. |
|
|
205
215
|
| `JOB_INACTIVITY_MS` | `600000` (10m) | Kills a hung agent that produces no output for this long. |
|
|
216
|
+
| `JOB_COLD_START_MS` | `120000` (2m) | First-output window (ADR 0026 D4). A job that has produced nothing this long records a cold-start diagnostic — a likely onboarding/auth wedge — WITHOUT being killed: logged, exposed on `GET /jobs/{id}`, and folded into the failure `detail` if the job goes on to fail. `0` disables it. |
|
|
217
|
+
| `DEPENDENCY_INSTALL_TIMEOUT_MS` | a third of `JOB_MAX_DURATION_MS` (20m at its default) | Watchdog for the pre-agent dependency install; a timeout is reported as a failed install (exit 124), never a failed job. Derived from the job ceiling rather than fixed, and an explicit value is clamped by the same share — the agent is what waits on this, so setup can never consume the run it is preparing for. |
|
|
218
|
+
| `DEPENDENCY_INSTALL_HEARTBEAT_MS` | `30000` (30s) | How often the dependency install feeds the job inactivity watchdog. A cold install is activity-silent and `JOB_INACTIVITY_MS` is tighter than its own watchdog, so without this a healthy install aborts the run as "likely hung". |
|
|
206
219
|
| `VALIDATION_COMMAND_TIMEOUT_MS` | `900000` (15m) | Per-command watchdog for a pre-PR validation check; a timeout counts as a failure (exit 124) so one hung command can't wedge the loop. |
|
|
207
220
|
| `REPRODUCTION_COMMAND_TIMEOUT_MS` | `900000` (15m) | Per-command watchdog for a reproduction-proof setup or check command; a timeout counts as a failure (exit 124). |
|
|
208
221
|
| `REPRODUCTION_HEARTBEAT_MS` | `30000` (30s) | How often the reproduction proof feeds the job inactivity watchdog while it runs commands the agent is not producing output for. |
|
package/dist/agent-runner.js
CHANGED
|
@@ -8,6 +8,7 @@ import { createCallMetricPublisher, publishCallMetric, } from './pi.js';
|
|
|
8
8
|
import { claudeAllowedToolPatterns, codexMcpConfigToml, mcpServerSecretValues, writeClaudeMcpConfig, } from './agent-capabilities.js';
|
|
9
9
|
import { ProgressGuard } from './progress-guard.js';
|
|
10
10
|
import { killChildProcess, spawnDetached } from './process.js';
|
|
11
|
+
import { describeProcessExit } from './process-exit.js';
|
|
11
12
|
import { redact, registerKnownSecrets, secretsToRedact } from './redact.js';
|
|
12
13
|
import { createSliceTracker, startSubagentWatcher } from './subagents.js';
|
|
13
14
|
import { createTaskPlanTracker, mergeProgress, normalizeStatus, pickProgress, toProgress, todosToProgress, } from './progress.js';
|
|
@@ -112,7 +113,7 @@ function streamCli(cli, prompt, opts, env, secrets, onEvent) {
|
|
|
112
113
|
opts.signal?.removeEventListener('abort', onAbort);
|
|
113
114
|
reject(err);
|
|
114
115
|
});
|
|
115
|
-
child.on('close', (code) => {
|
|
116
|
+
child.on('close', (code, signal) => {
|
|
116
117
|
opts.signal?.removeEventListener('abort', onAbort);
|
|
117
118
|
const stderrTail = redact(stderr, secrets).slice(-700);
|
|
118
119
|
if (lineBuffer.trim())
|
|
@@ -125,13 +126,95 @@ function streamCli(cli, prompt, opts, env, secrets, onEvent) {
|
|
|
125
126
|
return;
|
|
126
127
|
}
|
|
127
128
|
if (code !== 0) {
|
|
128
|
-
reject(new
|
|
129
|
+
reject(new CliExitFailure({ command, exitCode: code, signal, stderrTail }));
|
|
129
130
|
return;
|
|
130
131
|
}
|
|
131
132
|
resolve({ stderrTail });
|
|
132
133
|
});
|
|
133
134
|
});
|
|
134
135
|
}
|
|
136
|
+
/**
|
|
137
|
+
* A CLI subprocess that ended badly — it exited non-zero, or a signal killed it.
|
|
138
|
+
*
|
|
139
|
+
* Its own class (rather than a formatted string) because the message is not final at throw time:
|
|
140
|
+
* the caller folds in the CLI's terminal report before it surfaces (see {@link withAgentReport}),
|
|
141
|
+
* and rebuilding from parts beats patching a rendered sentence. Distinct from the watchdog-abort
|
|
142
|
+
* rejection above, which owns its own diagnostic and must keep it.
|
|
143
|
+
*/
|
|
144
|
+
class CliExitFailure extends Error {
|
|
145
|
+
parts;
|
|
146
|
+
/**
|
|
147
|
+
* Also exposed flat, matching the watchdog-abort rejection's shape: the guard-trip branch
|
|
148
|
+
* reads `stderrTail` off whatever it caught to append to its own replacement message.
|
|
149
|
+
*/
|
|
150
|
+
stderrTail;
|
|
151
|
+
constructor(parts, report = '') {
|
|
152
|
+
super(cliExitMessage(parts, report));
|
|
153
|
+
this.name = 'CliExitFailure';
|
|
154
|
+
this.parts = parts;
|
|
155
|
+
this.stderrTail = parts.stderrTail;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* One message shape for a CLI that ended badly, with or without a report to add.
|
|
160
|
+
*
|
|
161
|
+
* How it ended is rendered through {@link describeProcessExit}, the shared vocabulary every
|
|
162
|
+
* process-reporting transport uses, so an externally-killed container job (an OOM kill, a
|
|
163
|
+
* `docker stop` racing teardown) reads differently from the CLI's own failure exit.
|
|
164
|
+
*/
|
|
165
|
+
function cliExitMessage(exit, report) {
|
|
166
|
+
const how = describeProcessExit(exit.exitCode, exit.signal);
|
|
167
|
+
const suffix = report ? ` Agent's last report: ${report}` : '';
|
|
168
|
+
return `${exit.command} ${how}: ${exit.stderrTail || '(no stderr output)'}${suffix}`;
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Re-throw a bad CLI exit with the CLI's own account of how the run ended folded in.
|
|
172
|
+
*
|
|
173
|
+
* Both agent CLIs report a terminal failure on STDOUT, inside their event stream (Claude Code's
|
|
174
|
+
* `result` event, Codex's last agent message) — never on stderr. So a run the upstream API kept
|
|
175
|
+
* refusing exits non-zero with an EMPTY stderr tail, and the harness surfaces `claude exited with
|
|
176
|
+
* code 1:` and nothing else, while the reason it collected sits in a local variable only the
|
|
177
|
+
* SUCCESS path returns. That failure is indistinguishable from a crash, and the operator has no
|
|
178
|
+
* next step. Nothing else in the run records it: the CLI's session transcript dies with the
|
|
179
|
+
* per-run config home, and a local-mode container is removed the moment the job settles.
|
|
180
|
+
*
|
|
181
|
+
* Anything that is not a bad-exit rejection passes through untouched — a watchdog abort and a
|
|
182
|
+
* tripped progress guard carry more specific diagnostics already.
|
|
183
|
+
*/
|
|
184
|
+
function withAgentReport(err, report, secrets) {
|
|
185
|
+
if (!(err instanceof CliExitFailure))
|
|
186
|
+
return err;
|
|
187
|
+
const folded = capReport(redact(report, secrets).trim());
|
|
188
|
+
return folded ? new CliExitFailure(err.parts, folded) : err;
|
|
189
|
+
}
|
|
190
|
+
/** How much of the agent's terminal report the failure message carries. */
|
|
191
|
+
const MAX_AGENT_REPORT_CHARS = 700;
|
|
192
|
+
/**
|
|
193
|
+
* Bound the agent's terminal report, keeping its HEAD — the opposite bias from the stderr tail
|
|
194
|
+
* beside it, and deliberately so. A stderr tail is a log: the cause is whatever it ended on. A
|
|
195
|
+
* report is a written statement, and its opening is where the answer lives — the failure
|
|
196
|
+
* `subtype` {@link claudeResultReport} prepends, or the first line of Codex's last agent message.
|
|
197
|
+
* Tail-slicing it drops exactly the classification the fold exists to surface.
|
|
198
|
+
*
|
|
199
|
+
* A cut is marked, because a report that merely stops reads like an agent that trailed off.
|
|
200
|
+
*/
|
|
201
|
+
function capReport(report) {
|
|
202
|
+
if (report.length <= MAX_AGENT_REPORT_CHARS)
|
|
203
|
+
return report;
|
|
204
|
+
return `${report.slice(0, MAX_AGENT_REPORT_CHARS)}… (report truncated)`;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* The CLI's own account of how the run ended, read off its terminal `result` event: the failure
|
|
208
|
+
* `subtype` it names (`error_during_execution`, `error_max_turns`, …) joined to whatever text it
|
|
209
|
+
* printed. A headless `-p` run reports an upstream API refusal HERE — on stdout, as JSON — and
|
|
210
|
+
* nowhere else, so this is what a bad exit has to carry. A clean result yields just its text.
|
|
211
|
+
*/
|
|
212
|
+
function claudeResultReport(event) {
|
|
213
|
+
const subtype = typeof event.subtype === 'string' ? event.subtype : '';
|
|
214
|
+
const text = typeof event.result === 'string' ? event.result.trim() : '';
|
|
215
|
+
const failed = event.is_error === true || (subtype !== '' && subtype !== 'success');
|
|
216
|
+
return failed ? [subtype || 'error', text].filter(Boolean).join(': ') : text;
|
|
217
|
+
}
|
|
135
218
|
/**
|
|
136
219
|
* Fold a composed system prompt into the task prompt so the role + best-practice context
|
|
137
220
|
* rides stdin as a single user turn. Used by the Codex runner (no system-prompt flag) and
|
|
@@ -246,6 +329,8 @@ async function setUpClaudeMcp(servers, configHome) {
|
|
|
246
329
|
export async function runClaudeCode(opts) {
|
|
247
330
|
const stats = { toolCalls: 0, assistantChars: 0 };
|
|
248
331
|
let summary = '';
|
|
332
|
+
/** The CLI's own account of how the run ended — see {@link claudeResultReport}. */
|
|
333
|
+
let terminalReport = '';
|
|
249
334
|
let usage;
|
|
250
335
|
// Decide how the composed system prompt is carried up front, so the telemetry seed below
|
|
251
336
|
// reflects what actually reaches the model: a small prompt rides `--append-system-prompt`
|
|
@@ -397,49 +482,11 @@ export async function runClaudeCode(opts) {
|
|
|
397
482
|
if (typeof event.result === 'string')
|
|
398
483
|
summary = event.result;
|
|
399
484
|
usage = claudeUsage(event.usage) ?? usage;
|
|
485
|
+
terminalReport = claudeResultReport(event) || terminalReport;
|
|
400
486
|
}
|
|
401
487
|
};
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
// Claude Code persists user config/credentials under its config dir; point that at an
|
|
405
|
-
// isolated, per-run temp dir OUTSIDE the cloned checkout (`opts.cwd`). Otherwise the
|
|
406
|
-
// agents that finish with `git add -A` (blueprint/requirements/bootstrap) could stage a
|
|
407
|
-
// stray `.claude/` directory — and any cached credential in it — into the pushed branch.
|
|
408
|
-
// Mirrors the Codex CODEX_HOME isolation below; removed in `finally`.
|
|
409
|
-
if (!opts.ambientAuth && !opts.subscriptionToken) {
|
|
410
|
-
throw new Error('claude-code harness requires a subscription token (or ambientAuth)');
|
|
411
|
-
}
|
|
412
|
-
const configHome = opts.ambientAuth ? undefined : await mkdtemp(join(tmpdir(), 'cf-claude-'));
|
|
413
|
-
// The config dir is brand-new every run, so Claude Code would otherwise treat this
|
|
414
|
-
// as a first launch and BLOCK on the interactive onboarding / "trust this folder" /
|
|
415
|
-
// bypass-permissions acknowledgement prompts — which never get answered headlessly,
|
|
416
|
-
// hanging the job until the watchdog kills it. Pre-seed the config that marks those
|
|
417
|
-
// as already accepted so `-p` starts straight into the run. Best-effort: written
|
|
418
|
-
// before the CLI starts; unknown keys are harmless if a CLI version ignores them.
|
|
419
|
-
// (Ambient mode skips this — the developer's own config is already onboarded.)
|
|
420
|
-
// ADR 0026 D4: assert the pinned onboarding keys landed and log them with the CLI
|
|
421
|
-
// version, so a future first-run gate this set doesn't cover (which looks identical to
|
|
422
|
-
// a healthy-but-quiet subagent start) is diffable when the cold-start watchdog fires.
|
|
423
|
-
if (configHome) {
|
|
424
|
-
await writeOnboardingPreseed(configHome);
|
|
425
|
-
await assertOnboardingKeysCurrent(configHome, process.env.CLAUDE_CLI_VERSION, opts.log);
|
|
426
|
-
}
|
|
427
|
-
// Skills: install each as a native skill under the config dir's `skills/<name>/` so the CLI
|
|
428
|
-
// discovers and can invoke it. ONLY into the isolated per-run config home — never the
|
|
429
|
-
// developer's own `~/.claude` (ambient/native mode), where it would persist in their personal
|
|
430
|
-
// setup after the run and two concurrent jobs carrying same-named skills would clobber each
|
|
431
|
-
// other. An ambient run reads the skills from the checkout instead (`.cat-context/skill/<name>/`,
|
|
432
|
-
// materialised by the caller). Best-effort: a write failure must not wedge the run — the prompt
|
|
433
|
-
// still names the skills.
|
|
434
|
-
if (configHome) {
|
|
435
|
-
for (const skill of opts.skills ?? []) {
|
|
436
|
-
await writeNativeSkill(join(configHome, 'skills'), skill).catch(() => { });
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
// Tool servers (MCP): the CLI is pointed at a per-run config rather than discovering an ambient
|
|
440
|
-
// one. See `setUpClaudeMcp` for why that matters and what has to be cleaned up afterwards.
|
|
441
|
-
const mcp = await setUpClaudeMcp(opts.mcpServers, configHome);
|
|
442
|
-
const env = buildClaudeEnv(opts, configHome);
|
|
488
|
+
const home = await openClaudeRunHome(opts);
|
|
489
|
+
const { configHome } = home;
|
|
443
490
|
// ADR 0026 D3 (path corrected by ADR 0027 Defect A): while the run is live, tail the CLI's
|
|
444
491
|
// subagent `*.jsonl` transcripts so a parallel-subagent review keeps the inactivity
|
|
445
492
|
// heartbeat alive (any new bytes ⇒ `onActivity`) and its otherwise-invisible token spend is
|
|
@@ -478,10 +525,10 @@ export async function runClaudeCode(opts) {
|
|
|
478
525
|
'bypassPermissions',
|
|
479
526
|
'--model',
|
|
480
527
|
opts.model,
|
|
481
|
-
...
|
|
528
|
+
...home.mcpArgs,
|
|
482
529
|
...appendArgs,
|
|
483
530
|
],
|
|
484
|
-
}, prompt, { ...opts, signal: runSignal }, env, opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [], onEvent);
|
|
531
|
+
}, prompt, { ...opts, signal: runSignal }, home.env, opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [], onEvent);
|
|
485
532
|
// The stream has ended, so the last call has no successor envelope to complete it.
|
|
486
533
|
telemetry.flush();
|
|
487
534
|
return await assembleClaudeOutcome({
|
|
@@ -504,20 +551,80 @@ export async function runClaudeCode(opts) {
|
|
|
504
551
|
telemetry.flush();
|
|
505
552
|
publisher.flush();
|
|
506
553
|
// A tripped no-progress guard aborted the CLI; streamCli rejects with its generic abort
|
|
507
|
-
// message, so replace it with the guard's actionable diagnostic — carrying the stderr tail
|
|
508
|
-
//
|
|
509
|
-
// killed.
|
|
554
|
+
// message, so replace it with the guard's actionable diagnostic — carrying the stderr tail it
|
|
555
|
+
// attached, since that is usually the only evidence of what the CLI was doing when it was
|
|
556
|
+
// killed. The leading clauses are byte-for-byte the shape `runPi` fails with; a terminal
|
|
557
|
+
// report is appended after them when the CLI managed to emit one before it was killed, which
|
|
558
|
+
// is uncommon but is the same evidence a bad exit now carries — a guard trip is no reason to
|
|
559
|
+
// discard it.
|
|
510
560
|
if (guardReason) {
|
|
511
561
|
const tail = err?.stderrTail;
|
|
512
|
-
|
|
562
|
+
const report = capReport(redact(terminalReport, secrets).trim());
|
|
563
|
+
throw new Error([
|
|
564
|
+
guardReason,
|
|
565
|
+
tail ? `Agent stderr: ${tail}` : '',
|
|
566
|
+
report ? `Agent's last report: ${report}` : '',
|
|
567
|
+
]
|
|
568
|
+
.filter(Boolean)
|
|
569
|
+
.join(' '));
|
|
513
570
|
}
|
|
514
|
-
throw err;
|
|
571
|
+
throw withAgentReport(err, terminalReport, secrets);
|
|
515
572
|
}
|
|
516
573
|
finally {
|
|
517
574
|
await subagents?.stop();
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
575
|
+
await home.dispose();
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
async function openClaudeRunHome(opts) {
|
|
579
|
+
// Native (ambient) mode: run the developer's installed `claude` with its OWN login —
|
|
580
|
+
// no isolated config home, no injected credential, no onboarding pre-seed. Otherwise,
|
|
581
|
+
// Claude Code persists user config/credentials under its config dir; point that at an
|
|
582
|
+
// isolated, per-run temp dir OUTSIDE the cloned checkout (`opts.cwd`). Otherwise the
|
|
583
|
+
// agents that finish with `git add -A` (blueprint/requirements/bootstrap) could stage a
|
|
584
|
+
// stray `.claude/` directory — and any cached credential in it — into the pushed branch.
|
|
585
|
+
// Mirrors the Codex CODEX_HOME isolation below; removed by `dispose`.
|
|
586
|
+
if (!opts.ambientAuth && !opts.subscriptionToken) {
|
|
587
|
+
throw new Error('claude-code harness requires a subscription token (or ambientAuth)');
|
|
588
|
+
}
|
|
589
|
+
const configHome = opts.ambientAuth ? undefined : await mkdtemp(join(tmpdir(), 'cf-claude-'));
|
|
590
|
+
// The config dir is brand-new every run, so Claude Code would otherwise treat this
|
|
591
|
+
// as a first launch and BLOCK on the interactive onboarding / "trust this folder" /
|
|
592
|
+
// bypass-permissions acknowledgement prompts — which never get answered headlessly,
|
|
593
|
+
// hanging the job until the watchdog kills it. Pre-seed the config that marks those
|
|
594
|
+
// as already accepted so `-p` starts straight into the run. Best-effort: written
|
|
595
|
+
// before the CLI starts; unknown keys are harmless if a CLI version ignores them.
|
|
596
|
+
// (Ambient mode skips this — the developer's own config is already onboarded.)
|
|
597
|
+
// ADR 0026 D4: assert the pinned onboarding keys landed and log them with the CLI
|
|
598
|
+
// version, so a future first-run gate this set doesn't cover (which looks identical to
|
|
599
|
+
// a healthy-but-quiet subagent start) is diffable when the cold-start watchdog fires.
|
|
600
|
+
if (configHome) {
|
|
601
|
+
await writeOnboardingPreseed(configHome);
|
|
602
|
+
await assertOnboardingKeysCurrent(configHome, process.env.CLAUDE_CLI_VERSION, opts.log);
|
|
603
|
+
}
|
|
604
|
+
// Skills: install each as a native skill under the config dir's `skills/<name>/` so the CLI
|
|
605
|
+
// discovers and can invoke it. ONLY into the isolated per-run config home — never the
|
|
606
|
+
// developer's own `~/.claude` (ambient/native mode), where it would persist in their personal
|
|
607
|
+
// setup after the run and two concurrent jobs carrying same-named skills would clobber each
|
|
608
|
+
// other. An ambient run reads the skills from the checkout instead (`.cat-context/skill/<name>/`,
|
|
609
|
+
// materialised by the caller). Best-effort: a write failure must not wedge the run — the prompt
|
|
610
|
+
// still names the skills.
|
|
611
|
+
if (configHome) {
|
|
612
|
+
for (const skill of opts.skills ?? []) {
|
|
613
|
+
await writeNativeSkill(join(configHome, 'skills'), skill).catch(() => { });
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
// Tool servers (MCP): the CLI is pointed at a per-run config rather than discovering an ambient
|
|
617
|
+
// one. See `setUpClaudeMcp` for why that matters and what has to be cleaned up afterwards.
|
|
618
|
+
const mcp = await setUpClaudeMcp(opts.mcpServers, configHome);
|
|
619
|
+
return {
|
|
620
|
+
configHome,
|
|
621
|
+
mcpArgs: mcp.args,
|
|
622
|
+
env: buildClaudeEnv(opts, configHome),
|
|
623
|
+
dispose: async () => {
|
|
624
|
+
// The ambient-mode MCP config dir (credential-bearing) never outlives the run.
|
|
625
|
+
await mcp.cleanup();
|
|
626
|
+
if (!configHome)
|
|
627
|
+
return;
|
|
521
628
|
// Lift the CLI session transcripts (`projects/`) out for short-lived retention BEFORE the
|
|
522
629
|
// home is deleted — the credential lives at the home root, never in `projects/`, so this
|
|
523
630
|
// keeps the debugging artifact without leaking the token. Best-effort; never throws.
|
|
@@ -527,8 +634,8 @@ export async function runClaudeCode(opts) {
|
|
|
527
634
|
});
|
|
528
635
|
// Never leave the config dir (and any cached credential) on disk past the run.
|
|
529
636
|
await rm(configHome, { recursive: true, force: true }).catch(() => { });
|
|
530
|
-
}
|
|
531
|
-
}
|
|
637
|
+
},
|
|
638
|
+
};
|
|
532
639
|
}
|
|
533
640
|
/**
|
|
534
641
|
* Build the child-process env for the `claude` CLI: an isolated config home plus subscription
|
|
@@ -770,6 +877,11 @@ export async function runCodex(opts) {
|
|
|
770
877
|
...(calls.length ? { callMetrics: calls } : {}),
|
|
771
878
|
};
|
|
772
879
|
}
|
|
880
|
+
catch (err) {
|
|
881
|
+
// Codex surfaces its terminal failure the same way Claude Code does — in the stdout event
|
|
882
|
+
// stream, not on stderr — so a bad exit carries the last thing the agent said.
|
|
883
|
+
throw withAgentReport(err, summary, secrets);
|
|
884
|
+
}
|
|
773
885
|
finally {
|
|
774
886
|
if (codexHome) {
|
|
775
887
|
// Lift the CLI session transcripts (`sessions/`) out for short-lived retention BEFORE the
|
package/dist/agent.js
CHANGED
|
@@ -11,6 +11,7 @@ import { inferVcsProvider, openPullRequest } from './vcs-api.js';
|
|
|
11
11
|
import { applyPrDescription } from './pr-description.js';
|
|
12
12
|
import { makeDirClaimer, noChangesReason, runCodingAgent, runMultiRepoCoding, } from './coding-agent.js';
|
|
13
13
|
import { validationFailureMessage } from './validation-checks.js';
|
|
14
|
+
import { prepopulateDependencies, withDependencyNote } from './dependency-install.js';
|
|
14
15
|
import { agentCapabilities, mergeEffort } from './agent-shared.js';
|
|
15
16
|
import { runBootstrap } from './bootstrap-mode.js';
|
|
16
17
|
import { acquireRepoCheckout, agentNeverActed, agentOutputTail, NEVER_ACTED_CAUSE, runAgentInWorkspace, unusableFinalAnswerCause, withWorkspace, } from './pi-workspace.js';
|
|
@@ -451,6 +452,24 @@ async function runExploreMode(job, opts) {
|
|
|
451
452
|
});
|
|
452
453
|
logger.info('agent(explore): PR head fetch', { number: job.reviewPrNumber, fetched });
|
|
453
454
|
}
|
|
455
|
+
// DEPENDENCY PREPOPULATION, before the agent's first turn. An EXPLORE run is the case this
|
|
456
|
+
// exists for: a reviewer or architect reading a fresh clone can see that a library is
|
|
457
|
+
// depended upon but not what it actually exposes, so it reasons about the manifest instead
|
|
458
|
+
// of the code. Best-effort — the outcome is stated in the prompt either way and never fails
|
|
459
|
+
// the run. Runs in `workDir` so a monorepo service installs from its own subtree.
|
|
460
|
+
//
|
|
461
|
+
// BEFORE the stand-up below, deliberately. The frontend stand-up runs the service's own
|
|
462
|
+
// install and then SERVES what it built: installing after it would pay for a second install
|
|
463
|
+
// and, worse, rewrite the `node_modules` the running app resolves out of. Prepopulation is
|
|
464
|
+
// setup for everything that follows, so it goes first.
|
|
465
|
+
const dependencyNote = await prepopulateDependencies({
|
|
466
|
+
spec: job.dependencyInstall,
|
|
467
|
+
installDir: workDir,
|
|
468
|
+
repoDir: dir,
|
|
469
|
+
agentDir: workDir,
|
|
470
|
+
logger,
|
|
471
|
+
opts,
|
|
472
|
+
});
|
|
454
473
|
// Optional infra stand-up (the tester): bring the service's docker-compose
|
|
455
474
|
// dependencies up at the repo root for the duration of the run, tearing them down in
|
|
456
475
|
// the `finally`. A stand-up failure is non-fatal — it's surfaced to the agent as a
|
|
@@ -463,9 +482,7 @@ async function runExploreMode(job, opts) {
|
|
|
463
482
|
// failure) is flagged as a concern; a frontend serve URL points the UI tester at the
|
|
464
483
|
// app it just built + served (the backend env resolution already reached the harness).
|
|
465
484
|
const infraNotes = managed ? buildInfraNotes(managed) : [];
|
|
466
|
-
const userPrompt = infraNotes.length
|
|
467
|
-
? `${job.userPrompt}\n\nNote: ${infraNotes.join(' ')}`
|
|
468
|
-
: job.userPrompt;
|
|
485
|
+
const userPrompt = withDependencyNote(infraNotes.length ? `${job.userPrompt}\n\nNote: ${infraNotes.join(' ')}` : job.userPrompt, dependencyNote);
|
|
469
486
|
// The stand-up record (success or failure, with its captured logs) rides back on EVERY
|
|
470
487
|
// result branch — the backend surfaces it on the Tester step regardless of whether the
|
|
471
488
|
// agent then produced a usable report.
|
|
@@ -668,12 +685,32 @@ async function runMultiRepoExplore(job, opts) {
|
|
|
668
685
|
});
|
|
669
686
|
}
|
|
670
687
|
}
|
|
688
|
+
// DEPENDENCY PREPOPULATION for the PRIMARY leg. The install is declared on ONE service frame
|
|
689
|
+
// (the primary repo's), so it is run in that leg's checkout — never fanned out across the
|
|
690
|
+
// peers, whose services declare their own configs the dispatch never resolved. The agent runs
|
|
691
|
+
// at the workspace ROOT and reads across every sibling, which is exactly why this matters
|
|
692
|
+
// here: a cross-repo investigator reasoning about a manifest instead of the packages is the
|
|
693
|
+
// complaint that motivated the feature. Same treatment as the reference branches above.
|
|
694
|
+
//
|
|
695
|
+
// The note names the sibling directory rather than saying "this checkout": the agent's cwd is
|
|
696
|
+
// the workspace root, which has no dependency tree of its own.
|
|
697
|
+
const primaryLeg = legs[0];
|
|
698
|
+
const dependencyNote = primaryLeg
|
|
699
|
+
? await prepopulateDependencies({
|
|
700
|
+
spec: job.dependencyInstall,
|
|
701
|
+
installDir: join(root, primaryLeg.dirName),
|
|
702
|
+
repoDir: join(root, primaryLeg.dirName),
|
|
703
|
+
agentDir: root,
|
|
704
|
+
logger,
|
|
705
|
+
opts,
|
|
706
|
+
})
|
|
707
|
+
: undefined;
|
|
671
708
|
opts.onPhase?.('agent');
|
|
672
709
|
logger.info('multi-repo-explore: running agent', { repos: legs.map((l) => l.dirName) });
|
|
673
710
|
const run = await runAgentInWorkspace({
|
|
674
711
|
dir: root,
|
|
675
712
|
systemPrompt: job.systemPrompt,
|
|
676
|
-
userPrompt: job.userPrompt,
|
|
713
|
+
userPrompt: withDependencyNote(job.userPrompt, dependencyNote),
|
|
677
714
|
model: job.model,
|
|
678
715
|
harness: job.harness,
|
|
679
716
|
subscriptionToken: job.subscriptionToken,
|
|
@@ -814,6 +851,10 @@ function buildSingleRepoCodingSpec(job, pushBranch) {
|
|
|
814
851
|
// (see docs/initiatives/bugfix-reproduction-proof.md). Forwarded straight off the job body —
|
|
815
852
|
// like the checks above, the loop is generic machinery keyed on the data, not the agent kind.
|
|
816
853
|
...(job.reproduction ? { reproduction: job.reproduction } : {}),
|
|
854
|
+
// Dependency prepopulation: the service's install, run against the checkout BEFORE the
|
|
855
|
+
// agent's first turn (see docs/initiatives/agent-dependency-prepopulation.md). Forwarded
|
|
856
|
+
// straight off the job body like the two phases above — generic machinery keyed on the data.
|
|
857
|
+
...(job.dependencyInstall ? { dependencyInstall: job.dependencyInstall } : {}),
|
|
817
858
|
};
|
|
818
859
|
}
|
|
819
860
|
/**
|
|
@@ -1022,10 +1063,27 @@ async function runConflictResolution(job, opts) {
|
|
|
1022
1063
|
// there were conflicts), so it would drift onto the original feature task. Lead with the
|
|
1023
1064
|
// conflict; keep the task only as trailing reference.
|
|
1024
1065
|
const conflicted = await unmergedPaths(dir, signal);
|
|
1066
|
+
// DEPENDENCY PREPOPULATION, before this mode's agent turn. Resolving a conflict is a READING
|
|
1067
|
+
// task before it is a writing one — the agent has to understand what both sides do — so it
|
|
1068
|
+
// needs the dependency tree as much as any other kind. Placed AFTER the clean-merge branches
|
|
1069
|
+
// above so a conflict-free run (the common case) never pays for an install it has no agent to
|
|
1070
|
+
// hand the tree to; and the artifact exclusion inside matters here more than anywhere, because
|
|
1071
|
+
// this flow finishes its merge commit with a whole-tree `git add -A`.
|
|
1072
|
+
const workDir = await deriveWorkDir(dir, job.repo.serviceDirectory);
|
|
1073
|
+
const dependencyNote = await prepopulateDependencies({
|
|
1074
|
+
spec: job.dependencyInstall,
|
|
1075
|
+
installDir: workDir,
|
|
1076
|
+
repoDir: dir,
|
|
1077
|
+
// The agent resolves at the repo ROOT (git's conflict state is repo-wide), so a monorepo
|
|
1078
|
+
// service's install ran somewhere the agent is not standing and the note has to say where.
|
|
1079
|
+
agentDir: dir,
|
|
1080
|
+
logger,
|
|
1081
|
+
opts,
|
|
1082
|
+
});
|
|
1025
1083
|
opts.onPhase?.('agent');
|
|
1026
1084
|
logger.info('agent(conflict): resolving conflicts with agent', { conflicted });
|
|
1027
1085
|
const diff = await conflictDiff(dir, conflicted, signal);
|
|
1028
|
-
const userPrompt = buildConflictPrompt(mergeBase, job.branch, conflicted, diff, job.userPrompt);
|
|
1086
|
+
const userPrompt = withDependencyNote(buildConflictPrompt(mergeBase, job.branch, conflicted, diff, job.userPrompt), dependencyNote);
|
|
1029
1087
|
const { summary, stats, stderrTail, usage, callMetrics, effortReport } = await runAgentInWorkspace({
|
|
1030
1088
|
dir,
|
|
1031
1089
|
systemPrompt: job.systemPrompt,
|
package/dist/captured-command.js
CHANGED
|
@@ -103,6 +103,22 @@ export async function runCapturedCommand(args) {
|
|
|
103
103
|
child.on('close', (code) => finish(code ?? 1));
|
|
104
104
|
});
|
|
105
105
|
}
|
|
106
|
+
/**
|
|
107
|
+
* Wrap captured command output in a fenced block that the output itself cannot break out of.
|
|
108
|
+
*
|
|
109
|
+
* Every consumer of a captured tail embeds it in markdown a MODEL then reads — a repair prompt,
|
|
110
|
+
* the dependency-install note — and a package manager legitimately prints backticks (a linter
|
|
111
|
+
* quoting a template literal, a test echoing a fenced snippet from a fixture). A fixed three-tick
|
|
112
|
+
* fence closes on the first such run, and everything after it reads as prose: the remaining
|
|
113
|
+
* output, and worse, the INSTRUCTIONS that follow the block. Sizing the fence one tick longer than
|
|
114
|
+
* the longest run in the body is what CommonMark specifies for exactly this, so the block always
|
|
115
|
+
* spans the whole tail.
|
|
116
|
+
*/
|
|
117
|
+
export function fencedOutput(text) {
|
|
118
|
+
const longestRun = Math.max(0, ...[...text.matchAll(/`+/g)].map((m) => m[0].length));
|
|
119
|
+
const fence = '`'.repeat(Math.max(3, longestRun + 1));
|
|
120
|
+
return `${fence}\n${text}\n${fence}`;
|
|
121
|
+
}
|
|
106
122
|
/** Bound an already-scrubbed output tail to what a REPORT carries, saying what it dropped. */
|
|
107
123
|
export function boundTail(scrubbed, maxChars) {
|
|
108
124
|
if (scrubbed.length <= maxChars)
|
package/dist/coding-agent.js
CHANGED
|
@@ -10,6 +10,7 @@ import { acquireRepoCheckout, agentNeverActed, agentOutputTail, runAgentInWorksp
|
|
|
10
10
|
import { log } from './logger.js';
|
|
11
11
|
import { runValidationLoop, } from './validation-checks.js';
|
|
12
12
|
import { runReproductionLoop, } from './reproduction-proof.js';
|
|
13
|
+
import { prepopulateDependencies, withDependencyNote, } from './dependency-install.js';
|
|
13
14
|
/**
|
|
14
15
|
* How often the harness checkpoints the agent's work mid-run by pushing the branch.
|
|
15
16
|
* A per-run container can be evicted at any moment; pushing the agent's commits
|
|
@@ -133,13 +134,32 @@ export async function runCodingAgent(spec, opts = {}) {
|
|
|
133
134
|
}, followUpPollIntervalMs());
|
|
134
135
|
followUpTick.unref?.();
|
|
135
136
|
}
|
|
137
|
+
// DEPENDENCY PREPOPULATION: install the service's dependencies into the checkout BEFORE the
|
|
138
|
+
// agent's first turn, so it reads real packages instead of inferring capabilities from a
|
|
139
|
+
// manifest. Runs in `workDir` (a monorepo service installs from its own subtree, exactly
|
|
140
|
+
// where its manifest and lockfile live), and its outcome is STATED to the agent either way —
|
|
141
|
+
// a silent absence of dependencies reads to an agent as "this environment is offline".
|
|
142
|
+
// Best-effort by construction: a failed install never fails the run. Keyed purely off the
|
|
143
|
+
// job body (no agent-kind switch); absent ⇒ this is a no-op.
|
|
144
|
+
const dependencyNote = await prepopulateDependencies({
|
|
145
|
+
spec: spec.dependencyInstall,
|
|
146
|
+
installDir: workDir,
|
|
147
|
+
repoDir: dir,
|
|
148
|
+
agentDir: workDir,
|
|
149
|
+
logger,
|
|
150
|
+
opts,
|
|
151
|
+
});
|
|
136
152
|
// One agent pass over this checkout, parameterised only by the prompt — so the pre-PR
|
|
137
153
|
// validation loop below can re-run the agent with a repair instruction without
|
|
138
154
|
// re-deriving (or drifting from) the dispatch's own settings.
|
|
155
|
+
//
|
|
156
|
+
// The dependency note rides EVERY pass, not just the first: a repair round starts a fresh
|
|
157
|
+
// agent, and one that is not told the tree is already installed spends the round it was
|
|
158
|
+
// given to fix something reinstalling it instead.
|
|
139
159
|
const runAgentPass = (userPrompt) => runAgentInWorkspace({
|
|
140
160
|
dir: workDir,
|
|
141
161
|
systemPrompt: spec.systemPrompt,
|
|
142
|
-
userPrompt,
|
|
162
|
+
userPrompt: withDependencyNote(userPrompt, dependencyNote),
|
|
143
163
|
model: spec.model,
|
|
144
164
|
harness: spec.harness,
|
|
145
165
|
subscriptionToken: spec.subscriptionToken,
|
|
@@ -702,6 +722,28 @@ export async function runMultiRepoCoding(job, opts = {}) {
|
|
|
702
722
|
// Clone (or resume) every sibling checkout under the workspace root and fetch the primary's
|
|
703
723
|
// reference branches. Mutates each leg's `dir`/`resumed`/`baseSha` in place.
|
|
704
724
|
await prepareMultiRepoCheckouts(root, legs, job, logger, opts);
|
|
725
|
+
// DEPENDENCY PREPOPULATION for the PRIMARY leg, exactly as the read-only multi-repo fan-out
|
|
726
|
+
// does it. The install is declared on ONE service frame (the primary repo's), so it runs in
|
|
727
|
+
// that leg's checkout and is never fanned out across peers, whose own frames declare configs
|
|
728
|
+
// this dispatch never resolved — running a `pnpm install` inside a Go checkout is not a
|
|
729
|
+
// degraded outcome, it is a wrong one. A cross-repo implementer needs its dependencies for
|
|
730
|
+
// the same reason a cross-repo investigator does; the note names the sibling directory
|
|
731
|
+
// because the agent itself stands at the workspace root.
|
|
732
|
+
//
|
|
733
|
+
// At the leg's checkout ROOT, not a `serviceDirectory` subtree: this layout applies no
|
|
734
|
+
// service-directory scoping anywhere (the agent runs at the root and the prompt explains the
|
|
735
|
+
// sibling checkouts), and a root install is the one that resolves a monorepo workspace whole.
|
|
736
|
+
const primaryLeg = legs.find((leg) => leg.primary);
|
|
737
|
+
const dependencyNote = primaryLeg
|
|
738
|
+
? await prepopulateDependencies({
|
|
739
|
+
spec: job.dependencyInstall,
|
|
740
|
+
installDir: primaryLeg.dir,
|
|
741
|
+
repoDir: primaryLeg.dir,
|
|
742
|
+
agentDir: root,
|
|
743
|
+
logger,
|
|
744
|
+
opts,
|
|
745
|
+
})
|
|
746
|
+
: undefined;
|
|
705
747
|
// Run the agent ONCE with its cwd at the workspace root, so it sees every sibling checkout
|
|
706
748
|
// and can change them coherently. No monorepo/service-directory scoping — the multi-repo
|
|
707
749
|
// note + the backend system-prompt section explain the layout.
|
|
@@ -710,7 +752,7 @@ export async function runMultiRepoCoding(job, opts = {}) {
|
|
|
710
752
|
const { summary, stats, stderrTail, usage, callMetrics, effortReport } = await runAgentInWorkspace({
|
|
711
753
|
dir: root,
|
|
712
754
|
systemPrompt: job.systemPrompt,
|
|
713
|
-
userPrompt: job.userPrompt,
|
|
755
|
+
userPrompt: withDependencyNote(job.userPrompt, dependencyNote),
|
|
714
756
|
model: job.model,
|
|
715
757
|
harness: job.harness,
|
|
716
758
|
subscriptionToken: job.subscriptionToken,
|