@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 +46 -0
- package/dist/agent-runner.js +17 -2
- package/dist/agent.js +28 -6
- 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/environment-inventory.d.ts +158 -0
- package/dist/environment-inventory.js +412 -0
- 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 +2 -2
- package/src/agent-runner.ts +19 -2
- package/src/agent.ts +29 -5
- package/src/claude-cli.ts +20 -4
- package/src/coding-agent.ts +204 -69
- package/src/environment-inventory.ts +512 -0
- package/src/git.ts +14 -3
- package/src/multi-repo-coding.ts +1 -15
- package/src/salvage.ts +87 -2
package/src/coding-agent.ts
CHANGED
|
@@ -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
|
-
//
|
|
601
|
-
//
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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, {
|
|
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
|
|
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
|
-
//
|
|
1047
|
-
//
|
|
1048
|
-
//
|
|
1049
|
-
//
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
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.
|
|
1143
|
-
*
|
|
1144
|
-
*
|
|
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
|