@cat-factory/executor-harness 1.66.0 → 1.70.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/src/pi.ts CHANGED
@@ -33,6 +33,66 @@ import {
33
33
  */
34
34
  export const PI_MAX_OUTPUT_TOKENS = 32_768
35
35
 
36
+ /**
37
+ * Longest phase label the backend keeps. Mirrors kernel's `MAX_PHASE_CHARS`; see
38
+ * {@link normalizeProxyPhase} for why this is a copy rather than an import.
39
+ */
40
+ const MAX_PHASE_CHARS = 32
41
+
42
+ /**
43
+ * Normalise a phase label to what the backend will actually store: trimmed, lowercased,
44
+ * `[a-z0-9-]` only, bounded. `''` when the label is not a phase at all.
45
+ *
46
+ * A deliberate COPY of kernel's `normalizeCallPhase` — the container image is built from `src/`
47
+ * plus typescript alone, so the harness can carry no runtime dependency on a workspace package
48
+ * (the same constraint that forced `src/host-markdown.ts`). A copy that can drift is worse than
49
+ * no copy: if the harness rejected a label the backend would have accepted, the call would take
50
+ * the plain path and land unattributed, and if it accepted one the backend rejects it would
51
+ * spend a request on a segment destined for `''`. `test/llm-phase.conformity.test.ts` pins the
52
+ * two to identical verdicts over a corpus, so the alphabet can only be changed in both.
53
+ */
54
+ export function normalizeProxyPhase(phase: string | undefined): string {
55
+ if (typeof phase !== 'string') return ''
56
+ const trimmed = phase.trim().toLowerCase()
57
+ if (!trimmed || trimmed.length > MAX_PHASE_CHARS) return ''
58
+ return /^[a-z0-9-]+$/.test(trimmed) ? trimmed : ''
59
+ }
60
+
61
+ /**
62
+ * Point Pi's provider at the phase-tagged completions path for the pass about to run, so the
63
+ * backend can stamp WHICH slice of the run spent each call (the agent's own loop vs a pre-PR
64
+ * validation repair round vs a reproduction-proof repair round) — see
65
+ * `docs/initiatives/token-burn-instrumentation.md`. The harness drives those loops, so it is
66
+ * the only component that knows; reconstructing the boundary downstream from wall-clock
67
+ * timestamps is exactly the brittle inference this avoids.
68
+ *
69
+ * A URL segment because the harness does not make these requests: Pi does, from a config whose
70
+ * only per-run knobs are the base URL and the token — there is no per-request header to set.
71
+ *
72
+ * `supported` is the BACKEND's declaration that it serves the phase-tagged route, carried on the
73
+ * job body exactly as `webSearch` carries "point the search tool at my `/web-search`". Without it
74
+ * this function would encode a routing shape the receiving backend may not have: a runner pool
75
+ * pins its OWN harness image (`RunnerPoolManifest`), and `LOCAL_HARNESS_IMAGE` overrides the
76
+ * recommended pin outright, so "the image and the backend are a matched set" holds for the
77
+ * Cloudflare deployment and nowhere else. An image ahead of its backend would 404 EVERY model
78
+ * call — a dead run, not degraded telemetry. Absent/false ⇒ the plain path, and the calls land
79
+ * in the backend's unattributed slice.
80
+ *
81
+ * Pure so the join is unit-testable without spawning anything.
82
+ */
83
+ export function phasedProxyBaseUrl(
84
+ proxyBaseUrl: string,
85
+ phase: string | undefined,
86
+ supported: boolean | undefined,
87
+ ): string {
88
+ if (!supported) return proxyBaseUrl
89
+ // A label the backend would discard would be sent only to be thrown away, so send the plain
90
+ // path instead — the call is then honestly unattributed rather than attributed to nothing.
91
+ const normalized = normalizeProxyPhase(phase)
92
+ if (!normalized) return proxyBaseUrl
93
+ return `${proxyBaseUrl.replace(/\/+$/, '')}/phase/${normalized}`
94
+ }
95
+
36
96
  /** Write the Pi provider config that routes all model calls through the proxy. */
37
97
  export async function writePiModelsConfig(opts: {
38
98
  model: string
@@ -253,29 +313,37 @@ export async function materializeContextFiles(
253
313
  }
254
314
  }
255
315
 
256
- /** Subdirectory of {@link CONTEXT_DIR} where a repo-sourced skill's resources are materialised. */
316
+ /** Subdirectory of {@link CONTEXT_DIR} where a skill's resources are materialised, per skill. */
257
317
  export const SKILL_CONTEXT_SUBDIR = 'skill'
258
318
 
259
319
  /**
260
- * Materialise a repo-sourced skill's RESOURCE files under `.cat-context/skill/` in the checkout
261
- * (repo-sourced Claude Skills, slice 2) — the path for every run that does NOT get a native
262
- * install: Pi, codex, and ambient claude-code (no isolated `CLAUDE_CONFIG_DIR` to install into).
263
- * Their agents read the checkout, and the skill's instructions are folded into their prompt by the
264
- * backend (`renderSkillForHarness`, which keys off ambient auth as well as the harness). Resource sub-paths were sanitized at the job boundary (no traversal), so nested
265
- * dirs are created as needed. Kept out of the agent's commits via the same `.cat-context/` git
266
- * exclude entry. A skill with no resource bodies is a no-op.
320
+ * Materialise the run's skills' RESOURCE files under `.cat-context/skill/<name>/` in the checkout
321
+ * — the path for every run that does NOT get a native install: Pi, codex, and ambient claude-code
322
+ * (no isolated `CLAUDE_CONFIG_DIR` to install into). Their agents read the checkout, and the
323
+ * skills' instructions are folded into their prompt by the backend (`renderSkillsForHarness`,
324
+ * which keys off ambient auth as well as the harness).
325
+ *
326
+ * Each skill gets its OWN subdirectory: several skills can apply to one run (a step's pick plus
327
+ * the kind's declared playbooks), and a flat directory would let two skills' `templates/report.md`
328
+ * overwrite each other — silently handing the agent the wrong template. The names were sanitized
329
+ * to a single safe path segment at the job boundary, as were the resource sub-paths (no
330
+ * traversal), so nested dirs are created as needed. Kept out of the agent's commits via the same
331
+ * `.cat-context/` git exclude entry. Skills with no resource bodies are a no-op.
267
332
  */
268
333
  export async function materializeSkillResources(
269
334
  cwd: string,
270
- skill: { resources: { relPath: string; content: string }[] },
335
+ skills: { name: string; resources: { relPath: string; content: string }[] }[],
271
336
  ): Promise<void> {
272
- if (!skill.resources.length) return
273
- const dir = join(cwd, CONTEXT_DIR, SKILL_CONTEXT_SUBDIR)
274
- await mkdir(dir, { recursive: true })
275
- for (const r of skill.resources) {
276
- const dest = join(dir, r.relPath)
277
- await mkdir(dirname(dest), { recursive: true })
278
- await writeFile(dest, r.content, 'utf8')
337
+ const withResources = skills.filter((s) => s.resources.length)
338
+ if (!withResources.length) return
339
+ for (const skill of withResources) {
340
+ const dir = join(cwd, CONTEXT_DIR, SKILL_CONTEXT_SUBDIR, skill.name)
341
+ await mkdir(dir, { recursive: true })
342
+ for (const r of skill.resources) {
343
+ const dest = join(dir, r.relPath)
344
+ await mkdir(dirname(dest), { recursive: true })
345
+ await writeFile(dest, r.content, 'utf8')
346
+ }
279
347
  }
280
348
  const gitRoot = await findGitRoot(cwd)
281
349
  if (!gitRoot) return
@@ -540,6 +608,17 @@ export interface HarnessCallMetric {
540
608
  * falls back to the array index, which is what it always used before streaming existed.
541
609
  */
542
610
  seq?: number
611
+ /**
612
+ * The run PHASE that spent this call (`agent` / `validation-repair` / `reproduction-repair` /
613
+ * …), stamped by the job registry from the same marker the handlers set as they enter each
614
+ * phase — so the phase axis on `llm_call_metrics` comes from the component that owns the
615
+ * boundary rather than from a downstream guess
616
+ * (`docs/initiatives/token-burn-instrumentation.md`).
617
+ *
618
+ * Stamped on the SAME object as {@link seq}, so the live drain and the terminal result can
619
+ * never disagree about which phase billed a call.
620
+ */
621
+ phase?: string
543
622
  }
544
623
 
545
624
  /**
package/src/runner.ts CHANGED
@@ -66,6 +66,15 @@ export interface RunOptions {
66
66
  * per-phase wall-clock is logged on completion. Free-form; unknown phases just show verbatim.
67
67
  */
68
68
  onPhase?: (phase: string) => void
69
+ /**
70
+ * The phase most recently marked via {@link onPhase} — the read side of the same marker, for
71
+ * work that has to TELL the backend which phase it is in rather than merely record it. Today
72
+ * that is the Pi path, whose calls are metered server-side by the LLM proxy: the harness tags
73
+ * the proxy URL with this so a repair round's spend is attributable
74
+ * (`docs/initiatives/token-burn-instrumentation.md`). Absent ⇒ no phase is carried and those
75
+ * calls land in the backend's unattributed slice.
76
+ */
77
+ currentPhase?: () => string
69
78
  /** A per-job child logger carrying the run's correlation fields (jobId, repo, branch, …). */
70
79
  log?: Logger
71
80
  /**
@@ -468,9 +477,17 @@ export class JobRegistry<TJob = unknown, TResult extends JobResultBase = JobResu
468
477
  // instance for its terminal result, so both channels carry the same `seq` and the
469
478
  // backend mints one stable row id per call.
470
479
  call.seq = entry.callMetricSeq++
480
+ // …and the phase the job is in RIGHT NOW, which is what spent the call: the handlers
481
+ // mark `validation-repair` / `reproduction-repair` around each repair pass, so a
482
+ // looped run's telemetry says which loop the tokens went to instead of filing every
483
+ // turn under one undifferentiated "agent"
484
+ // (`docs/initiatives/token-burn-instrumentation.md`). Stamped at EMIT time, not at
485
+ // drain time: a poll can land long after the phase moved on.
486
+ call.phase = phase
471
487
  entry.callMetricBuffer.push(call)
472
488
  },
473
489
  onPhase: (next) => markPhase(next),
490
+ currentPhase: () => phase,
474
491
  log: jobLog,
475
492
  })
476
493
  markPhase('done')
@@ -1,6 +1,6 @@
1
1
  import { redact, redactSecrets, secretsToRedact } from './redact.js'
2
2
  import { log } from './logger.js'
3
- import { PI_MAX_OUTPUT_TOKENS } from './pi.js'
3
+ import { PI_MAX_OUTPUT_TOKENS, phasedProxyBaseUrl } from './pi.js'
4
4
 
5
5
  // A reusable abstraction for the "agent returns a structured JSON document as its
6
6
  // final assistant message" pattern (requirements, blueprint, merger — and any future
@@ -52,10 +52,23 @@ export interface StructuredOutputSpec<T> {
52
52
  parse: (text: string) => T | null
53
53
  }
54
54
 
55
+ /**
56
+ * The run phase a structured-output repair call is billed to. A constant, not a `currentPhase`
57
+ * read: this call is made by the harness itself (the agent has already finished and left text
58
+ * that won't parse), so it belongs to no pass the registry marks.
59
+ */
60
+ const STRUCTURED_REPAIR_PHASE = 'structured-repair'
61
+
55
62
  /** Runtime wiring to reach the LLM proxy for the repair call. */
56
63
  export interface ProxyAccess {
57
64
  /** Pi-harness proxy base URL; absent for subscription harnesses (no proxy repair). */
58
65
  proxyBaseUrl?: string
66
+ /**
67
+ * The backend serves the phase-tagged completions route, so the repair call can be attributed
68
+ * to {@link STRUCTURED_REPAIR_PHASE} rather than piling into the unattributed slice
69
+ * (see {@link HarnessAuthFields.proxyPhasePath}).
70
+ */
71
+ proxyPhasePath?: boolean
59
72
  /** Pi-harness proxy session token; absent for subscription harnesses. */
60
73
  sessionToken?: string
61
74
  model: string
@@ -266,7 +279,16 @@ async function callRepair<T>(
266
279
  if (!access.proxyBaseUrl || !access.sessionToken) {
267
280
  throw new Error('structured-output repair requires the LLM proxy (Pi harness)')
268
281
  }
269
- const url = `${access.proxyBaseUrl.replace(/\/+$/, '')}/chat/completions`
282
+ // A repair round is its own slice of the run's burn, not part of the agent's loop that
283
+ // produced the unparseable text — and unlike the phases the registry marks, this call is made
284
+ // by the HARNESS itself, so its phase is a constant rather than a read of `currentPhase`
285
+ // (docs/initiatives/token-burn-instrumentation.md).
286
+ const repairBaseUrl = phasedProxyBaseUrl(
287
+ access.proxyBaseUrl,
288
+ STRUCTURED_REPAIR_PHASE,
289
+ access.proxyPhasePath,
290
+ )
291
+ const url = `${repairBaseUrl.replace(/\/+$/, '')}/chat/completions`
270
292
  const messages = [
271
293
  { role: 'system', content: REPAIR_SYSTEM },
272
294
  {