@cat-factory/executor-harness 1.64.4 → 1.68.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 +33 -0
- package/dist/agent-capabilities.js +354 -0
- package/dist/agent-runner.js +111 -25
- package/dist/agent-shared.js +23 -0
- package/dist/agent.js +9 -139
- package/dist/bootstrap-mode.js +141 -0
- package/dist/claude-call-aggregator.js +6 -3
- package/dist/claude-stream.js +12 -6
- package/dist/coding-agent.js +85 -67
- package/dist/inline.js +29 -1
- package/dist/job.js +6 -75
- package/dist/pi-workspace.js +15 -14
- package/dist/pi.js +24 -16
- package/dist/subagents.js +14 -3
- package/package.json +4 -4
- package/src/agent-capabilities.ts +414 -0
- package/src/agent-runner.ts +155 -44
- package/src/agent-shared.ts +34 -0
- package/src/agent.ts +8 -165
- package/src/bootstrap-mode.ts +174 -0
- package/src/claude-call-aggregator.ts +8 -4
- package/src/claude-stream.ts +15 -7
- package/src/coding-agent.ts +102 -71
- package/src/inline.ts +34 -1
- package/src/job.ts +51 -94
- package/src/pi-workspace.ts +30 -20
- package/src/pi.ts +38 -17
- package/src/subagents.ts +17 -3
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { opendir } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import type { AgentJob, AgentResult } from './job.js'
|
|
4
|
+
import type { PiRunStats } from './pi.js'
|
|
5
|
+
import type { RunOptions } from './runner.js'
|
|
6
|
+
import {
|
|
7
|
+
NEVER_ACTED_CAUSE,
|
|
8
|
+
agentNeverActed,
|
|
9
|
+
agentOutputTail,
|
|
10
|
+
runAgentInWorkspace,
|
|
11
|
+
withWorkspace,
|
|
12
|
+
} from './pi-workspace.js'
|
|
13
|
+
import { cloneRepo, hasAgentChanges, reinitAndPush } from './git.js'
|
|
14
|
+
import { log } from './logger.js'
|
|
15
|
+
import { agentCapabilities, mergeEffort } from './agent-shared.js'
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// The repo-BOOTSTRAP mode: adapt a reference architecture (or scaffold from scratch) into a
|
|
19
|
+
// pre-created empty repo and force-push it as a single commit. Extracted from `agent.ts` as a
|
|
20
|
+
// cohesive collaborator — it is a whole MODE with its own push semantics (a separate target repo
|
|
21
|
+
// and a reinitialised history, not a work branch + PR), and it shares only the small agent-run
|
|
22
|
+
// helpers in `agent-shared.ts` with the coding/explore flows.
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Repo-bootstrap coding flow (the bootstrapper): with a reference architecture, clone it →
|
|
27
|
+
* the agent adapts it in place per the instructions; without one (`fromScratch`), start from
|
|
28
|
+
* an empty directory → the agent scaffolds the new service. Either way the result's history
|
|
29
|
+
* is reset to a single commit and force-pushed to the SEPARATE, pre-created target repo's
|
|
30
|
+
* default branch. Diverges from the ordinary coding flow in pushing to a different repo with
|
|
31
|
+
* a reinitialised history rather than a work branch + PR on the cloned repo.
|
|
32
|
+
*/
|
|
33
|
+
export async function runBootstrap(job: AgentJob, opts: RunOptions): Promise<AgentResult> {
|
|
34
|
+
const { signal } = opts
|
|
35
|
+
const boot = job.bootstrap!
|
|
36
|
+
const fromScratch = boot.fromScratch === true
|
|
37
|
+
const logger = (opts.log ?? log).child({ target: `${boot.target.owner}/${boot.target.name}` })
|
|
38
|
+
return withWorkspace('boot', async (dir) => {
|
|
39
|
+
if (!fromScratch) {
|
|
40
|
+
opts.onPhase?.('clone')
|
|
41
|
+
logger.info('agent(bootstrap): cloning reference architecture', {
|
|
42
|
+
reference: `${job.repo.owner}/${job.repo.name}`,
|
|
43
|
+
})
|
|
44
|
+
await cloneRepo({
|
|
45
|
+
repo: { ...job.repo, baseBranch: job.branch },
|
|
46
|
+
ghToken: job.ghToken,
|
|
47
|
+
dir,
|
|
48
|
+
signal,
|
|
49
|
+
})
|
|
50
|
+
} else {
|
|
51
|
+
logger.info('agent(bootstrap): scaffolding from scratch (no reference)')
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
opts.onPhase?.('agent')
|
|
55
|
+
logger.info('agent(bootstrap): running agent')
|
|
56
|
+
const { summary, stats, stderrTail, usage, callMetrics, effortReport } =
|
|
57
|
+
await runAgentInWorkspace(
|
|
58
|
+
{
|
|
59
|
+
dir,
|
|
60
|
+
systemPrompt: job.systemPrompt,
|
|
61
|
+
userPrompt: job.userPrompt,
|
|
62
|
+
model: job.model,
|
|
63
|
+
harness: job.harness,
|
|
64
|
+
subscriptionToken: job.subscriptionToken,
|
|
65
|
+
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
66
|
+
ambientAuth: job.ambientAuth,
|
|
67
|
+
proxyBaseUrl: job.proxyBaseUrl,
|
|
68
|
+
sessionToken: job.sessionToken,
|
|
69
|
+
guardLimits: job.guardLimits,
|
|
70
|
+
...agentCapabilities(job),
|
|
71
|
+
},
|
|
72
|
+
opts,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
// Guard against a no-op run: Pi can exit cleanly having done nothing (e.g. it never
|
|
76
|
+
// reached the model), and a force-push would then publish an empty tree — leaving the
|
|
77
|
+
// run "succeeded" but the repo bare. Fail with a structured error (carrying what the
|
|
78
|
+
// agent did) instead of pushing nothing.
|
|
79
|
+
if (!(await producedRepoContent(dir, !fromScratch, signal))) {
|
|
80
|
+
const error = bootstrapNoOpReason(!fromScratch, stats, summary, stderrTail)
|
|
81
|
+
logger.error('agent(bootstrap): agent produced no content, refusing to push', { ...stats })
|
|
82
|
+
return mergeEffort(
|
|
83
|
+
{
|
|
84
|
+
summary,
|
|
85
|
+
stats,
|
|
86
|
+
error,
|
|
87
|
+
failureCause: 'agent',
|
|
88
|
+
...(usage ? { usage } : {}),
|
|
89
|
+
...(callMetrics ? { callMetrics } : {}),
|
|
90
|
+
},
|
|
91
|
+
effortReport,
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
opts.onPhase?.('push')
|
|
96
|
+
logger.info('agent(bootstrap): pushing bootstrapped contents', { ...stats })
|
|
97
|
+
// Bootstrap always resets history to one commit + force-pushes (the fresh history
|
|
98
|
+
// shares no ancestor with whatever boilerplate the new repo was created with).
|
|
99
|
+
await reinitAndPush({
|
|
100
|
+
dir,
|
|
101
|
+
target: boot.target,
|
|
102
|
+
ghToken: job.ghToken,
|
|
103
|
+
message: fromScratch
|
|
104
|
+
? 'Bootstrap new repository'
|
|
105
|
+
: `Bootstrap from ${job.repo.owner}/${job.repo.name}`,
|
|
106
|
+
})
|
|
107
|
+
logger.info('agent(bootstrap): complete', { defaultBranch: boot.target.defaultBranch })
|
|
108
|
+
return mergeEffort(
|
|
109
|
+
{
|
|
110
|
+
defaultBranch: boot.target.defaultBranch,
|
|
111
|
+
summary,
|
|
112
|
+
stats,
|
|
113
|
+
...(usage ? { usage } : {}),
|
|
114
|
+
...(callMetrics ? { callMetrics } : {}),
|
|
115
|
+
},
|
|
116
|
+
effortReport,
|
|
117
|
+
)
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Whether the bootstrapper actually produced repository content, so a no-op run (the agent
|
|
123
|
+
* never reached the model / never wrote anything) is failed rather than force-pushed as an
|
|
124
|
+
* empty repo. With a reference architecture, "produced content" means the agent changed the
|
|
125
|
+
* clone; scaffolding from scratch, it means at least one file now exists in the working
|
|
126
|
+
* directory. (The harness writes its prompt context to Pi's global `~/.pi/agent/AGENTS.md`,
|
|
127
|
+
* never into `dir`, so nothing here needs to be filtered out as harness boilerplate.)
|
|
128
|
+
*/
|
|
129
|
+
export async function producedRepoContent(
|
|
130
|
+
dir: string,
|
|
131
|
+
hasReference: boolean,
|
|
132
|
+
signal?: AbortSignal,
|
|
133
|
+
): Promise<boolean> {
|
|
134
|
+
if (hasReference) return hasAgentChanges(dir, signal)
|
|
135
|
+
return containsAnyFile(dir)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Whether `dir` contains at least one regular file anywhere in its tree, walking
|
|
140
|
+
* depth-first and stopping at the FIRST file found — so the cost is bounded by how
|
|
141
|
+
* quickly a file turns up (a scaffold almost always writes a root-level file), not by
|
|
142
|
+
* the size of the produced tree (a full recursive `readdir` would materialise every
|
|
143
|
+
* entry before the check).
|
|
144
|
+
*/
|
|
145
|
+
async function containsAnyFile(dir: string): Promise<boolean> {
|
|
146
|
+
const handle = await opendir(dir)
|
|
147
|
+
try {
|
|
148
|
+
for await (const entry of handle) {
|
|
149
|
+
if (entry.isFile()) return true
|
|
150
|
+
if (entry.isDirectory() && (await containsAnyFile(join(dir, entry.name)))) return true
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
// A directory that vanished mid-walk has nothing to contribute.
|
|
154
|
+
}
|
|
155
|
+
return false
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Human-readable bootstrap no-op reason, embedding what the agent did so the cause is visible. */
|
|
159
|
+
function bootstrapNoOpReason(
|
|
160
|
+
hasReference: boolean,
|
|
161
|
+
stats: PiRunStats,
|
|
162
|
+
summary: string,
|
|
163
|
+
stderrTail: string | undefined,
|
|
164
|
+
): string {
|
|
165
|
+
const what = hasReference
|
|
166
|
+
? 'made no changes to the reference architecture'
|
|
167
|
+
: 'scaffolded no files'
|
|
168
|
+
const cause = agentNeverActed(stats) ? NEVER_ACTED_CAUSE : ''
|
|
169
|
+
return (
|
|
170
|
+
`the bootstrapper agent ${what} ` +
|
|
171
|
+
`(tool calls: ${stats.toolCalls}, assistant output: ${stats.assistantChars} chars).${cause}` +
|
|
172
|
+
agentOutputTail(stderrTail, summary)
|
|
173
|
+
)
|
|
174
|
+
}
|
|
@@ -22,7 +22,8 @@ export interface AggregatedClaudeCall {
|
|
|
22
22
|
reasoning: string
|
|
23
23
|
stopReason: string | null
|
|
24
24
|
inputTokens: number
|
|
25
|
-
|
|
25
|
+
cacheReadTokens: number
|
|
26
|
+
cacheWriteTokens: number
|
|
26
27
|
outputTokens: number
|
|
27
28
|
/** The `user` turns carrying this call's tool_result blocks, in arrival order. */
|
|
28
29
|
toolResults: unknown[][]
|
|
@@ -95,7 +96,8 @@ export function createClaudeCallAggregator(handlers: {
|
|
|
95
96
|
reasoning: '',
|
|
96
97
|
stopReason: null,
|
|
97
98
|
inputTokens: 0,
|
|
98
|
-
|
|
99
|
+
cacheReadTokens: 0,
|
|
100
|
+
cacheWriteTokens: 0,
|
|
99
101
|
outputTokens: 0,
|
|
100
102
|
toolResults: [],
|
|
101
103
|
toolUses: 0,
|
|
@@ -107,7 +109,8 @@ export function createClaudeCallAggregator(handlers: {
|
|
|
107
109
|
pending.reasoning += reasoning
|
|
108
110
|
pending.toolUses += toolUses
|
|
109
111
|
pending.inputTokens = Math.max(pending.inputTokens, usage.inputTokens)
|
|
110
|
-
pending.
|
|
112
|
+
pending.cacheReadTokens = Math.max(pending.cacheReadTokens, usage.cacheReadTokens)
|
|
113
|
+
pending.cacheWriteTokens = Math.max(pending.cacheWriteTokens, usage.cacheWriteTokens)
|
|
111
114
|
pending.outputTokens = Math.max(pending.outputTokens, usage.outputTokens)
|
|
112
115
|
// A block-split response reports its stop reason on the envelope that carries the end of the
|
|
113
116
|
// message; earlier ones report none. Keep the first non-null rather than the last seen.
|
|
@@ -185,7 +188,8 @@ export function createClaudeStreamTelemetry(opts: {
|
|
|
185
188
|
responseText: redactBody(call.text, opts.secrets),
|
|
186
189
|
reasoningText: redactBody(call.reasoning, opts.secrets),
|
|
187
190
|
inputTokens: call.inputTokens,
|
|
188
|
-
|
|
191
|
+
cacheReadTokens: call.cacheReadTokens,
|
|
192
|
+
cacheWriteTokens: call.cacheWriteTokens,
|
|
189
193
|
outputTokens: call.outputTokens,
|
|
190
194
|
finishReason: call.stopReason,
|
|
191
195
|
})
|
package/src/claude-stream.ts
CHANGED
|
@@ -59,19 +59,27 @@ export function claudeAssistantContent(content: unknown[]): {
|
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
61
|
* Per-CALL token usage off a Claude `assistant` message's `usage` (this turn only, not
|
|
62
|
-
* the cumulative `result` total).
|
|
63
|
-
*
|
|
62
|
+
* the cumulative `result` total).
|
|
63
|
+
*
|
|
64
|
+
* Anthropic reports all three input classes SEPARATELY and `input_tokens` is already
|
|
65
|
+
* exclusive of both caches, so the three fields here are orthogonal and additive:
|
|
66
|
+
* total input = `inputTokens + cacheReadTokens + cacheWriteTokens`. Do NOT re-lump the
|
|
67
|
+
* reads and the writes — a cache write costs 1.25–2× base input while a read costs ~0.1×,
|
|
68
|
+
* so a turn that keeps invalidating the prefix and one that rides a warm cache are
|
|
69
|
+
* indistinguishable once they are summed.
|
|
64
70
|
*/
|
|
65
71
|
export function claudeCallUsage(raw: unknown): {
|
|
66
72
|
inputTokens: number
|
|
67
|
-
|
|
73
|
+
cacheReadTokens: number
|
|
74
|
+
cacheWriteTokens: number
|
|
68
75
|
outputTokens: number
|
|
69
76
|
} {
|
|
70
|
-
if (!isObject(raw))
|
|
71
|
-
|
|
77
|
+
if (!isObject(raw))
|
|
78
|
+
return { inputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 0 }
|
|
72
79
|
return {
|
|
73
|
-
inputTokens: numberOf(raw.input_tokens)
|
|
74
|
-
|
|
80
|
+
inputTokens: numberOf(raw.input_tokens),
|
|
81
|
+
cacheReadTokens: numberOf(raw.cache_read_input_tokens),
|
|
82
|
+
cacheWriteTokens: numberOf(raw.cache_creation_input_tokens),
|
|
75
83
|
outputTokens: numberOf(raw.output_tokens),
|
|
76
84
|
}
|
|
77
85
|
}
|
package/src/coding-agent.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
import { mkdir } from 'node:fs/promises'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
|
-
import {
|
|
4
|
-
import { killChildProcess, spawnDetached } from './process.js'
|
|
5
|
-
import { MAX_CAPTURED_OUTPUT_CHARS, redactSecrets } from './redact.js'
|
|
3
|
+
import { runCapturedCommand } from './captured-command.js'
|
|
6
4
|
import type {
|
|
7
5
|
AgentJob,
|
|
8
6
|
AgentResult,
|
|
@@ -11,6 +9,7 @@ import type {
|
|
|
11
9
|
ReferenceRepoSpec,
|
|
12
10
|
RepoSpec,
|
|
13
11
|
SkillSpec,
|
|
12
|
+
McpServerSpec,
|
|
14
13
|
} from './job.js'
|
|
15
14
|
import {
|
|
16
15
|
branchAheadOfBase,
|
|
@@ -140,12 +139,18 @@ export interface CodingAgentSpec extends HarnessAuthFields {
|
|
|
140
139
|
*/
|
|
141
140
|
reproduction?: ReproductionSpec
|
|
142
141
|
/**
|
|
143
|
-
*
|
|
144
|
-
* into {@link runAgentInWorkspace}, which installs
|
|
145
|
-
* `CLAUDE_CONFIG_DIR` for a leased-credential
|
|
146
|
-
* else (Pi, codex, and ambient
|
|
142
|
+
* The skills to make available for this run — a `skill` step's pick and/or the running kind's
|
|
143
|
+
* declared playbooks. Threaded into {@link runAgentInWorkspace}, which installs them
|
|
144
|
+
* harness-aware: natively under the ISOLATED `CLAUDE_CONFIG_DIR` for a leased-credential
|
|
145
|
+
* claude-code run, `.cat-context/skill/<name>/` for everything else (Pi, codex, and ambient
|
|
146
|
+
* claude-code, which has no isolated config dir). Absent ⇒ no skills.
|
|
147
147
|
*/
|
|
148
|
-
|
|
148
|
+
skills?: SkillSpec[]
|
|
149
|
+
/**
|
|
150
|
+
* Tool servers (MCP) to wire into the agent CLI for this run. Forwarded verbatim — the backend
|
|
151
|
+
* has already dropped anything this harness cannot serve. Absent ⇒ built-in tools only.
|
|
152
|
+
*/
|
|
153
|
+
mcpServers?: McpServerSpec[]
|
|
149
154
|
}
|
|
150
155
|
|
|
151
156
|
/** The outcome of a coding agent run, before each caller maps it to its own result shape. */
|
|
@@ -179,6 +184,8 @@ export interface CodingAgentOutcome {
|
|
|
179
184
|
exitCode: number
|
|
180
185
|
validationOutputTail?: string
|
|
181
186
|
iteration?: number
|
|
187
|
+
/** The work-branch HEAD the command was judged against (absent when it could not be read). */
|
|
188
|
+
headSha?: string
|
|
182
189
|
}
|
|
183
190
|
/**
|
|
184
191
|
* The pre-PR validation loop's LAST attempt (present only when {@link CodingAgentSpec.validationChecks}
|
|
@@ -350,7 +357,8 @@ export async function runCodingAgent(
|
|
|
350
357
|
webToolsGuidance: spec.webToolsGuidance,
|
|
351
358
|
webSearchProxy: spec.webSearchProxy,
|
|
352
359
|
guardLimits: spec.guardLimits,
|
|
353
|
-
...(spec.
|
|
360
|
+
...(spec.skills?.length ? { skills: spec.skills } : {}),
|
|
361
|
+
...(spec.mcpServers?.length ? { mcpServers: spec.mcpServers } : {}),
|
|
354
362
|
},
|
|
355
363
|
opts,
|
|
356
364
|
)
|
|
@@ -718,7 +726,7 @@ async function finalizeCodingRun(args: {
|
|
|
718
726
|
// Runs regardless of whether this pass pushed — a no-op iteration must still be able
|
|
719
727
|
// to report that the criterion is (already) met. The harness runs it, never the model.
|
|
720
728
|
if (spec.validation) {
|
|
721
|
-
outcome.validation = await runRalphValidation(workDir, spec.validation, logger, opts)
|
|
729
|
+
outcome.validation = await runRalphValidation(dir, workDir, spec.validation, logger, opts)
|
|
722
730
|
}
|
|
723
731
|
// Pre-PR validation: the loop already ran (before this finalize, so a red checkout never
|
|
724
732
|
// reaches the PR-opening caller); attach its verdict for the caller to gate on and for the
|
|
@@ -796,22 +804,57 @@ function mergeAgentPasses<T extends Awaited<ReturnType<typeof runAgentInWorkspac
|
|
|
796
804
|
* killed and treated as a failure (a hung `pnpm test` must never block the loop forever).
|
|
797
805
|
* Overridable via env for tests; defaults to 15 minutes.
|
|
798
806
|
*/
|
|
799
|
-
function ralphValidationTimeoutMs(): number {
|
|
807
|
+
export function ralphValidationTimeoutMs(): number {
|
|
800
808
|
const n = Number(process.env.RALPH_VALIDATION_TIMEOUT_MS)
|
|
801
809
|
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 15 * 60_000
|
|
802
810
|
}
|
|
803
811
|
|
|
812
|
+
/**
|
|
813
|
+
* How often the Ralph validation feeds the run's inactivity watchdog while its command runs.
|
|
814
|
+
* The command is exactly the activity-SILENT kind — a full `pnpm test`, a cold install-then-build
|
|
815
|
+
* — and the harness spawns it ITSELF rather than through the agent, so it emits no activity
|
|
816
|
+
* events of its own. `JOB_INACTIVITY_MS` (default 10 min) is TIGHTER than the command's own
|
|
817
|
+
* watchdog ({@link ralphValidationTimeoutMs}, default 15 min), so without this heartbeat any
|
|
818
|
+
* validation running past 10 minutes aborted the whole iteration as "inactivity" — mislabelling
|
|
819
|
+
* a healthy test suite as a wedge, and making the 15-minute watchdog unreachable at stock
|
|
820
|
+
* settings. The two sibling harness-run phases (pre-PR validation, reproduction proof) have
|
|
821
|
+
* always fed it; this one did not. Overridable via env for tests.
|
|
822
|
+
*/
|
|
823
|
+
export function ralphHeartbeatMs(): number {
|
|
824
|
+
const n = Number(process.env.RALPH_VALIDATION_HEARTBEAT_MS)
|
|
825
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : 30_000
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* Bound on the validation output tail that crosses the wire. Deliberately smaller than
|
|
830
|
+
* `MAX_CAPTURED_OUTPUT_CHARS` (`redact.ts`) and equal to the two sibling phases' budgets, for
|
|
831
|
+
* the same reason: this tail is persisted on the step (and on EVERY iteration of the attempt
|
|
832
|
+
* log) inside the run's `detail` JSON blob, which is re-serialized on every step-progress write.
|
|
833
|
+
*/
|
|
834
|
+
export const RALPH_VALIDATION_TAIL_CHARS = 4_000
|
|
835
|
+
|
|
804
836
|
/**
|
|
805
837
|
* Ralph loop: run the programmatic completion command in the checkout and return its exit
|
|
806
|
-
* code
|
|
807
|
-
* done signal (0 = the criterion is met) — computed
|
|
808
|
-
* by the model, which is the whole point of a
|
|
809
|
-
*
|
|
810
|
-
*
|
|
811
|
-
*
|
|
812
|
-
*
|
|
838
|
+
* code, a bounded + redacted tail of its output, and the work branch's HEAD it ran against.
|
|
839
|
+
* The EXIT CODE is the loop's authoritative done signal (0 = the criterion is met) — computed
|
|
840
|
+
* here by the harness, never self-reported by the model, which is the whole point of a
|
|
841
|
+
* programmatic exit condition. The command runs INSIDE the sandboxed run container (the same
|
|
842
|
+
* trust boundary as the coding agent) — there is no host/backend execution.
|
|
843
|
+
*
|
|
844
|
+
* The spawn itself goes through {@link runCapturedCommand}, the ONE seam for a harness-run
|
|
845
|
+
* command, rather than the near-verbatim copy this used to be. That copy had drifted in two
|
|
846
|
+
* ways the seam exists to prevent: it scrubbed secrets AFTER the rolling truncation with no
|
|
847
|
+
* margin (so a credential straddling the cut lost its `KEY=` prefix and survived redaction as
|
|
848
|
+
* an unrecognised partial, on a tail that reaches the step, the notification and the SPA), and
|
|
849
|
+
* it published the full 16k capture where both siblings deliberately bound the wire tail.
|
|
850
|
+
*
|
|
851
|
+
* `headSha` is what lets the engine tell a loop that is iterating from one that is merely
|
|
852
|
+
* repeating: two consecutive failing iterations against an unchanged head means the agent
|
|
853
|
+
* committed nothing, and the loop is ended early instead of spending the rest of its budget.
|
|
854
|
+
* Best-effort — a head that cannot be read is simply omitted, and the engine's check fails open.
|
|
813
855
|
*/
|
|
814
|
-
async function runRalphValidation(
|
|
856
|
+
export async function runRalphValidation(
|
|
857
|
+
repoDir: string,
|
|
815
858
|
cwd: string,
|
|
816
859
|
validation: { command: string; iteration?: number },
|
|
817
860
|
logger: Logger,
|
|
@@ -821,66 +864,50 @@ async function runRalphValidation(
|
|
|
821
864
|
exitCode: number
|
|
822
865
|
validationOutputTail?: string
|
|
823
866
|
iteration?: number
|
|
867
|
+
headSha?: string
|
|
824
868
|
}> {
|
|
825
|
-
const timeoutMs = ralphValidationTimeoutMs()
|
|
826
869
|
logger.info('coding-agent(ralph): running validation command', {
|
|
827
870
|
iteration: validation.iteration,
|
|
828
871
|
})
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
872
|
+
// Keep the run's inactivity watchdog fed for the whole command — see `ralphHeartbeatMs`.
|
|
873
|
+
const heartbeat = setInterval(() => opts.onActivity?.(), ralphHeartbeatMs())
|
|
874
|
+
heartbeat.unref?.()
|
|
875
|
+
let captured
|
|
876
|
+
try {
|
|
877
|
+
captured = await runCapturedCommand({
|
|
833
878
|
cwd,
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
879
|
+
command: validation.command,
|
|
880
|
+
timeoutMs: ralphValidationTimeoutMs(),
|
|
881
|
+
reportTailChars: RALPH_VALIDATION_TAIL_CHARS,
|
|
882
|
+
logLabel: 'coding-agent(ralph): validation',
|
|
883
|
+
logFields: { iteration: validation.iteration },
|
|
884
|
+
logger,
|
|
885
|
+
opts,
|
|
840
886
|
})
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
opts.signal?.removeEventListener('abort', onAbort)
|
|
852
|
-
const trimmed = out.trim()
|
|
853
|
-
const tail = trimmed ? redactSecrets(trimmed) : undefined
|
|
854
|
-
logger.info('coding-agent(ralph): validation finished', {
|
|
855
|
-
exitCode,
|
|
856
|
-
iteration: validation.iteration,
|
|
857
|
-
})
|
|
858
|
-
resolve({
|
|
859
|
-
validationPassed: exitCode === 0,
|
|
860
|
-
exitCode,
|
|
861
|
-
...(tail ? { validationOutputTail: tail } : {}),
|
|
862
|
-
...(validation.iteration !== undefined ? { iteration: validation.iteration } : {}),
|
|
863
|
-
})
|
|
864
|
-
}
|
|
865
|
-
const timer = setTimeout(() => {
|
|
866
|
-
logger.warn('coding-agent(ralph): validation command timed out', { timeoutMs })
|
|
867
|
-
killChildProcess(child, undefined, logger)
|
|
868
|
-
finish(124) // conventional timeout exit code (a non-zero fail)
|
|
869
|
-
}, timeoutMs)
|
|
870
|
-
timer.unref?.()
|
|
871
|
-
const onAbort = (): void => {
|
|
872
|
-
killChildProcess(child, undefined, logger)
|
|
873
|
-
finish(130) // aborted (a non-zero fail)
|
|
874
|
-
}
|
|
875
|
-
opts.signal?.addEventListener('abort', onAbort, { once: true })
|
|
876
|
-
child.on('error', (err) => {
|
|
877
|
-
logger.warn('coding-agent(ralph): validation command failed to spawn', {
|
|
878
|
-
error: err instanceof Error ? err.message : String(err),
|
|
879
|
-
})
|
|
880
|
-
finish(127) // spawn error / command not found (a non-zero fail)
|
|
887
|
+
} finally {
|
|
888
|
+
clearInterval(heartbeat)
|
|
889
|
+
}
|
|
890
|
+
// The commit the criterion was judged against. Read AFTER the command so a validation that
|
|
891
|
+
// itself commits (a formatter check that rewrites files, say) is attributed to what it left.
|
|
892
|
+
// Best-effort: an unreadable head only costs the engine's no-progress guard, never the
|
|
893
|
+
// verdict — but it is REPORTED, or a guard that quietly stopped firing leaves no trace.
|
|
894
|
+
const headSha = await headCommit(repoDir, opts.signal).catch((err: unknown) => {
|
|
895
|
+
logger.warn('coding-agent(ralph): could not read the work-branch head', {
|
|
896
|
+
error: err instanceof Error ? err.message : String(err),
|
|
881
897
|
})
|
|
882
|
-
|
|
898
|
+
return ''
|
|
899
|
+
})
|
|
900
|
+
logger.info('coding-agent(ralph): validation finished', {
|
|
901
|
+
exitCode: captured.exitCode,
|
|
902
|
+
iteration: validation.iteration,
|
|
883
903
|
})
|
|
904
|
+
return {
|
|
905
|
+
validationPassed: captured.passed,
|
|
906
|
+
exitCode: captured.exitCode,
|
|
907
|
+
...(captured.outputTail ? { validationOutputTail: captured.outputTail } : {}),
|
|
908
|
+
...(validation.iteration !== undefined ? { iteration: validation.iteration } : {}),
|
|
909
|
+
...(headSha ? { headSha } : {}),
|
|
910
|
+
}
|
|
884
911
|
}
|
|
885
912
|
|
|
886
913
|
/** Sanitise an owner/name into a safe single path segment for a sibling checkout directory. */
|
|
@@ -1030,6 +1057,10 @@ export async function runMultiRepoCoding(
|
|
|
1030
1057
|
webSearchProxy: job.webSearch,
|
|
1031
1058
|
guardLimits: job.guardLimits,
|
|
1032
1059
|
...(job.contextFiles ? { contextFiles: job.contextFiles } : {}),
|
|
1060
|
+
// Skills + tool servers apply to a multi-repo run exactly as to a single-repo one: they
|
|
1061
|
+
// are properties of the AGENT KIND, not of the checkout layout.
|
|
1062
|
+
...(job.skills?.length ? { skills: job.skills } : {}),
|
|
1063
|
+
...(job.mcpServers?.length ? { mcpServers: job.mcpServers } : {}),
|
|
1033
1064
|
multiRepo: true,
|
|
1034
1065
|
},
|
|
1035
1066
|
opts,
|
package/src/inline.ts
CHANGED
|
@@ -54,10 +54,43 @@ export async function handleInline(job: InlineJob, opts: RunOptions): Promise<In
|
|
|
54
54
|
return {
|
|
55
55
|
text: outcome.summary,
|
|
56
56
|
finishReason: deriveFinishReason(outcome.callMetrics),
|
|
57
|
-
...(outcome.usage ? { usage: outcome.usage } : {}),
|
|
57
|
+
...(outcome.usage ? { usage: inlineUsage(outcome.usage, outcome.callMetrics) } : {}),
|
|
58
58
|
...(outcome.callMetrics ? { callMetrics: outcome.callMetrics } : {}),
|
|
59
59
|
}
|
|
60
60
|
} finally {
|
|
61
61
|
await rm(cwd, { recursive: true, force: true }).catch(() => {})
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Split the run's coarse usage into the three orthogonal input classes an {@link InlineResult}
|
|
67
|
+
* carries. `outcome.usage` is the ROTATION-window weight — every billed input bucket summed —
|
|
68
|
+
* so the split has to come from the per-call metrics, the only channel that kept the classes
|
|
69
|
+
* apart. Fresh input is likewise taken from the calls rather than derived by subtraction, so a
|
|
70
|
+
* CLI whose per-call and cumulative counts disagree can never produce a negative class.
|
|
71
|
+
*
|
|
72
|
+
* With no per-call telemetry (an older CLI build that streams nothing) the coarse total is
|
|
73
|
+
* reported as fresh with both cache classes 0. That is the honest reading: nothing is KNOWN to
|
|
74
|
+
* have been cached, and inventing a split would be worse than admitting the channel is silent.
|
|
75
|
+
*/
|
|
76
|
+
function inlineUsage(
|
|
77
|
+
usage: { inputTokens: number; outputTokens: number },
|
|
78
|
+
calls: HarnessCallMetric[] | undefined,
|
|
79
|
+
): NonNullable<InlineResult['usage']> {
|
|
80
|
+
if (!calls?.length) {
|
|
81
|
+
return {
|
|
82
|
+
inputTokens: usage.inputTokens,
|
|
83
|
+
cacheReadTokens: 0,
|
|
84
|
+
cacheWriteTokens: 0,
|
|
85
|
+
outputTokens: usage.outputTokens,
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const sum = (pick: (call: HarnessCallMetric) => number): number =>
|
|
89
|
+
calls.reduce((total, call) => total + pick(call), 0)
|
|
90
|
+
return {
|
|
91
|
+
inputTokens: sum((call) => call.inputTokens),
|
|
92
|
+
cacheReadTokens: sum((call) => call.cacheReadTokens),
|
|
93
|
+
cacheWriteTokens: sum((call) => call.cacheWriteTokens),
|
|
94
|
+
outputTokens: usage.outputTokens,
|
|
95
|
+
}
|
|
96
|
+
}
|