@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.
Files changed (55) 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 +8 -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/workspace-probe.d.ts +85 -0
  35. package/dist/workspace-probe.js +124 -0
  36. package/package.json +4 -4
  37. package/src/agent-env.ts +49 -0
  38. package/src/agent-runner.ts +14 -53
  39. package/src/agent.ts +7 -158
  40. package/src/captured-command.ts +3 -2
  41. package/src/coding-agent.ts +252 -44
  42. package/src/docker-status.ts +201 -0
  43. package/src/frontend-infra.ts +4 -3
  44. package/src/git.ts +104 -26
  45. package/src/guard-driver.ts +203 -0
  46. package/src/harness-server.ts +13 -0
  47. package/src/infra-standup.ts +218 -0
  48. package/src/job.ts +10 -0
  49. package/src/multi-repo-coding.ts +59 -8
  50. package/src/pi-workspace.ts +72 -0
  51. package/src/pi.ts +27 -12
  52. package/src/progress-guard.ts +110 -34
  53. package/src/runner.ts +1 -1
  54. package/src/salvage.ts +407 -0
  55. package/src/workspace-probe.ts +155 -0
@@ -0,0 +1,182 @@
1
+ // The run's infra stand-up: the docker-compose dependencies a local-mode service declares, and
2
+ // the frontend build/serve + WireMock flow the UI-test runs use instead. Split out of agent.ts,
3
+ // which owns the agent MODES; this owns what surrounds a mode with the dependencies it needs and
4
+ // guarantees the matching teardown. `manageInfra` is the one entry point a mode calls.
5
+ import { execFile } from 'node:child_process';
6
+ import { promisify } from 'node:util';
7
+ import { probeDockerServing, readDockerStatus, resolveDockerVerdict, } from './docker-status.js';
8
+ import { standUpFrontend, tearDownFrontend } from './frontend-infra.js';
9
+ import { captureRedactedOutput, redactSecrets } from './redact.js';
10
+ const exec = promisify(execFile);
11
+ /**
12
+ * Bring the service's docker-compose dependencies up (local infra only). Best-effort:
13
+ * runs `docker compose -f <path> up -d --wait` in the checkout. A compose failure is logged
14
+ * and surfaced to the agent (as a prompt note) rather than failing the job — the agent can
15
+ * still run unit-level tests and report what it could. A no-op for ephemeral / no-infra /
16
+ * no-compose-path runs.
17
+ *
18
+ * A CONFIRMED absence of a Docker daemon short-circuits it: the container's own probe
19
+ * ({@link readDockerStatus}, recorded by `entrypoint.sh`) already knows there is nothing to
20
+ * talk to, so running compose against it would only turn a fact this container holds into a
21
+ * connection error the agent has to interpret. The record then carries `dockerAvailable: false`
22
+ * and the stated cause, which is what makes the Tester step say why it ran no infra instead of
23
+ * looking like a Tester that simply chose not to. Anything OTHER than a confirmed absence
24
+ * attempts as before (`DockerStatus.available` in docker-status.ts states why "undecided" is its
25
+ * own value).
26
+ *
27
+ * "Confirmed", not merely recorded: {@link resolveDockerVerdict} re-checks a recorded absence
28
+ * against a live daemon first, so a warm-pool container whose sidecar came up late is not
29
+ * latched into refusing infra that works. `probe` is that check, injected so the unit suite can
30
+ * state both answers on a machine that has its own daemon either way.
31
+ *
32
+ * Whether it succeeds or fails, the (redacted, bounded) command output is captured into a
33
+ * {@link InfraSetupRecord} returned alongside the prompt `note`, so the backend can surface
34
+ * the in-container dependency stand-up logs on the Tester step — the failure-class artifact
35
+ * the orchestrator-side provisioning logs can't see.
36
+ *
37
+ * Exported for the unit suite (like {@link buildInfraNotes}): the refusal branch is a decision
38
+ * this container makes about itself, and the acceptance suite can only exercise it on a machine
39
+ * where the daemon genuinely fails.
40
+ */
41
+ export async function standUpInfra(dir, infra, signal, logger, probe = probeDockerServing) {
42
+ if (infra.environment !== 'local' || infra.noInfraDependencies || !infra.composePath) {
43
+ return { started: false };
44
+ }
45
+ const startedAt = Date.now();
46
+ const recorded = await readDockerStatus();
47
+ const docker = await resolveDockerVerdict(recorded, probe);
48
+ if (docker.refusal) {
49
+ const note = `the dependencies could not be started: ${docker.refusal}`;
50
+ logger.warn('agent(explore): infra stand-up refused, no docker daemon', {
51
+ composePath: infra.composePath,
52
+ dockerSource: recorded.source,
53
+ dockerReason: recorded.reason,
54
+ });
55
+ return {
56
+ started: false,
57
+ note,
58
+ record: {
59
+ started: false,
60
+ dockerAvailable: false,
61
+ composePath: infra.composePath,
62
+ at: Date.now(),
63
+ durationMs: Date.now() - startedAt,
64
+ error: redactSecrets(note),
65
+ },
66
+ };
67
+ }
68
+ try {
69
+ logger.info('agent(explore): standing up infra', { composePath: infra.composePath });
70
+ // Raise maxBuffer well above the 1MB default so a chatty compose stand-up can't fail the
71
+ // (best-effort) infra step with ENOBUFS; the captured output is tail-bounded on storage.
72
+ const { stdout, stderr } = await exec('docker', ['compose', '-f', infra.composePath, 'up', '-d', '--wait'], { cwd: dir, signal, timeout: 5 * 60_000, maxBuffer: 16 * 1024 * 1024 });
73
+ const logs = captureRedactedOutput(stdout, stderr);
74
+ return {
75
+ started: true,
76
+ record: {
77
+ started: true,
78
+ dockerAvailable: true,
79
+ composePath: infra.composePath,
80
+ at: Date.now(),
81
+ durationMs: Date.now() - startedAt,
82
+ ...(logs ? { logs } : {}),
83
+ },
84
+ };
85
+ }
86
+ catch (err) {
87
+ const note = err instanceof Error ? err.message : String(err);
88
+ logger.warn('agent(explore): infra stand-up failed', { error: note });
89
+ // `execFile` rejections carry the partial stdout/stderr on the error object — capture them
90
+ // so the stored logs explain the failure (a port clash, a pull-auth error, an exited
91
+ // dependency), not just the one-line exit message.
92
+ const e = err;
93
+ const logs = captureRedactedOutput(e.stdout, e.stderr);
94
+ return {
95
+ started: false,
96
+ note,
97
+ record: {
98
+ started: false,
99
+ // A compose failure with a REACHABLE daemon: the two `false`s above and here are
100
+ // different diagnoses (nothing to talk to vs the stack itself did not come up), and
101
+ // only stating both keeps the second from being read as the first. Read off the
102
+ // RESOLVED verdict, so a container whose daemon came up after boot claims the daemon it
103
+ // actually reached rather than the one its boot record still denies.
104
+ ...(docker.available === true ? { dockerAvailable: true } : {}),
105
+ composePath: infra.composePath,
106
+ at: Date.now(),
107
+ durationMs: Date.now() - startedAt,
108
+ error: redactSecrets(note),
109
+ ...(logs ? { logs } : {}),
110
+ },
111
+ };
112
+ }
113
+ }
114
+ /**
115
+ * Stand the run's infra up and return a single cleanup handle, dispatching on the spec's
116
+ * `kind`: the frontend UI-test flow (`kind: 'frontend'`) builds/serves the app + WireMock as
117
+ * processes (torn down by killing them); the default backend-service flow stands the
118
+ * docker-compose stack up (torn down with `docker compose down`). Unifying the two here keeps
119
+ * `runExploreMode` free of the branch and guarantees the matching teardown runs in its finally.
120
+ *
121
+ * `dir` is the clone ROOT; `workDir` is the service subtree (equal to `dir` when the run is not
122
+ * monorepo-scoped). The docker-compose stand-up runs at the root (its `composePath` is
123
+ * repo-relative), but the FRONTEND stand-up runs in `workDir`: a monorepo frontend's
124
+ * `package.json` / `outputDir` / `mocks/` all live under the service subtree, so installing,
125
+ * building, serving and seeding WireMock from the root would target the wrong directory.
126
+ */
127
+ export async function manageInfra(dir, workDir, infra, opts, logger) {
128
+ if (infra.kind === 'frontend') {
129
+ // `onActivity` feeds the inactivity watchdog through the frontend build/serve stand-up,
130
+ // which (unlike docker-compose's 5-min-capped `up`) can run past the inactivity window.
131
+ // Runs in `workDir` so a monorepo frontend builds/serves from its own package subtree.
132
+ const fe = await standUpFrontend(workDir, infra, opts, logger);
133
+ return {
134
+ ...(fe.note ? { note: fe.note } : {}),
135
+ ...(fe.serveUrl ? { serveUrl: fe.serveUrl } : {}),
136
+ record: fe.record,
137
+ cleanup: () => tearDownFrontend(fe.processes, logger),
138
+ };
139
+ }
140
+ const standUp = await standUpInfra(dir, infra, opts.signal, logger);
141
+ return {
142
+ ...(standUp.note ? { note: standUp.note } : {}),
143
+ ...(standUp.record ? { record: standUp.record } : {}),
144
+ cleanup: () => tearDownInfra(dir, infra),
145
+ };
146
+ }
147
+ /**
148
+ * Build the dynamic infra notes appended to the agent's user prompt from a stand-up outcome.
149
+ * A stand-up problem (a failed build / compose) is flagged as a concern to test around; a
150
+ * frontend serve URL points the UI tester at the app that was just built + served and pre-empts
151
+ * a live-backend CORS failure being mis-reported as an app defect. Pure (no IO) so the exact
152
+ * wording + ordering is unit-tested; returns the notes in order (problem first, serve URL next).
153
+ */
154
+ export function buildInfraNotes(managed) {
155
+ const notes = [];
156
+ if (managed.note) {
157
+ notes.push(`standing the infra up reported a problem (${managed.note}). Test what you can and ` +
158
+ `flag any dependency-related gaps as concerns.`);
159
+ }
160
+ if (managed.serveUrl) {
161
+ notes.push(`The frontend under test is built and served at ${managed.serveUrl}, with its other ` +
162
+ `backend upstreams handled by WireMock. Drive your UI tests against ${managed.serveUrl}. ` +
163
+ `If a call to a live backend fails with a CORS / cross-origin error, that is an infra ` +
164
+ `gap (the backend must allow the ${managed.serveUrl} origin), not an app defect — flag ` +
165
+ `it as a concern rather than a failing test.`);
166
+ }
167
+ return notes;
168
+ }
169
+ /** Tear the docker-compose dependencies down (best-effort; a no-op when none were started). */
170
+ async function tearDownInfra(dir, infra) {
171
+ if (infra.environment !== 'local' || infra.noInfraDependencies || !infra.composePath)
172
+ return;
173
+ try {
174
+ await exec('docker', ['compose', '-f', infra.composePath, 'down', '-v'], {
175
+ cwd: dir,
176
+ timeout: 2 * 60_000,
177
+ });
178
+ }
179
+ catch {
180
+ // The container is ephemeral and torn down with the run anyway — ignore.
181
+ }
182
+ }
package/dist/job.d.ts CHANGED
@@ -546,6 +546,16 @@ export interface GuardLimitsSpec {
546
546
  export interface InfraSetupRecord {
547
547
  /** Whether `docker compose up --wait` succeeded (the dependencies are up). */
548
548
  started: boolean;
549
+ /**
550
+ * Whether this container had a Docker daemon to talk to at all, when it knows.
551
+ *
552
+ * The distinction `started` alone cannot make: a stack that failed to come up and a container
553
+ * with no daemon are the same `started: false` and opposite problems (one is the service's
554
+ * compose file, the other is the executor image or the sandbox it runs in). ABSENT means this
555
+ * container's probe reached no verdict — never assume `false` from absence, which is the exact
556
+ * mistake that let a daemon-less image read as an ordinary infra failure for months.
557
+ */
558
+ dockerAvailable?: boolean;
549
559
  /** The repo-relative compose file that was stood up. */
550
560
  composePath?: string;
551
561
  /** Epoch ms the stand-up attempt finished. */
@@ -14,3 +14,20 @@ import type { RunOptions } from './runner.js';
14
14
  * git helpers, so the per-repo clone/commit/push/PR mechanics match the single-repo path exactly.
15
15
  */
16
16
  export declare function runMultiRepoCoding(job: AgentJob, opts?: RunOptions): Promise<AgentResult>;
17
+ /**
18
+ * The checkouts the no-progress guard's working-tree bound may judge this run on.
19
+ *
20
+ * A multi-repo run's cwd is the workspace ROOT, which is no git repository, so the guard's default
21
+ * (probe the cwd) asks git a question with no answer: every probe throws, the driver re-arms
22
+ * forever, and the bound is permanently unenforceable — strictly worse than the tool-name reading
23
+ * it replaced. The writable legs are the repositories this run may change, so they are what it
24
+ * has to show progress in.
25
+ *
26
+ * A READ-ONLY reference leg is excluded, and not merely as an optimisation: the run is forbidden
27
+ * to write to it, so a change appearing there is not this run making progress and must never be
28
+ * what saves it from the bound.
29
+ */
30
+ export declare function probeDirsForLegs(legs: readonly {
31
+ dir: string;
32
+ readOnly?: boolean;
33
+ }[]): string[];
@@ -1,7 +1,8 @@
1
1
  import { mkdir } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { makeDirClaimer } from './checkout-dir.js';
4
- import { branchAheadOfBase, branchHasCommitsSince, cloneExistingBranch, cloneRepo, commitTrackedEdits, createBranch, excludeFromGit, fetchReferenceBranches, headCommit, listUntrackedFiles, pushBranch, refreshFromBaseIfClean, remoteBranchExists, } from './git.js';
4
+ import { branchAheadOfBase, branchHasCommitsSince, cloneExistingBranch, cloneRepo, commitTrackedEdits, createBranch, excludeFromGit, fetchReferenceBranches, headCommit, pushBranch, refreshFromBaseIfClean, remoteBranchExists, } from './git.js';
5
+ import { salvageOnlyNotice, salvageUntrackedWork } from './salvage.js';
5
6
  import { openPullRequest } from './vcs-api.js';
6
7
  import { applyPrDescription, PR_DESCRIPTION_FILE, readPrDescription } from './pr-description.js';
7
8
  import { runAgentInWorkspace, withWorkspace } from './pi-workspace.js';
@@ -143,6 +144,8 @@ export async function runMultiRepoCoding(job, opts = {}) {
143
144
  ...(job.referenceScreenshots ? { referenceScreenshots: job.referenceScreenshots } : {}),
144
145
  ...(job.designImages ? { designImages: job.designImages } : {}),
145
146
  multiRepo: true,
147
+ // What the no-progress guard's working-tree bound decides on: see {@link probeDirsForLegs}.
148
+ repoDirs: probeDirsForLegs(legs),
146
149
  }, opts);
147
150
  // Commit forgotten tracked edits, then push + open a PR for each repo the run actually changed.
148
151
  const { primaryPushed, primaryPrUrl, peerPullRequests } = await pushMultiRepoLegs(legs, job, logger, opts, root, prTemplate);
@@ -288,6 +291,32 @@ async function prepareMultiRepoCheckouts(root, legs, job, logger, opts) {
288
291
  }
289
292
  }
