@cat-factory/executor-harness 1.137.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/dist/salvage.js CHANGED
@@ -206,6 +206,59 @@ export async function salvageUntrackedWork(args) {
206
206
  return { status: 'failed', ...report, reason };
207
207
  }
208
208
  }
209
+ /**
210
+ * Fold a later salvage pass onto an earlier one, so a run that salvaged TWICE reports what both
211
+ * passes did rather than only the last.
212
+ *
213
+ * A coding run salvages at each point where the next thing to happen reads COMMITS rather than the
214
+ * working tree: once before the pre-PR gates (which is what lets them run at all on a run whose
215
+ * only product is untracked files), and again at the settle, because a gate's repair round runs
216
+ * the agent afresh and can leave new files of its own. On almost every run the second pass is
217
+ * `none` (the first one already committed everything), so this is a cheap way to keep ONE honest
218
+ * report instead of two half-truths.
219
+ *
220
+ * Counts are summed only when BOTH passes committed, because only then are the two sets disjoint.
221
+ * A pass that refused or failed sees the same files again on the next pass, so summing there would
222
+ * double-count the one loss; instead the more significant status wins outright, `failed` over
223
+ * `refused` over `committed`, on the rule that a pass which could NOT keep its files is the fact a
224
+ * human has to act on and must not be hidden by a later pass that found nothing left to do. Ties
225
+ * take the later pass, whose numbers are the current ones.
226
+ *
227
+ * `withheld` is always the union: a credential-bearing file either pass declined to commit has to
228
+ * be named whatever else happened, since naming it is what lets someone rotate what it held.
229
+ */
230
+ export function foldSalvageReports(previous, next) {
231
+ if (next.status === 'none')
232
+ return withWithheld(previous, next);
233
+ if (previous.status === 'none')
234
+ return withWithheld(next, previous);
235
+ if (previous.status === 'committed' && next.status === 'committed') {
236
+ return withWithheld({
237
+ status: 'committed',
238
+ files: [...previous.files, ...next.files].slice(0, REPORTED_PATHS),
239
+ fileCount: previous.fileCount + next.fileCount,
240
+ totalBytes: previous.totalBytes + next.totalBytes,
241
+ ...((next.commitSha ?? previous.commitSha)
242
+ ? { commitSha: next.commitSha ?? previous.commitSha }
243
+ : {}),
244
+ }, previous);
245
+ }
246
+ return SALVAGE_STATUS_RANK[previous.status] > SALVAGE_STATUS_RANK[next.status]
247
+ ? withWithheld(previous, next)
248
+ : withWithheld(next, previous);
249
+ }
250
+ /** Which status a fold keeps when the two passes disagree; see {@link foldSalvageReports}. */
251
+ const SALVAGE_STATUS_RANK = {
252
+ none: 0,
253
+ committed: 1,
254
+ refused: 2,
255
+ failed: 3,
256
+ };
257
+ /** `kept` with `other`'s withheld paths merged in, de-duplicated and order-preserving. */
258
+ function withWithheld(kept, other) {
259
+ const union = [...new Set([...(kept.withheld ?? []), ...(other.withheld ?? [])])];
260
+ return union.length > 0 ? { ...kept, withheld: union } : kept;
261
+ }
209
262
  /** The salvage commit's message: what it is, why it exists, and how much to trust it. */
210
263
  export function salvageCommitMessage(fileCount, occasion) {
211
264
  const noun = fileCount === 1 ? 'file' : 'files';
@@ -240,6 +293,21 @@ export function salvageOnlyNotice() {
240
293
  `not be discarded with the container. Nothing has reviewed them for completeness or ` +
241
294
  `relevance, and some may be scratch work from the agent's task in a sibling repository.`);
242
295
  }
296
+ /**
297
+ * Put {@link salvageOnlyNotice} at the top of a pull request that is nothing but a salvage.
298
+ *
299
+ * Only the BODY is marked. A title carrying it would follow the PR into every list and
300
+ * notification a maintainer sees, which is a lot of noise for a caveat that belongs beside the
301
+ * diff, and the salvage commit's own message elaborates on it there.
302
+ *
303
+ * Lives here rather than beside either caller because BOTH open pull requests off a salvage-only
304
+ * branch: the multi-repo push phase, per leg, and the single-repo one. It was the multi-repo path
305
+ * alone for a while, which meant the same branch shape opened an unmarked PR depending only on how
306
+ * many repositories the run happened to clone.
307
+ */
308
+ export function withSalvageOnlyNote(pr, salvageOnly) {
309
+ return salvageOnly ? { ...pr, body: `${salvageOnlyNotice()}\n\n${pr.body}` } : pr;
310
+ }
243
311
  /** Total size of `paths` under `dir`; a file that cannot be stat'd counts as zero rather than failing. */
