@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
@@ -12,11 +12,11 @@ import {
12
12
  excludeFromGit,
13
13
  fetchReferenceBranches,
14
14
  headCommit,
15
- listUntrackedFiles,
16
15
  pushBranch,
17
16
  refreshFromBaseIfClean,
18
17
  remoteBranchExists,
19
18
  } from './git.js'
19
+ import { salvageOnlyNotice, salvageUntrackedWork } from './salvage.js'
20
20
  import { openPullRequest } from './vcs-api.js'
21
21
  import { applyPrDescription, PR_DESCRIPTION_FILE, readPrDescription } from './pr-description.js'
22
22
  import { runAgentInWorkspace, withWorkspace } from './pi-workspace.js'
@@ -212,6 +212,8 @@ export async function runMultiRepoCoding(
212
212
  ...(job.referenceScreenshots ? { referenceScreenshots: job.referenceScreenshots } : {}),
213
213
  ...(job.designImages ? { designImages: job.designImages } : {}),
214
214
  multiRepo: true,
215
+ // What the no-progress guard's working-tree bound decides on: see {@link probeDirsForLegs}.
216
+ repoDirs: probeDirsForLegs(legs),
215
217
  },
216
218
  opts,
217
219
  )
@@ -386,6 +388,37 @@ async function prepareMultiRepoCheckouts(
386
388
  }
387
389
  }
388
390
 
