@cat-factory/executor-harness 1.50.18 → 1.52.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 +13 -0
- package/dist/agent-runner.js +24 -7
- package/dist/pi-workspace.js +4 -0
- package/dist/pi.js +59 -0
- package/dist/runner.js +18 -4
- package/dist/subagents.js +3 -2
- package/package.json +3 -3
- package/src/agent-runner.ts +66 -26
- package/src/pi-workspace.ts +4 -0
- package/src/pi.ts +87 -0
- package/src/runner.ts +51 -4
- package/src/subagents.ts +29 -18
package/README.md
CHANGED
|
@@ -35,6 +35,19 @@ are surfaced as `progress` while a job runs. The exact request/response shapes
|
|
|
35
35
|
cat-factory sends are documented in
|
|
36
36
|
[`docs/runner-pool-integration.md`](../../docs/runner-pool-integration.md).
|
|
37
37
|
|
|
38
|
+
`GET /jobs/{id}` is also the harness's observability channel: `spans`, `followUps`
|
|
39
|
+
and `callMetrics` are **drain-on-read** — each poll returns what accumulated since
|
|
40
|
+
the previous one and clears the buffer. That is deliberate. A job that dies before
|
|
41
|
+
it can return a terminal result (an evicted container, an OOM-killed process) has
|
|
42
|
+
still reported the tool spans it ran and the model calls it paid for. Each drained
|
|
43
|
+
`callMetrics` entry carries a job-scoped `seq`, and the terminal result repeats the
|
|
44
|
+
complete list, so the backend can take both channels without double-counting a call.
|
|
45
|
+
|
|
46
|
+
Because the backend records a call as soon as it drains it (and ignores the terminal
|
|
47
|
+
repeat), a drained call is FINAL. A call whose tokens are still open — a CLI that reports
|
|
48
|
+
only a cumulative total, costed at the end — is withheld from the drain until it is
|
|
49
|
+
complete; see `createCallMetricPublisher` in `src/pi.ts`.
|
|
50
|
+
|
|
38
51
|
## What a job does
|
|
39
52
|
|
|
40
53
|
The implementation job (`POST /run`) is the canonical sequence:
|
package/dist/agent-runner.js
CHANGED
|
@@ -3,6 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
|
3
3
|
import { homedir, tmpdir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { claudeAssistantContent, claudeCallUsage, isObject, numberOf, redactBody, } from './claude-stream.js';
|
|
6
|
+
import { createCallMetricPublisher, publishCallMetric, } from './pi.js';
|
|
6
7
|
import { killChildProcess, spawnDetached } from './process.js';
|
|
7
8
|
import { redact, secretsToRedact } from './redact.js';
|
|
8
9
|
import { createSliceTracker, pickProgress, startSubagentWatcher } from './subagents.js';
|
|
@@ -219,6 +220,9 @@ export async function runClaudeCode(opts) {
|
|
|
219
220
|
{ role: 'user', content: opts.userPrompt },
|
|
220
221
|
];
|
|
221
222
|
const calls = [];
|
|
223
|
+
// Streams each call as the CLI yields it, EXCEPT one whose tokens `attributeCumulativeUsage`
|
|
224
|
+
// may still rewrite below (a published call must be final — see the publisher).
|
|
225
|
+
const publisher = createCallMetricPublisher(calls, opts.onCallMetric);
|
|
222
226
|
// ADR 0026 D2.1 + ADR 0027 Defect B: surface live slice progress from TWO reconciled
|
|
223
227
|
// sources. The parent's `Task` dispatches + their terminal tool_results DO appear on this
|
|
224
228
|
// stream (only a subagent's intermediate turns don't), so `sliceTracker` derives per-slice
|
|
@@ -256,7 +260,7 @@ export async function runClaudeCode(opts) {
|
|
|
256
260
|
// produced this response. The append-only array keeps each call's prompt a strict
|
|
257
261
|
// prefix of the next, so the backend's telemetry chain delta-compresses cleanly.
|
|
258
262
|
const u = claudeCallUsage(message.usage);
|
|
259
|
-
|
|
263
|
+
publisher.publish({
|
|
260
264
|
...(typeof message.model === 'string' ? { model: message.model } : {}),
|
|
261
265
|
promptText: redactBody(JSON.stringify(messages), secrets),
|
|
262
266
|
messageCount: messages.length,
|
|
@@ -333,6 +337,7 @@ export async function runClaudeCode(opts) {
|
|
|
333
337
|
...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
|
|
334
338
|
secrets,
|
|
335
339
|
model: opts.model,
|
|
340
|
+
...(opts.onCallMetric ? { onCallMetric: opts.onCallMetric } : {}),
|
|
336
341
|
...(opts.log ? { log: opts.log } : {}),
|
|
337
342
|
})
|
|
338
343
|
: undefined;
|
|
@@ -355,7 +360,15 @@ export async function runClaudeCode(opts) {
|
|
|
355
360
|
...appendArgs,
|
|
356
361
|
],
|
|
357
362
|
}, prompt, opts, env, opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [], onEvent);
|
|
358
|
-
return await assembleClaudeOutcome({
|
|
363
|
+
return await assembleClaudeOutcome({
|
|
364
|
+
summary,
|
|
365
|
+
stats,
|
|
366
|
+
stderrTail,
|
|
367
|
+
calls,
|
|
368
|
+
publisher,
|
|
369
|
+
usage,
|
|
370
|
+
subagents,
|
|
371
|
+
});
|
|
359
372
|
}
|
|
360
373
|
finally {
|
|
361
374
|
await subagents?.stop();
|
|
@@ -401,10 +414,14 @@ function buildClaudeEnv(opts, configHome) {
|
|
|
401
414
|
* {@link runClaudeCode} verbatim to keep its cyclomatic complexity down.
|
|
402
415
|
*/
|
|
403
416
|
async function assembleClaudeOutcome(args) {
|
|
404
|
-
const { summary, stats, stderrTail, calls, usage, subagents } = args;
|
|
417
|
+
const { summary, stats, stderrTail, calls, publisher, usage, subagents } = args;
|
|
405
418
|
// The parent's cumulative-usage fallback applies to the PARENT calls only (before the
|
|
406
419
|
// subagent calls, which carry their own per-turn tokens, are concatenated).
|
|
407
420
|
attributeCumulativeUsage(calls, usage);
|
|
421
|
+
// The withheld calls are final only NOW, so stream them: the completion poll drains them
|
|
422
|
+
// alongside the result, and the backend records the attributed numbers rather than the zeros
|
|
423
|
+
// they carried while the run was in flight.
|
|
424
|
+
publisher.flush();
|
|
408
425
|
// Final drain of any subagent transcript writes that landed after the last poll, then
|
|
409
426
|
// fold the subagents' usage + per-call telemetry into the run's outcome.
|
|
410
427
|
await subagents?.stop();
|
|
@@ -537,7 +554,7 @@ export async function runCodex(opts) {
|
|
|
537
554
|
// assistant text seen since the previous turn as one telemetry call.
|
|
538
555
|
const perTurn = codexLastTurnUsage(event);
|
|
539
556
|
if (perTurn) {
|
|
540
|
-
calls
|
|
557
|
+
publishCallMetric(calls, {
|
|
541
558
|
model: opts.model,
|
|
542
559
|
promptText: redactBody(JSON.stringify(messages), secrets),
|
|
543
560
|
messageCount: messages.length,
|
|
@@ -547,7 +564,7 @@ export async function runCodex(opts) {
|
|
|
547
564
|
cachedInputTokens: perTurn.cachedInputTokens,
|
|
548
565
|
outputTokens: perTurn.outputTokens,
|
|
549
566
|
finishReason: null,
|
|
550
|
-
});
|
|
567
|
+
}, opts.onCallMetric);
|
|
551
568
|
if (pendingText)
|
|
552
569
|
messages.push({ role: 'assistant', content: pendingText });
|
|
553
570
|
pendingText = '';
|
|
@@ -571,7 +588,7 @@ export async function runCodex(opts) {
|
|
|
571
588
|
// Fallback for a CLI/version that never emits per-turn `last_token_usage`: record a
|
|
572
589
|
// single call from the cumulative total + final text so the run is still observable.
|
|
573
590
|
if (calls.length === 0 && (usage || summary)) {
|
|
574
|
-
calls
|
|
591
|
+
publishCallMetric(calls, {
|
|
575
592
|
model: opts.model,
|
|
576
593
|
promptText: redactBody(JSON.stringify(messages), secrets),
|
|
577
594
|
messageCount: messages.length,
|
|
@@ -581,7 +598,7 @@ export async function runCodex(opts) {
|
|
|
581
598
|
cachedInputTokens: 0,
|
|
582
599
|
outputTokens: usage?.outputTokens ?? 0,
|
|
583
600
|
finishReason: null,
|
|
584
|
-
});
|
|
601
|
+
}, opts.onCallMetric);
|
|
585
602
|
}
|
|
586
603
|
return {
|
|
587
604
|
summary,
|
package/dist/pi-workspace.js
CHANGED
|
@@ -147,6 +147,10 @@ export async function runAgentInWorkspace(spec, opts = {}) {
|
|
|
147
147
|
signal: opts.signal,
|
|
148
148
|
onActivity: opts.onActivity,
|
|
149
149
|
onProgress: opts.onProgress,
|
|
150
|
+
// Stream this run's per-call telemetry to the job's live drain. The subscription
|
|
151
|
+
// harnesses are the only producers of `callMetrics` (Pi's calls are metered by the LLM
|
|
152
|
+
// proxy as they happen), so this is the only path that needs the hook.
|
|
153
|
+
onCallMetric: opts.onCallMetric,
|
|
150
154
|
...(opts.log ? { log: opts.log } : {}),
|
|
151
155
|
});
|
|
152
156
|
return withEffortReport(spec.dir, subOutcome);
|
package/dist/pi.js
CHANGED
|
@@ -344,6 +344,65 @@ export async function writeWebToolsConfig(config) {
|
|
|
344
344
|
function isObject(value) {
|
|
345
345
|
return typeof value === 'object' && value !== null;
|
|
346
346
|
}
|
|
347
|
+
/**
|
|
348
|
+
* Publish one captured model call: append it to the run's list (which becomes the terminal
|
|
349
|
+
* result's `callMetrics`) AND hand the SAME object to the live stream, where the job registry
|
|
350
|
+
* stamps its {@link HarnessCallMetric.seq} and buffers it for the next poll to drain.
|
|
351
|
+
*
|
|
352
|
+
* Every producer goes through here rather than a bare `calls.push`, so the two channels can't
|
|
353
|
+
* drift: a call that reaches the terminal list but never the live stream would be invisible
|
|
354
|
+
* until the job ends, and one that reaches only the live stream would go unrecorded if the
|
|
355
|
+
* poll response were lost.
|
|
356
|
+
*
|
|
357
|
+
* A published call must be FINAL. The backend records it the moment the drain reaches it and
|
|
358
|
+
* IGNORES the terminal repeat (first write wins, so its stored prompt delta stays valid against
|
|
359
|
+
* the chain tip it was written against), which means a field mutated after publishing never
|
|
360
|
+
* reaches the store. A producer whose calls can still change (the cumulative-usage fallback,
|
|
361
|
+
* whose totals arrive with the CLI's terminal `result` event) publishes through
|
|
362
|
+
* {@link createCallMetricPublisher} instead, which withholds exactly those.
|
|
363
|
+
*/
|
|
364
|
+
export function publishCallMetric(calls, call, onCallMetric) {
|
|
365
|
+
calls.push(call);
|
|
366
|
+
onCallMetric?.(call);
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* A {@link publishCallMetric} wrapper for a producer whose per-call tokens may be filled in at
|
|
370
|
+
* the END of the run: a CLI that reports only a cumulative total leaves every turn at zero, and
|
|
371
|
+
* `attributeCumulativeUsage` pins the total onto the last call once the terminal `result` event
|
|
372
|
+
* arrives.
|
|
373
|
+
*
|
|
374
|
+
* Since a published call must be final (the backend stores it on the drain and ignores the
|
|
375
|
+
* terminal repeat), a call the CLI did NOT cost is appended to the list but WITHHELD from the
|
|
376
|
+
* live stream — otherwise it records as a zero-token row and the attributed numbers never land.
|
|
377
|
+
* The withholding window closes the moment any call IS costed: attribution can no longer fire, so
|
|
378
|
+
* everything held is final and released at once, in capture order, and every later call streams
|
|
379
|
+
* immediately whatever its tokens. {@link flush} covers the run that was never costed at all.
|
|
380
|
+
*/
|
|
381
|
+
export function createCallMetricPublisher(calls, onCallMetric) {
|
|
382
|
+
const withheld = [];
|
|
383
|
+
let anyCosted = false;
|
|
384
|
+
const flush = () => {
|
|
385
|
+
for (const call of withheld)
|
|
386
|
+
onCallMetric?.(call);
|
|
387
|
+
withheld.length = 0;
|
|
388
|
+
};
|
|
389
|
+
return {
|
|
390
|
+
publish(call) {
|
|
391
|
+
const costed = call.inputTokens > 0 || call.outputTokens > 0;
|
|
392
|
+
if (!costed && !anyCosted) {
|
|
393
|
+
publishCallMetric(calls, call);
|
|
394
|
+
withheld.push(call);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (costed)
|
|
398
|
+
anyCosted = true;
|
|
399
|
+
// Released BEFORE this call so the live sequence stays in capture order.
|
|
400
|
+
flush();
|
|
401
|
+
publishCallMetric(calls, call, onCallMetric);
|
|
402
|
+
},
|
|
403
|
+
flush,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
347
406
|
/**
|
|
348
407
|
* Pull the `todo` tool's result `details` out of a Pi `--mode json` event, or
|
|
349
408
|
* undefined if the event isn't a successful `todo` tool result.
|
package/dist/runner.js
CHANGED
|
@@ -30,7 +30,7 @@ export function loadRunnerLimits(env = process.env) {
|
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
32
|
function toView(entry) {
|
|
33
|
-
const { promise: _promise, spanBuffer: _spanBuffer, followUpBuffer: _followUpBuffer, abort: _abort, ...view } = entry;
|
|
33
|
+
const { promise: _promise, spanBuffer: _spanBuffer, followUpBuffer: _followUpBuffer, callMetricBuffer: _callMetricBuffer, callMetricSeq: _callMetricSeq, abort: _abort, ...view } = entry;
|
|
34
34
|
return { ...view };
|
|
35
35
|
}
|
|
36
36
|
/**
|
|
@@ -75,15 +75,18 @@ export class JobRegistry {
|
|
|
75
75
|
promise: Promise.resolve(),
|
|
76
76
|
spanBuffer: [],
|
|
77
77
|
followUpBuffer: [],
|
|
78
|
+
callMetricBuffer: [],
|
|
79
|
+
callMetricSeq: 0,
|
|
78
80
|
};
|
|
79
81
|
this.jobs.set(id, entry);
|
|
80
82
|
entry.promise = this.drive(entry, job);
|
|
81
83
|
return toView(entry);
|
|
82
84
|
}
|
|
83
85
|
/**
|
|
84
|
-
* Poll the job — and DRAIN its
|
|
85
|
-
* handler is the sole caller, so each poll returns the spans
|
|
86
|
-
* previous poll and clears them, bounding the harness
|
|
86
|
+
* Poll the job — and DRAIN its observability buffers (drain-on-read). The GET /jobs/{id}
|
|
87
|
+
* handler is the sole caller, so each poll returns the spans / follow-ups / call metrics
|
|
88
|
+
* accumulated since the previous poll and clears them, bounding the harness buffers to one
|
|
89
|
+
* poll interval.
|
|
87
90
|
*/
|
|
88
91
|
get(id) {
|
|
89
92
|
const entry = this.jobs.get(id);
|
|
@@ -98,6 +101,10 @@ export class JobRegistry {
|
|
|
98
101
|
view.followUps = entry.followUpBuffer;
|
|
99
102
|
entry.followUpBuffer = [];
|
|
100
103
|
}
|
|
104
|
+
if (entry.callMetricBuffer.length > 0) {
|
|
105
|
+
view.callMetrics = entry.callMetricBuffer;
|
|
106
|
+
entry.callMetricBuffer = [];
|
|
107
|
+
}
|
|
101
108
|
return view;
|
|
102
109
|
}
|
|
103
110
|
/**
|
|
@@ -206,6 +213,13 @@ export class JobRegistry {
|
|
|
206
213
|
onFollowUp: (items) => {
|
|
207
214
|
entry.followUpBuffer.push(...items);
|
|
208
215
|
},
|
|
216
|
+
onCallMetric: (call) => {
|
|
217
|
+
// Stamp the job-scoped sequence on the metric OBJECT: the handler keeps the same
|
|
218
|
+
// instance for its terminal result, so both channels carry the same `seq` and the
|
|
219
|
+
// backend mints one stable row id per call.
|
|
220
|
+
call.seq = entry.callMetricSeq++;
|
|
221
|
+
entry.callMetricBuffer.push(call);
|
|
222
|
+
},
|
|
209
223
|
onPhase: (next) => markPhase(next),
|
|
210
224
|
log: jobLog,
|
|
211
225
|
});
|
package/dist/subagents.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readdir, stat } from 'node:fs/promises';
|
|
|
2
2
|
import { createReadStream } from 'node:fs';
|
|
3
3
|
import { basename, join } from 'node:path';
|
|
4
4
|
import { claudeAssistantContent, claudeCallUsage, isObject, redactBody } from './claude-stream.js';
|
|
5
|
+
import { publishCallMetric } from './pi.js';
|
|
5
6
|
export function createSliceTracker() {
|
|
6
7
|
// Insertion-ordered so the progress `items` render in dispatch order.
|
|
7
8
|
const slices = new Map();
|
|
@@ -164,7 +165,7 @@ export function startSubagentWatcher(root, opts) {
|
|
|
164
165
|
return;
|
|
165
166
|
const content = Array.isArray(message.content) ? message.content : [];
|
|
166
167
|
const { text, reasoning } = claudeAssistantContent(content);
|
|
167
|
-
calls
|
|
168
|
+
publishCallMetric(calls, {
|
|
168
169
|
...(typeof message.model === 'string'
|
|
169
170
|
? { model: message.model }
|
|
170
171
|
: opts.model
|
|
@@ -180,7 +181,7 @@ export function startSubagentWatcher(root, opts) {
|
|
|
180
181
|
cachedInputTokens: u.cachedInputTokens,
|
|
181
182
|
outputTokens: u.outputTokens,
|
|
182
183
|
finishReason: typeof message.stop_reason === 'string' ? message.stop_reason : null,
|
|
183
|
-
});
|
|
184
|
+
}, opts.onCallMetric);
|
|
184
185
|
usage.inputTokens += u.inputTokens;
|
|
185
186
|
usage.outputTokens += u.outputTokens;
|
|
186
187
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/executor-harness",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.52.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,8 +26,8 @@
|
|
|
26
26
|
"hono": "^4.12.30",
|
|
27
27
|
"typescript": "7.0.2",
|
|
28
28
|
"vitest": "^4.1.10",
|
|
29
|
-
"@cat-factory/server": "0.
|
|
30
|
-
"@cat-factory/spend": "0.12.
|
|
29
|
+
"@cat-factory/server": "0.144.0",
|
|
30
|
+
"@cat-factory/spend": "0.12.77"
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
|
33
33
|
"build": "tsc -p tsconfig.json",
|
package/src/agent-runner.ts
CHANGED
|
@@ -10,7 +10,15 @@ import {
|
|
|
10
10
|
redactBody,
|
|
11
11
|
} from './claude-stream.js'
|
|
12
12
|
import type { Logger } from './logger.js'
|
|
13
|
-
import
|
|
13
|
+
import {
|
|
14
|
+
createCallMetricPublisher,
|
|
15
|
+
publishCallMetric,
|
|
16
|
+
type CallMetricPublisher,
|
|
17
|
+
type HarnessCallMetric,
|
|
18
|
+
type PiRunOutcome,
|
|
19
|
+
type PiRunStats,
|
|
20
|
+
type TodoProgress,
|
|
21
|
+
} from './pi.js'
|
|
14
22
|
import { killChildProcess, spawnDetached } from './process.js'
|
|
15
23
|
import { redact, secretsToRedact } from './redact.js'
|
|
16
24
|
import { createSliceTracker, pickProgress, startSubagentWatcher } from './subagents.js'
|
|
@@ -81,6 +89,12 @@ export interface SubscriptionRunOptions {
|
|
|
81
89
|
onActivity?: () => void
|
|
82
90
|
/** Called with the latest subtask counts each time the CLI updates its todo/plan list. */
|
|
83
91
|
onProgress?: (progress: TodoProgress) => void
|
|
92
|
+
/**
|
|
93
|
+
* Called with each per-call telemetry row as the CLI stream yields it, so the backend can
|
|
94
|
+
* record the run's model calls WHILE it runs instead of only from its terminal result. The
|
|
95
|
+
* same row still rides the result, so a lost poll response costs nothing.
|
|
96
|
+
*/
|
|
97
|
+
onCallMetric?: (call: HarnessCallMetric) => void
|
|
84
98
|
/**
|
|
85
99
|
* The per-job child logger (jobId/repo/branch correlation). Threaded so the retained
|
|
86
100
|
* session-transcript path is logged for the run when the isolated config home is torn down.
|
|
@@ -324,6 +338,9 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
324
338
|
{ role: 'user', content: opts.userPrompt },
|
|
325
339
|
]
|
|
326
340
|
const calls: HarnessCallMetric[] = []
|
|
341
|
+
// Streams each call as the CLI yields it, EXCEPT one whose tokens `attributeCumulativeUsage`
|
|
342
|
+
// may still rewrite below (a published call must be final — see the publisher).
|
|
343
|
+
const publisher = createCallMetricPublisher(calls, opts.onCallMetric)
|
|
327
344
|
|
|
328
345
|
// ADR 0026 D2.1 + ADR 0027 Defect B: surface live slice progress from TWO reconciled
|
|
329
346
|
// sources. The parent's `Task` dispatches + their terminal tool_results DO appear on this
|
|
@@ -360,7 +377,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
360
377
|
// produced this response. The append-only array keeps each call's prompt a strict
|
|
361
378
|
// prefix of the next, so the backend's telemetry chain delta-compresses cleanly.
|
|
362
379
|
const u = claudeCallUsage(message.usage)
|
|
363
|
-
|
|
380
|
+
publisher.publish({
|
|
364
381
|
...(typeof message.model === 'string' ? { model: message.model } : {}),
|
|
365
382
|
promptText: redactBody(JSON.stringify(messages), secrets),
|
|
366
383
|
messageCount: messages.length,
|
|
@@ -439,6 +456,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
439
456
|
...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
|
|
440
457
|
secrets,
|
|
441
458
|
model: opts.model,
|
|
459
|
+
...(opts.onCallMetric ? { onCallMetric: opts.onCallMetric } : {}),
|
|
442
460
|
...(opts.log ? { log: opts.log } : {}),
|
|
443
461
|
})
|
|
444
462
|
: undefined
|
|
@@ -470,7 +488,15 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
470
488
|
onEvent,
|
|
471
489
|
)
|
|
472
490
|
|
|
473
|
-
return await assembleClaudeOutcome({
|
|
491
|
+
return await assembleClaudeOutcome({
|
|
492
|
+
summary,
|
|
493
|
+
stats,
|
|
494
|
+
stderrTail,
|
|
495
|
+
calls,
|
|
496
|
+
publisher,
|
|
497
|
+
usage,
|
|
498
|
+
subagents,
|
|
499
|
+
})
|
|
474
500
|
} finally {
|
|
475
501
|
await subagents?.stop()
|
|
476
502
|
if (configHome) {
|
|
@@ -523,13 +549,19 @@ async function assembleClaudeOutcome(args: {
|
|
|
523
549
|
stats: PiRunStats
|
|
524
550
|
stderrTail: string
|
|
525
551
|
calls: HarnessCallMetric[]
|
|
552
|
+
/** The live-stream publisher, flushed once attribution has finalised the calls' tokens. */
|
|
553
|
+
publisher: CallMetricPublisher
|
|
526
554
|
usage: { inputTokens: number; outputTokens: number } | undefined
|
|
527
555
|
subagents: ReturnType<typeof startSubagentWatcher> | undefined
|
|
528
556
|
}): Promise<PiRunOutcome> {
|
|
529
|
-
const { summary, stats, stderrTail, calls, usage, subagents } = args
|
|
557
|
+
const { summary, stats, stderrTail, calls, publisher, usage, subagents } = args
|
|
530
558
|
// The parent's cumulative-usage fallback applies to the PARENT calls only (before the
|
|
531
559
|
// subagent calls, which carry their own per-turn tokens, are concatenated).
|
|
532
560
|
attributeCumulativeUsage(calls, usage)
|
|
561
|
+
// The withheld calls are final only NOW, so stream them: the completion poll drains them
|
|
562
|
+
// alongside the result, and the backend records the attributed numbers rather than the zeros
|
|
563
|
+
// they carried while the run was in flight.
|
|
564
|
+
publisher.flush()
|
|
533
565
|
// Final drain of any subagent transcript writes that landed after the last poll, then
|
|
534
566
|
// fold the subagents' usage + per-call telemetry into the run's outcome.
|
|
535
567
|
await subagents?.stop()
|
|
@@ -668,17 +700,21 @@ export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutco
|
|
|
668
700
|
// assistant text seen since the previous turn as one telemetry call.
|
|
669
701
|
const perTurn = codexLastTurnUsage(event)
|
|
670
702
|
if (perTurn) {
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
703
|
+
publishCallMetric(
|
|
704
|
+
calls,
|
|
705
|
+
{
|
|
706
|
+
model: opts.model,
|
|
707
|
+
promptText: redactBody(JSON.stringify(messages), secrets),
|
|
708
|
+
messageCount: messages.length,
|
|
709
|
+
responseText: redactBody(pendingText, secrets),
|
|
710
|
+
reasoningText: '',
|
|
711
|
+
inputTokens: perTurn.inputTokens,
|
|
712
|
+
cachedInputTokens: perTurn.cachedInputTokens,
|
|
713
|
+
outputTokens: perTurn.outputTokens,
|
|
714
|
+
finishReason: null,
|
|
715
|
+
},
|
|
716
|
+
opts.onCallMetric,
|
|
717
|
+
)
|
|
682
718
|
if (pendingText) messages.push({ role: 'assistant', content: pendingText })
|
|
683
719
|
pendingText = ''
|
|
684
720
|
}
|
|
@@ -710,17 +746,21 @@ export async function runCodex(opts: SubscriptionRunOptions): Promise<PiRunOutco
|
|
|
710
746
|
// Fallback for a CLI/version that never emits per-turn `last_token_usage`: record a
|
|
711
747
|
// single call from the cumulative total + final text so the run is still observable.
|
|
712
748
|
if (calls.length === 0 && (usage || summary)) {
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
749
|
+
publishCallMetric(
|
|
750
|
+
calls,
|
|
751
|
+
{
|
|
752
|
+
model: opts.model,
|
|
753
|
+
promptText: redactBody(JSON.stringify(messages), secrets),
|
|
754
|
+
messageCount: messages.length,
|
|
755
|
+
responseText: redactBody(summary, secrets),
|
|
756
|
+
reasoningText: '',
|
|
757
|
+
inputTokens: usage?.inputTokens ?? 0,
|
|
758
|
+
cachedInputTokens: 0,
|
|
759
|
+
outputTokens: usage?.outputTokens ?? 0,
|
|
760
|
+
finishReason: null,
|
|
761
|
+
},
|
|
762
|
+
opts.onCallMetric,
|
|
763
|
+
)
|
|
724
764
|
}
|
|
725
765
|
return {
|
|
726
766
|
summary,
|
package/src/pi-workspace.ts
CHANGED
|
@@ -271,6 +271,10 @@ export async function runAgentInWorkspace(
|
|
|
271
271
|
signal: opts.signal,
|
|
272
272
|
onActivity: opts.onActivity,
|
|
273
273
|
onProgress: opts.onProgress,
|
|
274
|
+
// Stream this run's per-call telemetry to the job's live drain. The subscription
|
|
275
|
+
// harnesses are the only producers of `callMetrics` (Pi's calls are metered by the LLM
|
|
276
|
+
// proxy as they happen), so this is the only path that needs the hook.
|
|
277
|
+
onCallMetric: opts.onCallMetric,
|
|
274
278
|
...(opts.log ? { log: opts.log } : {}),
|
|
275
279
|
})
|
|
276
280
|
return withEffortReport(spec.dir, subOutcome)
|
package/src/pi.ts
CHANGED
|
@@ -505,6 +505,93 @@ export interface HarnessCallMetric {
|
|
|
505
505
|
outputTokens: number
|
|
506
506
|
/** The provider finish/stop reason when the CLI reports one (else null). */
|
|
507
507
|
finishReason: string | null
|
|
508
|
+
/**
|
|
509
|
+
* This call's position in the JOB's telemetry sequence, stamped by the job registry the
|
|
510
|
+
* moment the call is emitted (see `RunOptions.onCallMetric`). It is what makes a call's
|
|
511
|
+
* recorded row id stable across the two channels that carry it: the live drain (per poll,
|
|
512
|
+
* so a run's telemetry is inspectable WHILE it runs) and the terminal result (the complete
|
|
513
|
+
* list, so a transport that doesn't drain still records everything). Both channels hold the
|
|
514
|
+
* SAME metric objects, so both mint the same `<jobId>-hc-<seq>` row id and the backend's
|
|
515
|
+
* second write of an already-recorded call is a no-op instead of a duplicate row.
|
|
516
|
+
*
|
|
517
|
+
* Absent only when a producer built a metric without emitting it live; the recorder then
|
|
518
|
+
* falls back to the array index, which is what it always used before streaming existed.
|
|
519
|
+
*/
|
|
520
|
+
seq?: number
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Publish one captured model call: append it to the run's list (which becomes the terminal
|
|
525
|
+
* result's `callMetrics`) AND hand the SAME object to the live stream, where the job registry
|
|
526
|
+
* stamps its {@link HarnessCallMetric.seq} and buffers it for the next poll to drain.
|
|
527
|
+
*
|
|
528
|
+
* Every producer goes through here rather than a bare `calls.push`, so the two channels can't
|
|
529
|
+
* drift: a call that reaches the terminal list but never the live stream would be invisible
|
|
530
|
+
* until the job ends, and one that reaches only the live stream would go unrecorded if the
|
|
531
|
+
* poll response were lost.
|
|
532
|
+
*
|
|
533
|
+
* A published call must be FINAL. The backend records it the moment the drain reaches it and
|
|
534
|
+
* IGNORES the terminal repeat (first write wins, so its stored prompt delta stays valid against
|
|
535
|
+
* the chain tip it was written against), which means a field mutated after publishing never
|
|
536
|
+
* reaches the store. A producer whose calls can still change (the cumulative-usage fallback,
|
|
537
|
+
* whose totals arrive with the CLI's terminal `result` event) publishes through
|
|
538
|
+
* {@link createCallMetricPublisher} instead, which withholds exactly those.
|
|
539
|
+
*/
|
|
540
|
+
export function publishCallMetric(
|
|
541
|
+
calls: HarnessCallMetric[],
|
|
542
|
+
call: HarnessCallMetric,
|
|
543
|
+
onCallMetric?: (call: HarnessCallMetric) => void,
|
|
544
|
+
): void {
|
|
545
|
+
calls.push(call)
|
|
546
|
+
onCallMetric?.(call)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** Appends captured calls to a run's list, streaming each one as soon as it is final. */
|
|
550
|
+
export interface CallMetricPublisher {
|
|
551
|
+
/** Append a captured call, streaming it now unless its tokens can still be rewritten. */
|
|
552
|
+
publish(call: HarnessCallMetric): void
|
|
553
|
+
/** Stream whatever is still withheld. Call once the run's totals are attributed. */
|
|
554
|
+
flush(): void
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* A {@link publishCallMetric} wrapper for a producer whose per-call tokens may be filled in at
|
|
559
|
+
* the END of the run: a CLI that reports only a cumulative total leaves every turn at zero, and
|
|
560
|
+
* `attributeCumulativeUsage` pins the total onto the last call once the terminal `result` event
|
|
561
|
+
* arrives.
|
|
562
|
+
*
|
|
563
|
+
* Since a published call must be final (the backend stores it on the drain and ignores the
|
|
564
|
+
* terminal repeat), a call the CLI did NOT cost is appended to the list but WITHHELD from the
|
|
565
|
+
* live stream — otherwise it records as a zero-token row and the attributed numbers never land.
|
|
566
|
+
* The withholding window closes the moment any call IS costed: attribution can no longer fire, so
|
|
567
|
+
* everything held is final and released at once, in capture order, and every later call streams
|
|
568
|
+
* immediately whatever its tokens. {@link flush} covers the run that was never costed at all.
|
|
569
|
+
*/
|
|
570
|
+
export function createCallMetricPublisher(
|
|
571
|
+
calls: HarnessCallMetric[],
|
|
572
|
+
onCallMetric?: (call: HarnessCallMetric) => void,
|
|
573
|
+
): CallMetricPublisher {
|
|
574
|
+
const withheld: HarnessCallMetric[] = []
|
|
575
|
+
let anyCosted = false
|
|
576
|
+
const flush = (): void => {
|
|
577
|
+
for (const call of withheld) onCallMetric?.(call)
|
|
578
|
+
withheld.length = 0
|
|
579
|
+
}
|
|
580
|
+
return {
|
|
581
|
+
publish(call) {
|
|
582
|
+
const costed = call.inputTokens > 0 || call.outputTokens > 0
|
|
583
|
+
if (!costed && !anyCosted) {
|
|
584
|
+
publishCallMetric(calls, call)
|
|
585
|
+
withheld.push(call)
|
|
586
|
+
return
|
|
587
|
+
}
|
|
588
|
+
if (costed) anyCosted = true
|
|
589
|
+
// Released BEFORE this call so the live sequence stays in capture order.
|
|
590
|
+
flush()
|
|
591
|
+
publishCallMetric(calls, call, onCallMetric)
|
|
592
|
+
},
|
|
593
|
+
flush,
|
|
594
|
+
}
|
|
508
595
|
}
|
|
509
596
|
|
|
510
597
|
/** Pi's assistant summary plus {@link PiRunStats} describing what it did. */
|
package/src/runner.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { redactSecrets } from './redact.js'
|
|
2
2
|
import type { FollowUpLine } from './follow-ups.js'
|
|
3
|
-
import type { TodoProgress, ToolSpan } from './pi.js'
|
|
3
|
+
import type { HarnessCallMetric, TodoProgress, ToolSpan } from './pi.js'
|
|
4
4
|
import { log, type Logger } from './logger.js'
|
|
5
5
|
import {
|
|
6
6
|
type FailureCause,
|
|
@@ -30,6 +30,19 @@ export interface RunOptions {
|
|
|
30
30
|
onSpan?: (span: ToolSpan) => void
|
|
31
31
|
/** Receives the forward-looking follow-up / question items the Coder streamed since the last poll. */
|
|
32
32
|
onFollowUp?: (items: FollowUpLine[]) => void
|
|
33
|
+
/**
|
|
34
|
+
* Receives each per-call telemetry row the moment the agent's CLI stream yields it, so a
|
|
35
|
+
* run's model calls reach `llm_call_metrics` WHILE it runs rather than only in its terminal
|
|
36
|
+
* result. The registry stamps the call's job-scoped {@link HarnessCallMetric.seq} and buffers
|
|
37
|
+
* it for the next poll to drain.
|
|
38
|
+
*
|
|
39
|
+
* Call this for every metric you also put on the result — the SAME object, not a copy: the
|
|
40
|
+
* stamped `seq` is what lets the backend recognise the terminal write of an already-recorded
|
|
41
|
+
* call and skip it. A run that dies mid-flight (the container is evicted, the harness process
|
|
42
|
+
* is OOM-killed) never produces a terminal result, so without this its entire token spend and
|
|
43
|
+
* every prompt/response body are lost — exactly the run an operator most needs to inspect.
|
|
44
|
+
*/
|
|
45
|
+
onCallMetric?: (call: HarnessCallMetric) => void
|
|
33
46
|
/**
|
|
34
47
|
* Mark the coarse lifecycle phase the handler has entered (`clone` / `agent` / `push` / …).
|
|
35
48
|
* Drives the stuck-run breadcrumb: an inactivity kill reports WHICH phase was hung, and the
|
|
@@ -114,6 +127,17 @@ export interface JobView<TResult extends JobResultBase = JobResultBase> {
|
|
|
114
127
|
* surfaces the first one (and only on a follow-ups-enabled coding run).
|
|
115
128
|
*/
|
|
116
129
|
followUps?: FollowUpLine[]
|
|
130
|
+
/**
|
|
131
|
+
* Per-model-call telemetry the agent's CLI stream yielded SINCE THE LAST POLL
|
|
132
|
+
* (drain-on-read, exactly like {@link spans}). The backend records these into
|
|
133
|
+
* `llm_call_metrics` as they arrive, so a run's token spend and prompt/response bodies are
|
|
134
|
+
* queryable while it is still running — and survive it dying before it can produce a
|
|
135
|
+
* terminal result. Each carries a job-scoped `seq` so the terminal
|
|
136
|
+
* {@link JobResultBase} list can re-offer the same calls without duplicating rows.
|
|
137
|
+
* Absent until the agent's first model call (and on the proxy-metered Pi harness, whose
|
|
138
|
+
* calls the LLM proxy meters directly).
|
|
139
|
+
*/
|
|
140
|
+
callMetrics?: HarnessCallMetric[]
|
|
117
141
|
/**
|
|
118
142
|
* ADR 0026 D4: set when the cold-start watchdog fired — the job produced NO activity
|
|
119
143
|
* within {@link RunnerLimits.coldStartMs} of starting, a likely onboarding/auth wedge.
|
|
@@ -136,6 +160,13 @@ interface JobEntry<TResult extends JobResultBase> extends JobView<TResult> {
|
|
|
136
160
|
spanBuffer: ToolSpan[]
|
|
137
161
|
/** Follow-up items buffered since the last drain (see {@link JobView.followUps}). */
|
|
138
162
|
followUpBuffer: FollowUpLine[]
|
|
163
|
+
/** Call telemetry buffered since the last drain (see {@link JobView.callMetrics}). */
|
|
164
|
+
callMetricBuffer: HarnessCallMetric[]
|
|
165
|
+
/**
|
|
166
|
+
* Next job-scoped {@link HarnessCallMetric.seq} to stamp. Monotonic for the life of the job
|
|
167
|
+
* (never reset by a drain), so a call's row id stays unique across every poll window.
|
|
168
|
+
*/
|
|
169
|
+
callMetricSeq: number
|
|
139
170
|
/** Abort the in-flight run (see {@link JobRegistry.abortAll}); set while running only. */
|
|
140
171
|
abort?: (reason: string) => void
|
|
141
172
|
}
|
|
@@ -192,6 +223,8 @@ function toView<TResult extends JobResultBase>(entry: JobEntry<TResult>): JobVie
|
|
|
192
223
|
promise: _promise,
|
|
193
224
|
spanBuffer: _spanBuffer,
|
|
194
225
|
followUpBuffer: _followUpBuffer,
|
|
226
|
+
callMetricBuffer: _callMetricBuffer,
|
|
227
|
+
callMetricSeq: _callMetricSeq,
|
|
195
228
|
abort: _abort,
|
|
196
229
|
...view
|
|
197
230
|
} = entry
|
|
@@ -237,6 +270,8 @@ export class JobRegistry<TJob = unknown, TResult extends JobResultBase = JobResu
|
|
|
237
270
|
promise: Promise.resolve(),
|
|
238
271
|
spanBuffer: [],
|
|
239
272
|
followUpBuffer: [],
|
|
273
|
+
callMetricBuffer: [],
|
|
274
|
+
callMetricSeq: 0,
|
|
240
275
|
}
|
|
241
276
|
this.jobs.set(id, entry)
|
|
242
277
|
entry.promise = this.drive(entry, job)
|
|
@@ -244,9 +279,10 @@ export class JobRegistry<TJob = unknown, TResult extends JobResultBase = JobResu
|
|
|
244
279
|
}
|
|
245
280
|
|
|
246
281
|
/**
|
|
247
|
-
* Poll the job — and DRAIN its
|
|
248
|
-
* handler is the sole caller, so each poll returns the spans
|
|
249
|
-
* previous poll and clears them, bounding the harness
|
|
282
|
+
* Poll the job — and DRAIN its observability buffers (drain-on-read). The GET /jobs/{id}
|
|
283
|
+
* handler is the sole caller, so each poll returns the spans / follow-ups / call metrics
|
|
284
|
+
* accumulated since the previous poll and clears them, bounding the harness buffers to one
|
|
285
|
+
* poll interval.
|
|
250
286
|
*/
|
|
251
287
|
get(id: string): JobView<TResult> | undefined {
|
|
252
288
|
const entry = this.jobs.get(id)
|
|
@@ -260,6 +296,10 @@ export class JobRegistry<TJob = unknown, TResult extends JobResultBase = JobResu
|
|
|
260
296
|
view.followUps = entry.followUpBuffer
|
|
261
297
|
entry.followUpBuffer = []
|
|
262
298
|
}
|
|
299
|
+
if (entry.callMetricBuffer.length > 0) {
|
|
300
|
+
view.callMetrics = entry.callMetricBuffer
|
|
301
|
+
entry.callMetricBuffer = []
|
|
302
|
+
}
|
|
263
303
|
return view
|
|
264
304
|
}
|
|
265
305
|
|
|
@@ -374,6 +414,13 @@ export class JobRegistry<TJob = unknown, TResult extends JobResultBase = JobResu
|
|
|
374
414
|
onFollowUp: (items) => {
|
|
375
415
|
entry.followUpBuffer.push(...items)
|
|
376
416
|
},
|
|
417
|
+
onCallMetric: (call) => {
|
|
418
|
+
// Stamp the job-scoped sequence on the metric OBJECT: the handler keeps the same
|
|
419
|
+
// instance for its terminal result, so both channels carry the same `seq` and the
|
|
420
|
+
// backend mints one stable row id per call.
|
|
421
|
+
call.seq = entry.callMetricSeq++
|
|
422
|
+
entry.callMetricBuffer.push(call)
|
|
423
|
+
},
|
|
377
424
|
onPhase: (next) => markPhase(next),
|
|
378
425
|
log: jobLog,
|
|
379
426
|
})
|
package/src/subagents.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { createReadStream, type Dirent } from 'node:fs'
|
|
|
3
3
|
import { basename, join } from 'node:path'
|
|
4
4
|
import { claudeAssistantContent, claudeCallUsage, isObject, redactBody } from './claude-stream.js'
|
|
5
5
|
import type { Logger } from './logger.js'
|
|
6
|
-
import type
|
|
6
|
+
import { publishCallMetric, type HarnessCallMetric, type TodoProgress } from './pi.js'
|
|
7
7
|
|
|
8
8
|
// ADR 0026 D2.1 + D3, corrected by ADR 0027. When the Claude Code CLI reviews a large PR
|
|
9
9
|
// it fans the work out across parallel `Task` subagents. Two things then go dark to the
|
|
@@ -155,6 +155,13 @@ export interface SubagentWatcherOptions {
|
|
|
155
155
|
secrets?: string[]
|
|
156
156
|
/** Fallback model id stamped on a subagent call whose transcript omits one. */
|
|
157
157
|
model?: string
|
|
158
|
+
/**
|
|
159
|
+
* Streams each lifted subagent call to the live telemetry drain (the run's `RunOptions`
|
|
160
|
+
* hook). Subagent work is exactly where a long review spends most of its tokens, and it is
|
|
161
|
+
* the phase the parent stream goes quiet for — so without this a run killed mid-fan-out
|
|
162
|
+
* reports nothing at all.
|
|
163
|
+
*/
|
|
164
|
+
onCallMetric?: (call: HarnessCallMetric) => void
|
|
158
165
|
/** Poll cadence (ms); overridable for tests. */
|
|
159
166
|
intervalMs?: number
|
|
160
167
|
log?: Logger
|
|
@@ -244,23 +251,27 @@ export function startSubagentWatcher(root: string, opts: SubagentWatcherOptions)
|
|
|
244
251
|
if (u.inputTokens === 0 && u.outputTokens === 0) return
|
|
245
252
|
const content = Array.isArray(message.content) ? message.content : []
|
|
246
253
|
const { text, reasoning } = claudeAssistantContent(content)
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
? { model:
|
|
252
|
-
:
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
254
|
+
publishCallMetric(
|
|
255
|
+
calls,
|
|
256
|
+
{
|
|
257
|
+
...(typeof message.model === 'string'
|
|
258
|
+
? { model: message.model }
|
|
259
|
+
: opts.model
|
|
260
|
+
? { model: opts.model }
|
|
261
|
+
: {}),
|
|
262
|
+
// The subagent's own transcript isn't a re-sendable prompt chain, so we don't
|
|
263
|
+
// reconstruct the request side (kept empty); the response + tokens are faithful.
|
|
264
|
+
promptText: '',
|
|
265
|
+
messageCount: 0,
|
|
266
|
+
responseText: redactBody(text, secrets),
|
|
267
|
+
reasoningText: redactBody(reasoning, secrets),
|
|
268
|
+
inputTokens: u.inputTokens,
|
|
269
|
+
cachedInputTokens: u.cachedInputTokens,
|
|
270
|
+
outputTokens: u.outputTokens,
|
|
271
|
+
finishReason: typeof message.stop_reason === 'string' ? message.stop_reason : null,
|
|
272
|
+
},
|
|
273
|
+
opts.onCallMetric,
|
|
274
|
+
)
|
|
264
275
|
usage.inputTokens += u.inputTokens
|
|
265
276
|
usage.outputTokens += u.outputTokens
|
|
266
277
|
}
|