290
293
  }
294
+ /**
295
+ * The checkouts the no-progress guard's working-tree bound may judge this run on.
296
+ *
297
+ * A multi-repo run's cwd is the workspace ROOT, which is no git repository, so the guard's default
298
+ * (probe the cwd) asks git a question with no answer: every probe throws, the driver re-arms
299
+ * forever, and the bound is permanently unenforceable — strictly worse than the tool-name reading
300
+ * it replaced. The writable legs are the repositories this run may change, so they are what it
301
+ * has to show progress in.
302
+ *
303
+ * A READ-ONLY reference leg is excluded, and not merely as an optimisation: the run is forbidden
304
+ * to write to it, so a change appearing there is not this run making progress and must never be
305
+ * what saves it from the bound.
306
+ */
307
+ export function probeDirsForLegs(legs) {
308
+ return legs.filter((leg) => !leg.readOnly).map((leg) => leg.dir);
309
+ }
310
+ /**
311
+ * Put {@link salvageOnlyNotice} at the top of a pull request that is nothing but a salvage.
312
+ *
313
+ * Only the BODY is marked. A title carrying it would follow the PR into every list and
314
+ * notification a maintainer sees, which is a lot of noise for a caveat that belongs beside the
315
+ * diff, and the salvage commit's own message elaborates on it there.
316
+ */
317
+ function withSalvageOnlyNote(pr, salvageOnly) {
318
+ return salvageOnly ? { ...pr, body: `${salvageOnlyNotice()}\n\n${pr.body}` } : pr;
319
+ }
291
320
  /**
292
321
  * Push phase for {@link runMultiRepoCoding}: commit forgotten tracked edits, then push + open a PR
293
322
  * for each repo the run actually changed (a repo the agent left untouched is skipped — no branch,
@@ -323,19 +352,37 @@ prTemplate) {
323
352
  const agentPrDescription = (await readPrDescription(leg.dir, readOptions)) ??
324
353
  (leg.primary ? await readPrDescription(root, readOptions) : undefined);
325
354
  await commitTrackedEdits(leg.dir, job.commitMessage ?? leg.pr?.title ?? 'Agent changes', signal);
326
- const advanced = await branchHasCommitsSince(leg.dir, leg.baseSha, signal);
355
+ // Whether the leg carried COMMITTED work before the salvage, read here and not after it.
356
+ // Afterwards the salvage's own commit makes every leg it touched look advanced, and the two
357
+ // are not the same claim: work the agent committed to this repo is a change it chose to make,
358
+ // where a salvage-only leg is a branch built entirely out of files it left lying in that
359
+ // checkout. Both are worth keeping; only one is worth presenting as a proposed change without
360
+ // saying where it came from.
361
+ const committedOwnWork = await branchHasCommitsSince(leg.dir, leg.baseSha, signal);
362
+ // Recover this leg's new files, exactly as the single-repo settle path does and for the same
363
+ // reason: `commitTrackedEdits` captures edits to files git ALREADY tracks, so a new file the
364
+ // agent created and never added used to be listed, warned about and dropped. Runs BEFORE the
365
+ // advanced/no-op judgement below, so a leg whose only work is those files is pushed rather
366
+ // than read as untouched.
367
+ const salvage = await salvageUntrackedWork({
368
+ dir: leg.dir,
369
+ occasion: { kind: 'settled' },
370
+ logger: logger.child({ repo: leg.dirName }),
371
+ ...(signal ? { signal } : {}),
372
+ });
373
+ const salvageOnly = !committedOwnWork && salvage.status === 'committed';
374
+ const advanced = committedOwnWork || salvage.status === 'committed';
327
375
  let hasWork = advanced || leg.resumed;
328
376
  if (leg.resumed && !advanced) {
329
377
  const ahead = await branchAheadOfBase(leg.dir, leg.repo.baseBranch, leg.ghToken, signal);
330
378
  if (ahead === false)
331
379
  hasWork = false;
332
380
  }
333
- const leftover = await listUntrackedFiles(leg.dir, signal);
334
- if (leftover.length > 0) {
335
- logger.warn('multi-repo: uncommitted new files left behind (not pushed)', {
381
+ if (salvage.status === 'refused' || salvage.status === 'failed') {
382
+ logger.warn('multi-repo: new files were left behind and are NOT in the push', {
336
383
  repo: leg.dirName,
337
- count: leftover.length,
338
- files: leftover.slice(0, 20),
384
+ count: salvage.fileCount,
385
+ reason: salvage.reason,
339
386
  });
340
387
  }
341
388
  if (!hasWork) {
@@ -351,7 +398,7 @@ prTemplate) {
351
398
  ghToken: leg.ghToken,
352
399
  head: leg.workBranch,
353
400
  base: leg.repo.baseBranch,
354
- pr: applyPrDescription(leg.pr, agentPrDescription),
401
+ pr: withSalvageOnlyNote(applyPrDescription(leg.pr, agentPrDescription), salvageOnly),
355
402
  // See the single-repo call site: refresh a resumed leg's already-open PR, but only
356
403
  // when the text is the agent's own briefing rather than the dispatch-time fallback.
357
404
  ...(agentPrDescription ? { refreshExisting: true } : {}),
@@ -43,6 +43,17 @@ export declare function acquireRepoCheckout<T>(opts: {
43
43
  export interface AgentRunSpec {
44
44
  /** The prepared working directory (cloned/scaffolded by the caller). */
