@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.
- package/dist/agent-runner.js +63 -43
- package/dist/claude-call-aggregator.js +229 -0
- package/dist/progress.js +122 -13
- package/package.json +4 -4
- package/src/agent-runner.ts +73 -48
- package/src/claude-call-aggregator.ts +327 -0
- package/src/progress.ts +138 -14
- package/src/subagents.ts +9 -3
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
|
|
5
|
-
//
|
|
6
|
-
// `subagents.ts`)
|
|
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
|
|
211
|
-
*
|
|
212
|
-
*
|
|
213
|
-
*
|
|
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
|
+
}
|
package/src/subagents.ts
CHANGED
|
@@ -40,9 +40,15 @@ import { publishCallMetric, type HarnessCallMetric, type TodoProgress } from './
|
|
|
40
40
|
// `projects` root and DISCOVERS the `subagents/` dir by walking (see
|
|
41
41
|
// {@link findSubagentTranscripts}).
|
|
42
42
|
//
|
|
43
|
-
// Both degrade gracefully
|
|
44
|
-
//
|
|
45
|
-
//
|
|
43
|
+
// Both degrade gracefully in the sense that a missing directory, an unreadable file, or an
|
|
44
|
+
// unparseable line is swallowed rather than failing the run — the CLI's subagent transcript layout
|
|
45
|
+
// is not a stable contract. But note what that costs SINCE the per-call fold landed: the parent
|
|
46
|
+
// loop's telemetry now filters the subagent turns the CLI tags onto its stdout (they were being
|
|
47
|
+
// counted twice and spliced into the parent's message chain), so when this watcher is wired and
|
|
48
|
+
// yields nothing, the run's subagent calls are recorded by NEITHER channel. `runClaudeCode` warns
|
|
49
|
+
// on exactly that shape, and an `ambientAuth` run — which has no config home to watch, so no
|
|
50
|
+
// watcher — keeps recording them off the parent stream instead
|
|
51
|
+
// (`createSubagentStreamTelemetry`). Do not "simplify" that fallback away.
|
|
46
52
|
|
|
47
53
|
// ---------------------------------------------------------------------------
|
|
48
54
|
// Slice / progress tracking off the PARENT stream (D2.1)
|