@cat-factory/executor-harness 1.64.0 → 1.64.4

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.
@@ -2,13 +2,14 @@ import { spawn } from 'node:child_process';
2
2
  import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
3
  import { tmpdir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
- import { claudeAssistantContent, claudeCallUsage, isObject, numberOf, redactBody, } from './claude-stream.js';
5
+ import { claudeAssistantContent, isObject, numberOf, redactBody } from './claude-stream.js';
6
+ import { createClaudeRunTelemetry, subagentDispatchId } from './claude-call-aggregator.js';
6
7
  import { createCallMetricPublisher, publishCallMetric, } from './pi.js';
7
8
  import { ProgressGuard } from './progress-guard.js';
8
9
  import { killChildProcess, spawnDetached } from './process.js';
9
10
  import { redact, secretsToRedact } from './redact.js';
10
11
  import { createSliceTracker, startSubagentWatcher } from './subagents.js';
11
- import { createTaskPlanTracker, normalizeStatus, pickProgress, toProgress, todosToProgress, } from './progress.js';
12
+ import { createTaskPlanTracker, mergeProgress, normalizeStatus, pickProgress, toProgress, todosToProgress, } from './progress.js';
12
13
  import { assertOnboardingKeysCurrent, writeOnboardingPreseed } from './onboarding-preseed.js';
13
14
  import { retainSessionTranscripts } from './transcript-retention.js';
14
15
  /**
@@ -214,44 +215,54 @@ export async function runClaudeCode(opts) {
214
215
  });
215
216
  }
216
217
  // Reconstruct the full per-call request/response bodies for telemetry from the
217
- // stream. `--output-format stream-json --verbose` emits each turn as a near-verbatim
218
- // Anthropic Messages envelope, so `assistant` events carry the complete response
219
- // (text + tool_use blocks + usage), and `user` events carry the tool_result blocks
220
- // fed back — together the growing prompt transcript. We seed it with the inputs the
221
- // harness supplies (they never appear in the stream): the system + first user message
222
- // when the prompt rides argv, or a single folded user turn when it doesn't so the
223
- // reconstruction never shows a system turn that was never sent. Bodies are
224
- // credential-scrubbed (they can echo the leased token).
218
+ // stream. `--output-format stream-json --verbose` emits a near-verbatim Anthropic
219
+ // Messages envelope per response CONTENT BLOCK (not per call), so the aggregator below
220
+ // folds the envelopes sharing a `message.id` back into one call and buffers that call's
221
+ // `user` tool_result turns — together the growing prompt transcript, in the shape the
222
+ // model was actually sent. We seed it with the inputs the harness supplies (they never
223
+ // appear in the stream): the system + first user message when the prompt rides argv, or
224
+ // a single folded user turn when it doesn't — so the reconstruction never shows a system
225
+ // turn that was never sent. Bodies are credential-scrubbed (they can echo the leased token).
225
226
  const secrets = opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [];
226
- const messages = folded
227
- ? [{ role: 'user', content: prompt }]
228
- : [
229
- { role: 'system', content: opts.systemPrompt },
230
- { role: 'user', content: opts.userPrompt },
231
- ];
232
227
  const calls = [];
233
228
  // Streams each call as the CLI yields it, EXCEPT one whose tokens `attributeCumulativeUsage`
234
229
  // may still rewrite below (a published call must be final — see the publisher).
235
230
  const publisher = createCallMetricPublisher(calls, opts.onCallMetric);
236
- // ADR 0026 D2.1 + ADR 0027 Defect B: surface live slice progress from TWO reconciled
237
- // sources. The parent's subagent dispatches + their terminal tool_results DO appear on this
238
- // stream (only a subagent's intermediate turns don't), so `sliceTracker` derives per-slice
239
- // progress for the parallel shape; the parent's own plan (the sequential shape) is tracked
240
- // by `planTracker` + `lastTodo`. `pickProgress` picks whichever is further along on each
241
- // update, so neither masks the other — the pr-reviewer prompt writes its plan ONCE and never
242
- // marks it done, which used to gate the slice signal off and pin progress at 0%.
231
+ // `watcherOwnsSubagents` tracks the `startSubagentWatcher` wiring below: it is started only when
232
+ // the CLI has an isolated config home to watch, which an `ambientAuth` run does not have. The
233
+ // telemetry routes the CLI's tagged subagent turns accordingly see `createClaudeRunTelemetry`.
234
+ const telemetry = createClaudeRunTelemetry({
235
+ seed: folded
236
+ ? [{ role: 'user', content: prompt }]
237
+ : [
238
+ { role: 'system', content: opts.systemPrompt },
239
+ { role: 'user', content: opts.userPrompt },
240
+ ],
241
+ secrets,
242
+ watcherOwnsSubagents: !opts.ambientAuth,
243
+ publish: (metric) => publisher.publish(metric),
244
+ });
245
+ // ADR 0026 D2.1 + ADR 0027 Defect B: surface live slice progress from the two views the run
246
+ // produces of the SAME slicing. The parent's subagent dispatches + their terminal tool_results
247
+ // appear on this stream (as do the subagents' own intermediate turns, tagged with the dispatch
248
+ // that spawned them — see `isSubagentEvent`), so `sliceTracker` knows which slices are in flight
249
+ // and which have returned; the parent's own plan (tracked by `planTracker` + `lastTodo`) is the
250
+ // only place a not-yet-dispatched slice is named at all.
243
251
  //
244
252
  // The plan arrives in one of two tool vocabularies depending on the bundled CLI build:
245
253
  // `TodoWrite` (whole-list snapshots, tracked in `lastTodo`) or the incremental
246
254
  // `TaskCreate`/`TaskUpdate` pair (tracked by `planTracker`, which needs the tool RESULTS too
247
- // because the task id is minted there). Both are read see ./progress.ts.
255
+ // because the task id is minted there). Both are read, and `pickProgress` resolves that
256
+ // either/or; the plan then MERGES with the dispatch view (`mergeProgress`) rather than
257
+ // competing with it — picking the further-along view collapsed the list to the dispatched
258
+ // slices alone the moment the first subagent returned. See ./progress.ts.
248
259
  const sliceTracker = createSliceTracker();
249
260
  const planTracker = createTaskPlanTracker();
250
261
  let lastTodo;
251
262
  const emitProgress = () => {
252
263
  if (!opts.onProgress)
253
264
  return;
254
- const progress = pickProgress(pickProgress(lastTodo, planTracker.progress()), sliceTracker.progress());
265
+ const progress = mergeProgress(pickProgress(lastTodo, planTracker.progress()), sliceTracker.progress());
255
266
  if (progress)
256
267
  opts.onProgress(progress);
257
268
  };
@@ -294,12 +305,19 @@ export async function runClaudeCode(opts) {
294
305
  };
295
306
  const onEvent = (event, meta) => {
296
307
  const type = event.type;
308
+ // A subagent's turns ride the parent's stdout tagged with the dispatch that spawned them;
309
+ // `telemetry` routes them off the parent's chain (and decides who bills them). Progress, slice
310
+ // tracking, the guard and `stats` below deliberately see EVERY event: a subagent grinding on
311
+ // errors should trip the guard exactly as the parent would, and whether the agent acted at all
312
+ // does not depend on which channel billed it.
313
+ const dispatchId = subagentDispatchId(event);
297
314
  if (type === 'assistant' && isObject(event.message)) {
298
315
  const message = event.message;
299
316
  const content = Array.isArray(message.content) ? message.content : [];
300
- const { text, reasoning, toolUses } = claudeAssistantContent(content);
317
+ const { text, toolUses } = claudeAssistantContent(content);
301
318
  stats.assistantChars += text.length;
302
319
  stats.toolCalls += toolUses;
320
+ telemetry.onAssistant(dispatchId, message);
303
321
  for (const block of content) {
304
322
  if (!isObject(block) || block.type !== 'tool_use')
305
323
  continue;
@@ -317,22 +335,6 @@ export async function runClaudeCode(opts) {
317
335
  sliceTracker.onAssistant(content);
318
336
  planTracker.onAssistant(content);
319
337
  emitProgress();
320
- // Record this call BEFORE appending its turn: the prompt is the history that
321
- // produced this response. The append-only array keeps each call's prompt a strict
322
- // prefix of the next, so the backend's telemetry chain delta-compresses cleanly.
323
- const u = claudeCallUsage(message.usage);
324
- publisher.publish({
325
- ...(typeof message.model === 'string' ? { model: message.model } : {}),
326
- promptText: redactBody(JSON.stringify(messages), secrets),
327
- messageCount: messages.length,
328
- responseText: redactBody(text, secrets),
329
- reasoningText: redactBody(reasoning, secrets),
330
- inputTokens: u.inputTokens,
331
- cachedInputTokens: u.cachedInputTokens,
332
- outputTokens: u.outputTokens,
333
- finishReason: typeof message.stop_reason === 'string' ? message.stop_reason : null,
334
- });
335
- messages.push({ role: 'assistant', content });
336
338
  }
337
339
  else if (type === 'user' && isObject(event.message)) {
338
340
  // tool_result blocks the harness fed back to the model — part of the next prompt.
@@ -345,7 +347,7 @@ export async function runClaudeCode(opts) {
345
347
  // would kill nothing and only convert a clean exit into a spurious failure.
346
348
  if (!meta?.final)
347
349
  feedGuard(content);
348
- messages.push({ role: 'tool', content });
350
+ telemetry.onToolResult(dispatchId, content);
349
351
  }
350
352
  }
351
353
  else if (type === 'result') {
@@ -431,6 +433,8 @@ export async function runClaudeCode(opts) {
431
433
  ...appendArgs,
432
434
  ],
433
435
  }, prompt, { ...opts, signal: runSignal }, env, opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [], onEvent);
436
+ // The stream has ended, so the last call has no successor envelope to complete it.
437
+ telemetry.flush();
434
438
  return await assembleClaudeOutcome({
435
439
  summary,
436
440
  stats,
@@ -439,9 +443,17 @@ export async function runClaudeCode(opts) {
439
443
  publisher,
440
444
  usage,
441
445
  subagents,
446
+ expectSubagentCalls: telemetry.expectsWatcherCalls(),
447
+ ...(opts.log ? { log: opts.log } : {}),
442
448
  });
443
449
  }
444
450
  catch (err) {
451
+ // The stream ended abnormally (guard trip, watchdog kill, CLI crash). Complete the call in
452
+ // flight anyway, and release whatever the publisher was withholding: a killed run never
453
+ // returns an outcome, so the live channel is the ONLY record of what it spent, and dropping
454
+ // its last turn is what the streaming exists to avoid.
455
+ telemetry.flush();
456
+ publisher.flush();
445
457
  // A tripped no-progress guard aborted the CLI; streamCli rejects with its generic abort
446
458
  // message, so replace it with the guard's actionable diagnostic — carrying the stderr tail
447
459
  // it attached, since that is usually the only evidence of what the CLI was doing when it was
@@ -497,6 +509,10 @@ function buildClaudeEnv(opts, configHome) {
497
509
  * terminal `result` event's cumulative) covers ONLY the parent loop, and the subagent tokens live
498
510
  * exclusively in the per-session `subagents/*.jsonl` transcripts the watcher reads. Extracted from
499
511
  * {@link runClaudeCode} verbatim to keep its cyclomatic complexity down.
512
+ *
513
+ * The invariant is about the aggregate `usage` only. `calls` was NEVER disjoint from the watcher's
514
+ * on its own: the CLI streams a subagent's turns onto the parent's stdout as well, so the parent
515
+ * loop's telemetry must filter them (`subagentDispatchId`) for this concatenation to hold.
500
516
  */
501
517
  async function assembleClaudeOutcome(args) {
502
518
  const { summary, stats, stderrTail, calls, publisher, usage, subagents } = args;
@@ -512,6 +528,10 @@ async function assembleClaudeOutcome(args) {
512
528
  await subagents?.stop();
513
529
  const subUsage = subagents?.usage() ?? { inputTokens: 0, outputTokens: 0 };
514
530
  const subCalls = subagents?.calls() ?? [];
531
+ if (args.expectSubagentCalls && !subCalls.length) {
532
+ args.log?.warn('subagent turns were streamed but the transcript watcher captured no calls; their token ' +
533
+ 'spend is missing from this run’s telemetry (check the CLI’s subagents/*.jsonl layout)');
534
+ }
515
535
  const mergedCalls = [...calls, ...subCalls];
516
536
  const mergedUsage = usage || subUsage.inputTokens || subUsage.outputTokens
517
537
  ? {
@@ -0,0 +1,229 @@
1
+ import { claudeAssistantContent, claudeCallUsage, isObject, redactBody } from './claude-stream.js';
2
+ /**
3
+ * Assemble per-call telemetry out of Claude Code's per-block stream envelopes.
4
+ *
5
+ * `onCallStart` fires when a call's FIRST envelope arrives, which is the moment the caller must
6
+ * snapshot the prompt: the history at that point is what produced the response. `onCall` fires
7
+ * once the call is complete (a different `message.id` began, or the stream ended).
8
+ *
9
+ * Usage is merged as the MAXIMUM of each bucket across the call's envelopes rather than the last
10
+ * one seen. The envelopes carry a snapshot of the same call's usage, and which of them holds the
11
+ * final output count is a CLI detail we should not depend on; a max is right whether the value is
12
+ * repeated verbatim or grows.
13
+ *
14
+ * An envelope with no `message.id` cannot be attributed, so it is treated as a call of its own —
15
+ * the pre-aggregation behaviour, kept so a CLI build (or a transcript) that omits the id degrades
16
+ * to over-counting rather than to silently merging unrelated calls.
17
+ */
18
+ export function createClaudeCallAggregator(handlers) {
19
+ let pending;
20
+ let anonymous = 0;
21
+ const complete = () => {
22
+ if (!pending)
23
+ return;
24
+ const { id: _id, ...call } = pending;
25
+ pending = undefined;
26
+ handlers.onCall(call);
27
+ };
28
+ return {
29
+ onAssistant(message) {
30
+ // `#anon-<n>` cannot collide with a real id (the API mints `msg_…`), so an envelope
31
+ // with no id keeps its own call rather than merging into whatever came before it.
32
+ const id = typeof message.id === 'string' && message.id ? message.id : `#anon-${anonymous++}`;
33
+ if (pending && pending.id !== id)
34
+ complete();
35
+ const content = Array.isArray(message.content) ? message.content : [];
36
+ const { text, reasoning, toolUses } = claudeAssistantContent(content);
37
+ const usage = claudeCallUsage(message.usage);
38
+ const stopReason = typeof message.stop_reason === 'string' ? message.stop_reason : null;
39
+ const model = typeof message.model === 'string' ? message.model : undefined;
40
+ if (!pending) {
41
+ pending = {
42
+ id,
43
+ content: [],
44
+ text: '',
45
+ reasoning: '',
46
+ stopReason: null,
47
+ inputTokens: 0,
48
+ cachedInputTokens: 0,
49
+ outputTokens: 0,
50
+ toolResults: [],
51
+ toolUses: 0,
52
+ };
53
+ handlers.onCallStart?.();
54
+ }
55
+ pending.content.push(...content);
56
+ pending.text += text;
57
+ pending.reasoning += reasoning;
58
+ pending.toolUses += toolUses;
59
+ pending.inputTokens = Math.max(pending.inputTokens, usage.inputTokens);
60
+ pending.cachedInputTokens = Math.max(pending.cachedInputTokens, usage.cachedInputTokens);
61
+ pending.outputTokens = Math.max(pending.outputTokens, usage.outputTokens);
62
+ // A block-split response reports its stop reason on the envelope that carries the end of the
63
+ // message; earlier ones report none. Keep the first non-null rather than the last seen.
64
+ if (stopReason && !pending.stopReason)
65
+ pending.stopReason = stopReason;
66
+ if (model && !pending.model)
67
+ pending.model = model;
68
+ },
69
+ onToolResult(content) {
70
+ // Results can only belong to the tool_use blocks of the call in flight. Before the first
71
+ // assistant envelope there is nothing they could attach to.
72
+ if (pending)
73
+ pending.toolResults.push(content);
74
+ },
75
+ flush: complete,
76
+ };
77
+ }
78
+ /**
79
+ * Assemble ONE conversation's per-call telemetry from the CLI stream: the growing request
80
+ * transcript and the per-call token/body metrics.
81
+ *
82
+ * Owns the transcript because the two are one concern — a call's `promptText` is the transcript as
83
+ * of that call, and its turns may only be appended once the call that produced them is complete.
84
+ * `seed` is what the harness supplied and the stream therefore never shows (the system + first user
85
+ * message, or the single folded user turn), so the reconstruction never claims a system turn that
86
+ * was not sent. A subagent's conversation seeds EMPTY — its prompt was minted by the CLI and never
87
+ * crosses this stream — which is also why its first call carries `messageCount: 0` (the backend's
88
+ * `latestChainTip` skips those on purpose: there is no re-sendable chain to delta against).
89
+ * Bodies are credential-scrubbed; they can echo the leased token.
90
+ *
91
+ * Deliberately does NOT touch {@link PiRunStats}: the run's tool/output counters describe whether
92
+ * the agent ACTED at all (`agentNeverActed`), which is true of a subagent's turns whichever channel
93
+ * ends up owning their telemetry rows. The caller accumulates them off the raw stream instead.
94
+ */
95
+ export function createClaudeStreamTelemetry(opts) {
96
+ const messages = [...opts.seed];
97
+ let callPrompt = '';
98
+ let callMessageCount = 0;
99
+ // The aggregator IS the surface: the transcript and metric work happens in its callbacks, so
100
+ // there is nothing to wrap it in.
101
+ return createClaudeCallAggregator({
102
+ // Snapshotted when a call's FIRST envelope arrives: the history at that moment is what
103
+ // produced the response, and later envelopes of the same call must not see the turns it
104
+ // went on to add.
105
+ onCallStart: () => {
106
+ callPrompt = redactBody(JSON.stringify(messages), opts.secrets);
107
+ callMessageCount = messages.length;
108
+ },
109
+ onCall: (call) => {
110
+ opts.publish({
111
+ ...(call.model ? { model: call.model } : {}),
112
+ promptText: callPrompt,
113
+ messageCount: callMessageCount,
114
+ responseText: redactBody(call.text, opts.secrets),
115
+ reasoningText: redactBody(call.reasoning, opts.secrets),
116
+ inputTokens: call.inputTokens,
117
+ cachedInputTokens: call.cachedInputTokens,
118
+ outputTokens: call.outputTokens,
119
+ finishReason: call.stopReason,
120
+ });
121
+ // Appended only now, so each call's prompt stays a strict prefix of the next and the
122
+ // backend's telemetry chain delta-compresses cleanly.
123
+ messages.push({ role: 'assistant', content: call.content });
124
+ for (const result of call.toolResults)
125
+ messages.push({ role: 'tool', content: result });
126
+ },
127
+ });
128
+ }
129
+ /**
130
+ * The dispatch (`Agent`/`Task` tool_use) id a stream envelope is tagged with, or `undefined` for a
131
+ * parent-loop turn.
132
+ *
133
+ * Claude Code streams the turns of the subagents it dispatches onto the parent's stdout, tagged
134
+ * with the tool_use id that spawned them. Those same turns are also written to the per-session
135
+ * `subagents/*.jsonl` transcripts the watcher reads, so recording both channels counted every
136
+ * subagent call twice — and splicing them into the parent's message reconstruction produced a
137
+ * `promptText` chain that interleaves several conversations and therefore matches no real request.
138
+ *
139
+ * The id is what makes the fallback below possible: concurrent subagents interleave on one stdout,
140
+ * so it is the ONLY thing separating their conversations.
141
+ */
142
+ export function subagentDispatchId(event) {
143
+ if (!isObject(event))
144
+ return undefined;
145
+ const id = event.parent_tool_use_id;
146
+ return typeof id === 'string' && id ? id : undefined;
147
+ }
148
+ /** Whether a stream envelope describes a SUBAGENT's turn rather than the parent loop's. */
149
+ export function isSubagentEvent(event) {
150
+ return subagentDispatchId(event) !== undefined;
151
+ }
152
+ /**
153
+ * Per-call telemetry for the subagents whose turns ride the parent's stdout — the FALLBACK channel,
154
+ * used only when no `subagents/*.jsonl` watcher will run (see `startSubagentWatcher`, which is
155
+ * wired only when the CLI has an isolated config home; an `ambientAuth` run has none).
156
+ *
157
+ * Without this, filtering tagged events out of the parent's telemetry leaves a subagent-heavy run
158
+ * with its spend recorded by NEITHER channel — an under-count, which reads as a cheap run and is
159
+ * the worse failure direction than the double-count the filter exists to fix.
160
+ *
161
+ * Each dispatch id gets its OWN transcript, because concurrent subagents interleave arbitrarily on
162
+ * one stream: folding them into a single chain is exactly the defect this whole module removes,
163
+ * one level down.
164
+ */
165
+ function createSubagentStreamTelemetry(opts) {
166
+ const perDispatch = new Map();
167
+ const forDispatch = (dispatchId) => {
168
+ let telemetry = perDispatch.get(dispatchId);
169
+ if (!telemetry) {
170
+ // Seeded EMPTY: the CLI minted this subagent's prompt and it never crossed the stream.
171
+ telemetry = createClaudeStreamTelemetry({
172
+ seed: [],
173
+ secrets: opts.secrets,
174
+ publish: opts.publish,
175
+ });
176
+ perDispatch.set(dispatchId, telemetry);
177
+ }
178
+ return telemetry;
179
+ };
180
+ return {
181
+ onAssistant: (dispatchId, message) => forDispatch(dispatchId).onAssistant(message),
182
+ // Only against a dispatch already seen: a result for a subagent whose assistant turns never
183
+ // reached us has no conversation to attach to, and minting one would publish a call that is
184
+ // all tool output and no request.
185
+ onToolResult: (dispatchId, content) => perDispatch.get(dispatchId)?.onToolResult(content),
186
+ flush: () => {
187
+ for (const telemetry of perDispatch.values())
188
+ telemetry.flush();
189
+ },
190
+ };
191
+ }
192
+ /**
193
+ * Assemble a run's per-call telemetry, routing each envelope to the conversation it belongs to.
194
+ *
195
+ * The routing is the whole point. A subagent's turns ride the parent's stdout tagged with the
196
+ * dispatch that spawned them, and they must never join the PARENT's chain — that splice produced a
197
+ * `promptText` interleaving several conversations, matching no request that was ever sent.
198
+ *
199
+ * Who RECORDS them is a separate question, decided once per run rather than per event:
200
+ * `watcherOwnsSubagents` says a `subagents/*.jsonl` watcher will run, and it is the better source
201
+ * (it reads the settled transcript, so its usage and stop reason are final). With no watcher — an
202
+ * `ambientAuth` run has no isolated config home to watch — the tagged turns are recorded here
203
+ * instead, on per-dispatch transcripts of their own. Dropping them in that case would leave the run
204
+ * billed by neither channel, and an under-count reads as a cheap run rather than as an error.
205
+ */
206
+ export function createClaudeRunTelemetry(opts) {
207
+ const parent = createClaudeStreamTelemetry(opts);
208
+ const subagents = opts.watcherOwnsSubagents ? undefined : createSubagentStreamTelemetry(opts);
209
+ let sawSubagentTurn = false;
210
+ return {
211
+ onAssistant(dispatchId, message) {
212
+ if (!dispatchId)
213
+ return parent.onAssistant(message);
214
+ sawSubagentTurn = true;
215
+ subagents?.onAssistant(dispatchId, message);
216
+ },
217
+ onToolResult(dispatchId, content) {
218
+ if (!dispatchId)
219
+ return parent.onToolResult(content);
220
+ sawSubagentTurn = true;
221
+ subagents?.onToolResult(dispatchId, content);
222
+ },
223
+ flush() {
224
+ parent.flush();
225
+ subagents?.flush();
226
+ },
227
+ expectsWatcherCalls: () => opts.watcherOwnsSubagents && sawSubagentTurn,
228
+ };
229
+ }
package/dist/progress.js CHANGED
@@ -1,7 +1,8 @@
1
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.
2
+ // The parent agent's own PLAN, as progress counts. This is one of the two views a pr-reviewer
3
+ // run produces of the same slicing (the other is the parallel-subagent dispatch view in
4
+ // `subagents.ts`). The plan is the INVENTORY, the dispatches are the live STATUS, and
5
+ // {@link mergeProgress} folds them into the one list the board renders.
5
6
  //
6
7
  // The Claude Code CLI exposes the plan through TWO different tool vocabularies, and which one
7
8
  // a run uses depends on the CLI build, not on anything the harness controls:
@@ -190,17 +191,14 @@ export function createTaskPlanTracker() {
190
191
  };
191
192
  }
192
193
  /**
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
194
+ * Reconcile the parent's TWO plan vocabularies (`TodoWrite` snapshots vs the incremental
195
+ * `TaskCreate`/`TaskUpdate` pair) into one plan. A run uses one or the other, so this is a
196
+ * genuine either/or: prefer whichever is further along more `completed`, then more
197
+ * `inProgress`, then more `total` else the `TodoWrite` view. Pure + total; returns whichever
203
198
  * single input is present when only one is.
199
+ *
200
+ * This is NOT how the plan reconciles with the parallel-subagent view — those describe the same
201
+ * slices from two angles and are MERGED, see {@link mergeProgress}.
204
202
  */
205
203
  export function pickProgress(todo, slice) {
206
204
  if (!todo)
@@ -215,3 +213,114 @@ export function pickProgress(todo, slice) {
215
213
  return slice.total > todo.total ? slice : todo;
216
214
  return todo;
217
215
  }
216
+ /** Status ordering, so a merge can only ever ADVANCE an entry, never walk it back. */
217
+ const STATUS_RANK = {
218
+ pending: 0,
219
+ in_progress: 1,
220
+ completed: 2,
221
+ };
222
+ /**
223
+ * Words that carry no identity in a slice label, so `Review identity/auth slice` (the subagent
224
+ * description) and `identity/auth` (the plan entry's subject) compare equal.
225
+ */
226
+ const LABEL_FILLER = new Set([
227
+ 'a',
228
+ 'agent',
229
+ 'an',
230
+ 'and',
231
+ 'chunk',
232
+ 'chunks',
233
+ 'for',
234
+ 'of',
235
+ 'pass',
236
+ 'review',
237
+ 'reviewing',
238
+ 'slice',
239
+ 'slices',
240
+ 'subagent',
241
+ 'the',
242
+ ]);
243
+ /**
244
+ * A slice label reduced to its identifying words, for pairing a plan entry with the subagent
245
+ * dispatched to review it. Case, punctuation and the boilerplate around the slice name all
246
+ * differ between the two vocabularies; the slice NAME does not.
247
+ */
248
+ export function sliceLabelKey(label) {
249
+ return label
250
+ .toLowerCase()
251
+ .replace(/[^a-z0-9]+/g, ' ')
252
+ .split(' ')
253
+ .filter((w) => w.length > 0 && !LABEL_FILLER.has(w))
254
+ .join(' ');
255
+ }
256
+ /** Advance an entry to the stronger of its current status and the dispatch's. */
257
+ function advance(entry, status) {
258
+ if (STATUS_RANK[status] > STATUS_RANK[entry.status])
259
+ entry.status = status;
260
+ entry.paired = true;
261
+ }
262
+ /**
263
+ * MERGE the parent's plan with the `SliceTracker`'s subagent-dispatch view into the single list
264
+ * the board renders (ADR 0027 Defect B, corrected).
265
+ *
266
+ * The two are not competing answers, they are two halves of one: the plan is the INVENTORY (it
267
+ * names every slice, including the ones not dispatched yet, which is the only place a `pending`
268
+ * slice exists at all), and the dispatch view is the live STATUS (the plan advances only when
269
+ * the agent remembers to update it, which it does unreliably). Picking whichever looked "further
270
+ * along" — the previous behaviour — made the rendered list SHRINK the moment the first subagent
271
+ * returned: the dispatch view won on `completed`, and it only knows the slices dispatched so far,
272
+ * so every queued slice vanished from the window and reappeared one at a time as it was dispatched.
273
+ *
274
+ * Pairing is by normalised label ({@link sliceLabelKey}) — exact first, then containment — and
275
+ * finally positionally into the leftover pending entries, in dispatch order (the agent dispatches
276
+ * in plan order). A dispatch that pairs with nothing is APPENDED rather than dropped, so the list
277
+ * is at worst a union and can never lose a slice. Statuses only ever advance, so a plan entry the
278
+ * agent already marked done is not walked back by a re-dispatch.
279
+ *
280
+ * Pure + total. Falls back to {@link pickProgress} when either side carries counts but no items
281
+ * (nothing to merge onto).
282
+ */
283
+ export function mergeProgress(plan, slice) {
284
+ if (!plan)
285
+ return slice;
286
+ if (!slice)
287
+ return plan;
288
+ const planItems = plan.items ?? [];
289
+ const sliceItems = slice.items ?? [];
290
+ if (planItems.length === 0 || sliceItems.length === 0)
291
+ return pickProgress(plan, slice);
292
+ const entries = planItems.map((i) => ({
293
+ label: i.label,
294
+ status: normalizeStatus(i.status),
295
+ key: sliceLabelKey(i.label),
296
+ paired: false,
297
+ }));
298
+ const take = (match) => entries.find((e) => !e.paired && match(e));
299
+ // Pass 1 — the same slice named the same way.
300
+ // Pass 2 — one label contains the other (a dispatch description often expands the plan's short
301
+ // name). Length-guarded so a one-word residue can't match everything.
302
+ // Pass 3 — no words in common at all (renamed between planning and dispatch): absorb into the
303
+ // still-untouched pending entries in dispatch order.
304
+ // Anything still unpaired is a slice the plan never mentioned, so it JOINS the list.
305
+ const matchers = [
306
+ (key) => (e) => key.length > 0 && e.key === key,
307
+ (key) => (e) => key.length >= 3 && e.key.length >= 3 && (e.key.includes(key) || key.includes(e.key)),
308
+ () => (e) => e.status === 'pending',
309
+ ];
310
+ let unpaired = sliceItems;
311
+ for (const matcher of matchers) {
312
+ const rest = [];
313
+ for (const item of unpaired) {
314
+ const hit = take(matcher(sliceLabelKey(item.label)));
315
+ if (hit)
316
+ advance(hit, normalizeStatus(item.status));
317
+ else
318
+ rest.push(item);
319
+ }
320
+ unpaired = rest;
321
+ }
322
+ return toProgress([
323
+ ...entries.map((e) => ({ label: e.label, status: e.status })),
324
+ ...unpaired.map((i) => ({ label: i.label, status: normalizeStatus(i.status) })),
325
+ ]);
326
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cat-factory/executor-harness",
3
- "version": "1.64.0",
3
+ "version": "1.64.4",
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,9 +26,9 @@
26
26
  "hono": "^4.12.32",
27
27
  "typescript": "7.0.2",
28
28
  "vitest": "^4.1.10",
29
- "@cat-factory/kernel": "0.170.0",
30
- "@cat-factory/server": "0.160.0",
31
- "@cat-factory/spend": "0.12.99"
29
+ "@cat-factory/kernel": "0.171.0",
30
+ "@cat-factory/server": "0.162.1",
31
+ "@cat-factory/spend": "0.12.100"
32
32
  },
33
33
  "scripts": {
34
34
  "build": "tsc -p tsconfig.json",