@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.
@@ -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
- cachedInputTokens: number
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
- cachedInputTokens: 0,
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.cachedInputTokens = Math.max(pending.cachedInputTokens, usage.cachedInputTokens)
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
- cachedInputTokens: call.cachedInputTokens,
191
+ cacheReadTokens: call.cacheReadTokens,
192
+ cacheWriteTokens: call.cacheWriteTokens,
189
193
  outputTokens: call.outputTokens,
190
194
  finishReason: call.stopReason,
191
195
  })
@@ -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). `inputTokens` counts every billed input bucket (fresh
63
- * + both cache buckets); `cachedInputTokens` is the cache share, surfaced separately.
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
- cachedInputTokens: number
73
+ cacheReadTokens: number
74
+ cacheWriteTokens: number
68
75
  outputTokens: number
69
76
  } {
70
- if (!isObject(raw)) return { inputTokens: 0, cachedInputTokens: 0, outputTokens: 0 }
71
- const cached = numberOf(raw.cache_read_input_tokens) + numberOf(raw.cache_creation_input_tokens)
77
+ if (!isObject(raw))
78
+ return { inputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, outputTokens: 0 }
72
79
  return {
73
- inputTokens: numberOf(raw.input_tokens) + cached,
74
- cachedInputTokens: cached,
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
  }
@@ -1,8 +1,6 @@
1
1
  import { mkdir } from 'node:fs/promises'
2
2
  import { join } from 'node:path'
3
- import { spawn } from 'node:child_process'
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
- * A repo-sourced Claude Skill to make available for this run (a `skill` step, slice 2). Threaded
144
- * into {@link runAgentInWorkspace}, which installs it harness-aware: natively under the ISOLATED
145
- * `CLAUDE_CONFIG_DIR` for a leased-credential claude-code run, `.cat-context/skill/` for everything
146
- * else (Pi, codex, and ambient claude-code, which has no isolated config dir). Absent ⇒ no skill.
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
- skill?: SkillSpec
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.skill ? { skill: spec.skill } : {}),
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 plus a bounded, redacted tail of its output. The EXIT CODE is the loop's authoritative
807
- * done signal (0 = the criterion is met) — computed here by the harness, never self-reported
808
- * by the model, which is the whole point of a programmatic exit condition. Runs
809
- * `sh -c <command>` in `cwd`; a watchdog kills the whole process tree on timeout (a hung
810
- * command counts as a failure so the loop is never blocked), and an aborted run resolves to a
811
- * non-zero code too. The command runs INSIDE the sandboxed run container (the same trust
812
- * boundary as the coding agent) there is no host/backend execution.
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
- return new Promise((resolve) => {
830
- let out = ''
831
- let settled = false
832
- const child = spawn('sh', ['-c', validation.command], {
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
- detached: spawnDetached,
835
- stdio: ['ignore', 'pipe', 'pipe'],
836
- // The job's own env (see `RunOptions.agentEnv`): a validation command typically installs
837
- // before it tests, and this is spawned by the HARNESS rather than the agent, so it does not
838
- // otherwise inherit the job's private-registry npmrc pointer on the native path.
839
- env: { ...process.env, ...opts.agentEnv },
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
- // Keep only the tail; guard against unbounded buffering on a chatty command.
842
- const capture = (chunk: Buffer): void => {
843
- out = (out + chunk.toString('utf8')).slice(-MAX_CAPTURED_OUTPUT_CHARS)
844
- }
845
- child.stdout?.on('data', capture)
846
- child.stderr?.on('data', capture)
847
- const finish = (exitCode: number): void => {
848
- if (settled) return
849
- settled = true
850
- clearTimeout(timer)
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
- child.on('close', (code) => finish(code ?? 1))
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
+ }