45
45
  dir: string;
46
+ /**
47
+ * The git checkouts this pass may change, for the no-progress guard's working-tree bound.
48
+ * Absent ⇒ `[dir]`, which is right whenever the agent's cwd is (or is inside) the one repo.
49
+ *
50
+ * A MULTI-REPO run is the exception the default cannot serve: its cwd is a workspace ROOT
51
+ * holding sibling checkouts and is no repository itself, so probing it asks git a question with
52
+ * no answer, every probe throws, and the bound goes permanently unenforced. Such a caller names
53
+ * its writable legs here instead. A read-only reference checkout is deliberately NOT named: the
54
+ * run is forbidden to write to it, so a change there is not this run making progress.
55
+ */
56
+ repoDirs?: readonly string[];
46
57
  /** Composed role + best-practice fragments; written to Pi's global AGENTS.md context. */
47
58
  systemPrompt: string;
48
59
  /** The concrete task prompt handed to Pi. */
@@ -6,6 +6,7 @@ import { readEffortReport } from './effort.js';
6
6
  import { log } from './logger.js';
7
7
  import { CONTEXT_DIR, materializeContextFiles, materializeSkillResources, phasedProxyBaseUrl, runPi, webSearchConfigFromEnv, webSearchProxyEnv, writeAgentsContext, writePiModelsConfig, writeWebToolsConfig, } from './pi.js';
