@cat-factory/executor-harness 1.68.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/README.md CHANGED
@@ -57,7 +57,12 @@ The implementation job (`POST /run`) is the canonical sequence:
57
57
  to Pi's **global** context file `~/.pi/agent/AGENTS.md` (outside the checkout,
58
58
  so it never lands in a commit and never clobbers a repo's own `AGENTS.md` —
59
59
  Pi reads and concatenates both), and point Pi at the Worker's LLM proxy via
60
- `~/.pi/agent/models.json` (provider `proxy`, `api: openai-completions`),
60
+ `~/.pi/agent/models.json` (provider `proxy`, `api: openai-completions`) — at the
61
+ phase-tagged completions path for the pass about to run (`.../phase/<phase>`) when the job
62
+ body's `proxyPhasePath` says the backend serves it, which is how a repair round's model spend
63
+ stays distinguishable from the first pass's in telemetry; without that flag the plain path is
64
+ used and the calls are recorded as unattributed
65
+ (see [token-burn instrumentation](../../../docs/initiatives/token-burn-instrumentation.md)),
61
66
  3. **run Pi** non-interactively (`pi -p --mode json --model proxy/<model> --approve`),
62
67
  4. **validate** the checkout, when the job body carries `validationChecks` — the service's
63
68
  configured check commands (install/lint/test/build) run with `sh -c` in the checkout, and
