@cat-factory/executor-harness 1.132.3 → 1.135.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 +49 -0
- package/dist/agent-capabilities.d.ts +21 -24
- package/dist/agent-capabilities.js +22 -50
- package/dist/agent-env.d.ts +17 -0
- package/dist/agent-env.js +47 -0
- package/dist/agent-runner.d.ts +18 -2
- package/dist/agent-runner.js +29 -231
- package/dist/agent-shared.d.ts +14 -5
- package/dist/agent-shared.js +14 -5
- package/dist/agent.d.ts +0 -11
- package/dist/agent.js +7 -138
- package/dist/captured-command.d.ts +1 -1
- package/dist/captured-command.js +3 -2
- package/dist/claude-cli.d.ts +90 -0
- package/dist/claude-cli.js +181 -0
- package/dist/claude-home.d.ts +41 -0
- package/dist/claude-home.js +159 -0
- package/dist/coding-agent.d.ts +35 -0
- package/dist/coding-agent.js +213 -41
- package/dist/docker-status.d.ts +89 -0
- package/dist/docker-status.js +147 -0
- package/dist/frontend-infra.js +4 -3
- package/dist/git.d.ts +48 -5
- package/dist/git.js +93 -26
- package/dist/guard-driver.d.ts +71 -0
- package/dist/guard-driver.js +171 -0
- package/dist/harness-server.js +13 -0
- package/dist/infra-standup.d.ts +69 -0
- package/dist/infra-standup.js +182 -0
- package/dist/job.d.ts +10 -0
- package/dist/multi-repo-coding.d.ts +17 -0
- package/dist/multi-repo-coding.js +61 -16
- package/dist/pi-workspace.d.ts +11 -0
- package/dist/pi-workspace.js +126 -57
- package/dist/pi.d.ts +8 -0
- package/dist/pi.js +16 -9
- package/dist/progress-guard.d.ts +56 -10
- package/dist/progress-guard.js +84 -22
- package/dist/runner.d.ts +1 -1
- package/dist/salvage.d.ts +180 -0
- package/dist/salvage.js +289 -0
- package/dist/workspace-probe.d.ts +85 -0
- package/dist/workspace-probe.js +124 -0
- package/package.json +4 -4
- package/src/agent-capabilities.ts +25 -51
- package/src/agent-env.ts +49 -0
- package/src/agent-runner.ts +40 -267
- package/src/agent-shared.ts +16 -5
- package/src/agent.ts +7 -164
- package/src/captured-command.ts +3 -2
- package/src/claude-cli.ts +217 -0
- package/src/claude-home.ts +233 -0
- package/src/coding-agent.ts +252 -44
- package/src/docker-status.ts +201 -0
- package/src/frontend-infra.ts +4 -3
- package/src/git.ts +104 -26
- package/src/guard-driver.ts +203 -0
- package/src/harness-server.ts +13 -0
- package/src/infra-standup.ts +218 -0
- package/src/job.ts +10 -0
- package/src/multi-repo-coding.ts +65 -16
- package/src/pi-workspace.ts +161 -57
- package/src/pi.ts +27 -12
- package/src/progress-guard.ts +110 -34
- package/src/runner.ts +1 -1
- package/src/salvage.ts +407 -0
- package/src/workspace-probe.ts +155 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// The run's infra stand-up: the docker-compose dependencies a local-mode service declares, and
|
|
2
|
+
// the frontend build/serve + WireMock flow the UI-test runs use instead. Split out of agent.ts,
|
|
3
|
+
// which owns the agent MODES; this owns what surrounds a mode with the dependencies it needs and
|
|
4
|
+
// guarantees the matching teardown. `manageInfra` is the one entry point a mode calls.
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { promisify } from 'node:util';
|
|
7
|
+
import { probeDockerServing, readDockerStatus, resolveDockerVerdict, } from './docker-status.js';
|
|
8
|
+
import { standUpFrontend, tearDownFrontend } from './frontend-infra.js';
|
|
9
|
+
import { captureRedactedOutput, redactSecrets } from './redact.js';
|
|
10
|
+
const exec = promisify(execFile);
|
|
11
|
+
/**
|
|
12
|
+
* Bring the service's docker-compose dependencies up (local infra only). Best-effort:
|
|
13
|
+
* runs `docker compose -f <path> up -d --wait` in the checkout. A compose failure is logged
|
|
14
|
+
* and surfaced to the agent (as a prompt note) rather than failing the job — the agent can
|
|
15
|
+
* still run unit-level tests and report what it could. A no-op for ephemeral / no-infra /
|
|
16
|
+
* no-compose-path runs.
|
|
17
|
+
*
|
|
18
|
+
* A CONFIRMED absence of a Docker daemon short-circuits it: the container's own probe
|
|
19
|
+
* ({@link readDockerStatus}, recorded by `entrypoint.sh`) already knows there is nothing to
|
|
20
|
+
* talk to, so running compose against it would only turn a fact this container holds into a
|
|
21
|
+
* connection error the agent has to interpret. The record then carries `dockerAvailable: false`
|
|
22
|
+
* and the stated cause, which is what makes the Tester step say why it ran no infra instead of
|
|
23
|
+
* looking like a Tester that simply chose not to. Anything OTHER than a confirmed absence
|
|
24
|
+
* attempts as before (`DockerStatus.available` in docker-status.ts states why "undecided" is its
|
|
25
|
+
* own value).
|
|
26
|
+
*
|
|
27
|
+
* "Confirmed", not merely recorded: {@link resolveDockerVerdict} re-checks a recorded absence
|
|
28
|
+
* against a live daemon first, so a warm-pool container whose sidecar came up late is not
|
|
29
|
+
* latched into refusing infra that works. `probe` is that check, injected so the unit suite can
|
|
30
|
+
* state both answers on a machine that has its own daemon either way.
|
|
31
|
+
*
|
|
32
|
+
* Whether it succeeds or fails, the (redacted, bounded) command output is captured into a
|
|
33
|
+
* {@link InfraSetupRecord} returned alongside the prompt `note`, so the backend can surface
|
|
34
|
+
* the in-container dependency stand-up logs on the Tester step — the failure-class artifact
|
|
35
|
+
* the orchestrator-side provisioning logs can't see.
|
|
36
|
+
*
|
|
37
|
+
* Exported for the unit suite (like {@link buildInfraNotes}): the refusal branch is a decision
|
|
38
|
+
* this container makes about itself, and the acceptance suite can only exercise it on a machine
|
|
39
|
+
* where the daemon genuinely fails.
|
|
40
|
+
*/
|
|
41
|
+
export async function standUpInfra(dir, infra, signal, logger, probe = probeDockerServing) {
|
|
42
|
+
if (infra.environment !== 'local' || infra.noInfraDependencies || !infra.composePath) {
|
|
43
|
+
return { started: false };
|
|
44
|
+
}
|
|
45
|
+
const startedAt = Date.now();
|
|
46
|
+
const recorded = await readDockerStatus();
|
|
47
|
+
const docker = await resolveDockerVerdict(recorded, probe);
|
|
48
|
+
if (docker.refusal) {
|
|
49
|
+
const note = `the dependencies could not be started: ${docker.refusal}`;
|
|
50
|
+
logger.warn('agent(explore): infra stand-up refused, no docker daemon', {
|
|
51
|
+
composePath: infra.composePath,
|
|
52
|
+
dockerSource: recorded.source,
|
|
53
|
+
dockerReason: recorded.reason,
|
|
54
|
+
});
|
|
55
|
+
return {
|
|
56
|
+
started: false,
|
|
57
|
+
note,
|
|
58
|
+
record: {
|
|
59
|
+
started: false,
|
|
60
|
+
dockerAvailable: false,
|
|
61
|
+
composePath: infra.composePath,
|
|
62
|
+
at: Date.now(),
|
|
63
|
+
durationMs: Date.now() - startedAt,
|
|
64
|
+
error: redactSecrets(note),
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
try {
|
|
69
|
+
logger.info('agent(explore): standing up infra', { composePath: infra.composePath });
|
|
70
|
+
// Raise maxBuffer well above the 1MB default so a chatty compose stand-up can't fail the
|
|
71
|
+
// (best-effort) infra step with ENOBUFS; the captured output is tail-bounded on storage.
|
|
72
|
+
const { stdout, stderr } = await exec('docker', ['compose', '-f', infra.composePath, 'up', '-d', '--wait'], { cwd: dir, signal, timeout: 5 * 60_000, maxBuffer: 16 * 1024 * 1024 });
|
|
73
|
+
const logs = captureRedactedOutput(stdout, stderr);
|
|
74
|
+
return {
|
|
75
|
+
started: true,
|
|
76
|
+
record: {
|
|
77
|
+
started: true,
|
|
78
|
+
dockerAvailable: true,
|
|
79
|
+
composePath: infra.composePath,
|
|
80
|
+
at: Date.now(),
|
|
81
|
+
durationMs: Date.now() - startedAt,
|
|
82
|
+
...(logs ? { logs } : {}),
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
const note = err instanceof Error ? err.message : String(err);
|
|
88
|
+
logger.warn('agent(explore): infra stand-up failed', { error: note });
|
|
89
|
+
// `execFile` rejections carry the partial stdout/stderr on the error object — capture them
|
|
90
|
+
// so the stored logs explain the failure (a port clash, a pull-auth error, an exited
|
|
91
|
+
// dependency), not just the one-line exit message.
|
|
92
|
+
const e = err;
|
|
93
|
+
const logs = captureRedactedOutput(e.stdout, e.stderr);
|
|
94
|
+
return {
|
|
95
|
+
started: false,
|
|
96
|
+
note,
|
|
97
|
+
record: {
|
|
98
|
+
started: false,
|
|
99
|
+
// A compose failure with a REACHABLE daemon: the two `false`s above and here are
|
|
100
|
+
// different diagnoses (nothing to talk to vs the stack itself did not come up), and
|
|
101
|
+
// only stating both keeps the second from being read as the first. Read off the
|
|
102
|
+
// RESOLVED verdict, so a container whose daemon came up after boot claims the daemon it
|
|
103
|
+
// actually reached rather than the one its boot record still denies.
|
|
104
|
+
...(docker.available === true ? { dockerAvailable: true } : {}),
|
|
105
|
+
composePath: infra.composePath,
|
|
106
|
+
at: Date.now(),
|
|
107
|
+
durationMs: Date.now() - startedAt,
|
|
108
|
+
error: redactSecrets(note),
|
|
109
|
+
...(logs ? { logs } : {}),
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Stand the run's infra up and return a single cleanup handle, dispatching on the spec's
|
|
116
|
+
* `kind`: the frontend UI-test flow (`kind: 'frontend'`) builds/serves the app + WireMock as
|
|
117
|
+
* processes (torn down by killing them); the default backend-service flow stands the
|
|
118
|
+
* docker-compose stack up (torn down with `docker compose down`). Unifying the two here keeps
|
|
119
|
+
* `runExploreMode` free of the branch and guarantees the matching teardown runs in its finally.
|
|
120
|
+
*
|
|
121
|
+
* `dir` is the clone ROOT; `workDir` is the service subtree (equal to `dir` when the run is not
|
|
122
|
+
* monorepo-scoped). The docker-compose stand-up runs at the root (its `composePath` is
|
|
123
|
+
* repo-relative), but the FRONTEND stand-up runs in `workDir`: a monorepo frontend's
|
|
124
|
+
* `package.json` / `outputDir` / `mocks/` all live under the service subtree, so installing,
|
|
125
|
+
* building, serving and seeding WireMock from the root would target the wrong directory.
|
|
126
|
+
*/
|
|
127
|
+
export async function manageInfra(dir, workDir, infra, opts, logger) {
|
|
128
|
+
if (infra.kind === 'frontend') {
|
|
129
|
+
// `onActivity` feeds the inactivity watchdog through the frontend build/serve stand-up,
|
|
130
|
+
// which (unlike docker-compose's 5-min-capped `up`) can run past the inactivity window.
|
|
131
|
+
// Runs in `workDir` so a monorepo frontend builds/serves from its own package subtree.
|
|
132
|
+
const fe = await standUpFrontend(workDir, infra, opts, logger);
|
|
133
|
+
return {
|
|
134
|
+
...(fe.note ? { note: fe.note } : {}),
|
|
135
|
+
...(fe.serveUrl ? { serveUrl: fe.serveUrl } : {}),
|
|
136
|
+
record: fe.record,
|
|
137
|
+
cleanup: () => tearDownFrontend(fe.processes, logger),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
const standUp = await standUpInfra(dir, infra, opts.signal, logger);
|
|
141
|
+
return {
|
|
142
|
+
...(standUp.note ? { note: standUp.note } : {}),
|
|
143
|
+
...(standUp.record ? { record: standUp.record } : {}),
|
|
144
|
+
cleanup: () => tearDownInfra(dir, infra),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Build the dynamic infra notes appended to the agent's user prompt from a stand-up outcome.
|
|
149
|
+
* A stand-up problem (a failed build / compose) is flagged as a concern to test around; a
|
|
150
|
+
* frontend serve URL points the UI tester at the app that was just built + served and pre-empts
|
|
151
|
+
* a live-backend CORS failure being mis-reported as an app defect. Pure (no IO) so the exact
|
|
152
|
+
* wording + ordering is unit-tested; returns the notes in order (problem first, serve URL next).
|
|
153
|
+
*/
|
|
154
|
+
export function buildInfraNotes(managed) {
|
|
155
|
+
const notes = [];
|
|
156
|
+
if (managed.note) {
|
|
157
|
+
notes.push(`standing the infra up reported a problem (${managed.note}). Test what you can and ` +
|
|
158
|
+
`flag any dependency-related gaps as concerns.`);
|
|
159
|
+
}
|
|
160
|
+
if (managed.serveUrl) {
|
|
161
|
+
notes.push(`The frontend under test is built and served at ${managed.serveUrl}, with its other ` +
|
|
162
|
+
`backend upstreams handled by WireMock. Drive your UI tests against ${managed.serveUrl}. ` +
|
|
163
|
+
`If a call to a live backend fails with a CORS / cross-origin error, that is an infra ` +
|
|
164
|
+
`gap (the backend must allow the ${managed.serveUrl} origin), not an app defect — flag ` +
|
|
165
|
+
`it as a concern rather than a failing test.`);
|
|
166
|
+
}
|
|
167
|
+
return notes;
|
|
168
|
+
}
|
|
169
|
+
/** Tear the docker-compose dependencies down (best-effort; a no-op when none were started). */
|
|
170
|
+
async function tearDownInfra(dir, infra) {
|
|
171
|
+
if (infra.environment !== 'local' || infra.noInfraDependencies || !infra.composePath)
|
|
172
|
+
return;
|
|
173
|
+
try {
|
|
174
|
+
await exec('docker', ['compose', '-f', infra.composePath, 'down', '-v'], {
|
|
175
|
+
cwd: dir,
|
|
176
|
+
timeout: 2 * 60_000,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
// The container is ephemeral and torn down with the run anyway — ignore.
|
|
181
|
+
}
|
|
182
|
+
}
|
package/dist/job.d.ts
CHANGED
|
@@ -546,6 +546,16 @@ export interface GuardLimitsSpec {
|
|
|
546
546
|
export interface InfraSetupRecord {
|
|
547
547
|
/** Whether `docker compose up --wait` succeeded (the dependencies are up). */
|
|
548
548
|
started: boolean;
|
|
549
|
+
/**
|
|
550
|
+
* Whether this container had a Docker daemon to talk to at all, when it knows.
|
|
551
|
+
*
|
|
552
|
+
* The distinction `started` alone cannot make: a stack that failed to come up and a container
|
|
553
|
+
* with no daemon are the same `started: false` and opposite problems (one is the service's
|
|
554
|
+
* compose file, the other is the executor image or the sandbox it runs in). ABSENT means this
|
|
555
|
+
* container's probe reached no verdict — never assume `false` from absence, which is the exact
|
|
556
|
+
* mistake that let a daemon-less image read as an ordinary infra failure for months.
|
|
557
|
+
*/
|
|
558
|
+
dockerAvailable?: boolean;
|
|
549
559
|
/** The repo-relative compose file that was stood up. */
|
|
550
560
|
composePath?: string;
|
|
551
561
|
/** Epoch ms the stand-up attempt finished. */
|
|
@@ -14,3 +14,20 @@ import type { RunOptions } from './runner.js';
|
|
|
14
14
|
* git helpers, so the per-repo clone/commit/push/PR mechanics match the single-repo path exactly.
|
|
15
15
|
*/
|
|
16
16
|
export declare function runMultiRepoCoding(job: AgentJob, opts?: RunOptions): Promise<AgentResult>;
|
|
17
|
+
/**
|
|
18
|
+
* The checkouts the no-progress guard's working-tree bound may judge this run on.
|
|
19
|
+
*
|
|
20
|
+
* A multi-repo run's cwd is the workspace ROOT, which is no git repository, so the guard's default
|
|
21
|
+
* (probe the cwd) asks git a question with no answer: every probe throws, the driver re-arms
|
|
22
|
+
* forever, and the bound is permanently unenforceable — strictly worse than the tool-name reading
|
|
23
|
+
* it replaced. The writable legs are the repositories this run may change, so they are what it
|
|
24
|
+
* has to show progress in.
|
|
25
|
+
*
|
|
26
|
+
* A READ-ONLY reference leg is excluded, and not merely as an optimisation: the run is forbidden
|
|
27
|
+
* to write to it, so a change appearing there is not this run making progress and must never be
|
|
28
|
+
* what saves it from the bound.
|
|
29
|
+
*/
|
|
30
|
+
export declare function probeDirsForLegs(legs: readonly {
|
|
31
|
+
dir: string;
|
|
32
|
+
readOnly?: boolean;
|
|
33
|
+
}[]): string[];
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { mkdir } from 'node:fs/promises';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { makeDirClaimer } from './checkout-dir.js';
|
|
4
|
-
import { branchAheadOfBase, branchHasCommitsSince, cloneExistingBranch, cloneRepo, commitTrackedEdits, createBranch, excludeFromGit, fetchReferenceBranches, headCommit,
|
|
4
|
+
import { branchAheadOfBase, branchHasCommitsSince, cloneExistingBranch, cloneRepo, commitTrackedEdits, createBranch, excludeFromGit, fetchReferenceBranches, headCommit, pushBranch, refreshFromBaseIfClean, remoteBranchExists, } from './git.js';
|
|
5
|
+
import { salvageOnlyNotice, salvageUntrackedWork } from './salvage.js';
|
|
5
6
|
import { openPullRequest } from './vcs-api.js';
|
|
6
7
|
import { applyPrDescription, PR_DESCRIPTION_FILE, readPrDescription } from './pr-description.js';
|
|
7
8
|
import { runAgentInWorkspace, withWorkspace } from './pi-workspace.js';
|
|
8
9
|
import { log } from './logger.js';
|
|
9
10
|
import { prepopulateDependencies, withDependencyNote } from './dependency-install.js';
|
|
11
|
+
import { agentCapabilities } from './agent-shared.js';
|
|
10
12
|
import { resolvePrTemplateNote, withPrTemplateNote, } from './pr-template.js';
|
|
11
13
|
import { noChangesReason } from './coding-agent.js';
|
|
12
14
|
/**
|
|
@@ -132,17 +134,16 @@ export async function runMultiRepoCoding(job, opts = {}) {
|
|
|
132
134
|
proxyBaseUrl: job.proxyBaseUrl,
|
|
133
135
|
proxyPhasePath: job.proxyPhasePath,
|
|
134
136
|
sessionToken: job.sessionToken,
|
|
135
|
-
webToolsGuidance: job.webToolsGuidance,
|
|
136
|
-
webSearchProxy: job.webSearch,
|
|
137
137
|
guardLimits: job.guardLimits,
|
|
138
138
|
...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
|
|
139
|
-
// Skills
|
|
140
|
-
// are properties of the AGENT KIND, not of the checkout layout.
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
...(job
|
|
144
|
-
...(job.designImages ? { designImages: job.designImages } : {}),
|
|
139
|
+
// Skills, tool servers and web research apply to a multi-repo run exactly as to a
|
|
140
|
+
// single-repo one: they are properties of the AGENT KIND, not of the checkout layout.
|
|
141
|
+
// Through the shared helper rather than re-spread here, which is what let this flow
|
|
142
|
+
// drift from the single-repo one in the first place.
|
|
143
|
+
...agentCapabilities(job),
|
|
145
144
|
multiRepo: true,
|
|
145
|
+
// What the no-progress guard's working-tree bound decides on: see {@link probeDirsForLegs}.
|
|
146
|
+
repoDirs: probeDirsForLegs(legs),
|
|
146
147
|
}, opts);
|
|
147
148
|
// Commit forgotten tracked edits, then push + open a PR for each repo the run actually changed.
|
|
148
149
|
const { primaryPushed, primaryPrUrl, peerPullRequests } = await pushMultiRepoLegs(legs, job, logger, opts, root, prTemplate);
|
|
@@ -288,6 +289,32 @@ async function prepareMultiRepoCheckouts(root, legs, job, logger, opts) {
|
|
|
288
289
|
}
|
|
289
290
|
}
|
|
290
291
|
}
|
|
292
|
+
/**
|
|
293
|
+
* The checkouts the no-progress guard's working-tree bound may judge this run on.
|
|
294
|
+
*
|
|
295
|
+
* A multi-repo run's cwd is the workspace ROOT, which is no git repository, so the guard's default
|
|
296
|
+
* (probe the cwd) asks git a question with no answer: every probe throws, the driver re-arms
|
|
297
|
+
* forever, and the bound is permanently unenforceable — strictly worse than the tool-name reading
|
|
298
|
+
* it replaced. The writable legs are the repositories this run may change, so they are what it
|
|
299
|
+
* has to show progress in.
|
|
300
|
+
*
|
|
301
|
+
* A READ-ONLY reference leg is excluded, and not merely as an optimisation: the run is forbidden
|
|
302
|
+
* to write to it, so a change appearing there is not this run making progress and must never be
|
|
303
|
+
* what saves it from the bound.
|
|
304
|
+
*/
|
|
305
|
+
export function probeDirsForLegs(legs) {
|
|
306
|
+
return legs.filter((leg) => !leg.readOnly).map((leg) => leg.dir);
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Put {@link salvageOnlyNotice} at the top of a pull request that is nothing but a salvage.
|
|
310
|
+
*
|
|
311
|
+
* Only the BODY is marked. A title carrying it would follow the PR into every list and
|
|
312
|
+
* notification a maintainer sees, which is a lot of noise for a caveat that belongs beside the
|
|
313
|
+
* diff, and the salvage commit's own message elaborates on it there.
|
|
314
|
+
*/
|
|
315
|
+
function withSalvageOnlyNote(pr, salvageOnly) {
|
|
316
|
+
return salvageOnly ? { ...pr, body: `${salvageOnlyNotice()}\n\n${pr.body}` } : pr;
|
|
317
|
+
}
|
|
291
318
|
/**
|
|
292
319
|
* Push phase for {@link runMultiRepoCoding}: commit forgotten tracked edits, then push + open a PR
|
|
293
320
|
* for each repo the run actually changed (a repo the agent left untouched is skipped — no branch,
|
|
@@ -323,19 +350,37 @@ prTemplate) {
|
|
|
323
350
|
const agentPrDescription = (await readPrDescription(leg.dir, readOptions)) ??
|
|
324
351
|
(leg.primary ? await readPrDescription(root, readOptions) : undefined);
|
|
325
352
|
await commitTrackedEdits(leg.dir, job.commitMessage ?? leg.pr?.title ?? 'Agent changes', signal);
|
|
326
|
-
|
|
353
|
+
// Whether the leg carried COMMITTED work before the salvage, read here and not after it.
|
|
354
|
+
// Afterwards the salvage's own commit makes every leg it touched look advanced, and the two
|
|
355
|
+
// are not the same claim: work the agent committed to this repo is a change it chose to make,
|
|
356
|
+
// where a salvage-only leg is a branch built entirely out of files it left lying in that
|
|
357
|
+
// checkout. Both are worth keeping; only one is worth presenting as a proposed change without
|
|
358
|
+
// saying where it came from.
|
|
359
|
+
const committedOwnWork = await branchHasCommitsSince(leg.dir, leg.baseSha, signal);
|
|
360
|
+
// Recover this leg's new files, exactly as the single-repo settle path does and for the same
|
|
361
|
+
// reason: `commitTrackedEdits` captures edits to files git ALREADY tracks, so a new file the
|
|
362
|
+
// agent created and never added used to be listed, warned about and dropped. Runs BEFORE the
|
|
363
|
+
// advanced/no-op judgement below, so a leg whose only work is those files is pushed rather
|
|
364
|
+
// than read as untouched.
|
|
365
|
+
const salvage = await salvageUntrackedWork({
|
|
366
|
+
dir: leg.dir,
|
|
367
|
+
occasion: { kind: 'settled' },
|
|
368
|
+
logger: logger.child({ repo: leg.dirName }),
|
|
369
|
+
...(signal ? { signal } : {}),
|
|
370
|
+
});
|
|
371
|
+
const salvageOnly = !committedOwnWork && salvage.status === 'committed';
|
|
372
|
+
const advanced = committedOwnWork || salvage.status === 'committed';
|
|
327
373
|
let hasWork = advanced || leg.resumed;
|
|
328
374
|
if (leg.resumed && !advanced) {
|
|
329
375
|
const ahead = await branchAheadOfBase(leg.dir, leg.repo.baseBranch, leg.ghToken, signal);
|
|
330
376
|
if (ahead === false)
|
|
331
377
|
hasWork = false;
|
|
332
378
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
logger.warn('multi-repo: uncommitted new files left behind (not pushed)', {
|
|
379
|
+
if (salvage.status === 'refused' || salvage.status === 'failed') {
|
|
380
|
+
logger.warn('multi-repo: new files were left behind and are NOT in the push', {
|
|
336
381
|
repo: leg.dirName,
|
|
337
|
-
count:
|
|
338
|
-
|
|
382
|
+
count: salvage.fileCount,
|
|
383
|
+
reason: salvage.reason,
|
|
339
384
|
});
|
|
340
385
|
}
|
|
341
386
|
if (!hasWork) {
|
|
@@ -351,7 +396,7 @@ prTemplate) {
|
|
|
351
396
|
ghToken: leg.ghToken,
|
|
352
397
|
head: leg.workBranch,
|
|
353
398
|
base: leg.repo.baseBranch,
|
|
354
|
-
pr: applyPrDescription(leg.pr, agentPrDescription),
|
|
399
|
+
pr: withSalvageOnlyNote(applyPrDescription(leg.pr, agentPrDescription), salvageOnly),
|
|
355
400
|
// See the single-repo call site: refresh a resumed leg's already-open PR, but only
|
|
356
401
|
// when the text is the agent's own briefing rather than the dispatch-time fallback.
|
|
357
402
|
...(agentPrDescription ? { refreshExisting: true } : {}),
|
package/dist/pi-workspace.d.ts
CHANGED
|
@@ -43,6 +43,17 @@ export declare function acquireRepoCheckout<T>(opts: {
|
|
|
43
43
|
export interface AgentRunSpec {
|
|
44
44
|
/** The prepared working directory (cloned/scaffolded by the caller). */
|
|
45
45
|
dir: string;
|
|
46
|
+
/**
|
|
47
|
+
* The git checkouts this pass may change, for the no-progress guard's working-tree bound.
|
|
48
|
+
* Absent ⇒ `[dir]`, which is right whenever the agent's cwd is (or is inside) the one repo.
|
|
49
|
+
*
|
|
50
|
+
* A MULTI-REPO run is the exception the default cannot serve: its cwd is a workspace ROOT
|
|
51
|
+
* holding sibling checkouts and is no repository itself, so probing it asks git a question with
|
|
52
|
+
* no answer, every probe throws, and the bound goes permanently unenforced. Such a caller names
|
|
53
|
+
* its writable legs here instead. A read-only reference checkout is deliberately NOT named: the
|
|
54
|
+
* run is forbidden to write to it, so a change there is not this run making progress.
|
|
55
|
+
*/
|
|
56
|
+
repoDirs?: readonly string[];
|
|
46
57
|
/** Composed role + best-practice fragments; written to Pi's global AGENTS.md context. */
|
|
47
58
|
systemPrompt: string;
|
|
48
59
|
/** The concrete task prompt handed to Pi. */
|
package/dist/pi-workspace.js
CHANGED
|
@@ -6,6 +6,7 @@ import { readEffortReport } from './effort.js';
|
|
|
6
6
|
import { log } from './logger.js';
|
|
7
7
|
import { CONTEXT_DIR, materializeContextFiles, materializeSkillResources, phasedProxyBaseUrl, runPi, webSearchConfigFromEnv, webSearchProxyEnv, writeAgentsContext, writePiModelsConfig, writeWebToolsConfig, } from './pi.js';
|
|
8
8
|
import { mergeGuardLimits, progressGuardLimitsFromEnv, } from './progress-guard.js';
|
|
9
|
+
import { composeWorkspaceProbes, createWorkspaceProbe, readHeadOrEmpty, } from './workspace-probe.js';
|
|
9
10
|
import { runSubscriptionHarness } from './agent-runner.js';
|
|
10
11
|
// The thin base every container agent shares: an ephemeral working directory, and
|
|
11
12
|
// one Pi run inside it driven by the harness-written context. The agents differ in
|
|
@@ -131,6 +132,80 @@ export async function checkoutHasBlueprints(dir, multiRepo) {
|
|
|
131
132
|
const checks = await Promise.all(legs.filter((e) => e.isDirectory()).map((e) => isBlueprintDir(join(dir, e.name))));
|
|
132
133
|
return checks.some(Boolean);
|
|
133
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* Run one pass on a SUBSCRIPTION harness (Claude Code / Codex): the leased-credential path, which
|
|
137
|
+
* shares only the checkout preparation with the Pi one.
|
|
138
|
+
*
|
|
139
|
+
* Split out of {@link runAgentInWorkspace} for its cyclomatic budget. It is also the honest seam:
|
|
140
|
+
* everything here is a decision about what the vendor's own CLI is handed, while everything left
|
|
141
|
+
* behind is about the proxy-backed Pi run.
|
|
142
|
+
*/
|
|
143
|
+
async function runSubscriptionInWorkspace(harness, spec, opts, prepared) {
|
|
144
|
+
const { contextFiles, imageGuidance, workspaceProbe } = prepared;
|
|
145
|
+
// Ambient (native) mode authenticates with the developer's own CLI login, so no
|
|
146
|
+
// leased token is required; otherwise the leased subscription token is mandatory.
|
|
147
|
+
if (!spec.ambientAuth && !spec.subscriptionToken) {
|
|
148
|
+
throw new Error(`The ${harness} harness requires a subscription token`);
|
|
149
|
+
}
|
|
150
|
+
const subOutcome = await runSubscriptionHarness(harness, {
|
|
151
|
+
cwd: spec.dir,
|
|
152
|
+
model: spec.model,
|
|
153
|
+
systemPrompt: `${subscriptionSystemPrompt(spec.systemPrompt, contextFiles)}${imageGuidance}`,
|
|
154
|
+
userPrompt: spec.userPrompt,
|
|
155
|
+
...(spec.subscriptionToken ? { subscriptionToken: spec.subscriptionToken } : {}),
|
|
156
|
+
subscriptionBaseUrl: spec.subscriptionBaseUrl,
|
|
157
|
+
...(spec.ambientAuth ? { ambientAuth: true } : {}),
|
|
158
|
+
...(spec.skills?.length ? { skills: spec.skills } : {}),
|
|
159
|
+
...(spec.mcpServers?.length ? { mcpServers: spec.mcpServers } : {}),
|
|
160
|
+
// Codex's own image tool. Passed for both subscription harnesses because the option lives on
|
|
161
|
+
// the shared run options; `runClaudeCode` ignores it, since claude-code has no such tool and
|
|
162
|
+
// (unlike an MCP server) there is nothing to report as unservable — the backend never
|
|
163
|
+
// resolves a codex-served generator onto a claude-code step, because admission refuses it.
|
|
164
|
+
...(spec.generateImages ? { generateImages: true } : {}),
|
|
165
|
+
// `spec.webSearchProxy` is deliberately NOT forwarded. It states whether OUR PROXY serves web
|
|
166
|
+
// research for this run's account, which is what Pi's tools ride and what they would fail
|
|
167
|
+
// without; neither subscription CLI touches that proxy. Claude Code's `WebSearch`/`WebFetch`
|
|
168
|
+
// are served by the vendor the leased subscription already pays and are declared
|
|
169
|
+
// unconditionally (see `CLAUDE_TOOL_SET`), and Codex's surface is per-tool config rather than
|
|
170
|
+
// a list. Passing the proxy's availability here would withhold working tools on the strength
|
|
171
|
+
// of an unrelated deployment's wiring.
|
|
172
|
+
...(opts.agentEnv ? { extraEnv: opts.agentEnv } : {}),
|
|
173
|
+
signal: opts.signal,
|
|
174
|
+
// Run the SAME no-progress guard Pi gets (previously claude-code/codex had none): env
|
|
175
|
+
// defaults merged loosen-only with the kind's tuning + the backend's complexity-scaled
|
|
176
|
+
// no-edit allowance, so a claude-code run that stops making progress is killed early
|
|
177
|
+
// instead of burning the full wall-clock budget. The claude runner consumes it; codex
|
|
178
|
+
// ignores it for now (its stream isn't wired to the guard).
|
|
179
|
+
guardLimits: mergeGuardLimits(progressGuardLimitsFromEnv(), spec.guardLimits),
|
|
180
|
+
expectsEdits: spec.expectsEdits ?? true,
|
|
181
|
+
// What the guard's no-edit bound actually decides on (see `buildWorkspaceProbe`).
|
|
182
|
+
workspaceProbe,
|
|
183
|
+
onActivity: opts.onActivity,
|
|
184
|
+
onProgress: opts.onProgress,
|
|
185
|
+
// The run's tool-call trajectory, the same hook the Pi path feeds — so a subscription run
|
|
186
|
+
// and a proxied one produce the same evidence rather than one of them producing none.
|
|
187
|
+
onSpan: opts.onSpan,
|
|
188
|
+
// The tool-silence window (stuck-run audit F13), opened by whichever CLI actually runs.
|
|
189
|
+
// Wired for BOTH subscription harnesses: each reports tool activity on its own stream, so
|
|
190
|
+
// each can beat the window it opens.
|
|
191
|
+
beginToolWindow: opts.beginToolWindow,
|
|
192
|
+
// Per-slice review capture, so a parallel review's finished slices are persisted as they
|
|
193
|
+
// land rather than only in the terminal output. Only the subscription runners fan work out
|
|
194
|
+
// across subagents, so this is the only path that can produce it.
|
|
195
|
+
onSliceReviews: opts.onSliceReviews,
|
|
196
|
+
// What the CLI reported about the tool servers it loaded. Wired for BOTH subscription
|
|
197
|
+
// harnesses even though only claude-code's stream carries the report today: the hook is a
|
|
198
|
+
// pass-through, and a codex run that never calls it leaves the backend's record honestly
|
|
199
|
+
// absent rather than claiming every server it wired failed to start.
|
|
200
|
+
onToolServers: opts.onToolServers,
|
|
201
|
+
// Stream this run's per-call telemetry to the job's live drain. The subscription
|
|
202
|
+
// harnesses are the only producers of `callMetrics` (Pi's calls are metered by the LLM
|
|
203
|
+
// proxy as they happen), so this is the only path that needs the hook.
|
|
204
|
+
onCallMetric: opts.onCallMetric,
|
|
205
|
+
...(opts.log ? { log: opts.log } : {}),
|
|
206
|
+
});
|
|
207
|
+
return withEffortReport(spec.dir, subOutcome);
|
|
208
|
+
}
|
|
134
209
|
/**
|
|
135
210
|
* Write Pi's global agent context (`~/.pi/agent/AGENTS.md`) + provider config,
|
|
136
211
|
* then run Pi once in `spec.dir` and return its summary/stats/stderr. The context
|
|
@@ -167,65 +242,28 @@ export async function runAgentInWorkspace(spec, opts = {}) {
|
|
|
167
242
|
if (spec.skills?.length && !installsSkillNatively(spec)) {
|
|
168
243
|
await materializeSkillResources(spec.dir, spec.skills);
|
|
169
244
|
}
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
245
|
+
// The no-progress guard's no-edit bound asks "has this run changed the repository", and the
|
|
246
|
+
// tool names it can see are only a proxy for that: an agent writing every file through `bash`
|
|
247
|
+
// reads as making no edits at all, and the guard killed exactly such a run after it had built,
|
|
248
|
+
// tested and verified a whole service. The working tree is the honest answer, so wire the probe
|
|
249
|
+
// that reads it. Built HERE because this is the shared middle of both harness paths and the one
|
|
250
|
+
// place that knows the checkout: the guard itself stays pure and takes it injected.
|
|
251
|
+
//
|
|
252
|
+
// The baseline is HEAD as this PASS begins, not the clone's — a repair round is a fresh agent
|
|
253
|
+
// that must show its OWN progress, and judging it against the clone would let the previous
|
|
254
|
+
// round's commits satisfy its bound. A checkout with no commit yet (a scaffold-from-scratch
|
|
255
|
+
// bootstrap) has no HEAD to read; the probe then rides on the dirty-tree half alone, which is
|
|
256
|
+
// the half that matters there anyway.
|
|
257
|
+
const workspaceProbe = await buildWorkspaceProbe(spec, opts.signal);
|
|
258
|
+
// Subscription harnesses (Claude Code / Codex) authenticate with the leased token and talk
|
|
259
|
+
// direct to the vendor: no proxy config, no AGENTS.md. The system prompt is passed straight to
|
|
260
|
+
// the CLI; everything around this (clone, push, watchdogs) is unchanged.
|
|
174
261
|
if (spec.harness === 'claude-code' || spec.harness === 'codex') {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
}
|
|
180
|
-
const subOutcome = await runSubscriptionHarness(spec.harness, {
|
|
181
|
-
cwd: spec.dir,
|
|
182
|
-
model: spec.model,
|
|
183
|
-
systemPrompt: `${subscriptionSystemPrompt(spec.systemPrompt, contextFiles)}${imageGuidance}`,
|
|
184
|
-
userPrompt: spec.userPrompt,
|
|
185
|
-
...(spec.subscriptionToken ? { subscriptionToken: spec.subscriptionToken } : {}),
|
|
186
|
-
subscriptionBaseUrl: spec.subscriptionBaseUrl,
|
|
187
|
-
...(spec.ambientAuth ? { ambientAuth: true } : {}),
|
|
188
|
-
...(spec.skills?.length ? { skills: spec.skills } : {}),
|
|
189
|
-
...(spec.mcpServers?.length ? { mcpServers: spec.mcpServers } : {}),
|
|
190
|
-
// Codex's own image tool. Passed for both subscription harnesses because the option lives on
|
|
191
|
-
// the shared run options; `runClaudeCode` ignores it, since claude-code has no such tool and
|
|
192
|
-
// (unlike an MCP server) there is nothing to report as unservable — the backend never
|
|
193
|
-
// resolves a codex-served generator onto a claude-code step, because admission refuses it.
|
|
194
|
-
...(spec.generateImages ? { generateImages: true } : {}),
|
|
195
|
-
...(opts.agentEnv ? { extraEnv: opts.agentEnv } : {}),
|
|
196
|
-
signal: opts.signal,
|
|
197
|
-
// Run the SAME no-progress guard Pi gets (previously claude-code/codex had none): env
|
|
198
|
-
// defaults merged loosen-only with the kind's tuning + the backend's complexity-scaled
|
|
199
|
-
// no-edit allowance, so a claude-code run that stops making progress is killed early
|
|
200
|
-
// instead of burning the full wall-clock budget. The claude runner consumes it; codex
|
|
201
|
-
// ignores it for now (its stream isn't wired to the guard).
|
|
202
|
-
guardLimits: mergeGuardLimits(progressGuardLimitsFromEnv(), spec.guardLimits),
|
|
203
|
-
expectsEdits: spec.expectsEdits ?? true,
|
|
204
|
-
onActivity: opts.onActivity,
|
|
205
|
-
onProgress: opts.onProgress,
|
|
206
|
-
// The run's tool-call trajectory, the same hook the Pi path feeds — so a subscription run
|
|
207
|
-
// and a proxied one produce the same evidence rather than one of them producing none.
|
|
208
|
-
onSpan: opts.onSpan,
|
|
209
|
-
// The tool-silence window (stuck-run audit F13), opened by whichever CLI actually runs.
|
|
210
|
-
// Wired for BOTH subscription harnesses: each reports tool activity on its own stream, so
|
|
211
|
-
// each can beat the window it opens.
|
|
212
|
-
beginToolWindow: opts.beginToolWindow,
|
|
213
|
-
// Per-slice review capture, so a parallel review's finished slices are persisted as they
|
|
214
|
-
// land rather than only in the terminal output. Only the subscription runners fan work out
|
|
215
|
-
// across subagents, so this is the only path that can produce it.
|
|
216
|
-
onSliceReviews: opts.onSliceReviews,
|
|
217
|
-
// What the CLI reported about the tool servers it loaded. Wired for BOTH subscription
|
|
218
|
-
// harnesses even though only claude-code's stream carries the report today: the hook is a
|
|
219
|
-
// pass-through, and a codex run that never calls it leaves the backend's record honestly
|
|
220
|
-
// absent rather than claiming every server it wired failed to start.
|
|
221
|
-
onToolServers: opts.onToolServers,
|
|
222
|
-
// Stream this run's per-call telemetry to the job's live drain. The subscription
|
|
223
|
-
// harnesses are the only producers of `callMetrics` (Pi's calls are metered by the LLM
|
|
224
|
-
// proxy as they happen), so this is the only path that needs the hook.
|
|
225
|
-
onCallMetric: opts.onCallMetric,
|
|
226
|
-
...(opts.log ? { log: opts.log } : {}),
|
|
262
|
+
return await runSubscriptionInWorkspace(spec.harness, spec, opts, {
|
|
263
|
+
contextFiles,
|
|
264
|
+
imageGuidance,
|
|
265
|
+
workspaceProbe,
|
|
227
266
|
});
|
|
228
|
-
return withEffortReport(spec.dir, subOutcome);
|
|
229
267
|
}
|
|
230
268
|
if (!spec.proxyBaseUrl || !spec.sessionToken) {
|
|
231
269
|
throw new Error('The Pi harness requires proxyBaseUrl and sessionToken');
|
|
@@ -283,10 +321,41 @@ export async function runAgentInWorkspace(spec, opts = {}) {
|
|
|
283
321
|
// Start from the env/built-in defaults and apply only the per-knob overrides the
|
|
284
322
|
// backend set for this kind (loosen-only), so an unspecified knob keeps its default.
|
|
285
323
|
guardLimits: mergeGuardLimits(progressGuardLimitsFromEnv(), spec.guardLimits),
|
|
324
|
+
// What the guard's no-edit bound actually decides on (see `buildWorkspaceProbe`).
|
|
325
|
+
workspaceProbe,
|
|
286
326
|
extraEnv,
|
|
287
327
|
});
|
|
288
328
|
return withEffortReport(spec.dir, piOutcome);
|
|
289
329
|
}
|
|
330
|
+
/**
|
|
331
|
+
* The workspace probe for one agent pass: each of the pass's working trees, baselined against its
|
|
332
|
+
* own HEAD as this pass begins.
|
|
333
|
+
*
|
|
334
|
+
* Reading HEAD is the one part that can fail benignly: a scaffold-from-scratch checkout has no
|
|
335
|
+
* commit yet, so `rev-parse HEAD` errors. That is no reason to leave the bound blind, since the
|
|
336
|
+
* dirty-tree half is exactly what answers a from-scratch build — so the pass baselines against
|
|
337
|
+
* the empty sha (`readHeadOrEmpty`, which the probe itself reads HEAD through for the same
|
|
338
|
+
* reason), and any commit the agent makes reads as HEAD having moved off it.
|
|
339
|
+
*
|
|
340
|
+
* A directory that is no git repository at all makes every probe THROW, which the driver treats
|
|
341
|
+
* as inconclusive: the bound re-arms and the run is neither killed nor left to the streak bounds
|
|
342
|
+
* alone. Deliberate, and the same disposition a transient git failure gets.
|
|
343
|
+
*
|
|
344
|
+
* WHICH trees is `spec.repoDirs`, defaulted HERE rather than at the call site so the rule that a
|
|
345
|
+
* pass with no declared checkouts is judged on its own directory lives with the builder that acts
|
|
346
|
+
* on it. Several of them compose into one probe over the whole workspace (see
|
|
347
|
+
* {@link composeWorkspaceProbes}); an empty list would silently disarm the bound, so it falls back
|
|
348
|
+
* to `dir` too.
|
|
349
|
+
*/
|
|
350
|
+
async function buildWorkspaceProbe(spec, signal) {
|
|
351
|
+
const dirs = spec.repoDirs?.length ? spec.repoDirs : [spec.dir];
|
|
352
|
+
const probes = await Promise.all(dirs.map(async (dir) => createWorkspaceProbe({
|
|
353
|
+
dir,
|
|
354
|
+
baseSha: await readHeadOrEmpty(dir, signal),
|
|
355
|
+
...(signal ? { signal } : {}),
|
|
356
|
+
})));
|
|
357
|
+
return composeWorkspaceProbes(probes);
|
|
358
|
+
}
|
|
290
359
|
/**
|
|
291
360
|
* Whether the claude-code runner will install this run's skills natively (into the CLI's config
|
|
292
361
|
* dir) rather than the caller materialising them into the checkout. True ONLY for a
|
package/dist/pi.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { EffortReport } from './effort.js';
|
|
2
2
|
import { type ProgressGuardLimits } from './progress-guard.js';
|
|
3
|
+
import type { WorkspaceProbe } from './workspace-probe.js';
|
|
3
4
|
import { type PiRunStats, type RunDiagnostics } from './pi-reduction.js';
|
|
4
5
|
import { type ToolProgressWindow } from './tool-silence.js';
|
|
5
6
|
/**
|
|
@@ -438,6 +439,13 @@ export declare function runPi(opts: {
|
|
|
438
439
|
guardLimits?: ProgressGuardLimits;
|
|
439
440
|
/** Whether this run is expected to edit files (false for assess-only runs like the merger). */
|
|
440
441
|
expectsEdits?: boolean;
|
|
442
|
+
/**
|
|
443
|
+
* Probes the working tree for evidence the agent changed the repository — what the guard's
|
|
444
|
+
* no-edit bound is actually asking, as opposed to the tool names it can see. Injected (the
|
|
445
|
+
* guard stays pure) and consulted at most once per run, only when that bound is about to abort.
|
|
446
|
+
* Omitted ⇒ the bound falls back to its tool-name-only judgement.
|
|
447
|
+
*/
|
|
448
|
+
workspaceProbe?: WorkspaceProbe;
|
|
441
449
|
/**
|
|
442
450
|
* Extra environment for Pi's child process, merged over `process.env` (but under the
|
|
443
451
|
* proxy token). Used to hand the rpiv-web-tools extension its proxy-backed SearXNG
|