8
8
  import { mergeGuardLimits, progressGuardLimitsFromEnv, } from './progress-guard.js';
9
+ import { composeWorkspaceProbes, createWorkspaceProbe, readHeadOrEmpty, } from './workspace-probe.js';
9
10
  import { runSubscriptionHarness } from './agent-runner.js';
10
11
  // The thin base every container agent shares: an ephemeral working directory, and
11
12
  // one Pi run inside it driven by the harness-written context. The agents differ in
@@ -167,6 +168,19 @@ export async function runAgentInWorkspace(spec, opts = {}) {
167
168
  if (spec.skills?.length && !installsSkillNatively(spec)) {
168
169
  await materializeSkillResources(spec.dir, spec.skills);
169
170
  }
171
+ // The no-progress guard's no-edit bound asks "has this run changed the repository", and the
172
+ // tool names it can see are only a proxy for that: an agent writing every file through `bash`
173
+ // reads as making no edits at all, and the guard killed exactly such a run after it had built,
174
+ // tested and verified a whole service. The working tree is the honest answer, so wire the probe
175
+ // that reads it. Built HERE because this is the shared middle of both harness paths and the one
176
+ // place that knows the checkout: the guard itself stays pure and takes it injected.
177
+ //
178
+ // The baseline is HEAD as this PASS begins, not the clone's — a repair round is a fresh agent
179
+ // that must show its OWN progress, and judging it against the clone would let the previous
180
+ // round's commits satisfy its bound. A checkout with no commit yet (a scaffold-from-scratch
181
+ // bootstrap) has no HEAD to read; the probe then rides on the dirty-tree half alone, which is
182
+ // the half that matters there anyway.
183
+ const workspaceProbe = await buildWorkspaceProbe(spec, opts.signal);
170
184
  // Subscription harnesses (Claude Code / Codex) authenticate with the leased
171
185
  // token and talk direct to the vendor — no proxy config, no AGENTS.md. The
172
186
  // system prompt is passed straight to the CLI; everything around this (clone,
@@ -201,6 +215,8 @@ export async function runAgentInWorkspace(spec, opts = {}) {
201
215
  // ignores it for now (its stream isn't wired to the guard).
202
216
  guardLimits: mergeGuardLimits(progressGuardLimitsFromEnv(), spec.guardLimits),
203
217
  expectsEdits: spec.expectsEdits ?? true,
218
+ // What the guard's no-edit bound actually decides on (see `buildWorkspaceProbe`).
219
+ workspaceProbe,
204
220
  onActivity: opts.onActivity,
205
221
  onProgress: opts.onProgress,
206
222
  // The run's tool-call trajectory, the same hook the Pi path feeds — so a subscription run
@@ -283,10 +299,41 @@ export async function runAgentInWorkspace(spec, opts = {}) {
283
299
  // Start from the env/built-in defaults and apply only the per-knob overrides the
284
300
  // backend set for this kind (loosen-only), so an unspecified knob keeps its default.
285
301
  guardLimits: mergeGuardLimits(progressGuardLimitsFromEnv(), spec.guardLimits),
302
+ // What the guard's no-edit bound actually decides on (see `buildWorkspaceProbe`).
303
+ workspaceProbe,
286
304
  extraEnv,
287
305
  });
288
306
  return withEffortReport(spec.dir, piOutcome);
289
307
  }
