@cat-factory/executor-harness 1.31.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/LICENSE +21 -0
- package/README.md +143 -0
- package/dist/agent-runner.js +389 -0
- package/dist/agent.js +810 -0
- package/dist/blueprint.js +367 -0
- package/dist/bootstrap.js +99 -0
- package/dist/ci-fixer.js +46 -0
- package/dist/coding-agent.js +285 -0
- package/dist/conflict-resolver.js +138 -0
- package/dist/embed.js +8 -0
- package/dist/explore.js +74 -0
- package/dist/failure.js +47 -0
- package/dist/fixer.js +44 -0
- package/dist/follow-ups.js +103 -0
- package/dist/frontend-infra.js +283 -0
- package/dist/fs-utils.js +11 -0
- package/dist/git.js +778 -0
- package/dist/job.js +409 -0
- package/dist/logger.js +27 -0
- package/dist/merger.js +135 -0
- package/dist/on-call.js +126 -0
- package/dist/pi-workspace.js +237 -0
- package/dist/pi.js +971 -0
- package/dist/process.js +25 -0
- package/dist/redact.js +109 -0
- package/dist/runner.js +228 -0
- package/dist/server.js +135 -0
- package/dist/spec.js +754 -0
- package/dist/structured-output.js +431 -0
- package/dist/tester.js +191 -0
- package/package.json +35 -0
- package/src/agent-runner.ts +484 -0
- package/src/agent.ts +948 -0
- package/src/coding-agent.ts +393 -0
- package/src/embed.ts +32 -0
- package/src/failure.ts +73 -0
- package/src/follow-ups.ts +106 -0
- package/src/frontend-infra.ts +340 -0
- package/src/fs-utils.ts +11 -0
- package/src/git.ts +955 -0
- package/src/job.ts +766 -0
- package/src/logger.ts +45 -0
- package/src/pi-workspace.ts +348 -0
- package/src/pi.ts +1236 -0
- package/src/process.ts +33 -0
- package/src/redact.ts +109 -0
- package/src/runner.ts +384 -0
- package/src/server.ts +153 -0
- package/src/structured-output.ts +524 -0
package/dist/agent.js
ADDED
|
@@ -0,0 +1,810 @@
|
|
|
1
|
+
import { join } from 'node:path';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { mkdir, mkdtemp, opendir, rm } from 'node:fs/promises';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import { standUpFrontend, tearDownFrontend } from './frontend-infra.js';
|
|
7
|
+
import { captureRedactedOutput, redactSecrets } from './redact.js';
|
|
8
|
+
import { cloneRepo, commitAll, conflictDiff, hasAgentChanges, headCommit, mergeBranch, openPullRequest, prepareExistingCheckout, pushBranch, reinitAndPush, unmergedPaths, } from './git.js';
|
|
9
|
+
import { noChangesReason, runCodingAgent } from './coding-agent.js';
|
|
10
|
+
import { acquireRepoCheckout, agentNeverActed, agentOutputTail, NEVER_ACTED_CAUSE, runAgentInWorkspace, unusableFinalAnswerCause, withWorkspace, } from './pi-workspace.js';
|
|
11
|
+
import { diagnosticsSuffix, resolveStructuredOutput, } from './structured-output.js';
|
|
12
|
+
import { log } from './logger.js';
|
|
13
|
+
// The single generic agent handler — the manifest-driven replacement for the bespoke
|
|
14
|
+
// per-kind handlers. It runs an LLM over an optional checkout and returns text/JSON
|
|
15
|
+
// (`explore`) or commits + pushes its edits and optionally opens a PR (`coding`). WHAT
|
|
16
|
+
// the agent does is decided by the backend and passed as job DATA (never an agent-kind
|
|
17
|
+
// string), and all mechanical work that CAN run without a checkout (rendering artifact
|
|
18
|
+
// files from the structured output, board ingest) lives on the backend before/after this
|
|
19
|
+
// run via the RepoFiles port.
|
|
20
|
+
//
|
|
21
|
+
// Two coding flows still carry working-tree Git mechanics that a contents-API-only
|
|
22
|
+
// RepoFiles cannot perform, so they are keyed off job data here (NOT off a kind string):
|
|
23
|
+
// `mergeBase` ⇒ surface real merge conflicts via a working-tree base→branch merge
|
|
24
|
+
// (conflict resolution); `bootstrap` ⇒ reinitialise history and force-push to a separate
|
|
25
|
+
// target repo. These are the deliberate, documented exceptions — do NOT grow this into a
|
|
26
|
+
// general `if (job.someFlag)` dispatch; anything that doesn't need a checkout belongs in
|
|
27
|
+
// backend pre/post-ops. See backend/docs/custom-agents.md.
|
|
28
|
+
const exec = promisify(execFile);
|
|
29
|
+
/**
|
|
30
|
+
* Bring the service's docker-compose dependencies up (local infra only). Best-effort:
|
|
31
|
+
* runs `docker compose -f <path> up -d --wait` in the checkout. A missing Docker daemon
|
|
32
|
+
* or a compose failure is logged and surfaced to the agent (as a prompt note) rather
|
|
33
|
+
* than failing the job — the agent can still run unit-level tests and report what it
|
|
34
|
+
* could. A no-op for ephemeral / no-infra / no-compose-path runs.
|
|
35
|
+
*
|
|
36
|
+
* Whether it succeeds or fails, the (redacted, bounded) command output is captured into a
|
|
37
|
+
* {@link InfraSetupRecord} returned alongside the prompt `note`, so the backend can surface
|
|
38
|
+
* the in-container dependency stand-up logs on the Tester step — the failure-class artifact
|
|
39
|
+
* the orchestrator-side provisioning logs can't see.
|
|
40
|
+
*/
|
|
41
|
+
async function standUpInfra(dir, infra, signal, logger) {
|
|
42
|
+
if (infra.environment !== 'local' || infra.noInfraDependencies || !infra.composePath) {
|
|
43
|
+
return { started: false };
|
|
44
|
+
}
|
|
45
|
+
const startedAt = Date.now();
|
|
46
|
+
try {
|
|
47
|
+
logger.info('agent(explore): standing up infra', { composePath: infra.composePath });
|
|
48
|
+
// Raise maxBuffer well above the 1MB default so a chatty compose stand-up can't fail the
|
|
49
|
+
// (best-effort) infra step with ENOBUFS; the captured output is tail-bounded on storage.
|
|
50
|
+
const { stdout, stderr } = await exec('docker', ['compose', '-f', infra.composePath, 'up', '-d', '--wait'], { cwd: dir, signal, timeout: 5 * 60_000, maxBuffer: 16 * 1024 * 1024 });
|
|
51
|
+
const logs = captureRedactedOutput(stdout, stderr);
|
|
52
|
+
return {
|
|
53
|
+
started: true,
|
|
54
|
+
record: {
|
|
55
|
+
started: true,
|
|
56
|
+
composePath: infra.composePath,
|
|
57
|
+
at: Date.now(),
|
|
58
|
+
durationMs: Date.now() - startedAt,
|
|
59
|
+
...(logs ? { logs } : {}),
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
const note = err instanceof Error ? err.message : String(err);
|
|
65
|
+
logger.warn('agent(explore): infra stand-up failed', { error: note });
|
|
66
|
+
// `execFile` rejections carry the partial stdout/stderr on the error object — capture them
|
|
67
|
+
// so the stored logs explain the failure (a port clash, a pull-auth error, an exited
|
|
68
|
+
// dependency), not just the one-line exit message.
|
|
69
|
+
const e = err;
|
|
70
|
+
const logs = captureRedactedOutput(e.stdout, e.stderr);
|
|
71
|
+
return {
|
|
72
|
+
started: false,
|
|
73
|
+
note,
|
|
74
|
+
record: {
|
|
75
|
+
started: false,
|
|
76
|
+
composePath: infra.composePath,
|
|
77
|
+
at: Date.now(),
|
|
78
|
+
durationMs: Date.now() - startedAt,
|
|
79
|
+
error: redactSecrets(note),
|
|
80
|
+
...(logs ? { logs } : {}),
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Stand the run's infra up and return a single cleanup handle, dispatching on the spec's
|
|
87
|
+
* `kind`: the frontend UI-test flow (`kind: 'frontend'`) builds/serves the app + WireMock as
|
|
88
|
+
* processes (torn down by killing them); the default backend-service flow stands the
|
|
89
|
+
* docker-compose stack up (torn down with `docker compose down`). Unifying the two here keeps
|
|
90
|
+
* `runExploreMode` free of the branch and guarantees the matching teardown runs in its finally.
|
|
91
|
+
*
|
|
92
|
+
* `dir` is the clone ROOT; `workDir` is the service subtree (equal to `dir` when the run is not
|
|
93
|
+
* monorepo-scoped). The docker-compose stand-up runs at the root (its `composePath` is
|
|
94
|
+
* repo-relative), but the FRONTEND stand-up runs in `workDir`: a monorepo frontend's
|
|
95
|
+
* `package.json` / `outputDir` / `mocks/` all live under the service subtree, so installing,
|
|
96
|
+
* building, serving and seeding WireMock from the root would target the wrong directory.
|
|
97
|
+
*/
|
|
98
|
+
async function manageInfra(dir, workDir, infra, signal, onActivity, logger) {
|
|
99
|
+
if (infra.kind === 'frontend') {
|
|
100
|
+
// `onActivity` feeds the inactivity watchdog through the frontend build/serve stand-up,
|
|
101
|
+
// which (unlike docker-compose's 5-min-capped `up`) can run past the inactivity window.
|
|
102
|
+
// Runs in `workDir` so a monorepo frontend builds/serves from its own package subtree.
|
|
103
|
+
const fe = await standUpFrontend(workDir, infra, signal, onActivity, logger);
|
|
104
|
+
return {
|
|
105
|
+
...(fe.note ? { note: fe.note } : {}),
|
|
106
|
+
...(fe.serveUrl ? { serveUrl: fe.serveUrl } : {}),
|
|
107
|
+
record: fe.record,
|
|
108
|
+
cleanup: () => tearDownFrontend(fe.processes, logger),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
const standUp = await standUpInfra(dir, infra, signal, logger);
|
|
112
|
+
return {
|
|
113
|
+
...(standUp.note ? { note: standUp.note } : {}),
|
|
114
|
+
...(standUp.record ? { record: standUp.record } : {}),
|
|
115
|
+
cleanup: () => tearDownInfra(dir, infra),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Build the dynamic infra notes appended to the agent's user prompt from a stand-up outcome.
|
|
120
|
+
* A stand-up problem (a failed build / compose) is flagged as a concern to test around; a
|
|
121
|
+
* frontend serve URL points the UI tester at the app that was just built + served and pre-empts
|
|
122
|
+
* a live-backend CORS failure being mis-reported as an app defect. Pure (no IO) so the exact
|
|
123
|
+
* wording + ordering is unit-tested; returns the notes in order (problem first, serve URL next).
|
|
124
|
+
*/
|
|
125
|
+
export function buildInfraNotes(managed) {
|
|
126
|
+
const notes = [];
|
|
127
|
+
if (managed.note) {
|
|
128
|
+
notes.push(`standing the infra up reported a problem (${managed.note}). Test what you can and ` +
|
|
129
|
+
`flag any dependency-related gaps as concerns.`);
|
|
130
|
+
}
|
|
131
|
+
if (managed.serveUrl) {
|
|
132
|
+
notes.push(`The frontend under test is built and served at ${managed.serveUrl}, with its other ` +
|
|
133
|
+
`backend upstreams handled by WireMock. Drive your UI tests against ${managed.serveUrl}. ` +
|
|
134
|
+
`If a call to a live backend fails with a CORS / cross-origin error, that is an infra ` +
|
|
135
|
+
`gap (the backend must allow the ${managed.serveUrl} origin), not an app defect — flag ` +
|
|
136
|
+
`it as a concern rather than a failing test.`);
|
|
137
|
+
}
|
|
138
|
+
return notes;
|
|
139
|
+
}
|
|
140
|
+
/** Tear the docker-compose dependencies down (best-effort; a no-op when none were started). */
|
|
141
|
+
async function tearDownInfra(dir, infra) {
|
|
142
|
+
if (infra.environment !== 'local' || infra.noInfraDependencies || !infra.composePath)
|
|
143
|
+
return;
|
|
144
|
+
try {
|
|
145
|
+
await exec('docker', ['compose', '-f', infra.composePath, 'down', '-v'], {
|
|
146
|
+
cwd: dir,
|
|
147
|
+
timeout: 2 * 60_000,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// The container is ephemeral and torn down with the run anyway — ignore.
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
/** Extract the first JSON object from an agent's final message (tolerating fences/prose). */
|
|
155
|
+
function extractJsonObject(text) {
|
|
156
|
+
const trimmed = text.trim();
|
|
157
|
+
const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/i.exec(trimmed);
|
|
158
|
+
const body = fenced ? (fenced[1] ?? '') : trimmed;
|
|
159
|
+
try {
|
|
160
|
+
return JSON.parse(body);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
const start = body.indexOf('{');
|
|
164
|
+
const end = body.lastIndexOf('}');
|
|
165
|
+
if (start === -1 || end === -1 || end <= start) {
|
|
166
|
+
throw new Error('agent did not return a JSON object');
|
|
167
|
+
}
|
|
168
|
+
return JSON.parse(body.slice(start, end + 1));
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* The service work directory for a checkout at `dir`: the monorepo service subtree
|
|
173
|
+
* (`repo.serviceDirectory`, created if missing) when the job is service-scoped, else the clone
|
|
174
|
+
* root. Shared so the explore/preview flows derive `workDir` identically.
|
|
175
|
+
*/
|
|
176
|
+
async function deriveWorkDir(dir, serviceDirectory) {
|
|
177
|
+
const workDir = serviceDirectory ? join(dir, serviceDirectory) : dir;
|
|
178
|
+
if (serviceDirectory)
|
|
179
|
+
await mkdir(workDir, { recursive: true });
|
|
180
|
+
return workDir;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Fresh-clone `job.branch` into `dir` and return the derived service work directory. Shared by
|
|
184
|
+
* the explore and preview flows, which both start from a clean single-branch checkout. (The
|
|
185
|
+
* coding and persistent-checkout paths keep their own resume / full-clone logic.)
|
|
186
|
+
*/
|
|
187
|
+
async function cloneServiceCheckout(dir, job, signal) {
|
|
188
|
+
await cloneRepo({
|
|
189
|
+
repo: { ...job.repo, baseBranch: job.branch },
|
|
190
|
+
ghToken: job.ghToken,
|
|
191
|
+
dir,
|
|
192
|
+
full: job.full,
|
|
193
|
+
signal,
|
|
194
|
+
});
|
|
195
|
+
return deriveWorkDir(dir, job.repo.serviceDirectory);
|
|
196
|
+
}
|
|
197
|
+
/** Run one generic agent job end to end, dispatching on `mode`. */
|
|
198
|
+
export async function handleAgent(job, opts = {}) {
|
|
199
|
+
if (job.mode === 'preview')
|
|
200
|
+
return runPreviewMode(job, opts);
|
|
201
|
+
return job.mode === 'coding' ? runCodingMode(job, opts) : runExploreMode(job, opts);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Decide a preview stand-up's outcome from its result (pure, so the success/failure boundary
|
|
205
|
+
* is unit-tested without spawning a build). A preview must actually come up: unlike the tester's
|
|
206
|
+
* "test what you can" fallback, a stand-up that produced no reachable serve URL (failed build /
|
|
207
|
+
* server never bound) is a hard failure and its `note` becomes the failure reason. When the app
|
|
208
|
+
* is up but WireMock is not, the `note` rides along as a non-fatal warning.
|
|
209
|
+
*/
|
|
210
|
+
export function buildPreviewOutcome(standUp) {
|
|
211
|
+
if (!standUp.serveUrl) {
|
|
212
|
+
return {
|
|
213
|
+
ok: false,
|
|
214
|
+
error: standUp.note
|
|
215
|
+
? `the frontend preview did not come up (${standUp.note})`
|
|
216
|
+
: 'the frontend preview did not come up (the served app was never reachable)',
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
return { ok: true, url: standUp.serveUrl, ...(standUp.note ? { note: standUp.note } : {}) };
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Long-lived browsable preview (local/node only): clone the frontend branch, then build +
|
|
223
|
+
* serve the app with its other upstreams mocked using the SAME {@link standUpFrontend} the UI
|
|
224
|
+
* tester uses — but KEEP IT RUNNING. No agent runs, and the serve / WireMock child processes
|
|
225
|
+
* are deliberately NOT torn down when the job returns, so the app stays reachable inside the
|
|
226
|
+
* container until the container itself is stopped (the transport's explicit stop path). Because
|
|
227
|
+
* the served files must outlive the job, the checkout is cloned into a directory that is NOT
|
|
228
|
+
* auto-removed (unlike the explore/coding `withWorkspace`); the ephemeral preview container
|
|
229
|
+
* reclaims it on teardown. A preview that never comes up is a hard failure — the partial
|
|
230
|
+
* stand-up is torn down and its temp checkout removed so a failed attempt leaks nothing.
|
|
231
|
+
*/
|
|
232
|
+
async function runPreviewMode(job, opts) {
|
|
233
|
+
const logger = opts.log ?? log;
|
|
234
|
+
const infra = job.infra;
|
|
235
|
+
if (infra?.kind !== 'frontend') {
|
|
236
|
+
// Invalid dispatch (a preview job MUST carry the frontend infra spec). No checkout or
|
|
237
|
+
// processes exist yet, so return the structured hard failure the rest of this flow uses
|
|
238
|
+
// rather than throwing a bare exception at the job registry.
|
|
239
|
+
return {
|
|
240
|
+
error: "invalid preview job: 'infra.kind' must be 'frontend'",
|
|
241
|
+
failureCause: 'no-usable-output',
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
opts.onPhase?.('clone');
|
|
245
|
+
logger.info('agent(preview): cloning');
|
|
246
|
+
// Not a `withWorkspace` temp dir: that is removed in a `finally` the moment this function
|
|
247
|
+
// returns, which would delete the files the kept-alive server serves. The preview container
|
|
248
|
+
// is single-purpose and torn down on stop, so leaving the checkout in place is intended.
|
|
249
|
+
const dir = await mkdtemp(join(tmpdir(), 'agent-preview-'));
|
|
250
|
+
try {
|
|
251
|
+
const workDir = await cloneServiceCheckout(dir, job, opts.signal);
|
|
252
|
+
opts.onPhase?.('serve');
|
|
253
|
+
logger.info('agent(preview): building + serving', {
|
|
254
|
+
serviceDirectory: job.repo.serviceDirectory,
|
|
255
|
+
});
|
|
256
|
+
const fe = await standUpFrontend(workDir, infra, opts.signal, opts.onActivity, logger);
|
|
257
|
+
const infraSetupFields = fe.record
|
|
258
|
+
? { infraSetup: fe.record }
|
|
259
|
+
: {};
|
|
260
|
+
const outcome = buildPreviewOutcome(fe);
|
|
261
|
+
if (!outcome.ok) {
|
|
262
|
+
// Never came up: tear the partial stand-up down and drop the checkout so a failed preview
|
|
263
|
+
// leaks neither processes nor disk. The backend surfaces the stand-up record + failure.
|
|
264
|
+
await tearDownFrontend(fe.processes, logger);
|
|
265
|
+
await rm(dir, { recursive: true, force: true });
|
|
266
|
+
return { error: outcome.error, failureCause: 'no-usable-output', ...infraSetupFields };
|
|
267
|
+
}
|
|
268
|
+
// Deliberately NOT torn down: the serve/WireMock children outlive this job and keep the app
|
|
269
|
+
// reachable until the container is stopped. `outcome.note` (WireMock down) is a soft warning.
|
|
270
|
+
logger.info('agent(preview): serving (kept alive)', { url: outcome.url });
|
|
271
|
+
return {
|
|
272
|
+
summary: outcome.note
|
|
273
|
+
? `Frontend preview built and served at ${outcome.url} (${outcome.note}).`
|
|
274
|
+
: `Frontend preview built and served at ${outcome.url}.`,
|
|
275
|
+
preview: { url: outcome.url },
|
|
276
|
+
...infraSetupFields,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
// A throw BEFORE the stand-up handed off (a failed / aborted clone, an mkdir error) would
|
|
281
|
+
// otherwise leak the checkout that `withWorkspace` normally reclaims — no serve processes
|
|
282
|
+
// are running yet, so drop the dir and rethrow for the job registry to record the failure.
|
|
283
|
+
await rm(dir, { recursive: true, force: true });
|
|
284
|
+
throw err;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Read-only exploration: clone `branch`, run the agent making no edits, and return its
|
|
289
|
+
* prose report — or, when `output.kind==='structured'`, the parsed JSON object as
|
|
290
|
+
* `custom` (the backend renders any artifact files from it in a post-op). An edit-free
|
|
291
|
+
* run is the expected, correct outcome; the only failure is producing no usable output.
|
|
292
|
+
*/
|
|
293
|
+
async function runExploreMode(job, opts) {
|
|
294
|
+
const logger = opts.log ?? log;
|
|
295
|
+
return acquireRepoCheckout({ persistent: job.persistentCheckout === true, prefix: 'agent-explore', repo: job.repo }, async (dir) => {
|
|
296
|
+
opts.onPhase?.('clone');
|
|
297
|
+
// Monorepo: run with cwd set to the service subtree (created if missing), mirroring the
|
|
298
|
+
// coding flow so a service-scoped exploration sees the right subdirectory.
|
|
299
|
+
const serviceDirectory = job.repo.serviceDirectory;
|
|
300
|
+
let workDir;
|
|
301
|
+
if (job.persistentCheckout) {
|
|
302
|
+
logger.info('agent(explore): preparing reused checkout');
|
|
303
|
+
await prepareExistingCheckout({
|
|
304
|
+
dir,
|
|
305
|
+
repo: job.repo,
|
|
306
|
+
ghToken: job.ghToken,
|
|
307
|
+
branch: job.branch,
|
|
308
|
+
baseBranch: job.branch,
|
|
309
|
+
existing: true,
|
|
310
|
+
signal: opts.signal,
|
|
311
|
+
});
|
|
312
|
+
workDir = await deriveWorkDir(dir, serviceDirectory);
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
logger.info('agent(explore): cloning');
|
|
316
|
+
workDir = await cloneServiceCheckout(dir, job, opts.signal);
|
|
317
|
+
}
|
|
318
|
+
// Optional infra stand-up (the tester): bring the service's docker-compose
|
|
319
|
+
// dependencies up at the repo root for the duration of the run, tearing them down in
|
|
320
|
+
// the `finally`. A stand-up failure is non-fatal — it's surfaced to the agent as a
|
|
321
|
+
// prompt note so it can still run what it can and flag dependency gaps as concerns.
|
|
322
|
+
// The run-mode guidance itself lives in the backend-composed system/user prompt; the
|
|
323
|
+
// harness only manages the lifecycle + this dynamic stand-up note.
|
|
324
|
+
const infra = job.infra;
|
|
325
|
+
const managed = infra
|
|
326
|
+
? await manageInfra(dir, workDir, infra, opts.signal, opts.onActivity, logger)
|
|
327
|
+
: undefined;
|
|
328
|
+
// Fold the stand-up outcome into the agent prompt: a stand-up problem (build/compose
|
|
329
|
+
// failure) is flagged as a concern; a frontend serve URL points the UI tester at the
|
|
330
|
+
// app it just built + served (the backend env resolution already reached the harness).
|
|
331
|
+
const infraNotes = managed ? buildInfraNotes(managed) : [];
|
|
332
|
+
const userPrompt = infraNotes.length
|
|
333
|
+
? `${job.userPrompt}\n\nNote: ${infraNotes.join(' ')}`
|
|
334
|
+
: job.userPrompt;
|
|
335
|
+
// The stand-up record (success or failure, with its captured logs) rides back on EVERY
|
|
336
|
+
// result branch — the backend surfaces it on the Tester step regardless of whether the
|
|
337
|
+
// agent then produced a usable report.
|
|
338
|
+
const infraSetupFields = managed?.record
|
|
339
|
+
? { infraSetup: managed.record }
|
|
340
|
+
: {};
|
|
341
|
+
try {
|
|
342
|
+
opts.onPhase?.('agent');
|
|
343
|
+
logger.info('agent(explore): running agent', { serviceDirectory });
|
|
344
|
+
const { summary, stats, stderrTail, usage, diagnostics: runDiag, } = await runAgentInWorkspace({
|
|
345
|
+
dir: workDir,
|
|
346
|
+
systemPrompt: job.systemPrompt,
|
|
347
|
+
userPrompt,
|
|
348
|
+
model: job.model,
|
|
349
|
+
harness: job.harness,
|
|
350
|
+
subscriptionToken: job.subscriptionToken,
|
|
351
|
+
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
352
|
+
ambientAuth: job.ambientAuth,
|
|
353
|
+
proxyBaseUrl: job.proxyBaseUrl,
|
|
354
|
+
sessionToken: job.sessionToken,
|
|
355
|
+
serviceDirectory,
|
|
356
|
+
// Read-only: it inspects and reports, making no edits — so the no-progress
|
|
357
|
+
// guard's no-edit bound must not fire on its legitimately edit-free run.
|
|
358
|
+
expectsEdits: false,
|
|
359
|
+
webToolsGuidance: job.webToolsGuidance,
|
|
360
|
+
webSearchProxy: job.webSearch,
|
|
361
|
+
contextFiles: job.contextFiles,
|
|
362
|
+
guardLimits: job.guardLimits,
|
|
363
|
+
}, opts);
|
|
364
|
+
if (!summary.trim()) {
|
|
365
|
+
return {
|
|
366
|
+
summary,
|
|
367
|
+
stats,
|
|
368
|
+
error: noOutputReason(stats, stderrTail),
|
|
369
|
+
failureCause: 'no-usable-output',
|
|
370
|
+
...(usage ? { usage } : {}),
|
|
371
|
+
...infraSetupFields,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
// Opt-in (document producers): a final answer cut off at the output ceiling — or empty —
|
|
375
|
+
// must FAIL LOUDLY here, BEFORE the structured repair below could launder a truncated
|
|
376
|
+
// reply into a half-baked doc the backend then shards/commits + hands onward. Mirrors the
|
|
377
|
+
// bespoke `/spec` handler's `unusableFinalAnswerCause` gate (which drove the old loop).
|
|
378
|
+
if (job.output?.kind === 'structured' && job.output.failOnUnusableFinal) {
|
|
379
|
+
const unusable = unusableFinalAnswerCause(runDiag);
|
|
380
|
+
if (unusable) {
|
|
381
|
+
return {
|
|
382
|
+
summary,
|
|
383
|
+
stats,
|
|
384
|
+
error: `the agent did not return a usable result: ${unusable}.${agentOutputTail(stderrTail, summary)}`,
|
|
385
|
+
failureCause: 'no-usable-output',
|
|
386
|
+
...(usage ? { usage } : {}),
|
|
387
|
+
...infraSetupFields,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
// Prose: the summary IS the deliverable.
|
|
392
|
+
if (job.output?.kind !== 'structured') {
|
|
393
|
+
logger.info('agent(explore): done (prose)', { ...stats });
|
|
394
|
+
return { summary, stats, ...(usage ? { usage } : {}), ...infraSetupFields };
|
|
395
|
+
}
|
|
396
|
+
// Structured: parse the agent's JSON. With repair enabled (default) a malformed
|
|
397
|
+
// reply gets ONE structured repair call before giving up; with `repair:false` we
|
|
398
|
+
// parse directly (no repair channel). The backend coerces/validates + renders from
|
|
399
|
+
// the returned object in a post-op.
|
|
400
|
+
let custom = null;
|
|
401
|
+
let diagnostics;
|
|
402
|
+
if (job.output.repair === false) {
|
|
403
|
+
try {
|
|
404
|
+
custom = extractJsonObject(summary);
|
|
405
|
+
}
|
|
406
|
+
catch {
|
|
407
|
+
custom = null;
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
const resolved = await resolveStructuredOutput({
|
|
412
|
+
label: 'agent',
|
|
413
|
+
shapeHint: job.output.shapeHint ?? 'Expected a single JSON object.',
|
|
414
|
+
parse: (text) => extractJsonObject(text),
|
|
415
|
+
}, summary, {
|
|
416
|
+
harness: job.harness,
|
|
417
|
+
subscriptionToken: job.subscriptionToken,
|
|
418
|
+
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
419
|
+
proxyBaseUrl: job.proxyBaseUrl,
|
|
420
|
+
sessionToken: job.sessionToken,
|
|
421
|
+
model: job.model,
|
|
422
|
+
jobId: job.jobId,
|
|
423
|
+
signal: opts.signal,
|
|
424
|
+
});
|
|
425
|
+
custom = resolved.value;
|
|
426
|
+
diagnostics = resolved.diagnostics;
|
|
427
|
+
}
|
|
428
|
+
if (custom === undefined || custom === null) {
|
|
429
|
+
return {
|
|
430
|
+
summary,
|
|
431
|
+
stats,
|
|
432
|
+
error: noStructuredReason(stats, stderrTail, diagnostics),
|
|
433
|
+
failureCause: 'no-usable-output',
|
|
434
|
+
...(usage ? { usage } : {}),
|
|
435
|
+
...infraSetupFields,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
// Stamp the run's actual environment authoritatively onto the structured result when
|
|
439
|
+
// infra was managed (the tester): which env the suite ran in is decided by the job's
|
|
440
|
+
// infra spec, NOT the model, so the backend can echo it back to the UI deterministically
|
|
441
|
+
// even when the model omits it from its JSON (or a structured repair drops it). A
|
|
442
|
+
// frontend run tests the app against its live ephemeral backend(s), so it reports
|
|
443
|
+
// `ephemeral` (the TestReport env vocabulary has no separate frontend value).
|
|
444
|
+
const reportedEnvironment = infra
|
|
445
|
+
? infra.kind === 'frontend'
|
|
446
|
+
? 'ephemeral'
|
|
447
|
+
: infra.environment
|
|
448
|
+
: undefined;
|
|
449
|
+
if (reportedEnvironment && typeof custom === 'object') {
|
|
450
|
+
;
|
|
451
|
+
custom.environment = reportedEnvironment;
|
|
452
|
+
}
|
|
453
|
+
logger.info('agent(explore): done (structured)', { ...stats });
|
|
454
|
+
return { summary, custom, stats, ...(usage ? { usage } : {}), ...infraSetupFields };
|
|
455
|
+
}
|
|
456
|
+
finally {
|
|
457
|
+
if (managed)
|
|
458
|
+
await managed.cleanup();
|
|
459
|
+
}
|
|
460
|
+
});
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Edit-and-push coding: clone `branch` (or resume `newBranch`), run the agent, commit +
|
|
464
|
+
* push to `pushBranch`, and open `pr` when one is set and the run produced changes. A
|
|
465
|
+
* no-op is a failure for the implementer (`noChangesIsError` default) and a non-fatal
|
|
466
|
+
* no-op for the in-place fixers.
|
|
467
|
+
*/
|
|
468
|
+
async function runCodingMode(job, opts) {
|
|
469
|
+
// Repo bootstrap is a coding run that force-pushes a fresh history to a SEPARATE target
|
|
470
|
+
// repo (clone + adapt a reference, or scaffold from scratch). Keyed off job DATA
|
|
471
|
+
// (`bootstrap`), not the agent kind.
|
|
472
|
+
if (job.bootstrap)
|
|
473
|
+
return runBootstrap(job, opts);
|
|
474
|
+
// Conflict resolution is a coding run with a different pre/post around the agent:
|
|
475
|
+
// clone full, merge the base in to surface the conflicts, then complete the merge
|
|
476
|
+
// commit + push (no PR). Keyed off job DATA (`mergeBase`), not the agent kind.
|
|
477
|
+
if (job.mergeBase)
|
|
478
|
+
return runConflictResolution(job, opts);
|
|
479
|
+
const pushBranch = job.pushBranch ?? job.newBranch ?? job.branch;
|
|
480
|
+
const { summary, stats, stderrTail, pushed, usage } = await runCodingAgent({
|
|
481
|
+
kind: 'agent',
|
|
482
|
+
jobId: job.jobId,
|
|
483
|
+
repo: job.repo,
|
|
484
|
+
cloneBranch: job.branch,
|
|
485
|
+
...(job.newBranch ? { newBranch: job.newBranch } : {}),
|
|
486
|
+
pushBranch,
|
|
487
|
+
ghToken: job.ghToken,
|
|
488
|
+
systemPrompt: job.systemPrompt,
|
|
489
|
+
userPrompt: job.userPrompt,
|
|
490
|
+
model: job.model,
|
|
491
|
+
harness: job.harness,
|
|
492
|
+
subscriptionToken: job.subscriptionToken,
|
|
493
|
+
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
494
|
+
ambientAuth: job.ambientAuth,
|
|
495
|
+
proxyBaseUrl: job.proxyBaseUrl,
|
|
496
|
+
sessionToken: job.sessionToken,
|
|
497
|
+
commitMessage: job.commitMessage ?? job.pr?.title ?? 'Agent changes',
|
|
498
|
+
webToolsGuidance: job.webToolsGuidance,
|
|
499
|
+
webSearchProxy: job.webSearch,
|
|
500
|
+
guardLimits: job.guardLimits,
|
|
501
|
+
...(job.persistentCheckout ? { persistentCheckout: true } : {}),
|
|
502
|
+
...(job.streamFollowUps ? { streamFollowUps: true } : {}),
|
|
503
|
+
}, opts);
|
|
504
|
+
if (!pushed) {
|
|
505
|
+
// A no-op: a failure for the implementer, a clean non-event for the fixers.
|
|
506
|
+
if (job.noChangesIsError === false) {
|
|
507
|
+
return { pushed: false, branch: pushBranch, summary, stats, ...(usage ? { usage } : {}) };
|
|
508
|
+
}
|
|
509
|
+
return {
|
|
510
|
+
pushed: false,
|
|
511
|
+
branch: pushBranch,
|
|
512
|
+
summary,
|
|
513
|
+
stats,
|
|
514
|
+
error: noChangesReason('the agent produced no file changes', stats, stderrTail),
|
|
515
|
+
failureCause: 'no-changes',
|
|
516
|
+
...(usage ? { usage } : {}),
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
// Changes are on the branch. Open a PR only when the job asked for one.
|
|
520
|
+
if (job.pr) {
|
|
521
|
+
const prUrl = await openPullRequest({
|
|
522
|
+
owner: job.repo.owner,
|
|
523
|
+
name: job.repo.name,
|
|
524
|
+
ghToken: job.ghToken,
|
|
525
|
+
head: pushBranch,
|
|
526
|
+
base: job.repo.baseBranch,
|
|
527
|
+
pr: job.pr,
|
|
528
|
+
apiBase: job.githubApiBase,
|
|
529
|
+
// The provider (set by the server from the configured backend) selects GitHub-PR vs
|
|
530
|
+
// GitLab-MR authoritatively; the clone URL supplies the GitLab REST base + project path.
|
|
531
|
+
// The harness's git auth is already host-neutral.
|
|
532
|
+
cloneUrl: job.repo.cloneUrl,
|
|
533
|
+
...(job.repo.provider ? { provider: job.repo.provider } : {}),
|
|
534
|
+
signal: opts.signal,
|
|
535
|
+
});
|
|
536
|
+
return { pushed: true, prUrl, branch: pushBranch, summary, stats, ...(usage ? { usage } : {}) };
|
|
537
|
+
}
|
|
538
|
+
return { pushed: true, branch: pushBranch, summary, stats, ...(usage ? { usage } : {}) };
|
|
539
|
+
}
|
|
540
|
+
/**
|
|
541
|
+
* Conflict-resolution coding flow (the conflict-resolver): clone the PR head `branch`
|
|
542
|
+
* (full history), merge `origin/<mergeBase>` into it to surface the Git conflicts, run
|
|
543
|
+
* the agent to resolve them, then complete the merge commit and push back onto the SAME
|
|
544
|
+
* branch (no new branch / PR) so the PR becomes mergeable and CI re-runs. Diverges from
|
|
545
|
+
* the ordinary coding flow only in needing a full clone, a base→branch merge to produce
|
|
546
|
+
* the conflicts, the conflict hunks surfaced into the prompt, and a guard that refuses to
|
|
547
|
+
* push a half-resolved tree.
|
|
548
|
+
*/
|
|
549
|
+
async function runConflictResolution(job, opts) {
|
|
550
|
+
const { signal } = opts;
|
|
551
|
+
const mergeBase = job.mergeBase;
|
|
552
|
+
const logger = opts.log ?? log;
|
|
553
|
+
return withWorkspace('conflict', async (dir) => {
|
|
554
|
+
opts.onPhase?.('clone');
|
|
555
|
+
logger.info('agent(conflict): cloning PR branch (full history)');
|
|
556
|
+
// Full clone so the merge base + `origin/<mergeBase>` are present for the merge.
|
|
557
|
+
await cloneRepo({
|
|
558
|
+
repo: { ...job.repo, baseBranch: job.branch },
|
|
559
|
+
ghToken: job.ghToken,
|
|
560
|
+
dir,
|
|
561
|
+
signal,
|
|
562
|
+
full: true,
|
|
563
|
+
});
|
|
564
|
+
const prTip = await headCommit(dir, signal);
|
|
565
|
+
logger.info('agent(conflict): merging base into PR branch', { base: mergeBase });
|
|
566
|
+
const clean = await mergeBranch(dir, mergeBase, signal);
|
|
567
|
+
// No conflicts to resolve. If base brought new commits the merge advanced the branch,
|
|
568
|
+
// so push it; otherwise the branch is already up to date — a no-op we leave alone (a
|
|
569
|
+
// gate that keeps seeing GitHub report this branch as "conflicting" is then a
|
|
570
|
+
// base-resolution problem, not the agent's — logged so that loop is diagnosable).
|
|
571
|
+
if (clean) {
|
|
572
|
+
if ((await headCommit(dir, signal)) === prTip) {
|
|
573
|
+
logger.info('agent(conflict): base merged clean and branch already up to date', {
|
|
574
|
+
base: mergeBase,
|
|
575
|
+
});
|
|
576
|
+
return {
|
|
577
|
+
pushed: false,
|
|
578
|
+
branch: job.branch,
|
|
579
|
+
summary: 'No conflicts: the branch is already up to date with its base.',
|
|
580
|
+
stats: { toolCalls: 0, assistantChars: 0 },
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
opts.onPhase?.('push');
|
|
584
|
+
logger.info('agent(conflict): base merged clean — pushing the merge commit');
|
|
585
|
+
await pushBranch(dir, job.branch, job.ghToken, signal);
|
|
586
|
+
return {
|
|
587
|
+
pushed: true,
|
|
588
|
+
branch: job.branch,
|
|
589
|
+
summary: 'Merged the base in cleanly (no conflicts to resolve).',
|
|
590
|
+
stats: { toolCalls: 0, assistantChars: 0 },
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
// The merge left conflicts in the working tree. Surface the EXACT files + hunks to the
|
|
594
|
+
// agent: the generic task prompt alone never told it which files conflict (or even that
|
|
595
|
+
// there were conflicts), so it would drift onto the original feature task. Lead with the
|
|
596
|
+
// conflict; keep the task only as trailing reference.
|
|
597
|
+
const conflicted = await unmergedPaths(dir, signal);
|
|
598
|
+
opts.onPhase?.('agent');
|
|
599
|
+
logger.info('agent(conflict): resolving conflicts with agent', { conflicted });
|
|
600
|
+
const diff = await conflictDiff(dir, conflicted, signal);
|
|
601
|
+
const userPrompt = buildConflictPrompt(mergeBase, job.branch, conflicted, diff, job.userPrompt);
|
|
602
|
+
const { summary, stats, stderrTail, usage } = await runAgentInWorkspace({
|
|
603
|
+
dir,
|
|
604
|
+
systemPrompt: job.systemPrompt,
|
|
605
|
+
userPrompt,
|
|
606
|
+
model: job.model,
|
|
607
|
+
harness: job.harness,
|
|
608
|
+
subscriptionToken: job.subscriptionToken,
|
|
609
|
+
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
610
|
+
ambientAuth: job.ambientAuth,
|
|
611
|
+
proxyBaseUrl: job.proxyBaseUrl,
|
|
612
|
+
sessionToken: job.sessionToken,
|
|
613
|
+
contextFiles: job.contextFiles,
|
|
614
|
+
guardLimits: job.guardLimits,
|
|
615
|
+
}, opts);
|
|
616
|
+
// Never push a half-resolved tree: if any conflict markers / unmerged paths remain,
|
|
617
|
+
// the PR would still be broken. Fail so the engine can retry / notify.
|
|
618
|
+
const unresolved = await unmergedPaths(dir, signal);
|
|
619
|
+
if (unresolved.length > 0) {
|
|
620
|
+
logger.error('agent(conflict): unresolved conflicts remain, refusing to push', {
|
|
621
|
+
unresolved: unresolved.length,
|
|
622
|
+
});
|
|
623
|
+
return {
|
|
624
|
+
pushed: false,
|
|
625
|
+
branch: job.branch,
|
|
626
|
+
summary,
|
|
627
|
+
stats,
|
|
628
|
+
error: unresolvedReason(unresolved, stats, stderrTail),
|
|
629
|
+
failureCause: 'agent',
|
|
630
|
+
...(usage ? { usage } : {}),
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
// Complete the merge commit with the agent's resolution staged, then push.
|
|
634
|
+
await commitAll(dir, `Merge ${mergeBase} into ${job.branch}`, signal);
|
|
635
|
+
opts.onPhase?.('push');
|
|
636
|
+
logger.info('agent(conflict): pushing resolved branch', { ...stats });
|
|
637
|
+
await pushBranch(dir, job.branch, job.ghToken, signal);
|
|
638
|
+
return { pushed: true, branch: job.branch, summary, stats, ...(usage ? { usage } : {}) };
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* The conflict-focused user prompt: lead with the exact conflicted files and their hunks
|
|
643
|
+
* (so the model acts on the real conflict, not the original feature task), then carry the
|
|
644
|
+
* task only as trailing reference. The role/system prompt frames it as a merge-conflict
|
|
645
|
+
* resolution; this gives it the concrete material.
|
|
646
|
+
*/
|
|
647
|
+
function buildConflictPrompt(baseBranch, prBranch, conflicted, diff, taskReference) {
|
|
648
|
+
const fileList = conflicted.map((p) => `- ${p}`).join('\n');
|
|
649
|
+
const parts = [
|
|
650
|
+
`The base branch \`${baseBranch}\` was merged into this pull-request branch ` +
|
|
651
|
+
`\`${prBranch}\` and left Git merge conflicts in the following ${conflicted.length} ` +
|
|
652
|
+
`file(s):`,
|
|
653
|
+
'',
|
|
654
|
+
fileList,
|
|
655
|
+
'',
|
|
656
|
+
'Resolve EVERY conflict in these files: open each one, understand both sides of each ' +
|
|
657
|
+
'`<<<<<<<` / `=======` / `>>>>>>>` region, and edit it to a correct result that ' +
|
|
658
|
+
"preserves the intent of BOTH the base changes and this PR's changes — never just " +
|
|
659
|
+
'discard one side. Remove every conflict marker and leave the project building. Do ' +
|
|
660
|
+
'not create a new branch or PR; the harness completes the merge commit and pushes once ' +
|
|
661
|
+
'no conflict markers remain.',
|
|
662
|
+
'',
|
|
663
|
+
'Conflict hunks (`git diff` of the conflicted files):',
|
|
664
|
+
'',
|
|
665
|
+
'```diff',
|
|
666
|
+
diff,
|
|
667
|
+
'```',
|
|
668
|
+
];
|
|
669
|
+
const ref = taskReference.trim();
|
|
670
|
+
if (ref) {
|
|
671
|
+
parts.push('', 'For reference, the task this pull request implements:', '', ref);
|
|
672
|
+
}
|
|
673
|
+
return parts.join('\n');
|
|
674
|
+
}
|
|
675
|
+
/** Human-readable reason the agent failed to fully resolve the conflicts. */
|
|
676
|
+
function unresolvedReason(unresolved, stats, stderrTail) {
|
|
677
|
+
const cause = agentNeverActed(stats) ? NEVER_ACTED_CAUSE : '';
|
|
678
|
+
const sample = unresolved.slice(0, 10).join(', ');
|
|
679
|
+
return (`The agent did not resolve all merge conflicts ` +
|
|
680
|
+
`(${unresolved.length} file(s) still conflicted: ${sample}).${cause}` +
|
|
681
|
+
agentOutputTail(stderrTail));
|
|
682
|
+
}
|
|
683
|
+
/**
|
|
684
|
+
* Repo-bootstrap coding flow (the bootstrapper): with a reference architecture, clone it →
|
|
685
|
+
* the agent adapts it in place per the instructions; without one (`fromScratch`), start from
|
|
686
|
+
* an empty directory → the agent scaffolds the new service. Either way the result's history
|
|
687
|
+
* is reset to a single commit and force-pushed to the SEPARATE, pre-created target repo's
|
|
688
|
+
* default branch. Diverges from the ordinary coding flow in pushing to a different repo with
|
|
689
|
+
* a reinitialised history rather than a work branch + PR on the cloned repo.
|
|
690
|
+
*/
|
|
691
|
+
async function runBootstrap(job, opts) {
|
|
692
|
+
const { signal } = opts;
|
|
693
|
+
const boot = job.bootstrap;
|
|
694
|
+
const fromScratch = boot.fromScratch === true;
|
|
695
|
+
const logger = (opts.log ?? log).child({ target: `${boot.target.owner}/${boot.target.name}` });
|
|
696
|
+
return withWorkspace('boot', async (dir) => {
|
|
697
|
+
if (!fromScratch) {
|
|
698
|
+
opts.onPhase?.('clone');
|
|
699
|
+
logger.info('agent(bootstrap): cloning reference architecture', {
|
|
700
|
+
reference: `${job.repo.owner}/${job.repo.name}`,
|
|
701
|
+
});
|
|
702
|
+
await cloneRepo({
|
|
703
|
+
repo: { ...job.repo, baseBranch: job.branch },
|
|
704
|
+
ghToken: job.ghToken,
|
|
705
|
+
dir,
|
|
706
|
+
signal,
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
else {
|
|
710
|
+
logger.info('agent(bootstrap): scaffolding from scratch (no reference)');
|
|
711
|
+
}
|
|
712
|
+
opts.onPhase?.('agent');
|
|
713
|
+
logger.info('agent(bootstrap): running agent');
|
|
714
|
+
const { summary, stats, stderrTail, usage } = await runAgentInWorkspace({
|
|
715
|
+
dir,
|
|
716
|
+
systemPrompt: job.systemPrompt,
|
|
717
|
+
userPrompt: job.userPrompt,
|
|
718
|
+
model: job.model,
|
|
719
|
+
harness: job.harness,
|
|
720
|
+
subscriptionToken: job.subscriptionToken,
|
|
721
|
+
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
722
|
+
ambientAuth: job.ambientAuth,
|
|
723
|
+
proxyBaseUrl: job.proxyBaseUrl,
|
|
724
|
+
sessionToken: job.sessionToken,
|
|
725
|
+
guardLimits: job.guardLimits,
|
|
726
|
+
}, opts);
|
|
727
|
+
// Guard against a no-op run: Pi can exit cleanly having done nothing (e.g. it never
|
|
728
|
+
// reached the model), and a force-push would then publish an empty tree — leaving the
|
|
729
|
+
// run "succeeded" but the repo bare. Fail with a structured error (carrying what the
|
|
730
|
+
// agent did) instead of pushing nothing.
|
|
731
|
+
if (!(await producedRepoContent(dir, !fromScratch, signal))) {
|
|
732
|
+
const error = bootstrapNoOpReason(!fromScratch, stats, summary, stderrTail);
|
|
733
|
+
logger.error('agent(bootstrap): agent produced no content, refusing to push', { ...stats });
|
|
734
|
+
return { summary, stats, error, failureCause: 'agent', ...(usage ? { usage } : {}) };
|
|
735
|
+
}
|
|
736
|
+
opts.onPhase?.('push');
|
|
737
|
+
logger.info('agent(bootstrap): pushing bootstrapped contents', { ...stats });
|
|
738
|
+
// Bootstrap always resets history to one commit + force-pushes (the fresh history
|
|
739
|
+
// shares no ancestor with whatever boilerplate the new repo was created with).
|
|
740
|
+
await reinitAndPush({
|
|
741
|
+
dir,
|
|
742
|
+
target: boot.target,
|
|
743
|
+
ghToken: job.ghToken,
|
|
744
|
+
message: fromScratch
|
|
745
|
+
? 'Bootstrap new repository'
|
|
746
|
+
: `Bootstrap from ${job.repo.owner}/${job.repo.name}`,
|
|
747
|
+
});
|
|
748
|
+
logger.info('agent(bootstrap): complete', { defaultBranch: boot.target.defaultBranch });
|
|
749
|
+
return { defaultBranch: boot.target.defaultBranch, summary, stats, ...(usage ? { usage } : {}) };
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Whether the bootstrapper actually produced repository content, so a no-op run (the agent
|
|
754
|
+
* never reached the model / never wrote anything) is failed rather than force-pushed as an
|
|
755
|
+
* empty repo. With a reference architecture, "produced content" means the agent changed the
|
|
756
|
+
* clone; scaffolding from scratch, it means at least one file now exists in the working
|
|
757
|
+
* directory. (The harness writes its prompt context to Pi's global `~/.pi/agent/AGENTS.md`,
|
|
758
|
+
* never into `dir`, so nothing here needs to be filtered out as harness boilerplate.)
|
|
759
|
+
*/
|
|
760
|
+
export async function producedRepoContent(dir, hasReference, signal) {
|
|
761
|
+
if (hasReference)
|
|
762
|
+
return hasAgentChanges(dir, signal);
|
|
763
|
+
return containsAnyFile(dir);
|
|
764
|
+
}
|
|
765
|
+
/**
|
|
766
|
+
* Whether `dir` contains at least one regular file anywhere in its tree, walking
|
|
767
|
+
* depth-first and stopping at the FIRST file found — so the cost is bounded by how
|
|
768
|
+
* quickly a file turns up (a scaffold almost always writes a root-level file), not by
|
|
769
|
+
* the size of the produced tree (a full recursive `readdir` would materialise every
|
|
770
|
+
* entry before the check).
|
|
771
|
+
*/
|
|
772
|
+
async function containsAnyFile(dir) {
|
|
773
|
+
const handle = await opendir(dir);
|
|
774
|
+
try {
|
|
775
|
+
for await (const entry of handle) {
|
|
776
|
+
if (entry.isFile())
|
|
777
|
+
return true;
|
|
778
|
+
if (entry.isDirectory() && (await containsAnyFile(join(dir, entry.name))))
|
|
779
|
+
return true;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
catch {
|
|
783
|
+
// A directory that vanished mid-walk has nothing to contribute.
|
|
784
|
+
}
|
|
785
|
+
return false;
|
|
786
|
+
}
|
|
787
|
+
/** Human-readable bootstrap no-op reason, embedding what the agent did so the cause is visible. */
|
|
788
|
+
function bootstrapNoOpReason(hasReference, stats, summary, stderrTail) {
|
|
789
|
+
const what = hasReference
|
|
790
|
+
? 'made no changes to the reference architecture'
|
|
791
|
+
: 'scaffolded no files';
|
|
792
|
+
const cause = agentNeverActed(stats) ? NEVER_ACTED_CAUSE : '';
|
|
793
|
+
return (`the bootstrapper agent ${what} ` +
|
|
794
|
+
`(tool calls: ${stats.toolCalls}, assistant output: ${stats.assistantChars} chars).${cause}` +
|
|
795
|
+
agentOutputTail(stderrTail, summary));
|
|
796
|
+
}
|
|
797
|
+
/** Human-readable reason a read-only run produced no usable output. */
|
|
798
|
+
function noOutputReason(stats, stderrTail) {
|
|
799
|
+
const cause = agentNeverActed(stats)
|
|
800
|
+
? ' (the agent never acted — it most likely could not reach the model)'
|
|
801
|
+
: '';
|
|
802
|
+
return `the agent produced no report${cause}.${agentOutputTail(stderrTail)}`;
|
|
803
|
+
}
|
|
804
|
+
/** Human-readable reason a structured run produced no parseable JSON. */
|
|
805
|
+
function noStructuredReason(stats, stderrTail, diagnostics) {
|
|
806
|
+
const cause = agentNeverActed(stats)
|
|
807
|
+
? NEVER_ACTED_CAUSE
|
|
808
|
+
: ' The agent did not return a parseable JSON object.';
|
|
809
|
+
return `the agent produced no structured result.${cause}${diagnostics ? diagnosticsSuffix(diagnostics) : ''}${agentOutputTail(stderrTail)}`;
|
|
810
|
+
}
|