@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.
- package/dist/agent-runner.js +11 -10
- package/dist/progress.js +122 -13
- package/package.json +4 -4
- package/src/agent-runner.ts +11 -9
- package/src/progress.ts +138 -14
package/dist/agent-runner.js
CHANGED
|
@@ -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
|
|
237
|
-
//
|
|
238
|
-
// stream (only a subagent's intermediate turns don't), so `sliceTracker`
|
|
239
|
-
//
|
|
240
|
-
//
|
|
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
|
|
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 =
|
|
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
|
|
3
|
-
//
|
|
4
|
-
// `subagents.ts`)
|
|
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
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
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.
|
|
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.
|
|
30
|
-
"@cat-factory/server": "0.
|
|
31
|
-
"@cat-factory/spend": "0.12.
|
|
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",
|
package/src/agent-runner.ts
CHANGED
|
@@ -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
|
|
382
|
-
//
|
|
383
|
-
// stream (only a subagent's intermediate turns don't), so `sliceTracker`
|
|
384
|
-
//
|
|
385
|
-
//
|
|
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
|
|
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 =
|
|
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
|
|
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
|
+
}
|