@cat-factory/executor-harness 1.132.3 → 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 +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-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 +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,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The harness's own side-channel sentinels, written INTO the checkout by the platform rather than
|
|
3
|
+
* by the agent. Excluded from the dirty check, or a run that wrote nothing but its effort report
|
|
4
|
+
* reads as productive and the guard it is meant to satisfy never fires again.
|
|
5
|
+
*
|
|
6
|
+
* Deliberately just this list. A cleverer rule (anything dotted, anything the harness has ever
|
|
7
|
+
* touched) would start excluding the agent's own work — a `.github/workflows/ci.yml` or an
|
|
8
|
+
* `eslint.config.js` is exactly the greenfield deliverable this whole change exists to keep.
|
|
9
|
+
*/
|
|
10
|
+
export declare const HARNESS_SENTINEL_FILES: readonly string[];
|
|
11
|
+
/** What a workspace probe found. Never `undefined`: a probe that cannot answer THROWS. */
|
|
12
|
+
export interface WorkspaceEvidence {
|
|
13
|
+
/** Whether the repository changed: a dirty working tree, or HEAD moved off the pass's base. */
|
|
14
|
+
mutated: boolean;
|
|
15
|
+
/** HEAD at probe time, quoted in the guard's diagnostic so the evidence is on the record. */
|
|
16
|
+
headSha: string;
|
|
17
|
+
/** Whether HEAD moved off the sha the pass started from (the agent committed). */
|
|
18
|
+
headMoved: boolean;
|
|
19
|
+
/** How many non-sentinel paths the working tree reports as changed. */
|
|
20
|
+
dirtyPathCount: number;
|
|
21
|
+
}
|
|
22
|
+
/** Probes the working tree for evidence the agent changed the repository. Throws if it cannot. */
|
|
23
|
+
export type WorkspaceProbe = () => Promise<WorkspaceEvidence>;
|
|
24
|
+
/**
|
|
25
|
+
* The non-sentinel paths in a porcelain status — the working-tree half of the evidence.
|
|
26
|
+
*
|
|
27
|
+
* Pure, so the sentinel rule is unit-testable without a repository. A sentinel matches by BASENAME
|
|
28
|
+
* as well as by exact path: the agent's cwd is a service subdirectory in a monorepo, so its effort
|
|
29
|
+
* report lands at `services/api/.cat-effort.json`, and a root-anchored comparison would miss it.
|
|
30
|
+
*/
|
|
31
|
+
export declare function agentChangedPaths(status: string): string[];
|
|
32
|
+
/**
|
|
33
|
+
* Build the probe for one pass: the working tree at `dir`, judged against the sha the pass
|
|
34
|
+
* started from.
|
|
35
|
+
*
|
|
36
|
+
* The repository changed if the tree is dirty or HEAD has moved off `baseSha`. Both are the agent
|
|
37
|
+
* changing the repo, and between them they cover the two shapes the tool-name proxy missed: files
|
|
38
|
+
* written through `bash` and left in the tree, and files written and then committed. Gitignored
|
|
39
|
+
* paths are excluded by git itself, so an `npm install` still reads as nothing.
|
|
40
|
+
*
|
|
41
|
+
* ORDER MATTERS, and carries the "is this a repository at all" question. The status runs FIRST and
|
|
42
|
+
* is never caught: a `dir` that is no git repository fails there, and the driver treats a throw as
|
|
43
|
+
* inconclusive (re-arm and warn, never abort). HEAD is read second and a failure to read it is
|
|
44
|
+
* NOT a failed probe: a scaffold-from-scratch checkout has no commit yet, so `rev-parse HEAD`
|
|
45
|
+
* errors in exactly the case the dirty-tree half was written for. It reads as the empty sha, which
|
|
46
|
+
* is what the pass baselined against too, so the two agree that HEAD has not moved and the tree
|
|
47
|
+
* decides. Catching it around the status instead would turn "not a repository" into "no evidence
|
|
48
|
+
* of change" and hand the guard a clean verdict it has no business acting on.
|
|
49
|
+
*
|
|
50
|
+
* INJECTED, never imported by the guard: the guard stays pure and synchronous so it can be driven
|
|
51
|
+
* over a fixed event sequence in a unit test, and the git access lives out here where a test
|
|
52
|
+
* substitutes a stub.
|
|
53
|
+
*/
|
|
54
|
+
export declare function createWorkspaceProbe(deps: {
|
|
55
|
+
dir: string;
|
|
56
|
+
/** HEAD when this pass began — a repair round's base is its own start, not the clone's. */
|
|
57
|
+
baseSha: string;
|
|
58
|
+
signal?: AbortSignal;
|
|
59
|
+
}): WorkspaceProbe;
|
|
60
|
+
/**
|
|
61
|
+
* HEAD at `dir`, or the empty sha where there is no commit to read.
|
|
62
|
+
*
|
|
63
|
+
* The one shared reader for both the pass BASELINE and the probe, so the two can never disagree
|
|
64
|
+
* about what a commit-less checkout is worth: if the baseline tolerates a missing HEAD and the
|
|
65
|
+
* probe throws on it, a from-scratch build has no working bound at all.
|
|
66
|
+
*/
|
|
67
|
+
export declare function readHeadOrEmpty(dir: string, signal?: AbortSignal): Promise<string>;
|
|
68
|
+
/**
|
|
69
|
+
* One probe over SEVERAL checkouts, for a run whose cwd is not itself a repository.
|
|
70
|
+
*
|
|
71
|
+
* A multi-repo run works at a WORKSPACE ROOT holding sibling checkouts, so probing the cwd asks
|
|
72
|
+
* git about a directory that is no repository: every probe throws, the driver re-arms forever and
|
|
73
|
+
* the no-edit bound is permanently unenforceable. The honest question there is "did the run change
|
|
74
|
+
* ANY of the repositories it was given", which is this.
|
|
75
|
+
*
|
|
76
|
+
* Mutation is a disjunction and inconclusiveness WINS OVER cleanliness. A leg that answers
|
|
77
|
+
* `mutated` settles it, since one changed repository is the run making progress. But a leg that
|
|
78
|
+
* THREW might have been the changed one, so `mutated: false` is only reported when every leg
|
|
79
|
+
* actually answered; otherwise this throws and the driver re-arms, which is the same fail-open
|
|
80
|
+
* disposition a single failing probe already gets. Killing a productive run is the expensive error.
|
|
81
|
+
*
|
|
82
|
+
* `headSha` joins the answering legs' shas, because a workspace has no single HEAD and quoting one
|
|
83
|
+
* leg's would put a sha in the abort diagnostic that says nothing about where the run actually was.
|
|
84
|
+
*/
|
|
85
|
+
export declare function composeWorkspaceProbes(probes: readonly WorkspaceProbe[]): WorkspaceProbe;
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { changedPathsFromPorcelain, headCommit, workingTreeStatus } from './git.js';
|
|
2
|
+
import { EFFORT_REPORT_FILE } from './effort.js';
|
|
3
|
+
import { FOLLOW_UPS_FILENAME } from './follow-ups.js';
|
|
4
|
+
import { PR_DESCRIPTION_FILE } from './pr-description.js';
|
|
5
|
+
// The working-tree answer to "has this run actually changed the repository". The no-progress
|
|
6
|
+
// guard's no-edit bound used to answer it from TOOL NAMES, which is a fact about which tool the
|
|
7
|
+
// model happened to pick, not about the repo: an agent writing every file through `bash`
|
|
8
|
+
// (heredocs, `sed -i`, `node -e`) read as forty calls and not one edit however much it had built,
|
|
9
|
+
// and the guard killed it. This module is the evidence the guard now decides on instead.
|
|
10
|
+
//
|
|
11
|
+
// Kept OFF the hot path. The bound only matters at the instant it is about to abort, so the probe
|
|
12
|
+
// runs there and at most once per run (see `guard-driver.ts`), never per tool call.
|
|
13
|
+
/**
|
|
14
|
+
* The harness's own side-channel sentinels, written INTO the checkout by the platform rather than
|
|
15
|
+
* by the agent. Excluded from the dirty check, or a run that wrote nothing but its effort report
|
|
16
|
+
* reads as productive and the guard it is meant to satisfy never fires again.
|
|
17
|
+
*
|
|
18
|
+
* Deliberately just this list. A cleverer rule (anything dotted, anything the harness has ever
|
|
19
|
+
* touched) would start excluding the agent's own work — a `.github/workflows/ci.yml` or an
|
|
20
|
+
* `eslint.config.js` is exactly the greenfield deliverable this whole change exists to keep.
|
|
21
|
+
*/
|
|
22
|
+
export const HARNESS_SENTINEL_FILES = [
|
|
23
|
+
EFFORT_REPORT_FILE,
|
|
24
|
+
FOLLOW_UPS_FILENAME,
|
|
25
|
+
PR_DESCRIPTION_FILE,
|
|
26
|
+
];
|
|
27
|
+
/**
|
|
28
|
+
* The non-sentinel paths in a porcelain status — the working-tree half of the evidence.
|
|
29
|
+
*
|
|
30
|
+
* Pure, so the sentinel rule is unit-testable without a repository. A sentinel matches by BASENAME
|
|
31
|
+
* as well as by exact path: the agent's cwd is a service subdirectory in a monorepo, so its effort
|
|
32
|
+
* report lands at `services/api/.cat-effort.json`, and a root-anchored comparison would miss it.
|
|
33
|
+
*/
|
|
34
|
+
export function agentChangedPaths(status) {
|
|
35
|
+
const sentinels = new Set(HARNESS_SENTINEL_FILES);
|
|
36
|
+
return changedPathsFromPorcelain(status).filter((path) => {
|
|
37
|
+
const basename = path.slice(path.lastIndexOf('/') + 1);
|
|
38
|
+
return !sentinels.has(path) && !sentinels.has(basename);
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Build the probe for one pass: the working tree at `dir`, judged against the sha the pass
|
|
43
|
+
* started from.
|
|
44
|
+
*
|
|
45
|
+
* The repository changed if the tree is dirty or HEAD has moved off `baseSha`. Both are the agent
|
|
46
|
+
* changing the repo, and between them they cover the two shapes the tool-name proxy missed: files
|
|
47
|
+
* written through `bash` and left in the tree, and files written and then committed. Gitignored
|
|
48
|
+
* paths are excluded by git itself, so an `npm install` still reads as nothing.
|
|
49
|
+
*
|
|
50
|
+
* ORDER MATTERS, and carries the "is this a repository at all" question. The status runs FIRST and
|
|
51
|
+
* is never caught: a `dir` that is no git repository fails there, and the driver treats a throw as
|
|
52
|
+
* inconclusive (re-arm and warn, never abort). HEAD is read second and a failure to read it is
|
|
53
|
+
* NOT a failed probe: a scaffold-from-scratch checkout has no commit yet, so `rev-parse HEAD`
|
|
54
|
+
* errors in exactly the case the dirty-tree half was written for. It reads as the empty sha, which
|
|
55
|
+
* is what the pass baselined against too, so the two agree that HEAD has not moved and the tree
|
|
56
|
+
* decides. Catching it around the status instead would turn "not a repository" into "no evidence
|
|
57
|
+
* of change" and hand the guard a clean verdict it has no business acting on.
|
|
58
|
+
*
|
|
59
|
+
* INJECTED, never imported by the guard: the guard stays pure and synchronous so it can be driven
|
|
60
|
+
* over a fixed event sequence in a unit test, and the git access lives out here where a test
|
|
61
|
+
* substitutes a stub.
|
|
62
|
+
*/
|
|
63
|
+
export function createWorkspaceProbe(deps) {
|
|
64
|
+
return async () => {
|
|
65
|
+
const status = await workingTreeStatus(deps.dir, deps.signal);
|
|
66
|
+
const dirty = agentChangedPaths(status);
|
|
67
|
+
const headSha = await readHeadOrEmpty(deps.dir, deps.signal);
|
|
68
|
+
const headMoved = headSha !== deps.baseSha;
|
|
69
|
+
return {
|
|
70
|
+
mutated: dirty.length > 0 || headMoved,
|
|
71
|
+
headSha,
|
|
72
|
+
headMoved,
|
|
73
|
+
dirtyPathCount: dirty.length,
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* HEAD at `dir`, or the empty sha where there is no commit to read.
|
|
79
|
+
*
|
|
80
|
+
* The one shared reader for both the pass BASELINE and the probe, so the two can never disagree
|
|
81
|
+
* about what a commit-less checkout is worth: if the baseline tolerates a missing HEAD and the
|
|
82
|
+
* probe throws on it, a from-scratch build has no working bound at all.
|
|
83
|
+
*/
|
|
84
|
+
export async function readHeadOrEmpty(dir, signal) {
|
|
85
|
+
return headCommit(dir, signal).catch(() => '');
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* One probe over SEVERAL checkouts, for a run whose cwd is not itself a repository.
|
|
89
|
+
*
|
|
90
|
+
* A multi-repo run works at a WORKSPACE ROOT holding sibling checkouts, so probing the cwd asks
|
|
91
|
+
* git about a directory that is no repository: every probe throws, the driver re-arms forever and
|
|
92
|
+
* the no-edit bound is permanently unenforceable. The honest question there is "did the run change
|
|
93
|
+
* ANY of the repositories it was given", which is this.
|
|
94
|
+
*
|
|
95
|
+
* Mutation is a disjunction and inconclusiveness WINS OVER cleanliness. A leg that answers
|
|
96
|
+
* `mutated` settles it, since one changed repository is the run making progress. But a leg that
|
|
97
|
+
* THREW might have been the changed one, so `mutated: false` is only reported when every leg
|
|
98
|
+
* actually answered; otherwise this throws and the driver re-arms, which is the same fail-open
|
|
99
|
+
* disposition a single failing probe already gets. Killing a productive run is the expensive error.
|
|
100
|
+
*
|
|
101
|
+
* `headSha` joins the answering legs' shas, because a workspace has no single HEAD and quoting one
|
|
102
|
+
* leg's would put a sha in the abort diagnostic that says nothing about where the run actually was.
|
|
103
|
+
*/
|
|
104
|
+
export function composeWorkspaceProbes(probes) {
|
|
105
|
+
if (probes.length === 1)
|
|
106
|
+
return probes[0];
|
|
107
|
+
return async () => {
|
|
108
|
+
const settled = await Promise.allSettled(probes.map((probe) => probe()));
|
|
109
|
+
const answered = settled.filter((result) => result.status === 'fulfilled');
|
|
110
|
+
const evidence = answered.map((result) => result.value);
|
|
111
|
+
const mutated = evidence.some((one) => one.mutated);
|
|
112
|
+
if (!mutated && answered.length < probes.length) {
|
|
113
|
+
const first = settled.find((result) => result.status === 'rejected');
|
|
114
|
+
throw new Error(`${probes.length - answered.length} of ${probes.length} checkouts could not be probed and ` +
|
|
115
|
+
`none of the rest had changed, so whether this run changed anything is unknown`, { cause: first?.status === 'rejected' ? first.reason : undefined });
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
mutated,
|
|
119
|
+
headSha: evidence.map((one) => one.headSha).join(', '),
|
|
120
|
+
headMoved: evidence.some((one) => one.headMoved),
|
|
121
|
+
dirtyPathCount: evidence.reduce((total, one) => total + one.dirtyPathCount, 0),
|
|
122
|
+
};
|
|
123
|
+
};
|
|
124
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/executor-harness",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.134.0",
|
|
4
4
|
"description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -25,9 +25,9 @@
|
|
|
25
25
|
"access": "public"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
|
-
"@cat-factory/kernel": "0.
|
|
29
|
-
"@cat-factory/server": "0.306.
|
|
30
|
-
"@cat-factory/spend": "0.16.
|
|
28
|
+
"@cat-factory/kernel": "0.321.0",
|
|
29
|
+
"@cat-factory/server": "0.306.5",
|
|
30
|
+
"@cat-factory/spend": "0.16.17",
|
|
31
31
|
"@hono/node-server": "^2.1.1",
|
|
32
32
|
"@types/node": "^26.2.0",
|
|
33
33
|
"hono": "^4.13.4",
|
package/src/agent-env.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// The environment the harness hands to everything it spawns INTO the agent's checkout: the agent
|
|
2
|
+
// CLI itself, the captured commands (dependency prepopulation, validation checks, the reproduction
|
|
3
|
+
// proof) and the frontend build/serve.
|
|
4
|
+
//
|
|
5
|
+
// The rule this exists for: the harness process and the agent's checkout are two different
|
|
6
|
+
// programs, and a few of the harness's own environment variables are actively wrong for the
|
|
7
|
+
// second. `NODE_ENV=production` is the one that bit: npm reads it as `omit=dev`, so `npm install`
|
|
8
|
+
// in a checkout silently skips devDependencies, leaving the agent with no test runner, no linter
|
|
9
|
+
// and no build tool. One measured coder run spent six of its forty budgeted tool calls
|
|
10
|
+
// discovering and undoing that (install, `npm ls`, `npm config get omit`, reinstall with
|
|
11
|
+
// `--include=dev`, re-check the bin directory, approve an install script) — all of it caused by a
|
|
12
|
+
// variable the platform set, on a project the platform knows nothing about.
|
|
13
|
+
//
|
|
14
|
+
// Stripping it at THIS seam rather than in the image is what makes it true everywhere: the
|
|
15
|
+
// container gets `NODE_ENV=production` from `entrypoint.sh` (so the harness itself still runs in
|
|
16
|
+
// production mode) and the native host transport sets the same variable on the harness process it
|
|
17
|
+
// spawns, so an image-only fix would have left the developer's own machine leaking it.
|
|
18
|
+
//
|
|
19
|
+
// Per-job env NEVER goes through `process.env` (CLAUDE.md, "Harness rules"): the native transport
|
|
20
|
+
// serves every concurrent `ambientAuth` job from one long-lived process, so a mutation here would
|
|
21
|
+
// be a cross-job leak. This function only READS the process env and returns a fresh object.
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Variables of the HARNESS PROCESS that must not reach the agent's checkout.
|
|
25
|
+
*
|
|
26
|
+
* Deliberately short, and it stays short: the bar is a variable whose value is a fact about the
|
|
27
|
+
* harness that a tool in the checkout will silently act on. It is not a sandbox (an agent can set
|
|
28
|
+
* whatever it likes in its own shell) and not a secret filter (the harness holds per-job secrets
|
|
29
|
+
* in `agentEnv`, never in `process.env`).
|
|
30
|
+
*/
|
|
31
|
+
export const HARNESS_ONLY_ENV_NAMES: readonly string[] = ['NODE_ENV']
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The child env for a command run in the agent's checkout: the harness's own environment minus
|
|
35
|
+
* {@link HARNESS_ONLY_ENV_NAMES}, with each layer merged over it in order.
|
|
36
|
+
*
|
|
37
|
+
* A layer may still SET a stripped name — a job that explicitly asks for `NODE_ENV` gets it. The
|
|
38
|
+
* strip removes what was merely INHERITED, which is the thing nobody chose.
|
|
39
|
+
*/
|
|
40
|
+
export function agentChildEnv(
|
|
41
|
+
...layers: (Record<string, string | undefined> | undefined)[]
|
|
42
|
+
): NodeJS.ProcessEnv {
|
|
43
|
+
const env: NodeJS.ProcessEnv = { ...process.env }
|
|
44
|
+
for (const name of HARNESS_ONLY_ENV_NAMES) delete env[name]
|
|
45
|
+
for (const layer of layers) {
|
|
46
|
+
if (layer) Object.assign(env, layer)
|
|
47
|
+
}
|
|
48
|
+
return env
|
|
49
|
+
}
|
package/src/agent-runner.ts
CHANGED
|
@@ -34,9 +34,12 @@ import {
|
|
|
34
34
|
type SkillSpec,
|
|
35
35
|
} from './agent-capabilities.js'
|
|
36
36
|
import { codexImageGapNote, createCodexHome, disposeCodexHome } from './codex-home.js'
|
|
37
|
-
import {
|
|
37
|
+
import type { ProgressGuardLimits } from './progress-guard.js'
|
|
38
|
+
import { createClaudeProgressGuard } from './guard-driver.js'
|
|
39
|
+
import type { WorkspaceProbe } from './workspace-probe.js'
|
|
38
40
|
import { BoundedTail, JsonlLineReader } from './jsonl-stream.js'
|
|
39
41
|
import { killChildProcess, spawnDetached } from './process.js'
|
|
42
|
+
import { agentChildEnv } from './agent-env.js'
|
|
40
43
|
import { abortReasonOf } from './failure.js'
|
|
41
44
|
import { describeProcessExit } from './process-exit.js'
|
|
42
45
|
import { redact, registerKnownSecrets, secretsToRedact } from './redact.js'
|
|
@@ -129,7 +132,7 @@ export interface SubscriptionRunOptions {
|
|
|
129
132
|
generateImages?: boolean
|
|
130
133
|
/**
|
|
131
134
|
* Extra environment for the CLI child, scoped to this job (the tester's secrets, a
|
|
132
|
-
* private-registry npmrc pointer). Merged over the inherited
|
|
135
|
+
* private-registry npmrc pointer). Merged over the inherited env at spawn (`agentChildEnv`), so the
|
|
133
136
|
* agent and its shell tools see them without the harness mutating its OWN environment — which
|
|
134
137
|
* is shared by every concurrent job under the native host-process transport. See
|
|
135
138
|
* `RunOptions.agentEnv`.
|
|
@@ -148,6 +151,14 @@ export interface SubscriptionRunOptions {
|
|
|
148
151
|
guardLimits?: ProgressGuardLimits
|
|
149
152
|
/** Whether this run is expected to edit files (false for assess-only runs); gates the no-edit bound. */
|
|
150
153
|
expectsEdits?: boolean
|
|
154
|
+
/**
|
|
155
|
+
* Probes the working tree for evidence the agent changed the repository. The guard's no-edit
|
|
156
|
+
* bound asks that question and can only see TOOL NAMES, so an agent writing files through
|
|
157
|
+
* `bash` reads as making no edits at all; this is what settles it before anything is killed.
|
|
158
|
+
* Injected so the guard stays pure, and consulted at most once per run (only when the bound is
|
|
159
|
+
* about to abort). Omitted ⇒ the bound falls back to its tool-name-only judgement.
|
|
160
|
+
*/
|
|
161
|
+
workspaceProbe?: WorkspaceProbe
|
|
151
162
|
/** Called on every chunk of CLI output, so the watchdog sees the agent is alive. */
|
|
152
163
|
onActivity?: () => void
|
|
153
164
|
/** Called with the latest subtask counts each time the CLI updates its todo/plan list. */
|
|
@@ -234,7 +245,7 @@ function streamCli(
|
|
|
234
245
|
}
|
|
235
246
|
const child = spawn(command, args, {
|
|
236
247
|
cwd: opts.cwd,
|
|
237
|
-
env:
|
|
248
|
+
env: agentChildEnv(env),
|
|
238
249
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
239
250
|
// Own process group (POSIX) so killChildProcess reaps the CLI's grandchildren too.
|
|
240
251
|
detached: spawnDetached,
|
|
@@ -607,56 +618,6 @@ function reportToolServerStartup(
|
|
|
607
618
|
if (observed) onToolServers(observed)
|
|
608
619
|
}
|
|
609
620
|
|
|
610
|
-
/**
|
|
611
|
-
* No-progress guard on the CLI's own tool stream — the claude-code analogue of runPi's guard,
|
|
612
|
-
* which cannot see the CLI's internal turns. The caller remembers each `tool_use` id's name off
|
|
613
|
-
* the assistant turn (`rememberTool`) and hands the following user turn's content to `feedGuard`,
|
|
614
|
-
* which pairs each `tool_result`'s `is_error` with that name. The FIRST reason trips it: the
|
|
615
|
-
* diagnostic is recorded (readable via `reason()`, which the catch surfaces over the generic abort
|
|
616
|
-
* message) and `guardAbort` fires — folded into streamCli's signal so a tripped guard kills the CLI
|
|
617
|
-
* the same way the external watchdog does. Disabled when the caller supplies no limits (only the
|
|
618
|
-
* external watchdog then bounds the run).
|
|
619
|
-
*
|
|
620
|
-
* Split out of {@link runClaudeCode} for the per-function line budget.
|
|
621
|
-
*/
|
|
622
|
-
function createClaudeProgressGuard(opts: SubscriptionRunOptions): {
|
|
623
|
-
rememberTool: (id: string, name: string) => void
|
|
624
|
-
feedGuard: (content: unknown[]) => void
|
|
625
|
-
guardAbort: AbortController
|
|
626
|
-
reason: () => string | undefined
|
|
627
|
-
} {
|
|
628
|
-
const guard = opts.guardLimits
|
|
629
|
-
? new ProgressGuard(opts.guardLimits, opts.expectsEdits ?? true)
|
|
630
|
-
: undefined
|
|
631
|
-
const toolNames = new Map<string, string>()
|
|
632
|
-
const guardAbort = new AbortController()
|
|
633
|
-
let guardReason: string | undefined
|
|
634
|
-
|
|
635
|
-
const feedGuard = (content: unknown[]): void => {
|
|
636
|
-
if (!guard || guardReason) return
|
|
637
|
-
for (const block of content) {
|
|
638
|
-
if (!isObject(block) || block.type !== 'tool_result') continue
|
|
639
|
-
const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined
|
|
640
|
-
const name = id ? toolNames.get(id) : undefined
|
|
641
|
-
if (id) toolNames.delete(id)
|
|
642
|
-
if (!name) continue
|
|
643
|
-
const reason = guard.observeSignal({ name, isError: block.is_error === true })
|
|
644
|
-
if (reason) {
|
|
645
|
-
guardReason = reason
|
|
646
|
-
guardAbort.abort()
|
|
647
|
-
return
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
return {
|
|
653
|
-
rememberTool: (id, name) => toolNames.set(id, name),
|
|
654
|
-
feedGuard,
|
|
655
|
-
guardAbort,
|
|
656
|
-
reason: () => guardReason,
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
|
|
660
621
|
/**
|
|
661
622
|
* The run's TRAJECTORY, on the claude-code stream: each `tool_use` block paired with the
|
|
662
623
|
* `tool_result` that answers it on the following user turn, numbered and captured (scrubbed +
|
package/src/agent.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
2
|
import { tmpdir } from 'node:os'
|
|
3
3
|
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
|
|
4
|
-
import { execFile } from 'node:child_process'
|
|
5
|
-
import { promisify } from 'node:util'
|
|
6
4
|
import type {
|
|
7
5
|
AgentInfraSpec,
|
|
8
6
|
AgentJob,
|
|
@@ -11,10 +9,13 @@ import type {
|
|
|
11
9
|
ServiceInfraSpec,
|
|
12
10
|
TestSecretSpec,
|
|
13
11
|
} from './job.js'
|
|
12
|
+
// The preview mode drives the frontend stand-up directly rather than through `manageInfra`:
|
|
13
|
+
// its serve/WireMock children outlive the job on purpose, so it wants no cleanup handle.
|
|
14
14
|
import { standUpFrontend, tearDownFrontend } from './frontend-infra.js'
|
|
15
|
+
import { buildInfraNotes, manageInfra } from './infra-standup.js'
|
|
15
16
|
import { artifactUploadEnv } from './artifact-upload.js'
|
|
16
17
|
import { configurePackageRegistries } from './package-registries.js'
|
|
17
|
-
import {
|
|
18
|
+
import { registerKnownSecrets } from './redact.js'
|
|
18
19
|
import {
|
|
19
20
|
cloneRepo,
|
|
20
21
|
commitAll,
|
|
@@ -71,158 +72,6 @@ import { log, type Logger } from './logger.js'
|
|
|
71
72
|
// general `if (job.someFlag)` dispatch; anything that doesn't need a checkout belongs in
|
|
72
73
|
// backend pre/post-ops. See backend/docs/custom-agents.md.
|
|
73
74
|
|
|
74
|
-
const exec = promisify(execFile)
|
|
75
|
-
|
|
76
|
-
/**
|
|
77
|
-
* Bring the service's docker-compose dependencies up (local infra only). Best-effort:
|
|
78
|
-
* runs `docker compose -f <path> up -d --wait` in the checkout. A missing Docker daemon
|
|
79
|
-
* or a compose failure is logged and surfaced to the agent (as a prompt note) rather
|
|
80
|
-
* than failing the job — the agent can still run unit-level tests and report what it
|
|
81
|
-
* could. A no-op for ephemeral / no-infra / no-compose-path runs.
|
|
82
|
-
*
|
|
83
|
-
* Whether it succeeds or fails, the (redacted, bounded) command output is captured into a
|
|
84
|
-
* {@link InfraSetupRecord} returned alongside the prompt `note`, so the backend can surface
|
|
85
|
-
* the in-container dependency stand-up logs on the Tester step — the failure-class artifact
|
|
86
|
-
* the orchestrator-side provisioning logs can't see.
|
|
87
|
-
*/
|
|
88
|
-
async function standUpInfra(
|
|
89
|
-
dir: string,
|
|
90
|
-
infra: ServiceInfraSpec,
|
|
91
|
-
signal: AbortSignal | undefined,
|
|
92
|
-
logger: Logger,
|
|
93
|
-
): Promise<{ started: boolean; note?: string; record?: InfraSetupRecord }> {
|
|
94
|
-
if (infra.environment !== 'local' || infra.noInfraDependencies || !infra.composePath) {
|
|
95
|
-
return { started: false }
|
|
96
|
-
}
|
|
97
|
-
const startedAt = Date.now()
|
|
98
|
-
try {
|
|
99
|
-
logger.info('agent(explore): standing up infra', { composePath: infra.composePath })
|
|
100
|
-
// Raise maxBuffer well above the 1MB default so a chatty compose stand-up can't fail the
|
|
101
|
-
// (best-effort) infra step with ENOBUFS; the captured output is tail-bounded on storage.
|
|
102
|
-
const { stdout, stderr } = await exec(
|
|
103
|
-
'docker',
|
|
104
|
-
['compose', '-f', infra.composePath, 'up', '-d', '--wait'],
|
|
105
|
-
{ cwd: dir, signal, timeout: 5 * 60_000, maxBuffer: 16 * 1024 * 1024 },
|
|
106
|
-
)
|
|
107
|
-
const logs = captureRedactedOutput(stdout, stderr)
|
|
108
|
-
return {
|
|
109
|
-
started: true,
|
|
110
|
-
record: {
|
|
111
|
-
started: true,
|
|
112
|
-
composePath: infra.composePath,
|
|
113
|
-
at: Date.now(),
|
|
114
|
-
durationMs: Date.now() - startedAt,
|
|
115
|
-
...(logs ? { logs } : {}),
|
|
116
|
-
},
|
|
117
|
-
}
|
|
118
|
-
} catch (err) {
|
|
119
|
-
const note = err instanceof Error ? err.message : String(err)
|
|
120
|
-
logger.warn('agent(explore): infra stand-up failed', { error: note })
|
|
121
|
-
// `execFile` rejections carry the partial stdout/stderr on the error object — capture them
|
|
122
|
-
// so the stored logs explain the failure (a port clash, a pull-auth error, an exited
|
|
123
|
-
// dependency), not just the one-line exit message.
|
|
124
|
-
const e = err as { stdout?: unknown; stderr?: unknown }
|
|
125
|
-
const logs = captureRedactedOutput(e.stdout, e.stderr)
|
|
126
|
-
return {
|
|
127
|
-
started: false,
|
|
128
|
-
note,
|
|
129
|
-
record: {
|
|
130
|
-
started: false,
|
|
131
|
-
composePath: infra.composePath,
|
|
132
|
-
at: Date.now(),
|
|
133
|
-
durationMs: Date.now() - startedAt,
|
|
134
|
-
error: redactSecrets(note),
|
|
135
|
-
...(logs ? { logs } : {}),
|
|
136
|
-
},
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Stand the run's infra up and return a single cleanup handle, dispatching on the spec's
|
|
143
|
-
* `kind`: the frontend UI-test flow (`kind: 'frontend'`) builds/serves the app + WireMock as
|
|
144
|
-
* processes (torn down by killing them); the default backend-service flow stands the
|
|
145
|
-
* docker-compose stack up (torn down with `docker compose down`). Unifying the two here keeps
|
|
146
|
-
* `runExploreMode` free of the branch and guarantees the matching teardown runs in its finally.
|
|
147
|
-
*
|
|
148
|
-
* `dir` is the clone ROOT; `workDir` is the service subtree (equal to `dir` when the run is not
|
|
149
|
-
* monorepo-scoped). The docker-compose stand-up runs at the root (its `composePath` is
|
|
150
|
-
* repo-relative), but the FRONTEND stand-up runs in `workDir`: a monorepo frontend's
|
|
151
|
-
* `package.json` / `outputDir` / `mocks/` all live under the service subtree, so installing,
|
|
152
|
-
* building, serving and seeding WireMock from the root would target the wrong directory.
|
|
153
|
-
*/
|
|
154
|
-
async function manageInfra(
|
|
155
|
-
dir: string,
|
|
156
|
-
workDir: string,
|
|
157
|
-
infra: AgentInfraSpec,
|
|
158
|
-
opts: RunOptions,
|
|
159
|
-
logger: Logger,
|
|
160
|
-
): Promise<{
|
|
161
|
-
note?: string
|
|
162
|
-
serveUrl?: string
|
|
163
|
-
record?: InfraSetupRecord
|
|
164
|
-
cleanup: () => Promise<void>
|
|
165
|
-
}> {
|
|
166
|
-
if (infra.kind === 'frontend') {
|
|
167
|
-
// `onActivity` feeds the inactivity watchdog through the frontend build/serve stand-up,
|
|
168
|
-
// which (unlike docker-compose's 5-min-capped `up`) can run past the inactivity window.
|
|
169
|
-
// Runs in `workDir` so a monorepo frontend builds/serves from its own package subtree.
|
|
170
|
-
const fe = await standUpFrontend(workDir, infra, opts, logger)
|
|
171
|
-
return {
|
|
172
|
-
...(fe.note ? { note: fe.note } : {}),
|
|
173
|
-
...(fe.serveUrl ? { serveUrl: fe.serveUrl } : {}),
|
|
174
|
-
record: fe.record,
|
|
175
|
-
cleanup: () => tearDownFrontend(fe.processes, logger),
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
const standUp = await standUpInfra(dir, infra, opts.signal, logger)
|
|
179
|
-
return {
|
|
180
|
-
...(standUp.note ? { note: standUp.note } : {}),
|
|
181
|
-
...(standUp.record ? { record: standUp.record } : {}),
|
|
182
|
-
cleanup: () => tearDownInfra(dir, infra),
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* Build the dynamic infra notes appended to the agent's user prompt from a stand-up outcome.
|
|
188
|
-
* A stand-up problem (a failed build / compose) is flagged as a concern to test around; a
|
|
189
|
-
* frontend serve URL points the UI tester at the app that was just built + served and pre-empts
|
|
190
|
-
* a live-backend CORS failure being mis-reported as an app defect. Pure (no IO) so the exact
|
|
191
|
-
* wording + ordering is unit-tested; returns the notes in order (problem first, serve URL next).
|
|
192
|
-
*/
|
|
193
|
-
export function buildInfraNotes(managed: { note?: string; serveUrl?: string }): string[] {
|
|
194
|
-
const notes: string[] = []
|
|
195
|
-
if (managed.note) {
|
|
196
|
-
notes.push(
|
|
197
|
-
`standing the infra up reported a problem (${managed.note}). Test what you can and ` +
|
|
198
|
-
`flag any dependency-related gaps as concerns.`,
|
|
199
|
-
)
|
|
200
|
-
}
|
|
201
|
-
if (managed.serveUrl) {
|
|
202
|
-
notes.push(
|
|
203
|
-
`The frontend under test is built and served at ${managed.serveUrl}, with its other ` +
|
|
204
|
-
`backend upstreams handled by WireMock. Drive your UI tests against ${managed.serveUrl}. ` +
|
|
205
|
-
`If a call to a live backend fails with a CORS / cross-origin error, that is an infra ` +
|
|
206
|
-
`gap (the backend must allow the ${managed.serveUrl} origin), not an app defect — flag ` +
|
|
207
|
-
`it as a concern rather than a failing test.`,
|
|
208
|
-
)
|
|
209
|
-
}
|
|
210
|
-
return notes
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/** Tear the docker-compose dependencies down (best-effort; a no-op when none were started). */
|
|
214
|
-
async function tearDownInfra(dir: string, infra: ServiceInfraSpec): Promise<void> {
|
|
215
|
-
if (infra.environment !== 'local' || infra.noInfraDependencies || !infra.composePath) return
|
|
216
|
-
try {
|
|
217
|
-
await exec('docker', ['compose', '-f', infra.composePath, 'down', '-v'], {
|
|
218
|
-
cwd: dir,
|
|
219
|
-
timeout: 2 * 60_000,
|
|
220
|
-
})
|
|
221
|
-
} catch {
|
|
222
|
-
// The container is ephemeral and torn down with the run anyway — ignore.
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
75
|
/**
|
|
227
76
|
* Parse an agent's final reply into the structured JSON `custom`, shared by the explore and
|
|
228
77
|
* coding structured-output paths. With repair enabled (default) a malformed reply gets ONE
|
|
@@ -337,9 +186,9 @@ export async function handleAgent(job: AgentJob, opts: RunOptions = {}): Promise
|
|
|
337
186
|
|
|
338
187
|
/**
|
|
339
188
|
* Layer extra child-process env onto a job's {@link RunOptions}. The agent CLI is spawned with
|
|
340
|
-
* `
|
|
341
|
-
*
|
|
342
|
-
*
|
|
189
|
+
* `agentChildEnv(agentEnv)`, so this is how per-job values reach the agent (and the shell tools it
|
|
190
|
+
* spawns) WITHOUT mutating the harness's own `process.env` — which is shared by every concurrent
|
|
191
|
+
* job when the harness runs as a native host process. Empty `env` ⇒ `opts` unchanged.
|
|
343
192
|
*/
|
|
344
193
|
function withAgentEnv(opts: RunOptions, env: Record<string, string>): RunOptions {
|
|
345
194
|
if (Object.keys(env).length === 0) return opts
|
package/src/captured-command.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process'
|
|
2
2
|
import { killChildProcess, spawnDetached } from './process.js'
|
|
3
|
+
import { agentChildEnv } from './agent-env.js'
|
|
3
4
|
import { MAX_CAPTURED_OUTPUT_CHARS, redactSecrets } from './redact.js'
|
|
4
5
|
import type { RunOptions } from './runner.js'
|
|
5
6
|
import type { Logger } from './logger.js'
|
|
@@ -54,7 +55,7 @@ export interface CapturedCommandResult {
|
|
|
54
55
|
* tree on timeout and an aborted run resolves non-zero, so a phase is never what blocks a job
|
|
55
56
|
* from settling.
|
|
56
57
|
*
|
|
57
|
-
* The child inherits the JOB's environment (`RunOptions.agentEnv` layered over
|
|
58
|
+
* The child inherits the JOB's environment (`RunOptions.agentEnv` layered over `agentChildEnv`),
|
|
58
59
|
* not a mutated global: the harness spawns this itself rather than through the agent, so without
|
|
59
60
|
* the explicit merge a native-mode job would run without the private-registry npmrc pointer (and,
|
|
60
61
|
* had this been staged in `process.env`, against a sibling job's state).
|
|
@@ -84,7 +85,7 @@ export async function runCapturedCommand(args: {
|
|
|
84
85
|
cwd,
|
|
85
86
|
detached: spawnDetached,
|
|
86
87
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
87
|
-
env:
|
|
88
|
+
env: agentChildEnv(opts.agentEnv),
|
|
88
89
|
})
|
|
89
90
|
// Keep only the tail (plus the scrub margin); guard against unbounded buffering on a chatty
|
|
90
91
|
// command.
|