@cat-factory/executor-harness 1.50.18 → 1.52.2
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 +41 -36
- package/dist/pi-workspace.js +4 -0
- package/dist/pi.js +59 -0
- package/dist/progress.js +217 -0
- package/dist/runner.js +18 -4
- package/dist/subagents.js +55 -29
- package/package.json +3 -3
- package/src/agent-runner.ts +92 -54
- package/src/pi-workspace.ts +4 -0
- package/src/pi.ts +87 -0
- package/src/progress.ts +232 -0
- package/src/runner.ts +51 -4
- package/src/subagents.ts +54 -52
package/dist/subagents.js
CHANGED
|
@@ -2,6 +2,56 @@ 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';
|
|
6
|
+
// ADR 0026 D2.1 + D3, corrected by ADR 0027. When the Claude Code CLI reviews a large PR
|
|
7
|
+
// it fans the work out across parallel `Task` subagents. Two things then go dark to the
|
|
8
|
+
// harness, which only reads the PARENT process's stream-json stdout:
|
|
9
|
+
//
|
|
10
|
+
// - the parent stream falls quiet for the whole (potentially 15+ minute) parallel
|
|
11
|
+
// review, so the inactivity heartbeat freezes and a healthy run looks wedged (P3);
|
|
12
|
+
// - every subagent's token spend is written to a SEPARATE `subagents/*.jsonl`
|
|
13
|
+
// transcript under the CLI's config home and never reaches the parent stream, so
|
|
14
|
+
// the run's telemetry reports ~0 tokens while hundreds of thousands are spent (P3).
|
|
15
|
+
//
|
|
16
|
+
// This module closes both without disabling the (context-bounding, ADR-0023-wanted)
|
|
17
|
+
// subagent parallelism:
|
|
18
|
+
//
|
|
19
|
+
// - {@link createSliceTracker} derives the slice plan + per-slice progress from the
|
|
20
|
+
// PARENT stream alone — the subagent-dispatch tool_use and its terminal tool_result
|
|
21
|
+
// DO appear there (only the subagent's intermediate turns don't), so slices/progress
|
|
22
|
+
// need no file watching (D2.1). `pickProgress` (./progress.ts) reconciles it with the
|
|
23
|
+
// parent's own plan (ADR 0027 Defect B);
|
|
24
|
+
// - {@link startSubagentWatcher} tails the `subagents/*.jsonl` transcripts for the
|
|
25
|
+
// heartbeat (any new bytes ⇒ `onActivity`) and sums each subagent turn's usage into
|
|
26
|
+
// the run's telemetry (D3).
|
|
27
|
+
//
|
|
28
|
+
// The CLI does NOT write those transcripts to `<configHome>/subagents` (the location ADR
|
|
29
|
+
// 0026 assumed, which never exists — ADR 0027 Defect A). It writes them PER SESSION under
|
|
30
|
+
// `<configHome>/projects/<encoded-cwd>/<session-uuid>/subagents/agent-*.jsonl`, and the
|
|
31
|
+
// session-uuid dir isn't known before the CLI mints it — so the watcher is pointed at the
|
|
32
|
+
// `projects` root and DISCOVERS the `subagents/` dir by walking (see
|
|
33
|
+
// {@link findSubagentTranscripts}).
|
|
34
|
+
//
|
|
35
|
+
// Both degrade gracefully: the CLI's subagent transcript layout is not a stable contract,
|
|
36
|
+
// so a missing directory, an unreadable file, or an unparseable line is swallowed and the
|
|
37
|
+
// harness falls back to today's parent-stream-only behaviour.
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Slice / progress tracking off the PARENT stream (D2.1)
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
/**
|
|
42
|
+
* The tool names the Claude Code CLI dispatches a parallel subagent under. `Agent` is what the
|
|
43
|
+
* shipped schema declares (`AgentInput` in `sdk-tools.d.ts`, carrying `description` / `prompt` /
|
|
44
|
+
* `subagent_type`); `Task` is the older name for the same dispatch. Both are matched because the
|
|
45
|
+
* harness runs against whatever CLI the image happens to bundle, and matching only the old name
|
|
46
|
+
* is what left a CLI 2.1.x pr-review reporting no slices at all.
|
|
47
|
+
*
|
|
48
|
+
* Note the asymmetry: keeping the legacy `Task` here is the one place a CLI rename could produce a
|
|
49
|
+
* FALSE signal rather than merely no signal — if a future build were to name a plain task-list
|
|
50
|
+
* tool `Task`, its writes would be counted as in-flight slices. We accept that because no shipped
|
|
51
|
+
* build does (the incremental plan tool is `TaskCreate`/`TaskUpdate`, tracked separately in
|
|
52
|
+
* `progress.ts`), and dropping legacy coverage is the more likely regression.
|
|
53
|
+
*/
|
|
54
|
+
const SUBAGENT_TOOL_NAMES = new Set(['Agent', 'Task']);
|
|
5
55
|
export function createSliceTracker() {
|
|
6
56
|
// Insertion-ordered so the progress `items` render in dispatch order.
|
|
7
57
|
const slices = new Map();
|
|
@@ -10,7 +60,9 @@ export function createSliceTracker() {
|
|
|
10
60
|
if (!Array.isArray(content))
|
|
11
61
|
return;
|
|
12
62
|
for (const block of content) {
|
|
13
|
-
if (!isObject(block) || block.type !== 'tool_use'
|
|
63
|
+
if (!isObject(block) || block.type !== 'tool_use')
|
|
64
|
+
continue;
|
|
65
|
+
if (typeof block.name !== 'string' || !SUBAGENT_TOOL_NAMES.has(block.name))
|
|
14
66
|
continue;
|
|
15
67
|
const id = typeof block.id === 'string' ? block.id : undefined;
|
|
16
68
|
if (!id || slices.has(id))
|
|
@@ -54,32 +106,6 @@ export function createSliceTracker() {
|
|
|
54
106
|
},
|
|
55
107
|
};
|
|
56
108
|
}
|
|
57
|
-
/**
|
|
58
|
-
* Reconcile the two redundant views of the same slice work into the one to surface
|
|
59
|
-
* (ADR 0027 Defect B). A pr-reviewer run has BOTH a parent `TodoWrite` plan (the slices,
|
|
60
|
-
* written once at grouping time) and the {@link SliceTracker}'s `Task`-dispatch view. The
|
|
61
|
-
* sequential shape advances the todo plan; the parallel-subagent shape advances ONLY the
|
|
62
|
-
* slice tracker (the CLI writes the plan once and never marks it done, while the parallel
|
|
63
|
-
* `Task`s report in-flight/complete). Neither alone covers both shapes, and gating the
|
|
64
|
-
* slice tracker OFF whenever a todo plan exists (the old behaviour) pinned parallel runs
|
|
65
|
-
* at 0%. So prefer whichever view is further along: more `completed`, then more
|
|
66
|
-
* `inProgress` (an all-pending todo plan must not beat live in-flight slices), then more
|
|
67
|
-
* `total` (the richer view — the todo plan can carry an extra "aggregate" entry), else the
|
|
68
|
-
* todo plan. Pure + total; returns whichever single input is present when only one is.
|
|
69
|
-
*/
|
|
70
|
-
export function pickProgress(todo, slice) {
|
|
71
|
-
if (!todo)
|
|
72
|
-
return slice;
|
|
73
|
-
if (!slice)
|
|
74
|
-
return todo;
|
|
75
|
-
if (slice.completed !== todo.completed)
|
|
76
|
-
return slice.completed > todo.completed ? slice : todo;
|
|
77
|
-
if (slice.inProgress !== todo.inProgress)
|
|
78
|
-
return slice.inProgress > todo.inProgress ? slice : todo;
|
|
79
|
-
if (slice.total !== todo.total)
|
|
80
|
-
return slice.total > todo.total ? slice : todo;
|
|
81
|
-
return todo;
|
|
82
|
-
}
|
|
83
109
|
// ---------------------------------------------------------------------------
|
|
84
110
|
// Subagent transcript watcher (heartbeat + usage) (D3)
|
|
85
111
|
// ---------------------------------------------------------------------------
|
|
@@ -164,7 +190,7 @@ export function startSubagentWatcher(root, opts) {
|
|
|
164
190
|
return;
|
|
165
191
|
const content = Array.isArray(message.content) ? message.content : [];
|
|
166
192
|
const { text, reasoning } = claudeAssistantContent(content);
|
|
167
|
-
calls
|
|
193
|
+
publishCallMetric(calls, {
|
|
168
194
|
...(typeof message.model === 'string'
|
|
169
195
|
? { model: message.model }
|
|
170
196
|
: opts.model
|
|
@@ -180,7 +206,7 @@ export function startSubagentWatcher(root, opts) {
|
|
|
180
206
|
cachedInputTokens: u.cachedInputTokens,
|
|
181
207
|
outputTokens: u.outputTokens,
|
|
182
208
|
finishReason: typeof message.stop_reason === 'string' ? message.stop_reason : null,
|
|
183
|
-
});
|
|
209
|
+
}, opts.onCallMetric);
|
|
184
210
|
usage.inputTokens += u.inputTokens;
|
|
185
211
|
usage.outputTokens += u.outputTokens;
|
|
186
212
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/executor-harness",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.52.2",
|
|
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.1",
|
|
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,10 +10,25 @@ 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
|
-
import { createSliceTracker,
|
|
24
|
+
import { createSliceTracker, startSubagentWatcher } from './subagents.js'
|
|
25
|
+
import {
|
|
26
|
+
createTaskPlanTracker,
|
|
27
|
+
normalizeStatus,
|
|
28
|
+
pickProgress,
|
|
29
|
+
toProgress,
|
|
30
|
+
todosToProgress,
|
|
31
|
+
} from './progress.js'
|
|
17
32
|
import { assertOnboardingKeysCurrent, writeOnboardingPreseed } from './onboarding-preseed.js'
|
|
18
33
|
import { retainSessionTranscripts } from './transcript-retention.js'
|
|
19
34
|
|
|
@@ -81,6 +96,12 @@ export interface SubscriptionRunOptions {
|
|
|
81
96
|
onActivity?: () => void
|
|
82
97
|
/** Called with the latest subtask counts each time the CLI updates its todo/plan list. */
|
|
83
98
|
onProgress?: (progress: TodoProgress) => void
|
|
99
|
+
/**
|
|
100
|
+
* Called with each per-call telemetry row as the CLI stream yields it, so the backend can
|
|
101
|
+
* record the run's model calls WHILE it runs instead of only from its terminal result. The
|
|
102
|
+
* same row still rides the result, so a lost poll response costs nothing.
|
|
103
|
+
*/
|
|
104
|
+
onCallMetric?: (call: HarnessCallMetric) => void
|
|
84
105
|
/**
|
|
85
106
|
* The per-job child logger (jobId/repo/branch correlation). Threaded so the retained
|
|
86
107
|
* session-transcript path is logged for the run when the isolated config home is torn down.
|
|
@@ -324,19 +345,31 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
324
345
|
{ role: 'user', content: opts.userPrompt },
|
|
325
346
|
]
|
|
326
347
|
const calls: HarnessCallMetric[] = []
|
|
348
|
+
// Streams each call as the CLI yields it, EXCEPT one whose tokens `attributeCumulativeUsage`
|
|
349
|
+
// may still rewrite below (a published call must be final — see the publisher).
|
|
350
|
+
const publisher = createCallMetricPublisher(calls, opts.onCallMetric)
|
|
327
351
|
|
|
328
352
|
// ADR 0026 D2.1 + ADR 0027 Defect B: surface live slice progress from TWO reconciled
|
|
329
|
-
// sources. The parent's
|
|
353
|
+
// sources. The parent's subagent dispatches + their terminal tool_results DO appear on this
|
|
330
354
|
// stream (only a subagent's intermediate turns don't), so `sliceTracker` derives per-slice
|
|
331
|
-
// progress for the parallel
|
|
332
|
-
//
|
|
333
|
-
// update, so neither masks the other — the pr-reviewer prompt writes its
|
|
334
|
-
//
|
|
355
|
+
// progress for the parallel shape; the parent's own plan (the sequential shape) is tracked
|
|
356
|
+
// by `planTracker` + `lastTodo`. `pickProgress` picks whichever is further along on each
|
|
357
|
+
// update, so neither masks the other — the pr-reviewer prompt writes its plan ONCE and never
|
|
358
|
+
// marks it done, which used to gate the slice signal off and pin progress at 0%.
|
|
359
|
+
//
|
|
360
|
+
// The plan arrives in one of two tool vocabularies depending on the bundled CLI build:
|
|
361
|
+
// `TodoWrite` (whole-list snapshots, tracked in `lastTodo`) or the incremental
|
|
362
|
+
// `TaskCreate`/`TaskUpdate` pair (tracked by `planTracker`, which needs the tool RESULTS too
|
|
363
|
+
// because the task id is minted there). Both are read — see ./progress.ts.
|
|
335
364
|
const sliceTracker = createSliceTracker()
|
|
365
|
+
const planTracker = createTaskPlanTracker()
|
|
336
366
|
let lastTodo: TodoProgress | undefined
|
|
337
367
|
const emitProgress = (): void => {
|
|
338
368
|
if (!opts.onProgress) return
|
|
339
|
-
const progress = pickProgress(
|
|
369
|
+
const progress = pickProgress(
|
|
370
|
+
pickProgress(lastTodo, planTracker.progress()),
|
|
371
|
+
sliceTracker.progress(),
|
|
372
|
+
)
|
|
340
373
|
if (progress) opts.onProgress(progress)
|
|
341
374
|
}
|
|
342
375
|
|
|
@@ -355,12 +388,13 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
355
388
|
}
|
|
356
389
|
}
|
|
357
390
|
sliceTracker.onAssistant(content)
|
|
391
|
+
planTracker.onAssistant(content)
|
|
358
392
|
emitProgress()
|
|
359
393
|
// Record this call BEFORE appending its turn: the prompt is the history that
|
|
360
394
|
// produced this response. The append-only array keeps each call's prompt a strict
|
|
361
395
|
// prefix of the next, so the backend's telemetry chain delta-compresses cleanly.
|
|
362
396
|
const u = claudeCallUsage(message.usage)
|
|
363
|
-
|
|
397
|
+
publisher.publish({
|
|
364
398
|
...(typeof message.model === 'string' ? { model: message.model } : {}),
|
|
365
399
|
promptText: redactBody(JSON.stringify(messages), secrets),
|
|
366
400
|
messageCount: messages.length,
|
|
@@ -377,6 +411,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
377
411
|
const content = (event.message as Record<string, unknown>).content
|
|
378
412
|
if (Array.isArray(content)) {
|
|
379
413
|
sliceTracker.onUser(content)
|
|
414
|
+
planTracker.onUser(content)
|
|
380
415
|
emitProgress()
|
|
381
416
|
messages.push({ role: 'tool', content })
|
|
382
417
|
}
|
|
@@ -439,6 +474,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
439
474
|
...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
|
|
440
475
|
secrets,
|
|
441
476
|
model: opts.model,
|
|
477
|
+
...(opts.onCallMetric ? { onCallMetric: opts.onCallMetric } : {}),
|
|
442
478
|
...(opts.log ? { log: opts.log } : {}),
|
|
443
479
|
})
|
|
444
480
|
: undefined
|
|
@@ -470,7 +506,15 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
470
506
|
onEvent,
|
|
471
507
|
)
|
|
472
508
|
|
|
473
|
-
return await assembleClaudeOutcome({
|
|
509
|
+
return await assembleClaudeOutcome({
|
|
510
|
+
summary,
|
|
511
|
+
stats,
|
|
512
|
+
stderrTail,
|
|
513
|
+
calls,
|
|
514
|
+
publisher,
|
|
515
|
+
usage,
|
|
516
|
+
subagents,
|
|
517
|
+
})
|
|
474
518
|
} finally {
|
|
475
519
|
await subagents?.stop()
|
|
476
520
|
if (configHome) {
|
|
@@ -523,13 +567,19 @@ async function assembleClaudeOutcome(args: {
|
|
|
523
567
|
stats: PiRunStats
|
|
524
568
|
stderrTail: string
|
|
525
569
|
calls: HarnessCallMetric[]
|
|
570
|
+
/** The live-stream publisher, flushed once attribution has finalised the calls' tokens. */
|
|
571
|
+
publisher: CallMetricPublisher
|
|
526
572
|
usage: { inputTokens: number; outputTokens: number } | undefined
|
|
527
573
|
subagents: ReturnType<typeof startSubagentWatcher> | undefined
|
|
528
574
|
}): Promise<PiRunOutcome> {
|
|
529
|
-
const { summary, stats, stderrTail, calls, usage, subagents } = args
|
|
575
|
+
const { summary, stats, stderrTail, calls, publisher, usage, subagents } = args
|
|
530
576
|
// The parent's cumulative-usage fallback applies to the PARENT calls only (before the
|
|
531
577
|
// subagent calls, which carry their own per-turn tokens, are concatenated).
|
|
532
578
|
attributeCumulativeUsage(calls, usage)
|
|
579
|
+
// The withheld calls are final only NOW, so stream them: the completion poll drains them
|
|
580
|
+
// alongside the result, and the backend records the attributed numbers rather than the zeros
|
|
581
|
+
// they carried while the run was in flight.
|
|
582
|
+
publisher.flush()
|
|
533
583
|
// Final drain of any subagent transcript writes that landed after the last poll, then
|
|
534
584
|
// fold the subagents' usage + per-call telemetry into the run's outcome.
|
|
535
585
|
await subagents?.stop()
|
|
@@ -552,24 +602,6 @@ async function assembleClaudeOutcome(args: {
|
|
|
552
602
|
}
|
|
553
603
|
}
|
|
554
604
|
|
|
555
|
-
/** Map Claude Code's `TodoWrite` todos array onto subtask counts. */
|
|
556
|
-
function todosToProgress(todos: unknown): TodoProgress | undefined {
|
|
557
|
-
if (!Array.isArray(todos)) return undefined
|
|
558
|
-
const items = todos.filter(isObject).map((t) => ({
|
|
559
|
-
label: typeof t.content === 'string' ? t.content : String(t.content ?? ''),
|
|
560
|
-
status: normalizeStatus(t.status),
|
|
561
|
-
}))
|
|
562
|
-
const completed = items.filter((i) => i.status === 'completed').length
|
|
563
|
-
const inProgress = items.filter((i) => i.status === 'in_progress').length
|
|
564
|
-
return { completed, inProgress, total: items.length, items }
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
function normalizeStatus(status: unknown): 'pending' | 'in_progress' | 'completed' {
|
|
568
|
-
if (status === 'completed') return 'completed'
|
|
569
|
-
if (status === 'in_progress') return 'in_progress'
|
|
570
|
-
return 'pending'
|
|
571
|
-
}
|
|
572
|
-
|
|
573
605
|
function claudeUsage(raw: unknown): { inputTokens: number; outputTokens: number } | undefined {
|
|
574
606
|
if (!isObject(raw)) return undefined
|
|
575
607
|
// Count every input bucket Anthropic bills: fresh input plus BOTH cache reads and
|
|
@@ -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,
|
|
@@ -789,9 +829,7 @@ function codexPlanProgress(event: Record<string, unknown>): TodoProgress | undef
|
|
|
789
829
|
status: normalizeStatus(s.status),
|
|
790
830
|
}))
|
|
791
831
|
if (items.length === 0) return undefined
|
|
792
|
-
|
|
793
|
-
const inProgress = items.filter((i) => i.status === 'in_progress').length
|
|
794
|
-
return { completed, inProgress, total: items.length, items }
|
|
832
|
+
return toProgress(items)
|
|
795
833
|
}
|
|
796
834
|
|
|
797
835
|
/**
|
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. */
|