@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/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
- * inline `finishReason` the reviewer keys off. Only Claude Code reports it (`max_tokens` on
23
- * a `--output-format stream-json` result); Codex's thinner stream exposes none, so it reads
24
- * as `stop` the same one-shot limitation the host-CLI runner has.
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: deriveFinishReason(outcome.callMetrics),
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
- /** `length` when the model hit its output cap (the reviewer rejects a truncated doc). */
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. A producer whose calls can still change (the cumulative-usage fallback,
661
- * whose totals arrive with the CLI's terminal `result` event) publishes through
662
- * {@link createCallMetricPublisher} instead, which withholds exactly those.
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
+ }