391
+ /**
392
+ * The checkouts the no-progress guard's working-tree bound may judge this run on.
393
+ *
394
+ * A multi-repo run's cwd is the workspace ROOT, which is no git repository, so the guard's default
395
+ * (probe the cwd) asks git a question with no answer: every probe throws, the driver re-arms
396
+ * forever, and the bound is permanently unenforceable — strictly worse than the tool-name reading
397
+ * it replaced. The writable legs are the repositories this run may change, so they are what it
398
+ * has to show progress in.
399
+ *
400
+ * A READ-ONLY reference leg is excluded, and not merely as an optimisation: the run is forbidden
401
+ * to write to it, so a change appearing there is not this run making progress and must never be
402
+ * what saves it from the bound.
403
+ */
404
+ export function probeDirsForLegs(legs: readonly { dir: string; readOnly?: boolean }[]): string[] {
405
+ return legs.filter((leg) => !leg.readOnly).map((leg) => leg.dir)
406
+ }
407
+
408
+ /**
409
+ * Put {@link salvageOnlyNotice} at the top of a pull request that is nothing but a salvage.
410
+ *
411
+ * Only the BODY is marked. A title carrying it would follow the PR into every list and
412
+ * notification a maintainer sees, which is a lot of noise for a caveat that belongs beside the
413
+ * diff, and the salvage commit's own message elaborates on it there.
414
+ */
415
+ function withSalvageOnlyNote(
416
+ pr: { title: string; body: string },
417
+ salvageOnly: boolean,
418
+ ): { title: string; body: string } {
419
+ return salvageOnly ? { ...pr, body: `${salvageOnlyNotice()}\n\n${pr.body}` } : pr
420
+ }
421
+
389
422
  /**
390
423
  * Push phase for {@link runMultiRepoCoding}: commit forgotten tracked edits, then push + open a PR
391
424
  * for each repo the run actually changed (a repo the agent left untouched is skipped — no branch,
@@ -430,18 +463,36 @@ async function pushMultiRepoLegs(
430
463
  (await readPrDescription(leg.dir, readOptions)) ??
431
464
  (leg.primary ? await readPrDescription(root, readOptions) : undefined)
432
465
  await commitTrackedEdits(leg.dir, job.commitMessage ?? leg.pr?.title ?? 'Agent changes', signal)
433
- const advanced = await branchHasCommitsSince(leg.dir, leg.baseSha, signal)
466
+ // Whether the leg carried COMMITTED work before the salvage, read here and not after it.
467
+ // Afterwards the salvage's own commit makes every leg it touched look advanced, and the two
468
+ // are not the same claim: work the agent committed to this repo is a change it chose to make,
469
+ // where a salvage-only leg is a branch built entirely out of files it left lying in that
470
+ // checkout. Both are worth keeping; only one is worth presenting as a proposed change without
471
+ // saying where it came from.
472
+ const committedOwnWork = await branchHasCommitsSince(leg.dir, leg.baseSha, signal)
473
+ // Recover this leg's new files, exactly as the single-repo settle path does and for the same
474
+ // reason: `commitTrackedEdits` captures edits to files git ALREADY tracks, so a new file the
475
+ // agent created and never added used to be listed, warned about and dropped. Runs BEFORE the
476
+ // advanced/no-op judgement below, so a leg whose only work is those files is pushed rather
477
+ // than read as untouched.
478
+ const salvage = await salvageUntrackedWork({
479
+ dir: leg.dir,
480
+ occasion: { kind: 'settled' },
481
+ logger: logger.child({ repo: leg.dirName }),
482
+ ...(signal ? { signal } : {}),
483
+ })
484
+ const salvageOnly = !committedOwnWork && salvage.status === 'committed'
485
+ const advanced = committedOwnWork || salvage.status === 'committed'
434
486
  let hasWork = advanced || leg.resumed
435
487
  if (leg.resumed && !advanced) {
436
488
  const ahead = await branchAheadOfBase(leg.dir, leg.repo.baseBranch, leg.ghToken, signal)
437
489
  if (ahead === false) hasWork = false
438
490
  }
439
- const leftover = await listUntrackedFiles(leg.dir, signal)
440
- if (leftover.length > 0) {
441
- logger.warn('multi-repo: uncommitted new files left behind (not pushed)', {
491
+ if (salvage.status === 'refused' || salvage.status === 'failed') {
492
+ logger.warn('multi-repo: new files were left behind and are NOT in the push', {
442
493
  repo: leg.dirName,
443
- count: leftover.length,
444
- files: leftover.slice(0, 20),
494
+ count: salvage.fileCount,
495
+ reason: salvage.reason,
445
496
  })
446
497
  }
447
498
  if (!hasWork) {
@@ -457,7 +508,7 @@ async function pushMultiRepoLegs(
457
508
  ghToken: leg.ghToken,
458
509
  head: leg.workBranch,
459
510
  base: leg.repo.baseBranch,
460
- pr: applyPrDescription(leg.pr, agentPrDescription),
511
+ pr: withSalvageOnlyNote(applyPrDescription(leg.pr, agentPrDescription), salvageOnly),
461
512
  // See the single-repo call site: refresh a resumed leg's already-open PR, but only
462
513
  // when the text is the agent's own briefing rather than the dispatch-time fallback.
463
514
  ...(agentPrDescription ? { refreshExisting: true } : {}),
@@ -26,6 +26,12 @@ import {
26
26
  mergeGuardLimits,
27
27
  progressGuardLimitsFromEnv,
28
28
  } from './progress-guard.js'
29
+ import {
30
+ composeWorkspaceProbes,
31
+ createWorkspaceProbe,
32
+ readHeadOrEmpty,
33
+ type WorkspaceProbe,
34
+ } from './workspace-probe.js'
29
35
  import type { RunOptions } from './runner.js'
30
36
  import { type SubscriptionHarness, runSubscriptionHarness } from './agent-runner.js'
31
37
 
@@ -151,6 +157,17 @@ export async function acquireRepoCheckout<T>(
151
157
  export interface AgentRunSpec {
152
158
  /** The prepared working directory (cloned/scaffolded by the caller). */
153
159
  dir: string
160
+ /**
161
+ * The git checkouts this pass may change, for the no-progress guard's working-tree bound.
162
+ * Absent ⇒ `[dir]`, which is right whenever the agent's cwd is (or is inside) the one repo.
163
+ *
164
+ * A MULTI-REPO run is the exception the default cannot serve: its cwd is a workspace ROOT
165
+ * holding sibling checkouts and is no repository itself, so probing it asks git a question with
166
+ * no answer, every probe throws, and the bound goes permanently unenforced. Such a caller names
167
+ * its writable legs here instead. A read-only reference checkout is deliberately NOT named: the
168
+ * run is forbidden to write to it, so a change there is not this run making progress.
169
+ */
170
+ repoDirs?: readonly string[]
154
171
  /** Composed role + best-practice fragments; written to Pi's global AGENTS.md context. */
155
172
  systemPrompt: string
156
173
  /** The concrete task prompt handed to Pi. */
@@ -331,6 +348,20 @@ export async function runAgentInWorkspace(
331
348
  await materializeSkillResources(spec.dir, spec.skills)
332
349
  }
333
350
 
