@cat-factory/executor-harness 1.52.0 → 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/dist/agent-runner.js +17 -29
- package/dist/progress.js +217 -0
- package/dist/subagents.js +52 -27
- package/package.json +2 -2
- package/src/agent-runner.ts +26 -28
- package/src/progress.ts +232 -0
- package/src/subagents.ts +25 -34
package/dist/agent-runner.js
CHANGED
|
@@ -6,7 +6,8 @@ import { claudeAssistantContent, claudeCallUsage, isObject, numberOf, redactBody
|
|
|
6
6
|
import { createCallMetricPublisher, publishCallMetric, } from './pi.js';
|
|
7
7
|
import { killChildProcess, spawnDetached } from './process.js';
|
|
8
8
|
import { redact, secretsToRedact } from './redact.js';
|
|
9
|
-
import { createSliceTracker,
|
|
9
|
+
import { createSliceTracker, startSubagentWatcher } from './subagents.js';
|
|
10
|
+
import { createTaskPlanTracker, normalizeStatus, pickProgress, toProgress, todosToProgress, } from './progress.js';
|
|
10
11
|
import { assertOnboardingKeysCurrent, writeOnboardingPreseed } from './onboarding-preseed.js';
|
|
11
12
|
import { retainSessionTranscripts } from './transcript-retention.js';
|
|
12
13
|
/**
|
|
@@ -224,18 +225,24 @@ export async function runClaudeCode(opts) {
|
|
|
224
225
|
// may still rewrite below (a published call must be final — see the publisher).
|
|
225
226
|
const publisher = createCallMetricPublisher(calls, opts.onCallMetric);
|
|
226
227
|
// ADR 0026 D2.1 + ADR 0027 Defect B: surface live slice progress from TWO reconciled
|
|
227
|
-
// sources. The parent's
|
|
228
|
+
// sources. The parent's subagent dispatches + their terminal tool_results DO appear on this
|
|
228
229
|
// stream (only a subagent's intermediate turns don't), so `sliceTracker` derives per-slice
|
|
229
|
-
// progress for the parallel
|
|
230
|
-
//
|
|
231
|
-
// update, so neither masks the other — the pr-reviewer prompt writes its
|
|
232
|
-
//
|
|
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.
|
|
233
239
|
const sliceTracker = createSliceTracker();
|
|
240
|
+
const planTracker = createTaskPlanTracker();
|
|
234
241
|
let lastTodo;
|
|
235
242
|
const emitProgress = () => {
|
|
236
243
|
if (!opts.onProgress)
|
|
237
244
|
return;
|
|
238
|
-
const progress = pickProgress(lastTodo, sliceTracker.progress());
|
|
245
|
+
const progress = pickProgress(pickProgress(lastTodo, planTracker.progress()), sliceTracker.progress());
|
|
239
246
|
if (progress)
|
|
240
247
|
opts.onProgress(progress);
|
|
241
248
|
};
|
|
@@ -255,6 +262,7 @@ export async function runClaudeCode(opts) {
|
|
|
255
262
|
}
|
|
256
263
|
}
|
|
257
264
|
sliceTracker.onAssistant(content);
|
|
265
|
+
planTracker.onAssistant(content);
|
|
258
266
|
emitProgress();
|
|
259
267
|
// Record this call BEFORE appending its turn: the prompt is the history that
|
|
260
268
|
// produced this response. The append-only array keeps each call's prompt a strict
|
|
@@ -278,6 +286,7 @@ export async function runClaudeCode(opts) {
|
|
|
278
286
|
const content = event.message.content;
|
|
279
287
|
if (Array.isArray(content)) {
|
|
280
288
|
sliceTracker.onUser(content);
|
|
289
|
+
planTracker.onUser(content);
|
|
281
290
|
emitProgress();
|
|
282
291
|
messages.push({ role: 'tool', content });
|
|
283
292
|
}
|
|
@@ -442,25 +451,6 @@ async function assembleClaudeOutcome(args) {
|
|
|
442
451
|
...(mergedCalls.length ? { callMetrics: mergedCalls } : {}),
|
|
443
452
|
};
|
|
444
453
|
}
|
|
445
|
-
/** Map Claude Code's `TodoWrite` todos array onto subtask counts. */
|
|
446
|
-
function todosToProgress(todos) {
|
|
447
|
-
if (!Array.isArray(todos))
|
|
448
|
-
return undefined;
|
|
449
|
-
const items = todos.filter(isObject).map((t) => ({
|
|
450
|
-
label: typeof t.content === 'string' ? t.content : String(t.content ?? ''),
|
|
451
|
-
status: normalizeStatus(t.status),
|
|
452
|
-
}));
|
|
453
|
-
const completed = items.filter((i) => i.status === 'completed').length;
|
|
454
|
-
const inProgress = items.filter((i) => i.status === 'in_progress').length;
|
|
455
|
-
return { completed, inProgress, total: items.length, items };
|
|
456
|
-
}
|
|
457
|
-
function normalizeStatus(status) {
|
|
458
|
-
if (status === 'completed')
|
|
459
|
-
return 'completed';
|
|
460
|
-
if (status === 'in_progress')
|
|
461
|
-
return 'in_progress';
|
|
462
|
-
return 'pending';
|
|
463
|
-
}
|
|
464
454
|
function claudeUsage(raw) {
|
|
465
455
|
if (!isObject(raw))
|
|
466
456
|
return undefined;
|
|
@@ -670,9 +660,7 @@ function codexPlanProgress(event) {
|
|
|
670
660
|
}));
|
|
671
661
|
if (items.length === 0)
|
|
672
662
|
return undefined;
|
|
673
|
-
|
|
674
|
-
const inProgress = items.filter((i) => i.status === 'in_progress').length;
|
|
675
|
-
return { completed, inProgress, total: items.length, items };
|
|
663
|
+
return toProgress(items);
|
|
676
664
|
}
|
|
677
665
|
/**
|
|
678
666
|
* Best-effort: pull token usage out of a Codex usage event. Codex `exec --json`
|
package/dist/progress.js
ADDED
|
@@ -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/subagents.js
CHANGED
|
@@ -3,6 +3,55 @@ import { createReadStream } from 'node:fs';
|
|
|
3
3
|
import { basename, join } from 'node:path';
|
|
4
4
|
import { claudeAssistantContent, claudeCallUsage, isObject, redactBody } from './claude-stream.js';
|
|
5
5
|
import { publishCallMetric } from './pi.js';
|
|
6
|
+
// ADR 0026 D2.1 + D3, corrected by ADR 0027. When the Claude Code CLI reviews a large PR
|
|
7
|
+
// it fans the work out across parallel `Task` subagents. Two things then go dark to the
|
|
8
|
+
// harness, which only reads the PARENT process's stream-json stdout:
|
|
9
|
+
//
|
|
10
|
+
// - the parent stream falls quiet for the whole (potentially 15+ minute) parallel
|
|
11
|
+
// review, so the inactivity heartbeat freezes and a healthy run looks wedged (P3);
|
|
12
|
+
// - every subagent's token spend is written to a SEPARATE `subagents/*.jsonl`
|
|
13
|
+
// transcript under the CLI's config home and never reaches the parent stream, so
|
|
14
|
+
// the run's telemetry reports ~0 tokens while hundreds of thousands are spent (P3).
|
|
15
|
+
//
|
|
16
|
+
// This module closes both without disabling the (context-bounding, ADR-0023-wanted)
|
|
17
|
+
// subagent parallelism:
|
|
18
|
+
//
|
|
19
|
+
// - {@link createSliceTracker} derives the slice plan + per-slice progress from the
|
|
20
|
+
// PARENT stream alone — the subagent-dispatch tool_use and its terminal tool_result
|
|
21
|
+
// DO appear there (only the subagent's intermediate turns don't), so slices/progress
|
|
22
|
+
// need no file watching (D2.1). `pickProgress` (./progress.ts) reconciles it with the
|
|
23
|
+
// parent's own plan (ADR 0027 Defect B);
|
|
24
|
+
// - {@link startSubagentWatcher} tails the `subagents/*.jsonl` transcripts for the
|
|
25
|
+
// heartbeat (any new bytes ⇒ `onActivity`) and sums each subagent turn's usage into
|
|
26
|
+
// the run's telemetry (D3).
|
|
27
|
+
//
|
|
28
|
+
// The CLI does NOT write those transcripts to `<configHome>/subagents` (the location ADR
|
|
29
|
+
// 0026 assumed, which never exists — ADR 0027 Defect A). It writes them PER SESSION under
|
|
30
|
+
// `<configHome>/projects/<encoded-cwd>/<session-uuid>/subagents/agent-*.jsonl`, and the
|
|
31
|
+
// session-uuid dir isn't known before the CLI mints it — so the watcher is pointed at the
|
|
32
|
+
// `projects` root and DISCOVERS the `subagents/` dir by walking (see
|
|
33
|
+
// {@link findSubagentTranscripts}).
|
|
34
|
+
//
|
|
35
|
+
// Both degrade gracefully: the CLI's subagent transcript layout is not a stable contract,
|
|
36
|
+
// so a missing directory, an unreadable file, or an unparseable line is swallowed and the
|
|
37
|
+
// harness falls back to today's parent-stream-only behaviour.
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Slice / progress tracking off the PARENT stream (D2.1)
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
/**
|
|
42
|
+
* The tool names the Claude Code CLI dispatches a parallel subagent under. `Agent` is what the
|
|
43
|
+
* shipped schema declares (`AgentInput` in `sdk-tools.d.ts`, carrying `description` / `prompt` /
|
|
44
|
+
* `subagent_type`); `Task` is the older name for the same dispatch. Both are matched because the
|
|
45
|
+
* harness runs against whatever CLI the image happens to bundle, and matching only the old name
|
|
46
|
+
* is what left a CLI 2.1.x pr-review reporting no slices at all.
|
|
47
|
+
*
|
|
48
|
+
* Note the asymmetry: keeping the legacy `Task` here is the one place a CLI rename could produce a
|
|
49
|
+
* FALSE signal rather than merely no signal — if a future build were to name a plain task-list
|
|
50
|
+
* tool `Task`, its writes would be counted as in-flight slices. We accept that because no shipped
|
|
51
|
+
* build does (the incremental plan tool is `TaskCreate`/`TaskUpdate`, tracked separately in
|
|
52
|
+
* `progress.ts`), and dropping legacy coverage is the more likely regression.
|
|
53
|
+
*/
|
|
54
|
+
const SUBAGENT_TOOL_NAMES = new Set(['Agent', 'Task']);
|
|
6
55
|
export function createSliceTracker() {
|
|
7
56
|
// Insertion-ordered so the progress `items` render in dispatch order.
|
|
8
57
|
const slices = new Map();
|
|
@@ -11,7 +60,9 @@ export function createSliceTracker() {
|
|
|
11
60
|
if (!Array.isArray(content))
|
|
12
61
|
return;
|
|
13
62
|
for (const block of content) {
|
|
14
|
-
if (!isObject(block) || block.type !== 'tool_use'
|
|
63
|
+
if (!isObject(block) || block.type !== 'tool_use')
|
|
64
|
+
continue;
|
|
65
|
+
if (typeof block.name !== 'string' || !SUBAGENT_TOOL_NAMES.has(block.name))
|
|
15
66
|
continue;
|
|
16
67
|
const id = typeof block.id === 'string' ? block.id : undefined;
|
|
17
68
|
if (!id || slices.has(id))
|
|
@@ -55,32 +106,6 @@ export function createSliceTracker() {
|
|
|
55
106
|
},
|
|
56
107
|
};
|
|
57
108
|
}
|
|
58
|
-
/**
|
|
59
|
-
* Reconcile the two redundant views of the same slice work into the one to surface
|
|
60
|
-
* (ADR 0027 Defect B). A pr-reviewer run has BOTH a parent `TodoWrite` plan (the slices,
|
|
61
|
-
* written once at grouping time) and the {@link SliceTracker}'s `Task`-dispatch view. The
|
|
62
|
-
* sequential shape advances the todo plan; the parallel-subagent shape advances ONLY the
|
|
63
|
-
* slice tracker (the CLI writes the plan once and never marks it done, while the parallel
|
|
64
|
-
* `Task`s report in-flight/complete). Neither alone covers both shapes, and gating the
|
|
65
|
-
* slice tracker OFF whenever a todo plan exists (the old behaviour) pinned parallel runs
|
|
66
|
-
* at 0%. So prefer whichever view is further along: more `completed`, then more
|
|
67
|
-
* `inProgress` (an all-pending todo plan must not beat live in-flight slices), then more
|
|
68
|
-
* `total` (the richer view — the todo plan can carry an extra "aggregate" entry), else the
|
|
69
|
-
* todo plan. Pure + total; returns whichever single input is present when only one is.
|
|
70
|
-
*/
|
|
71
|
-
export function pickProgress(todo, slice) {
|
|
72
|
-
if (!todo)
|
|
73
|
-
return slice;
|
|
74
|
-
if (!slice)
|
|
75
|
-
return todo;
|
|
76
|
-
if (slice.completed !== todo.completed)
|
|
77
|
-
return slice.completed > todo.completed ? slice : todo;
|
|
78
|
-
if (slice.inProgress !== todo.inProgress)
|
|
79
|
-
return slice.inProgress > todo.inProgress ? slice : todo;
|
|
80
|
-
if (slice.total !== todo.total)
|
|
81
|
-
return slice.total > todo.total ? slice : todo;
|
|
82
|
-
return todo;
|
|
83
|
-
}
|
|
84
109
|
// ---------------------------------------------------------------------------
|
|
85
110
|
// Subagent transcript watcher (heartbeat + usage) (D3)
|
|
86
111
|
// ---------------------------------------------------------------------------
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/executor-harness",
|
|
3
|
-
"version": "1.52.
|
|
3
|
+
"version": "1.52.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,7 +26,7 @@
|
|
|
26
26
|
"hono": "^4.12.30",
|
|
27
27
|
"typescript": "7.0.2",
|
|
28
28
|
"vitest": "^4.1.10",
|
|
29
|
-
"@cat-factory/server": "0.144.
|
|
29
|
+
"@cat-factory/server": "0.144.1",
|
|
30
30
|
"@cat-factory/spend": "0.12.77"
|
|
31
31
|
},
|
|
32
32
|
"scripts": {
|
package/src/agent-runner.ts
CHANGED
|
@@ -21,7 +21,14 @@ import {
|
|
|
21
21
|
} from './pi.js'
|
|
22
22
|
import { killChildProcess, spawnDetached } from './process.js'
|
|
23
23
|
import { redact, secretsToRedact } from './redact.js'
|
|
24
|
-
import { createSliceTracker,
|
|
24
|
+
import { createSliceTracker, startSubagentWatcher } from './subagents.js'
|
|
25
|
+
import {
|
|
26
|
+
createTaskPlanTracker,
|
|
27
|
+
normalizeStatus,
|
|
28
|
+
pickProgress,
|
|
29
|
+
toProgress,
|
|
30
|
+
todosToProgress,
|
|
31
|
+
} from './progress.js'
|
|
25
32
|
import { assertOnboardingKeysCurrent, writeOnboardingPreseed } from './onboarding-preseed.js'
|
|
26
33
|
import { retainSessionTranscripts } from './transcript-retention.js'
|
|
27
34
|
|
|
@@ -343,17 +350,26 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
343
350
|
const publisher = createCallMetricPublisher(calls, opts.onCallMetric)
|
|
344
351
|
|
|
345
352
|
// ADR 0026 D2.1 + ADR 0027 Defect B: surface live slice progress from TWO reconciled
|
|
346
|
-
// sources. The parent's
|
|
353
|
+
// sources. The parent's subagent dispatches + their terminal tool_results DO appear on this
|
|
347
354
|
// stream (only a subagent's intermediate turns don't), so `sliceTracker` derives per-slice
|
|
348
|
-
// progress for the parallel
|
|
349
|
-
//
|
|
350
|
-
// update, so neither masks the other — the pr-reviewer prompt writes its
|
|
351
|
-
//
|
|
355
|
+
// progress for the parallel shape; the parent's own plan (the sequential shape) is tracked
|
|
356
|
+
// by `planTracker` + `lastTodo`. `pickProgress` picks whichever is further along on each
|
|
357
|
+
// update, so neither masks the other — the pr-reviewer prompt writes its plan ONCE and never
|
|
358
|
+
// marks it done, which used to gate the slice signal off and pin progress at 0%.
|
|
359
|
+
//
|
|
360
|
+
// The plan arrives in one of two tool vocabularies depending on the bundled CLI build:
|
|
361
|
+
// `TodoWrite` (whole-list snapshots, tracked in `lastTodo`) or the incremental
|
|
362
|
+
// `TaskCreate`/`TaskUpdate` pair (tracked by `planTracker`, which needs the tool RESULTS too
|
|
363
|
+
// because the task id is minted there). Both are read — see ./progress.ts.
|
|
352
364
|
const sliceTracker = createSliceTracker()
|
|
365
|
+
const planTracker = createTaskPlanTracker()
|
|
353
366
|
let lastTodo: TodoProgress | undefined
|
|
354
367
|
const emitProgress = (): void => {
|
|
355
368
|
if (!opts.onProgress) return
|
|
356
|
-
const progress = pickProgress(
|
|
369
|
+
const progress = pickProgress(
|
|
370
|
+
pickProgress(lastTodo, planTracker.progress()),
|
|
371
|
+
sliceTracker.progress(),
|
|
372
|
+
)
|
|
357
373
|
if (progress) opts.onProgress(progress)
|
|
358
374
|
}
|
|
359
375
|
|
|
@@ -372,6 +388,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
372
388
|
}
|
|
373
389
|
}
|
|
374
390
|
sliceTracker.onAssistant(content)
|
|
391
|
+
planTracker.onAssistant(content)
|
|
375
392
|
emitProgress()
|
|
376
393
|
// Record this call BEFORE appending its turn: the prompt is the history that
|
|
377
394
|
// produced this response. The append-only array keeps each call's prompt a strict
|
|
@@ -394,6 +411,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
394
411
|
const content = (event.message as Record<string, unknown>).content
|
|
395
412
|
if (Array.isArray(content)) {
|
|
396
413
|
sliceTracker.onUser(content)
|
|
414
|
+
planTracker.onUser(content)
|
|
397
415
|
emitProgress()
|
|
398
416
|
messages.push({ role: 'tool', content })
|
|
399
417
|
}
|
|
@@ -584,24 +602,6 @@ async function assembleClaudeOutcome(args: {
|
|
|
584
602
|
}
|
|
585
603
|
}
|
|
586
604
|
|
|
587
|
-
/** Map Claude Code's `TodoWrite` todos array onto subtask counts. */
|
|
588
|
-
function todosToProgress(todos: unknown): TodoProgress | undefined {
|
|
589
|
-
if (!Array.isArray(todos)) return undefined
|
|
590
|
-
const items = todos.filter(isObject).map((t) => ({
|
|
591
|
-
label: typeof t.content === 'string' ? t.content : String(t.content ?? ''),
|
|
592
|
-
status: normalizeStatus(t.status),
|
|
593
|
-
}))
|
|
594
|
-
const completed = items.filter((i) => i.status === 'completed').length
|
|
595
|
-
const inProgress = items.filter((i) => i.status === 'in_progress').length
|
|
596
|
-
return { completed, inProgress, total: items.length, items }
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
function normalizeStatus(status: unknown): 'pending' | 'in_progress' | 'completed' {
|
|
600
|
-
if (status === 'completed') return 'completed'
|
|
601
|
-
if (status === 'in_progress') return 'in_progress'
|
|
602
|
-
return 'pending'
|
|
603
|
-
}
|
|
604
|
-
|
|
605
605
|
function claudeUsage(raw: unknown): { inputTokens: number; outputTokens: number } | undefined {
|
|
606
606
|
if (!isObject(raw)) return undefined
|
|
607
607
|
// Count every input bucket Anthropic bills: fresh input plus BOTH cache reads and
|
|
@@ -829,9 +829,7 @@ function codexPlanProgress(event: Record<string, unknown>): TodoProgress | undef
|
|
|
829
829
|
status: normalizeStatus(s.status),
|
|
830
830
|
}))
|
|
831
831
|
if (items.length === 0) return undefined
|
|
832
|
-
|
|
833
|
-
const inProgress = items.filter((i) => i.status === 'in_progress').length
|
|
834
|
-
return { completed, inProgress, total: items.length, items }
|
|
832
|
+
return toProgress(items)
|
|
835
833
|
}
|
|
836
834
|
|
|
837
835
|
/**
|
package/src/progress.ts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { isObject } from './claude-stream.js'
|
|
2
|
+
import type { TodoProgress } from './pi.js'
|
|
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.
|
|
7
|
+
//
|
|
8
|
+
// The Claude Code CLI exposes the plan through TWO different tool vocabularies, and which one
|
|
9
|
+
// a run uses depends on the CLI build, not on anything the harness controls:
|
|
10
|
+
//
|
|
11
|
+
// - `TodoWrite` — one call carrying the WHOLE list (`todos[]`), each entry with its own
|
|
12
|
+
// status. Every call is a complete snapshot, so the last one wins.
|
|
13
|
+
// - `TaskCreate` / `TaskUpdate` — an incremental, id-keyed task list. `TaskCreate` appends a
|
|
14
|
+
// task and the CLI assigns its id in the tool RESULT; `TaskUpdate` moves one task by id.
|
|
15
|
+
//
|
|
16
|
+
// Both are live in the shipped schema (`sdk-tools.d.ts` in `@anthropic-ai/claude-code` declares
|
|
17
|
+
// `TodoWriteInput` AND `TaskCreateInput`/`TaskUpdateInput`), so the harness tracks both rather
|
|
18
|
+
// than betting on one. Reading only `TodoWrite` is what pinned a CLI 2.1.x pr-review at 0%:
|
|
19
|
+
// the run planned entirely through `TaskCreate`/`TaskUpdate` and the harness saw nothing.
|
|
20
|
+
//
|
|
21
|
+
// Everything here is best-effort and defensive: an unknown status, a missing id, or a result
|
|
22
|
+
// string the CLI reworded degrades to "no progress from this signal" rather than throwing. The
|
|
23
|
+
// tool vocabulary is not a stable contract, so this module may only ever ADD signal.
|
|
24
|
+
|
|
25
|
+
/** Statuses a plan entry can carry; anything unrecognised is treated as not-yet-started. */
|
|
26
|
+
export function normalizeStatus(status: unknown): 'pending' | 'in_progress' | 'completed' {
|
|
27
|
+
if (status === 'completed') return 'completed'
|
|
28
|
+
if (status === 'in_progress') return 'in_progress'
|
|
29
|
+
return 'pending'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Roll a label+status list up into the counts the board renders. Shared by every plan shape. */
|
|
33
|
+
export function toProgress(items: { label: string; status: ReturnType<typeof normalizeStatus> }[]) {
|
|
34
|
+
return {
|
|
35
|
+
completed: items.filter((i) => i.status === 'completed').length,
|
|
36
|
+
inProgress: items.filter((i) => i.status === 'in_progress').length,
|
|
37
|
+
total: items.length,
|
|
38
|
+
items,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Map a `TodoWrite` call's `todos` array onto subtask counts. Each call is a full snapshot. */
|
|
43
|
+
export function todosToProgress(todos: unknown): TodoProgress | undefined {
|
|
44
|
+
if (!Array.isArray(todos)) return undefined
|
|
45
|
+
return toProgress(
|
|
46
|
+
todos.filter(isObject).map((t) => ({
|
|
47
|
+
label: typeof t.content === 'string' ? t.content : String(t.content ?? ''),
|
|
48
|
+
status: normalizeStatus(t.status),
|
|
49
|
+
})),
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The id the CLI assigned to a just-created task, read from `TaskCreate`'s tool RESULT.
|
|
55
|
+
*
|
|
56
|
+
* `TaskCreate`'s INPUT carries only `{subject, description}` — the id is minted by the CLI and
|
|
57
|
+
* comes back on the result, so pairing a later `TaskUpdate({taskId})` to the task it created
|
|
58
|
+
* requires reading the result text. The CLI's shipped `TaskCreateOutput` is
|
|
59
|
+
* `{task: {id, subject}}`, but the parent stream's `tool_result` block carries the rendered
|
|
60
|
+
* STRING (`"Task #1 created successfully: <subject>"`), so both shapes are accepted.
|
|
61
|
+
*/
|
|
62
|
+
export function parseCreatedTaskId(content: unknown): string | undefined {
|
|
63
|
+
if (isObject(content)) {
|
|
64
|
+
const task = isObject(content.task) ? content.task : undefined
|
|
65
|
+
const id = task?.id
|
|
66
|
+
if (typeof id === 'string' && id.trim()) return id.trim()
|
|
67
|
+
if (typeof id === 'number') return String(id)
|
|
68
|
+
}
|
|
69
|
+
const text =
|
|
70
|
+
typeof content === 'string'
|
|
71
|
+
? content
|
|
72
|
+
: Array.isArray(content)
|
|
73
|
+
? content
|
|
74
|
+
.filter(isObject)
|
|
75
|
+
.map((b) => (typeof b.text === 'string' ? b.text : ''))
|
|
76
|
+
.join('\n')
|
|
77
|
+
: ''
|
|
78
|
+
return /\bTask\s+#(\d+)\b/i.exec(text)?.[1]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface PlannedTask {
|
|
82
|
+
id: string
|
|
83
|
+
label: string
|
|
84
|
+
status: ReturnType<typeof normalizeStatus>
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Tracks the parent's incremental `TaskCreate` / `TaskUpdate` plan.
|
|
89
|
+
*
|
|
90
|
+
* A `TaskCreate` is registered as pending against its tool_use id, then bound to the CLI-assigned
|
|
91
|
+
* task id when its result arrives; `TaskUpdate` moves the bound task. A create whose result is
|
|
92
|
+
* never seen (or whose id can't be parsed) still counts toward `total` under a synthetic key, so
|
|
93
|
+
* the plan size stays honest even when the pairing fails — it simply can never advance.
|
|
94
|
+
*
|
|
95
|
+
* `deleted` tombstones are dropped from the list entirely (matching `TodoWrite`'s live-tasks-only
|
|
96
|
+
* shape), so a task the agent abandons doesn't hold the bar back forever.
|
|
97
|
+
*/
|
|
98
|
+
export interface TaskPlanTracker {
|
|
99
|
+
/** Feed an `assistant` message's content blocks: registers creates + applies updates. */
|
|
100
|
+
onAssistant(content: unknown[]): void
|
|
101
|
+
/** Feed a `user` message's content blocks: binds each create to its CLI-assigned task id. */
|
|
102
|
+
onUser(content: unknown[]): void
|
|
103
|
+
/** The plan as progress counts, or undefined when nothing has been planned yet. */
|
|
104
|
+
progress(): TodoProgress | undefined
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function createTaskPlanTracker(): TaskPlanTracker {
|
|
108
|
+
// Insertion-ordered so `items` render in plan order.
|
|
109
|
+
const tasks = new Map<string, PlannedTask>()
|
|
110
|
+
// tool_use id of an unresolved `TaskCreate` -> the synthetic key it was filed under, so the
|
|
111
|
+
// task can be re-keyed to its real id once the result lands.
|
|
112
|
+
const pendingCreates = new Map<string, string>()
|
|
113
|
+
// Updates that arrived before their target was bound (the CLI can interleave), replayed on bind.
|
|
114
|
+
const orphanUpdates = new Map<string, Partial<PlannedTask>>()
|
|
115
|
+
// `deleted` tombstones for a task id whose create has not bound yet, replayed on bind — else a
|
|
116
|
+
// delete that races ahead of its create leaves the task in the plan forever.
|
|
117
|
+
const pendingDeletes = new Set<string>()
|
|
118
|
+
|
|
119
|
+
const apply = (task: PlannedTask, patch: Partial<PlannedTask>): void => {
|
|
120
|
+
if (patch.label) task.label = patch.label
|
|
121
|
+
if (patch.status) task.status = patch.status
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Drop a tombstoned task. When it isn't present yet (its create hasn't bound), remember the
|
|
125
|
+
// tombstone so the bind drops it rather than leaving it stuck in the plan forever.
|
|
126
|
+
const markDeleted = (taskId: string): void => {
|
|
127
|
+
if (!tasks.delete(taskId)) pendingDeletes.add(taskId)
|
|
128
|
+
orphanUpdates.delete(taskId)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
onAssistant(content) {
|
|
133
|
+
if (!Array.isArray(content)) return
|
|
134
|
+
for (const block of content) {
|
|
135
|
+
if (!isObject(block) || block.type !== 'tool_use') continue
|
|
136
|
+
const input = isObject(block.input) ? block.input : {}
|
|
137
|
+
if (block.name === 'TaskCreate') {
|
|
138
|
+
const toolUseId = typeof block.id === 'string' ? block.id : undefined
|
|
139
|
+
if (!toolUseId || pendingCreates.has(toolUseId)) continue
|
|
140
|
+
const label =
|
|
141
|
+
(typeof input.subject === 'string' && input.subject.trim()) ||
|
|
142
|
+
(typeof input.description === 'string' && input.description.trim()) ||
|
|
143
|
+
`Task ${tasks.size + 1}`
|
|
144
|
+
const key = `pending:${toolUseId}`
|
|
145
|
+
tasks.set(key, { id: key, label, status: 'pending' })
|
|
146
|
+
pendingCreates.set(toolUseId, key)
|
|
147
|
+
} else if (block.name === 'TaskUpdate') {
|
|
148
|
+
const taskId = typeof input.taskId === 'string' ? input.taskId : undefined
|
|
149
|
+
if (!taskId) continue
|
|
150
|
+
const patch: Partial<PlannedTask> = {}
|
|
151
|
+
if (typeof input.subject === 'string' && input.subject.trim())
|
|
152
|
+
patch.label = input.subject.trim()
|
|
153
|
+
if (input.status === 'deleted') {
|
|
154
|
+
// `deleted` is a tombstone, not a status — drop the task from the live list.
|
|
155
|
+
markDeleted(taskId)
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
if (input.status !== undefined) patch.status = normalizeStatus(input.status)
|
|
159
|
+
const task = tasks.get(taskId)
|
|
160
|
+
if (task) apply(task, patch)
|
|
161
|
+
else orphanUpdates.set(taskId, { ...orphanUpdates.get(taskId), ...patch })
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
onUser(content) {
|
|
166
|
+
if (!Array.isArray(content)) return
|
|
167
|
+
for (const block of content) {
|
|
168
|
+
if (!isObject(block) || block.type !== 'tool_result') continue
|
|
169
|
+
const toolUseId = typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined
|
|
170
|
+
const key = toolUseId ? pendingCreates.get(toolUseId) : undefined
|
|
171
|
+
if (!key) continue
|
|
172
|
+
const taskId = parseCreatedTaskId(block.content)
|
|
173
|
+
pendingCreates.delete(toolUseId!)
|
|
174
|
+
const task = tasks.get(key)
|
|
175
|
+
// No parsable id ⇒ leave it filed under its synthetic key: it still counts toward the
|
|
176
|
+
// plan total, it just can never be advanced by a later `TaskUpdate`. A parsed id that
|
|
177
|
+
// already names a live task (a duplicate / misparse) is also left under the synthetic key
|
|
178
|
+
// rather than overwriting that task — the rebuild below would otherwise drop a row and
|
|
179
|
+
// undercount `total`.
|
|
180
|
+
if (!taskId || !task || taskId === key || tasks.has(taskId)) continue
|
|
181
|
+
// Re-key in place. Rebuilding the map preserves insertion order, which `items` relies on.
|
|
182
|
+
const entries = [...tasks.entries()]
|
|
183
|
+
tasks.clear()
|
|
184
|
+
for (const [k, v] of entries) {
|
|
185
|
+
if (k !== key) tasks.set(k, v)
|
|
186
|
+
else tasks.set(taskId, { ...v, id: taskId })
|
|
187
|
+
}
|
|
188
|
+
// A tombstone that raced ahead of this bind drops the task now that it exists.
|
|
189
|
+
if (pendingDeletes.delete(taskId)) {
|
|
190
|
+
tasks.delete(taskId)
|
|
191
|
+
orphanUpdates.delete(taskId)
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
const pendingPatch = orphanUpdates.get(taskId)
|
|
195
|
+
if (pendingPatch) {
|
|
196
|
+
const bound = tasks.get(taskId)
|
|
197
|
+
if (bound) apply(bound, pendingPatch)
|
|
198
|
+
orphanUpdates.delete(taskId)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
progress() {
|
|
203
|
+
if (tasks.size === 0) return undefined
|
|
204
|
+
return toProgress([...tasks.values()].map((t) => ({ label: t.label, status: t.status })))
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
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
|
|
220
|
+
* single input is present when only one is.
|
|
221
|
+
*/
|
|
222
|
+
export function pickProgress(
|
|
223
|
+
todo: TodoProgress | undefined,
|
|
224
|
+
slice: TodoProgress | undefined,
|
|
225
|
+
): TodoProgress | undefined {
|
|
226
|
+
if (!todo) return slice
|
|
227
|
+
if (!slice) return todo
|
|
228
|
+
if (slice.completed !== todo.completed) return slice.completed > todo.completed ? slice : todo
|
|
229
|
+
if (slice.inProgress !== todo.inProgress) return slice.inProgress > todo.inProgress ? slice : todo
|
|
230
|
+
if (slice.total !== todo.total) return slice.total > todo.total ? slice : todo
|
|
231
|
+
return todo
|
|
232
|
+
}
|
package/src/subagents.ts
CHANGED
|
@@ -19,10 +19,10 @@ import { publishCallMetric, type HarnessCallMetric, type TodoProgress } from './
|
|
|
19
19
|
// subagent parallelism:
|
|
20
20
|
//
|
|
21
21
|
// - {@link createSliceTracker} derives the slice plan + per-slice progress from the
|
|
22
|
-
// PARENT stream alone — the
|
|
22
|
+
// PARENT stream alone — the subagent-dispatch tool_use and its terminal tool_result
|
|
23
23
|
// DO appear there (only the subagent's intermediate turns don't), so slices/progress
|
|
24
|
-
// need no file watching (D2.1).
|
|
25
|
-
//
|
|
24
|
+
// need no file watching (D2.1). `pickProgress` (./progress.ts) reconciles it with the
|
|
25
|
+
// parent's own plan (ADR 0027 Defect B);
|
|
26
26
|
// - {@link startSubagentWatcher} tails the `subagents/*.jsonl` transcripts for the
|
|
27
27
|
// heartbeat (any new bytes ⇒ `onActivity`) and sums each subagent turn's usage into
|
|
28
28
|
// the run's telemetry (D3).
|
|
@@ -42,17 +42,32 @@ import { publishCallMetric, type HarnessCallMetric, type TodoProgress } from './
|
|
|
42
42
|
// Slice / progress tracking off the PARENT stream (D2.1)
|
|
43
43
|
// ---------------------------------------------------------------------------
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* The tool names the Claude Code CLI dispatches a parallel subagent under. `Agent` is what the
|
|
47
|
+
* shipped schema declares (`AgentInput` in `sdk-tools.d.ts`, carrying `description` / `prompt` /
|
|
48
|
+
* `subagent_type`); `Task` is the older name for the same dispatch. Both are matched because the
|
|
49
|
+
* harness runs against whatever CLI the image happens to bundle, and matching only the old name
|
|
50
|
+
* is what left a CLI 2.1.x pr-review reporting no slices at all.
|
|
51
|
+
*
|
|
52
|
+
* Note the asymmetry: keeping the legacy `Task` here is the one place a CLI rename could produce a
|
|
53
|
+
* FALSE signal rather than merely no signal — if a future build were to name a plain task-list
|
|
54
|
+
* tool `Task`, its writes would be counted as in-flight slices. We accept that because no shipped
|
|
55
|
+
* build does (the incremental plan tool is `TaskCreate`/`TaskUpdate`, tracked separately in
|
|
56
|
+
* `progress.ts`), and dropping legacy coverage is the more likely regression.
|
|
57
|
+
*/
|
|
58
|
+
const SUBAGENT_TOOL_NAMES = new Set(['Agent', 'Task'])
|
|
59
|
+
|
|
45
60
|
interface TrackedSlice {
|
|
46
|
-
/** The
|
|
61
|
+
/** The dispatch's tool_use id, used to pair the terminal tool_result. */
|
|
47
62
|
toolUseId: string
|
|
48
63
|
/** The subagent's description (`Review <slice> slice`), rendered as the progress label. */
|
|
49
64
|
description: string
|
|
50
65
|
done: boolean
|
|
51
66
|
}
|
|
52
67
|
|
|
53
|
-
/** Tracks parallel
|
|
68
|
+
/** Tracks parallel subagents seen on the parent stream to derive slice progress. */
|
|
54
69
|
export interface SliceTracker {
|
|
55
|
-
/** Feed an `assistant` message's content blocks: registers any
|
|
70
|
+
/** Feed an `assistant` message's content blocks: registers any subagent dispatches. */
|
|
56
71
|
onAssistant(content: unknown[]): void
|
|
57
72
|
/** Feed a `user` message's content blocks: marks the paired subagent(s) complete. */
|
|
58
73
|
onUser(content: unknown[]): void
|
|
@@ -60,8 +75,8 @@ export interface SliceTracker {
|
|
|
60
75
|
hasSlices(): boolean
|
|
61
76
|
/**
|
|
62
77
|
* Progress derived from the dispatched subagents (completed / in-flight / total),
|
|
63
|
-
* or undefined when none have been dispatched. Reconciled with
|
|
64
|
-
*
|
|
78
|
+
* or undefined when none have been dispatched. Reconciled with the parent's own plan
|
|
79
|
+
* by `pickProgress` (./progress.ts) — it is NOT gated off by the presence of a plan
|
|
65
80
|
* (that gate was ADR 0027 Defect B: the pr-reviewer prompt writes the plan ONCE at
|
|
66
81
|
* grouping time and never marks it done, which used to permanently mask this signal).
|
|
67
82
|
*/
|
|
@@ -76,7 +91,8 @@ export function createSliceTracker(): SliceTracker {
|
|
|
76
91
|
onAssistant(content) {
|
|
77
92
|
if (!Array.isArray(content)) return
|
|
78
93
|
for (const block of content) {
|
|
79
|
-
if (!isObject(block) || block.type !== 'tool_use'
|
|
94
|
+
if (!isObject(block) || block.type !== 'tool_use') continue
|
|
95
|
+
if (typeof block.name !== 'string' || !SUBAGENT_TOOL_NAMES.has(block.name)) continue
|
|
80
96
|
const id = typeof block.id === 'string' ? block.id : undefined
|
|
81
97
|
if (!id || slices.has(id)) continue
|
|
82
98
|
const input = isObject(block.input) ? block.input : {}
|
|
@@ -116,31 +132,6 @@ export function createSliceTracker(): SliceTracker {
|
|
|
116
132
|
}
|
|
117
133
|
}
|
|
118
134
|
|
|
119
|
-
/**
|
|
120
|
-
* Reconcile the two redundant views of the same slice work into the one to surface
|
|
121
|
-
* (ADR 0027 Defect B). A pr-reviewer run has BOTH a parent `TodoWrite` plan (the slices,
|
|
122
|
-
* written once at grouping time) and the {@link SliceTracker}'s `Task`-dispatch view. The
|
|
123
|
-
* sequential shape advances the todo plan; the parallel-subagent shape advances ONLY the
|
|
124
|
-
* slice tracker (the CLI writes the plan once and never marks it done, while the parallel
|
|
125
|
-
* `Task`s report in-flight/complete). Neither alone covers both shapes, and gating the
|
|
126
|
-
* slice tracker OFF whenever a todo plan exists (the old behaviour) pinned parallel runs
|
|
127
|
-
* at 0%. So prefer whichever view is further along: more `completed`, then more
|
|
128
|
-
* `inProgress` (an all-pending todo plan must not beat live in-flight slices), then more
|
|
129
|
-
* `total` (the richer view — the todo plan can carry an extra "aggregate" entry), else the
|
|
130
|
-
* todo plan. Pure + total; returns whichever single input is present when only one is.
|
|
131
|
-
*/
|
|
132
|
-
export function pickProgress(
|
|
133
|
-
todo: TodoProgress | undefined,
|
|
134
|
-
slice: TodoProgress | undefined,
|
|
135
|
-
): TodoProgress | undefined {
|
|
136
|
-
if (!todo) return slice
|
|
137
|
-
if (!slice) return todo
|
|
138
|
-
if (slice.completed !== todo.completed) return slice.completed > todo.completed ? slice : todo
|
|
139
|
-
if (slice.inProgress !== todo.inProgress) return slice.inProgress > todo.inProgress ? slice : todo
|
|
140
|
-
if (slice.total !== todo.total) return slice.total > todo.total ? slice : todo
|
|
141
|
-
return todo
|
|
142
|
-
}
|
|
143
|
-
|
|
144
135
|
// ---------------------------------------------------------------------------
|
|
145
136
|
// Subagent transcript watcher (heartbeat + usage) (D3)
|
|
146
137
|
// ---------------------------------------------------------------------------
|