@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.
- package/README.md +49 -0
- package/dist/agent-capabilities.d.ts +21 -24
- package/dist/agent-capabilities.js +22 -50
- package/dist/agent-env.d.ts +17 -0
- package/dist/agent-env.js +47 -0
- package/dist/agent-runner.d.ts +18 -2
- package/dist/agent-runner.js +29 -231
- package/dist/agent-shared.d.ts +14 -5
- package/dist/agent-shared.js +14 -5
- package/dist/agent.d.ts +0 -11
- package/dist/agent.js +7 -138
- package/dist/captured-command.d.ts +1 -1
- package/dist/captured-command.js +3 -2
- package/dist/claude-cli.d.ts +90 -0
- package/dist/claude-cli.js +181 -0
- package/dist/claude-home.d.ts +41 -0
- package/dist/claude-home.js +159 -0
- package/dist/coding-agent.d.ts +35 -0
- package/dist/coding-agent.js +213 -41
- package/dist/docker-status.d.ts +89 -0
- package/dist/docker-status.js +147 -0
- package/dist/frontend-infra.js +4 -3
- package/dist/git.d.ts +48 -5
- package/dist/git.js +93 -26
- package/dist/guard-driver.d.ts +71 -0
- package/dist/guard-driver.js +171 -0
- package/dist/harness-server.js +13 -0
- package/dist/infra-standup.d.ts +69 -0
- package/dist/infra-standup.js +182 -0
- package/dist/job.d.ts +10 -0
- package/dist/multi-repo-coding.d.ts +17 -0
- package/dist/multi-repo-coding.js +61 -16
- package/dist/pi-workspace.d.ts +11 -0
- package/dist/pi-workspace.js +126 -57
- package/dist/pi.d.ts +8 -0
- package/dist/pi.js +16 -9
- package/dist/progress-guard.d.ts +56 -10
- package/dist/progress-guard.js +84 -22
- package/dist/runner.d.ts +1 -1
- package/dist/salvage.d.ts +180 -0
- package/dist/salvage.js +289 -0
- package/dist/workspace-probe.d.ts +85 -0
- package/dist/workspace-probe.js +124 -0
- package/package.json +4 -4
- package/src/agent-capabilities.ts +25 -51
- package/src/agent-env.ts +49 -0
- package/src/agent-runner.ts +40 -267
- package/src/agent-shared.ts +16 -5
- package/src/agent.ts +7 -164
- package/src/captured-command.ts +3 -2
- package/src/claude-cli.ts +217 -0
- package/src/claude-home.ts +233 -0
- package/src/coding-agent.ts +252 -44
- package/src/docker-status.ts +201 -0
- package/src/frontend-infra.ts +4 -3
- package/src/git.ts +104 -26
- package/src/guard-driver.ts +203 -0
- package/src/harness-server.ts +13 -0
- package/src/infra-standup.ts +218 -0
- package/src/job.ts +10 -0
- package/src/multi-repo-coding.ts +65 -16
- package/src/pi-workspace.ts +161 -57
- package/src/pi.ts +27 -12
- package/src/progress-guard.ts +110 -34
- package/src/runner.ts +1 -1
- package/src/salvage.ts +407 -0
- 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:
|
|
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
|
package/src/progress-guard.ts
CHANGED
|
@@ -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.
|
|
108
|
-
//
|
|
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
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
*
|
|
281
|
-
*
|
|
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
|
|
298
|
-
observe(event: Record<string, unknown>):
|
|
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
|
-
*
|
|
306
|
-
*
|
|
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 }):
|
|
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
|
-
|
|
319
|
-
|
|
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
|
-
|
|
333
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
* `
|
|
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
|