@cat-factory/executor-harness 1.58.0 → 1.62.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 +24 -2
- package/dist/agent.js +28 -14
- package/dist/captured-command.js +112 -0
- package/dist/coding-agent.js +90 -9
- package/dist/git.js +108 -319
- package/dist/host-markdown.js +142 -0
- package/dist/job.js +5 -46
- package/dist/pr-description.js +157 -0
- package/dist/reproduction-proof.js +614 -0
- package/dist/runner.js +3 -0
- package/dist/validation-checks.js +70 -82
- package/dist/vcs-api.js +402 -0
- package/package.json +4 -3
- package/src/agent.ts +28 -14
- package/src/captured-command.ts +144 -0
- package/src/coding-agent.ts +133 -6
- package/src/git.ts +134 -385
- package/src/host-markdown.ts +155 -0
- package/src/job.ts +32 -46
- package/src/pr-description.ts +171 -0
- package/src/reproduction-proof.ts +806 -0
- package/src/runner.ts +20 -0
- package/src/validation-checks.ts +71 -81
- package/src/vcs-api.ts +512 -0
package/src/agent.ts
CHANGED
|
@@ -22,16 +22,16 @@ import {
|
|
|
22
22
|
fetchReferenceBranches,
|
|
23
23
|
hasAgentChanges,
|
|
24
24
|
headCommit,
|
|
25
|
-
inferVcsProvider,
|
|
26
25
|
mergeBranch,
|
|
27
|
-
openPullRequest,
|
|
28
26
|
prepareExistingCheckout,
|
|
29
27
|
pushBranch,
|
|
30
28
|
reinitAndPush,
|
|
31
29
|
unmergedPaths,
|
|
32
30
|
} from './git.js'
|
|
31
|
+
import { inferVcsProvider, openPullRequest } from './vcs-api.js'
|
|
33
32
|
import type { PiRunStats, RunDiagnostics } from './pi.js'
|
|
34
33
|
import type { EffortReport } from './effort.js'
|
|
34
|
+
import { applyPrDescription } from './pr-description.js'
|
|
35
35
|
import {
|
|
36
36
|
makeDirClaimer,
|
|
37
37
|
noChangesReason,
|
|
@@ -972,6 +972,10 @@ function buildSingleRepoCodingSpec(
|
|
|
972
972
|
// Forwarded straight off the job body — the loop is generic machinery keyed on the data, not
|
|
973
973
|
// on the agent kind.
|
|
974
974
|
...(job.validationChecks ? { validationChecks: job.validationChecks } : {}),
|
|
975
|
+
// Bugfix reproduction proof: the declared command run against the pre-fix and final trees
|
|
976
|
+
// (see docs/initiatives/bugfix-reproduction-proof.md). Forwarded straight off the job body —
|
|
977
|
+
// like the checks above, the loop is generic machinery keyed on the data, not the agent kind.
|
|
978
|
+
...(job.reproduction ? { reproduction: job.reproduction } : {}),
|
|
975
979
|
}
|
|
976
980
|
}
|
|
977
981
|
|
|
@@ -992,17 +996,23 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
992
996
|
callMetrics,
|
|
993
997
|
validation,
|
|
994
998
|
validationReport,
|
|
999
|
+
reproductionReport,
|
|
995
1000
|
effortReport,
|
|
1001
|
+
prDescription,
|
|
996
1002
|
} = await runCodingAgent(buildSingleRepoCodingSpec(job, pushBranch), opts)
|
|
997
1003
|
// Ralph loop: the harness-computed validation verdict, forwarded onto the coding result as
|
|
998
1004
|
// `ralphVerdict` so the backend's `toRunResult` lifts it onto `AgentRunResult.ralphVerdict`.
|
|
999
1005
|
const ralphVerdict = validation ? { ralphVerdict: validation } : {}
|
|
1000
1006
|
// The agent's effort self-assessment, spread onto every result path below (mirrors ralphVerdict).
|
|
1001
1007
|
const effort = effortReport ? { effortReport } : {}
|
|
1002
|
-
// The
|
|
1003
|
-
// the captured proof the checkout was green when the PR opened; on the
|
|
1004
|
-
// evidence behind the failure below.
|
|
1005
|
-
|
|
1008
|
+
// The two PRE-PR VERIFICATION reports, spread onto every result path below. The validation one:
|
|
1009
|
+
// on the passing path it is the captured proof the checkout was green when the PR opened; on the
|
|
1010
|
+
// exhausted path it is the evidence behind the failure below. The reproduction one is evidence
|
|
1011
|
+
// on every path — it never gates the PR. Each is absent when its phase was not configured.
|
|
1012
|
+
const verificationFields = {
|
|
1013
|
+
...(validationReport ? { validationReport } : {}),
|
|
1014
|
+
...(reproductionReport ? { reproductionReport } : {}),
|
|
1015
|
+
}
|
|
1006
1016
|
|
|
1007
1017
|
// Pre-PR validation spent its attempt budget with the checkout still red. FAIL the job — do
|
|
1008
1018
|
// NOT open a pull request, and do not pretend the push succeeded as a deliverable. The work is
|
|
@@ -1022,7 +1032,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
1022
1032
|
failureCause: 'agent',
|
|
1023
1033
|
...(usage ? { usage } : {}),
|
|
1024
1034
|
...(callMetrics ? { callMetrics } : {}),
|
|
1025
|
-
...
|
|
1035
|
+
...verificationFields,
|
|
1026
1036
|
...effort,
|
|
1027
1037
|
}
|
|
1028
1038
|
}
|
|
@@ -1038,7 +1048,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
1038
1048
|
...(usage ? { usage } : {}),
|
|
1039
1049
|
...(callMetrics ? { callMetrics } : {}),
|
|
1040
1050
|
...ralphVerdict,
|
|
1041
|
-
...
|
|
1051
|
+
...verificationFields,
|
|
1042
1052
|
...effort,
|
|
1043
1053
|
}
|
|
1044
1054
|
}
|
|
@@ -1051,7 +1061,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
1051
1061
|
failureCause: 'no-changes',
|
|
1052
1062
|
...(usage ? { usage } : {}),
|
|
1053
1063
|
...(callMetrics ? { callMetrics } : {}),
|
|
1054
|
-
...
|
|
1064
|
+
...verificationFields,
|
|
1055
1065
|
...effort,
|
|
1056
1066
|
}
|
|
1057
1067
|
}
|
|
@@ -1064,7 +1074,11 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
1064
1074
|
ghToken: job.ghToken,
|
|
1065
1075
|
head: pushBranch,
|
|
1066
1076
|
base: job.repo.baseBranch,
|
|
1067
|
-
|
|
1077
|
+
// The agent-authored briefing (title/body) wins field-wise over the dispatch-time text.
|
|
1078
|
+
pr: applyPrDescription(job.pr, prDescription),
|
|
1079
|
+
// A resumed run's PR is already open, so refresh it rather than lose the briefing to the
|
|
1080
|
+
// duplicate-PR 422 — only from a REAL briefing (see `refreshExisting` for why).
|
|
1081
|
+
...(prDescription ? { refreshExisting: true } : {}),
|
|
1068
1082
|
apiBase: job.githubApiBase,
|
|
1069
1083
|
// The provider (set by the server from the configured backend) selects GitHub-PR vs
|
|
1070
1084
|
// GitLab-MR authoritatively; the clone URL supplies the GitLab REST base + project path.
|
|
@@ -1086,7 +1100,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
1086
1100
|
stats,
|
|
1087
1101
|
...(usage ? { usage } : {}),
|
|
1088
1102
|
...(callMetrics ? { callMetrics } : {}),
|
|
1089
|
-
...
|
|
1103
|
+
...verificationFields,
|
|
1090
1104
|
...effort,
|
|
1091
1105
|
}
|
|
1092
1106
|
}
|
|
@@ -1103,7 +1117,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
1103
1117
|
failureCause: 'no-changes',
|
|
1104
1118
|
...(usage ? { usage } : {}),
|
|
1105
1119
|
...(callMetrics ? { callMetrics } : {}),
|
|
1106
|
-
...
|
|
1120
|
+
...verificationFields,
|
|
1107
1121
|
...effort,
|
|
1108
1122
|
}
|
|
1109
1123
|
}
|
|
@@ -1116,7 +1130,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
1116
1130
|
...(usage ? { usage } : {}),
|
|
1117
1131
|
...(callMetrics ? { callMetrics } : {}),
|
|
1118
1132
|
...ralphVerdict,
|
|
1119
|
-
...
|
|
1133
|
+
...verificationFields,
|
|
1120
1134
|
...effort,
|
|
1121
1135
|
}
|
|
1122
1136
|
}
|
|
@@ -1128,7 +1142,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
1128
1142
|
...(usage ? { usage } : {}),
|
|
1129
1143
|
...(callMetrics ? { callMetrics } : {}),
|
|
1130
1144
|
...ralphVerdict,
|
|
1131
|
-
...
|
|
1145
|
+
...verificationFields,
|
|
1132
1146
|
...effort,
|
|
1133
1147
|
}
|
|
1134
1148
|
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { killChildProcess, spawnDetached } from './process.js'
|
|
3
|
+
import { MAX_CAPTURED_OUTPUT_CHARS, redactSecrets } from './redact.js'
|
|
4
|
+
import type { RunOptions } from './runner.js'
|
|
5
|
+
import type { Logger } from './logger.js'
|
|
6
|
+
|
|
7
|
+
// The ONE way the harness runs a declared shell command on its own behalf (rather than through
|
|
8
|
+
// the agent) and keeps a bounded, secret-scrubbed record of what it printed.
|
|
9
|
+
//
|
|
10
|
+
// Both pre-PR verification phases need exactly this — the PRE-PR VALIDATION checks
|
|
11
|
+
// (`validation-checks.ts`) and the BUGFIX REPRODUCTION PROOF (`reproduction-proof.ts`) — and they
|
|
12
|
+
// need it to behave IDENTICALLY: same watchdog semantics, same abort handling, same conventional
|
|
13
|
+
// exit codes, same scrub-then-bound pipeline. They were two near-verbatim copies; a fix applied to
|
|
14
|
+
// one of them (a redaction ordering, an exit-code convention) silently missed the other, which is
|
|
15
|
+
// the whole reason this seam exists.
|
|
16
|
+
//
|
|
17
|
+
// Everything is PER-JOB by construction: the command, the cwd and the environment all arrive as
|
|
18
|
+
// arguments and nothing is read from or written to `process.env`/`HOME`. The local NATIVE
|
|
19
|
+
// transport serves every concurrent job from ONE host process, so a global would leak one job's
|
|
20
|
+
// state into a sibling's — and the container path would never catch it.
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A little slack kept in the rolling capture buffer ON TOP of {@link MAX_CAPTURED_OUTPUT_CHARS},
|
|
24
|
+
* so scrubbing sees whole secrets.
|
|
25
|
+
*
|
|
26
|
+
* The buffer discards from the FRONT as output arrives, and `redactSecrets` only runs once the
|
|
27
|
+
* command settles. Without the margin a token straddling that rolling cut would already have lost
|
|
28
|
+
* its `KEY=` prefix by scrub time and would survive as an unrecognised partial. Capturing a bit
|
|
29
|
+
* more than we keep, scrubbing, and only THEN bounding to the real limit closes that window; 512
|
|
30
|
+
* chars comfortably exceeds any single credential assignment the rules match.
|
|
31
|
+
*/
|
|
32
|
+
const CAPTURE_MARGIN_CHARS = 512
|
|
33
|
+
|
|
34
|
+
/** What one harness-spawned command did, as both phases record it. */
|
|
35
|
+
export interface CapturedCommandResult {
|
|
36
|
+
/** Exit code (0 = pass); 124 on watchdog timeout, 127 on spawn failure, 130 on abort. */
|
|
37
|
+
exitCode: number
|
|
38
|
+
passed: boolean
|
|
39
|
+
/** Scrubbed output bounded to the caller's REPORT budget (what crosses the wire). */
|
|
40
|
+
outputTail?: string
|
|
41
|
+
durationMs: number
|
|
42
|
+
timedOut?: boolean
|
|
43
|
+
/**
|
|
44
|
+
* The FULL scrubbed tail (up to {@link MAX_CAPTURED_OUTPUT_CHARS}) for a repair prompt. Never
|
|
45
|
+
* leaves the container — the agent needs the whole failure to act on it, the wire does not.
|
|
46
|
+
*/
|
|
47
|
+
fullTail?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Run ONE command as `sh -c` in `cwd`, capturing a bounded, secret-scrubbed tail of its combined
|
|
52
|
+
* stdout+stderr. The exit code is the verdict — computed here by the harness, never self-reported
|
|
53
|
+
* by the model, which is the whole point of a programmatic phase. A watchdog kills the process
|
|
54
|
+
* tree on timeout and an aborted run resolves non-zero, so a phase is never what blocks a job
|
|
55
|
+
* from settling.
|
|
56
|
+
*
|
|
57
|
+
* The child inherits the JOB's environment (`RunOptions.agentEnv` layered over the process env),
|
|
58
|
+
* not a mutated global: the harness spawns this itself rather than through the agent, so without
|
|
59
|
+
* the explicit merge a native-mode job would run without the private-registry npmrc pointer (and,
|
|
60
|
+
* had this been staged in `process.env`, against a sibling job's state).
|
|
61
|
+
*
|
|
62
|
+
* `logLabel`/`logFields` shape only the two warnings this runner emits itself (the watchdog kill
|
|
63
|
+
* and a spawn failure); the caller keeps its own start/finish logging, which knows what the
|
|
64
|
+
* command MEANS.
|
|
65
|
+
*/
|
|
66
|
+
export async function runCapturedCommand(args: {
|
|
67
|
+
cwd: string
|
|
68
|
+
command: string
|
|
69
|
+
timeoutMs: number
|
|
70
|
+
/** Bound for {@link CapturedCommandResult.outputTail} — the caller's per-report budget. */
|
|
71
|
+
reportTailChars: number
|
|
72
|
+
logLabel: string
|
|
73
|
+
logFields?: Record<string, unknown>
|
|
74
|
+
logger: Logger
|
|
75
|
+
opts: RunOptions
|
|
76
|
+
}): Promise<CapturedCommandResult> {
|
|
77
|
+
const { cwd, command, timeoutMs, reportTailChars, logLabel, logFields, logger, opts } = args
|
|
78
|
+
const startedAt = Date.now()
|
|
79
|
+
return new Promise((resolve) => {
|
|
80
|
+
let out = ''
|
|
81
|
+
let settled = false
|
|
82
|
+
let timedOut = false
|
|
83
|
+
const child = spawn('sh', ['-c', command], {
|
|
84
|
+
cwd,
|
|
85
|
+
detached: spawnDetached,
|
|
86
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
87
|
+
env: { ...process.env, ...opts.agentEnv },
|
|
88
|
+
})
|
|
89
|
+
// Keep only the tail (plus the scrub margin); guard against unbounded buffering on a chatty
|
|
90
|
+
// command.
|
|
91
|
+
const capture = (chunk: Buffer): void => {
|
|
92
|
+
out = (out + chunk.toString('utf8')).slice(
|
|
93
|
+
-(MAX_CAPTURED_OUTPUT_CHARS + CAPTURE_MARGIN_CHARS),
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
child.stdout?.on('data', capture)
|
|
97
|
+
child.stderr?.on('data', capture)
|
|
98
|
+
const finish = (exitCode: number): void => {
|
|
99
|
+
if (settled) return
|
|
100
|
+
settled = true
|
|
101
|
+
clearTimeout(timer)
|
|
102
|
+
opts.signal?.removeEventListener('abort', onAbort)
|
|
103
|
+
const trimmed = out.trim()
|
|
104
|
+
// Scrub BEFORE either bound: the pattern rules need a whole assignment to match, so the
|
|
105
|
+
// margin above is trimmed away only once the secrets are already gone.
|
|
106
|
+
const scrubbed = trimmed ? redactSecrets(trimmed).slice(-MAX_CAPTURED_OUTPUT_CHARS) : ''
|
|
107
|
+
resolve({
|
|
108
|
+
exitCode,
|
|
109
|
+
passed: exitCode === 0,
|
|
110
|
+
...(scrubbed ? { outputTail: boundTail(scrubbed, reportTailChars) } : {}),
|
|
111
|
+
durationMs: Date.now() - startedAt,
|
|
112
|
+
...(timedOut ? { timedOut: true } : {}),
|
|
113
|
+
...(scrubbed ? { fullTail: scrubbed } : {}),
|
|
114
|
+
})
|
|
115
|
+
}
|
|
116
|
+
const timer = setTimeout(() => {
|
|
117
|
+
logger.warn(`${logLabel}: command timed out`, { ...logFields, timeoutMs })
|
|
118
|
+
timedOut = true
|
|
119
|
+
killChildProcess(child, undefined, logger)
|
|
120
|
+
finish(124) // conventional timeout exit code (a non-zero fail)
|
|
121
|
+
}, timeoutMs)
|
|
122
|
+
timer.unref?.()
|
|
123
|
+
const onAbort = (): void => {
|
|
124
|
+
killChildProcess(child, undefined, logger)
|
|
125
|
+
finish(130) // aborted (a non-zero fail)
|
|
126
|
+
}
|
|
127
|
+
opts.signal?.addEventListener('abort', onAbort, { once: true })
|
|
128
|
+
child.on('error', (err) => {
|
|
129
|
+
logger.warn(`${logLabel}: command failed to spawn`, {
|
|
130
|
+
...logFields,
|
|
131
|
+
error: err instanceof Error ? err.message : String(err),
|
|
132
|
+
})
|
|
133
|
+
finish(127) // spawn error / command not found (a non-zero fail)
|
|
134
|
+
})
|
|
135
|
+
child.on('close', (code) => finish(code ?? 1))
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Bound an already-scrubbed output tail to what a REPORT carries, saying what it dropped. */
|
|
140
|
+
export function boundTail(scrubbed: string, maxChars: number): string {
|
|
141
|
+
if (scrubbed.length <= maxChars) return scrubbed
|
|
142
|
+
const trimmed = scrubbed.length - maxChars
|
|
143
|
+
return `…(${trimmed} earlier chars trimmed)\n${scrubbed.slice(-maxChars)}`
|
|
144
|
+
}
|
package/src/coding-agent.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type {
|
|
|
14
14
|
} from './job.js'
|
|
15
15
|
import {
|
|
16
16
|
branchAheadOfBase,
|
|
17
|
+
changedFilesSinceBase,
|
|
17
18
|
branchHasCommitsSince,
|
|
18
19
|
cloneExistingBranch,
|
|
19
20
|
cloneRepo,
|
|
@@ -23,15 +24,21 @@ import {
|
|
|
23
24
|
fetchReferenceBranches,
|
|
24
25
|
headCommit,
|
|
25
26
|
listUntrackedFiles,
|
|
26
|
-
openPullRequest,
|
|
27
27
|
prepareExistingCheckout,
|
|
28
28
|
pushBranch,
|
|
29
29
|
refreshFromBaseIfClean,
|
|
30
30
|
remoteBranchExists,
|
|
31
31
|
} from './git.js'
|
|
32
|
+
import { openPullRequest } from './vcs-api.js'
|
|
32
33
|
import { FOLLOW_UPS_FILENAME, FollowUpTailer } from './follow-ups.js'
|
|
33
34
|
import type { HarnessCallMetric, PiRunStats } from './pi.js'
|
|
34
35
|
import { EFFORT_REPORT_FILE, type EffortReport } from './effort.js'
|
|
36
|
+
import {
|
|
37
|
+
type AgentPrDescription,
|
|
38
|
+
applyPrDescription,
|
|
39
|
+
PR_DESCRIPTION_FILE,
|
|
40
|
+
readPrDescription,
|
|
41
|
+
} from './pr-description.js'
|
|
35
42
|
import {
|
|
36
43
|
acquireRepoCheckout,
|
|
37
44
|
agentNeverActed,
|
|
@@ -47,6 +54,11 @@ import {
|
|
|
47
54
|
type ValidationChecksSpec,
|
|
48
55
|
type ValidationReport,
|
|
49
56
|
} from './validation-checks.js'
|
|
57
|
+
import {
|
|
58
|
+
runReproductionLoop,
|
|
59
|
+
type ReproductionReport,
|
|
60
|
+
type ReproductionSpec,
|
|
61
|
+
} from './reproduction-proof.js'
|
|
50
62
|
|
|
51
63
|
// The shared skeleton for the container coding agents that clone a repo, run Pi
|
|
52
64
|
// against it and push the result on a branch. The implementation (`/run`) and
|
|
@@ -117,6 +129,16 @@ export interface CodingAgentSpec extends HarnessAuthFields {
|
|
|
117
129
|
* `docs/initiatives/pre-pr-validation.md`.
|
|
118
130
|
*/
|
|
119
131
|
validationChecks?: ValidationChecksSpec
|
|
132
|
+
/**
|
|
133
|
+
* BUGFIX REPRODUCTION PROOF: the run's declared reproduction command + test files. When set, the
|
|
134
|
+
* harness runs that command against the pre-fix tree AND the tree the PR will open from, feeding
|
|
135
|
+
* a failed verification back to the agent while budget remains, and attaches the verdict to the
|
|
136
|
+
* outcome. Unlike {@link validationChecks} it NEVER gates the pull request — an unproven
|
|
137
|
+
* reproduction is weak evidence, which is a reviewer's call, not a machine's. Set only for a
|
|
138
|
+
* dispatch that opens a PR and whose run carries a declaration. See
|
|
139
|
+
* `docs/initiatives/bugfix-reproduction-proof.md`.
|
|
140
|
+
*/
|
|
141
|
+
reproduction?: ReproductionSpec
|
|
120
142
|
/**
|
|
121
143
|
* A repo-sourced Claude Skill to make available for this run (a `skill` step, slice 2). Threaded
|
|
122
144
|
* into {@link runAgentInWorkspace}, which installs it harness-aware: natively under the ISOLATED
|
|
@@ -141,6 +163,12 @@ export interface CodingAgentOutcome {
|
|
|
141
163
|
callMetrics?: HarnessCallMetric[]
|
|
142
164
|
/** The agent's effort self-assessment, lifted from its sentinel file (absent when it wrote none). */
|
|
143
165
|
effortReport?: EffortReport
|
|
166
|
+
/**
|
|
167
|
+
* The agent-authored PR description, lifted from its sentinel file (absent when it wrote none).
|
|
168
|
+
* The PR-opening caller folds it over the dispatch-time title/body via {@link applyPrDescription};
|
|
169
|
+
* absent means the fallback text, unchanged.
|
|
170
|
+
*/
|
|
171
|
+
prDescription?: AgentPrDescription
|
|
144
172
|
/**
|
|
145
173
|
* Ralph loop: the verdict of the post-commit validation command (whether it exited 0, the
|
|
146
174
|
* exit code, and a bounded/redacted output tail). Present only when {@link CodingAgentSpec.validation}
|
|
@@ -158,6 +186,12 @@ export interface CodingAgentOutcome {
|
|
|
158
186
|
* the caller must open no PR and fail the job with this as the evidence.
|
|
159
187
|
*/
|
|
160
188
|
validationReport?: ValidationReport
|
|
189
|
+
/**
|
|
190
|
+
* The bugfix reproduction proof's LAST attempt (present only when
|
|
191
|
+
* {@link CodingAgentSpec.reproduction} was set). Evidence, never a gate: `inconclusive` is
|
|
192
|
+
* attached to a perfectly successful run and the PR still opens.
|
|
193
|
+
*/
|
|
194
|
+
reproductionReport?: ReproductionReport
|
|
161
195
|
}
|
|
162
196
|
|
|
163
197
|
/**
|
|
@@ -273,6 +307,9 @@ export async function runCodingAgent(
|
|
|
273
307
|
// but that cannot un-stage a mid-run commit; the per-clone exclude is what prevents it. A bare
|
|
274
308
|
// filename pattern matches the file in any subdirectory, so it covers a monorepo `workDir` too.
|
|
275
309
|
await excludeFromGit(dir, EFFORT_REPORT_FILE, signal)
|
|
310
|
+
// Same treatment for the agent-authored PR-description sentinel: excluded locally so the
|
|
311
|
+
// agent's own `git add` can never stage the briefing into the PR it describes.
|
|
312
|
+
await excludeFromGit(dir, PR_DESCRIPTION_FILE, signal)
|
|
276
313
|
|
|
277
314
|
// Follow-up companion: tail the Coder's sentinel file and stream new items out on the
|
|
278
315
|
// job view. Locally exclude it from git first so the agent's own `git add` can never
|
|
@@ -323,6 +360,64 @@ export async function runCodingAgent(
|
|
|
323
360
|
opts.onPhase?.('agent')
|
|
324
361
|
logger.info('coding-agent: running agent', { serviceDirectory })
|
|
325
362
|
let agentRun = await runAgentPass(spec.userPrompt)
|
|
363
|
+
const foldPass = (run: typeof agentRun): void => {
|
|
364
|
+
agentRun = mergeAgentPasses(agentRun, run)
|
|
365
|
+
}
|
|
366
|
+
// The new files the agent left unadded, folded into either loop's repair prompt. Both
|
|
367
|
+
// loops judge state the push will NOT carry unless it is committed — the checks run
|
|
368
|
+
// against the working tree, the proof against committed trees — so an unadded file is
|
|
369
|
+
// exactly the thing to name. A throw degrades to "no warning" inside each loop.
|
|
370
|
+
const listUncommittedNewFiles = (): Promise<string[]> =>
|
|
371
|
+
listUntrackedFiles(workDir, opts.signal)
|
|
372
|
+
|
|
373
|
+
// BUGFIX REPRODUCTION PROOF: run the run's declared reproduction command against the
|
|
374
|
+
// pre-fix tree and the tree the PR will open from, and record whether it was red then
|
|
375
|
+
// green. Runs BEFORE the validation loop below, deliberately: validation is the GATE
|
|
376
|
+
// ("only a green checkout opens a PR"), so it has to stay the last thing that touches the
|
|
377
|
+
// tree — otherwise a reproduction repair round could leave the checkout red behind it and
|
|
378
|
+
// the PR would open anyway. Keyed purely off the job body carrying a spec (no agent-kind
|
|
379
|
+
// switch); absent ⇒ a no-op and the flow below is byte-for-byte what it was.
|
|
380
|
+
const reproduction = spec.reproduction
|
|
381
|
+
let reproductionReport: ReproductionReport | undefined
|
|
382
|
+
if (reproduction && (await producedWork(dir, spec, baseSha, resumed, opts))) {
|
|
383
|
+
opts.onPhase?.('reproduction')
|
|
384
|
+
reproductionReport = await runReproductionLoop({
|
|
385
|
+
dir,
|
|
386
|
+
baseSha,
|
|
387
|
+
// Re-read per attempt: a repair pass commits, so the final tree moves under the loop.
|
|
388
|
+
// `producedWork` has already committed forgotten tracked edits, and each repair round
|
|
389
|
+
// re-commits before the next read.
|
|
390
|
+
resolveFinalSha: async () => {
|
|
391
|
+
await commitTrackedEdits(dir, spec.commitMessage, signal)
|
|
392
|
+
return headCommit(dir, signal)
|
|
393
|
+
},
|
|
394
|
+
...(serviceDirectory ? { serviceDirectory } : {}),
|
|
395
|
+
spec: reproduction,
|
|
396
|
+
logger,
|
|
397
|
+
opts,
|
|
398
|
+
runAgentPass,
|
|
399
|
+
onAgentPass: foldPass,
|
|
400
|
+
listUncommittedNewFiles,
|
|
401
|
+
// Only a RESUMED run can have a pre-fix tree that already carries work: a fresh run
|
|
402
|
+
// branched off base, so `baseSha` IS base. Wiring the probe unconditionally would buy
|
|
403
|
+
// an always-empty answer for the price of a fetch — and a fresh clone is shallow, so
|
|
404
|
+
// it could not resolve a merge base to answer with anyway. Lazy inside the loop: it
|
|
405
|
+
// only runs if a tree comes back green.
|
|
406
|
+
...(resumed
|
|
407
|
+
? {
|
|
408
|
+
listBaseTreeChanges: () =>
|
|
409
|
+
changedFilesSinceBase(
|
|
410
|
+
dir,
|
|
411
|
+
spec.repo.baseBranch,
|
|
412
|
+
spec.ghToken,
|
|
413
|
+
baseSha,
|
|
414
|
+
opts.signal,
|
|
415
|
+
),
|
|
416
|
+
}
|
|
417
|
+
: {}),
|
|
418
|
+
})
|
|
419
|
+
opts.onPhase?.('agent')
|
|
420
|
+
}
|
|
326
421
|
// PRE-PR VALIDATION: run the service's configured checks against the checkout and, while
|
|
327
422
|
// they fail and budget remains, hand the captured output back to the agent and run it
|
|
328
423
|
// again. Sits BETWEEN the agent and the finalize/push/PR step so a red checkout never
|
|
@@ -338,17 +433,16 @@ export async function runCodingAgent(
|
|
|
338
433
|
logger,
|
|
339
434
|
opts,
|
|
340
435
|
runAgentPass,
|
|
341
|
-
onAgentPass:
|
|
342
|
-
agentRun = mergeAgentPasses(agentRun, run)
|
|
343
|
-
},
|
|
436
|
+
onAgentPass: foldPass,
|
|
344
437
|
// The checks run against the WORKING TREE, but only tracked edits are staged for the
|
|
345
438
|
// push — so a repair round can go green on a new file the PR would never contain.
|
|
346
439
|
// Name those files in the next repair prompt so the agent adds them.
|
|
347
|
-
listUncommittedNewFiles
|
|
440
|
+
listUncommittedNewFiles,
|
|
348
441
|
})
|
|
349
442
|
}
|
|
350
443
|
outcome = await finalizeCodingRun({
|
|
351
444
|
validationReport,
|
|
445
|
+
reproductionReport,
|
|
352
446
|
dir,
|
|
353
447
|
spec,
|
|
354
448
|
logger,
|
|
@@ -499,6 +593,8 @@ async function prepareCodingCheckout(
|
|
|
499
593
|
async function finalizeCodingRun(args: {
|
|
500
594
|
/** The pre-PR validation loop's last attempt, attached to the outcome (absent when unconfigured). */
|
|
501
595
|
validationReport?: ValidationReport
|
|
596
|
+
/** The reproduction proof's last attempt, attached to the outcome (absent when unconfigured). */
|
|
597
|
+
reproductionReport?: ReproductionReport
|
|
502
598
|
dir: string
|
|
503
599
|
spec: CodingAgentSpec
|
|
504
600
|
logger: Logger
|
|
@@ -515,6 +611,7 @@ async function finalizeCodingRun(args: {
|
|
|
515
611
|
}): Promise<CodingAgentOutcome> {
|
|
516
612
|
const {
|
|
517
613
|
validationReport,
|
|
614
|
+
reproductionReport,
|
|
518
615
|
dir,
|
|
519
616
|
spec,
|
|
520
617
|
logger,
|
|
@@ -542,6 +639,14 @@ async function finalizeCodingRun(args: {
|
|
|
542
639
|
// untracked scratch files/artifacts — the agent owns committing new files).
|
|
543
640
|
await commitTrackedEdits(dir, spec.commitMessage, signal)
|
|
544
641
|
|
|
642
|
+
// The agent-authored PR description, read AFTER the validation loop (a repair round may have
|
|
643
|
+
// changed what the briefing should say) and removed so it never lingers in the checkout. The
|
|
644
|
+
// prompt asks for it at the top level of the checkout; a monorepo agent working in a service
|
|
645
|
+
// subdirectory may drop it in its cwd instead, so probe the checkout root first, then the cwd.
|
|
646
|
+
const prDescription =
|
|
647
|
+
(await readPrDescription(dir)) ??
|
|
648
|
+
(workDir !== dir ? await readPrDescription(workDir) : undefined)
|
|
649
|
+
|
|
545
650
|
// Stop periodic checkpoints and let any in-flight one settle BEFORE the final
|
|
546
651
|
// push, so the two never run a concurrent `git push` to the same branch (the
|
|
547
652
|
// final push below is then a fresh attempt whose failure is the real signal).
|
|
@@ -604,6 +709,7 @@ async function finalizeCodingRun(args: {
|
|
|
604
709
|
...(usage ? { usage } : {}),
|
|
605
710
|
...(callMetrics ? { callMetrics } : {}),
|
|
606
711
|
...(effortReport ? { effortReport } : {}),
|
|
712
|
+
...(prDescription ? { prDescription } : {}),
|
|
607
713
|
}
|
|
608
714
|
}
|
|
609
715
|
|
|
@@ -618,6 +724,9 @@ async function finalizeCodingRun(args: {
|
|
|
618
724
|
// reaches the PR-opening caller); attach its verdict for the caller to gate on and for the
|
|
619
725
|
// backend to record on the step.
|
|
620
726
|
if (validationReport) outcome.validationReport = validationReport
|
|
727
|
+
// The reproduction proof: attached to EVERY outcome, including a no-op or an `inconclusive`
|
|
728
|
+
// verdict. It is evidence about the change, not a gate on it — see the loop's D6 note.
|
|
729
|
+
if (reproductionReport) outcome.reproductionReport = reproductionReport
|
|
621
730
|
return outcome
|
|
622
731
|
}
|
|
623
732
|
|
|
@@ -932,6 +1041,7 @@ export async function runMultiRepoCoding(
|
|
|
932
1041
|
job,
|
|
933
1042
|
logger,
|
|
934
1043
|
opts,
|
|
1044
|
+
root,
|
|
935
1045
|
)
|
|
936
1046
|
|
|
937
1047
|
const anyWork = primaryPushed || peerPullRequests.length > 0
|
|
@@ -1044,6 +1154,9 @@ async function prepareMultiRepoCheckouts(
|
|
|
1044
1154
|
await createBranch(dir, leg.workBranch, signal)
|
|
1045
1155
|
}
|
|
1046
1156
|
leg.dir = dir
|
|
1157
|
+
// Exclude the agent-authored PR-description sentinel locally (as the single-repo path does)
|
|
1158
|
+
// so the agent's own `git add` can never stage the briefing into the PR it describes.
|
|
1159
|
+
await excludeFromGit(dir, PR_DESCRIPTION_FILE, signal)
|
|
1047
1160
|
// The branch tip before the agent runs. Captured BEFORE the resume base refresh below so
|
|
1048
1161
|
// that refresh's merge commit counts as advancement and is pushed (as in the single-repo
|
|
1049
1162
|
// path). A fresh leg produced work iff its branch advances past this; a resumed leg already
|
|
@@ -1102,6 +1215,8 @@ async function pushMultiRepoLegs(
|
|
|
1102
1215
|
job: AgentJob,
|
|
1103
1216
|
logger: Logger,
|
|
1104
1217
|
opts: RunOptions,
|
|
1218
|
+
/** The workspace root the agent ran in — the fallback probe for the primary's briefing. */
|
|
1219
|
+
root: string,
|
|
1105
1220
|
): Promise<{
|
|
1106
1221
|
primaryPushed: boolean
|
|
1107
1222
|
primaryPrUrl: string | undefined
|
|
@@ -1116,6 +1231,15 @@ async function pushMultiRepoLegs(
|
|
|
1116
1231
|
// A read-only reference leg is never committed or pushed — the third layer of the read-only
|
|
1117
1232
|
// guarantee (the spec carries no branch/PR, and the clone phase gave it no work branch).
|
|
1118
1233
|
if (leg.readOnly) continue
|
|
1234
|
+
// Lift (and remove) the agent-authored PR description for THIS repo's PR before anything
|
|
1235
|
+
// else touches the checkout — each sibling checkout carries its own briefing for its own PR.
|
|
1236
|
+
// The agent's cwd here is the WORKSPACE ROOT rather than any one checkout, so an agent that
|
|
1237
|
+
// read the prompt loosely may well have written a single briefing there instead. Fall back
|
|
1238
|
+
// to it for the PRIMARY leg only: at the root there is nothing to say which repo it
|
|
1239
|
+
// describes, and the primary is the one the run is actually about.
|
|
1240
|
+
const agentPrDescription =
|
|
1241
|
+
(await readPrDescription(leg.dir)) ??
|
|
1242
|
+
(leg.primary ? await readPrDescription(root) : undefined)
|
|
1119
1243
|
await commitTrackedEdits(leg.dir, job.commitMessage ?? leg.pr?.title ?? 'Agent changes', signal)
|
|
1120
1244
|
const advanced = await branchHasCommitsSince(leg.dir, leg.baseSha, signal)
|
|
1121
1245
|
let hasWork = advanced || leg.resumed
|
|
@@ -1144,7 +1268,10 @@ async function pushMultiRepoLegs(
|
|
|
1144
1268
|
ghToken: leg.ghToken,
|
|
1145
1269
|
head: leg.workBranch,
|
|
1146
1270
|
base: leg.repo.baseBranch,
|
|
1147
|
-
pr: leg.pr,
|
|
1271
|
+
pr: applyPrDescription(leg.pr, agentPrDescription),
|
|
1272
|
+
// See the single-repo call site: refresh a resumed leg's already-open PR, but only
|
|
1273
|
+
// when the text is the agent's own briefing rather than the dispatch-time fallback.
|
|
1274
|
+
...(agentPrDescription ? { refreshExisting: true } : {}),
|
|
1148
1275
|
apiBase: job.githubApiBase,
|
|
1149
1276
|
cloneUrl: leg.repo.cloneUrl,
|
|
1150
1277
|
...(leg.repo.provider ? { provider: leg.repo.provider } : {}),
|