@cat-factory/executor-harness 1.137.0 → 1.139.1
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/agent-runner.js +17 -2
- package/dist/agent.js +6 -3
- package/dist/claude-cli.d.ts +17 -0
- package/dist/claude-cli.js +8 -4
- package/dist/coding-agent.d.ts +8 -0
- package/dist/coding-agent.js +157 -61
- package/dist/git.d.ts +7 -0
- package/dist/git.js +14 -3
- package/dist/multi-repo-coding.js +1 -11
- package/dist/salvage.d.ts +44 -1
- package/dist/salvage.js +81 -4
- package/package.json +4 -4
- package/src/agent-runner.ts +19 -2
- package/src/agent.ts +6 -2
- package/src/claude-cli.ts +20 -4
- package/src/coding-agent.ts +204 -69
- package/src/git.ts +14 -3
- package/src/multi-repo-coding.ts +1 -15
- package/src/salvage.ts +87 -2
package/dist/agent-runner.js
CHANGED
|
@@ -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,
|
|
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({
|
|
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,6 +11,7 @@ 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';
|
|
@@ -794,7 +795,7 @@ export function buildSingleRepoCodingSpec(job, pushBranch) {
|
|
|
794
795
|
*/
|
|
795
796
|
async function runSingleRepoCoding(job, opts) {
|
|
796
797
|
const pushBranch = job.pushBranch ?? job.newBranch ?? job.branch;
|
|
797
|
-
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);
|
|
798
799
|
// Ralph loop: the harness-computed validation verdict, forwarded onto the coding result as
|
|
799
800
|
// `ralphVerdict` so the backend's `toRunResult` lifts it onto `AgentRunResult.ralphVerdict`.
|
|
800
801
|
const ralphVerdict = validation ? { ralphVerdict: validation } : {};
|
|
@@ -866,8 +867,10 @@ async function runSingleRepoCoding(job, opts) {
|
|
|
866
867
|
ghToken: job.ghToken,
|
|
867
868
|
head: pushBranch,
|
|
868
869
|
base: job.repo.baseBranch,
|
|
869
|
-
// The agent-authored briefing (title/body) wins field-wise over the dispatch-time text
|
|
870
|
-
|
|
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),
|
|
871
874
|
// A resumed run's PR is already open, so refresh it rather than lose the briefing to the
|
|
872
875
|
// duplicate-PR 422 — only from a REAL briefing (see `refreshExisting` for why).
|
|
873
876
|
...(prDescription ? { refreshExisting: true } : {}),
|
package/dist/claude-cli.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/claude-cli.js
CHANGED
|
@@ -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
|
};
|
package/dist/coding-agent.d.ts
CHANGED
|
@@ -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;
|
package/dist/coding-agent.js
CHANGED
|
@@ -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
|
-
//
|
|
296
|
-
//
|
|
297
|
-
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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, {
|
|
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
|
|
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
|
-
//
|
|
666
|
-
//
|
|
667
|
-
//
|
|
668
|
-
//
|
|
669
|
-
|
|
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:
|
|
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.
|
|
762
|
-
*
|
|
763
|
-
*
|
|
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
|
package/dist/git.d.ts
CHANGED
|
@@ -185,6 +185,13 @@ export declare function workingTreeStatus(dir: string, signal?: AbortSignal): Pr
|
|
|
185
185
|
* path in ONE command, that one name discards the whole all-or-nothing salvage, exactly as an
|
|
186
186
|
* unquoted accented name did. `:(literal)` matches the entry as itself and nothing else.
|
|
187
187
|
*
|
|
188
|
+
* The commit is SCOPED to those same pathspecs, and so is the staged-anything check above it. A
|
|
189
|
+
* bare `git commit` takes whatever the index holds, which is not the same set: an agent killed
|
|
190
|
+
* mid-flight leaves its own `git add` staged, and that content would then land under a message
|
|
191
|
+
* naming only the paths passed here, counted by a caller that had never looked at it. The commit
|
|
192
|
+
* and the claim made about it have to describe ONE set of files. Content the agent staged and this
|
|
193
|
+
* call did not name stays staged for whoever commits it next.
|
|
194
|
+
*
|
|
188
195
|
* The caller has already decided WHICH paths belong; this only commits them.
|
|
189
196
|
*/
|
|
190
197
|
export declare function commitPaths(dir: string, paths: string[], message: string, signal?: AbortSignal): Promise<string | null>;
|
package/dist/git.js
CHANGED
|
@@ -526,16 +526,27 @@ export async function workingTreeStatus(dir, signal) {
|
|
|
526
526
|
* path in ONE command, that one name discards the whole all-or-nothing salvage, exactly as an
|
|
527
527
|
* unquoted accented name did. `:(literal)` matches the entry as itself and nothing else.
|
|
528
528
|
*
|
|
529
|
+
* The commit is SCOPED to those same pathspecs, and so is the staged-anything check above it. A
|
|
530
|
+
* bare `git commit` takes whatever the index holds, which is not the same set: an agent killed
|
|
531
|
+
* mid-flight leaves its own `git add` staged, and that content would then land under a message
|
|
532
|
+
* naming only the paths passed here, counted by a caller that had never looked at it. The commit
|
|
533
|
+
* and the claim made about it have to describe ONE set of files. Content the agent staged and this
|
|
534
|
+
* call did not name stays staged for whoever commits it next.
|
|
535
|
+
*
|
|
529
536
|
* The caller has already decided WHICH paths belong; this only commits them.
|
|
530
537
|
*/
|
|
531
538
|
export async function commitPaths(dir, paths, message, signal) {
|
|
532
539
|
if (paths.length === 0)
|
|
533
540
|
return null;
|
|
534
|
-
|
|
535
|
-
|
|
541
|
+
const pathspecs = paths.map(literalPathspec);
|
|
542
|
+
await git(['add', '--', ...pathspecs], { cwd: dir, signal });
|
|
543
|
+
const staged = await git(['diff', '--cached', '--name-only', '--', ...pathspecs], {
|
|
544
|
+
cwd: dir,
|
|
545
|
+
signal,
|
|
546
|
+
});
|
|
536
547
|
if (staged.trim() === '')
|
|
537
548
|
return null;
|
|
538
|
-
await git(['commit', '-m', message], { cwd: dir, signal });
|
|
549
|
+
await git(['commit', '-m', message, '--', ...pathspecs], { cwd: dir, signal });
|
|
539
550
|
return headCommit(dir, signal);
|
|
540
551
|
}
|
|
541
552
|
/** One path as a pathspec that matches only itself — see {@link commitPaths} for why. */
|
|
@@ -2,7 +2,7 @@ import { mkdir } from 'node:fs/promises';
|
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { makeDirClaimer } from './checkout-dir.js';
|
|
4
4
|
import { branchAheadOfBase, branchHasCommitsSince, cloneExistingBranch, cloneRepo, commitTrackedEdits, createBranch, excludeFromGit, fetchReferenceBranches, headCommit, pushBranch, refreshFromBaseIfClean, remoteBranchExists, } from './git.js';
|
|
5
|
-
import {
|
|
5
|
+
import { salvageUntrackedWork, withSalvageOnlyNote } from './salvage.js';
|
|
6
6
|
import { openPullRequest } from './vcs-api.js';
|
|
7
7
|
import { applyPrDescription, PR_DESCRIPTION_FILE, readPrDescription } from './pr-description.js';
|
|
8
8
|
import { runAgentInWorkspace, withWorkspace } from './pi-workspace.js';
|
|
@@ -305,16 +305,6 @@ async function prepareMultiRepoCheckouts(root, legs, job, logger, opts) {
|
|
|
305
305
|
export function probeDirsForLegs(legs) {
|
|
306
306
|
return legs.filter((leg) => !leg.readOnly).map((leg) => leg.dir);
|
|
307
307
|
}
|
|
308
|
-
/**
|
|
309
|
-
* Put {@link salvageOnlyNotice} at the top of a pull request that is nothing but a salvage.
|
|
310
|
-
*
|
|
311
|
-
* Only the BODY is marked. A title carrying it would follow the PR into every list and
|
|
312
|
-
* notification a maintainer sees, which is a lot of noise for a caveat that belongs beside the
|
|
313
|
-
* diff, and the salvage commit's own message elaborates on it there.
|
|
314
|
-
*/
|
|
315
|
-
function withSalvageOnlyNote(pr, salvageOnly) {
|
|
316
|
-
return salvageOnly ? { ...pr, body: `${salvageOnlyNotice()}\n\n${pr.body}` } : pr;
|
|
317
|
-
}
|
|
318
308
|
/**
|
|
319
309
|
* Push phase for {@link runMultiRepoCoding}: commit forgotten tracked edits, then push + open a PR
|
|
320
310
|
* for each repo the run actually changed (a repo the agent left untouched is skipped — no branch,
|
package/dist/salvage.d.ts
CHANGED
|
@@ -126,6 +126,28 @@ export declare function salvageUntrackedWork(args: {
|
|
|
126
126
|
signal?: AbortSignal;
|
|
127
127
|
bounds?: SalvageBounds;
|
|
128
128
|
}): Promise<SalvageReport>;
|
|
129
|
+
/**
|
|
130
|
+
* Fold a later salvage pass onto an earlier one, so a run that salvaged TWICE reports what both
|
|
131
|
+
* passes did rather than only the last.
|
|
132
|
+
*
|
|
133
|
+
* A coding run salvages at each point where the next thing to happen reads COMMITS rather than the
|
|
134
|
+
* working tree: once before the pre-PR gates (which is what lets them run at all on a run whose
|
|
135
|
+
* only product is untracked files), and again at the settle, because a gate's repair round runs
|
|
136
|
+
* the agent afresh and can leave new files of its own. On almost every run the second pass is
|
|
137
|
+
* `none` (the first one already committed everything), so this is a cheap way to keep ONE honest
|
|
138
|
+
* report instead of two half-truths.
|
|
139
|
+
*
|
|
140
|
+
* Counts are summed only when BOTH passes committed, because only then are the two sets disjoint.
|
|
141
|
+
* A pass that refused or failed sees the same files again on the next pass, so summing there would
|
|
142
|
+
* double-count the one loss; instead the more significant status wins outright, `failed` over
|
|
143
|
+
* `refused` over `committed`, on the rule that a pass which could NOT keep its files is the fact a
|
|
144
|
+
* human has to act on and must not be hidden by a later pass that found nothing left to do. Ties
|
|
145
|
+
* take the later pass, whose numbers are the current ones.
|
|
146
|
+
*
|
|
147
|
+
* `withheld` is always the union: a credential-bearing file either pass declined to commit has to
|
|
148
|
+
* be named whatever else happened, since naming it is what lets someone rotate what it held.
|
|
149
|
+
*/
|
|
150
|
+
export declare function foldSalvageReports(previous: SalvageReport, next: SalvageReport): SalvageReport;
|
|
129
151
|
/**
|
|
130
152
|
* How the run that left these files behind ended. It decides what the commit message SAYS, which
|
|
131
153
|
* is the whole point of marking a salvage: a commit arriving on a branch with no explanation is
|
|
@@ -159,6 +181,21 @@ export declare function salvageCommitMessage(fileCount: number, occasion: Salvag
|
|
|
159
181
|
* into describing it differently. The caller decides WHERE it goes.
|
|
160
182
|
*/
|
|
161
183
|
export declare function salvageOnlyNotice(): string;
|
|
184
|
+
/**
|
|
185
|
+
* Put {@link salvageOnlyNotice} at the top of a pull request that is nothing but a salvage.
|
|
186
|
+
*
|
|
187
|
+
* Only the BODY is marked. A title carrying it would follow the PR into every list and
|
|
188
|
+
* notification a maintainer sees, which is a lot of noise for a caveat that belongs beside the
|
|
189
|
+
* diff, and the salvage commit's own message elaborates on it there.
|
|
190
|
+
*
|
|
191
|
+
* Lives here rather than beside either caller because BOTH open pull requests off a salvage-only
|
|
192
|
+
* branch: the multi-repo push phase, per leg, and the single-repo one. It was the multi-repo path
|
|
193
|
+
* alone for a while, which meant the same branch shape opened an unmarked PR depending only on how
|
|
194
|
+
* many repositories the run happened to clone.
|
|
195
|
+
*/
|
|
196
|
+
export declare function withSalvageOnlyNote<T extends {
|
|
197
|
+
body: string;
|
|
198
|
+
}>(pr: T, salvageOnly: boolean): T;
|
|
162
199
|
/**
|
|
163
200
|
* Where a salvage commit ENDED UP, which the salvage itself cannot know: it commits, and someone
|
|
164
201
|
* else pushes. A commit that was not pushed dies with the container exactly as the uncommitted
|
|
@@ -174,7 +211,13 @@ export interface SalvageDelivery {
|
|
|
174
211
|
* reports, so the person reading "the run was killed" is told in the same breath what became of
|
|
175
212
|
* its work: on the branch and reviewed by nobody, still in the container, or never committed.
|
|
176
213
|
*
|
|
214
|
+
* `occasion` is the SAME fact {@link salvageCommitMessage} is given, and for the same reason: how
|
|
215
|
+
* the run ended is what decides how much to trust the files. A run that was killed left them
|
|
216
|
+
* mid-thought; a run that settled simply never added them, and telling a human a clean run "was
|
|
217
|
+
* aborted" describes a failure that did not happen. The two texts had drifted precisely here,
|
|
218
|
+
* which is why the occasion is now a parameter of both rather than a constant inside one.
|
|
219
|
+
*
|
|
177
220
|
* `delivery` is supplied by whoever pushed. Absent means the caller is on a path where the
|
|
178
221
|
* ordinary push follows (the settle path), so there is nothing extra to say.
|
|
179
222
|
*/
|
|
180
|
-
export declare function describeSalvage(report: SalvageReport, delivery?: SalvageDelivery): string | undefined;
|
|
223
|
+
export declare function describeSalvage(report: SalvageReport, occasion: SalvageOccasion, delivery?: SalvageDelivery): string | undefined;
|