@cat-factory/executor-harness 1.132.1 → 1.134.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 +47 -0
- package/dist/agent-env.d.ts +17 -0
- package/dist/agent-env.js +47 -0
- package/dist/agent-runner.d.ts +11 -2
- package/dist/agent-runner.js +3 -48
- package/dist/agent.d.ts +0 -11
- package/dist/agent.js +7 -132
- package/dist/captured-command.d.ts +1 -1
- package/dist/captured-command.js +3 -2
- 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 +55 -8
- package/dist/pi-workspace.d.ts +11 -0
- package/dist/pi-workspace.js +47 -0
- package/dist/pi.d.ts +23 -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/usage-attribution.d.ts +8 -0
- package/dist/usage-attribution.js +9 -0
- package/dist/workspace-probe.d.ts +85 -0
- package/dist/workspace-probe.js +124 -0
- package/package.json +4 -4
- package/src/agent-env.ts +49 -0
- package/src/agent-runner.ts +14 -53
- package/src/agent.ts +7 -158
- package/src/captured-command.ts +3 -2
- 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 +59 -8
- package/src/pi-workspace.ts +72 -0
- package/src/pi.ts +42 -12
- package/src/progress-guard.ts +110 -34
- package/src/runner.ts +1 -1
- package/src/salvage.ts +407 -0
- package/src/usage-attribution.ts +9 -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,7 +1,8 @@
|
|
|
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';
|
|
@@ -143,6 +144,8 @@ export async function runMultiRepoCoding(job, opts = {}) {
|
|
|
143
144
|
...(job.referenceScreenshots ? { referenceScreenshots: job.referenceScreenshots } : {}),
|
|
144
145
|
...(job.designImages ? { designImages: job.designImages } : {}),
|
|
145
146
|
multiRepo: true,
|
|
147
|
+
// What the no-progress guard's working-tree bound decides on: see {@link probeDirsForLegs}.
|
|
148
|
+
repoDirs: probeDirsForLegs(legs),
|
|
146
149
|
}, opts);
|
|
147
150
|
// Commit forgotten tracked edits, then push + open a PR for each repo the run actually changed.
|
|
148
151
|
const { primaryPushed, primaryPrUrl, peerPullRequests } = await pushMultiRepoLegs(legs, job, logger, opts, root, prTemplate);
|
|
@@ -288,6 +291,32 @@ async function prepareMultiRepoCheckouts(root, legs, job, logger, opts) {
|
|
|
288
291
|
}
|
|
289
292
|
}
|
|
290
293
|
}
|
|
294
|
+
/**
|
|
295
|
+
* The checkouts the no-progress guard's working-tree bound may judge this run on.
|
|
296
|
+
*
|
|
297
|
+
* A multi-repo run's cwd is the workspace ROOT, which is no git repository, so the guard's default
|
|
298
|
+
* (probe the cwd) asks git a question with no answer: every probe throws, the driver re-arms
|
|
299
|
+
* forever, and the bound is permanently unenforceable — strictly worse than the tool-name reading
|
|
300
|
+
* it replaced. The writable legs are the repositories this run may change, so they are what it
|
|
301
|
+
* has to show progress in.
|
|
302
|
+
*
|
|
303
|
+
* A READ-ONLY reference leg is excluded, and not merely as an optimisation: the run is forbidden
|
|
304
|
+
* to write to it, so a change appearing there is not this run making progress and must never be
|
|
305
|
+
* what saves it from the bound.
|
|
306
|
+
*/
|
|
307
|
+
export function probeDirsForLegs(legs) {
|
|
308
|
+
return legs.filter((leg) => !leg.readOnly).map((leg) => leg.dir);
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Put {@link salvageOnlyNotice} at the top of a pull request that is nothing but a salvage.
|
|
312
|
+
*
|
|
313
|
+
* Only the BODY is marked. A title carrying it would follow the PR into every list and
|
|
314
|
+
* notification a maintainer sees, which is a lot of noise for a caveat that belongs beside the
|
|
315
|
+
* diff, and the salvage commit's own message elaborates on it there.
|
|
316
|
+
*/
|
|
317
|
+
function withSalvageOnlyNote(pr, salvageOnly) {
|
|
318
|
+
return salvageOnly ? { ...pr, body: `${salvageOnlyNotice()}\n\n${pr.body}` } : pr;
|
|
319
|
+
}
|
|
291
320
|
/**
|
|
292
321
|
* Push phase for {@link runMultiRepoCoding}: commit forgotten tracked edits, then push + open a PR
|
|
293
322
|
* for each repo the run actually changed (a repo the agent left untouched is skipped — no branch,
|
|
@@ -323,19 +352,37 @@ prTemplate) {
|
|
|
323
352
|
const agentPrDescription = (await readPrDescription(leg.dir, readOptions)) ??
|
|
324
353
|
(leg.primary ? await readPrDescription(root, readOptions) : undefined);
|
|
325
354
|
await commitTrackedEdits(leg.dir, job.commitMessage ?? leg.pr?.title ?? 'Agent changes', signal);
|
|
326
|
-
|
|
355
|
+
// Whether the leg carried COMMITTED work before the salvage, read here and not after it.
|
|
356
|
+
// Afterwards the salvage's own commit makes every leg it touched look advanced, and the two
|
|
357
|
+
// are not the same claim: work the agent committed to this repo is a change it chose to make,
|
|
358
|
+
// where a salvage-only leg is a branch built entirely out of files it left lying in that
|
|
359
|
+
// checkout. Both are worth keeping; only one is worth presenting as a proposed change without
|
|
360
|
+
// saying where it came from.
|
|
361
|
+
const committedOwnWork = await branchHasCommitsSince(leg.dir, leg.baseSha, signal);
|
|
362
|
+
// Recover this leg's new files, exactly as the single-repo settle path does and for the same
|
|
363
|
+
// reason: `commitTrackedEdits` captures edits to files git ALREADY tracks, so a new file the
|
|
364
|
+
// agent created and never added used to be listed, warned about and dropped. Runs BEFORE the
|
|
365
|
+
// advanced/no-op judgement below, so a leg whose only work is those files is pushed rather
|
|
366
|
+
// than read as untouched.
|
|
367
|
+
const salvage = await salvageUntrackedWork({
|
|
368
|
+
dir: leg.dir,
|
|
369
|
+
occasion: { kind: 'settled' },
|
|
370
|
+
logger: logger.child({ repo: leg.dirName }),
|
|
371
|
+
...(signal ? { signal } : {}),
|
|
372
|
+
});
|
|
373
|
+
const salvageOnly = !committedOwnWork && salvage.status === 'committed';
|
|
374
|
+
const advanced = committedOwnWork || salvage.status === 'committed';
|
|
327
375
|
let hasWork = advanced || leg.resumed;
|
|
328
376
|
if (leg.resumed && !advanced) {
|
|
329
377
|
const ahead = await branchAheadOfBase(leg.dir, leg.repo.baseBranch, leg.ghToken, signal);
|
|
330
378
|
if (ahead === false)
|
|
331
379
|
hasWork = false;
|
|
332
380
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
logger.warn('multi-repo: uncommitted new files left behind (not pushed)', {
|
|
381
|
+
if (salvage.status === 'refused' || salvage.status === 'failed') {
|
|
382
|
+
logger.warn('multi-repo: new files were left behind and are NOT in the push', {
|
|
336
383
|
repo: leg.dirName,
|
|
337
|
-
count:
|
|
338
|
-
|
|
384
|
+
count: salvage.fileCount,
|
|
385
|
+
reason: salvage.reason,
|
|
339
386
|
});
|
|
340
387
|
}
|
|
341
388
|
if (!hasWork) {
|
|
@@ -351,7 +398,7 @@ prTemplate) {
|
|
|
351
398
|
ghToken: leg.ghToken,
|
|
352
399
|
head: leg.workBranch,
|
|
353
400
|
base: leg.repo.baseBranch,
|
|
354
|
-
pr: applyPrDescription(leg.pr, agentPrDescription),
|
|
401
|
+
pr: withSalvageOnlyNote(applyPrDescription(leg.pr, agentPrDescription), salvageOnly),
|
|
355
402
|
// See the single-repo call site: refresh a resumed leg's already-open PR, but only
|
|
356
403
|
// when the text is the agent's own briefing rather than the dispatch-time fallback.
|
|
357
404
|
...(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
|
|
@@ -167,6 +168,19 @@ export async function runAgentInWorkspace(spec, opts = {}) {
|
|
|
167
168
|
if (spec.skills?.length && !installsSkillNatively(spec)) {
|
|
168
169
|
await materializeSkillResources(spec.dir, spec.skills);
|
|
169
170
|
}
|
|
171
|
+
// The no-progress guard's no-edit bound asks "has this run changed the repository", and the
|
|
172
|
+
// tool names it can see are only a proxy for that: an agent writing every file through `bash`
|
|
173
|
+
// reads as making no edits at all, and the guard killed exactly such a run after it had built,
|
|
174
|
+
// tested and verified a whole service. The working tree is the honest answer, so wire the probe
|
|
175
|
+
// that reads it. Built HERE because this is the shared middle of both harness paths and the one
|
|
176
|
+
// place that knows the checkout: the guard itself stays pure and takes it injected.
|
|
177
|
+
//
|
|
178
|
+
// The baseline is HEAD as this PASS begins, not the clone's — a repair round is a fresh agent
|
|
179
|
+
// that must show its OWN progress, and judging it against the clone would let the previous
|
|
180
|
+
// round's commits satisfy its bound. A checkout with no commit yet (a scaffold-from-scratch
|
|
181
|
+
// bootstrap) has no HEAD to read; the probe then rides on the dirty-tree half alone, which is
|
|
182
|
+
// the half that matters there anyway.
|
|
183
|
+
const workspaceProbe = await buildWorkspaceProbe(spec, opts.signal);
|
|
170
184
|
// Subscription harnesses (Claude Code / Codex) authenticate with the leased
|
|
171
185
|
// token and talk direct to the vendor — no proxy config, no AGENTS.md. The
|
|
172
186
|
// system prompt is passed straight to the CLI; everything around this (clone,
|
|
@@ -201,6 +215,8 @@ export async function runAgentInWorkspace(spec, opts = {}) {
|
|
|
201
215
|
// ignores it for now (its stream isn't wired to the guard).
|
|
202
216
|
guardLimits: mergeGuardLimits(progressGuardLimitsFromEnv(), spec.guardLimits),
|
|
203
217
|
expectsEdits: spec.expectsEdits ?? true,
|
|
218
|
+
// What the guard's no-edit bound actually decides on (see `buildWorkspaceProbe`).
|
|
219
|
+
workspaceProbe,
|
|
204
220
|
onActivity: opts.onActivity,
|
|
205
221
|
onProgress: opts.onProgress,
|
|
206
222
|
// The run's tool-call trajectory, the same hook the Pi path feeds — so a subscription run
|
|
@@ -283,10 +299,41 @@ export async function runAgentInWorkspace(spec, opts = {}) {
|
|
|
283
299
|
// Start from the env/built-in defaults and apply only the per-knob overrides the
|
|
284
300
|
// backend set for this kind (loosen-only), so an unspecified knob keeps its default.
|
|
285
301
|
guardLimits: mergeGuardLimits(progressGuardLimitsFromEnv(), spec.guardLimits),
|
|
302
|
+
// What the guard's no-edit bound actually decides on (see `buildWorkspaceProbe`).
|
|
303
|
+
workspaceProbe,
|
|
286
304
|
extraEnv,
|
|
287
305
|
});
|
|
288
306
|
return withEffortReport(spec.dir, piOutcome);
|
|
289
307
|
}
|
|
308
|
+
/**
|
|
309
|
+
* The workspace probe for one agent pass: each of the pass's working trees, baselined against its
|
|
310
|
+
* own HEAD as this pass begins.
|
|
311
|
+
*
|
|
312
|
+
* Reading HEAD is the one part that can fail benignly: a scaffold-from-scratch checkout has no
|
|
313
|
+
* commit yet, so `rev-parse HEAD` errors. That is no reason to leave the bound blind, since the
|
|
314
|
+
* dirty-tree half is exactly what answers a from-scratch build — so the pass baselines against
|
|
315
|
+
* the empty sha (`readHeadOrEmpty`, which the probe itself reads HEAD through for the same
|
|
316
|
+
* reason), and any commit the agent makes reads as HEAD having moved off it.
|
|
317
|
+
*
|
|
318
|
+
* A directory that is no git repository at all makes every probe THROW, which the driver treats
|
|
319
|
+
* as inconclusive: the bound re-arms and the run is neither killed nor left to the streak bounds
|
|
320
|
+
* alone. Deliberate, and the same disposition a transient git failure gets.
|
|
321
|
+
*
|
|
322
|
+
* WHICH trees is `spec.repoDirs`, defaulted HERE rather than at the call site so the rule that a
|
|
323
|
+
* pass with no declared checkouts is judged on its own directory lives with the builder that acts
|
|
324
|
+
* on it. Several of them compose into one probe over the whole workspace (see
|
|
325
|
+
* {@link composeWorkspaceProbes}); an empty list would silently disarm the bound, so it falls back
|
|
326
|
+
* to `dir` too.
|
|
327
|
+
*/
|
|
328
|
+
async function buildWorkspaceProbe(spec, signal) {
|
|
329
|
+
const dirs = spec.repoDirs?.length ? spec.repoDirs : [spec.dir];
|
|
330
|
+
const probes = await Promise.all(dirs.map(async (dir) => createWorkspaceProbe({
|
|
331
|
+
dir,
|
|
332
|
+
baseSha: await readHeadOrEmpty(dir, signal),
|
|
333
|
+
...(signal ? { signal } : {}),
|
|
334
|
+
})));
|
|
335
|
+
return composeWorkspaceProbes(probes);
|
|
336
|
+
}
|
|
290
337
|
/**
|
|
291
338
|
* Whether the claude-code runner will install this run's skills natively (into the CLI's config
|
|
292
339
|
* 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
|
/**
|
|
@@ -314,6 +315,21 @@ export interface HarnessCallMetric {
|
|
|
314
315
|
* turn. `CliInlineLanguageModel`'s step-level row is the same idea on the inline path.
|
|
315
316
|
*/
|
|
316
317
|
standsForJob?: boolean;
|
|
318
|
+
/**
|
|
319
|
+
* This row carries only TOKENS and stands for no model call of its own, so the backend counts it
|
|
320
|
+
* in every token sum and in no `calls` figure.
|
|
321
|
+
*
|
|
322
|
+
* Separate from {@link standsForJob}, which is only about the missing TURN ordinal. The
|
|
323
|
+
* shortfall row occupies no turn either way, but when the CLI narrated NO turns at all it is the
|
|
324
|
+
* job's only record and IS the call — nothing else recorded it, and a count excluding it would
|
|
325
|
+
* report a step that burned tokens across zero calls. The producer is the only layer that can
|
|
326
|
+
* tell the two apart: the backend sees one BATCH of a job's calls at a time (the live drain
|
|
327
|
+
* splits them across polls), so it cannot ask whether any turn was narrated.
|
|
328
|
+
*
|
|
329
|
+
* Absent on every real turn. `CliInlineLanguageModel.fileUnaccounted` makes the same split on
|
|
330
|
+
* the inline path, off its own `reported > 0`.
|
|
331
|
+
*/
|
|
332
|
+
spendOnly?: boolean;
|
|
317
333
|
}
|
|
318
334
|
/**
|
|
319
335
|
* Publish one captured model call: append it to the run's list (which becomes the terminal
|
|
@@ -423,6 +439,13 @@ export declare function runPi(opts: {
|
|
|
423
439
|
guardLimits?: ProgressGuardLimits;
|
|
424
440
|
/** Whether this run is expected to edit files (false for assess-only runs like the merger). */
|
|
425
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;
|
|
426
449
|
/**
|
|
427
450
|
* Extra environment for Pi's child process, merged over `process.env` (but under the
|
|
428
451
|
* proxy token). Used to hand the rpiv-web-tools extension its proxy-backed SearXNG
|
package/dist/pi.js
CHANGED
|
@@ -3,11 +3,13 @@ import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { killChildProcess, spawnDetached } from './process.js';
|
|
6
|
+
import { agentChildEnv } from './agent-env.js';
|
|
6
7
|
import { pathExists } from './fs-utils.js';
|
|
7
8
|
import { redactSecrets, secretsToRedact } from './redact.js';
|
|
8
9
|
import { HarnessFailure } from './failure.js';
|
|
9
10
|
import { log } from './logger.js';
|
|
10
11
|
import { ProgressGuard, progressGuardLimitsFromEnv, toolCallSignal, } from './progress-guard.js';
|
|
12
|
+
import { createGuardDriver } from './guard-driver.js';
|
|
11
13
|
import { ToolCallTracker, readToolCallId, toolCallResult, toolCallStart, } from './tool-trajectory.js';
|
|
12
14
|
import { BoundedTail, JsonlLineReader } from './jsonl-stream.js';
|
|
13
15
|
import { PI_MAX_OUTPUT_TOKENS, PiRunReducer, isObject, } from './pi-reduction.js';
|
|
@@ -562,7 +564,7 @@ export function runPi(opts) {
|
|
|
562
564
|
}
|
|
563
565
|
const child = spawn('pi', ['-p', '--mode', 'json', '--model', `proxy/${opts.model}`, '--approve'], {
|
|
564
566
|
cwd: opts.cwd,
|
|
565
|
-
env:
|
|
567
|
+
env: agentChildEnv(opts.extraEnv, { PI_PROXY_TOKEN: opts.sessionToken }),
|
|
566
568
|
// stdin is piped (not 'ignore') so the prompt is delivered out-of-band
|
|
567
569
|
// rather than on argv — see the function doc for the injection rationale.
|
|
568
570
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -597,7 +599,6 @@ export function runPi(opts) {
|
|
|
597
599
|
// spam): `{`-leading lines that failed to JSON.parse, and observer-callback throws.
|
|
598
600
|
let malformedLines = 0;
|
|
599
601
|
let observerErrors = 0;
|
|
600
|
-
const guard = new ProgressGuard(opts.guardLimits ?? progressGuardLimitsFromEnv(), opts.expectsEdits ?? true);
|
|
601
602
|
// Pairs each tool call's start with its result, numbers the pairs and captures the two
|
|
602
603
|
// bodies (scrubbed + capped). A call whose start Pi never emitted still gets an entry,
|
|
603
604
|
// timed from the previous call's end — see `ToolCallTracker`.
|
|
@@ -612,6 +613,17 @@ export function runPi(opts) {
|
|
|
612
613
|
// SIGTERM first, then SIGKILL if Pi ignores it. Shared by the watchdog abort
|
|
613
614
|
// and the no-progress guard; the `close` handler turns it into a rejection.
|
|
614
615
|
const killChild = () => killChildProcess(child);
|
|
616
|
+
// The guard, plus the driver that settles its one bound needing evidence from outside this
|
|
617
|
+
// stream (see `guard-driver.ts`). `processLine` is a synchronous reader, so the driver owns
|
|
618
|
+
// the probe's lifetime rather than this handler awaiting inside it.
|
|
619
|
+
const guardDriver = createGuardDriver({
|
|
620
|
+
guard: new ProgressGuard(opts.guardLimits ?? progressGuardLimitsFromEnv(), opts.expectsEdits ?? true),
|
|
621
|
+
probe: opts.workspaceProbe,
|
|
622
|
+
onAbort: (reason) => {
|
|
623
|
+
guardReason = reason;
|
|
624
|
+
killChild();
|
|
625
|
+
},
|
|
626
|
+
});
|
|
615
627
|
// Parse each complete JSONL record once, retaining it for the close-of-run reductions and
|
|
616
628
|
// feeding the todo-progress emitter and the no-progress guard. A tripped guard kills Pi
|
|
617
629
|
// with a diagnostic the run then fails on.
|
|
@@ -668,13 +680,8 @@ export function runPi(opts) {
|
|
|
668
680
|
}
|
|
669
681
|
}
|
|
670
682
|
}
|
|
671
|
-
if (!final && !guardReason && !aborted)
|
|
672
|
-
|
|
673
|
-
if (reason) {
|
|
674
|
-
guardReason = reason;
|
|
675
|
-
killChild();
|
|
676
|
-
}
|
|
677
|
-
}
|
|
683
|
+
if (!final && !guardReason && !aborted)
|
|
684
|
+
guardDriver.observeEvent(event);
|
|
678
685
|
};
|
|
679
686
|
// Pi's json mode is strict LF-framed JSONL; the reader buffers partial records across
|
|
680
687
|
// chunks (bounded — see `JsonlLineReader`) so we only ever parse complete ones.
|
package/dist/progress-guard.d.ts
CHANGED
|
@@ -82,11 +82,35 @@ export declare function progressGuardLimitsFromEnv(env?: NodeJS.ProcessEnv): Pro
|
|
|
82
82
|
*/
|
|
83
83
|
export declare function mergeGuardLimits(base: ProgressGuardLimits, overrides: Partial<ProgressGuardLimits> | undefined): ProgressGuardLimits;
|
|
84
84
|
/**
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
85
|
+
* What the guard concluded from one tool-call signal.
|
|
86
|
+
*
|
|
87
|
+
* `abort` is a settled judgement the caller acts on immediately: every STREAK bound
|
|
88
|
+
* (consecutive errors / web calls / MCP calls / non-action calls) reads only the stream, so the
|
|
89
|
+
* stream is all the evidence there is.
|
|
90
|
+
*
|
|
91
|
+
* `needs-workspace-evidence` is the no-edit bound, and it is deliberately NOT settled. That bound
|
|
92
|
+
* asks "has this run changed the repository yet", and the tool names are only a proxy for it: an
|
|
93
|
+
* agent writing files through `bash` reads as forty calls and no edits however much work it did.
|
|
94
|
+
* So the guard hands the question back with the diagnostic it would abort on, and the caller
|
|
95
|
+
* answers it from the working tree (see `workspace-probe.ts`) before anything is killed.
|
|
96
|
+
*/
|
|
97
|
+
export type ProgressVerdict = {
|
|
98
|
+
kind: 'abort';
|
|
99
|
+
reason: string;
|
|
100
|
+
} | {
|
|
101
|
+
kind: 'needs-workspace-evidence';
|
|
102
|
+
reason: string;
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* Live anti-rabbithole guard: fed each streamed tool-call signal, it returns a {@link
|
|
106
|
+
* ProgressVerdict} the moment a run has plainly stopped making progress, so the harness can kill
|
|
107
|
+
* the CLI early instead of letting it burn the whole budget (and then surface a useful failure
|
|
108
|
+
* instead of a generic "no file changes").
|
|
109
|
+
*
|
|
110
|
+
* PURE, SYNCHRONOUS and INCREMENTAL, so it can be unit-tested over a fixed event sequence: it
|
|
111
|
+
* spawns nothing and reads nothing off disk. The one bound that needs evidence from outside the
|
|
112
|
+
* stream says so in its verdict and lets the caller fetch it, then reports the answer back
|
|
113
|
+
* through {@link noteWorkspaceMutation} / {@link rearmNoEditBound}.
|
|
90
114
|
*/
|
|
91
115
|
export declare class ProgressGuard {
|
|
92
116
|
private readonly limits;
|
|
@@ -98,14 +122,36 @@ export declare class ProgressGuard {
|
|
|
98
122
|
private consecutiveWebCalls;
|
|
99
123
|
private consecutiveMcpCalls;
|
|
100
124
|
private consecutiveNonActionCalls;
|
|
125
|
+
private awaitingWorkspaceEvidence;
|
|
101
126
|
constructor(limits: ProgressGuardLimits,
|
|
102
127
|
/** When false (assess-only runs like the merger), the no-edit bound is skipped. */
|
|
103
128
|
expectsEdits?: boolean);
|
|
104
|
-
/** Feed one parsed Pi event; returns a
|
|
105
|
-
observe(event: Record<string, unknown>):
|
|
129
|
+
/** Feed one parsed Pi event; returns a {@link ProgressVerdict} when the run is in trouble, else null. */
|
|
130
|
+
observe(event: Record<string, unknown>): ProgressVerdict | null;
|
|
131
|
+
/**
|
|
132
|
+
* Record that the run HAS changed the repository, however it did it. Satisfies the no-edit
|
|
133
|
+
* bound permanently, exactly as a recognised edit-tool call does, matching that bound's
|
|
134
|
+
* existing semantics: it guards a run only UNTIL its first edit, because an agent that has
|
|
135
|
+
* changed the tree has demonstrably started the work.
|
|
136
|
+
*
|
|
137
|
+
* Called by the driver when a workspace probe answers a `needs-workspace-evidence` verdict
|
|
138
|
+
* positively. Idempotent, and cheap enough that a caller who probes for other reasons may also
|
|
139
|
+
* report through it.
|
|
140
|
+
*/
|
|
141
|
+
noteWorkspaceMutation(): void;
|
|
142
|
+
/**
|
|
143
|
+
* Re-arm the no-edit bound after a probe that could answer NEITHER way (it threw). The bound
|
|
144
|
+
* becomes trippable again once another `maxToolCallsWithoutEdit` action calls have gone by,
|
|
145
|
+
* rather than the run being killed on a git failure or left permanently unguarded by one.
|
|
146
|
+
*
|
|
147
|
+
* Failing open here is the deliberate half: killing a productive run is the expensive error,
|
|
148
|
+
* and the streak bounds, the inactivity watchdog and the job's wall-clock cap all still hold
|
|
149
|
+
* the run in the meantime.
|
|
150
|
+
*/
|
|
151
|
+
rearmNoEditBound(): void;
|
|
106
152
|
/**
|
|
107
|
-
* Feed one already-parsed tool-call signal (name + error flag), returning a
|
|
108
|
-
* when
|
|
153
|
+
* Feed one already-parsed tool-call signal (name + error flag), returning a {@link
|
|
154
|
+
* ProgressVerdict} when a bound is reached, else null. Split out of {@link observe} so a caller whose stream
|
|
109
155
|
* is NOT Pi's `tool_execution_end` envelope — the claude-code runner, which correlates a
|
|
110
156
|
* `tool_use` block's name with its `tool_result`'s `is_error` — can drive the SAME guard logic
|
|
111
157
|
* without synthesising a fake Pi event.
|
|
@@ -113,5 +159,5 @@ export declare class ProgressGuard {
|
|
|
113
159
|
observeSignal(tool: {
|
|
114
160
|
name: string;
|
|
115
161
|
isError: boolean;
|
|
116
|
-
}):
|
|
162
|
+
}): ProgressVerdict | null;
|
|
117
163
|
}
|