308
+ /**
309
+ * The workspace probe for one agent pass: each of the pass's working trees, baselined against its
310
+ * own HEAD as this pass begins.
311
+ *
312
+ * Reading HEAD is the one part that can fail benignly: a scaffold-from-scratch checkout has no
313
+ * commit yet, so `rev-parse HEAD` errors. That is no reason to leave the bound blind, since the
314
+ * dirty-tree half is exactly what answers a from-scratch build — so the pass baselines against
315
+ * the empty sha (`readHeadOrEmpty`, which the probe itself reads HEAD through for the same
316
+ * reason), and any commit the agent makes reads as HEAD having moved off it.
317
+ *
318
+ * A directory that is no git repository at all makes every probe THROW, which the driver treats
319
+ * as inconclusive: the bound re-arms and the run is neither killed nor left to the streak bounds
320
+ * alone. Deliberate, and the same disposition a transient git failure gets.
321
+ *
322
+ * WHICH trees is `spec.repoDirs`, defaulted HERE rather than at the call site so the rule that a
323
+ * pass with no declared checkouts is judged on its own directory lives with the builder that acts
324
+ * on it. Several of them compose into one probe over the whole workspace (see
325
+ * {@link composeWorkspaceProbes}); an empty list would silently disarm the bound, so it falls back
326
+ * to `dir` too.
327
+ */
328
+ async function buildWorkspaceProbe(spec, signal) {
329
+ const dirs = spec.repoDirs?.length ? spec.repoDirs : [spec.dir];
330
+ const probes = await Promise.all(dirs.map(async (dir) => createWorkspaceProbe({
331
+ dir,
332
+ baseSha: await readHeadOrEmpty(dir, signal),
333
+ ...(signal ? { signal } : {}),
334
+ })));
335
+ return composeWorkspaceProbes(probes);
336
+ }
290
337
  /**
291
338
  * Whether the claude-code runner will install this run's skills natively (into the CLI's config
292
339
  * dir) rather than the caller materialising them into the checkout. True ONLY for a
package/dist/pi.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { EffortReport } from './effort.js';
2
2
  import { type ProgressGuardLimits } from './progress-guard.js';
3
+ import type { WorkspaceProbe } from './workspace-probe.js';
3
4
  import { type PiRunStats, type RunDiagnostics } from './pi-reduction.js';
4
5
  import { type ToolProgressWindow } from './tool-silence.js';
5
6
  /**
@@ -438,6 +439,13 @@ export declare function runPi(opts: {
438
439
  guardLimits?: ProgressGuardLimits;
439
440
  /** Whether this run is expected to edit files (false for assess-only runs like the merger). */
