@cat-factory/executor-harness 1.66.0 → 1.70.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 +39 -1
- package/dist/agent-capabilities.js +354 -0
- package/dist/agent-runner.js +72 -11
- package/dist/agent-shared.js +23 -0
- package/dist/agent.js +14 -139
- package/dist/bootstrap-mode.js +142 -0
- package/dist/coding-agent.js +87 -67
- package/dist/job.js +8 -75
- package/dist/pi-workspace.js +25 -16
- package/dist/pi.js +81 -16
- package/dist/runner.js +8 -0
- package/dist/structured-output.js +13 -2
- package/package.json +4 -4
- package/src/agent-capabilities.ts +414 -0
- package/src/agent-runner.ts +97 -26
- package/src/agent-shared.ts +34 -0
- package/src/agent.ts +13 -165
- package/src/bootstrap-mode.ts +175 -0
- package/src/coding-agent.ts +106 -71
- package/src/job.ts +53 -93
- package/src/pi-workspace.ts +46 -21
- package/src/pi.ts +95 -16
- package/src/runner.ts +17 -0
- package/src/structured-output.ts +24 -2
package/dist/agent.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { join } from 'node:path';
|
|
2
2
|
import { tmpdir } from 'node:os';
|
|
3
|
-
import { mkdir, mkdtemp,
|
|
3
|
+
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
|
4
4
|
import { execFile } from 'node:child_process';
|
|
5
5
|
import { promisify } from 'node:util';
|
|
6
6
|
import { standUpFrontend, tearDownFrontend } from './frontend-infra.js';
|
|
7
7
|
import { configurePackageRegistries } from './package-registries.js';
|
|
8
8
|
import { captureRedactedOutput, redactSecrets, registerKnownSecrets } from './redact.js';
|
|
9
|
-
import { cloneRepo, commitAll, conflictDiff, fetchPullRequestHead, fetchReferenceBranches,
|
|
9
|
+
import { cloneRepo, commitAll, conflictDiff, fetchPullRequestHead, fetchReferenceBranches, headCommit, mergeBranch, prepareExistingCheckout, pushBranch, unmergedPaths, } from './git.js';
|
|
10
10
|
import { inferVcsProvider, openPullRequest } from './vcs-api.js';
|
|
11
11
|
import { applyPrDescription } from './pr-description.js';
|
|
12
12
|
import { makeDirClaimer, noChangesReason, runCodingAgent, runMultiRepoCoding, } from './coding-agent.js';
|
|
13
13
|
import { validationFailureMessage } from './validation-checks.js';
|
|
14
|
+
import { agentCapabilities, mergeEffort } from './agent-shared.js';
|
|
15
|
+
import { runBootstrap } from './bootstrap-mode.js';
|
|
14
16
|
import { acquireRepoCheckout, agentNeverActed, agentOutputTail, NEVER_ACTED_CAUSE, runAgentInWorkspace, unusableFinalAnswerCause, withWorkspace, } from './pi-workspace.js';
|
|
15
17
|
import { diagnosticsSuffix, resolveStructuredOutput, } from './structured-output.js';
|
|
16
18
|
import { log } from './logger.js';
|
|
@@ -181,6 +183,7 @@ async function resolveReplyCustom(job, summary, signal) {
|
|
|
181
183
|
subscriptionToken: job.subscriptionToken,
|
|
182
184
|
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
183
185
|
proxyBaseUrl: job.proxyBaseUrl,
|
|
186
|
+
proxyPhasePath: job.proxyPhasePath,
|
|
184
187
|
sessionToken: job.sessionToken,
|
|
185
188
|
model: job.model,
|
|
186
189
|
jobId: job.jobId,
|
|
@@ -231,14 +234,6 @@ async function cloneServiceCheckout(dir, job, signal) {
|
|
|
231
234
|
});
|
|
232
235
|
return deriveWorkDir(dir, job.repo.serviceDirectory);
|
|
233
236
|
}
|
|
234
|
-
/**
|
|
235
|
-
* Fold an agent's effort self-assessment (lifted from its sentinel file by `runAgentInWorkspace`)
|
|
236
|
-
* onto its final result. Every container mode routes its result through this so the report reaches
|
|
237
|
-
* the backend uniformly. A run that wrote no report passes through unchanged.
|
|
238
|
-
*/
|
|
239
|
-
function mergeEffort(result, effortReport) {
|
|
240
|
-
return effortReport ? { ...result, effortReport } : result;
|
|
241
|
-
}
|
|
242
237
|
/** Run one generic agent job end to end, dispatching on `mode`. */
|
|
243
238
|
export async function handleAgent(job, opts = {}) {
|
|
244
239
|
// An `ambientAuth` job runs in the SHARED native host process on the developer's own HOME
|
|
@@ -494,6 +489,7 @@ async function runExploreMode(job, opts) {
|
|
|
494
489
|
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
495
490
|
ambientAuth: job.ambientAuth,
|
|
496
491
|
proxyBaseUrl: job.proxyBaseUrl,
|
|
492
|
+
proxyPhasePath: job.proxyPhasePath,
|
|
497
493
|
sessionToken: job.sessionToken,
|
|
498
494
|
serviceDirectory,
|
|
499
495
|
// Read-only: it inspects and reports, making no edits — so the no-progress
|
|
@@ -503,6 +499,7 @@ async function runExploreMode(job, opts) {
|
|
|
503
499
|
webSearchProxy: job.webSearch,
|
|
504
500
|
contextFiles: job.contextFiles,
|
|
505
501
|
guardLimits: job.guardLimits,
|
|
502
|
+
...agentCapabilities(job),
|
|
506
503
|
}, agentOpts);
|
|
507
504
|
return mergeEffort(await finalizeExploreResult(job, { summary, stats, stderrTail, usage, callMetrics, runDiag }, { infra, infraSetupFields, logger, signal: opts.signal }), effortReport);
|
|
508
505
|
}
|
|
@@ -683,6 +680,7 @@ async function runMultiRepoExplore(job, opts) {
|
|
|
683
680
|
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
684
681
|
ambientAuth: job.ambientAuth,
|
|
685
682
|
proxyBaseUrl: job.proxyBaseUrl,
|
|
683
|
+
proxyPhasePath: job.proxyPhasePath,
|
|
686
684
|
sessionToken: job.sessionToken,
|
|
687
685
|
// Read-only: no edits expected, so the no-progress guard's no-edit bound must not fire.
|
|
688
686
|
expectsEdits: false,
|
|
@@ -690,6 +688,7 @@ async function runMultiRepoExplore(job, opts) {
|
|
|
690
688
|
webSearchProxy: job.webSearch,
|
|
691
689
|
...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
|
|
692
690
|
guardLimits: job.guardLimits,
|
|
691
|
+
...agentCapabilities(job),
|
|
693
692
|
multiRepo: true,
|
|
694
693
|
}, opts);
|
|
695
694
|
return mergeEffort(await finalizeExploreResult(job, {
|
|
@@ -784,6 +783,7 @@ function buildSingleRepoCodingSpec(job, pushBranch) {
|
|
|
784
783
|
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
785
784
|
ambientAuth: job.ambientAuth,
|
|
786
785
|
proxyBaseUrl: job.proxyBaseUrl,
|
|
786
|
+
proxyPhasePath: job.proxyPhasePath,
|
|
787
787
|
sessionToken: job.sessionToken,
|
|
788
788
|
commitMessage: job.commitMessage ?? job.pr?.title ?? 'Agent changes',
|
|
789
789
|
webToolsGuidance: job.webToolsGuidance,
|
|
@@ -792,8 +792,8 @@ function buildSingleRepoCodingSpec(job, pushBranch) {
|
|
|
792
792
|
...(job.persistentCheckout ? { persistentCheckout: true } : {}),
|
|
793
793
|
...(job.streamFollowUps ? { streamFollowUps: true } : {}),
|
|
794
794
|
...(job.referenceBranches?.length ? { referenceBranches: job.referenceBranches } : {}),
|
|
795
|
-
//
|
|
796
|
-
...(job
|
|
795
|
+
// Skills + tool servers: installed/wired harness-aware by runAgentInWorkspace.
|
|
796
|
+
...agentCapabilities(job),
|
|
797
797
|
// Ralph loop: run the completion command after the agent commits and report its verdict.
|
|
798
798
|
...(job.validation
|
|
799
799
|
? {
|
|
@@ -1036,9 +1036,11 @@ async function runConflictResolution(job, opts) {
|
|
|
1036
1036
|
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
1037
1037
|
ambientAuth: job.ambientAuth,
|
|
1038
1038
|
proxyBaseUrl: job.proxyBaseUrl,
|
|
1039
|
+
proxyPhasePath: job.proxyPhasePath,
|
|
1039
1040
|
sessionToken: job.sessionToken,
|
|
1040
1041
|
contextFiles: job.contextFiles,
|
|
1041
1042
|
guardLimits: job.guardLimits,
|
|
1043
|
+
...agentCapabilities(job),
|
|
1042
1044
|
}, opts);
|
|
1043
1045
|
// Never push a half-resolved tree: if any conflict markers / unmerged paths remain,
|
|
1044
1046
|
// the PR would still be broken. Fail so the engine can retry / notify.
|
|
@@ -1115,133 +1117,6 @@ function unresolvedReason(unresolved, stats, stderrTail) {
|
|
|
1115
1117
|
`(${unresolved.length} file(s) still conflicted: ${sample}).${cause}` +
|
|
1116
1118
|
agentOutputTail(stderrTail));
|
|
1117
1119
|
}
|
|
1118
|
-
/**
|
|
1119
|
-
* Repo-bootstrap coding flow (the bootstrapper): with a reference architecture, clone it →
|
|
1120
|
-
* the agent adapts it in place per the instructions; without one (`fromScratch`), start from
|
|
1121
|
-
* an empty directory → the agent scaffolds the new service. Either way the result's history
|
|
1122
|
-
* is reset to a single commit and force-pushed to the SEPARATE, pre-created target repo's
|
|
1123
|
-
* default branch. Diverges from the ordinary coding flow in pushing to a different repo with
|
|
1124
|
-
* a reinitialised history rather than a work branch + PR on the cloned repo.
|
|
1125
|
-
*/
|
|
1126
|
-
async function runBootstrap(job, opts) {
|
|
1127
|
-
const { signal } = opts;
|
|
1128
|
-
const boot = job.bootstrap;
|
|
1129
|
-
const fromScratch = boot.fromScratch === true;
|
|
1130
|
-
const logger = (opts.log ?? log).child({ target: `${boot.target.owner}/${boot.target.name}` });
|
|
1131
|
-
return withWorkspace('boot', async (dir) => {
|
|
1132
|
-
if (!fromScratch) {
|
|
1133
|
-
opts.onPhase?.('clone');
|
|
1134
|
-
logger.info('agent(bootstrap): cloning reference architecture', {
|
|
1135
|
-
reference: `${job.repo.owner}/${job.repo.name}`,
|
|
1136
|
-
});
|
|
1137
|
-
await cloneRepo({
|
|
1138
|
-
repo: { ...job.repo, baseBranch: job.branch },
|
|
1139
|
-
ghToken: job.ghToken,
|
|
1140
|
-
dir,
|
|
1141
|
-
signal,
|
|
1142
|
-
});
|
|
1143
|
-
}
|
|
1144
|
-
else {
|
|
1145
|
-
logger.info('agent(bootstrap): scaffolding from scratch (no reference)');
|
|
1146
|
-
}
|
|
1147
|
-
opts.onPhase?.('agent');
|
|
1148
|
-
logger.info('agent(bootstrap): running agent');
|
|
1149
|
-
const { summary, stats, stderrTail, usage, callMetrics, effortReport } = await runAgentInWorkspace({
|
|
1150
|
-
dir,
|
|
1151
|
-
systemPrompt: job.systemPrompt,
|
|
1152
|
-
userPrompt: job.userPrompt,
|
|
1153
|
-
model: job.model,
|
|
1154
|
-
harness: job.harness,
|
|
1155
|
-
subscriptionToken: job.subscriptionToken,
|
|
1156
|
-
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
1157
|
-
ambientAuth: job.ambientAuth,
|
|
1158
|
-
proxyBaseUrl: job.proxyBaseUrl,
|
|
1159
|
-
sessionToken: job.sessionToken,
|
|
1160
|
-
guardLimits: job.guardLimits,
|
|
1161
|
-
}, opts);
|
|
1162
|
-
// Guard against a no-op run: Pi can exit cleanly having done nothing (e.g. it never
|
|
1163
|
-
// reached the model), and a force-push would then publish an empty tree — leaving the
|
|
1164
|
-
// run "succeeded" but the repo bare. Fail with a structured error (carrying what the
|
|
1165
|
-
// agent did) instead of pushing nothing.
|
|
1166
|
-
if (!(await producedRepoContent(dir, !fromScratch, signal))) {
|
|
1167
|
-
const error = bootstrapNoOpReason(!fromScratch, stats, summary, stderrTail);
|
|
1168
|
-
logger.error('agent(bootstrap): agent produced no content, refusing to push', { ...stats });
|
|
1169
|
-
return mergeEffort({
|
|
1170
|
-
summary,
|
|
1171
|
-
stats,
|
|
1172
|
-
error,
|
|
1173
|
-
failureCause: 'agent',
|
|
1174
|
-
...(usage ? { usage } : {}),
|
|
1175
|
-
...(callMetrics ? { callMetrics } : {}),
|
|
1176
|
-
}, effortReport);
|
|
1177
|
-
}
|
|
1178
|
-
opts.onPhase?.('push');
|
|
1179
|
-
logger.info('agent(bootstrap): pushing bootstrapped contents', { ...stats });
|
|
1180
|
-
// Bootstrap always resets history to one commit + force-pushes (the fresh history
|
|
1181
|
-
// shares no ancestor with whatever boilerplate the new repo was created with).
|
|
1182
|
-
await reinitAndPush({
|
|
1183
|
-
dir,
|
|
1184
|
-
target: boot.target,
|
|
1185
|
-
ghToken: job.ghToken,
|
|
1186
|
-
message: fromScratch
|
|
1187
|
-
? 'Bootstrap new repository'
|
|
1188
|
-
: `Bootstrap from ${job.repo.owner}/${job.repo.name}`,
|
|
1189
|
-
});
|
|
1190
|
-
logger.info('agent(bootstrap): complete', { defaultBranch: boot.target.defaultBranch });
|
|
1191
|
-
return mergeEffort({
|
|
1192
|
-
defaultBranch: boot.target.defaultBranch,
|
|
1193
|
-
summary,
|
|
1194
|
-
stats,
|
|
1195
|
-
...(usage ? { usage } : {}),
|
|
1196
|
-
...(callMetrics ? { callMetrics } : {}),
|
|
1197
|
-
}, effortReport);
|
|
1198
|
-
});
|
|
1199
|
-
}
|
|
1200
|
-
/**
|
|
1201
|
-
* Whether the bootstrapper actually produced repository content, so a no-op run (the agent
|
|
1202
|
-
* never reached the model / never wrote anything) is failed rather than force-pushed as an
|
|
1203
|
-
* empty repo. With a reference architecture, "produced content" means the agent changed the
|
|
1204
|
-
* clone; scaffolding from scratch, it means at least one file now exists in the working
|
|
1205
|
-
* directory. (The harness writes its prompt context to Pi's global `~/.pi/agent/AGENTS.md`,
|
|
1206
|
-
* never into `dir`, so nothing here needs to be filtered out as harness boilerplate.)
|
|
1207
|
-
*/
|
|
1208
|
-
export async function producedRepoContent(dir, hasReference, signal) {
|
|
1209
|
-
if (hasReference)
|
|
1210
|
-
return hasAgentChanges(dir, signal);
|
|
1211
|
-
return containsAnyFile(dir);
|
|
1212
|
-
}
|
|
1213
|
-
/**
|
|
1214
|
-
* Whether `dir` contains at least one regular file anywhere in its tree, walking
|
|
1215
|
-
* depth-first and stopping at the FIRST file found — so the cost is bounded by how
|
|
1216
|
-
* quickly a file turns up (a scaffold almost always writes a root-level file), not by
|
|
1217
|
-
* the size of the produced tree (a full recursive `readdir` would materialise every
|
|
1218
|
-
* entry before the check).
|
|
1219
|
-
*/
|
|
1220
|
-
async function containsAnyFile(dir) {
|
|
1221
|
-
const handle = await opendir(dir);
|
|
1222
|
-
try {
|
|
1223
|
-
for await (const entry of handle) {
|
|
1224
|
-
if (entry.isFile())
|
|
1225
|
-
return true;
|
|
1226
|
-
if (entry.isDirectory() && (await containsAnyFile(join(dir, entry.name))))
|
|
1227
|
-
return true;
|
|
1228
|
-
}
|
|
1229
|
-
}
|
|
1230
|
-
catch {
|
|
1231
|
-
// A directory that vanished mid-walk has nothing to contribute.
|
|
1232
|
-
}
|
|
1233
|
-
return false;
|
|
1234
|
-
}
|
|
1235
|
-
/** Human-readable bootstrap no-op reason, embedding what the agent did so the cause is visible. */
|
|
1236
|
-
function bootstrapNoOpReason(hasReference, stats, summary, stderrTail) {
|
|
1237
|
-
const what = hasReference
|
|
1238
|
-
? 'made no changes to the reference architecture'
|
|
1239
|
-
: 'scaffolded no files';
|
|
1240
|
-
const cause = agentNeverActed(stats) ? NEVER_ACTED_CAUSE : '';
|
|
1241
|
-
return (`the bootstrapper agent ${what} ` +
|
|
1242
|
-
`(tool calls: ${stats.toolCalls}, assistant output: ${stats.assistantChars} chars).${cause}` +
|
|
1243
|
-
agentOutputTail(stderrTail, summary));
|
|
1244
|
-
}
|
|
1245
1120
|
/** Human-readable reason a read-only run produced no usable output. */
|
|
1246
1121
|
function noOutputReason(stats, stderrTail) {
|
|
1247
1122
|
const cause = agentNeverActed(stats)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { opendir } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { NEVER_ACTED_CAUSE, agentNeverActed, agentOutputTail, runAgentInWorkspace, withWorkspace, } from './pi-workspace.js';
|
|
4
|
+
import { cloneRepo, hasAgentChanges, reinitAndPush } from './git.js';
|
|
5
|
+
import { log } from './logger.js';
|
|
6
|
+
import { agentCapabilities, mergeEffort } from './agent-shared.js';
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// The repo-BOOTSTRAP mode: adapt a reference architecture (or scaffold from scratch) into a
|
|
9
|
+
// pre-created empty repo and force-push it as a single commit. Extracted from `agent.ts` as a
|
|
10
|
+
// cohesive collaborator — it is a whole MODE with its own push semantics (a separate target repo
|
|
11
|
+
// and a reinitialised history, not a work branch + PR), and it shares only the small agent-run
|
|
12
|
+
// helpers in `agent-shared.ts` with the coding/explore flows.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
/**
|
|
15
|
+
* Repo-bootstrap coding flow (the bootstrapper): with a reference architecture, clone it →
|
|
16
|
+
* the agent adapts it in place per the instructions; without one (`fromScratch`), start from
|
|
17
|
+
* an empty directory → the agent scaffolds the new service. Either way the result's history
|
|
18
|
+
* is reset to a single commit and force-pushed to the SEPARATE, pre-created target repo's
|
|
19
|
+
* default branch. Diverges from the ordinary coding flow in pushing to a different repo with
|
|
20
|
+
* a reinitialised history rather than a work branch + PR on the cloned repo.
|
|
21
|
+
*/
|
|
22
|
+
export async function runBootstrap(job, opts) {
|
|
23
|
+
const { signal } = opts;
|
|
24
|
+
const boot = job.bootstrap;
|
|
25
|
+
const fromScratch = boot.fromScratch === true;
|
|
26
|
+
const logger = (opts.log ?? log).child({ target: `${boot.target.owner}/${boot.target.name}` });
|
|
27
|
+
return withWorkspace('boot', async (dir) => {
|
|
28
|
+
if (!fromScratch) {
|
|
29
|
+
opts.onPhase?.('clone');
|
|
30
|
+
logger.info('agent(bootstrap): cloning reference architecture', {
|
|
31
|
+
reference: `${job.repo.owner}/${job.repo.name}`,
|
|
32
|
+
});
|
|
33
|
+
await cloneRepo({
|
|
34
|
+
repo: { ...job.repo, baseBranch: job.branch },
|
|
35
|
+
ghToken: job.ghToken,
|
|
36
|
+
dir,
|
|
37
|
+
signal,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
else {
|
|
41
|
+
logger.info('agent(bootstrap): scaffolding from scratch (no reference)');
|
|
42
|
+
}
|
|
43
|
+
opts.onPhase?.('agent');
|
|
44
|
+
logger.info('agent(bootstrap): running agent');
|
|
45
|
+
const { summary, stats, stderrTail, usage, callMetrics, effortReport } = await runAgentInWorkspace({
|
|
46
|
+
dir,
|
|
47
|
+
systemPrompt: job.systemPrompt,
|
|
48
|
+
userPrompt: job.userPrompt,
|
|
49
|
+
model: job.model,
|
|
50
|
+
harness: job.harness,
|
|
51
|
+
subscriptionToken: job.subscriptionToken,
|
|
52
|
+
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
53
|
+
ambientAuth: job.ambientAuth,
|
|
54
|
+
proxyBaseUrl: job.proxyBaseUrl,
|
|
55
|
+
proxyPhasePath: job.proxyPhasePath,
|
|
56
|
+
sessionToken: job.sessionToken,
|
|
57
|
+
guardLimits: job.guardLimits,
|
|
58
|
+
...agentCapabilities(job),
|
|
59
|
+
}, opts);
|
|
60
|
+
// Guard against a no-op run: Pi can exit cleanly having done nothing (e.g. it never
|
|
61
|
+
// reached the model), and a force-push would then publish an empty tree — leaving the
|
|
62
|
+
// run "succeeded" but the repo bare. Fail with a structured error (carrying what the
|
|
63
|
+
// agent did) instead of pushing nothing.
|
|
64
|
+
if (!(await producedRepoContent(dir, !fromScratch, signal))) {
|
|
65
|
+
const error = bootstrapNoOpReason(!fromScratch, stats, summary, stderrTail);
|
|
66
|
+
logger.error('agent(bootstrap): agent produced no content, refusing to push', { ...stats });
|
|
67
|
+
return mergeEffort({
|
|
68
|
+
summary,
|
|
69
|
+
stats,
|
|
70
|
+
error,
|
|
71
|
+
failureCause: 'agent',
|
|
72
|
+
...(usage ? { usage } : {}),
|
|
73
|
+
...(callMetrics ? { callMetrics } : {}),
|
|
74
|
+
}, effortReport);
|
|
75
|
+
}
|
|
76
|
+
opts.onPhase?.('push');
|
|
77
|
+
logger.info('agent(bootstrap): pushing bootstrapped contents', { ...stats });
|
|
78
|
+
// Bootstrap always resets history to one commit + force-pushes (the fresh history
|
|
79
|
+
// shares no ancestor with whatever boilerplate the new repo was created with).
|
|
80
|
+
await reinitAndPush({
|
|
81
|
+
dir,
|
|
82
|
+
target: boot.target,
|
|
83
|
+
ghToken: job.ghToken,
|
|
84
|
+
message: fromScratch
|
|
85
|
+
? 'Bootstrap new repository'
|
|
86
|
+
: `Bootstrap from ${job.repo.owner}/${job.repo.name}`,
|
|
87
|
+
});
|
|
88
|
+
logger.info('agent(bootstrap): complete', { defaultBranch: boot.target.defaultBranch });
|
|
89
|
+
return mergeEffort({
|
|
90
|
+
defaultBranch: boot.target.defaultBranch,
|
|
91
|
+
summary,
|
|
92
|
+
stats,
|
|
93
|
+
...(usage ? { usage } : {}),
|
|
94
|
+
...(callMetrics ? { callMetrics } : {}),
|
|
95
|
+
}, effortReport);
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Whether the bootstrapper actually produced repository content, so a no-op run (the agent
|
|
100
|
+
* never reached the model / never wrote anything) is failed rather than force-pushed as an
|
|
101
|
+
* empty repo. With a reference architecture, "produced content" means the agent changed the
|
|
102
|
+
* clone; scaffolding from scratch, it means at least one file now exists in the working
|
|
103
|
+
* directory. (The harness writes its prompt context to Pi's global `~/.pi/agent/AGENTS.md`,
|
|
104
|
+
* never into `dir`, so nothing here needs to be filtered out as harness boilerplate.)
|
|
105
|
+
*/
|
|
106
|
+
export async function producedRepoContent(dir, hasReference, signal) {
|
|
107
|
+
if (hasReference)
|
|
108
|
+
return hasAgentChanges(dir, signal);
|
|
109
|
+
return containsAnyFile(dir);
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Whether `dir` contains at least one regular file anywhere in its tree, walking
|
|
113
|
+
* depth-first and stopping at the FIRST file found — so the cost is bounded by how
|
|
114
|
+
* quickly a file turns up (a scaffold almost always writes a root-level file), not by
|
|
115
|
+
* the size of the produced tree (a full recursive `readdir` would materialise every
|
|
116
|
+
* entry before the check).
|
|
117
|
+
*/
|
|
118
|
+
async function containsAnyFile(dir) {
|
|
119
|
+
const handle = await opendir(dir);
|
|
120
|
+
try {
|
|
121
|
+
for await (const entry of handle) {
|
|
122
|
+
if (entry.isFile())
|
|
123
|
+
return true;
|
|
124
|
+
if (entry.isDirectory() && (await containsAnyFile(join(dir, entry.name))))
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// A directory that vanished mid-walk has nothing to contribute.
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
}
|
|
133
|
+
/** Human-readable bootstrap no-op reason, embedding what the agent did so the cause is visible. */
|
|
134
|
+
function bootstrapNoOpReason(hasReference, stats, summary, stderrTail) {
|
|
135
|
+
const what = hasReference
|
|
136
|
+
? 'made no changes to the reference architecture'
|
|
137
|
+
: 'scaffolded no files';
|
|
138
|
+
const cause = agentNeverActed(stats) ? NEVER_ACTED_CAUSE : '';
|
|
139
|
+
return (`the bootstrapper agent ${what} ` +
|
|
140
|
+
`(tool calls: ${stats.toolCalls}, assistant output: ${stats.assistantChars} chars).${cause}` +
|
|
141
|
+
agentOutputTail(stderrTail, summary));
|
|
142
|
+
}
|
package/dist/coding-agent.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { mkdir } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import {
|
|
4
|
-
import { killChildProcess, spawnDetached } from './process.js';
|
|
5
|
-
import { MAX_CAPTURED_OUTPUT_CHARS, redactSecrets } from './redact.js';
|
|
3
|
+
import { runCapturedCommand } from './captured-command.js';
|
|
6
4
|
import { branchAheadOfBase, changedFilesSinceBase, branchHasCommitsSince, cloneExistingBranch, cloneRepo, commitTrackedEdits, createBranch, excludeFromGit, fetchReferenceBranches, headCommit, listUntrackedFiles, prepareExistingCheckout, pushBranch, refreshFromBaseIfClean, remoteBranchExists, } from './git.js';
|
|
7
5
|
import { openPullRequest } from './vcs-api.js';
|
|
8
6
|
import { FOLLOW_UPS_FILENAME, FollowUpTailer } from './follow-ups.js';
|
|
@@ -148,12 +146,14 @@ export async function runCodingAgent(spec, opts = {}) {
|
|
|
148
146
|
subscriptionBaseUrl: spec.subscriptionBaseUrl,
|
|
149
147
|
ambientAuth: spec.ambientAuth,
|
|
150
148
|
proxyBaseUrl: spec.proxyBaseUrl,
|
|
149
|
+
proxyPhasePath: spec.proxyPhasePath,
|
|
151
150
|
sessionToken: spec.sessionToken,
|
|
152
151
|
serviceDirectory,
|
|
153
152
|
webToolsGuidance: spec.webToolsGuidance,
|
|
154
153
|
webSearchProxy: spec.webSearchProxy,
|
|
155
154
|
guardLimits: spec.guardLimits,
|
|
156
|
-
...(spec.
|
|
155
|
+
...(spec.skills?.length ? { skills: spec.skills } : {}),
|
|
156
|
+
...(spec.mcpServers?.length ? { mcpServers: spec.mcpServers } : {}),
|
|
157
157
|
}, opts);
|
|
158
158
|
let outcome;
|
|
159
159
|
try {
|
|
@@ -457,7 +457,7 @@ async function finalizeCodingRun(args) {
|
|
|
457
457
|
// Runs regardless of whether this pass pushed — a no-op iteration must still be able
|
|
458
458
|
// to report that the criterion is (already) met. The harness runs it, never the model.
|
|
459
459
|
if (spec.validation) {
|
|
460
|
-
outcome.validation = await runRalphValidation(workDir, spec.validation, logger, opts);
|
|
460
|
+
outcome.validation = await runRalphValidation(dir, workDir, spec.validation, logger, opts);
|
|
461
461
|
}
|
|
462
462
|
// Pre-PR validation: the loop already ran (before this finalize, so a red checkout never
|
|
463
463
|
// reaches the PR-opening caller); attach its verdict for the caller to gate on and for the
|
|
@@ -525,81 +525,96 @@ function mergeAgentPasses(previous, next) {
|
|
|
525
525
|
* killed and treated as a failure (a hung `pnpm test` must never block the loop forever).
|
|
526
526
|
* Overridable via env for tests; defaults to 15 minutes.
|
|
527
527
|
*/
|
|
528
|
-
function ralphValidationTimeoutMs() {
|
|
528
|
+
export function ralphValidationTimeoutMs() {
|
|
529
529
|
const n = Number(process.env.RALPH_VALIDATION_TIMEOUT_MS);
|
|
530
530
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 15 * 60_000;
|
|
531
531
|
}
|
|
532
|
+
/**
|
|
533
|
+
* How often the Ralph validation feeds the run's inactivity watchdog while its command runs.
|
|
534
|
+
* The command is exactly the activity-SILENT kind — a full `pnpm test`, a cold install-then-build
|
|
535
|
+
* — and the harness spawns it ITSELF rather than through the agent, so it emits no activity
|
|
536
|
+
* events of its own. `JOB_INACTIVITY_MS` (default 10 min) is TIGHTER than the command's own
|
|
537
|
+
* watchdog ({@link ralphValidationTimeoutMs}, default 15 min), so without this heartbeat any
|
|
538
|
+
* validation running past 10 minutes aborted the whole iteration as "inactivity" — mislabelling
|
|
539
|
+
* a healthy test suite as a wedge, and making the 15-minute watchdog unreachable at stock
|
|
540
|
+
* settings. The two sibling harness-run phases (pre-PR validation, reproduction proof) have
|
|
541
|
+
* always fed it; this one did not. Overridable via env for tests.
|
|
542
|
+
*/
|
|
543
|
+
export function ralphHeartbeatMs() {
|
|
544
|
+
const n = Number(process.env.RALPH_VALIDATION_HEARTBEAT_MS);
|
|
545
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 30_000;
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Bound on the validation output tail that crosses the wire. Deliberately smaller than
|
|
549
|
+
* `MAX_CAPTURED_OUTPUT_CHARS` (`redact.ts`) and equal to the two sibling phases' budgets, for
|
|
550
|
+
* the same reason: this tail is persisted on the step (and on EVERY iteration of the attempt
|
|
551
|
+
* log) inside the run's `detail` JSON blob, which is re-serialized on every step-progress write.
|
|
552
|
+
*/
|
|
553
|
+
export const RALPH_VALIDATION_TAIL_CHARS = 4_000;
|
|
532
554
|
/**
|
|
533
555
|
* Ralph loop: run the programmatic completion command in the checkout and return its exit
|
|
534
|
-
* code
|
|
535
|
-
* done signal (0 = the criterion is met) — computed
|
|
536
|
-
* by the model, which is the whole point of a
|
|
537
|
-
*
|
|
538
|
-
*
|
|
539
|
-
*
|
|
540
|
-
*
|
|
556
|
+
* code, a bounded + redacted tail of its output, and the work branch's HEAD it ran against.
|
|
557
|
+
* The EXIT CODE is the loop's authoritative done signal (0 = the criterion is met) — computed
|
|
558
|
+
* here by the harness, never self-reported by the model, which is the whole point of a
|
|
559
|
+
* programmatic exit condition. The command runs INSIDE the sandboxed run container (the same
|
|
560
|
+
* trust boundary as the coding agent) — there is no host/backend execution.
|
|
561
|
+
*
|
|
562
|
+
* The spawn itself goes through {@link runCapturedCommand}, the ONE seam for a harness-run
|
|
563
|
+
* command, rather than the near-verbatim copy this used to be. That copy had drifted in two
|
|
564
|
+
* ways the seam exists to prevent: it scrubbed secrets AFTER the rolling truncation with no
|
|
565
|
+
* margin (so a credential straddling the cut lost its `KEY=` prefix and survived redaction as
|
|
566
|
+
* an unrecognised partial, on a tail that reaches the step, the notification and the SPA), and
|
|
567
|
+
* it published the full 16k capture where both siblings deliberately bound the wire tail.
|
|
568
|
+
*
|
|
569
|
+
* `headSha` is what lets the engine tell a loop that is iterating from one that is merely
|
|
570
|
+
* repeating: two consecutive failing iterations against an unchanged head means the agent
|
|
571
|
+
* committed nothing, and the loop is ended early instead of spending the rest of its budget.
|
|
572
|
+
* Best-effort — a head that cannot be read is simply omitted, and the engine's check fails open.
|
|
541
573
|
*/
|
|
542
|
-
async function runRalphValidation(cwd, validation, logger, opts) {
|
|
543
|
-
const timeoutMs = ralphValidationTimeoutMs();
|
|
574
|
+
export async function runRalphValidation(repoDir, cwd, validation, logger, opts) {
|
|
544
575
|
logger.info('coding-agent(ralph): running validation command', {
|
|
545
576
|
iteration: validation.iteration,
|
|
546
577
|
});
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
578
|
+
// Keep the run's inactivity watchdog fed for the whole command — see `ralphHeartbeatMs`.
|
|
579
|
+
const heartbeat = setInterval(() => opts.onActivity?.(), ralphHeartbeatMs());
|
|
580
|
+
heartbeat.unref?.();
|
|
581
|
+
let captured;
|
|
582
|
+
try {
|
|
583
|
+
captured = await runCapturedCommand({
|
|
551
584
|
cwd,
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
585
|
+
command: validation.command,
|
|
586
|
+
timeoutMs: ralphValidationTimeoutMs(),
|
|
587
|
+
reportTailChars: RALPH_VALIDATION_TAIL_CHARS,
|
|
588
|
+
logLabel: 'coding-agent(ralph): validation',
|
|
589
|
+
logFields: { iteration: validation.iteration },
|
|
590
|
+
logger,
|
|
591
|
+
opts,
|
|
558
592
|
});
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
opts.signal?.removeEventListener('abort', onAbort);
|
|
571
|
-
const trimmed = out.trim();
|
|
572
|
-
const tail = trimmed ? redactSecrets(trimmed) : undefined;
|
|
573
|
-
logger.info('coding-agent(ralph): validation finished', {
|
|
574
|
-
exitCode,
|
|
575
|
-
iteration: validation.iteration,
|
|
576
|
-
});
|
|
577
|
-
resolve({
|
|
578
|
-
validationPassed: exitCode === 0,
|
|
579
|
-
exitCode,
|
|
580
|
-
...(tail ? { validationOutputTail: tail } : {}),
|
|
581
|
-
...(validation.iteration !== undefined ? { iteration: validation.iteration } : {}),
|
|
582
|
-
});
|
|
583
|
-
};
|
|
584
|
-
const timer = setTimeout(() => {
|
|
585
|
-
logger.warn('coding-agent(ralph): validation command timed out', { timeoutMs });
|
|
586
|
-
killChildProcess(child, undefined, logger);
|
|
587
|
-
finish(124); // conventional timeout exit code (a non-zero fail)
|
|
588
|
-
}, timeoutMs);
|
|
589
|
-
timer.unref?.();
|
|
590
|
-
const onAbort = () => {
|
|
591
|
-
killChildProcess(child, undefined, logger);
|
|
592
|
-
finish(130); // aborted (a non-zero fail)
|
|
593
|
-
};
|
|
594
|
-
opts.signal?.addEventListener('abort', onAbort, { once: true });
|
|
595
|
-
child.on('error', (err) => {
|
|
596
|
-
logger.warn('coding-agent(ralph): validation command failed to spawn', {
|
|
597
|
-
error: err instanceof Error ? err.message : String(err),
|
|
598
|
-
});
|
|
599
|
-
finish(127); // spawn error / command not found (a non-zero fail)
|
|
593
|
+
}
|
|
594
|
+
finally {
|
|
595
|
+
clearInterval(heartbeat);
|
|
596
|
+
}
|
|
597
|
+
// The commit the criterion was judged against. Read AFTER the command so a validation that
|
|
598
|
+
// itself commits (a formatter check that rewrites files, say) is attributed to what it left.
|
|
599
|
+
// Best-effort: an unreadable head only costs the engine's no-progress guard, never the
|
|
600
|
+
// verdict — but it is REPORTED, or a guard that quietly stopped firing leaves no trace.
|
|
601
|
+
const headSha = await headCommit(repoDir, opts.signal).catch((err) => {
|
|
602
|
+
logger.warn('coding-agent(ralph): could not read the work-branch head', {
|
|
603
|
+
error: err instanceof Error ? err.message : String(err),
|
|
600
604
|
});
|
|
601
|
-
|
|
605
|
+
return '';
|
|
602
606
|
});
|
|
607
|
+
logger.info('coding-agent(ralph): validation finished', {
|
|
608
|
+
exitCode: captured.exitCode,
|
|
609
|
+
iteration: validation.iteration,
|
|
610
|
+
});
|
|
611
|
+
return {
|
|
612
|
+
validationPassed: captured.passed,
|
|
613
|
+
exitCode: captured.exitCode,
|
|
614
|
+
...(captured.outputTail ? { validationOutputTail: captured.outputTail } : {}),
|
|
615
|
+
...(validation.iteration !== undefined ? { iteration: validation.iteration } : {}),
|
|
616
|
+
...(headSha ? { headSha } : {}),
|
|
617
|
+
};
|
|
603
618
|
}
|
|
604
619
|
/** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
|
|
605
620
|
export function safeDirSegment(value) {
|
|
@@ -702,11 +717,16 @@ export async function runMultiRepoCoding(job, opts = {}) {
|
|
|
702
717
|
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
703
718
|
ambientAuth: job.ambientAuth,
|
|
704
719
|
proxyBaseUrl: job.proxyBaseUrl,
|
|
720
|
+
proxyPhasePath: job.proxyPhasePath,
|
|
705
721
|
sessionToken: job.sessionToken,
|
|
706
722
|
webToolsGuidance: job.webToolsGuidance,
|
|
707
723
|
webSearchProxy: job.webSearch,
|
|
708
724
|
guardLimits: job.guardLimits,
|
|
709
725
|
...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
|
|
726
|
+
// Skills + tool servers apply to a multi-repo run exactly as to a single-repo one: they
|
|
727
|
+
// are properties of the AGENT KIND, not of the checkout layout.
|
|
728
|
+
...(job.skills?.length ? { skills: job.skills } : {}),
|
|
729
|
+
...(job.mcpServers?.length ? { mcpServers: job.mcpServers } : {}),
|
|
710
730
|
multiRepo: true,
|
|
711
731
|
}, opts);
|
|
712
732
|
// Commit forgotten tracked edits, then push + open a PR for each repo the run actually changed.
|