@cat-factory/executor-harness 1.132.3 → 1.135.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 (67) hide show
  1. package/README.md +49 -0
  2. package/dist/agent-capabilities.d.ts +21 -24
  3. package/dist/agent-capabilities.js +22 -50
  4. package/dist/agent-env.d.ts +17 -0
  5. package/dist/agent-env.js +47 -0
  6. package/dist/agent-runner.d.ts +18 -2
  7. package/dist/agent-runner.js +29 -231
  8. package/dist/agent-shared.d.ts +14 -5
  9. package/dist/agent-shared.js +14 -5
  10. package/dist/agent.d.ts +0 -11
  11. package/dist/agent.js +7 -138
  12. package/dist/captured-command.d.ts +1 -1
  13. package/dist/captured-command.js +3 -2
  14. package/dist/claude-cli.d.ts +90 -0
  15. package/dist/claude-cli.js +181 -0
  16. package/dist/claude-home.d.ts +41 -0
  17. package/dist/claude-home.js +159 -0
  18. package/dist/coding-agent.d.ts +35 -0
  19. package/dist/coding-agent.js +213 -41
  20. package/dist/docker-status.d.ts +89 -0
  21. package/dist/docker-status.js +147 -0
  22. package/dist/frontend-infra.js +4 -3
  23. package/dist/git.d.ts +48 -5
  24. package/dist/git.js +93 -26
  25. package/dist/guard-driver.d.ts +71 -0
  26. package/dist/guard-driver.js +171 -0
  27. package/dist/harness-server.js +13 -0
  28. package/dist/infra-standup.d.ts +69 -0
  29. package/dist/infra-standup.js +182 -0
  30. package/dist/job.d.ts +10 -0
  31. package/dist/multi-repo-coding.d.ts +17 -0
  32. package/dist/multi-repo-coding.js +61 -16
  33. package/dist/pi-workspace.d.ts +11 -0
  34. package/dist/pi-workspace.js +126 -57
  35. package/dist/pi.d.ts +8 -0
  36. package/dist/pi.js +16 -9
  37. package/dist/progress-guard.d.ts +56 -10
  38. package/dist/progress-guard.js +84 -22
  39. package/dist/runner.d.ts +1 -1
  40. package/dist/salvage.d.ts +180 -0
  41. package/dist/salvage.js +289 -0
  42. package/dist/workspace-probe.d.ts +85 -0
  43. package/dist/workspace-probe.js +124 -0
  44. package/package.json +4 -4
  45. package/src/agent-capabilities.ts +25 -51
  46. package/src/agent-env.ts +49 -0
  47. package/src/agent-runner.ts +40 -267
  48. package/src/agent-shared.ts +16 -5
  49. package/src/agent.ts +7 -164
  50. package/src/captured-command.ts +3 -2
  51. package/src/claude-cli.ts +217 -0
  52. package/src/claude-home.ts +233 -0
  53. package/src/coding-agent.ts +252 -44
  54. package/src/docker-status.ts +201 -0
  55. package/src/frontend-infra.ts +4 -3
  56. package/src/git.ts +104 -26
  57. package/src/guard-driver.ts +203 -0
  58. package/src/harness-server.ts +13 -0
  59. package/src/infra-standup.ts +218 -0
  60. package/src/job.ts +10 -0
  61. package/src/multi-repo-coding.ts +65 -16
  62. package/src/pi-workspace.ts +161 -57
  63. package/src/pi.ts +27 -12
  64. package/src/progress-guard.ts +110 -34
  65. package/src/runner.ts +1 -1
  66. package/src/salvage.ts +407 -0
  67. package/src/workspace-probe.ts +155 -0
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