440
441
  expectsEdits?: boolean;
442
+ /**
443
+ * Probes the working tree for evidence the agent changed the repository — what the guard's
444
+ * no-edit bound is actually asking, as opposed to the tool names it can see. Injected (the
445
+ * guard stays pure) and consulted at most once per run, only when that bound is about to abort.
446
+ * Omitted ⇒ the bound falls back to its tool-name-only judgement.
447
+ */
448
+ workspaceProbe?: WorkspaceProbe;
441
449
  /**
442
450
  * Extra environment for Pi's child process, merged over `process.env` (but under the
443
451
  * proxy token). Used to hand the rpiv-web-tools extension its proxy-backed SearXNG
package/dist/pi.js CHANGED
@@ -3,11 +3,13 @@ import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { killChildProcess, spawnDetached } from './process.js';
6
+ import { agentChildEnv } from './agent-env.js';
6
7
  import { pathExists } from './fs-utils.js';
7
8
  import { redactSecrets, secretsToRedact } from './redact.js';
8
9
  import { HarnessFailure } from './failure.js';
9
10
  import { log } from './logger.js';
10
11
  import { ProgressGuard, progressGuardLimitsFromEnv, toolCallSignal, } from './progress-guard.js';
12
+ import { createGuardDriver } from './guard-driver.js';
11
13
  import { ToolCallTracker, readToolCallId, toolCallResult, toolCallStart, } from './tool-trajectory.js';
12
14
  import { BoundedTail, JsonlLineReader } from './jsonl-stream.js';
13
15
  import { PI_MAX_OUTPUT_TOKENS, PiRunReducer, isObject, } from './pi-reduction.js';
@@ -562,7 +564,7 @@ export function runPi(opts) {
562
564
  }
563
565
  const child = spawn('pi', ['-p', '--mode', 'json', '--model', `proxy/${opts.model}`, '--approve'], {
564
566
  cwd: opts.cwd,
565
- env: { ...process.env, ...opts.extraEnv, PI_PROXY_TOKEN: opts.sessionToken },
567
+ env: agentChildEnv(opts.extraEnv, { PI_PROXY_TOKEN: opts.sessionToken }),
566
568
  // stdin is piped (not 'ignore') so the prompt is delivered out-of-band
567
569
  // rather than on argv — see the function doc for the injection rationale.
568
570
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -597,7 +599,6 @@ export function runPi(opts) {
597
599
  // spam): `{`-leading lines that failed to JSON.parse, and observer-callback throws.
598
600
  let malformedLines = 0;
599
601
  let observerErrors = 0;
600
- const guard = new ProgressGuard(opts.guardLimits ?? progressGuardLimitsFromEnv(), opts.expectsEdits ?? true);
601
602
  // Pairs each tool call's start with its result, numbers the pairs and captures the two
602
603
  // bodies (scrubbed + capped). A call whose start Pi never emitted still gets an entry,
603
604
  // timed from the previous call's end — see `ToolCallTracker`.
@@ -612,6 +613,17 @@ export function runPi(opts) {
612
613
  // SIGTERM first, then SIGKILL if Pi ignores it. Shared by the watchdog abort
613
614
  // and the no-progress guard; the `close` handler turns it into a rejection.
614
615
  const killChild = () => killChildProcess(child);
616
+ // The guard, plus the driver that settles its one bound needing evidence from outside this
617
+ // stream (see `guard-driver.ts`). `processLine` is a synchronous reader, so the driver owns
618
+ // the probe's lifetime rather than this handler awaiting inside it.
619
+ const guardDriver = createGuardDriver({
620
+ guard: new ProgressGuard(opts.guardLimits ?? progressGuardLimitsFromEnv(), opts.expectsEdits ?? true),
621
+ probe: opts.workspaceProbe,
622
+ onAbort: (reason) => {
623
+ guardReason = reason;
624
+ killChild();
625
+ },
626
+ });
615
627
  // Parse each complete JSONL record once, retaining it for the close-of-run reductions and
616
628
  // feeding the todo-progress emitter and the no-progress guard. A tripped guard kills Pi
617
629
  // with a diagnostic the run then fails on.
@@ -668,13 +680,8 @@ export function runPi(opts) {
668
680
  }
669
681
  }
670
682
  }
671
- if (!final && !guardReason && !aborted) {
672
- const reason = guard.observe(event);
673
- if (reason) {
674
- guardReason = reason;
675
- killChild();
676
- }
677
- }
683
+ if (!final && !guardReason && !aborted)
684
+ guardDriver.observeEvent(event);
678
685
  };
679
686
  // Pi's json mode is strict LF-framed JSONL; the reader buffers partial records across
680
687
  // chunks (bounded — see `JsonlLineReader`) so we only ever parse complete ones.
@@ -82,11 +82,35 @@ export declare function progressGuardLimitsFromEnv(env?: NodeJS.ProcessEnv): Pro
82
82
  */
