@cat-factory/executor-harness 1.114.0 → 1.118.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 +34 -1
- package/dist/agent-runner.js +68 -74
- package/dist/claude-call-aggregator.d.ts +9 -0
- package/dist/claude-call-aggregator.js +10 -1
- package/dist/coding-agent.js +50 -9
- package/dist/failure.d.ts +7 -1
- package/dist/failure.js +7 -0
- package/dist/git.d.ts +118 -3
- package/dist/git.js +162 -9
- package/dist/inline.js +20 -6
- package/dist/job.d.ts +8 -1
- package/dist/pi.d.ts +17 -24
- package/dist/pi.js +7 -41
- package/dist/usage-attribution.d.ts +56 -0
- package/dist/usage-attribution.js +96 -0
- package/package.json +4 -4
- package/src/agent-runner.ts +89 -80
- package/src/claude-call-aggregator.ts +15 -2
- package/src/coding-agent.ts +52 -8
- package/src/failure.ts +7 -0
- package/src/git.ts +212 -9
- package/src/inline.ts +20 -7
- package/src/job.ts +8 -1
- package/src/pi.ts +17 -51
- package/src/usage-attribution.ts +102 -0
package/src/inline.ts
CHANGED
|
@@ -18,14 +18,26 @@ import type { RunOptions } from './runner.js'
|
|
|
18
18
|
// is injected.
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* Map the harness CLI's terminal stop reason (lifted onto the last call metric) to the
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
21
|
+
* Map the harness CLI's terminal stop reason (lifted onto the last call metric) to the inline
|
|
22
|
+
* `finishReason` the reviewer keys off, or `undefined` when the CLI reported NONE.
|
|
23
|
+
*
|
|
24
|
+
* Undefined rather than `stop`, which is what this returned for years on the strength of a
|
|
25
|
+
* comment claiming Claude Code reports the reason. It does not: its `stream-json` `assistant`
|
|
26
|
+
* envelopes carry the message-START snapshot, whose `stop_reason` is null, so every call metric
|
|
27
|
+
* a claude-code or codex run produces has a null reason and this answered `stop` for all of them.
|
|
28
|
+
* `stop` is a positive claim that the model finished of its own accord, and it is the exact claim
|
|
29
|
+
* a truncation check is trying to disprove — so the one caller keyed off it
|
|
30
|
+
* (`finishReason === 'length'`) could never fire, and every store that kept the row recorded a
|
|
31
|
+
* clean stop nobody observed.
|
|
32
|
+
*
|
|
33
|
+
* Kept as a MAPPING rather than deleted because the field remains reachable: a subagent turn is
|
|
34
|
+
* read from a completed JSONL transcript, which does carry `stop_reason`, and a future CLI build
|
|
35
|
+
* (or `--include-partial-messages`) would restore it on the parent stream too.
|
|
25
36
|
*/
|
|
26
|
-
function deriveFinishReason(calls: HarnessCallMetric[] | undefined): 'stop' | 'length' {
|
|
37
|
+
function deriveFinishReason(calls: HarnessCallMetric[] | undefined): 'stop' | 'length' | undefined {
|
|
27
38
|
const last = calls?.[calls.length - 1]
|
|
28
|
-
const reason = last?.finishReason?.toLowerCase()
|
|
39
|
+
const reason = last?.finishReason?.toLowerCase()
|
|
40
|
+
if (!reason) return undefined
|
|
29
41
|
return reason === 'max_tokens' || reason === 'length' ? 'length' : 'stop'
|
|
30
42
|
}
|
|
31
43
|
|
|
@@ -57,9 +69,10 @@ export async function handleInline(job: InlineJob, opts: RunOptions): Promise<In
|
|
|
57
69
|
...(opts.signal ? { signal: opts.signal } : {}),
|
|
58
70
|
...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
|
|
59
71
|
})
|
|
72
|
+
const finishReason = deriveFinishReason(outcome.callMetrics)
|
|
60
73
|
return {
|
|
61
74
|
text: outcome.summary,
|
|
62
|
-
finishReason:
|
|
75
|
+
...(finishReason ? { finishReason } : {}),
|
|
63
76
|
...(outcome.usage ? { usage: inlineUsage(outcome.usage, outcome.callMetrics) } : {}),
|
|
64
77
|
...(outcome.callMetrics ? { callMetrics: outcome.callMetrics } : {}),
|
|
65
78
|
}
|
package/src/job.ts
CHANGED
|
@@ -1164,7 +1164,14 @@ export interface InlineJob extends HarnessAuthFields {
|
|
|
1164
1164
|
/** The inline completion result: the reply text plus lifted token usage / per-call telemetry. */
|
|
1165
1165
|
export interface InlineResult {
|
|
1166
1166
|
text: string
|
|
1167
|
-
/**
|
|
1167
|
+
/**
|
|
1168
|
+
* `length` when the model hit its output cap (the reviewer rejects a truncated doc), `stop`
|
|
1169
|
+
* when it finished of its own accord, ABSENT when the CLI reported no stop reason at all.
|
|
1170
|
+
*
|
|
1171
|
+
* Absent is the normal case today: neither subscription CLI exposes a per-call stop reason on
|
|
1172
|
+
* its parent stream, and the three states must stay distinct because a reader that takes
|
|
1173
|
+
* absent for `stop` is asserting the one thing a truncation check exists to disprove.
|
|
1174
|
+
*/
|
|
1168
1175
|
finishReason?: 'stop' | 'length'
|
|
1169
1176
|
/**
|
|
1170
1177
|
* The job's token usage with the input side split into its three ORTHOGONAL classes:
|
package/src/pi.ts
CHANGED
|
@@ -642,6 +642,16 @@ export interface HarnessCallMetric {
|
|
|
642
642
|
* never disagree about which phase billed a call.
|
|
643
643
|
*/
|
|
644
644
|
phase?: string
|
|
645
|
+
/**
|
|
646
|
+
* This row is not a TURN: it stands for the job as a whole, carrying the spend the CLI reported
|
|
647
|
+
* in its terminal cumulative total and did not attribute to any turn it narrated (see
|
|
648
|
+
* {@link unaccountedUsageCall}). It has no bodies, because there was no request to capture.
|
|
649
|
+
*
|
|
650
|
+
* The backend files it with a NULL turn index for that reason, while still deriving its row id
|
|
651
|
+
* from {@link seq} so a replayed poll re-records instead of duplicating. Absent on every real
|
|
652
|
+
* turn. `CliInlineLanguageModel`'s step-level row is the same idea on the inline path.
|
|
653
|
+
*/
|
|
654
|
+
standsForJob?: boolean
|
|
645
655
|
}
|
|
646
656
|
|
|
647
657
|
/**
|
|
@@ -657,9 +667,13 @@ export interface HarnessCallMetric {
|
|
|
657
667
|
* A published call must be FINAL. The backend records it the moment the drain reaches it and
|
|
658
668
|
* IGNORES the terminal repeat (first write wins, so its stored prompt delta stays valid against
|
|
659
669
|
* the chain tip it was written against), which means a field mutated after publishing never
|
|
660
|
-
* reaches the store.
|
|
661
|
-
*
|
|
662
|
-
*
|
|
670
|
+
* reaches the store.
|
|
671
|
+
*
|
|
672
|
+
* That is a rule about every producer, and it is why the cumulative-usage reconciliation files its
|
|
673
|
+
* shortfall as a NEW row here at the end of the run (`unaccountedUsageCall`) rather than growing the
|
|
674
|
+
* last captured turn. This used to be wrapped by a publisher that withheld the turn attribution
|
|
675
|
+
* could still rewrite, trading a turn of streaming lag for that mutability; with nothing mutated,
|
|
676
|
+
* the wrapper had no reason left to exist.
|
|
663
677
|
*/
|
|
664
678
|
export function publishCallMetric(
|
|
665
679
|
calls: HarnessCallMetric[],
|
|
@@ -670,54 +684,6 @@ export function publishCallMetric(
|
|
|
670
684
|
onCallMetric?.(call)
|
|
671
685
|
}
|
|
672
686
|
|
|
673
|
-
/** Appends captured calls to a run's list, streaming each one as soon as it is final. */
|
|
674
|
-
export interface CallMetricPublisher {
|
|
675
|
-
/** Append a captured call, streaming it now unless its tokens can still be rewritten. */
|
|
676
|
-
publish(call: HarnessCallMetric): void
|
|
677
|
-
/** Stream whatever is still withheld. Call once the run's totals are attributed. */
|
|
678
|
-
flush(): void
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
/**
|
|
682
|
-
* A {@link publishCallMetric} wrapper for a producer whose per-call tokens may be filled in at
|
|
683
|
-
* the END of the run: a CLI that reports only a cumulative total leaves every turn at zero, and
|
|
684
|
-
* `attributeCumulativeUsage` pins the total onto the last call once the terminal `result` event
|
|
685
|
-
* arrives.
|
|
686
|
-
*
|
|
687
|
-
* Since a published call must be final (the backend stores it on the drain and ignores the
|
|
688
|
-
* terminal repeat), a call the CLI did NOT cost is appended to the list but WITHHELD from the
|
|
689
|
-
* live stream — otherwise it records as a zero-token row and the attributed numbers never land.
|
|
690
|
-
* The withholding window closes the moment any call IS costed: attribution can no longer fire, so
|
|
691
|
-
* everything held is final and released at once, in capture order, and every later call streams
|
|
692
|
-
* immediately whatever its tokens. {@link flush} covers the run that was never costed at all.
|
|
693
|
-
*/
|
|
694
|
-
export function createCallMetricPublisher(
|
|
695
|
-
calls: HarnessCallMetric[],
|
|
696
|
-
onCallMetric?: (call: HarnessCallMetric) => void,
|
|
697
|
-
): CallMetricPublisher {
|
|
698
|
-
const withheld: HarnessCallMetric[] = []
|
|
699
|
-
let anyCosted = false
|
|
700
|
-
const flush = (): void => {
|
|
701
|
-
for (const call of withheld) onCallMetric?.(call)
|
|
702
|
-
withheld.length = 0
|
|
703
|
-
}
|
|
704
|
-
return {
|
|
705
|
-
publish(call) {
|
|
706
|
-
const costed = call.inputTokens > 0 || call.outputTokens > 0
|
|
707
|
-
if (!costed && !anyCosted) {
|
|
708
|
-
publishCallMetric(calls, call)
|
|
709
|
-
withheld.push(call)
|
|
710
|
-
return
|
|
711
|
-
}
|
|
712
|
-
if (costed) anyCosted = true
|
|
713
|
-
// Released BEFORE this call so the live sequence stays in capture order.
|
|
714
|
-
flush()
|
|
715
|
-
publishCallMetric(calls, call, onCallMetric)
|
|
716
|
-
},
|
|
717
|
-
flush,
|
|
718
|
-
}
|
|
719
|
-
}
|
|
720
|
-
|
|
721
687
|
/** Pi's assistant summary plus {@link PiRunStats} describing what it did. */
|
|
722
688
|
export interface PiRunOutcome {
|
|
723
689
|
summary: string
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { isObject, numberOf } from './claude-stream.js'
|
|
2
|
+
import type { HarnessCallMetric } from './pi.js'
|
|
3
|
+
|
|
4
|
+
// How a subscription CLI's TWO token channels are reconciled into the per-call rows the backend
|
|
5
|
+
// stores: the per-turn usage the stream narrates, and the cumulative total the terminal `result`
|
|
6
|
+
// event reports. They disagree routinely and in a specific direction, so the reconciliation is a
|
|
7
|
+
// concern of its own rather than a helper beside the stream reader that happens to need it.
|
|
8
|
+
//
|
|
9
|
+
// Split out of `agent-runner.ts` when it hit its size budget.
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Read Claude Code's terminal cumulative usage.
|
|
13
|
+
*
|
|
14
|
+
* Counts every input bucket Anthropic bills: fresh input plus BOTH cache reads and cache writes
|
|
15
|
+
* (`cache_creation_input_tokens`), which are real consumed tokens and are the dominant share on a
|
|
16
|
+
* long agent run. Omitting them under-weights a token's true load in the usage-aware rotation
|
|
17
|
+
* window. `undefined` when the event carried no usage at all, so a caller can tell that from a
|
|
18
|
+
* genuine zero.
|
|
19
|
+
*/
|
|
20
|
+
export function claudeUsage(
|
|
21
|
+
raw: unknown,
|
|
22
|
+
): { inputTokens: number; outputTokens: number } | undefined {
|
|
23
|
+
if (!isObject(raw)) return undefined
|
|
24
|
+
const input =
|
|
25
|
+
numberOf(raw.input_tokens) +
|
|
26
|
+
numberOf(raw.cache_read_input_tokens) +
|
|
27
|
+
numberOf(raw.cache_creation_input_tokens)
|
|
28
|
+
const output = numberOf(raw.output_tokens)
|
|
29
|
+
if (input === 0 && output === 0) return undefined
|
|
30
|
+
return { inputTokens: input, outputTokens: output }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The row standing for whatever the per-turn channel did NOT account for: the terminal cumulative
|
|
35
|
+
* usage minus the sum of the turns already costed, computed PER SIDE. `undefined` when the turns
|
|
36
|
+
* add up, so nothing is double counted.
|
|
37
|
+
*
|
|
38
|
+
* The per-side part is why this exists at all, and it replaced an all-or-nothing guard
|
|
39
|
+
* (`calls.some(c => c.inputTokens > 0 || c.outputTokens > 0)` ⇒ return) that only ever fired for a
|
|
40
|
+
* CLI reporting no per-turn usage at all. Claude Code reports plenty: its `assistant` envelopes
|
|
41
|
+
* carry the message-START usage snapshot, whose INPUT and cache counts are final and whose
|
|
42
|
+
* `output_tokens` is the 1-5 tokens produced when the message opened. So the guard saw costed
|
|
43
|
+
* turns, returned, and the run's whole output side stayed at that snapshot. Measured on a real
|
|
44
|
+
* board: a `coder` step recorded 198 output tokens across 34 calls against the 14,033 the terminal
|
|
45
|
+
* `result` event reported, an `initiative-analyst` 531 against 30,471. Input matched the terminal
|
|
46
|
+
* figure exactly, which is what made the shortfall invisible to a check that asked whether ANY
|
|
47
|
+
* tokens had been reported.
|
|
48
|
+
*
|
|
49
|
+
* **It is its OWN row rather than tokens added to the last captured call.** Growing a real turn by
|
|
50
|
+
* thousands of output tokens it did not produce makes a fabricated number indistinguishable from a
|
|
51
|
+
* measured one everywhere a per-call figure is read (`/api/v1/debug/*`, the observability panel, a
|
|
52
|
+
* step's per-call breakdown), and there is nothing on the row to mark it. The sibling rule on the
|
|
53
|
+
* inline path (`CliInlineLanguageModel.fileUnaccounted`) reached that conclusion first and files a
|
|
54
|
+
* step-level row; this is the same answer for the channel that has a call list. {@link
|
|
55
|
+
* HarnessCallMetric.standsForJob} is what keeps it from reading as a turn.
|
|
56
|
+
*
|
|
57
|
+
* **`calls` must be the PARENT loop's alone.** The terminal `result` event's cumulative covers the
|
|
58
|
+
* parent conversation only — a subagent's tokens live in its own transcript — so subtracting a
|
|
59
|
+
* subagent turn's tokens from it understates the shortfall, and pinning the remainder near one
|
|
60
|
+
* would bill a conversation for spend it never saw. Both were live in `ambientAuth` mode, where the
|
|
61
|
+
* CLI streams subagent turns onto the parent's stdout and no transcript watcher runs, so those
|
|
62
|
+
* turns are captured through the same publisher as the parent's.
|
|
63
|
+
*
|
|
64
|
+
* {@link claudeUsage} sums every billed input bucket, so the already-accounted input is the sum of
|
|
65
|
+
* all THREE per-call input classes, not `inputTokens` (fresh) alone. A residual input shortfall
|
|
66
|
+
* lands on `inputTokens` because nothing in the terminal event says which class it belonged to.
|
|
67
|
+
*
|
|
68
|
+
* Clamped at 0 per side: a CLI whose terminal figure is LOWER than its own per-turn sum has
|
|
69
|
+
* reported the two inconsistently, and negative spend is not a thing to record.
|
|
70
|
+
*/
|
|
71
|
+
export function unaccountedUsageCall(
|
|
72
|
+
parentCalls: readonly HarnessCallMetric[],
|
|
73
|
+
usage: { inputTokens: number; outputTokens: number } | undefined,
|
|
74
|
+
): HarnessCallMetric | undefined {
|
|
75
|
+
if (!usage) return undefined
|
|
76
|
+
let accountedInput = 0
|
|
77
|
+
let accountedOutput = 0
|
|
78
|
+
for (const call of parentCalls) {
|
|
79
|
+
accountedInput += call.inputTokens + call.cacheReadTokens + call.cacheWriteTokens
|
|
80
|
+
accountedOutput += call.outputTokens
|
|
81
|
+
}
|
|
82
|
+
const inputTokens = Math.max(0, usage.inputTokens - accountedInput)
|
|
83
|
+
const outputTokens = Math.max(0, usage.outputTokens - accountedOutput)
|
|
84
|
+
if (!inputTokens && !outputTokens) return undefined
|
|
85
|
+
return {
|
|
86
|
+
// No `model`: the terminal event names none, and the recorder then files the row under the
|
|
87
|
+
// model the step DISPATCHED, which is the same answer without this claiming to have observed
|
|
88
|
+
// it. (Claude Code serves some turns with a different model, so a guess here misprices.)
|
|
89
|
+
promptText: '',
|
|
90
|
+
messageCount: 0,
|
|
91
|
+
responseText: '',
|
|
92
|
+
reasoningText: '',
|
|
93
|
+
inputTokens,
|
|
94
|
+
// Both 0 rather than a split of `inputTokens`: the terminal figure is one number and says
|
|
95
|
+
// nothing about which input class the remainder belonged to.
|
|
96
|
+
cacheReadTokens: 0,
|
|
97
|
+
cacheWriteTokens: 0,
|
|
98
|
+
outputTokens,
|
|
99
|
+
finishReason: null,
|
|
100
|
+
standsForJob: true,
|
|
101
|
+
}
|
|
102
|
+
}
|