@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.
Files changed (58) hide show
  1. package/README.md +47 -0
  2. package/dist/agent-env.d.ts +17 -0
  3. package/dist/agent-env.js +47 -0
  4. package/dist/agent-runner.d.ts +11 -2
  5. package/dist/agent-runner.js +3 -48
  6. package/dist/agent.d.ts +0 -11
  7. package/dist/agent.js +7 -132
  8. package/dist/captured-command.d.ts +1 -1
  9. package/dist/captured-command.js +3 -2
  10. package/dist/coding-agent.d.ts +35 -0
  11. package/dist/coding-agent.js +213 -41
  12. package/dist/docker-status.d.ts +89 -0
  13. package/dist/docker-status.js +147 -0
  14. package/dist/frontend-infra.js +4 -3
  15. package/dist/git.d.ts +48 -5
  16. package/dist/git.js +93 -26
  17. package/dist/guard-driver.d.ts +71 -0
  18. package/dist/guard-driver.js +171 -0
  19. package/dist/harness-server.js +13 -0
  20. package/dist/infra-standup.d.ts +69 -0
  21. package/dist/infra-standup.js +182 -0
  22. package/dist/job.d.ts +10 -0
  23. package/dist/multi-repo-coding.d.ts +17 -0
  24. package/dist/multi-repo-coding.js +55 -8
  25. package/dist/pi-workspace.d.ts +11 -0
  26. package/dist/pi-workspace.js +47 -0
  27. package/dist/pi.d.ts +23 -0
  28. package/dist/pi.js +16 -9
  29. package/dist/progress-guard.d.ts +56 -10
  30. package/dist/progress-guard.js +84 -22
  31. package/dist/runner.d.ts +1 -1
  32. package/dist/salvage.d.ts +180 -0
  33. package/dist/salvage.js +289 -0
  34. package/dist/usage-attribution.d.ts +8 -0
  35. package/dist/usage-attribution.js +9 -0
  36. package/dist/workspace-probe.d.ts +85 -0
  37. package/dist/workspace-probe.js +124 -0
  38. package/package.json +4 -4
  39. package/src/agent-env.ts +49 -0
  40. package/src/agent-runner.ts +14 -53
  41. package/src/agent.ts +7 -158
  42. package/src/captured-command.ts +3 -2
  43. package/src/coding-agent.ts +252 -44
  44. package/src/docker-status.ts +201 -0
  45. package/src/frontend-infra.ts +4 -3
  46. package/src/git.ts +104 -26
  47. package/src/guard-driver.ts +203 -0
  48. package/src/harness-server.ts +13 -0
  49. package/src/infra-standup.ts +218 -0
  50. package/src/job.ts +10 -0
  51. package/src/multi-repo-coding.ts +59 -8
  52. package/src/pi-workspace.ts +72 -0
  53. package/src/pi.ts +42 -12
  54. package/src/progress-guard.ts +110 -34
  55. package/src/runner.ts +1 -1
  56. package/src/salvage.ts +407 -0
  57. package/src/usage-attribution.ts +9 -0
  58. package/src/workspace-probe.ts +155 -0
@@ -49,6 +49,14 @@ export declare function claudeUsage(raw: unknown): {
49
49
  *
50
50
  * Clamped at 0 per side: a CLI whose terminal figure is LOWER than its own per-turn sum has
51
51
  * reported the two inconsistently, and negative spend is not a thing to record.
52
+ *
53
+ * {@link HarnessCallMetric.spendOnly} is decided HERE, and it is not the same question as
54
+ * `standsForJob`. The row never occupies a turn, but whether it is a CALL depends on whether any
55
+ * turn was narrated beside it: with costed turns present this only corrects THEIR under-reporting
56
+ * (counting it would report one phantom call per dispatch), while with none it is the job's ONLY
57
+ * record and excluding it would report a step that spent tokens across zero calls. Only this
58
+ * function can answer that — the backend records a job's calls in BATCHES as the live drain
59
+ * delivers them, so a batch holding just this row cannot tell the two cases apart.
52
60
  */
53
61
  export declare function unaccountedUsageCall(parentCalls: readonly HarnessCallMetric[], usage: {
54
62
  inputTokens: number;
@@ -62,6 +62,14 @@ export function claudeUsage(raw) {
62
62
  *
63
63
  * Clamped at 0 per side: a CLI whose terminal figure is LOWER than its own per-turn sum has
64
64
  * reported the two inconsistently, and negative spend is not a thing to record.
65
+ *
66
+ * {@link HarnessCallMetric.spendOnly} is decided HERE, and it is not the same question as
67
+ * `standsForJob`. The row never occupies a turn, but whether it is a CALL depends on whether any
68
+ * turn was narrated beside it: with costed turns present this only corrects THEIR under-reporting
69
+ * (counting it would report one phantom call per dispatch), while with none it is the job's ONLY
70
+ * record and excluding it would report a step that spent tokens across zero calls. Only this
71
+ * function can answer that — the backend records a job's calls in BATCHES as the live drain
72
+ * delivers them, so a batch holding just this row cannot tell the two cases apart.
65
73
  */
66
74
  export function unaccountedUsageCall(parentCalls, usage) {
67
75
  if (!usage)
@@ -92,5 +100,6 @@ export function unaccountedUsageCall(parentCalls, usage) {
92
100
  outputTokens,
93
101
  finishReason: null,
94
102
  standsForJob: true,
103
+ spendOnly: parentCalls.length > 0,
95
104
  };
96
105
  }
@@ -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.132.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.318.1",
29
- "@cat-factory/server": "0.306.1",
30
- "@cat-factory/spend": "0.16.13",
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",
@@ -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
+ }
@@ -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 { ProgressGuard, type ProgressGuardLimits } from './progress-guard.js'
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 `process.env` at spawn, so the
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: { ...process.env, ...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 { captureRedactedOutput, redactSecrets, registerKnownSecrets } from './redact.js'
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
- * `{...process.env, ...agentEnv}`, so this is how per-job values reach the agent (and the shell
341
- * tools it spawns) WITHOUT mutating the harness's own `process.env` — which is shared by every
342
- * concurrent job when the harness runs as a native host process. Empty `env` ⇒ `opts` unchanged.
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
@@ -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 the process env),
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: { ...process.env, ...opts.agentEnv },
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.