83
83
  export declare function mergeGuardLimits(base: ProgressGuardLimits, overrides: Partial<ProgressGuardLimits> | undefined): ProgressGuardLimits;
84
84
  /**
85
- * Live anti-rabbithole guard: fed each streamed Pi event, it returns a diagnostic
86
- * reason the moment a run has plainly stopped making progress, so the harness can
87
- * kill Pi early instead of letting it burn the whole budget (and then surface a
88
- * useful failure instead of a generic "no file changes"). Pure and incremental so
89
- * it can be unit-tested over a fixed event sequence.
85
+ * What the guard concluded from one tool-call signal.
86
+ *
87
+ * `abort` is a settled judgement the caller acts on immediately: every STREAK bound
88
+ * (consecutive errors / web calls / MCP calls / non-action calls) reads only the stream, so the
89
+ * stream is all the evidence there is.
90
+ *
91
+ * `needs-workspace-evidence` is the no-edit bound, and it is deliberately NOT settled. That bound
92
+ * asks "has this run changed the repository yet", and the tool names are only a proxy for it: an
93
+ * agent writing files through `bash` reads as forty calls and no edits however much work it did.
94
+ * So the guard hands the question back with the diagnostic it would abort on, and the caller
95
+ * answers it from the working tree (see `workspace-probe.ts`) before anything is killed.
96
+ */
97
+ export type ProgressVerdict = {
98
+ kind: 'abort';
99
+ reason: string;
100
+ } | {
101
+ kind: 'needs-workspace-evidence';
102
+ reason: string;
103
+ };
104
+ /**
105
+ * Live anti-rabbithole guard: fed each streamed tool-call signal, it returns a {@link
106
+ * ProgressVerdict} the moment a run has plainly stopped making progress, so the harness can kill
107
+ * the CLI early instead of letting it burn the whole budget (and then surface a useful failure
108
+ * instead of a generic "no file changes").
109
+ *
110
+ * PURE, SYNCHRONOUS and INCREMENTAL, so it can be unit-tested over a fixed event sequence: it
111
+ * spawns nothing and reads nothing off disk. The one bound that needs evidence from outside the
112
+ * stream says so in its verdict and lets the caller fetch it, then reports the answer back
113
+ * through {@link noteWorkspaceMutation} / {@link rearmNoEditBound}.
90
114
  */
