@cat-factory/executor-harness 1.64.0 → 1.64.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.
@@ -8,7 +8,7 @@ import { ProgressGuard } from './progress-guard.js';
8
8
  import { killChildProcess, spawnDetached } from './process.js';
9
9
  import { redact, secretsToRedact } from './redact.js';
10
10
  import { createSliceTracker, startSubagentWatcher } from './subagents.js';
11
- import { createTaskPlanTracker, normalizeStatus, pickProgress, toProgress, todosToProgress, } from './progress.js';
11
+ import { createTaskPlanTracker, mergeProgress, normalizeStatus, pickProgress, toProgress, todosToProgress, } from './progress.js';
12
12
  import { assertOnboardingKeysCurrent, writeOnboardingPreseed } from './onboarding-preseed.js';
13
13
  import { retainSessionTranscripts } from './transcript-retention.js';
14
14
  /**
@@ -233,25 +233,26 @@ export async function runClaudeCode(opts) {
233
233
  // Streams each call as the CLI yields it, EXCEPT one whose tokens `attributeCumulativeUsage`
234
234
  // may still rewrite below (a published call must be final — see the publisher).
235
235
  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%.
236
+ // ADR 0026 D2.1 + ADR 0027 Defect B: surface live slice progress from the two views the run
237
+ // produces of the SAME slicing. The parent's subagent dispatches + their terminal tool_results
238
+ // DO appear on this stream (only a subagent's intermediate turns don't), so `sliceTracker`
239
+ // knows which slices are in flight and which have returned; the parent's own plan (tracked by
240
+ // `planTracker` + `lastTodo`) is the only place a not-yet-dispatched slice is named at all.
243
241
  //
244
242
  // The plan arrives in one of two tool vocabularies depending on the bundled CLI build:
245
243
  // `TodoWrite` (whole-list snapshots, tracked in `lastTodo`) or the incremental
246
244
  // `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.
245
+ // because the task id is minted there). Both are read, and `pickProgress` resolves that
246
+ // either/or; the plan then MERGES with the dispatch view (`mergeProgress`) rather than
247
+ // competing with it — picking the further-along view collapsed the list to the dispatched
248
+ // slices alone the moment the first subagent returned. See ./progress.ts.
248
249
  const sliceTracker = createSliceTracker();
249
250
  const planTracker = createTaskPlanTracker();
250
251
  let lastTodo;
251
252
  const emitProgress = () => {
252
253
  if (!opts.onProgress)
253
254
  return;
254
- const progress = pickProgress(pickProgress(lastTodo, planTracker.progress()), sliceTracker.progress());
255
+ const progress = mergeProgress(pickProgress(lastTodo, planTracker.progress()), sliceTracker.progress());
255
256
  if (progress)
256
257
  opts.onProgress(progress);
257
258
  };
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.2",
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.0",
31
+ "@cat-factory/spend": "0.12.100"
32
32
  },
33
33
  "scripts": {
34
34
  "build": "tsc -p tsconfig.json",
@@ -25,6 +25,7 @@ import { redact, secretsToRedact } from './redact.js'
25
25
  import { createSliceTracker, startSubagentWatcher } from './subagents.js'
26
26
  import {
27
27
  createTaskPlanTracker,
28
+ mergeProgress,
28
29
  normalizeStatus,
29
30
  pickProgress,
30
31
  toProgress,
@@ -378,24 +379,25 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
378
379
  // may still rewrite below (a published call must be final — see the publisher).
379
380
  const publisher = createCallMetricPublisher(calls, opts.onCallMetric)
380
381
 
381
- // ADR 0026 D2.1 + ADR 0027 Defect B: surface live slice progress from TWO reconciled
382
- // sources. The parent's subagent dispatches + their terminal tool_results DO appear on this
383
- // stream (only a subagent's intermediate turns don't), so `sliceTracker` derives per-slice
384
- // progress for the parallel shape; the parent's own plan (the sequential shape) is tracked
385
- // by `planTracker` + `lastTodo`. `pickProgress` picks whichever is further along on each
386
- // update, so neither masks the other — the pr-reviewer prompt writes its plan ONCE and never
387
- // marks it done, which used to gate the slice signal off and pin progress at 0%.
382
+ // ADR 0026 D2.1 + ADR 0027 Defect B: surface live slice progress from the two views the run
383
+ // produces of the SAME slicing. The parent's subagent dispatches + their terminal tool_results
384
+ // DO appear on this stream (only a subagent's intermediate turns don't), so `sliceTracker`
385
+ // knows which slices are in flight and which have returned; the parent's own plan (tracked by
386
+ // `planTracker` + `lastTodo`) is the only place a not-yet-dispatched slice is named at all.
388
387
  //
389
388
  // The plan arrives in one of two tool vocabularies depending on the bundled CLI build:
390
389
  // `TodoWrite` (whole-list snapshots, tracked in `lastTodo`) or the incremental
391
390
  // `TaskCreate`/`TaskUpdate` pair (tracked by `planTracker`, which needs the tool RESULTS too
392
- // because the task id is minted there). Both are read see ./progress.ts.
391
+ // because the task id is minted there). Both are read, and `pickProgress` resolves that
392
+ // either/or; the plan then MERGES with the dispatch view (`mergeProgress`) rather than
393
+ // competing with it — picking the further-along view collapsed the list to the dispatched
394
+ // slices alone the moment the first subagent returned. See ./progress.ts.
393
395
  const sliceTracker = createSliceTracker()
394
396
  const planTracker = createTaskPlanTracker()
395
397
  let lastTodo: TodoProgress | undefined
396
398
  const emitProgress = (): void => {
397
399
  if (!opts.onProgress) return
398
- const progress = pickProgress(
400
+ const progress = mergeProgress(
399
401
  pickProgress(lastTodo, planTracker.progress()),
400
402
  sliceTracker.progress(),
401
403
  )
package/src/progress.ts CHANGED
@@ -1,9 +1,10 @@
1
1
  import { isObject } from './claude-stream.js'
2
- import type { TodoProgress } from './pi.js'
2
+ import type { TodoItem, TodoProgress } from './pi.js'
3
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.
4
+ // The parent agent's own PLAN, as progress counts. This is one of the two views a pr-reviewer
5
+ // run produces of the same slicing (the other is the parallel-subagent dispatch view in
6
+ // `subagents.ts`). The plan is the INVENTORY, the dispatches are the live STATUS, and
7
+ // {@link mergeProgress} folds them into the one list the board renders.
7
8
  //
8
9
  // The Claude Code CLI exposes the plan through TWO different tool vocabularies, and which one
9
10
  // a run uses depends on the CLI build, not on anything the harness controls:
@@ -207,17 +208,14 @@ export function createTaskPlanTracker(): TaskPlanTracker {
207
208
  }
208
209
 
209
210
  /**
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
211
+ * Reconcile the parent's TWO plan vocabularies (`TodoWrite` snapshots vs the incremental
212
+ * `TaskCreate`/`TaskUpdate` pair) into one plan. A run uses one or the other, so this is a
213
+ * genuine either/or: prefer whichever is further along more `completed`, then more
214
+ * `inProgress`, then more `total` else the `TodoWrite` view. Pure + total; returns whichever
220
215
  * single input is present when only one is.
216
+ *
217
+ * This is NOT how the plan reconciles with the parallel-subagent view — those describe the same
218
+ * slices from two angles and are MERGED, see {@link mergeProgress}.
221
219
  */