351
+ // The no-progress guard's no-edit bound asks "has this run changed the repository", and the
352
+ // tool names it can see are only a proxy for that: an agent writing every file through `bash`
353
+ // reads as making no edits at all, and the guard killed exactly such a run after it had built,
354
+ // tested and verified a whole service. The working tree is the honest answer, so wire the probe
355
+ // that reads it. Built HERE because this is the shared middle of both harness paths and the one
356
+ // place that knows the checkout: the guard itself stays pure and takes it injected.
357
+ //
358
+ // The baseline is HEAD as this PASS begins, not the clone's — a repair round is a fresh agent
359
+ // that must show its OWN progress, and judging it against the clone would let the previous
360
+ // round's commits satisfy its bound. A checkout with no commit yet (a scaffold-from-scratch
361
+ // bootstrap) has no HEAD to read; the probe then rides on the dirty-tree half alone, which is
362
+ // the half that matters there anyway.
363
+ const workspaceProbe = await buildWorkspaceProbe(spec, opts.signal)
364
+
334
365
  // Subscription harnesses (Claude Code / Codex) authenticate with the leased
335
366
  // token and talk direct to the vendor — no proxy config, no AGENTS.md. The
336
367
  // system prompt is passed straight to the CLI; everything around this (clone,
@@ -365,6 +396,8 @@ export async function runAgentInWorkspace(
365
396
  // ignores it for now (its stream isn't wired to the guard).
366
397
  guardLimits: mergeGuardLimits(progressGuardLimitsFromEnv(), spec.guardLimits),
367
398
  expectsEdits: spec.expectsEdits ?? true,
399
+ // What the guard's no-edit bound actually decides on (see `buildWorkspaceProbe`).
400
+ workspaceProbe,
368
401
  onActivity: opts.onActivity,
369
402
  onProgress: opts.onProgress,
370
403
  // The run's tool-call trajectory, the same hook the Pi path feeds — so a subscription run
@@ -446,11 +479,50 @@ export async function runAgentInWorkspace(
446
479
  // Start from the env/built-in defaults and apply only the per-knob overrides the
447
480
  // backend set for this kind (loosen-only), so an unspecified knob keeps its default.
448
481
  guardLimits: mergeGuardLimits(progressGuardLimitsFromEnv(), spec.guardLimits),
482
+ // What the guard's no-edit bound actually decides on (see `buildWorkspaceProbe`).
483
+ workspaceProbe,
449
484
  extraEnv,
450
485
  })
451
486
  return withEffortReport(spec.dir, piOutcome)
452
487
  }
453
488
 
489
+ /**
490
+ * The workspace probe for one agent pass: each of the pass's working trees, baselined against its
491
+ * own HEAD as this pass begins.
492
+ *
493
+ * Reading HEAD is the one part that can fail benignly: a scaffold-from-scratch checkout has no
494
+ * commit yet, so `rev-parse HEAD` errors. That is no reason to leave the bound blind, since the
495
+ * dirty-tree half is exactly what answers a from-scratch build — so the pass baselines against
496
+ * the empty sha (`readHeadOrEmpty`, which the probe itself reads HEAD through for the same
497
+ * reason), and any commit the agent makes reads as HEAD having moved off it.
498
+ *
499
+ * A directory that is no git repository at all makes every probe THROW, which the driver treats
500
+ * as inconclusive: the bound re-arms and the run is neither killed nor left to the streak bounds
501
+ * alone. Deliberate, and the same disposition a transient git failure gets.
502
+ *
503
+ * WHICH trees is `spec.repoDirs`, defaulted HERE rather than at the call site so the rule that a
504
+ * pass with no declared checkouts is judged on its own directory lives with the builder that acts
505
+ * on it. Several of them compose into one probe over the whole workspace (see
506
+ * {@link composeWorkspaceProbes}); an empty list would silently disarm the bound, so it falls back
507
+ * to `dir` too.
508
+ */
509
+ async function buildWorkspaceProbe(
510
+ spec: Pick<AgentRunSpec, 'dir' | 'repoDirs'>,
511
+ signal: AbortSignal | undefined,
512
+ ): Promise<WorkspaceProbe> {
513
+ const dirs = spec.repoDirs?.length ? spec.repoDirs : [spec.dir]
514
+ const probes = await Promise.all(
515
+ dirs.map(async (dir) =>
516
+ createWorkspaceProbe({
517
+ dir,
518
+ baseSha: await readHeadOrEmpty(dir, signal),
519
+ ...(signal ? { signal } : {}),
520
+ }),
521
+ ),
522
+ )
523
+ return composeWorkspaceProbes(probes)
524
+ }
525
+
454
526
  /**
455
527
  * Whether the claude-code runner will install this run's skills natively (into the CLI's config
456
528
  * dir) rather than the caller materialising them into the checkout. True ONLY for a
package/src/pi.ts CHANGED
@@ -3,6 +3,7 @@ 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'
@@ -14,6 +15,8 @@ import {
14
15
  toolCallSignal,
15
16
  type ProgressGuardLimits,
16
17
  } from './progress-guard.js'
18
+ import { createGuardDriver } from './guard-driver.js'
19
+ import type { WorkspaceProbe } from './workspace-probe.js'
17
20
  import {
18
21
  ToolCallTracker,
19
22
  readToolCallId,
@@ -881,6 +884,13 @@ export function runPi(opts: {
881
884
  guardLimits?: ProgressGuardLimits
882
885
  /** Whether this run is expected to edit files (false for assess-only runs like the merger). */