91
115
  export declare class ProgressGuard {
92
116
  private readonly limits;
@@ -98,14 +122,36 @@ export declare class ProgressGuard {
98
122
  private consecutiveWebCalls;
99
123
  private consecutiveMcpCalls;
100
124
  private consecutiveNonActionCalls;
125
+ private awaitingWorkspaceEvidence;
101
126
  constructor(limits: ProgressGuardLimits,
102
127
  /** When false (assess-only runs like the merger), the no-edit bound is skipped. */
103
128
  expectsEdits?: boolean);
104
- /** Feed one parsed Pi event; returns a diagnostic reason when the run should abort, else null. */
105
- observe(event: Record<string, unknown>): string | null;
129
+ /** Feed one parsed Pi event; returns a {@link ProgressVerdict} when the run is in trouble, else null. */
130
+ observe(event: Record<string, unknown>): ProgressVerdict | null;
131
+ /**
132
+ * Record that the run HAS changed the repository, however it did it. Satisfies the no-edit
133
+ * bound permanently, exactly as a recognised edit-tool call does, matching that bound's
134
+ * existing semantics: it guards a run only UNTIL its first edit, because an agent that has
135
+ * changed the tree has demonstrably started the work.
136
+ *
137
+ * Called by the driver when a workspace probe answers a `needs-workspace-evidence` verdict
138
+ * positively. Idempotent, and cheap enough that a caller who probes for other reasons may also
139
+ * report through it.
140
+ */
141
+ noteWorkspaceMutation(): void;
142
+ /**
143
+ * Re-arm the no-edit bound after a probe that could answer NEITHER way (it threw). The bound
144
+ * becomes trippable again once another `maxToolCallsWithoutEdit` action calls have gone by,
145
+ * rather than the run being killed on a git failure or left permanently unguarded by one.
146
+ *
147
+ * Failing open here is the deliberate half: killing a productive run is the expensive error,
148
+ * and the streak bounds, the inactivity watchdog and the job's wall-clock cap all still hold
149
+ * the run in the meantime.
150
+ */
151
+ rearmNoEditBound(): void;
106
152
  /**
107
- * Feed one already-parsed tool-call signal (name + error flag), returning a diagnostic reason
108
- * when the run should abort, else null. Split out of {@link observe} so a caller whose stream
153
+ * Feed one already-parsed tool-call signal (name + error flag), returning a {@link
154
+ * ProgressVerdict} when a bound is reached, else null. Split out of {@link observe} so a caller whose stream
109
155
  * is NOT Pi's `tool_execution_end` envelope — the claude-code runner, which correlates a
110
156
  * `tool_use` block's name with its `tool_result`'s `is_error` — can drive the SAME guard logic
111
157
  * without synthesising a fake Pi event.
@@ -113,5 +159,5 @@ export declare class ProgressGuard {
113
159
  observeSignal(tool: {
114
160
  name: string;
115
161
  isError: boolean;
116
- }): string | null;
162
+ }): ProgressVerdict | null;
117
163
  }