@cat-factory/executor-harness 1.135.0 → 1.139.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -116,6 +116,51 @@ Bootstrap differs at the ends: it may start from an empty dir, and **resets
116
116
  history to one commit and force-pushes** the default branch instead of opening a
117
117
  PR. Blueprint **commits onto a branch** (no history reset) and returns the tree.
118
118
 
119
+ ### The environment is probed once, not by the agent
120
+
121
+ Before any mode branches, `handleAgent` probes the machine it is about to run on and appends an
122
+ `ENVIRONMENT INVENTORY` block to the job's system prompt (`src/environment-inventory.ts`). It names
123
+ the toolchain that answered and its versions, the curated list of tools that did not, and whether a
124
+ Docker DAEMON is actually reachable.
125
+
126
+ It exists because the platform used to ask every agent to find this out for itself, and every agent
127
+ did: in one measured run an architect ran `for c in docker kubectl helm kustomize; …` and then
128
+ `docker info`, and the coder it handed off to rediscovered both answers thirty calls later. Four
129
+ calls out of a forty-call budget for facts this process holds before the agent's first turn. The
130
+ backend cannot hold them (it composes its prompt before a transport is chosen, and the same body
131
+ reaches this image, a deployment's own image variant and, under `LOCAL_NATIVE_AGENTS`, the
132
+ developer's own machine), so it states the POLICY and names no tooling at all.
133
+
134
+ Three rules bind anything added to it:
135
+
136
+ - **A failed probe is not an absence.** Only `ENOENT` means "not installed"; a timeout or a refused
137
+ spawn renders on its own line as could-not-be-determined, because the two lead an agent to
138
+ opposite next moves.
139
+ - **An unlisted tool is unknown too**, which the block's last line says. The probe list is curated,
140
+ so silence about `terraform` must not read as its absence.
141
+ - **The Docker daemon is answered by running `docker info`**, never by finding the CLI. The CLI is
142
+ installed in this image unconditionally, `entrypoint.sh` starts the rootless daemon best-effort
143
+ and execs the server without waiting for it, so at job start this probe is the only thing that
144
+ knows how that went.
145
+ - **A daemon that is STARTING is not a daemon that is absent.** Because the entrypoint does not
146
+ wait, the backend dispatches seconds before there is a socket, and `docker info` is then refused
147
+ at once rather than slowly. So a refusal is read against `DOCKER_HOST`, which the entrypoint sets
148
+ whenever something is meant to serve a daemon here: unset means nothing was coming and the
149
+ absence is stated definitively, set means one short retry and then could-not-be-determined. The
150
+ absent wording tells an agent `docker compose up` "will fail here whatever the CLI reports", which
151
+ is a prohibition, so it may only be reached where nothing is going to answer.
152
+ - **A tool the platform did not provide is not installable system-wide either**, since the job runs
153
+ unprivileged. The line says that instead of banning installation outright: `pnpm` is absent from
154
+ this image (only the UI variant carries it), so it is routinely the package manager the job's own
155
+ repository declares, and a flat prohibition pushed agents onto `npm install` against a pnpm
156
+ lockfile. Reaching a project's own manager for that project alone is allowed and named.
157
+
158
+ Composed at exactly ONE point, onto the job's own `systemPrompt`, which every mode already forwards
159
+ and all three CLIs already carry (claude-code's `--append-system-prompt` and its oversized-argv
160
+ fallback, Codex's fold, Pi's `AGENTS.md`). `test/environment-inventory.coverage.test.ts` pins that:
161
+ a mode that folded its own copy would state the machine twice, and one that folded none would leave
162
+ its agent probing, with nothing failing either way.
163
+
119
164
  ### The work-branch push is CHECKPOINTED, so it is lease-guarded
120
165
 
121
166
  Step 8's push is not the run's first: every `JOB_CHECKPOINT_INTERVAL_MS` (60s) the harness pushes
@@ -393,6 +438,7 @@ verdict is stated.
393
438
  | `src/bootstrap-mode.ts` | The repo-bootstrap MODE: clone-a-reference-or-scaffold → run the agent → refuse to push an empty tree → reinit + force-push to the pre-created target repo. |
394
439
  | `src/artifact-upload.ts` | The OUTBOUND half of the artifact seam: parses the body's `artifactUpload` and projects it onto the agent's env as `ARTIFACT_UPLOAD_URL` / `ARTIFACT_UPLOAD_TOKEN`, registering the token for redaction first. Passes through what the body carries and decides nothing: which kinds get the seam is the backend's call. |
395
440
  | `src/codex-images.ts` | Codex's own `image_gen` output, staged where the agent can reach it: creates `$CODEX_HOME/generated_images` as a symlink into `.cat-context/binary-output/generated/` before the CLI starts, sweeps anything a failed redirect left behind, and unlinks (never follows) the redirect at teardown — a failed unlink is REPORTED, because that unlink is what stops the recursive delete reaching the checkout. Exists because codex exposes no path for what it generated AND `$CODEX_HOME` holds the run's decrypted credential, so neither asking the agent nor sending it there is available. |
441
+ | `src/environment-inventory.ts` | What the MACHINE holds, probed once per job and appended to the agent's system prompt as an ENVIRONMENT INVENTORY block. The only layer that can state it: the backend composes its prompt before a transport is chosen, and the same body serves this image, a deployment's own variant and the developer's laptop under `LOCAL_NATIVE_AGENTS`. Three-valued on purpose, so a probe that failed renders as unknown rather than as an absence, and the Docker DAEMON is answered by running `docker info` rather than by finding the CLI, which is installed here either way. See [The environment is probed once, not by the agent](#the-environment-is-probed-once-not-by-the-agent). |
396
442
  | `src/agent-shared.ts` | The few helpers every agent MODE shares (effort-report folding, the capability fields forwarded to `runAgentInWorkspace`). |
397
443
  | `src/logger.ts` | Structured logging. |
398
444
  | `src/docker-status.ts` | This container's own verdict about its Docker daemon, as recorded by `entrypoint.sh`. Three-valued on purpose: a daemon that FAILED and a daemon nobody asked about are different facts, and only a DECIDED absence refuses a stand-up. See [Local infra: the container's Docker daemon](#local-infra-the-containers-docker-daemon). |
@@ -425,6 +425,15 @@ export async function runClaudeCode(opts) {
425
425
  // The built-in tools this run declares, named ONCE: the same list rides `--tools` and the
426
426
  // `--allowedTools` re-grant, which is additive rather than inert (see `claudeAllowedToolPatterns`).
427
427
  const tools = CLAUDE_TOOL_SET;
428
+ // ...but `--tools` itself is withheld from an ambient run, whose `claude` is the developer's own
429
+ // rather than this image's pinned one: an unrecognised FLAG fails the whole run, where an
430
+ // unrecognised tool NAME is merely dropped. See `claudeCliArgs`. The re-grant is unaffected
431
+ // (`--allowedTools` long predates this), so a tool-server run still unlocks what it wires.
432
+ const declareTools = opts.ambientAuth !== true;
433
+ const declaredTools = declareTools ? tools : [];
434
+ if (!declareTools) {
435
+ opts.log?.info('claude-code: taking the CLI’s default tool surface (ambient CLI, version unknown)');
436
+ }
428
437
  const secrets = opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [];
429
438
  const capture = openClaudeCallCapture(opts, { prompt, folded, secrets });
430
439
  const telemetry = capture.telemetry;
@@ -468,7 +477,7 @@ export async function runClaudeCode(opts) {
468
477
  reportToolServerStartup(event, opts.onToolServers);
469
478
  // The same startup event answers what the CLI granted of what we asked for; a capability it
470
479
  // named no tool for is a silent capability loss otherwise (see `assertClaudeToolsCurrent`).
471
- assertClaudeToolsCurrent(event, tools, opts.log);
480
+ assertClaudeToolsCurrent(event, declaredTools, opts.log);
472
481
  // A subagent's turns ride the parent's stdout tagged with the dispatch that spawned them;
473
482
  // `telemetry` routes them off the parent's chain (and decides who bills them). Progress, slice
474
483
  // tracking, the guard and `stats` below deliberately see EVERY event: a subagent grinding on
@@ -560,7 +569,13 @@ export async function runClaudeCode(opts) {
560
569
  try {
561
570
  const { stderrTail } = await streamCli({
562
571
  command: 'claude',
563
- args: claudeCliArgs({ model: opts.model, tools, mcpArgs: home.mcpArgs, appendArgs }),
572
+ args: claudeCliArgs({
573
+ model: opts.model,
574
+ tools,
575
+ declareTools,
576
+ mcpArgs: home.mcpArgs,
577
+ appendArgs,
578
+ }),
564
579
  }, prompt, { ...opts, signal: runSignal }, home.env, opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [], onEvent);
565
580
  // The stream has ended, so the last call has no successor envelope to complete it.
566
581
  telemetry.flush();
package/dist/agent.js CHANGED
@@ -11,12 +11,14 @@ import { registerKnownSecrets } from './redact.js';
11
11
  import { cloneRepo, commitAll, conflictDiff, fetchPullRequestHead, fetchReferenceBranches, headCommit, mergeBranch, prepareExistingCheckout, pushBranch, unmergedPaths, } from './git.js';
12
12
  import { inferVcsProvider, openPullRequest } from './vcs-api.js';
13
13
  import { applyPrDescription } from './pr-description.js';
14
+ import { withSalvageOnlyNote } from './salvage.js';
14
15
  import { makeDirClaimer } from './checkout-dir.js';
15
16
  import { noChangesReason, runCodingAgent } from './coding-agent.js';
16
17
  import { runMultiRepoCoding } from './multi-repo-coding.js';
17
18
  import { validationFailureMessage } from './validation-checks.js';
18
19
  import { prepopulateDependencies, withDependencyNote } from './dependency-install.js';
19
20
  import { agentCapabilities, mergeEffort } from './agent-shared.js';
21
+ import { appendEnvironmentInventory } from './environment-inventory.js';
20
22
  import { runBootstrap } from './bootstrap-mode.js';
21
23
  import { acquireRepoCheckout, agentNeverActed, agentOutputTail, NEVER_ACTED_CAUSE, runAgentInWorkspace, unusableFinalAnswerCause, withWorkspace, } from './pi-workspace.js';
22
24
  import { diagnosticsSuffix, resolveStructuredOutput, } from './structured-output.js';
@@ -126,9 +128,27 @@ export async function handleAgent(job, opts = {}) {
126
128
  });
127
129
  if (job.mode === 'preview')
128
130
  return await runPreviewMode(job, scoped);
129
- return job.mode === 'coding'
130
- ? await runCodingMode(job, scoped)
131
- : await runExploreMode(job, scoped);
131
+ // THE composition point for the environment inventory (see `environment-inventory.ts`): the
132
+ // machine is probed ONCE here, before any mode branches, and the result is folded onto the
133
+ // job's own system prompt. Every mode, every repair round and all three agent CLIs read that
134
+ // one field, so none of them can end up without the block and none can carry it twice.
135
+ // `preview` returns above because it runs no agent at all, so there is no prompt to fold onto.
136
+ //
137
+ // This sits on the critical path AHEAD of the clone, which is the cost of having one
138
+ // composition point instead of one per mode (each mode owns its own clone, so there is no
139
+ // single post-clone place to put this). The pass is sized for that: everything in it runs
140
+ // concurrently, every probe is a call that answers in milliseconds or is wedged, and the one
141
+ // deliberate wait is a single short retry for a daemon that is still starting.
142
+ const staged = {
143
+ ...job,
144
+ systemPrompt: await appendEnvironmentInventory(job.systemPrompt, {
145
+ ...(opts.signal ? { signal: opts.signal } : {}),
146
+ ...(opts.log ? { log: opts.log } : {}),
147
+ }),
148
+ };
149
+ return staged.mode === 'coding'
150
+ ? await runCodingMode(staged, scoped)
151
+ : await runExploreMode(staged, scoped);
132
152
  }
133
153
  finally {
134
154
  if (scopeDir)
@@ -775,7 +795,7 @@ export function buildSingleRepoCodingSpec(job, pushBranch) {
775
795
  */
776
796
  async function runSingleRepoCoding(job, opts) {
777
797
  const pushBranch = job.pushBranch ?? job.newBranch ?? job.branch;
778
- const { summary, stats, stderrTail, pushed, usage, callMetrics, validation, validationReport, reproductionReport, effortReport, prDescription, } = await runCodingAgent(buildSingleRepoCodingSpec(job, pushBranch), opts);
798
+ const { summary, stats, stderrTail, pushed, usage, callMetrics, validation, validationReport, reproductionReport, effortReport, prDescription, salvageOnly, } = await runCodingAgent(buildSingleRepoCodingSpec(job, pushBranch), opts);
779
799
  // Ralph loop: the harness-computed validation verdict, forwarded onto the coding result as
780
800
  // `ralphVerdict` so the backend's `toRunResult` lifts it onto `AgentRunResult.ralphVerdict`.
781
801
  const ralphVerdict = validation ? { ralphVerdict: validation } : {};
@@ -847,8 +867,10 @@ async function runSingleRepoCoding(job, opts) {
847
867
  ghToken: job.ghToken,
848
868
  head: pushBranch,
849
869
  base: job.repo.baseBranch,
850
- // The agent-authored briefing (title/body) wins field-wise over the dispatch-time text.
851
- pr: applyPrDescription(job.pr, prDescription),
870
+ // The agent-authored briefing (title/body) wins field-wise over the dispatch-time text,
871
+ // and a branch that is nothing but salvage says so above whichever body won: the agent
872
+ // committed nothing here, so no briefing on it describes a change anyone proposed.
873
+ pr: withSalvageOnlyNote(applyPrDescription(job.pr, prDescription), salvageOnly === true),
852
874
  // A resumed run's PR is already open, so refresh it rather than lose the briefing to the
853
875
  // duplicate-PR 422 — only from a REAL briefing (see `refreshExisting` for why).
854
876
  ...(prDescription ? { refreshExisting: true } : {}),
@@ -67,6 +67,18 @@ export declare function claudeCliArgs(opts: {
67
67
  model: string;
68
68
  /** The built-in tools this run asks for; see {@link CLAUDE_TOOL_SET}. */
69
69
  tools: readonly string[];
70
+ /**
71
+ * Whether to DECLARE that set with `--tools`, or take whatever the CLI defaults to.
72
+ *
73
+ * False for an `ambientAuth` run, which is the one case where the CLI is not this image's. A
74
+ * name the build does not carry is dropped silently, which is what makes {@link CLAUDE_TOOL_SET}
75
+ * safe to be over-inclusive, but that rule is about tool NAMES. An unrecognised FLAG is a
76
+ * different failure: the CLI exits before the run starts. Everywhere else the image pins the
77
+ * version and the flag is measured against it; on a developer's own machine the harness knows
78
+ * neither which `claude` is on the PATH nor how old it is, and the cost of guessing wrong is
79
+ * every local native run, not a thinner tool surface on one.
80
+ */
81
+ declareTools: boolean;
70
82
  /** `--mcp-config` + `--strict-mcp-config` + any `--allowedTools`; empty when no server is wired. */
71
83
  mcpArgs: readonly string[];
72
84
  /** `--append-system-prompt <prompt>`, or empty when the prompt was folded into stdin. */
@@ -85,6 +97,11 @@ export declare function claudeCliArgs(opts: {
85
97
  * Best-effort and never throws: a run whose tool surface is short is still a run, and the honest
86
98
  * disposition for a floor this image cannot verify is to SAY it could not be read, not to fail the
87
99
  * job and not to stay silent (which reads exactly like a satisfied request).
100
+ *
101
+ * `requested` is what the argv actually DECLARED, so an ambient run (which declares nothing, see
102
+ * {@link claudeCliArgs}) passes none and the line says the surface is the CLI's own default. The
103
+ * floor is still read back there: "the default set carries no search tool" and "we asked for one
104
+ * and did not get it" are both worth a line, and they are not the same fact or the same fix.
88
105
  */
89
106
  export declare function assertClaudeToolsCurrent(event: Record<string, unknown>, requested: readonly string[], log: Logger | undefined): void;
90
107
  export {};
@@ -131,8 +131,7 @@ export function claudeCliArgs(opts) {
131
131
  '--model',
132
132
  opts.model,
133
133
  // Declared rather than defaulted: see this module's header for what the default set costs.
134
- '--tools',
135
- opts.tools.join(','),
134
+ ...(opts.declareTools ? ['--tools', opts.tools.join(',')] : []),
136
135
  ...opts.mcpArgs,
137
136
  ...opts.appendArgs,
138
137
  ];
@@ -150,6 +149,11 @@ export function claudeCliArgs(opts) {
150
149
  * Best-effort and never throws: a run whose tool surface is short is still a run, and the honest
151
150
  * disposition for a floor this image cannot verify is to SAY it could not be read, not to fail the
152
151
  * job and not to stay silent (which reads exactly like a satisfied request).
152
+ *
153
+ * `requested` is what the argv actually DECLARED, so an ambient run (which declares nothing, see
154
+ * {@link claudeCliArgs}) passes none and the line says the surface is the CLI's own default. The
155
+ * floor is still read back there: "the default set carries no search tool" and "we asked for one
156
+ * and did not get it" are both worth a line, and they are not the same fact or the same fix.
153
157
  */
154
158
  export function assertClaudeToolsCurrent(event, requested, log) {
155
159
  if (!log || event.type !== 'system' || event.subtype !== 'init')
@@ -158,7 +162,7 @@ export function assertClaudeToolsCurrent(event, requested, log) {
158
162
  const cliVersion = version ? { cliVersion: version } : {};
159
163
  if (!Array.isArray(event.tools)) {
160
164
  log.warn('claude-code announced no tool list, so this run has an unverified tool surface', {
161
- requestedTools: [...requested],
165
+ ...(requested.length > 0 ? { requestedTools: [...requested] } : { toolsDeclared: false }),
162
166
  ...cliVersion,
163
167
  });
164
168
  return;
@@ -166,7 +170,7 @@ export function assertClaudeToolsCurrent(event, requested, log) {
166
170
  const granted = new Set(event.tools.filter((t) => typeof t === 'string'));
167
171
  const missing = CLAUDE_TOOL_FLOOR.filter((c) => !c.spellings.some((s) => granted.has(s))).map((c) => c.capability);
168
172
  const fields = {
169
- requestedTools: [...requested],
173
+ ...(requested.length > 0 ? { requestedTools: [...requested] } : { toolsDeclared: false }),
170
174
  grantedTools: [...granted].sort(),
171
175
  ...cliVersion,
172
176
  };
@@ -200,6 +200,12 @@ export interface CodingAgentOutcome {
200
200
  * clean pass.
201
201
  */
202
202
  salvage?: SalvageReport;
203
+ /**
204
+ * This branch is NOTHING BUT salvage: the agent committed to it not once, and everything on it
205
+ * is files it left uncommitted in the checkout. Set only when a pull request would present that
206
+ * as a proposed change, so the caller can say so in the body before anyone reads the diff.
207
+ */
208
+ salvageOnly?: boolean;
203
209
  }
204
210
  export declare function runCodingAgent(spec: CodingAgentSpec, opts?: RunOptions): Promise<CodingAgentOutcome>;
205
211
  /**
@@ -225,6 +231,8 @@ export declare function runCodingAgent(spec: CodingAgentSpec, opts?: RunOptions)
225
231
  */
226
232
  export declare function withSalvagedWork(error: unknown, args: {
227
233
  dir: string;
234
+ /** The message the run's own commits carry, reused for the tracked edits rescued below. */
235
+ commitMessage: string;
228
236
  logger: Logger;
229
237
  pushWorkOnce: (override?: AbortSignal) => Promise<void>;
230
238
  inFlightPush: () => Promise<void> | null;
@@ -11,7 +11,7 @@ import { runValidationLoop, } from './validation-checks.js';
11
11
  import { runReproductionLoop, } from './reproduction-proof.js';
12
12
  import { prepopulateDependencies, withDependencyNote, } from './dependency-install.js';
13
13
  import { resolvePrTemplateNote, withPrTemplateNote, } from './pr-template.js';
14
- import { describeSalvage, salvageUntrackedWork, } from './salvage.js';
14
+ import { describeSalvage, foldSalvageReports, salvageUntrackedWork, } from './salvage.js';
15
15
  /**
16
16
  * How often the harness checkpoints the agent's work mid-run by pushing the branch.
17
17
  * A per-run container can be evicted at any moment; pushing the agent's commits
@@ -292,47 +292,24 @@ export async function runCodingAgent(spec, opts = {}) {
292
292
  // against the working tree, the proof against committed trees — so an unadded file is
293
293
  // exactly the thing to name. A throw degrades to "no warning" inside each loop.
294
294
  const listUncommittedNewFiles = () => listUntrackedFiles(workDir, opts.signal);
295
- // BUGFIX REPRODUCTION PROOF: run the run's declared reproduction command against the
296
- // pre-fix tree and the tree the PR will open from, and record whether it was red then
297
- // green. Runs BEFORE the validation loop below, deliberately: validation is the GATE
298
- // ("only a green checkout opens a PR"), so it has to stay the last thing that touches the
299
- // tree otherwise a reproduction repair round could leave the checkout red behind it and
300
- // the PR would open anyway. Keyed purely off the job body carrying a spec (no agent-kind
301
- // switch); absent a no-op and the flow below is byte-for-byte what it was.
302
- const reproduction = spec.reproduction;
303
- let reproductionReport;
304
- if (reproduction && (await producedWork(dir, spec, baseSha, resumed, opts))) {
305
- opts.onPhase?.('reproduction');
306
- reproductionReport = await runReproductionLoop({
307
- dir,
308
- baseSha,
309
- // Re-read per attempt: a repair pass commits, so the final tree moves under the loop.
310
- // `producedWork` has already committed forgotten tracked edits, and each repair round
311
- // re-commits before the next read.
312
- resolveFinalSha: async () => {
313
- await commitTrackedEdits(dir, spec.commitMessage, signal);
314
- return headCommit(dir, signal);
315
- },
316
- ...(serviceDirectory ? { serviceDirectory } : {}),
317
- spec: reproduction,
318
- logger,
319
- opts,
320
- runAgentPass,
321
- onAgentPass: foldPass,
322
- listUncommittedNewFiles,
323
- // Only a RESUMED run can have a pre-fix tree that already carries work: a fresh run
324
- // branched off base, so `baseSha` IS base. Wiring the probe unconditionally would buy
325
- // an always-empty answer for the price of a fetch — and a fresh clone is shallow, so
326
- // it could not resolve a merge base to answer with anyway. Lazy inside the loop: it
327
- // only runs if a tree comes back green.
328
- ...(resumed
329
- ? {
330
- listBaseTreeChanges: () => changedFilesSinceBase(dir, spec.repo.baseBranch, spec.ghToken, baseSha, opts.signal),
331
- }
332
- : {}),
333
- });
334
- opts.onPhase?.('agent');
335
- }
295
+ // Commit what the agent left uncommitted, BEFORE the two pre-PR phases below read the
296
+ // branch. See {@link settleAgentWork} for why the order is the whole point.
297
+ const { committedOwnWork, preGateSalvage } = await settleAgentWork(dir, spec, baseSha, logger, opts);
298
+ // BUGFIX REPRODUCTION PROOF, run before the validation loop below. See
299
+ // {@link runReproductionPhase} for why that order is load-bearing. A no-op when the job
300
+ // body carries no reproduction spec.
301
+ const reproductionReport = await runReproductionPhase({
302
+ dir,
303
+ spec,
304
+ baseSha,
305
+ resumed,
306
+ logger,
307
+ opts,
308
+ ...(serviceDirectory ? { serviceDirectory } : {}),
309
+ runAgentPass,
310
+ onAgentPass: foldPass,
311
+ listUncommittedNewFiles,
312
+ });
336
313
  // PRE-PR VALIDATION: run the service's configured checks against the checkout and, while
337
314
  // they fail and budget remains, hand the captured output back to the agent and run it
338
315
  // again. Sits BETWEEN the agent and the finalize/push/PR step so a red checkout never
@@ -358,6 +335,8 @@ export async function runCodingAgent(spec, opts = {}) {
358
335
  outcome = await finalizeCodingRun({
359
336
  validationReport,
360
337
  reproductionReport,
338
+ preGateSalvage,
339
+ committedOwnWork,
361
340
  dir,
362
341
  spec,
363
342
  logger,
@@ -390,7 +369,13 @@ export async function runCodingAgent(spec, opts = {}) {
390
369
  // handed the rescue's push and a rescue starting behind a checkpoint would be handed a
391
370
  // push made BEFORE the salvage commit existed — reporting as pushed a commit that is not.
392
371
  clearInterval(checkpoint);
393
- throw await withSalvagedWork(error, { dir, logger, pushWorkOnce, inFlightPush });
372
+ throw await withSalvagedWork(error, {
373
+ dir,
374
+ commitMessage: spec.commitMessage,
375
+ logger,
376
+ pushWorkOnce,
377
+ inFlightPush,
378
+ });
394
379
  }
395
380
  finally {
396
381
  // Safety net for the throw path (the happy path already cleared these above).
@@ -414,9 +399,16 @@ function withSalvageNote(summary, salvage) {
414
399
  const missedWork = salvage.status === 'refused' || salvage.status === 'failed';
415
400
  if (!missedWork && (salvage.withheld?.length ?? 0) === 0)
416
401
  return summary;
417
- const note = describeSalvage(salvage);
402
+ const note = describeSalvage(salvage, SETTLED);
418
403
  return note ? `${note}\n\n${summary}` : summary;
419
404
  }
405
+ /**
406
+ * How the run ended, for every salvage on the settle path: the agent finished, it simply never
407
+ * added these files. Named once because the commit message and the human-readable note are both
408
+ * given it, and a settle-path salvage that describes itself as an abort reports a failure that
409
+ * did not happen.
410
+ */
411
+ const SETTLED = { kind: 'settled' };
420
412
  /**
421
413
  * How long the rescue of an aborted run's work gets, on its own clock.
422
414
  *
@@ -466,18 +458,30 @@ function rescueSignal() {
466
458
  */
467
459
  export async function withSalvagedWork(error, args) {
468
460
  const cause = error instanceof Error ? error.message : String(error);
461
+ const occasion = { kind: 'aborted', cause };
469
462
  const signal = rescueSignal();
470
463
  await drainInFlightPush(args.inFlightPush, args.logger);
464
+ // Edits to files git ALREADY tracks, committed under the run's own message before the salvage
465
+ // takes the untracked ones. On the settle path this has run several times already; here it has
466
+ // never run at all, and a killed agent leaves edits behind exactly as it leaves new files.
467
+ // Naming them separately is also what keeps the salvage's own count honest: `commitPaths`
468
+ // commits the paths it was given and no others, so anything not swept up here is simply lost.
469
+ // Best-effort: a failure here must not cost the salvage that follows it.
470
+ await commitTrackedEdits(args.dir, args.commitMessage, signal).catch((commitError) => {
471
+ args.logger.warn('coding-agent: could not commit the tracked edits an aborted run left', {
472
+ reason: commitError instanceof Error ? commitError.message : String(commitError),
473
+ });
474
+ });
471
475
  const note = await salvageUntrackedWork({
472
476
  dir: args.dir,
473
- occasion: { kind: 'aborted', cause },
477
+ occasion,
474
478
  logger: args.logger,
475
479
  signal,
476
480
  })
477
481
  .then(async (report) => {
478
482
  if (report.status !== 'committed')
479
- return describeSalvage(report);
480
- return describeSalvage(report, await deliverSalvage(args, signal));
483
+ return describeSalvage(report, occasion);
484
+ return describeSalvage(report, occasion, await deliverSalvage(args, signal));
481
485
  })
482
486
  .catch((salvageError) => {
483
487
  args.logger.error('coding-agent: salvage of an aborted run failed', {
@@ -631,7 +635,7 @@ async function prepareCodingCheckout(dir, spec, logger, opts) {
631
635
  * {@link runCodingAgent} so its body stays small; returns the built {@link CodingAgentOutcome}.
632
636
  */
633
637
  async function finalizeCodingRun(args) {
634
- const { validationReport, reproductionReport, dir, spec, logger, opts, baseSha, resumed, workDir, checkpoint, followUpTick, followUpTailer, pushWorkOnce, inFlightPush, agentRun, prTemplate, } = args;
638
+ const { validationReport, reproductionReport, preGateSalvage, committedOwnWork, dir, spec, logger, opts, baseSha, resumed, workDir, checkpoint, followUpTick, followUpTailer, pushWorkOnce, inFlightPush, agentRun, prTemplate, } = args;
635
639
  const { signal } = opts;
636
640
  const { stats, stderrTail, usage, callMetrics, effortReport } = agentRun;
637
641
  let outcome;
@@ -662,19 +666,21 @@ async function finalizeCodingRun(args) {
662
666
  const inflight = inFlightPush();
663
667
  if (inflight)
664
668
  await inflight.catch(() => { });
665
- // Recover the untracked, non-ignored files the agent left behind. `commitTrackedEdits` above
666
- // only captures edits to ALREADY tracked files, so a NEW file the agent created and forgot to
667
- // commit used to be listed, warned about and dropped and on a greenfield task EVERY file is
668
- // new, which made that warning the whole deliverable going in the bin. Observable is not
669
- // recovered, so commit them. Guardrails (a dependency/build deny-list, a file-count and byte
670
- // bound, an all-or-nothing refusal over it) live in `salvage.ts`; this path is coding mode by
671
- // construction, which is the other rule it must obey.
672
- const salvage = await salvageUntrackedWork({
669
+ // The MOP-UP salvage. The caller already ran one ahead of the pre-PR phases (see there for why
670
+ // the order matters); this second pass exists because a validation or reproduction REPAIR round
671
+ // runs the agent afresh and can leave new files of its own after that first pass. On a run with
672
+ // no repair round it finds nothing and folds away to the earlier report.
673
+ const salvage = foldSalvageReports(preGateSalvage, await salvageUntrackedWork({
673
674
  dir,
674
- occasion: { kind: 'settled' },
675
+ occasion: SETTLED,
675
676
  logger,
676
677
  ...(signal ? { signal } : {}),
677
- });
678
+ }));
679
+ // A branch whose ENTIRE content is salvage is not a change anyone proposed, and its reviewer has
680
+ // to be told that before reading it as one. `committedOwnWork` is the caller's pre-salvage read;
681
+ // a RESUMED run carries prior commits of the agent's own regardless of what this pass added, so
682
+ // it is never salvage-only. The multi-repo path marks its peer PRs the same way.
683
+ const salvageOnly = !resumed && !committedOwnWork && salvage.status === 'committed';
678
684
  // A salvage that COMMITTED needs no announcement: its files are in the push and its commit
679
685
  // message says where they came from. A refused or failed one means work the agent produced is
680
686
  // NOT in the pull request, on a run that otherwise reads as a clean pass — so say it in the
@@ -728,6 +734,7 @@ async function finalizeCodingRun(args) {
728
734
  ...(effortReport ? { effortReport } : {}),
729
735
  ...(prDescription ? { prDescription } : {}),
730
736
  ...(salvage.status === 'none' ? {} : { salvage }),
737
+ ...(salvageOnly ? { salvageOnly: true } : {}),
731
738
  };
732
739
  }
733
740
  // Ralph loop: run the programmatic completion command against the pushed/committed
@@ -758,14 +765,103 @@ async function finalizeCodingRun(args) {
758
765
  *
759
766
  * Commits forgotten edits to tracked files first, exactly as {@link finalizeCodingRun} does, so
760
767
  * an agent that edited-but-didn't-commit still counts as work. That call is idempotent, so
761
- * finalize repeating it later is a no-op. Uncommitted NEW files are invisible here — but they
762
- * are equally invisible to finalize, so a run whose only product is an uncommitted new file is
763
- * a no-op on both paths, and the checks would have nothing to gate anyway.
768
+ * finalize repeating it later is a no-op.
769
+ *
770
+ * Uncommitted NEW files are invisible to it, which is why {@link settleAgentWork} runs ahead of
771
+ * every caller and commits them. Reading commits is the whole point of this gate, and it used to
772
+ * mean that a run whose only product was new files (a greenfield task, where that is ALL of
773
+ * them) answered `false` here and skipped the checks, only for the salvage to commit the lot
774
+ * afterwards and open a pull request nothing had validated.
764
775
  */
765
776
  async function producedWork(dir, spec, baseSha, resumed, opts) {
766
777
  await commitTrackedEdits(dir, spec.commitMessage, opts.signal);
767
778
  return resumed || (await branchHasCommitsSince(dir, baseSha, opts.signal));
768
779
  }
780
+ /**
781
+ * The bugfix reproduction proof: run the run's declared reproduction command against the pre-fix
782
+ * tree and the tree the PR will open from, and record whether it was red then green.
783
+ *
784
+ * Runs BEFORE the pre-PR validation loop, deliberately: validation is the GATE ("only a green
785
+ * checkout opens a PR"), so it has to stay the last thing that touches the tree — otherwise a
786
+ * reproduction repair round could leave the checkout red behind it and the PR would open anyway.
787
+ * Keyed purely off the job body carrying a spec (no agent-kind switch); absent ⇒ `undefined` and
788
+ * the flow around it is byte-for-byte what it was.
789
+ */
790
+ async function runReproductionPhase(args) {
791
+ const { dir, spec, baseSha, resumed, logger, opts, serviceDirectory } = args;
792
+ const { signal } = opts;
793
+ const reproduction = spec.reproduction;
794
+ if (!reproduction || !(await producedWork(dir, spec, baseSha, resumed, opts)))
795
+ return undefined;
796
+ opts.onPhase?.('reproduction');
797
+ const report = await runReproductionLoop({
798
+ dir,
799
+ baseSha,
800
+ // Re-read per attempt: a repair pass commits, so the final tree moves under the loop.
801
+ // `producedWork` has already committed forgotten tracked edits, and each repair round
802
+ // re-commits before the next read.
803
+ resolveFinalSha: async () => {
804
+ await commitTrackedEdits(dir, spec.commitMessage, signal);
805
+ return headCommit(dir, signal);
806
+ },
807
+ ...(serviceDirectory ? { serviceDirectory } : {}),
808
+ spec: reproduction,
809
+ logger,
810
+ opts,
811
+ runAgentPass: args.runAgentPass,
812
+ onAgentPass: args.onAgentPass,
813
+ listUncommittedNewFiles: args.listUncommittedNewFiles,
814
+ // Only a RESUMED run can have a pre-fix tree that already carries work: a fresh run branched
815
+ // off base, so `baseSha` IS base. Wiring the probe unconditionally would buy an always-empty
816
+ // answer for the price of a fetch — and a fresh clone is shallow, so it could not resolve a
817
+ // merge base to answer with anyway. Lazy inside the loop: it only runs if a tree comes back
818
+ // green.
819
+ ...(resumed
820
+ ? {
821
+ listBaseTreeChanges: () => changedFilesSinceBase(dir, spec.repo.baseBranch, spec.ghToken, baseSha, opts.signal),
822
+ }
823
+ : {}),
824
+ });
825
+ opts.onPhase?.('agent');
826
+ return report;
827
+ }
828
+ /**
829
+ * Commit everything the settled agent left behind, and say whether the branch already carried
830
+ * commits of its OWN before that.
831
+ *
832
+ * Runs between the agent and the two pre-PR phases, and the ORDER is the whole point. Both phases
833
+ * are gated on {@link producedWork}, which reads COMMITS. The salvage used to run last, at the
834
+ * settle, which left that gate false for exactly the runs the salvage exists to save: a greenfield
835
+ * task whose every file is new and uncommitted skipped the validation loop entirely, and then
836
+ * opened a pull request with no validation report at all, so "only a green checkout opens a PR"
837
+ * held for every run except those. Committing first is what puts that work in front of the gate.
838
+ *
839
+ * `commitTrackedEdits` only captures edits to files git ALREADY tracks, so a NEW file the agent
840
+ * created and forgot to add used to be listed, warned about and dropped. Guardrails on what may be
841
+ * swept up (a dependency/build deny-list, a file-count and byte bound, an all-or-nothing refusal
842
+ * over it) live in `salvage.ts`; this path is coding mode by construction, which is the other rule
843
+ * it must obey.
844
+ *
845
+ * `committedOwnWork` is read BETWEEN the two, and that is not incidental. Afterwards the salvage's
846
+ * own commit makes the branch look advanced, and the two are not the same claim: work the agent
847
+ * committed is a change it chose to make, where a salvage-only branch is one built entirely out of
848
+ * what it left lying in the checkout. Only the second has to say so on the pull request it opens.
849
+ *
850
+ * A repair round runs the agent afresh and can leave new files of its own, so `finalizeCodingRun`
851
+ * runs a second, mop-up pass and folds the two reports.
852
+ */
853
+ async function settleAgentWork(dir, spec, baseSha, logger, opts) {
854
+ const { signal } = opts;
855
+ await commitTrackedEdits(dir, spec.commitMessage, signal);
856
+ const committedOwnWork = await branchHasCommitsSince(dir, baseSha, signal);
857
+ const preGateSalvage = await salvageUntrackedWork({
858
+ dir,
859
+ occasion: SETTLED,
860
+ logger,
861
+ ...(signal ? { signal } : {}),
862
+ });
863
+ return { committedOwnWork, preGateSalvage };
864
+ }
769
865
  /**
770
866
  * Fold a pre-PR validation REPAIR pass's run into the accumulated agent outcome, so a looped run
771
867
  * reports what every round actually spent rather than only the first. Counts and telemetry are