@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 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:
@@ -3,9 +3,11 @@ 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
- import { createSliceTracker, pickProgress, startSubagentWatcher } from './subagents.js';
9
+ import { createSliceTracker, startSubagentWatcher } from './subagents.js';
10
+ import { createTaskPlanTracker, normalizeStatus, pickProgress, toProgress, todosToProgress, } from './progress.js';
9
11
  import { assertOnboardingKeysCurrent, writeOnboardingPreseed } from './onboarding-preseed.js';
10
12
  import { retainSessionTranscripts } from './transcript-retention.js';
11
13
  /**
@@ -219,19 +221,28 @@ export async function runClaudeCode(opts) {
219
221
  { role: 'user', content: opts.userPrompt },
220
222
  ];
221
223
  const calls = [];
224
+ // Streams each call as the CLI yields it, EXCEPT one whose tokens `attributeCumulativeUsage`
225
+ // may still rewrite below (a published call must be final — see the publisher).
226
+ const publisher = createCallMetricPublisher(calls, opts.onCallMetric);
222
227
  // ADR 0026 D2.1 + ADR 0027 Defect B: surface live slice progress from TWO reconciled
223
- // sources. The parent's `Task` dispatches + their terminal tool_results DO appear on this
228
+ // sources. The parent's subagent dispatches + their terminal tool_results DO appear on this
224
229
  // stream (only a subagent's intermediate turns don't), so `sliceTracker` derives per-slice
225
- // progress for the parallel-subagent shape; a parent `TodoWrite` plan (the sequential
226
- // shape) is tracked in `lastTodo`. `pickProgress` picks whichever is further along on each
227
- // update, so neither masks the other — the pr-reviewer prompt writes its todo plan ONCE
228
- // and never marks it done, which used to gate the slice signal off and pin progress at 0%.
230
+ // progress for the parallel shape; the parent's own plan (the sequential shape) is tracked
231
+ // by `planTracker` + `lastTodo`. `pickProgress` picks whichever is further along on each
232
+ // update, so neither masks the other — the pr-reviewer prompt writes its plan ONCE and never
233
+ // marks it done, which used to gate the slice signal off and pin progress at 0%.
234
+ //
235
+ // The plan arrives in one of two tool vocabularies depending on the bundled CLI build:
236
+ // `TodoWrite` (whole-list snapshots, tracked in `lastTodo`) or the incremental
237
+ // `TaskCreate`/`TaskUpdate` pair (tracked by `planTracker`, which needs the tool RESULTS too
238
+ // because the task id is minted there). Both are read — see ./progress.ts.
229
239
  const sliceTracker = createSliceTracker();
240
+ const planTracker = createTaskPlanTracker();
230
241
  let lastTodo;
231
242
  const emitProgress = () => {
232
243
  if (!opts.onProgress)
233
244
  return;
234
- const progress = pickProgress(lastTodo, sliceTracker.progress());
245
+ const progress = pickProgress(pickProgress(lastTodo, planTracker.progress()), sliceTracker.progress());
235
246
  if (progress)
236
247
  opts.onProgress(progress);
237
248
  };
@@ -251,12 +262,13 @@ export async function runClaudeCode(opts) {
251
262
  }
252
263
  }
253
264
  sliceTracker.onAssistant(content);
265
+ planTracker.onAssistant(content);
254
266
  emitProgress();
255
267
  // Record this call BEFORE appending its turn: the prompt is the history that
256
268
  // produced this response. The append-only array keeps each call's prompt a strict
257
269
  // prefix of the next, so the backend's telemetry chain delta-compresses cleanly.
258
270
  const u = claudeCallUsage(message.usage);
259
- calls.push({
271
+ publisher.publish({
260
272
  ...(typeof message.model === 'string' ? { model: message.model } : {}),
261
273
  promptText: redactBody(JSON.stringify(messages), secrets),
262
274
  messageCount: messages.length,
@@ -274,6 +286,7 @@ export async function runClaudeCode(opts) {
274
286
  const content = event.message.content;
275
287
  if (Array.isArray(content)) {
276
288
  sliceTracker.onUser(content);
289
+ planTracker.onUser(content);
277
290
  emitProgress();
278
291
  messages.push({ role: 'tool', content });
279
292
  }
@@ -333,6 +346,7 @@ export async function runClaudeCode(opts) {
333
346
  ...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
334
347
  secrets,
335
348
  model: opts.model,
349
+ ...(opts.onCallMetric ? { onCallMetric: opts.onCallMetric } : {}),
336
350
  ...(opts.log ? { log: opts.log } : {}),
337
351
  })
338
352
  : undefined;
@@ -355,7 +369,15 @@ export async function runClaudeCode(opts) {
355
369
  ...appendArgs,
356
370
  ],
357
371
  }, prompt, opts, env, opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [], onEvent);
358
- return await assembleClaudeOutcome({ summary, stats, stderrTail, calls, usage, subagents });
372
+ return await assembleClaudeOutcome({
373
+ summary,
374
+ stats,
375
+ stderrTail,
376
+ calls,
377
+ publisher,
378
+ usage,
379
+ subagents,
380
+ });
359
381
  }
360
382
  finally {
361
383
  await subagents?.stop();
@@ -401,10 +423,14 @@ function buildClaudeEnv(opts, configHome) {
401
423
  * {@link runClaudeCode} verbatim to keep its cyclomatic complexity down.
402
424
  */
