@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.
@@ -0,0 +1,232 @@
1
+ import { isObject } from './claude-stream.js'
2
+ import type { TodoProgress } from './pi.js'
3
+
4
+ // The parent agent's own PLAN, as progress counts. This is one of the two redundant views a
5
+ // pr-reviewer run produces (the other is the parallel-subagent dispatch view in
6
+ // `subagents.ts`); {@link pickProgress} reconciles them.
7
+ //
8
+ // The Claude Code CLI exposes the plan through TWO different tool vocabularies, and which one
9
+ // a run uses depends on the CLI build, not on anything the harness controls:
10
+ //
11
+ // - `TodoWrite` — one call carrying the WHOLE list (`todos[]`), each entry with its own
12
+ // status. Every call is a complete snapshot, so the last one wins.
13
+ // - `TaskCreate` / `TaskUpdate` — an incremental, id-keyed task list. `TaskCreate` appends a
14
+ // task and the CLI assigns its id in the tool RESULT; `TaskUpdate` moves one task by id.
15
+ //
16
+ // Both are live in the shipped schema (`sdk-tools.d.ts` in `@anthropic-ai/claude-code` declares
17
+ // `TodoWriteInput` AND `TaskCreateInput`/`TaskUpdateInput`), so the harness tracks both rather
18
+ // than betting on one. Reading only `TodoWrite` is what pinned a CLI 2.1.x pr-review at 0%:
19
+ // the run planned entirely through `TaskCreate`/`TaskUpdate` and the harness saw nothing.
20
+ //
21
+ // Everything here is best-effort and defensive: an unknown status, a missing id, or a result
22
+ // string the CLI reworded degrades to "no progress from this signal" rather than throwing. The
23
+ // tool vocabulary is not a stable contract, so this module may only ever ADD signal.
24
+
25
+ /** Statuses a plan entry can carry; anything unrecognised is treated as not-yet-started. */
26
+ export function normalizeStatus(status: unknown): 'pending' | 'in_progress' | 'completed' {
27
+ if (status === 'completed') return 'completed'
28
+ if (status === 'in_progress') return 'in_progress'
29
+ return 'pending'
30
+ }
31
+
32
+ /** Roll a label+status list up into the counts the board renders. Shared by every plan shape. */
33
+ export function toProgress(items: { label: string; status: ReturnType<typeof normalizeStatus> }[]) {
34
+ return {
35
+ completed: items.filter((i) => i.status === 'completed').length,
36
+ inProgress: items.filter((i) => i.status === 'in_progress').length,
37
+ total: items.length,
38
+ items,
39
+ }
40
+ }
41
+
42
+ /** Map a `TodoWrite` call's `todos` array onto subtask counts. Each call is a full snapshot. */
43
+ export function todosToProgress(todos: unknown): TodoProgress | undefined {
44
+ if (!Array.isArray(todos)) return undefined
45
+ return toProgress(
46
+ todos.filter(isObject).map((t) => ({
47
+ label: typeof t.content === 'string' ? t.content : String(t.content ?? ''),
48
+ status: normalizeStatus(t.status),
49
+ })),
50
+ )
51
+ }
52
+
53
+ /**
54
+ * The id the CLI assigned to a just-created task, read from `TaskCreate`'s tool RESULT.
55
+ *
56
+ * `TaskCreate`'s INPUT carries only `{subject, description}` — the id is minted by the CLI and
57
+ * comes back on the result, so pairing a later `TaskUpdate({taskId})` to the task it created
58
+ * requires reading the result text. The CLI's shipped `TaskCreateOutput` is
59
+ * `{task: {id, subject}}`, but the parent stream's `tool_result` block carries the rendered
60
+ * STRING (`"Task #1 created successfully: <subject>"`), so both shapes are accepted.
61
+ */
62
+ export function parseCreatedTaskId(content: unknown): string | undefined {
63
+ if (isObject(content)) {
64
+ const task = isObject(content.task) ? content.task : undefined
65
+ const id = task?.id
66
+ if (typeof id === 'string' && id.trim()) return id.trim()
67
+ if (typeof id === 'number') return String(id)
68
+ }
69
+ const text =
70
+ typeof content === 'string'
71
+ ? content
72
+ : Array.isArray(content)
73
+ ? content
74
+ .filter(isObject)
75
+ .map((b) => (typeof b.text === 'string' ? b.text : ''))
76
+ .join('\n')
77
+ : ''
78
+ return /\bTask\s+#(\d+)\b/i.exec(text)?.[1]
79
+ }
80
+
81
+ interface PlannedTask {
82
+ id: string
83
+ label: string
84
+ status: ReturnType<typeof normalizeStatus>
85
+ }
86
+
87
+ /**
88
+ * Tracks the parent's incremental `TaskCreate` / `TaskUpdate` plan.
89
+ *
90
+ * A `TaskCreate` is registered as pending against its tool_use id, then bound to the CLI-assigned
91
+ * task id when its result arrives; `TaskUpdate` moves the bound task. A create whose result is
92
+ * never seen (or whose id can't be parsed) still counts toward `total` under a synthetic key, so
93
+ * the plan size stays honest even when the pairing fails — it simply can never advance.
94
+ *
95
+ * `deleted` tombstones are dropped from the list entirely (matching `TodoWrite`'s live-tasks-only
96
+ * shape), so a task the agent abandons doesn't hold the bar back forever.
97
+ */
98
+ export interface TaskPlanTracker {
99
+ /** Feed an `assistant` message's content blocks: registers creates + applies updates. */
100
+ onAssistant(content: unknown[]): void
101
+ /** Feed a `user` message's content blocks: binds each create to its CLI-assigned task id. */
102
+ onUser(content: unknown[]): void
103
+ /** The plan as progress counts, or undefined when nothing has been planned yet. */
104
+ progress(): TodoProgress | undefined
105
+ }
106
+
107
+ export function createTaskPlanTracker(): TaskPlanTracker {
108
+ // Insertion-ordered so `items` render in plan order.
109
+ const tasks = new Map<string, PlannedTask>()
110
+ // tool_use id of an unresolved `TaskCreate` -> the synthetic key it was filed under, so the
111
+ // task can be re-keyed to its real id once the result lands.
112
+ const pendingCreates = new Map<string, string>()
113
+ // Updates that arrived before their target was bound (the CLI can interleave), replayed on bind.
114
+ const orphanUpdates = new Map<string, Partial<PlannedTask>>()
115
+ // `deleted` tombstones for a task id whose create has not bound yet, replayed on bind — else a
116
+ // delete that races ahead of its create leaves the task in the plan forever.
117
+ const pendingDeletes = new Set<string>()
118
+
119
+ const apply = (task: PlannedTask, patch: Partial<PlannedTask>): void => {
120
+ if (patch.label) task.label = patch.label
121
+ if (patch.status) task.status = patch.status
122
+ }
123
+
124
+ // Drop a tombstoned task. When it isn't present yet (its create hasn't bound), remember the
125
+ // tombstone so the bind drops it rather than leaving it stuck in the plan forever.
126
+ const markDeleted = (taskId: string): void => {
127
+ if (!tasks.delete(taskId)) pendingDeletes.add(taskId)
128
+ orphanUpdates.delete(taskId)
129
+ }
130
+
131
+ return {
132
+ onAssistant(content) {
133
+ if (!Array.isArray(content)) return
134
+ for (const block of content) {
135
+ if (!isObject(block) || block.type !== 'tool_use') continue
136
+ const input = isObject(block.input) ? block.input : {}
137
+ if (block.name === 'TaskCreate') {
138
+ const toolUseId = typeof block.id === 'string' ? block.id : undefined
139
+ if (!toolUseId || pendingCreates.has(toolUseId)) continue
140
+ const label =
141
+ (typeof input.subject === 'string' && input.subject.trim()) ||
142
+ (typeof input.description === 'string' && input.description.trim()) ||
143
+ `Task ${tasks.size + 1}`
144
+ const key = `pending:${toolUseId}`
145
+ tasks.set(key, { id: key, label, status: 'pending' })
146
+ pendingCreates.set(toolUseId, key)
147
+ } else if (block.name === 'TaskUpdate') {
148
+ const taskId = typeof input.taskId === 'string' ? input.taskId : undefined
149
+ if (!taskId) continue
150
+ const patch: Partial<PlannedTask> = {}
151
+ if (typeof input.subject === 'string' && input.subject.trim())
152
+ patch.label = input.subject.trim()
153
+ if (input.status === 'deleted') {
154
+ // `deleted` is a tombstone, not a status — drop the task from the live list.
155
+ markDeleted(taskId)
156
+ continue
157
+ }
158
+ if (input.status !== undefined) patch.status = normalizeStatus(input.status)
159
+ const task = tasks.get(taskId)
160
+ if (task) apply(task, patch)
161
+ else orphanUpdates.set(taskId, { ...orphanUpdates.get(taskId), ...patch })
162
+ }
163
+ }
164
+ },
165
+ onUser(content) {
166
+ if (!Array.isArray(content)) return
167
+ for (const block of content) {
168
+ if (!isObject(block) || block.type !== 'tool_result') continue
169
+ const toolUseId = typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined
170
+ const key = toolUseId ? pendingCreates.get(toolUseId) : undefined
171
+ if (!key) continue
172
+ const taskId = parseCreatedTaskId(block.content)
173
+ pendingCreates.delete(toolUseId!)
174
+ const task = tasks.get(key)
175
+ // No parsable id ⇒ leave it filed under its synthetic key: it still counts toward the
176
+ // plan total, it just can never be advanced by a later `TaskUpdate`. A parsed id that
177
+ // already names a live task (a duplicate / misparse) is also left under the synthetic key
178
+ // rather than overwriting that task — the rebuild below would otherwise drop a row and
179
+ // undercount `total`.
180
+ if (!taskId || !task || taskId === key || tasks.has(taskId)) continue
181
+ // Re-key in place. Rebuilding the map preserves insertion order, which `items` relies on.
182
+ const entries = [...tasks.entries()]
183
+ tasks.clear()
184
+ for (const [k, v] of entries) {
185
+ if (k !== key) tasks.set(k, v)
186
+ else tasks.set(taskId, { ...v, id: taskId })
187
+ }
188
+ // A tombstone that raced ahead of this bind drops the task now that it exists.
189
+ if (pendingDeletes.delete(taskId)) {
190
+ tasks.delete(taskId)
191
+ orphanUpdates.delete(taskId)
192
+ continue
193
+ }
194
+ const pendingPatch = orphanUpdates.get(taskId)
195
+ if (pendingPatch) {
196
+ const bound = tasks.get(taskId)
197
+ if (bound) apply(bound, pendingPatch)
198
+ orphanUpdates.delete(taskId)
199
+ }
200
+ }
201
+ },
202
+ progress() {
203
+ if (tasks.size === 0) return undefined
204
+ return toProgress([...tasks.values()].map((t) => ({ label: t.label, status: t.status })))
205
+ },
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Reconcile the redundant views of the same work into the one to surface (ADR 0027 Defect B).
211
+ * A pr-reviewer run has BOTH a parent plan (`TodoWrite` or `TaskCreate`/`TaskUpdate`) and the
212
+ * `SliceTracker`'s subagent-dispatch view. The sequential shape advances the plan; the parallel
213
+ * shape advances ONLY the slice tracker (the reviewer writes its plan once and the parallel
214
+ * subagents report in-flight/complete). Neither alone covers both shapes, and gating the slice
215
+ * tracker off whenever a plan exists (the original behaviour) pinned parallel runs at 0%.
216
+ *
217
+ * So prefer whichever view is further along: more `completed`, then more `inProgress` (an
218
+ * all-pending plan must not beat live in-flight slices), then more `total` (the richer view — a
219
+ * plan can carry an extra "aggregate" entry), else the plan. Pure + total; returns whichever
220
+ * single input is present when only one is.
221
+ */
222
+ export function pickProgress(
223
+ todo: TodoProgress | undefined,
224
+ slice: TodoProgress | undefined,
225
+ ): TodoProgress | undefined {
226
+ if (!todo) return slice
227
+ if (!slice) return todo
228
+ if (slice.completed !== todo.completed) return slice.completed > todo.completed ? slice : todo
229
+ if (slice.inProgress !== todo.inProgress) return slice.inProgress > todo.inProgress ? slice : todo
230
+ if (slice.total !== todo.total) return slice.total > todo.total ? slice : todo
231
+ return todo
232
+ }
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 tool-span buffer (drain-on-read). The GET /jobs/{id}
248
- * handler is the sole caller, so each poll returns the spans accumulated since the
249
- * previous poll and clears them, bounding the harness buffer to one poll interval.
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 { HarnessCallMetric, TodoProgress } from './pi.js'
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
@@ -19,10 +19,10 @@ import type { HarnessCallMetric, TodoProgress } from './pi.js'
19
19
  // subagent parallelism:
20
20
  //
21
21
  // - {@link createSliceTracker} derives the slice plan + per-slice progress from the
22
- // PARENT stream alone — the `Task` tool_use dispatch and its terminal tool_result
22
+ // PARENT stream alone — the subagent-dispatch tool_use and its terminal tool_result
23
23
  // DO appear there (only the subagent's intermediate turns don't), so slices/progress
24
- // need no file watching (D2.1). {@link pickProgress} reconciles it with any parent
25
- // TodoWrite plan (ADR 0027 Defect B);
24
+ // need no file watching (D2.1). `pickProgress` (./progress.ts) reconciles it with the
25
+ // parent's own plan (ADR 0027 Defect B);
26
26
  // - {@link startSubagentWatcher} tails the `subagents/*.jsonl` transcripts for the
27
27
  // heartbeat (any new bytes ⇒ `onActivity`) and sums each subagent turn's usage into
28
28
  // the run's telemetry (D3).
@@ -42,17 +42,32 @@ import type { HarnessCallMetric, TodoProgress } from './pi.js'
42
42
  // Slice / progress tracking off the PARENT stream (D2.1)
43
43
  // ---------------------------------------------------------------------------
44
44
 
45
+ /**
46
+ * The tool names the Claude Code CLI dispatches a parallel subagent under. `Agent` is what the
47
+ * shipped schema declares (`AgentInput` in `sdk-tools.d.ts`, carrying `description` / `prompt` /
48
+ * `subagent_type`); `Task` is the older name for the same dispatch. Both are matched because the
49
+ * harness runs against whatever CLI the image happens to bundle, and matching only the old name
50
+ * is what left a CLI 2.1.x pr-review reporting no slices at all.
51
+ *
52
+ * Note the asymmetry: keeping the legacy `Task` here is the one place a CLI rename could produce a
53
+ * FALSE signal rather than merely no signal — if a future build were to name a plain task-list
54
+ * tool `Task`, its writes would be counted as in-flight slices. We accept that because no shipped
55
+ * build does (the incremental plan tool is `TaskCreate`/`TaskUpdate`, tracked separately in
56
+ * `progress.ts`), and dropping legacy coverage is the more likely regression.
57
+ */
58
+ const SUBAGENT_TOOL_NAMES = new Set(['Agent', 'Task'])
59
+
45
60
  interface TrackedSlice {
46
- /** The `Task` tool_use id, used to pair the terminal tool_result. */
61
+ /** The dispatch's tool_use id, used to pair the terminal tool_result. */
47
62
  toolUseId: string
48
63
  /** The subagent's description (`Review <slice> slice`), rendered as the progress label. */
49
64
  description: string
50
65
  done: boolean
51
66
  }
52
67
 
53
- /** Tracks parallel `Task` subagents seen on the parent stream to derive slice progress. */
68
+ /** Tracks parallel subagents seen on the parent stream to derive slice progress. */
54
69
  export interface SliceTracker {
55
- /** Feed an `assistant` message's content blocks: registers any `Task` dispatches. */
70
+ /** Feed an `assistant` message's content blocks: registers any subagent dispatches. */
56
71
  onAssistant(content: unknown[]): void
57
72
  /** Feed a `user` message's content blocks: marks the paired subagent(s) complete. */
58
73
  onUser(content: unknown[]): void
@@ -60,8 +75,8 @@ export interface SliceTracker {
60
75
  hasSlices(): boolean
61
76
  /**
62
77
  * Progress derived from the dispatched subagents (completed / in-flight / total),
63
- * or undefined when none have been dispatched. Reconciled with any parent TodoWrite
64
- * plan by {@link pickProgress} — it is NOT gated off by the presence of a todo plan
78
+ * or undefined when none have been dispatched. Reconciled with the parent's own plan
79
+ * by `pickProgress` (./progress.ts) — it is NOT gated off by the presence of a plan
65
80
  * (that gate was ADR 0027 Defect B: the pr-reviewer prompt writes the plan ONCE at
66
81
  * grouping time and never marks it done, which used to permanently mask this signal).
67
82
  */
@@ -76,7 +91,8 @@ export function createSliceTracker(): SliceTracker {
76
91
  onAssistant(content) {
77
92
  if (!Array.isArray(content)) return
78
93
  for (const block of content) {
79
- if (!isObject(block) || block.type !== 'tool_use' || block.name !== 'Task') continue
94
+ if (!isObject(block) || block.type !== 'tool_use') continue
95
+ if (typeof block.name !== 'string' || !SUBAGENT_TOOL_NAMES.has(block.name)) continue
80
96
  const id = typeof block.id === 'string' ? block.id : undefined
81
97
  if (!id || slices.has(id)) continue
82
98
  const input = isObject(block.input) ? block.input : {}
@@ -116,31 +132,6 @@ export function createSliceTracker(): SliceTracker {
116
132
  }
117
133
  }
118
134
 
119
- /**
120
- * Reconcile the two redundant views of the same slice work into the one to surface
121
- * (ADR 0027 Defect B). A pr-reviewer run has BOTH a parent `TodoWrite` plan (the slices,
122
- * written once at grouping time) and the {@link SliceTracker}'s `Task`-dispatch view. The
123
- * sequential shape advances the todo plan; the parallel-subagent shape advances ONLY the
124
- * slice tracker (the CLI writes the plan once and never marks it done, while the parallel
125
- * `Task`s report in-flight/complete). Neither alone covers both shapes, and gating the
126
- * slice tracker OFF whenever a todo plan exists (the old behaviour) pinned parallel runs
127
- * at 0%. So prefer whichever view is further along: more `completed`, then more
128
- * `inProgress` (an all-pending todo plan must not beat live in-flight slices), then more
129
- * `total` (the richer view — the todo plan can carry an extra "aggregate" entry), else the
130
- * todo plan. Pure + total; returns whichever single input is present when only one is.
131
- */
132
- export function pickProgress(
133
- todo: TodoProgress | undefined,
134
- slice: TodoProgress | undefined,
135
- ): TodoProgress | undefined {
136
- if (!todo) return slice
137
- if (!slice) return todo
138
- if (slice.completed !== todo.completed) return slice.completed > todo.completed ? slice : todo
139
- if (slice.inProgress !== todo.inProgress) return slice.inProgress > todo.inProgress ? slice : todo
140
- if (slice.total !== todo.total) return slice.total > todo.total ? slice : todo
141
- return todo
142
- }
143
-
144
135
  // ---------------------------------------------------------------------------
145
136
  // Subagent transcript watcher (heartbeat + usage) (D3)
146
137
  // ---------------------------------------------------------------------------
@@ -155,6 +146,13 @@ export interface SubagentWatcherOptions {
155
146
  secrets?: string[]
156
147
  /** Fallback model id stamped on a subagent call whose transcript omits one. */
157
148
  model?: string
149
+ /**
150
+ * Streams each lifted subagent call to the live telemetry drain (the run's `RunOptions`
151
+ * hook). Subagent work is exactly where a long review spends most of its tokens, and it is
152
+ * the phase the parent stream goes quiet for — so without this a run killed mid-fan-out
153
+ * reports nothing at all.
154
+ */
155
+ onCallMetric?: (call: HarnessCallMetric) => void
158
156
  /** Poll cadence (ms); overridable for tests. */
159
157
  intervalMs?: number
160
158
  log?: Logger
@@ -244,23 +242,27 @@ export function startSubagentWatcher(root: string, opts: SubagentWatcherOptions)
244
242
  if (u.inputTokens === 0 && u.outputTokens === 0) return
245
243
  const content = Array.isArray(message.content) ? message.content : []
246
244
  const { text, reasoning } = claudeAssistantContent(content)
247
- calls.push({
248
- ...(typeof message.model === 'string'
249
- ? { model: message.model }
250
- : opts.model
251
- ? { model: opts.model }
252
- : {}),
253
- // The subagent's own transcript isn't a re-sendable prompt chain, so we don't
254
- // reconstruct the request side (kept empty); the response + tokens are faithful.
255
- promptText: '',
256
- messageCount: 0,
257
- responseText: redactBody(text, secrets),
258
- reasoningText: redactBody(reasoning, secrets),
259
- inputTokens: u.inputTokens,
260
- cachedInputTokens: u.cachedInputTokens,
261
- outputTokens: u.outputTokens,
262
- finishReason: typeof message.stop_reason === 'string' ? message.stop_reason : null,
263
- })
245
+ publishCallMetric(
246
+ calls,
247
+ {
248
+ ...(typeof message.model === 'string'
249
+ ? { model: message.model }
250
+ : opts.model
251
+ ? { model: opts.model }
252
+ : {}),
253
+ // The subagent's own transcript isn't a re-sendable prompt chain, so we don't
254
+ // reconstruct the request side (kept empty); the response + tokens are faithful.
255
+ promptText: '',
256
+ messageCount: 0,
257
+ responseText: redactBody(text, secrets),
258
+ reasoningText: redactBody(reasoning, secrets),
259
+ inputTokens: u.inputTokens,
260
+ cachedInputTokens: u.cachedInputTokens,
261
+ outputTokens: u.outputTokens,
262
+ finishReason: typeof message.stop_reason === 'string' ? message.stop_reason : null,
263
+ },
264
+ opts.onCallMetric,
265
+ )
264
266
  usage.inputTokens += u.inputTokens
265
267
  usage.outputTokens += u.outputTokens
266
268
  }