244
312
  async function measure(dir, paths) {
245
313
  const sizes = await Promise.all(paths.map((path) => stat(join(dir, path)).then((info) => info.size, () => 0)));
@@ -250,15 +318,21 @@ async function measure(dir, paths) {
250
318
  * reports, so the person reading "the run was killed" is told in the same breath what became of
251
319
  * its work: on the branch and reviewed by nobody, still in the container, or never committed.
252
320
  *
321
+ * `occasion` is the SAME fact {@link salvageCommitMessage} is given, and for the same reason: how
322
+ * the run ended is what decides how much to trust the files. A run that was killed left them
323
+ * mid-thought; a run that settled simply never added them, and telling a human a clean run "was
324
+ * aborted" describes a failure that did not happen. The two texts had drifted precisely here,
325
+ * which is why the occasion is now a parameter of both rather than a constant inside one.
326
+ *
253
327
  * `delivery` is supplied by whoever pushed. Absent means the caller is on a path where the
254
328
  * ordinary push follows (the settle path), so there is nothing extra to say.
255
329
  */
256
- export function describeSalvage(report, delivery) {
257
- const parts = [describeOutcome(report, delivery), describeWithheld(report)].filter((part) => part !== undefined);
330
+ export function describeSalvage(report, occasion, delivery) {
331
+ const parts = [describeOutcome(report, occasion, delivery), describeWithheld(report)].filter((part) => part !== undefined);
258
332
  return parts.length > 0 ? parts.join(' ') : undefined;
259
333
  }
260
334
  /** The fate of the files the salvage DID try to keep. */
261
- function describeOutcome(report, delivery) {
335
+ function describeOutcome(report, occasion, delivery) {
262
336
  switch (report.status) {
263
337
  case 'none':
264
338
  return undefined;
@@ -267,8 +341,11 @@ function describeOutcome(report, delivery) {
267
341
  ? `commit ${report.commitSha ?? 'unknown'}, which could NOT be pushed ` +
268
342
  `(${delivery.reason ?? 'the push failed'}) and so is lost with the container`
269
343
  : `commit ${report.commitSha ?? 'unknown'}`;
344
+ const why = occasion.kind === 'aborted'
345
+ ? 'this run was aborted'
346
+ : 'the agent finished without committing them';
270
347
  return (`${report.fileCount} uncommitted new file(s) the agent left behind were salvaged into ` +
271
- `${landed}; this run was aborted, so review them before trusting them.`);
348
+ `${landed}; ${why}, so review them before trusting them.`);
272
349
  }
273
350
  case 'refused':
274
351
  return `Uncommitted new files were NOT salvaged: ${report.reason ?? 'over the salvage bounds'}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.137.0",
3
+ "version": "1.139.0",
4
4
  "description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,7 +26,7 @@
26
26
  },
27
27
  "devDependencies": {
28
28
  "@cat-factory/kernel": "0.321.0",
29
- "@cat-factory/server": "0.306.6",
29
+ "@cat-factory/server": "0.306.7",
30
30
  "@cat-factory/spend": "0.16.17",
31
31
  "@hono/node-server": "^2.1.1",
32
32
  "@types/node": "^26.2.0",
@@ -683,6 +683,17 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
683
683
  // The built-in tools this run declares, named ONCE: the same list rides `--tools` and the
684
684
  // `--allowedTools` re-grant, which is additive rather than inert (see `claudeAllowedToolPatterns`).
685
685
  const tools = CLAUDE_TOOL_SET
686
+ // ...but `--tools` itself is withheld from an ambient run, whose `claude` is the developer's own
687
+ // rather than this image's pinned one: an unrecognised FLAG fails the whole run, where an
688
+ // unrecognised tool NAME is merely dropped. See `claudeCliArgs`. The re-grant is unaffected
689
+ // (`--allowedTools` long predates this), so a tool-server run still unlocks what it wires.
690
+ const declareTools = opts.ambientAuth !== true
691
+ const declaredTools = declareTools ? tools : []
692
+ if (!declareTools) {
693
+ opts.log?.info(
694
+ 'claude-code: taking the CLI’s default tool surface (ambient CLI, version unknown)',
695
+ )
696
+ }
686
697
 
687
698
  const secrets = opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : []
688
699
  const capture = openClaudeCallCapture(opts, { prompt, folded, secrets })
@@ -730,7 +741,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
730
741
  reportToolServerStartup(event, opts.onToolServers)
731
742
  // The same startup event answers what the CLI granted of what we asked for; a capability it
732
743
  // named no tool for is a silent capability loss otherwise (see `assertClaudeToolsCurrent`).
733
- assertClaudeToolsCurrent(event, tools, opts.log)
744
+ assertClaudeToolsCurrent(event, declaredTools, opts.log)
734
745
  // A subagent's turns ride the parent's stdout tagged with the dispatch that spawned them;
735
746
  // `telemetry` routes them off the parent's chain (and decides who bills them). Progress, slice
736
747
  // tracking, the guard and `stats` below deliberately see EVERY event: a subagent grinding on
@@ -820,7 +831,13 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
820
831
  const { stderrTail } = await streamCli(
821
832
  {
822
833
  command: 'claude',
823
- args: claudeCliArgs({ model: opts.model, tools, mcpArgs: home.mcpArgs, appendArgs }),
834
+ args: claudeCliArgs({
835
+ model: opts.model,
836
+ tools,
837
+ declareTools,
838
+ mcpArgs: home.mcpArgs,
839
+ appendArgs,
840
+ }),
824
841
  },
825
842
  prompt,
826
843
  { ...opts, signal: runSignal },
package/src/agent.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  import { inferVcsProvider, openPullRequest } from './vcs-api.js'
32
32
  import type { PiRunStats, RunDiagnostics } from './pi-reduction.js'
33
33
  import { applyPrDescription } from './pr-description.js'
34
+ import { withSalvageOnlyNote } from './salvage.js'
34
35
  import { makeDirClaimer } from './checkout-dir.js'
35
36
  import { noChangesReason, runCodingAgent } from './coding-agent.js'
36
37
  import { runMultiRepoCoding } from './multi-repo-coding.js'
@@ -945,6 +946,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
945
946
  reproductionReport,
946
947
  effortReport,
947
948
  prDescription,
949
+ salvageOnly,
948
950
  } = await runCodingAgent(buildSingleRepoCodingSpec(job, pushBranch), opts)
949
951
  // Ralph loop: the harness-computed validation verdict, forwarded onto the coding result as
950
952
  // `ralphVerdict` so the backend's `toRunResult` lifts it onto `AgentRunResult.ralphVerdict`.
@@ -1020,8 +1022,10 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
1020
1022
  ghToken: job.ghToken,
1021
1023
  head: pushBranch,
1022
1024
  base: job.repo.baseBranch,
1023
- // The agent-authored briefing (title/body) wins field-wise over the dispatch-time text.
1024
- pr: applyPrDescription(job.pr, prDescription),
1025
+ // The agent-authored briefing (title/body) wins field-wise over the dispatch-time text,
1026
+ // and a branch that is nothing but salvage says so above whichever body won: the agent
1027
+ // committed nothing here, so no briefing on it describes a change anyone proposed.
1028
+ pr: withSalvageOnlyNote(applyPrDescription(job.pr, prDescription), salvageOnly === true),
1025
1029
  // A resumed run's PR is already open, so refresh it rather than lose the briefing to the
1026
1030
  // duplicate-PR 422 — only from a REAL briefing (see `refreshExisting` for why).
1027
1031
  ...(prDescription ? { refreshExisting: true } : {}),
package/src/claude-cli.ts CHANGED
@@ -141,6 +141,18 @@ export function claudeCliArgs(opts: {
141
141
  model: string
142
142
  /** The built-in tools this run asks for; see {@link CLAUDE_TOOL_SET}. */
143
143
  tools: readonly string[]
144
+ /**
145
+ * Whether to DECLARE that set with `--tools`, or take whatever the CLI defaults to.
146
+ *
147
+ * False for an `ambientAuth` run, which is the one case where the CLI is not this image's. A
148
+ * name the build does not carry is dropped silently, which is what makes {@link CLAUDE_TOOL_SET}
149
+ * safe to be over-inclusive, but that rule is about tool NAMES. An unrecognised FLAG is a
150
+ * different failure: the CLI exits before the run starts. Everywhere else the image pins the
151
+ * version and the flag is measured against it; on a developer's own machine the harness knows
152
+ * neither which `claude` is on the PATH nor how old it is, and the cost of guessing wrong is
153
+ * every local native run, not a thinner tool surface on one.
154
+ */
155
+ declareTools: boolean
144
156
  /** `--mcp-config` + `--strict-mcp-config` + any `--allowedTools`; empty when no server is wired. */
145
157
  mcpArgs: readonly string[]
146
158
  /** `--append-system-prompt <prompt>`, or empty when the prompt was folded into stdin. */
@@ -160,8 +172,7 @@ export function claudeCliArgs(opts: {
160
172
  '--model',
161
173
  opts.model,
162
174
  // Declared rather than defaulted: see this module's header for what the default set costs.
163
- '--tools',
164
- opts.tools.join(','),
175
+ ...(opts.declareTools ? ['--tools', opts.tools.join(',')] : []),
165
176
  ...opts.mcpArgs,
166
177
  ...opts.appendArgs,
167
178
  ]
@@ -180,6 +191,11 @@ export function claudeCliArgs(opts: {
180
191
  * Best-effort and never throws: a run whose tool surface is short is still a run, and the honest
181
192
  * disposition for a floor this image cannot verify is to SAY it could not be read, not to fail the
182
193
  * job and not to stay silent (which reads exactly like a satisfied request).
194
+ *
195
+ * `requested` is what the argv actually DECLARED, so an ambient run (which declares nothing, see
196
+ * {@link claudeCliArgs}) passes none and the line says the surface is the CLI's own default. The
197
+ * floor is still read back there: "the default set carries no search tool" and "we asked for one
198
+ * and did not get it" are both worth a line, and they are not the same fact or the same fix.
183
199
  */
184
200
  export function assertClaudeToolsCurrent(
185
201
  event: Record<string, unknown>,
@@ -192,7 +208,7 @@ export function assertClaudeToolsCurrent(
192
208
  const cliVersion = version ? { cliVersion: version } : {}
193
209
  if (!Array.isArray(event.tools)) {
194
210
  log.warn('claude-code announced no tool list, so this run has an unverified tool surface', {
195
- requestedTools: [...requested],
211
+ ...(requested.length > 0 ? { requestedTools: [...requested] } : { toolsDeclared: false }),
196
212
  ...cliVersion,
197
213
  })
198
214
  return
@@ -202,7 +218,7 @@ export function assertClaudeToolsCurrent(
202
218
  (c) => c.capability,
203
219
  )
204
220
  const fields = {
205
- requestedTools: [...requested],
221
+ ...(requested.length > 0 ? { requestedTools: [...requested] } : { toolsDeclared: false }),
206
222
  grantedTools: [...granted].sort(),
207
223
  ...cliVersion,
208
224
  }
@@ -68,8 +68,10 @@ import {
68
68
  } from './pr-template.js'
69
69
  import {
70
70
  describeSalvage,
71
+ foldSalvageReports,
71
72
  salvageUntrackedWork,
72
73
  type SalvageDelivery,
74
+ type SalvageOccasion,
73
75
  type SalvageReport,
74
76
  } from './salvage.js'
75
77
 
@@ -267,6 +269,12 @@ export interface CodingAgentOutcome {
267
269
  * clean pass.
268
270
  */
269
271
  salvage?: SalvageReport
272
+ /**
273
+ * This branch is NOTHING BUT salvage: the agent committed to it not once, and everything on it
274
+ * is files it left uncommitted in the checkout. Set only when a pull request would present that
275
+ * as a proposed change, so the caller can say so in the body before anyone reads the diff.
276
+ */
277
+ salvageOnly?: boolean
270
278
  }
271
279
 
272
280
  /**
@@ -597,54 +605,31 @@ export async function runCodingAgent(
597
605
  const listUncommittedNewFiles = (): Promise<string[]> =>
598
606
  listUntrackedFiles(workDir, opts.signal)
599
607
 
600
- // BUGFIX REPRODUCTION PROOF: run the run's declared reproduction command against the
601
- // pre-fix tree and the tree the PR will open from, and record whether it was red then
602
- // green. Runs BEFORE the validation loop below, deliberately: validation is the GATE
603
- // ("only a green checkout opens a PR"), so it has to stay the last thing that touches the
604
- // tree — otherwise a reproduction repair round could leave the checkout red behind it and
605
- // the PR would open anyway. Keyed purely off the job body carrying a spec (no agent-kind
606
- // switch); absent ⇒ a no-op and the flow below is byte-for-byte what it was.
607
- const reproduction = spec.reproduction
608
- let reproductionReport: ReproductionReport | undefined
609
- if (reproduction && (await producedWork(dir, spec, baseSha, resumed, opts))) {
610
- opts.onPhase?.('reproduction')
611
- reproductionReport = await runReproductionLoop({
612
- dir,
613
- baseSha,
614
- // Re-read per attempt: a repair pass commits, so the final tree moves under the loop.
615
- // `producedWork` has already committed forgotten tracked edits, and each repair round
616
- // re-commits before the next read.
617
- resolveFinalSha: async () => {
618
- await commitTrackedEdits(dir, spec.commitMessage, signal)
619
- return headCommit(dir, signal)
620
- },
621
- ...(serviceDirectory ? { serviceDirectory } : {}),
622
- spec: reproduction,
623
- logger,
624
- opts,
625
- runAgentPass,
626
- onAgentPass: foldPass,
627
- listUncommittedNewFiles,
628
- // Only a RESUMED run can have a pre-fix tree that already carries work: a fresh run
629
- // branched off base, so `baseSha` IS base. Wiring the probe unconditionally would buy
630
- // an always-empty answer for the price of a fetch — and a fresh clone is shallow, so
631
- // it could not resolve a merge base to answer with anyway. Lazy inside the loop: it
632
- // only runs if a tree comes back green.
633
- ...(resumed
634
- ? {
635
- listBaseTreeChanges: () =>
636
- changedFilesSinceBase(
637
- dir,
638
- spec.repo.baseBranch,
639
- spec.ghToken,
640
- baseSha,
641
- opts.signal,
642
- ),
643
- }
644
- : {}),
645
- })
646
- opts.onPhase?.('agent')
647
- }
608
+ // Commit what the agent left uncommitted, BEFORE the two pre-PR phases below read the
609
+ // branch. See {@link settleAgentWork} for why the order is the whole point.
610
+ const { committedOwnWork, preGateSalvage } = await settleAgentWork(
611
+ dir,
612
+ spec,
613
+ baseSha,
614
+ logger,
615
+ opts,
616
+ )
617
+
618
+ // BUGFIX REPRODUCTION PROOF, run before the validation loop below. See
619
+ // {@link runReproductionPhase} for why that order is load-bearing. A no-op when the job
620
+ // body carries no reproduction spec.
621
+ const reproductionReport = await runReproductionPhase({
622
+ dir,
623
+ spec,
624
+ baseSha,
625
+ resumed,
626
+ logger,
627
+ opts,
628
+ ...(serviceDirectory ? { serviceDirectory } : {}),
629
+ runAgentPass,
630
+ onAgentPass: foldPass,
631
+ listUncommittedNewFiles,
632
+ })
648
633
  // PRE-PR VALIDATION: run the service's configured checks against the checkout and, while
649
634
  // they fail and budget remains, hand the captured output back to the agent and run it
650
635
  // again. Sits BETWEEN the agent and the finalize/push/PR step so a red checkout never
@@ -670,6 +655,8 @@ export async function runCodingAgent(
670
655
  outcome = await finalizeCodingRun({
671
656
  validationReport,
672
657
  reproductionReport,
658
+ preGateSalvage,
659
+ committedOwnWork,
673
660
  dir,
674
661
  spec,
675
662
  logger,
@@ -701,7 +688,13 @@ export async function runCodingAgent(
701
688
  // handed the rescue's push and a rescue starting behind a checkpoint would be handed a
702
689
  // push made BEFORE the salvage commit existed — reporting as pushed a commit that is not.
703
690
  clearInterval(checkpoint)
704
- throw await withSalvagedWork(error, { dir, logger, pushWorkOnce, inFlightPush })
691
+ throw await withSalvagedWork(error, {
692
+ dir,
693
+ commitMessage: spec.commitMessage,
694
+ logger,
695
+ pushWorkOnce,
696
+ inFlightPush,
697
+ })
705
698
  } finally {
706
699
  // Safety net for the throw path (the happy path already cleared these above).
707
700
  clearInterval(checkpoint)
@@ -724,10 +717,18 @@ export async function runCodingAgent(
724
717
  function withSalvageNote(summary: string, salvage: SalvageReport): string {
725
718
  const missedWork = salvage.status === 'refused' || salvage.status === 'failed'
726
719
  if (!missedWork && (salvage.withheld?.length ?? 0) === 0) return summary
727
- const note = describeSalvage(salvage)
720
+ const note = describeSalvage(salvage, SETTLED)
728
721
  return note ? `${note}\n\n${summary}` : summary
729
722
  }
730
723
 
724
+ /**
725
+ * How the run ended, for every salvage on the settle path: the agent finished, it simply never
726
+ * added these files. Named once because the commit message and the human-readable note are both
727
+ * given it, and a settle-path salvage that describes itself as an abort reports a failure that
728
+ * did not happen.
729
+ */
730
+ const SETTLED: SalvageOccasion = { kind: 'settled' }
731
+
731
732
  /**
732
733
  * How long the rescue of an aborted run's work gets, on its own clock.
733
734
  *
@@ -781,23 +782,37 @@ export async function withSalvagedWork(
781
782
  error: unknown,
782
783
  args: {
783
784
  dir: string
785
+ /** The message the run's own commits carry, reused for the tracked edits rescued below. */
786
+ commitMessage: string
784
787
  logger: Logger
785
788
  pushWorkOnce: (override?: AbortSignal) => Promise<void>
786
789
  inFlightPush: () => Promise<void> | null
787
790
  },
788
791
  ): Promise<unknown> {
789
792
  const cause = error instanceof Error ? error.message : String(error)
793
+ const occasion: SalvageOccasion = { kind: 'aborted', cause }
790
794
  const signal = rescueSignal()
791
795
  await drainInFlightPush(args.inFlightPush, args.logger)
796
+ // Edits to files git ALREADY tracks, committed under the run's own message before the salvage
797
+ // takes the untracked ones. On the settle path this has run several times already; here it has
798
+ // never run at all, and a killed agent leaves edits behind exactly as it leaves new files.
799
+ // Naming them separately is also what keeps the salvage's own count honest: `commitPaths`
800
+ // commits the paths it was given and no others, so anything not swept up here is simply lost.
801
+ // Best-effort: a failure here must not cost the salvage that follows it.
802
+ await commitTrackedEdits(args.dir, args.commitMessage, signal).catch((commitError: unknown) => {
803
+ args.logger.warn('coding-agent: could not commit the tracked edits an aborted run left', {
804
+ reason: commitError instanceof Error ? commitError.message : String(commitError),
805
+ })
806
+ })
792
807
  const note = await salvageUntrackedWork({
793
808
  dir: args.dir,
794
- occasion: { kind: 'aborted', cause },
809
+ occasion,
795
810
  logger: args.logger,
796
811
  signal,
797
812
  })
798
813
  .then(async (report) => {
799
- if (report.status !== 'committed') return describeSalvage(report)
800
- return describeSalvage(report, await deliverSalvage(args, signal))
814
+ if (report.status !== 'committed') return describeSalvage(report, occasion)
815
+ return describeSalvage(report, occasion, await deliverSalvage(args, signal))
801
816
  })
802
817
  .catch((salvageError: unknown) => {
803
818
  args.logger.error('coding-agent: salvage of an aborted run failed', {
@@ -975,6 +990,10 @@ async function finalizeCodingRun(args: {
975
990
  validationReport?: ValidationReport
976
991
  /** The reproduction proof's last attempt, attached to the outcome (absent when unconfigured). */
977
992
  reproductionReport?: ReproductionReport
993
+ /** What the salvage pass that ran ahead of the pre-PR phases recovered; folded with the mop-up. */
994
+ preGateSalvage: SalvageReport
995
+ /** Whether the branch carried commits of the agent's OWN, read before that salvage committed. */
996
+ committedOwnWork: boolean
978
997
  dir: string
979
998
  spec: CodingAgentSpec
980
999
  logger: Logger
@@ -994,6 +1013,8 @@ async function finalizeCodingRun(args: {
994
1013
  const {
995
1014
  validationReport,
996
1015
  reproductionReport,
1016
+ preGateSalvage,
1017
+ committedOwnWork,
997
1018
  dir,
998
1019
  spec,
999
1020
  logger,
@@ -1043,19 +1064,24 @@ async function finalizeCodingRun(args: {
1043
1064
  const inflight = inFlightPush()
1044
1065
  if (inflight) await inflight.catch(() => {})
1045
1066
 
1046
- // Recover the untracked, non-ignored files the agent left behind. `commitTrackedEdits` above
1047
- // only captures edits to ALREADY tracked files, so a NEW file the agent created and forgot to
1048
- // commit used to be listed, warned about and dropped and on a greenfield task EVERY file is
1049
- // new, which made that warning the whole deliverable going in the bin. Observable is not
1050
- // recovered, so commit them. Guardrails (a dependency/build deny-list, a file-count and byte
1051
- // bound, an all-or-nothing refusal over it) live in `salvage.ts`; this path is coding mode by
1052
- // construction, which is the other rule it must obey.
1053
- const salvage = await salvageUntrackedWork({
1054
- dir,
1055
- occasion: { kind: 'settled' },
1056
- logger,
1057
- ...(signal ? { signal } : {}),
1058
- })
1067
+ // The MOP-UP salvage. The caller already ran one ahead of the pre-PR phases (see there for why
1068
+ // the order matters); this second pass exists because a validation or reproduction REPAIR round
1069
+ // runs the agent afresh and can leave new files of its own after that first pass. On a run with
1070
+ // no repair round it finds nothing and folds away to the earlier report.
1071
+ const salvage = foldSalvageReports(
1072
+ preGateSalvage,
1073
+ await salvageUntrackedWork({
1074
+ dir,
1075
+ occasion: SETTLED,
1076
+ logger,
1077
+ ...(signal ? { signal } : {}),
1078
+ }),
1079
+ )
1080
+ // A branch whose ENTIRE content is salvage is not a change anyone proposed, and its reviewer has
1081
+ // to be told that before reading it as one. `committedOwnWork` is the caller's pre-salvage read;
1082
+ // a RESUMED run carries prior commits of the agent's own regardless of what this pass added, so
1083
+ // it is never salvage-only. The multi-repo path marks its peer PRs the same way.
1084
+ const salvageOnly = !resumed && !committedOwnWork && salvage.status === 'committed'
1059
1085
  // A salvage that COMMITTED needs no announcement: its files are in the push and its commit
1060
1086
  // message says where they came from. A refused or failed one means work the agent produced is
1061
1087
  // NOT in the pull request, on a run that otherwise reads as a clean pass — so say it in the
@@ -1109,6 +1135,7 @@ async function finalizeCodingRun(args: {
1109
1135
  ...(effortReport ? { effortReport } : {}),
1110
1136
  ...(prDescription ? { prDescription } : {}),
1111
1137
  ...(salvage.status === 'none' ? {} : { salvage }),
1138
+ ...(salvageOnly ? { salvageOnly: true } : {}),
1112
1139
  }
1113
1140
  }
1114
1141
 
@@ -1139,9 +1166,13 @@ async function finalizeCodingRun(args: {
1139
1166
  *
1140
1167
  * Commits forgotten edits to tracked files first, exactly as {@link finalizeCodingRun} does, so
1141
1168
  * an agent that edited-but-didn't-commit still counts as work. That call is idempotent, so
1142
- * finalize repeating it later is a no-op. Uncommitted NEW files are invisible here — but they
1143
- * are equally invisible to finalize, so a run whose only product is an uncommitted new file is
1144
- * a no-op on both paths, and the checks would have nothing to gate anyway.
1169
+ * finalize repeating it later is a no-op.
1170
+ *
1171
+ * Uncommitted NEW files are invisible to it, which is why {@link settleAgentWork} runs ahead of
1172
+ * every caller and commits them. Reading commits is the whole point of this gate, and it used to
1173
+ * mean that a run whose only product was new files (a greenfield task, where that is ALL of
1174
+ * them) answered `false` here and skipped the checks, only for the salvage to commit the lot
1175
+ * afterwards and open a pull request nothing had validated.
1145
1176
  */
1146
1177
  async function producedWork(
1147
1178
  dir: string,
@@ -1154,6 +1185,110 @@ async function producedWork(
1154
1185
  return resumed || (await branchHasCommitsSince(dir, baseSha, opts.signal))
1155
1186
  }
1156
1187
 
1188
+ /**
1189
+ * The bugfix reproduction proof: run the run's declared reproduction command against the pre-fix
1190
+ * tree and the tree the PR will open from, and record whether it was red then green.
1191
+ *
1192
+ * Runs BEFORE the pre-PR validation loop, deliberately: validation is the GATE ("only a green
1193
+ * checkout opens a PR"), so it has to stay the last thing that touches the tree — otherwise a
1194
+ * reproduction repair round could leave the checkout red behind it and the PR would open anyway.
1195
+ * Keyed purely off the job body carrying a spec (no agent-kind switch); absent ⇒ `undefined` and
1196
+ * the flow around it is byte-for-byte what it was.
1197
+ */
1198
+ async function runReproductionPhase<TRun>(args: {
1199
+ dir: string
1200
+ spec: CodingAgentSpec
1201
+ baseSha: string
1202
+ resumed: boolean
1203
+ logger: Logger
1204
+ opts: RunOptions
1205
+ serviceDirectory?: string
1206
+ runAgentPass: (userPrompt: string) => Promise<TRun>
1207
+ onAgentPass: (run: TRun) => void
1208
+ listUncommittedNewFiles: () => Promise<string[]>
1209
+ }): Promise<ReproductionReport | undefined> {
1210
+ const { dir, spec, baseSha, resumed, logger, opts, serviceDirectory } = args
1211
+ const { signal } = opts
1212
+ const reproduction = spec.reproduction
1213
+ if (!reproduction || !(await producedWork(dir, spec, baseSha, resumed, opts))) return undefined
1214
+ opts.onPhase?.('reproduction')
1215
+ const report = await runReproductionLoop({
1216
+ dir,
1217
+ baseSha,
1218
+ // Re-read per attempt: a repair pass commits, so the final tree moves under the loop.
1219
+ // `producedWork` has already committed forgotten tracked edits, and each repair round
1220
+ // re-commits before the next read.
1221
+ resolveFinalSha: async () => {
1222
+ await commitTrackedEdits(dir, spec.commitMessage, signal)
1223
+ return headCommit(dir, signal)
1224
+ },
1225
+ ...(serviceDirectory ? { serviceDirectory } : {}),
1226
+ spec: reproduction,
1227
+ logger,
1228
+ opts,
1229
+ runAgentPass: args.runAgentPass,
1230
+ onAgentPass: args.onAgentPass,
1231
+ listUncommittedNewFiles: args.listUncommittedNewFiles,
1232
+ // Only a RESUMED run can have a pre-fix tree that already carries work: a fresh run branched
1233
+ // off base, so `baseSha` IS base. Wiring the probe unconditionally would buy an always-empty
1234
+ // answer for the price of a fetch — and a fresh clone is shallow, so it could not resolve a
1235
+ // merge base to answer with anyway. Lazy inside the loop: it only runs if a tree comes back
1236
+ // green.
1237
+ ...(resumed
1238
+ ? {
1239
+ listBaseTreeChanges: () =>
1240
+ changedFilesSinceBase(dir, spec.repo.baseBranch, spec.ghToken, baseSha, opts.signal),
1241
+ }
1242
+ : {}),
1243
+ })
1244
+ opts.onPhase?.('agent')
1245
+ return report
1246
+ }
1247
+
1248
+ /**
1249
+ * Commit everything the settled agent left behind, and say whether the branch already carried
1250
+ * commits of its OWN before that.
1251
+ *
1252
+ * Runs between the agent and the two pre-PR phases, and the ORDER is the whole point. Both phases
1253
+ * are gated on {@link producedWork}, which reads COMMITS. The salvage used to run last, at the
1254
+ * settle, which left that gate false for exactly the runs the salvage exists to save: a greenfield
1255
+ * task whose every file is new and uncommitted skipped the validation loop entirely, and then
1256
+ * opened a pull request with no validation report at all, so "only a green checkout opens a PR"
1257
+ * held for every run except those. Committing first is what puts that work in front of the gate.
1258
+ *
1259
+ * `commitTrackedEdits` only captures edits to files git ALREADY tracks, so a NEW file the agent
1260
+ * created and forgot to add used to be listed, warned about and dropped. Guardrails on what may be
1261
+ * swept up (a dependency/build deny-list, a file-count and byte bound, an all-or-nothing refusal
1262
+ * over it) live in `salvage.ts`; this path is coding mode by construction, which is the other rule
1263
+ * it must obey.
1264
+ *
1265
+ * `committedOwnWork` is read BETWEEN the two, and that is not incidental. Afterwards the salvage's
1266
+ * own commit makes the branch look advanced, and the two are not the same claim: work the agent
1267
+ * committed is a change it chose to make, where a salvage-only branch is one built entirely out of
1268
+ * what it left lying in the checkout. Only the second has to say so on the pull request it opens.
1269
+ *
1270
+ * A repair round runs the agent afresh and can leave new files of its own, so `finalizeCodingRun`
1271
+ * runs a second, mop-up pass and folds the two reports.
1272
+ */
1273
+ async function settleAgentWork(
1274
+ dir: string,
1275
+ spec: CodingAgentSpec,
1276
+ baseSha: string,
1277
+ logger: Logger,
1278
+ opts: RunOptions,
1279
+ ): Promise<{ committedOwnWork: boolean; preGateSalvage: SalvageReport }> {
1280
+ const { signal } = opts
1281
+ await commitTrackedEdits(dir, spec.commitMessage, signal)
1282
+ const committedOwnWork = await branchHasCommitsSince(dir, baseSha, signal)
1283
+ const preGateSalvage = await salvageUntrackedWork({
1284
+ dir,
1285
+ occasion: SETTLED,
1286
+ logger,
1287
+ ...(signal ? { signal } : {}),
1288
+ })
1289
+ return { committedOwnWork, preGateSalvage }
1290
+ }
1291
+
1157
1292
  /**
1158
1293
  * Fold a pre-PR validation REPAIR pass's run into the accumulated agent outcome, so a looped run
1159
1294
  * reports what every round actually spent rather than only the first. Counts and telemetry are