@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
package/README.md CHANGED
@@ -14,6 +14,7 @@ accepts the job and returns immediately with a `jobId`; the driver then polls
14
14
  - [Job protocol](#job-protocol)
15
15
  - [What a job does](#what-a-job-does)
16
16
  - [No secrets in the image](#no-secrets-in-the-image)
17
+ - [Local infra: the container's Docker daemon](#local-infra-the-containers-docker-daemon)
17
18
  - [Layout](#layout)
18
19
  - [Runner lifecycle knobs](#runner-lifecycle-knobs)
19
20
  - [Build / test](#build--test)
@@ -316,6 +317,44 @@ signed, model-locked LLM-proxy **session token** in the request body. Pi reaches
316
317
  models only through the Worker proxy, which injects the real provider key (qwen /
317
318
  Kimi / DeepSeek) and meters spend. The provider key never enters the container.
318
319
 
320
+ ## Local infra: the container's Docker daemon
321
+
322
+ The Tester's local-mode infra stand-up runs `docker compose up --wait` INSIDE this container, so
323
+ the container needs a daemon of its own. It runs rootless, as the unprivileged `harness` user:
324
+ Cloudflare Containers (and most managed runners) give no root and no privileged mode, and a host
325
+ Docker socket would hand the container root on the host.
326
+
327
+ `entrypoint.sh` starts it, waits for it in the background, and RECORDS the verdict
328
+ (`src/docker-status.ts`). Two things consume that record and nothing else does:
329
+
330
+ - `GET /health` reports it, so an operator (and a boot-time probe) can see what the container
331
+ concluded about itself.
332
+ - The compose stand-up REFUSES on a decided absence and says why, instead of running compose
333
+ against nothing and handing the agent a connection error to interpret. The refusal rides back on
334
+ the Tester step as `infraSetup.dockerAvailable: false` with the cause.
335
+
336
+ The verdict is three-valued, and that is the point. `false` is a decided absence. `undefined` is
337
+ "nothing decided" — the probe is still in flight, or nothing recorded anything at all, which is the
338
+ normal state under the native host transport (`LOCAL_NATIVE_AGENTS`) where the harness runs on a
339
+ developer's machine with no entrypoint. Undecided attempts the stand-up; only a decided absence
340
+ refuses it.
341
+
342
+ What is recorded describes BOOT, and a container outlives its boot: a warm pool serves many jobs
343
+ from one, and a sidecar daemon that took longer to come up than the entrypoint's bounded wait
344
+ allows is serving perfectly well by the second job. So a recorded absence is a hypothesis, not the
345
+ refusal: `resolveDockerVerdict` re-checks it against a live daemon at the moment a stand-up is
346
+ about to run, and the record supplies what only the record holds, the cause and the daemon's own
347
+ log tail. `GET /health` deliberately keeps reporting the boot record rather than probing per poll,
348
+ since it is not the surface that acts on the answer.
349
+
350
+ Why it is written down at all: the image shipped for months with `docker-ce-rootless-extras` (the
351
+ wrappers that START a daemon) and no `docker-ce` (the daemon itself), and no `iproute2` for the
352
+ network rootlesskit builds. The entrypoint backgrounded the start in a subshell where its exit
353
+ status could not be observed, so every local-infra Tester run degraded silently to a no-infra run
354
+ whose only trace was a compose error in a prompt note. A capability that reports itself present and
355
+ then degrades in silence is worse than one that is absent, so the daemon is installed AND the
356
+ verdict is stated.
357
+
319
358
  ## Layout
320
359
 
321
360
  | File | Responsibility |
@@ -329,6 +368,10 @@ Kimi / DeepSeek) and meters spend. The provider key never enters the container.
329
368
  | `src/pi-reduction.ts` | Reducing a Pi event stream to what the run PRODUCED (summary, stats, diagnostics, terminal failure), FOLDED as records stream rather than over a retained array — memory is O(largest record), not O(records). The array-taking entry points offline tooling uses are defined in terms of the same reducer. |
330
369
  | `src/tool-silence.ts` | The tool-silence watchdog (F13) and the `ToolProgressWindow` an agent stream opens, beats and closes. Separate from the phase marker on purpose: a window is only meaningful while something able to reset it is running. |
331
370
  | `src/git.ts` | clone / branch / commit / push (lease-guarded: [The work-branch push is CHECKPOINTED, so it is lease-guarded](#the-work-branch-push-is-checkpointed-so-it-is-lease-guarded)) + GitHub PR creation; bootstrap history reset + force-push. |
371
+ | `src/progress-guard.ts` | The live anti-rabbithole bounds every agent run is held to, plus the tool-name vocabulary they classify calls with. PURE and SYNCHRONOUS: it spawns nothing and reads nothing off disk, so it can be driven over a fixed event sequence in a unit test. Shared by both runners, because two copies of a bound are two bounds. |
372
+ | `src/workspace-probe.ts` | The working-tree answer to "has this run actually changed the repository": a dirty tree, or HEAD moved off the sha the pass began at. What the no-edit bound decides on, since the tool names it can see are a fact about which tool the model picked and not about the repo (an agent writing everything through `bash` heredocs read as making no edits at all). Gitignored paths are excluded by git, which is what keeps a dependency install from reading as progress. Order carries the "is this a repository at all" question: the status runs first and is never caught, while a missing HEAD is the from-scratch case rather than a failure. A run whose cwd is a workspace of sibling checkouts composes one probe over them, where a checkout that could not be probed makes the answer inconclusive rather than clean. |
373
+ | `src/guard-driver.ts` | The bridge between the synchronous guard and the async evidence one of its bounds needs. Both runners feed the guard from a sync stream handler, so the driver owns the probe's lifetime: at most one probe per run, a positive answer satisfying the bound permanently, a negative one aborting with the evidence quoted, and a THROWN one inconclusive (re-arm and warn, never kill). Also hosts the claude-code stream's tool_use/tool_result pairing. |
374
+ | `src/salvage.ts` | Committing the new, untracked files an agent left behind, under a dependency/build deny-list and file-count + byte bounds that refuse ALL-or-nothing rather than truncating. Coding modes only. A credential-bearing name (`.env`, a private key, `.npmrc`) is a THIRD disposition, not a fourth junk entry: it is withheld like the rest but NAMED on the outcome, because for a secret the deny-list's usual trade inverts (a missed file is recoverable, a leaked key is not) and someone has to decide whether to rotate it. Every message here states the salvage's own provenance: a commit arriving with no explanation is indistinguishable from work someone chose to keep, and nobody chose this. |
332
375
  | `src/bootstrap.ts` | The `/bootstrap` handler (clone-or-empty → adapt → reinit + force-push). |
333
376
  | `src/blueprint.ts` | The `/blueprint` handler (decompose → render `blueprints/` → commit on branch). |
334
377
  | `src/embed.ts` | Bundled assets/templates written into the workspace. |
@@ -350,6 +393,8 @@ Kimi / DeepSeek) and meters spend. The provider key never enters the container.
350
393
  | `src/codex-images.ts` | Codex's own `image_gen` output, staged where the agent can reach it: creates `$CODEX_HOME/generated_images` as a symlink into `.cat-context/binary-output/generated/` before the CLI starts, sweeps anything a failed redirect left behind, and unlinks (never follows) the redirect at teardown — a failed unlink is REPORTED, because that unlink is what stops the recursive delete reaching the checkout. Exists because codex exposes no path for what it generated AND `$CODEX_HOME` holds the run's decrypted credential, so neither asking the agent nor sending it there is available. |
351
394
  | `src/agent-shared.ts` | The few helpers every agent MODE shares (effort-report folding, the capability fields forwarded to `runAgentInWorkspace`). |
352
395
  | `src/logger.ts` | Structured logging. |
396
+ | `src/docker-status.ts` | This container's own verdict about its Docker daemon, as recorded by `entrypoint.sh`. Three-valued on purpose: a daemon that FAILED and a daemon nobody asked about are different facts, and only a DECIDED absence refuses a stand-up. See [Local infra: the container's Docker daemon](#local-infra-the-containers-docker-daemon). |
397
+ | `src/agent-env.ts` | The env for anything the harness spawns into the agent's CHECKOUT: its own environment minus the variables that are facts about the HARNESS. Today that is `NODE_ENV` — the harness runs in production mode, and an inherited `NODE_ENV=production` makes npm omit devDependencies in a checkout that never asked for it. |
353
398
 
354
399
  ## Runner lifecycle knobs
355
400
 
@@ -373,6 +418,8 @@ runner):
373
418
  | `REPRODUCTION_TOTAL_BUDGET_MS` | `2700000` (45m) | Wall-clock ceiling on the WHOLE proof phase (every attempt, both trees, setup included). Attempts multiply two full tree runs each and the heartbeat above deliberately stops the inactivity watchdog from firing, so this is what bounds the phase. Checked at phase boundaries; exceeding it settles `inconclusive`, never a run failure. |
374
419
  | `HARNESS_TRANSCRIPT_TTL_MS` | `259200000` (3d) | How long lifted subscription-CLI session transcripts are kept before the retention sweep prunes them. |
375
420
  | `HARNESS_TRANSCRIPT_ROOT` | `<tmpdir>/cf-agent-transcripts` | Where retained session transcripts are moved to (one dir per run). Meaningful only on a reused (warm-pool) container; a per-run container is torn down with the job. The TTL sweep deletes only dirs it created (each carries a `.cf-retained` marker), so pointing this at a shared directory never touches unrelated content, though a dedicated dir is still recommended. An override on a different filesystem than the config home falls back to copy-then-remove. |
421
+ | `HARNESS_DOCKER_READY_TIMEOUT_SECONDS` | `60` | How long `entrypoint.sh` waits for the container's Docker daemon before recording it unavailable. Only a HUNG daemon pays this in full: the wait ends early both when the socket answers and when the daemon process is gone. It runs in the BACKGROUND, so it never delays the container's boot. |
422
+ | `HARNESS_DOCKER_STATUS_FILE` | `/tmp/harness-docker-status.json` | Where that verdict is recorded. `entrypoint.sh` writes it and the harness reads it, so an override must be set for BOTH (they share one process env). |
376
423
 
377
424
  ## Build / test
378
425
 
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Variables of the HARNESS PROCESS that must not reach the agent's checkout.
3
+ *
4
+ * Deliberately short, and it stays short: the bar is a variable whose value is a fact about the
5
+ * harness that a tool in the checkout will silently act on. It is not a sandbox (an agent can set
6
+ * whatever it likes in its own shell) and not a secret filter (the harness holds per-job secrets
7
+ * in `agentEnv`, never in `process.env`).
8
+ */
9
+ export declare const HARNESS_ONLY_ENV_NAMES: readonly string[];
10
+ /**
11
+ * The child env for a command run in the agent's checkout: the harness's own environment minus
12
+ * {@link HARNESS_ONLY_ENV_NAMES}, with each layer merged over it in order.
13
+ *
14
+ * A layer may still SET a stripped name — a job that explicitly asks for `NODE_ENV` gets it. The
15
+ * strip removes what was merely INHERITED, which is the thing nobody chose.
16
+ */
17
+ export declare function agentChildEnv(...layers: (Record<string, string | undefined> | undefined)[]): NodeJS.ProcessEnv;
@@ -0,0 +1,47 @@
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
+ * Variables of the HARNESS PROCESS that must not reach the agent's checkout.
24
+ *
25
+ * Deliberately short, and it stays short: the bar is a variable whose value is a fact about the
26
+ * harness that a tool in the checkout will silently act on. It is not a sandbox (an agent can set
27
+ * whatever it likes in its own shell) and not a secret filter (the harness holds per-job secrets
28
+ * in `agentEnv`, never in `process.env`).
29
+ */
30
+ export const HARNESS_ONLY_ENV_NAMES = ['NODE_ENV'];
31
+ /**
32
+ * The child env for a command run in the agent's checkout: the harness's own environment minus
33
+ * {@link HARNESS_ONLY_ENV_NAMES}, with each layer merged over it in order.
34
+ *
35
+ * A layer may still SET a stripped name — a job that explicitly asks for `NODE_ENV` gets it. The
36
+ * strip removes what was merely INHERITED, which is the thing nobody chose.
37
+ */
38
+ export function agentChildEnv(...layers) {
39
+ const env = { ...process.env };
40
+ for (const name of HARNESS_ONLY_ENV_NAMES)
41
+ delete env[name];
42
+ for (const layer of layers) {
43
+ if (layer)
44
+ Object.assign(env, layer);
45
+ }
46
+ return env;
47
+ }
@@ -2,7 +2,8 @@ import { type Logger } from './logger.js';
2
2
  import { type ToolProgressWindow } from './tool-silence.js';
3
3
  import { type HarnessCallMetric, type PiRunOutcome, type TodoProgress, type ToolSpan } from './pi.js';
4
4
  import { type McpServerSpec, type ObservedMcpServer, type SkillSpec } from './agent-capabilities.js';
5
- import { type ProgressGuardLimits } from './progress-guard.js';
5
+ import type { ProgressGuardLimits } from './progress-guard.js';
6
+ import type { WorkspaceProbe } from './workspace-probe.js';
6
7
  import { type SliceReview } from './subagents.js';
7
8
  /** Which subscription harness to run (the Pi harness uses `runPi` directly). */
8
9
  export type SubscriptionHarness = 'claude-code' | 'codex';
@@ -65,7 +66,7 @@ export interface SubscriptionRunOptions {
65
66
  generateImages?: boolean;
66
67
  /**
67
68
  * Extra environment for the CLI child, scoped to this job (the tester's secrets, a
68
- * private-registry npmrc pointer). Merged over the inherited `process.env` at spawn, so the
69
+ * private-registry npmrc pointer). Merged over the inherited env at spawn (`agentChildEnv`), so the
69
70
  * agent and its shell tools see them without the harness mutating its OWN environment — which
70
71
  * is shared by every concurrent job under the native host-process transport. See
71
72
  * `RunOptions.agentEnv`.
@@ -84,6 +85,14 @@ export interface SubscriptionRunOptions {
84
85
  guardLimits?: ProgressGuardLimits;
85
86
  /** Whether this run is expected to edit files (false for assess-only runs); gates the no-edit bound. */
86
87
  expectsEdits?: boolean;
88
+ /**
89
+ * Probes the working tree for evidence the agent changed the repository. The guard's no-edit
90
+ * bound asks that question and can only see TOOL NAMES, so an agent writing files through
91
+ * `bash` reads as making no edits at all; this is what settles it before anything is killed.
92
+ * Injected so the guard stays pure, and consulted at most once per run (only when the bound is
93
+ * about to abort). Omitted ⇒ the bound falls back to its tool-name-only judgement.
94
+ */
95
+ workspaceProbe?: WorkspaceProbe;
87
96
  /** Called on every chunk of CLI output, so the watchdog sees the agent is alive. */
88
97
  onActivity?: () => void;
89
98
  /** Called with the latest subtask counts each time the CLI updates its todo/plan list. */
@@ -11,9 +11,10 @@ import { NO_TOOL_WINDOW } from './tool-silence.js';
11
11
  import { publishCallMetric, } from './pi.js';
12
12
  import { claudeAllowedToolPatterns, mcpServerSecretValues, observeClaudeMcpInit, writeClaudeMcpConfig, } from './agent-capabilities.js';
13
13
  import { codexImageGapNote, createCodexHome, disposeCodexHome } from './codex-home.js';
14
- import { ProgressGuard } from './progress-guard.js';
14
+ import { createClaudeProgressGuard } from './guard-driver.js';
15
15
  import { BoundedTail, JsonlLineReader } from './jsonl-stream.js';
16
16
  import { killChildProcess, spawnDetached } from './process.js';
17
+ import { agentChildEnv } from './agent-env.js';
17
18
  import { abortReasonOf } from './failure.js';
18
19
  import { describeProcessExit } from './process-exit.js';
19
20
  import { redact, registerKnownSecrets, secretsToRedact } from './redact.js';
@@ -42,7 +43,7 @@ function streamCli(cli, prompt, opts, env, secrets, onEvent) {
42
43
  }
43
44
  const child = spawn(command, args, {
44
45
  cwd: opts.cwd,
45
- env: { ...process.env, ...env },
46
+ env: agentChildEnv(env),
46
47
  stdio: ['pipe', 'pipe', 'pipe'],
47
48
  // Own process group (POSIX) so killChildProcess reaps the CLI's grandchildren too.
48
49
  detached: spawnDetached,
@@ -383,52 +384,6 @@ function reportToolServerStartup(event, onToolServers) {
383
384
  if (observed)
384
385
  onToolServers(observed);
385
386
  }
386
- /**
387
- * No-progress guard on the CLI's own tool stream — the claude-code analogue of runPi's guard,
388
- * which cannot see the CLI's internal turns. The caller remembers each `tool_use` id's name off
389
- * the assistant turn (`rememberTool`) and hands the following user turn's content to `feedGuard`,
390
- * which pairs each `tool_result`'s `is_error` with that name. The FIRST reason trips it: the
391
- * diagnostic is recorded (readable via `reason()`, which the catch surfaces over the generic abort
392
- * message) and `guardAbort` fires — folded into streamCli's signal so a tripped guard kills the CLI
393
- * the same way the external watchdog does. Disabled when the caller supplies no limits (only the
394
- * external watchdog then bounds the run).
395
- *
396
- * Split out of {@link runClaudeCode} for the per-function line budget.
397
- */
398
- function createClaudeProgressGuard(opts) {
399
- const guard = opts.guardLimits
400
- ? new ProgressGuard(opts.guardLimits, opts.expectsEdits ?? true)
401
- : undefined;
402
- const toolNames = new Map();
403
- const guardAbort = new AbortController();
404
- let guardReason;
405
- const feedGuard = (content) => {
406
- if (!guard || guardReason)
407
- return;
408
- for (const block of content) {
409
- if (!isObject(block) || block.type !== 'tool_result')
410
- continue;
411
- const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined;
412
- const name = id ? toolNames.get(id) : undefined;
413
- if (id)
414
- toolNames.delete(id);
415
- if (!name)
416
- continue;
417
- const reason = guard.observeSignal({ name, isError: block.is_error === true });
418
- if (reason) {
419
- guardReason = reason;
420
- guardAbort.abort();
421
- return;
422
- }
423
- }
424
- };
425
- return {
426
- rememberTool: (id, name) => toolNames.set(id, name),
427
- feedGuard,
428
- guardAbort,
429
- reason: () => guardReason,
430
- };
431
- }
432
387
  /**
433
388
  * The run's TRAJECTORY, on the claude-code stream: each `tool_use` block paired with the
434
389
  * `tool_result` that answers it on the following user turn, numbered and captured (scrubbed +
package/dist/agent.d.ts CHANGED
@@ -1,17 +1,6 @@
1
1
  import type { AgentJob, AgentResult, TestSecretSpec } from './job.js';
2
2
  import { runCodingAgent } from './coding-agent.js';
3
3
  import type { RunOptions } from './runner.js';
4
- /**
5
- * Build the dynamic infra notes appended to the agent's user prompt from a stand-up outcome.
6
- * A stand-up problem (a failed build / compose) is flagged as a concern to test around; a
7
- * frontend serve URL points the UI tester at the app that was just built + served and pre-empts
8
- * a live-backend CORS failure being mis-reported as an app defect. Pure (no IO) so the exact
9
- * wording + ordering is unit-tested; returns the notes in order (problem first, serve URL next).
10
- */
11
- export declare function buildInfraNotes(managed: {
12
- note?: string;
13
- serveUrl?: string;
14
- }): string[];
15
4
  /** Run one generic agent job end to end, dispatching on `mode`. */
16
5
  export declare function handleAgent(job: AgentJob, opts?: RunOptions): Promise<AgentResult>;
17
6
  /**
package/dist/agent.js CHANGED
@@ -1,12 +1,13 @@
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';
4
+ // The preview mode drives the frontend stand-up directly rather than through `manageInfra`:
5
+ // its serve/WireMock children outlive the job on purpose, so it wants no cleanup handle.
6
6
  import { standUpFrontend, tearDownFrontend } from './frontend-infra.js';
7
+ import { buildInfraNotes, manageInfra } from './infra-standup.js';
7
8
  import { artifactUploadEnv } from './artifact-upload.js';
8
9
  import { configurePackageRegistries } from './package-registries.js';
9
- import { captureRedactedOutput, redactSecrets, registerKnownSecrets } from './redact.js';
10
+ import { registerKnownSecrets } from './redact.js';
10
11
  import { cloneRepo, commitAll, conflictDiff, fetchPullRequestHead, fetchReferenceBranches, headCommit, mergeBranch, prepareExistingCheckout, pushBranch, unmergedPaths, } from './git.js';
11
12
  import { inferVcsProvider, openPullRequest } from './vcs-api.js';
12
13
  import { applyPrDescription } from './pr-description.js';
@@ -36,132 +37,6 @@ import { log } from './logger.js';
36
37
  // target repo. These are the deliberate, documented exceptions — do NOT grow this into a
37
38
  // general `if (job.someFlag)` dispatch; anything that doesn't need a checkout belongs in
38
39
  // backend pre/post-ops. See backend/docs/custom-agents.md.
39
- const exec = promisify(execFile);
40
- /**
41
- * Bring the service's docker-compose dependencies up (local infra only). Best-effort:
42
- * runs `docker compose -f <path> up -d --wait` in the checkout. A missing Docker daemon
43
- * or a compose failure is logged and surfaced to the agent (as a prompt note) rather
44
- * than failing the job — the agent can still run unit-level tests and report what it
45
- * could. A no-op for ephemeral / no-infra / no-compose-path runs.
46
- *
47
- * Whether it succeeds or fails, the (redacted, bounded) command output is captured into a
48
- * {@link InfraSetupRecord} returned alongside the prompt `note`, so the backend can surface
49
- * the in-container dependency stand-up logs on the Tester step — the failure-class artifact
50
- * the orchestrator-side provisioning logs can't see.
51
- */
52
- async function standUpInfra(dir, infra, signal, logger) {
53
- if (infra.environment !== 'local' || infra.noInfraDependencies || !infra.composePath) {
54
- return { started: false };
55
- }
56
- const startedAt = Date.now();
57
- try {
58
- logger.info('agent(explore): standing up infra', { composePath: infra.composePath });
59
- // Raise maxBuffer well above the 1MB default so a chatty compose stand-up can't fail the
60
- // (best-effort) infra step with ENOBUFS; the captured output is tail-bounded on storage.
61
- const { stdout, stderr } = await exec('docker', ['compose', '-f', infra.composePath, 'up', '-d', '--wait'], { cwd: dir, signal, timeout: 5 * 60_000, maxBuffer: 16 * 1024 * 1024 });
62
- const logs = captureRedactedOutput(stdout, stderr);
63
- return {
64
- started: true,
65
- record: {
66
- started: true,
67
- composePath: infra.composePath,
68
- at: Date.now(),
69
- durationMs: Date.now() - startedAt,
70
- ...(logs ? { logs } : {}),
71
- },
72
- };
73
- }
74
- catch (err) {
75
- const note = err instanceof Error ? err.message : String(err);
76
- logger.warn('agent(explore): infra stand-up failed', { error: note });
77
- // `execFile` rejections carry the partial stdout/stderr on the error object — capture them
78
- // so the stored logs explain the failure (a port clash, a pull-auth error, an exited
79
- // dependency), not just the one-line exit message.
80
- const e = err;
81
- const logs = captureRedactedOutput(e.stdout, e.stderr);
82
- return {
83
- started: false,
84
- note,
85
- record: {
86
- started: false,
87
- composePath: infra.composePath,
88
- at: Date.now(),
89
- durationMs: Date.now() - startedAt,
90
- error: redactSecrets(note),
91
- ...(logs ? { logs } : {}),
92
- },
93
- };
94
- }
95
- }
96
- /**
97
- * Stand the run's infra up and return a single cleanup handle, dispatching on the spec's
98
- * `kind`: the frontend UI-test flow (`kind: 'frontend'`) builds/serves the app + WireMock as
99
- * processes (torn down by killing them); the default backend-service flow stands the
100
- * docker-compose stack up (torn down with `docker compose down`). Unifying the two here keeps
101
- * `runExploreMode` free of the branch and guarantees the matching teardown runs in its finally.
102
- *
103
- * `dir` is the clone ROOT; `workDir` is the service subtree (equal to `dir` when the run is not
104
- * monorepo-scoped). The docker-compose stand-up runs at the root (its `composePath` is
105
- * repo-relative), but the FRONTEND stand-up runs in `workDir`: a monorepo frontend's
106
- * `package.json` / `outputDir` / `mocks/` all live under the service subtree, so installing,
107
- * building, serving and seeding WireMock from the root would target the wrong directory.
108
- */
109
- async function manageInfra(dir, workDir, infra, opts, logger) {
110
- if (infra.kind === 'frontend') {
111
- // `onActivity` feeds the inactivity watchdog through the frontend build/serve stand-up,
112
- // which (unlike docker-compose's 5-min-capped `up`) can run past the inactivity window.
113
- // Runs in `workDir` so a monorepo frontend builds/serves from its own package subtree.
114
- const fe = await standUpFrontend(workDir, infra, opts, logger);
115
- return {
116
- ...(fe.note ? { note: fe.note } : {}),
117
- ...(fe.serveUrl ? { serveUrl: fe.serveUrl } : {}),
118
- record: fe.record,
119
- cleanup: () => tearDownFrontend(fe.processes, logger),
120
- };
121
- }
122
- const standUp = await standUpInfra(dir, infra, opts.signal, logger);
123
- return {
124
- ...(standUp.note ? { note: standUp.note } : {}),
125
- ...(standUp.record ? { record: standUp.record } : {}),
126
- cleanup: () => tearDownInfra(dir, infra),
127
- };
128
- }
129
- /**
130
- * Build the dynamic infra notes appended to the agent's user prompt from a stand-up outcome.
131
- * A stand-up problem (a failed build / compose) is flagged as a concern to test around; a
132
- * frontend serve URL points the UI tester at the app that was just built + served and pre-empts
133
- * a live-backend CORS failure being mis-reported as an app defect. Pure (no IO) so the exact
134
- * wording + ordering is unit-tested; returns the notes in order (problem first, serve URL next).
135
- */
136
- export function buildInfraNotes(managed) {
137
- const notes = [];
138
- if (managed.note) {
139
- notes.push(`standing the infra up reported a problem (${managed.note}). Test what you can and ` +
140
- `flag any dependency-related gaps as concerns.`);
141
- }
142
- if (managed.serveUrl) {
143
- notes.push(`The frontend under test is built and served at ${managed.serveUrl}, with its other ` +
144
- `backend upstreams handled by WireMock. Drive your UI tests against ${managed.serveUrl}. ` +
145
- `If a call to a live backend fails with a CORS / cross-origin error, that is an infra ` +
146
- `gap (the backend must allow the ${managed.serveUrl} origin), not an app defect — flag ` +
147
- `it as a concern rather than a failing test.`);
148
- }
149
- return notes;
150
- }
151
- /** Tear the docker-compose dependencies down (best-effort; a no-op when none were started). */
152
- async function tearDownInfra(dir, infra) {
153
- if (infra.environment !== 'local' || infra.noInfraDependencies || !infra.composePath)
154
- return;
155
- try {
156
- await exec('docker', ['compose', '-f', infra.composePath, 'down', '-v'], {
157
- cwd: dir,
158
- timeout: 2 * 60_000,
159
- });
160
- }
161
- catch {
162
- // The container is ephemeral and torn down with the run anyway — ignore.
163
- }
164
- }
165
40
  /**
166
41
  * Parse an agent's final reply into the structured JSON `custom`, shared by the explore and
167
42
  * coding structured-output paths. With repair enabled (default) a malformed reply gets ONE
@@ -262,9 +137,9 @@ export async function handleAgent(job, opts = {}) {
262
137
  }
263
138
  /**
264
139
  * Layer extra child-process env onto a job's {@link RunOptions}. The agent CLI is spawned with
265
- * `{...process.env, ...agentEnv}`, so this is how per-job values reach the agent (and the shell
266
- * tools it spawns) WITHOUT mutating the harness's own `process.env` — which is shared by every
267
- * concurrent job when the harness runs as a native host process. Empty `env` ⇒ `opts` unchanged.
140
+ * `agentChildEnv(agentEnv)`, so this is how per-job values reach the agent (and the shell tools it
141
+ * spawns) WITHOUT mutating the harness's own `process.env` — which is shared by every concurrent
142
+ * job when the harness runs as a native host process. Empty `env` ⇒ `opts` unchanged.
268
143
  */
269
144
  function withAgentEnv(opts, env) {
270
145
  if (Object.keys(env).length === 0)
@@ -22,7 +22,7 @@ export interface CapturedCommandResult {
22
22
  * tree on timeout and an aborted run resolves non-zero, so a phase is never what blocks a job
23
23
  * from settling.
24
24
  *
25
- * The child inherits the JOB's environment (`RunOptions.agentEnv` layered over the process env),
25
+ * The child inherits the JOB's environment (`RunOptions.agentEnv` layered over `agentChildEnv`),
26
26
  * not a mutated global: the harness spawns this itself rather than through the agent, so without
27
27
  * the explicit merge a native-mode job would run without the private-registry npmrc pointer (and,
28
28
  * had this been staged in `process.env`, against a sibling job's state).
@@ -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
  // The ONE way the harness runs a declared shell command on its own behalf (rather than through
5
6
  // the agent) and keeps a bounded, secret-scrubbed record of what it printed.
@@ -33,7 +34,7 @@ const CAPTURE_MARGIN_CHARS = 512;
33
34
  * tree on timeout and an aborted run resolves non-zero, so a phase is never what blocks a job
34
35
  * from settling.
35
36
  *
36
- * The child inherits the JOB's environment (`RunOptions.agentEnv` layered over the process env),
37
+ * The child inherits the JOB's environment (`RunOptions.agentEnv` layered over `agentChildEnv`),
37
38
  * not a mutated global: the harness spawns this itself rather than through the agent, so without
38
39
  * the explicit merge a native-mode job would run without the private-registry npmrc pointer (and,
39
40
  * had this been staged in `process.env`, against a sibling job's state).
@@ -53,7 +54,7 @@ export async function runCapturedCommand(args) {
53
54
  cwd,
54
55
  detached: spawnDetached,
55
56
  stdio: ['ignore', 'pipe', 'pipe'],
56
- env: { ...process.env, ...opts.agentEnv },
57
+ env: agentChildEnv(opts.agentEnv),
57
58
  });
58
59
  // Keep only the tail (plus the scrub margin); guard against unbounded buffering on a chatty
59
60
  // command.
@@ -9,6 +9,7 @@ import { type Logger } from './logger.js';
9
9
  import { type ValidationChecksSpec, type ValidationReport } from './validation-checks.js';
10
10
  import { type ReproductionReport, type ReproductionSpec } from './reproduction-proof.js';
11
11
  import { type DependencyInstallSpec } from './dependency-install.js';
12
+ import { type SalvageReport } from './salvage.js';
12
13
  /** What a coding agent run needs: where to clone, what to run, where to push. */
13
14
  export interface CodingAgentSpec extends HarnessAuthFields {
14
15
  /** Short label for the temp dir + log lines (e.g. 'impl', 'ci-fix'). */
@@ -192,8 +193,42 @@ export interface CodingAgentOutcome {
192
193
  * attached to a perfectly successful run and the PR still opens.
193
194
  */
194
195
  reproductionReport?: ReproductionReport;
196
+ /**
197
+ * What became of the new files the agent created and never committed. Absent means there were
198
+ * none to consider; `status: 'refused'` or `'failed'` means work was left behind and is NOT in
199
+ * the push, which the backend must be able to tell a human rather than presenting the run as a
200
+ * clean pass.
201
+ */
202
+ salvage?: SalvageReport;
195
203
  }
196
204
  export declare function runCodingAgent(spec: CodingAgentSpec, opts?: RunOptions): Promise<CodingAgentOutcome>;
205
+ /**
206
+ * Salvage what an aborted run left uncommitted, push it, and return the error to rethrow with the
207
+ * salvage stated on it.
208
+ *
209
+ * Returns rather than throws so the caller's `throw` stays visible at the call site, and so this
210
+ * can never REPLACE the failure being reported: a salvage that throws is swallowed, because the
211
+ * reason the run died is strictly more useful than the reason its rescue did.
212
+ *
213
+ * The push is what makes the salvage worth anything — the commit lives in a container that is
214
+ * about to be reclaimed — and it is reported HONESTLY: a commit that could not be pushed is lost
215
+ * exactly as the uncommitted files would have been, so the note says so rather than naming a sha
216
+ * nobody will ever be able to fetch.
217
+ *
218
+ * Two things have to happen before the salvage, and the caller has already stopped the checkpoint
219
+ * interval for the first. The second is here: any push the checkpoint had IN FLIGHT is drained,
220
+ * because `pushWorkOnce` coalesces onto it and would otherwise hand the rescue a push that was
221
+ * made before the salvage commit existed.
222
+ *
223
+ * Exported for its test: every collaborator it needs is a parameter, so the ordering and the
224
+ * signal it pushes on can be asserted against a real repository without a container.
225
+ */
226
+ export declare function withSalvagedWork(error: unknown, args: {
227
+ dir: string;
228
+ logger: Logger;
229
+ pushWorkOnce: (override?: AbortSignal) => Promise<void>;
230
+ inFlightPush: () => Promise<void> | null;
231
+ }): Promise<unknown>;
197
232
  /**
198
233
  * The Ralph-loop validation watchdog: the longest a completion command may run before it is
199
234
  * killed and treated as a failure (a hung `pnpm test` must never block the loop forever).