222
220
  export function pickProgress(
223
221
  todo: TodoProgress | undefined,
@@ -230,3 +228,129 @@ export function pickProgress(
230
228
  if (slice.total !== todo.total) return slice.total > todo.total ? slice : todo
231
229
  return todo
232
230
  }
231
+
232
+ /** Status ordering, so a merge can only ever ADVANCE an entry, never walk it back. */
233
+ const STATUS_RANK: Record<ReturnType<typeof normalizeStatus>, number> = {
234
+ pending: 0,
235
+ in_progress: 1,
236
+ completed: 2,
237
+ }
238
+
239
+ /**
240
+ * Words that carry no identity in a slice label, so `Review identity/auth slice` (the subagent
241
+ * description) and `identity/auth` (the plan entry's subject) compare equal.
242
+ */
243
+ const LABEL_FILLER = new Set([
244
+ 'a',
245
+ 'agent',
246
+ 'an',
247
+ 'and',
248
+ 'chunk',
249
+ 'chunks',
250
+ 'for',
251
+ 'of',
252
+ 'pass',
253
+ 'review',
254
+ 'reviewing',
255
+ 'slice',
256
+ 'slices',
257
+ 'subagent',
258
+ 'the',
259
+ ])
260
+
261
+ /**
262
+ * A slice label reduced to its identifying words, for pairing a plan entry with the subagent
263
+ * dispatched to review it. Case, punctuation and the boilerplate around the slice name all
264
+ * differ between the two vocabularies; the slice NAME does not.
265
+ */
266
+ export function sliceLabelKey(label: string): string {
267
+ return label
268
+ .toLowerCase()
269
+ .replace(/[^a-z0-9]+/g, ' ')
270
+ .split(' ')
271
+ .filter((w) => w.length > 0 && !LABEL_FILLER.has(w))
272
+ .join(' ')
273
+ }
274
+
275
+ interface MergeEntry {
276
+ label: string
277
+ status: ReturnType<typeof normalizeStatus>
278
+ key: string
279
+ /** A dispatched subagent has already been paired to this entry. */
280
+ paired: boolean
281
+ }
282
+
283
+ /** Advance an entry to the stronger of its current status and the dispatch's. */
284
+ function advance(entry: MergeEntry, status: ReturnType<typeof normalizeStatus>): void {
285
+ if (STATUS_RANK[status] > STATUS_RANK[entry.status]) entry.status = status
286
+ entry.paired = true
287
+ }
288
+
289
+ /**
290
+ * MERGE the parent's plan with the `SliceTracker`'s subagent-dispatch view into the single list
291
+ * the board renders (ADR 0027 Defect B, corrected).
292
+ *
293
+ * The two are not competing answers, they are two halves of one: the plan is the INVENTORY (it
294
+ * names every slice, including the ones not dispatched yet, which is the only place a `pending`
295
+ * slice exists at all), and the dispatch view is the live STATUS (the plan advances only when
296
+ * the agent remembers to update it, which it does unreliably). Picking whichever looked "further
297
+ * along" — the previous behaviour — made the rendered list SHRINK the moment the first subagent
298
+ * returned: the dispatch view won on `completed`, and it only knows the slices dispatched so far,
299
+ * so every queued slice vanished from the window and reappeared one at a time as it was dispatched.
300
+ *
301
+ * Pairing is by normalised label ({@link sliceLabelKey}) — exact first, then containment — and
302
+ * finally positionally into the leftover pending entries, in dispatch order (the agent dispatches
303
+ * in plan order). A dispatch that pairs with nothing is APPENDED rather than dropped, so the list
304
+ * is at worst a union and can never lose a slice. Statuses only ever advance, so a plan entry the
305
+ * agent already marked done is not walked back by a re-dispatch.
306
+ *
307
+ * Pure + total. Falls back to {@link pickProgress} when either side carries counts but no items
308
+ * (nothing to merge onto).
309
+ */
310
+ export function mergeProgress(
311
+ plan: TodoProgress | undefined,
312
+ slice: TodoProgress | undefined,
313
+ ): TodoProgress | undefined {
314
+ if (!plan) return slice
315
+ if (!slice) return plan
316
+ const planItems = plan.items ?? []
317
+ const sliceItems = slice.items ?? []
318
+ if (planItems.length === 0 || sliceItems.length === 0) return pickProgress(plan, slice)
319
+
320
+ const entries: MergeEntry[] = planItems.map((i) => ({
321
+ label: i.label,
322
+ status: normalizeStatus(i.status),
323
+ key: sliceLabelKey(i.label),
324
+ paired: false,
325
+ }))
326
+ const take = (match: (e: MergeEntry) => boolean): MergeEntry | undefined =>
327
+ entries.find((e) => !e.paired && match(e))
328
+
329
+ // Pass 1 — the same slice named the same way.
330
+ // Pass 2 — one label contains the other (a dispatch description often expands the plan's short
331
+ // name). Length-guarded so a one-word residue can't match everything.
332
+ // Pass 3 — no words in common at all (renamed between planning and dispatch): absorb into the
333
+ // still-untouched pending entries in dispatch order.
334
+ // Anything still unpaired is a slice the plan never mentioned, so it JOINS the list.
335
+ const matchers: ((key: string) => (e: MergeEntry) => boolean)[] = [
336
+ (key) => (e) => key.length > 0 && e.key === key,
337
+ (key) => (e) =>
338
+ key.length >= 3 && e.key.length >= 3 && (e.key.includes(key) || key.includes(e.key)),
339
+ () => (e) => e.status === 'pending',
340
+ ]
341
+ let unpaired: TodoItem[] = sliceItems
342
+ for (const matcher of matchers) {
343
+ const rest: TodoItem[] = []
344
+ for (const item of unpaired) {
345
+ const hit = take(matcher(sliceLabelKey(item.label)))
346
+ if (hit) advance(hit, normalizeStatus(item.status))
347
+ else rest.push(item)
348
+ }
349
+ unpaired = rest
350
+ }
351
+
352
+ return toProgress([
353
+ ...entries.map((e) => ({ label: e.label, status: e.status })),
354
+ ...unpaired.map((i) => ({ label: i.label, status: normalizeStatus(i.status) })),
355
+ ])
356
+ }