883
886
  expectsEdits?: boolean
887
+ /**
888
+ * Probes the working tree for evidence the agent changed the repository — what the guard's
889
+ * no-edit bound is actually asking, as opposed to the tool names it can see. Injected (the
890
+ * guard stays pure) and consulted at most once per run, only when that bound is about to abort.
891
+ * Omitted ⇒ the bound falls back to its tool-name-only judgement.
892
+ */
893
+ workspaceProbe?: WorkspaceProbe
884
894
  /**
885
895
  * Extra environment for Pi's child process, merged over `process.env` (but under the
886
896
  * proxy token). Used to hand the rpiv-web-tools extension its proxy-backed SearXNG
@@ -898,7 +908,7 @@ export function runPi(opts: {
898
908
  ['-p', '--mode', 'json', '--model', `proxy/${opts.model}`, '--approve'],
899
909
  {
900
910
  cwd: opts.cwd,
901
- env: { ...process.env, ...opts.extraEnv, PI_PROXY_TOKEN: opts.sessionToken },
911
+ env: agentChildEnv(opts.extraEnv, { PI_PROXY_TOKEN: opts.sessionToken }),
902
912
  // stdin is piped (not 'ignore') so the prompt is delivered out-of-band
903
913
  // rather than on argv — see the function doc for the injection rationale.
904
914
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -934,10 +944,6 @@ export function runPi(opts: {
934
944
  // spam): `{`-leading lines that failed to JSON.parse, and observer-callback throws.
935
945
  let malformedLines = 0
936
946
  let observerErrors = 0
937
- const guard = new ProgressGuard(
938
- opts.guardLimits ?? progressGuardLimitsFromEnv(),
939
- opts.expectsEdits ?? true,
940
- )
941
947
  // Pairs each tool call's start with its result, numbers the pairs and captures the two
942
948
  // bodies (scrubbed + capped). A call whose start Pi never emitted still gets an entry,
943
949
  // timed from the previous call's end — see `ToolCallTracker`.
@@ -954,6 +960,21 @@ export function runPi(opts: {
954
960
  // and the no-progress guard; the `close` handler turns it into a rejection.
955
961
  const killChild = (): void => killChildProcess(child)
956
962
 
963
+ // The guard, plus the driver that settles its one bound needing evidence from outside this
964
+ // stream (see `guard-driver.ts`). `processLine` is a synchronous reader, so the driver owns
965
+ // the probe's lifetime rather than this handler awaiting inside it.
966
+ const guardDriver = createGuardDriver({
967
+ guard: new ProgressGuard(
968
+ opts.guardLimits ?? progressGuardLimitsFromEnv(),
969
+ opts.expectsEdits ?? true,
970
+ ),
971
+ probe: opts.workspaceProbe,
972
+ onAbort: (reason) => {
973
+ guardReason = reason
974
+ killChild()
975
+ },
976
+ })
977
+
957
978
  // Parse each complete JSONL record once, retaining it for the close-of-run reductions and
958
979
  // feeding the todo-progress emitter and the no-progress guard. A tripped guard kills Pi
959
980
  // with a diagnostic the run then fails on.
@@ -1008,13 +1029,7 @@ export function runPi(opts: {
1008
1029
  }
1009
1030
  }
1010
1031
  }
1011
- if (!final && !guardReason && !aborted) {
1012
- const reason = guard.observe(event)
1013
- if (reason) {
1014
- guardReason = reason
1015
- killChild()
1016
- }
1017
- }
1032
+ if (!final && !guardReason && !aborted) guardDriver.observeEvent(event)
1018
1033
  }
1019
1034
 
1020
1035
  // Pi's json mode is strict LF-framed JSONL; the reader buffers partial records across
@@ -104,8 +104,14 @@ export const DEFAULT_PROGRESS_GUARD_LIMITS = {
104
104
  // broad on purpose: different models/extensions name the same capability differently
105
105
  // (`edit`/`write`, but also `apply_patch`/`patch`/`str_replace`/`multiedit`/`create`),
106
106
  // and a false "no edits" reading would kill a run that IS making changes. Matched
107
- // case-insensitively. NOTE: a file written purely via `bash` (e.g. a heredoc) is not
108
- // recognised here — broaden or move to a working-tree signal if that becomes common.
107
+ // case-insensitively.
108
+ //
109
+ // A file written purely through `bash` (a heredoc, `sed -i`, `node -e`) is NOT recognised here,
110
+ // and deliberately so: this set answers "did the model call a tool we already know edits files",
111
+ // which is a cheap SUFFICIENT condition and never a necessary one. The necessary one is the
112
+ // working tree itself, which is what the no-edit bound now actually decides on: see the
113
+ // `needs-workspace-evidence` verdict and {@link ProgressGuard.noteWorkspaceMutation}. A hit here
114
+ // still satisfies the bound outright, so the common case never pays for a probe.
109
115
  const FILE_EDIT_TOOLS = new Set([
110
116
  'edit',
111
117
  'write',
@@ -274,11 +280,32 @@ export function mergeGuardLimits(
274
280
  }
275
281
 
276
282
  /**
277
- * Live anti-rabbithole guard: fed each streamed Pi event, it returns a diagnostic
278
- * reason the moment a run has plainly stopped making progress, so the harness can
279
- * kill Pi early instead of letting it burn the whole budget (and then surface a
280
- * useful failure instead of a generic "no file changes"). Pure and incremental so
281
- * it can be unit-tested over a fixed event sequence.
283
+ * What the guard concluded from one tool-call signal.
284
+ *
285
+ * `abort` is a settled judgement the caller acts on immediately: every STREAK bound
286
+ * (consecutive errors / web calls / MCP calls / non-action calls) reads only the stream, so the
287
+ * stream is all the evidence there is.
288
+ *
289
+ * `needs-workspace-evidence` is the no-edit bound, and it is deliberately NOT settled. That bound
290
+ * asks "has this run changed the repository yet", and the tool names are only a proxy for it: an
291
+ * agent writing files through `bash` reads as forty calls and no edits however much work it did.
292
+ * So the guard hands the question back with the diagnostic it would abort on, and the caller
293
+ * answers it from the working tree (see `workspace-probe.ts`) before anything is killed.
294
+ */
295
+ export type ProgressVerdict =
296
+ | { kind: 'abort'; reason: string }
297
+ | { kind: 'needs-workspace-evidence'; reason: string }
298
+
299
+ /**
300
+ * Live anti-rabbithole guard: fed each streamed tool-call signal, it returns a {@link
301
+ * ProgressVerdict} the moment a run has plainly stopped making progress, so the harness can kill
302
+ * the CLI early instead of letting it burn the whole budget (and then surface a useful failure
303
+ * instead of a generic "no file changes").
304
+ *
305
+ * PURE, SYNCHRONOUS and INCREMENTAL, so it can be unit-tested over a fixed event sequence: it
306
+ * spawns nothing and reads nothing off disk. The one bound that needs evidence from outside the
307
+ * stream says so in its verdict and lets the caller fetch it, then reports the answer back
308
+ * through {@link noteWorkspaceMutation} / {@link rearmNoEditBound}.
282
309
  */