package/dist/agent.js CHANGED
@@ -183,6 +183,7 @@ async function resolveReplyCustom(job, summary, signal) {
183
183
  subscriptionToken: job.subscriptionToken,
184
184
  subscriptionBaseUrl: job.subscriptionBaseUrl,
185
185
  proxyBaseUrl: job.proxyBaseUrl,
186
+ proxyPhasePath: job.proxyPhasePath,
186
187
  sessionToken: job.sessionToken,
187
188
  model: job.model,
188
189
  jobId: job.jobId,
@@ -488,6 +489,7 @@ async function runExploreMode(job, opts) {
488
489
  subscriptionBaseUrl: job.subscriptionBaseUrl,
489
490
  ambientAuth: job.ambientAuth,
490
491
  proxyBaseUrl: job.proxyBaseUrl,
492
+ proxyPhasePath: job.proxyPhasePath,
491
493
  sessionToken: job.sessionToken,
492
494
  serviceDirectory,
493
495
  // Read-only: it inspects and reports, making no edits — so the no-progress
@@ -678,6 +680,7 @@ async function runMultiRepoExplore(job, opts) {
678
680
  subscriptionBaseUrl: job.subscriptionBaseUrl,
679
681
  ambientAuth: job.ambientAuth,
680
682
  proxyBaseUrl: job.proxyBaseUrl,
683
+ proxyPhasePath: job.proxyPhasePath,
681
684
  sessionToken: job.sessionToken,
682
685
  // Read-only: no edits expected, so the no-progress guard's no-edit bound must not fire.
683
686
  expectsEdits: false,
@@ -780,6 +783,7 @@ function buildSingleRepoCodingSpec(job, pushBranch) {
780
783
  subscriptionBaseUrl: job.subscriptionBaseUrl,
781
784
  ambientAuth: job.ambientAuth,
782
785
  proxyBaseUrl: job.proxyBaseUrl,
786
+ proxyPhasePath: job.proxyPhasePath,
783
787
  sessionToken: job.sessionToken,
784
788
  commitMessage: job.commitMessage ?? job.pr?.title ?? 'Agent changes',
785
789
  webToolsGuidance: job.webToolsGuidance,
@@ -1032,6 +1036,7 @@ async function runConflictResolution(job, opts) {
1032
1036
  subscriptionBaseUrl: job.subscriptionBaseUrl,
1033
1037
  ambientAuth: job.ambientAuth,
1034
1038
  proxyBaseUrl: job.proxyBaseUrl,
1039
+ proxyPhasePath: job.proxyPhasePath,
1035
1040
  sessionToken: job.sessionToken,
1036
1041
  contextFiles: job.contextFiles,
1037
1042
  guardLimits: job.guardLimits,
@@ -52,6 +52,7 @@ export async function runBootstrap(job, opts) {
52
52
  subscriptionBaseUrl: job.subscriptionBaseUrl,
53
53
  ambientAuth: job.ambientAuth,
54
54
  proxyBaseUrl: job.proxyBaseUrl,
55
+ proxyPhasePath: job.proxyPhasePath,
55
56
  sessionToken: job.sessionToken,
56
57
  guardLimits: job.guardLimits,
57
58
  ...agentCapabilities(job),
@@ -146,6 +146,7 @@ export async function runCodingAgent(spec, opts = {}) {
146
146
  subscriptionBaseUrl: spec.subscriptionBaseUrl,
147
147
  ambientAuth: spec.ambientAuth,
148
148
  proxyBaseUrl: spec.proxyBaseUrl,
149
+ proxyPhasePath: spec.proxyPhasePath,
149
150
  sessionToken: spec.sessionToken,
150
151
  serviceDirectory,
151
152
  webToolsGuidance: spec.webToolsGuidance,
@@ -716,6 +717,7 @@ export async function runMultiRepoCoding(job, opts = {}) {
716
717
  subscriptionBaseUrl: job.subscriptionBaseUrl,
717
718
  ambientAuth: job.ambientAuth,
718
719
  proxyBaseUrl: job.proxyBaseUrl,
720
+ proxyPhasePath: job.proxyPhasePath,
719
721
  sessionToken: job.sessionToken,
720
722
  webToolsGuidance: job.webToolsGuidance,
721
723
  webSearchProxy: job.webSearch,
package/dist/job.js CHANGED
@@ -96,6 +96,8 @@ function parseHarnessAuth(o) {
96
96
  harness,
97
97
  proxyBaseUrl: str(o.proxyBaseUrl, 'proxyBaseUrl'),
98
98
  sessionToken: str(o.sessionToken, 'sessionToken'),
99
+ // Opt-IN, so a backend that doesn't serve the phase route (or predates it) is the default.
100
+ ...(o.proxyPhasePath === true ? { proxyPhasePath: true } : {}),
99
101
  };
100
102
  }
101
103
  /**
@@ -3,7 +3,7 @@ import { tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { readEffortReport } from './effort.js';
5
5
  import { log } from './logger.js';
6
- import { CONTEXT_DIR, materializeContextFiles, materializeSkillResources, runPi, webSearchConfigFromEnv, webSearchProxyEnv, writeAgentsContext, writePiModelsConfig, writeWebToolsConfig, } from './pi.js';
6
+ import { CONTEXT_DIR, materializeContextFiles, materializeSkillResources, phasedProxyBaseUrl, runPi, webSearchConfigFromEnv, webSearchProxyEnv, writeAgentsContext, writePiModelsConfig, writeWebToolsConfig, } from './pi.js';
7
7
  import { mergeGuardLimits, progressGuardLimitsFromEnv, } from './progress-guard.js';
8
8
  import { runSubscriptionHarness } from './agent-runner.js';
9
9
  // The thin base every container agent shares: an ephemeral working directory, and
@@ -221,7 +221,15 @@ export async function runAgentInWorkspace(spec, opts = {}) {
221
221
  hasBlueprints,
222
222
  ...(spec.multiRepo ? { multiRepo: true } : {}),
223
223
  });
224
- await writePiModelsConfig({ model: spec.model, proxyBaseUrl });
224
+ // Pi's calls are metered server-side by the LLM proxy, which sees only an HTTP request — so
225
+ // the phase this pass runs under is carried on the URL it is pointed at. Resolved per pass
226
+ // (this whole function re-runs for every repair round), which is what makes a repair round's
227
+ // spend distinguishable from the first pass's. Only when the BACKEND said it serves that
228
+ // route, since a runner pool or `LOCAL_HARNESS_IMAGE` can pair this image with an older one.
229
+ await writePiModelsConfig({
230
+ model: spec.model,
231
+ proxyBaseUrl: phasedProxyBaseUrl(proxyBaseUrl, opts.currentPhase?.(), spec.proxyPhasePath),
232
+ });
225
233
  const { signal, onActivity, onProgress, onSpan } = opts;
226
234
  const piOutcome = await runPi({
227
235
  cwd: spec.dir,
package/dist/pi.js CHANGED
@@ -24,6 +24,63 @@ import { ProgressGuard, progressGuardLimitsFromEnv, toolCallSignal, } from './pr
24
24
  * {@link runDiagnostics} flagging the rare case where even 32k is not enough.
25
25
  */
26
26
  export const PI_MAX_OUTPUT_TOKENS = 32_768;
27
+ /**
28
+ * Longest phase label the backend keeps. Mirrors kernel's `MAX_PHASE_CHARS`; see
29
+ * {@link normalizeProxyPhase} for why this is a copy rather than an import.
30
+ */
31
+ const MAX_PHASE_CHARS = 32;
32
+ /**
33
+ * Normalise a phase label to what the backend will actually store: trimmed, lowercased,
34
+ * `[a-z0-9-]` only, bounded. `''` when the label is not a phase at all.
35
+ *
36
+ * A deliberate COPY of kernel's `normalizeCallPhase` — the container image is built from `src/`
37
+ * plus typescript alone, so the harness can carry no runtime dependency on a workspace package
38
+ * (the same constraint that forced `src/host-markdown.ts`). A copy that can drift is worse than
39
+ * no copy: if the harness rejected a label the backend would have accepted, the call would take
40
+ * the plain path and land unattributed, and if it accepted one the backend rejects it would
41
+ * spend a request on a segment destined for `''`. `test/llm-phase.conformity.test.ts` pins the
42
+ * two to identical verdicts over a corpus, so the alphabet can only be changed in both.
43
+ */
44
+ export function normalizeProxyPhase(phase) {
45
+ if (typeof phase !== 'string')
46
+ return '';
47
+ const trimmed = phase.trim().toLowerCase();
48
+ if (!trimmed || trimmed.length > MAX_PHASE_CHARS)
49
+ return '';
50
+ return /^[a-z0-9-]+$/.test(trimmed) ? trimmed : '';
51
+ }
52
+ /**
53
+ * Point Pi's provider at the phase-tagged completions path for the pass about to run, so the
54
+ * backend can stamp WHICH slice of the run spent each call (the agent's own loop vs a pre-PR
55
+ * validation repair round vs a reproduction-proof repair round) — see
56
+ * `docs/initiatives/token-burn-instrumentation.md`. The harness drives those loops, so it is
57
+ * the only component that knows; reconstructing the boundary downstream from wall-clock
58
+ * timestamps is exactly the brittle inference this avoids.
59
+ *
60
+ * A URL segment because the harness does not make these requests: Pi does, from a config whose
61
+ * only per-run knobs are the base URL and the token — there is no per-request header to set.
62
+ *
63
+ * `supported` is the BACKEND's declaration that it serves the phase-tagged route, carried on the
64
+ * job body exactly as `webSearch` carries "point the search tool at my `/web-search`". Without it
65
+ * this function would encode a routing shape the receiving backend may not have: a runner pool
66
+ * pins its OWN harness image (`RunnerPoolManifest`), and `LOCAL_HARNESS_IMAGE` overrides the
67
+ * recommended pin outright, so "the image and the backend are a matched set" holds for the
68
+ * Cloudflare deployment and nowhere else. An image ahead of its backend would 404 EVERY model
69
+ * call — a dead run, not degraded telemetry. Absent/false ⇒ the plain path, and the calls land
70
+ * in the backend's unattributed slice.
71
+ *
72
+ * Pure so the join is unit-testable without spawning anything.
73
+ */
74
+ export function phasedProxyBaseUrl(proxyBaseUrl, phase, supported) {
75
+ if (!supported)
76
+ return proxyBaseUrl;
77
+ // A label the backend would discard would be sent only to be thrown away, so send the plain
78
+ // path instead — the call is then honestly unattributed rather than attributed to nothing.
79
+ const normalized = normalizeProxyPhase(phase);
80
+ if (!normalized)
81
+ return proxyBaseUrl;
82
+ return `${proxyBaseUrl.replace(/\/+$/, '')}/phase/${normalized}`;
83
+ }
27
84
  /** Write the Pi provider config that routes all model calls through the proxy. */
28
85
  export async function writePiModelsConfig(opts) {
29
86
  const dir = join(homedir(), '.pi', 'agent');
package/dist/runner.js CHANGED
@@ -224,9 +224,17 @@ export class JobRegistry {
224
224
  // instance for its terminal result, so both channels carry the same `seq` and the
225
225
  // backend mints one stable row id per call.
226
226
  call.seq = entry.callMetricSeq++;
227
+ // …and the phase the job is in RIGHT NOW, which is what spent the call: the handlers
228
+ // mark `validation-repair` / `reproduction-repair` around each repair pass, so a
229
+ // looped run's telemetry says which loop the tokens went to instead of filing every
230
+ // turn under one undifferentiated "agent"
231
+ // (`docs/initiatives/token-burn-instrumentation.md`). Stamped at EMIT time, not at
232
+ // drain time: a poll can land long after the phase moved on.
233
+ call.phase = phase;
227
234
  entry.callMetricBuffer.push(call);
228
235
  },
229
236
  onPhase: (next) => markPhase(next),
237
+ currentPhase: () => phase,
230
238
  log: jobLog,
231
239
  });
232
240
  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
  // A reusable abstraction for the "agent returns a structured JSON document as its
5
5
  // final assistant message" pattern (requirements, blueprint, merger — and any future
6
6
  // kind). An agent of this kind emits its result as text, not a tool call, and the
@@ -31,6 +31,12 @@ const REPAIR_SYSTEM = 'You repair malformed JSON. You are given text that was me
31
31
  'JSON object but does not parse. Return ONLY the corrected JSON object: no prose, ' +
32
32
  'no markdown code fences, no commentary, and never repeat or duplicate any tokens. ' +
33
33
  'Preserve the original content faithfully; only fix the JSON structure.';
34
+ /**
35
+ * The run phase a structured-output repair call is billed to. A constant, not a `currentPhase`
36
+ * read: this call is made by the harness itself (the agent has already finished and left text
37
+ * that won't parse), so it belongs to no pass the registry marks.
38
+ */
39
+ const STRUCTURED_REPAIR_PHASE = 'structured-repair';
34
40
  /**
35
41
  * Largest immediately-repeated run length we look for. The corruption duplicates
36
42
  * whole model tokens, which carry whitespace/punctuation context and run to ~10-15
@@ -195,7 +201,12 @@ async function callRepair(badText, spec, access) {
195
201
  if (!access.proxyBaseUrl || !access.sessionToken) {
196
202
  throw new Error('structured-output repair requires the LLM proxy (Pi harness)');
197
203
  }
198
- const url = `${access.proxyBaseUrl.replace(/\/+$/, '')}/chat/completions`;
204
+ // A repair round is its own slice of the run's burn, not part of the agent's loop that
205
+ // produced the unparseable text — and unlike the phases the registry marks, this call is made
206
+ // by the HARNESS itself, so its phase is a constant rather than a read of `currentPhase`
207
+ // (docs/initiatives/token-burn-instrumentation.md).
208
+ const repairBaseUrl = phasedProxyBaseUrl(access.proxyBaseUrl, STRUCTURED_REPAIR_PHASE, access.proxyPhasePath);
209
+ const url = `${repairBaseUrl.replace(/\/+$/, '')}/chat/completions`;
199
210
  const messages = [
200
211
  { role: 'system', content: REPAIR_SYSTEM },
201
212
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.68.0",
3
+ "version": "1.70.0",
4
4
  "description": "Container payload: a thin TypeScript wrapper that runs the Pi coding agent against a cloned repo and opens a PR. Runs in the Cloudflare Container (and, in local native mode, as a host process); carries no secrets.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,9 +26,9 @@
26
26
  "hono": "^4.12.32",
27
27
  "typescript": "7.0.2",
28
28
  "vitest": "^4.1.10",
29
- "@cat-factory/kernel": "0.176.0",
30
- "@cat-factory/server": "0.166.0",
31
- "@cat-factory/spend": "0.12.105"
29
+ "@cat-factory/kernel": "0.179.0",
30
+ "@cat-factory/server": "0.167.0",
31
+ "@cat-factory/spend": "0.12.108"
32
32
  },
33
33
  "scripts": {
34
34
  "build": "tsc -p tsconfig.json",
package/src/agent.ts CHANGED
@@ -255,6 +255,7 @@ async function resolveReplyCustom(
255
255
  subscriptionToken: job.subscriptionToken,
256
256
  subscriptionBaseUrl: job.subscriptionBaseUrl,
257
257
  proxyBaseUrl: job.proxyBaseUrl,
258
+ proxyPhasePath: job.proxyPhasePath,
258
259
  sessionToken: job.sessionToken,
259
260
  model: job.model,
260
261
  jobId: job.jobId,
@@ -589,6 +590,7 @@ async function runExploreMode(job: AgentJob, opts: RunOptions): Promise<AgentRes
589
590
  subscriptionBaseUrl: job.subscriptionBaseUrl,
590
591
  ambientAuth: job.ambientAuth,
591
592
  proxyBaseUrl: job.proxyBaseUrl,
593
+ proxyPhasePath: job.proxyPhasePath,
592
594
  sessionToken: job.sessionToken,
593
595
  serviceDirectory,
594
596
  // Read-only: it inspects and reports, making no edits — so the no-progress
@@ -820,6 +822,7 @@ async function runMultiRepoExplore(job: AgentJob, opts: RunOptions): Promise<Age
820
822
  subscriptionBaseUrl: job.subscriptionBaseUrl,
821
823
  ambientAuth: job.ambientAuth,
822
824
  proxyBaseUrl: job.proxyBaseUrl,
825
+ proxyPhasePath: job.proxyPhasePath,
823
826
  sessionToken: job.sessionToken,
824
827
  // Read-only: no edits expected, so the no-progress guard's no-edit bound must not fire.
825
828
  expectsEdits: false,
@@ -938,6 +941,7 @@ function buildSingleRepoCodingSpec(
938
941
  subscriptionBaseUrl: job.subscriptionBaseUrl,
939
942
  ambientAuth: job.ambientAuth,
940
943
  proxyBaseUrl: job.proxyBaseUrl,
944
+ proxyPhasePath: job.proxyPhasePath,
941
945
  sessionToken: job.sessionToken,
942
946
  commitMessage: job.commitMessage ?? job.pr?.title ?? 'Agent changes',
943
947
  webToolsGuidance: job.webToolsGuidance,
@@ -1217,6 +1221,7 @@ async function runConflictResolution(job: AgentJob, opts: RunOptions): Promise<A
1217
1221
  subscriptionBaseUrl: job.subscriptionBaseUrl,
1218
1222
  ambientAuth: job.ambientAuth,
1219
1223
  proxyBaseUrl: job.proxyBaseUrl,
1224
+ proxyPhasePath: job.proxyPhasePath,
1220
1225
  sessionToken: job.sessionToken,
1221
1226
  contextFiles: job.contextFiles,
1222
1227
  guardLimits: job.guardLimits,
@@ -65,6 +65,7 @@ export async function runBootstrap(job: AgentJob, opts: RunOptions): Promise<Age
65
65
  subscriptionBaseUrl: job.subscriptionBaseUrl,
66
66
  ambientAuth: job.ambientAuth,
67
67
  proxyBaseUrl: job.proxyBaseUrl,
68
+ proxyPhasePath: job.proxyPhasePath,
68
69
  sessionToken: job.sessionToken,
69
70
  guardLimits: job.guardLimits,
70
71
  ...agentCapabilities(job),
@@ -93,6 +93,8 @@ export interface CodingAgentSpec extends HarnessAuthFields {
93
93
  webToolsGuidance?: string
94
94
  /** Enable proxy-backed web search for this run (see {@link AgentRunSpec.webSearchProxy}). */
95
95
  webSearchProxy?: boolean
96
+ /** Backend serves the phase-tagged completions route (see {@link AgentRunSpec.proxyPhasePath}). */
97
+ proxyPhasePath?: boolean
96
98
  /** Per-knob progress-guard overrides (loosen-only), set per agent kind by the backend. */
97
99
  guardLimits?: Partial<ProgressGuardLimits>
98
100
  /**
@@ -352,6 +354,7 @@ export async function runCodingAgent(
352
354
  subscriptionBaseUrl: spec.subscriptionBaseUrl,
353
355
  ambientAuth: spec.ambientAuth,
354
356
  proxyBaseUrl: spec.proxyBaseUrl,
357
+ proxyPhasePath: spec.proxyPhasePath,
355
358
  sessionToken: spec.sessionToken,
356
359
  serviceDirectory,
357
360
  webToolsGuidance: spec.webToolsGuidance,
@@ -1052,6 +1055,7 @@ export async function runMultiRepoCoding(
1052
1055
  subscriptionBaseUrl: job.subscriptionBaseUrl,
1053
1056
  ambientAuth: job.ambientAuth,
1054
1057
  proxyBaseUrl: job.proxyBaseUrl,
1058
+ proxyPhasePath: job.proxyPhasePath,
1055
1059
  sessionToken: job.sessionToken,
1056
1060
  webToolsGuidance: job.webToolsGuidance,
1057
1061
  webSearchProxy: job.webSearch,
package/src/job.ts CHANGED
@@ -38,6 +38,20 @@ export interface HarnessAuthFields {
38
38
  harness?: HarnessKind
39
39
  /** Worker LLM proxy base URL, including /v1 (Pi harness only). */
40
40
  proxyBaseUrl?: string
41
+ /**
42
+ * The backend declaring that it serves the phase-tagged completions route
43
+ * (`${proxyBaseUrl}/phase/<phase>/chat/completions`), so this run may attribute each model
44
+ * call to the phase that spent it (`docs/initiatives/token-burn-instrumentation.md`). The
45
+ * same shape as {@link AgentJob.webSearch}: the backend states what IT serves, and the
46
+ * harness points Pi accordingly.
47
+ *
48
+ * Not a capability handshake — the harness never asks and never adapts to an answer. It
49
+ * exists because the harness image and the backend are only a matched set on the Cloudflare
50
+ * deployment: a runner pool pins its own image and `LOCAL_HARNESS_IMAGE` overrides the
51
+ * recommended pin, so an image ahead of its backend would otherwise 404 every model call.
52
+ * Absent ⇒ the plain path, and the run's calls are recorded as unattributed.
53
+ */
54
+ proxyPhasePath?: boolean
41
55
  /** Signed, model-locked proxy session token (Pi harness only). */
42
56
  sessionToken?: string
43
57
  /** Leased subscription credential (Claude Code OAuth token / Codex auth.json). */
@@ -222,6 +236,8 @@ function parseHarnessAuth(o: Record<string, unknown>): HarnessAuthFields {
222
236
  harness,
223
237
  proxyBaseUrl: str(o.proxyBaseUrl, 'proxyBaseUrl'),
224
238
  sessionToken: str(o.sessionToken, 'sessionToken'),
239
+ // Opt-IN, so a backend that doesn't serve the phase route (or predates it) is the default.
240
+ ...(o.proxyPhasePath === true ? { proxyPhasePath: true } : {}),
225
241
  }
226
242
  }
227
243
 
@@ -13,6 +13,7 @@ import {
13
13
  CONTEXT_DIR,
14
14
  materializeContextFiles,
15
15
  materializeSkillResources,
16
+ phasedProxyBaseUrl,
16
17
  runPi,
17
18
  webSearchConfigFromEnv,
18
19
  webSearchProxyEnv,
@@ -173,6 +174,12 @@ export interface AgentRunSpec {
173
174
  ambientAuth?: boolean
174
175
  /** Pi proxy base URL (Pi harness only). */
175
176
  proxyBaseUrl?: string
177
+ /**
178
+ * The backend serves the phase-tagged completions route, so this pass may tag the URL it
179
+ * points Pi at with the phase it is running under (see {@link HarnessAuthFields.proxyPhasePath}
180
+ * and `phasedProxyBaseUrl`). Absent ⇒ the plain path.
181
+ */
182
+ proxyPhasePath?: boolean
176
183
  /** Pi proxy session token (Pi harness only). */
177
184
  sessionToken?: string
178
185
  /**
@@ -356,7 +363,15 @@ export async function runAgentInWorkspace(
356
363
  hasBlueprints,
357
364
  ...(spec.multiRepo ? { multiRepo: true } : {}),
358
365
  })
359
- await writePiModelsConfig({ model: spec.model, proxyBaseUrl })
366
+ // Pi's calls are metered server-side by the LLM proxy, which sees only an HTTP request — so
367
+ // the phase this pass runs under is carried on the URL it is pointed at. Resolved per pass
368
+ // (this whole function re-runs for every repair round), which is what makes a repair round's
369
+ // spend distinguishable from the first pass's. Only when the BACKEND said it serves that
370
+ // route, since a runner pool or `LOCAL_HARNESS_IMAGE` can pair this image with an older one.
371
+ await writePiModelsConfig({
372
+ model: spec.model,
373
+ proxyBaseUrl: phasedProxyBaseUrl(proxyBaseUrl, opts.currentPhase?.(), spec.proxyPhasePath),
374
+ })
360
375
  const { signal, onActivity, onProgress, onSpan } = opts
361
376
  const piOutcome = await runPi({
362
377
  cwd: spec.dir,
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
@@ -548,6 +608,17 @@ export interface HarnessCallMetric {
548
608
  * falls back to the array index, which is what it always used before streaming existed.
549
609
  */
550
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
551
622
  }
552
623
 
553
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
  {