403
425
  async function assembleClaudeOutcome(args) {
404
- const { summary, stats, stderrTail, calls, usage, subagents } = args;
426
+ const { summary, stats, stderrTail, calls, publisher, usage, subagents } = args;
405
427
  // The parent's cumulative-usage fallback applies to the PARENT calls only (before the
406
428
  // subagent calls, which carry their own per-turn tokens, are concatenated).
407
429
  attributeCumulativeUsage(calls, usage);
430
+ // The withheld calls are final only NOW, so stream them: the completion poll drains them
431
+ // alongside the result, and the backend records the attributed numbers rather than the zeros
432
+ // they carried while the run was in flight.
433
+ publisher.flush();
408
434
  // Final drain of any subagent transcript writes that landed after the last poll, then
409
435
  // fold the subagents' usage + per-call telemetry into the run's outcome.
410
436
  await subagents?.stop();
@@ -425,25 +451,6 @@ async function assembleClaudeOutcome(args) {
425
451
  ...(mergedCalls.length ? { callMetrics: mergedCalls } : {}),
426
452
  };
427
453
  }
428
- /** Map Claude Code's `TodoWrite` todos array onto subtask counts. */
429
- function todosToProgress(todos) {
430
- if (!Array.isArray(todos))
431
- return undefined;
432
- const items = todos.filter(isObject).map((t) => ({
433
- label: typeof t.content === 'string' ? t.content : String(t.content ?? ''),
434
- status: normalizeStatus(t.status),
435
- }));
436
- const completed = items.filter((i) => i.status === 'completed').length;
437
- const inProgress = items.filter((i) => i.status === 'in_progress').length;
438
- return { completed, inProgress, total: items.length, items };
439
- }
440
- function normalizeStatus(status) {
441
- if (status === 'completed')
442
- return 'completed';
443
- if (status === 'in_progress')
444
- return 'in_progress';
445
- return 'pending';
446
- }
447
454
  function claudeUsage(raw) {
448
455
  if (!isObject(raw))
449
456
  return undefined;
@@ -537,7 +544,7 @@ export async function runCodex(opts) {
537
544
  // assistant text seen since the previous turn as one telemetry call.
538
545
  const perTurn = codexLastTurnUsage(event);
539
546
  if (perTurn) {
540
- calls.push({
547
+ publishCallMetric(calls, {
541
548
  model: opts.model,
542
549
  promptText: redactBody(JSON.stringify(messages), secrets),
543
550
  messageCount: messages.length,
@@ -547,7 +554,7 @@ export async function runCodex(opts) {
547
554
  cachedInputTokens: perTurn.cachedInputTokens,
548
555
  outputTokens: perTurn.outputTokens,
549
556
  finishReason: null,
550
- });
557
+ }, opts.onCallMetric);
551
558
  if (pendingText)
552
559
  messages.push({ role: 'assistant', content: pendingText });
553
560
  pendingText = '';
@@ -571,7 +578,7 @@ export async function runCodex(opts) {
571
578
  // Fallback for a CLI/version that never emits per-turn `last_token_usage`: record a
572
579
  // single call from the cumulative total + final text so the run is still observable.
573
580
  if (calls.length === 0 && (usage || summary)) {
574
- calls.push({
581
+ publishCallMetric(calls, {
575
582
  model: opts.model,
576
583
  promptText: redactBody(JSON.stringify(messages), secrets),
577
584
  messageCount: messages.length,
@@ -581,7 +588,7 @@ export async function runCodex(opts) {
581
588
  cachedInputTokens: 0,
582
589
  outputTokens: usage?.outputTokens ?? 0,
583
590
  finishReason: null,
584
- });
591
+ }, opts.onCallMetric);
585
592
  }
586
593
  return {
587
594
  summary,
@@ -653,9 +660,7 @@ function codexPlanProgress(event) {
653
660
  }));
654
661
  if (items.length === 0)
655
662
  return undefined;
656
- const completed = items.filter((i) => i.status === 'completed').length;
657
- const inProgress = items.filter((i) => i.status === 'in_progress').length;
658
- return { completed, inProgress, total: items.length, items };
663
+ return toProgress(items);
659
664
  }
660
665
  /**
661
666
  * Best-effort: pull token usage out of a Codex usage event. Codex `exec --json`
@@ -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.
@@ -0,0 +1,217 @@
1
+ import { isObject } from './claude-stream.js';
2
+ // The parent agent's own PLAN, as progress counts. This is one of the two redundant views a
3
+ // pr-reviewer run produces (the other is the parallel-subagent dispatch view in
4
+ // `subagents.ts`); {@link pickProgress} reconciles them.
5
+ //
6
+ // The Claude Code CLI exposes the plan through TWO different tool vocabularies, and which one
7
+ // a run uses depends on the CLI build, not on anything the harness controls:
8
+ //
9
+ // - `TodoWrite` — one call carrying the WHOLE list (`todos[]`), each entry with its own
10
+ // status. Every call is a complete snapshot, so the last one wins.
11
+ // - `TaskCreate` / `TaskUpdate` — an incremental, id-keyed task list. `TaskCreate` appends a
12
+ // task and the CLI assigns its id in the tool RESULT; `TaskUpdate` moves one task by id.
13
+ //
14
+ // Both are live in the shipped schema (`sdk-tools.d.ts` in `@anthropic-ai/claude-code` declares
15
+ // `TodoWriteInput` AND `TaskCreateInput`/`TaskUpdateInput`), so the harness tracks both rather
16
+ // than betting on one. Reading only `TodoWrite` is what pinned a CLI 2.1.x pr-review at 0%:
17
+ // the run planned entirely through `TaskCreate`/`TaskUpdate` and the harness saw nothing.
18
+ //
19
+ // Everything here is best-effort and defensive: an unknown status, a missing id, or a result
20
+ // string the CLI reworded degrades to "no progress from this signal" rather than throwing. The
21
+ // tool vocabulary is not a stable contract, so this module may only ever ADD signal.
22
+ /** Statuses a plan entry can carry; anything unrecognised is treated as not-yet-started. */
23
+ export function normalizeStatus(status) {
24
+ if (status === 'completed')
25
+ return 'completed';
26
+ if (status === 'in_progress')
27
+ return 'in_progress';
28
+ return 'pending';
29
+ }
30
+ /** Roll a label+status list up into the counts the board renders. Shared by every plan shape. */
31
+ export function toProgress(items) {
32
+ return {
33
+ completed: items.filter((i) => i.status === 'completed').length,
34
+ inProgress: items.filter((i) => i.status === 'in_progress').length,
35
+ total: items.length,
36
+ items,
37
+ };
38
+ }
39
+ /** Map a `TodoWrite` call's `todos` array onto subtask counts. Each call is a full snapshot. */
40
+ export function todosToProgress(todos) {
41
+ if (!Array.isArray(todos))
42
+ return undefined;
43
+ return toProgress(todos.filter(isObject).map((t) => ({
44
+ label: typeof t.content === 'string' ? t.content : String(t.content ?? ''),
45
+ status: normalizeStatus(t.status),
46
+ })));
47
+ }
48
+ /**
49
+ * The id the CLI assigned to a just-created task, read from `TaskCreate`'s tool RESULT.
50
+ *
51
+ * `TaskCreate`'s INPUT carries only `{subject, description}` — the id is minted by the CLI and
52
+ * comes back on the result, so pairing a later `TaskUpdate({taskId})` to the task it created
53
+ * requires reading the result text. The CLI's shipped `TaskCreateOutput` is
54
+ * `{task: {id, subject}}`, but the parent stream's `tool_result` block carries the rendered
55
+ * STRING (`"Task #1 created successfully: <subject>"`), so both shapes are accepted.
56
+ */
57
+ export function parseCreatedTaskId(content) {
58
+ if (isObject(content)) {
59
+ const task = isObject(content.task) ? content.task : undefined;
60
+ const id = task?.id;
61
+ if (typeof id === 'string' && id.trim())
62
+ return id.trim();
63
+ if (typeof id === 'number')
64
+ return String(id);
65
+ }
66
+ const text = typeof content === 'string'
67
+ ? content
68
+ : Array.isArray(content)
69
+ ? content
70
+ .filter(isObject)
71
+ .map((b) => (typeof b.text === 'string' ? b.text : ''))
72
+ .join('\n')
73
+ : '';
74
+ return /\bTask\s+#(\d+)\b/i.exec(text)?.[1];
75
+ }
76
+ export function createTaskPlanTracker() {
77
+ // Insertion-ordered so `items` render in plan order.
78
+ const tasks = new Map();
79
+ // tool_use id of an unresolved `TaskCreate` -> the synthetic key it was filed under, so the
80
+ // task can be re-keyed to its real id once the result lands.
81
+ const pendingCreates = new Map();
82
+ // Updates that arrived before their target was bound (the CLI can interleave), replayed on bind.
83
+ const orphanUpdates = new Map();
84
+ // `deleted` tombstones for a task id whose create has not bound yet, replayed on bind — else a
85
+ // delete that races ahead of its create leaves the task in the plan forever.
86
+ const pendingDeletes = new Set();
87
+ const apply = (task, patch) => {
88
+ if (patch.label)
89
+ task.label = patch.label;
90
+ if (patch.status)
91
+ task.status = patch.status;
92
+ };
93
+ // Drop a tombstoned task. When it isn't present yet (its create hasn't bound), remember the
94
+ // tombstone so the bind drops it rather than leaving it stuck in the plan forever.
95
+ const markDeleted = (taskId) => {
96
+ if (!tasks.delete(taskId))
97
+ pendingDeletes.add(taskId);
98
+ orphanUpdates.delete(taskId);
99
+ };
100
+ return {
101
+ onAssistant(content) {
102
+ if (!Array.isArray(content))
103
+ return;
104
+ for (const block of content) {
105
+ if (!isObject(block) || block.type !== 'tool_use')
106
+ continue;
107
+ const input = isObject(block.input) ? block.input : {};
108
+ if (block.name === 'TaskCreate') {
109
+ const toolUseId = typeof block.id === 'string' ? block.id : undefined;
110
+ if (!toolUseId || pendingCreates.has(toolUseId))
111
+ continue;
112
+ const label = (typeof input.subject === 'string' && input.subject.trim()) ||
113
+ (typeof input.description === 'string' && input.description.trim()) ||
114
+ `Task ${tasks.size + 1}`;
115
+ const key = `pending:${toolUseId}`;
116
+ tasks.set(key, { id: key, label, status: 'pending' });
117
+ pendingCreates.set(toolUseId, key);
118
+ }
119
+ else if (block.name === 'TaskUpdate') {
120
+ const taskId = typeof input.taskId === 'string' ? input.taskId : undefined;
121
+ if (!taskId)
122
+ continue;
123
+ const patch = {};
124
+ if (typeof input.subject === 'string' && input.subject.trim())
125
+ patch.label = input.subject.trim();
126
+ if (input.status === 'deleted') {
127
+ // `deleted` is a tombstone, not a status — drop the task from the live list.
128
+ markDeleted(taskId);
129
+ continue;
130
+ }
131
+ if (input.status !== undefined)
132
+ patch.status = normalizeStatus(input.status);
133
+ const task = tasks.get(taskId);
134
+ if (task)
135
+ apply(task, patch);
136
+ else
137
+ orphanUpdates.set(taskId, { ...orphanUpdates.get(taskId), ...patch });
138
+ }
139
+ }
140
+ },
141
+ onUser(content) {
142
+ if (!Array.isArray(content))
143
+ return;
144
+ for (const block of content) {
145
+ if (!isObject(block) || block.type !== 'tool_result')
146
+ continue;
147
+ const toolUseId = typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined;
148
+ const key = toolUseId ? pendingCreates.get(toolUseId) : undefined;
149
+ if (!key)
150
+ continue;
151
+ const taskId = parseCreatedTaskId(block.content);
152
+ pendingCreates.delete(toolUseId);
153
+ const task = tasks.get(key);
154
+ // No parsable id ⇒ leave it filed under its synthetic key: it still counts toward the
155
+ // plan total, it just can never be advanced by a later `TaskUpdate`. A parsed id that
156
+ // already names a live task (a duplicate / misparse) is also left under the synthetic key
157
+ // rather than overwriting that task — the rebuild below would otherwise drop a row and
158
+ // undercount `total`.
159
+ if (!taskId || !task || taskId === key || tasks.has(taskId))
160
+ continue;
161
+ // Re-key in place. Rebuilding the map preserves insertion order, which `items` relies on.
162
+ const entries = [...tasks.entries()];
163
+ tasks.clear();
164
+ for (const [k, v] of entries) {
165
+ if (k !== key)
166
+ tasks.set(k, v);
167
+ else
168
+ tasks.set(taskId, { ...v, id: taskId });
169
+ }
170
+ // A tombstone that raced ahead of this bind drops the task now that it exists.
171
+ if (pendingDeletes.delete(taskId)) {
172
+ tasks.delete(taskId);
173
+ orphanUpdates.delete(taskId);
174
+ continue;
175
+ }
176
+ const pendingPatch = orphanUpdates.get(taskId);
177
+ if (pendingPatch) {
178
+ const bound = tasks.get(taskId);
179
+ if (bound)
180
+ apply(bound, pendingPatch);
181
+ orphanUpdates.delete(taskId);
182
+ }
183
+ }
184
+ },
185
+ progress() {
186
+ if (tasks.size === 0)
187
+ return undefined;
188
+ return toProgress([...tasks.values()].map((t) => ({ label: t.label, status: t.status })));
189
+ },
190
+ };
191
+ }
192
+ /**
193
+ * Reconcile the redundant views of the same work into the one to surface (ADR 0027 Defect B).
194
+ * A pr-reviewer run has BOTH a parent plan (`TodoWrite` or `TaskCreate`/`TaskUpdate`) and the
195
+ * `SliceTracker`'s subagent-dispatch view. The sequential shape advances the plan; the parallel
196
+ * shape advances ONLY the slice tracker (the reviewer writes its plan once and the parallel
197
+ * subagents report in-flight/complete). Neither alone covers both shapes, and gating the slice
198
+ * tracker off whenever a plan exists (the original behaviour) pinned parallel runs at 0%.
199
+ *
200
+ * So prefer whichever view is further along: more `completed`, then more `inProgress` (an
201
+ * all-pending plan must not beat live in-flight slices), then more `total` (the richer view — a
202
+ * plan can carry an extra "aggregate" entry), else the plan. Pure + total; returns whichever
203
+ * single input is present when only one is.
204
+ */
205
+ export function pickProgress(todo, slice) {
206
+ if (!todo)
207
+ return slice;
208
+ if (!slice)
209
+ return todo;
210
+ if (slice.completed !== todo.completed)
211
+ return slice.completed > todo.completed ? slice : todo;
212
+ if (slice.inProgress !== todo.inProgress)
213
+ return slice.inProgress > todo.inProgress ? slice : todo;
214
+ if (slice.total !== todo.total)
215
+ return slice.total > todo.total ? slice : todo;
216
+ return todo;
217
+ }
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 tool-span buffer (drain-on-read). The GET /jobs/{id}
85
- * handler is the sole caller, so each poll returns the spans accumulated since the
86
- * previous poll and clears them, bounding the harness buffer to one poll interval.
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
  });