283
310
  export class ProgressGuard {
284
311
  private toolCalls = 0
@@ -287,6 +314,11 @@ export class ProgressGuard {
287
314
  private consecutiveWebCalls = 0
288
315
  private consecutiveMcpCalls = 0
289
316
  private consecutiveNonActionCalls = 0
317
+ // Set when the no-edit bound has been reported as `needs-workspace-evidence` and the caller's
318
+ // probe has not answered yet. It suppresses a second report: the bound is a threshold, so every
319
+ // action call past it would otherwise re-raise the same unanswered question and the caller would
320
+ // probe git once per tool call. Cleared by whichever answer comes back.
321
+ private awaitingWorkspaceEvidence = false
290
322
 
291
323
  constructor(
292
324
  private readonly limits: ProgressGuardLimits,
@@ -294,30 +326,61 @@ export class ProgressGuard {
294
326
  private readonly expectsEdits: boolean = true,
295
327
  ) {}
296
328
 
297
- /** Feed one parsed Pi event; returns a diagnostic reason when the run should abort, else null. */
298
- observe(event: Record<string, unknown>): string | null {
329
+ /** Feed one parsed Pi event; returns a {@link ProgressVerdict} when the run is in trouble, else null. */
330
+ observe(event: Record<string, unknown>): ProgressVerdict | null {
299
331
  const tool = toolCallSignal(event)
300
332
  if (!tool) return null
301
333
  return this.observeSignal(tool)
302
334
  }
303
335
 
304
336
  /**
305
- * Feed one already-parsed tool-call signal (name + error flag), returning a diagnostic reason
306
- * when the run should abort, else null. Split out of {@link observe} so a caller whose stream
337
+ * Record that the run HAS changed the repository, however it did it. Satisfies the no-edit
338
+ * bound permanently, exactly as a recognised edit-tool call does, matching that bound's
339
+ * existing semantics: it guards a run only UNTIL its first edit, because an agent that has
340
+ * changed the tree has demonstrably started the work.
341
+ *
342
+ * Called by the driver when a workspace probe answers a `needs-workspace-evidence` verdict
343
+ * positively. Idempotent, and cheap enough that a caller who probes for other reasons may also
344
+ * report through it.
345
+ */
346
+ noteWorkspaceMutation(): void {
347
+ this.edits++
348
+ this.awaitingWorkspaceEvidence = false
349
+ }
350
+
351
+ /**
352
+ * Re-arm the no-edit bound after a probe that could answer NEITHER way (it threw). The bound
353
+ * becomes trippable again once another `maxToolCallsWithoutEdit` action calls have gone by,
354
+ * rather than the run being killed on a git failure or left permanently unguarded by one.
355
+ *
356
+ * Failing open here is the deliberate half: killing a productive run is the expensive error,
357
+ * and the streak bounds, the inactivity watchdog and the job's wall-clock cap all still hold
358
+ * the run in the meantime.
359
+ */
360
+ rearmNoEditBound(): void {
361
+ this.toolCalls = 0
362
+ this.awaitingWorkspaceEvidence = false
363
+ }
364
+
365
+ /**
366
+ * Feed one already-parsed tool-call signal (name + error flag), returning a {@link
367
+ * ProgressVerdict} when a bound is reached, else null. Split out of {@link observe} so a caller whose stream
307
368
  * is NOT Pi's `tool_execution_end` envelope — the claude-code runner, which correlates a
308
369
  * `tool_use` block's name with its `tool_result`'s `is_error` — can drive the SAME guard logic
309
370
  * without synthesising a fake Pi event.
310
371
  */
311
- observeSignal(tool: { name: string; isError: boolean }): string | null {
372
+ observeSignal(tool: { name: string; isError: boolean }): ProgressVerdict | null {
312
373
  const name = tool.name.toLowerCase()
313
374
  // The error streak tracks ANY tool call (a planning call still proves the agent
314
375
  // isn't wedged in a failing-op loop), so it's updated before the planning skip.
315
376
  this.consecutiveErrors = tool.isError ? this.consecutiveErrors + 1 : 0
316
377
  if (this.consecutiveErrors >= this.limits.maxConsecutiveErrors) {
317
- return (
318
- `no progress: ${this.consecutiveErrors} consecutive failing tool calls — the agent is stuck ` +
319
- `retrying a failing operation rather than making progress. Aborting.`
320
- )
378
+ return {
379
+ kind: 'abort',
380
+ reason:
381
+ `no progress: ${this.consecutiveErrors} consecutive failing tool calls — the agent is stuck ` +
382
+ `retrying a failing operation rather than making progress. Aborting.`,
383
+ }
321
384
  }
322
385
 
323
386
  // Web search/fetch loop: web tools are read-only (they don't count toward the
@@ -328,10 +391,12 @@ export class ProgressGuard {
328
391
  const webCap =
329
392
  this.limits.maxConsecutiveWebCalls ?? DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveWebCalls
330
393
  if (this.consecutiveWebCalls >= webCap) {
331
- return (
332
- `no progress: ${this.consecutiveWebCalls} consecutive web search/fetch calls without ` +
333
- `any other action — the agent is stuck researching instead of doing the work. Aborting.`
334
- )
394
+ return {
395
+ kind: 'abort',
396
+ reason:
397
+ `no progress: ${this.consecutiveWebCalls} consecutive web search/fetch calls without ` +
398
+ `any other action — the agent is stuck researching instead of doing the work. Aborting.`,
399
+ }
335
400
  }
336
401
  } else {
337
402
  this.consecutiveWebCalls = 0
@@ -345,11 +410,13 @@ export class ProgressGuard {
345
410
  const mcpCap =
346
411
  this.limits.maxConsecutiveMcpCalls ?? DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveMcpCalls
347
412
  if (this.consecutiveMcpCalls >= mcpCap) {
348
- return (
349
- `no progress: ${this.consecutiveMcpCalls} consecutive tool-server (MCP) calls without ` +
350
- `any other action. The agent is stuck querying its tools instead of doing the work. ` +
351
- `Aborting.`
352
- )
413
+ return {
414
+ kind: 'abort',
415
+ reason:
416
+ `no progress: ${this.consecutiveMcpCalls} consecutive tool-server (MCP) calls without ` +
417
+ `any other action. The agent is stuck querying its tools instead of doing the work. ` +
418
+ `Aborting.`,
419
+ }
353
420
  }
354
421
  } else {
355
422
  this.consecutiveMcpCalls = 0
@@ -377,11 +444,13 @@ export class ProgressGuard {
377
444
  this.limits.maxConsecutiveNonActionCalls ??
378
445
  DEFAULT_PROGRESS_GUARD_LIMITS.maxConsecutiveNonActionCalls
379
446
  if (this.consecutiveNonActionCalls >= nonActionCap) {
380
- return (
381
- `no progress: ${this.consecutiveNonActionCalls} consecutive read-only calls (searching, ` +
382
- `reading, tool-server lookups, subagent dispatches) with no action call between them. ` +
383
- `The agent is cycling through research instead of doing the work. Aborting.`
384
- )
447
+ return {
448
+ kind: 'abort',
449
+ reason:
450
+ `no progress: ${this.consecutiveNonActionCalls} consecutive read-only calls (searching, ` +
451
+ `reading, tool-server lookups, subagent dispatches) with no action call between them. ` +
452
+ `The agent is cycling through research instead of doing the work. Aborting.`,
453
+ }
385
454
  }
386
455
  return null
387
456
  }
@@ -389,15 +458,22 @@ export class ProgressGuard {
389
458
  this.toolCalls++
390
459
  if (FILE_EDIT_TOOLS.has(name)) this.edits++
391
460
 
461
+ // PROVISIONAL, not settled: the tool names say no recognised edit tool was called, which is
462
+ // not the same fact as "the repository is unchanged". The caller answers that from the
463
+ // working tree and reports back; until it does, the question is not re-raised.
392
464
  if (
393
465
  this.expectsEdits &&
394
466
  this.edits === 0 &&
467
+ !this.awaitingWorkspaceEvidence &&
395
468
  this.toolCalls >= this.limits.maxToolCallsWithoutEdit
396
469
  ) {
397
- return (
398
- `no progress: ${this.toolCalls} tool calls and not one file edit — the agent is exploring or ` +
399
- `probing the environment without implementing anything. Aborting before it burns the whole run.`
400
- )
470
+ this.awaitingWorkspaceEvidence = true
471
+ return {
472
+ kind: 'needs-workspace-evidence',
473
+ reason:
474
+ `no progress: ${this.toolCalls} tool calls and no recognised file edit — the agent may be ` +
475
+ `exploring or probing the environment without implementing anything.`,
476
+ }
401
477
  }
402
478
  return null
403
479
  }
package/src/runner.ts CHANGED
@@ -116,7 +116,7 @@ export interface RunOptions {
116
116
  log?: Logger
117
117
  /**
118
118
  * Extra environment for the agent's child process, scoped to THIS job. The CLI is spawned with
119
- * `{...process.env, ...agentEnv}`, so these reach the agent and every shell tool it spawns.
119
+ * `agentChildEnv(agentEnv)`, so these reach the agent and every shell tool it spawns.
120
120
  *
121
121
  * This is the seam for anything per-job that would otherwise be written to a process- or
122
122
  * HOME-global (the tester's secrets, a private-registry npmrc pointer). Those globals are only