@cat-factory/executor-harness 1.50.18 → 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/README.md +13 -0
- package/dist/agent-runner.js +41 -36
- package/dist/pi-workspace.js +4 -0
- package/dist/pi.js +59 -0
- package/dist/progress.js +217 -0
- package/dist/runner.js +18 -4
- package/dist/subagents.js +55 -29
- package/package.json +3 -3
- package/src/agent-runner.ts +92 -54
- package/src/pi-workspace.ts +4 -0
- package/src/pi.ts +87 -0
- package/src/progress.ts +232 -0
- package/src/runner.ts +51 -4
- package/src/subagents.ts +54 -52
package/README.md
CHANGED
|
@@ -35,6 +35,19 @@ are surfaced as `progress` while a job runs. The exact request/response shapes
|
|
|
35
35
|
cat-factory sends are documented in
|
|
36
36
|
[`docs/runner-pool-integration.md`](../../docs/runner-pool-integration.md).
|
|
37
37
|
|
|
38
|
+
`GET /jobs/{id}` is also the harness's observability channel: `spans`, `followUps`
|
|
39
|
+
and `callMetrics` are **drain-on-read** — each poll returns what accumulated since
|
|
40
|
+
the previous one and clears the buffer. That is deliberate. A job that dies before
|
|
41
|
+
it can return a terminal result (an evicted container, an OOM-killed process) has
|
|
42
|
+
still reported the tool spans it ran and the model calls it paid for. Each drained
|
|
43
|
+
`callMetrics` entry carries a job-scoped `seq`, and the terminal result repeats the
|
|
44
|
+
complete list, so the backend can take both channels without double-counting a call.
|
|
45
|
+
|
|
46
|
+
Because the backend records a call as soon as it drains it (and ignores the terminal
|
|
47
|
+
repeat), a drained call is FINAL. A call whose tokens are still open — a CLI that reports
|
|
48
|
+
only a cumulative total, costed at the end — is withheld from the drain until it is
|
|
49
|
+
complete; see `createCallMetricPublisher` in `src/pi.ts`.
|
|
50
|
+
|
|
38
51
|
## What a job does
|
|
39
52
|
|
|
40
53
|
The implementation job (`POST /run`) is the canonical sequence:
|
package/dist/agent-runner.js
CHANGED
|
@@ -3,9 +3,11 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
|
3
3
|
import { homedir, tmpdir } from 'node:os';
|
|
4
4
|
import { dirname, join } from 'node:path';
|
|
5
5
|
import { claudeAssistantContent, claudeCallUsage, isObject, numberOf, redactBody, } from './claude-stream.js';
|
|
6
|
+
import { createCallMetricPublisher, publishCallMetric, } from './pi.js';
|
|
6
7
|
import { killChildProcess, spawnDetached } from './process.js';
|
|
7
8
|
import { redact, secretsToRedact } from './redact.js';
|
|
8
|
-
import { createSliceTracker,
|
|
9
|
+
import { createSliceTracker, startSubagentWatcher } from './subagents.js';
|
|
10
|
+
import { createTaskPlanTracker, normalizeStatus, pickProgress, toProgress, todosToProgress, } from './progress.js';
|
|
9
11
|
import { assertOnboardingKeysCurrent, writeOnboardingPreseed } from './onboarding-preseed.js';
|
|
10
12
|
import { retainSessionTranscripts } from './transcript-retention.js';
|
|
11
13
|
/**
|
|
@@ -219,19 +221,28 @@ export async function runClaudeCode(opts) {
|
|
|
219
221
|
{ role: 'user', content: opts.userPrompt },
|
|
220
222
|
];
|
|
221
223
|
const calls = [];
|
|
224
|
+
// Streams each call as the CLI yields it, EXCEPT one whose tokens `attributeCumulativeUsage`
|
|
225
|
+
// may still rewrite below (a published call must be final — see the publisher).
|
|
226
|
+
const publisher = createCallMetricPublisher(calls, opts.onCallMetric);
|
|
222
227
|
// ADR 0026 D2.1 + ADR 0027 Defect B: surface live slice progress from TWO reconciled
|
|
223
|
-
// sources. The parent's
|
|
228
|
+
// sources. The parent's subagent dispatches + their terminal tool_results DO appear on this
|
|
224
229
|
// stream (only a subagent's intermediate turns don't), so `sliceTracker` derives per-slice
|
|
225
|
-
// progress for the parallel
|
|
226
|
-
//
|
|
227
|
-
// update, so neither masks the other — the pr-reviewer prompt writes its
|
|
228
|
-
//
|
|
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.
|
|
229
239
|
const sliceTracker = createSliceTracker();
|
|
240
|
+
const planTracker = createTaskPlanTracker();
|
|
230
241
|
let lastTodo;
|
|
231
242
|
const emitProgress = () => {
|
|
232
243
|
if (!opts.onProgress)
|
|
233
244
|
return;
|
|
234
|
-
const progress = pickProgress(lastTodo, sliceTracker.progress());
|
|
245
|
+
const progress = pickProgress(pickProgress(lastTodo, planTracker.progress()), sliceTracker.progress());
|
|
235
246
|
if (progress)
|
|
236
247
|
opts.onProgress(progress);
|
|
237
248
|
};
|
|
@@ -251,12 +262,13 @@ export async function runClaudeCode(opts) {
|
|
|
251
262
|
}
|
|
252
263
|
}
|
|
253
264
|
sliceTracker.onAssistant(content);
|
|
265
|
+
planTracker.onAssistant(content);
|
|
254
266
|
emitProgress();
|
|
255
267
|
// Record this call BEFORE appending its turn: the prompt is the history that
|
|
256
268
|
// produced this response. The append-only array keeps each call's prompt a strict
|
|
257
269
|
// prefix of the next, so the backend's telemetry chain delta-compresses cleanly.
|
|
258
270
|
const u = claudeCallUsage(message.usage);
|
|
259
|
-
|
|
271
|
+
publisher.publish({
|
|
260
272
|
...(typeof message.model === 'string' ? { model: message.model } : {}),
|
|
261
273
|
promptText: redactBody(JSON.stringify(messages), secrets),
|
|
262
274
|
messageCount: messages.length,
|
|
@@ -274,6 +286,7 @@ export async function runClaudeCode(opts) {
|
|
|
274
286
|
const content = event.message.content;
|
|
275
287
|
if (Array.isArray(content)) {
|
|
276
288
|
sliceTracker.onUser(content);
|
|
289
|
+
planTracker.onUser(content);
|
|
277
290
|
emitProgress();
|
|
278
291
|
messages.push({ role: 'tool', content });
|
|
279
292
|
}
|
|
@@ -333,6 +346,7 @@ export async function runClaudeCode(opts) {
|
|
|
333
346
|
...(opts.onActivity ? { onActivity: opts.onActivity } : {}),
|
|
334
347
|
secrets,
|
|
335
348
|
model: opts.model,
|
|
349
|
+
...(opts.onCallMetric ? { onCallMetric: opts.onCallMetric } : {}),
|
|
336
350
|
...(opts.log ? { log: opts.log } : {}),
|
|
337
351
|
})
|
|
338
352
|
: undefined;
|
|
@@ -355,7 +369,15 @@ export async function runClaudeCode(opts) {
|
|
|
355
369
|
...appendArgs,
|
|
356
370
|
],
|
|
357
371
|
}, prompt, opts, env, opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [], onEvent);
|
|
358
|
-
return await assembleClaudeOutcome({
|
|
372
|
+
return await assembleClaudeOutcome({
|
|
373
|
+
summary,
|
|
374
|
+
stats,
|
|
375
|
+
stderrTail,
|
|
376
|
+
calls,
|
|
377
|
+
publisher,
|
|
378
|
+
usage,
|
|
379
|
+
subagents,
|
|
380
|
+
});
|
|
359
381
|
}
|
|
360
382
|
finally {
|
|
361
383
|
await subagents?.stop();
|
|
@@ -401,10 +423,14 @@ function buildClaudeEnv(opts, configHome) {
|
|
|
401
423
|
* {@link runClaudeCode} verbatim to keep its cyclomatic complexity down.
|
|
402
424
|
*/
|
|
403
425
|
async function assembleClaudeOutcome(args) {
|
|
404
|
-
const { summary, stats, stderrTail, calls, usage, subagents } = args;
|
|
426
|
+
const { summary, stats, stderrTail, calls, publisher, usage, subagents } = args;
|
|
405
427
|
// The parent's cumulative-usage fallback applies to the PARENT calls only (before the
|
|
406
428
|
// subagent calls, which carry their own per-turn tokens, are concatenated).
|
|
407
429
|
attributeCumulativeUsage(calls, usage);
|
|
430
|
+
// The withheld calls are final only NOW, so stream them: the completion poll drains them
|
|
431
|
+
// alongside the result, and the backend records the attributed numbers rather than the zeros
|
|
432
|
+
// they carried while the run was in flight.
|
|
433
|
+
publisher.flush();
|
|
408
434
|
// Final drain of any subagent transcript writes that landed after the last poll, then
|
|
409
435
|
// fold the subagents' usage + per-call telemetry into the run's outcome.
|
|
410
436
|
await subagents?.stop();
|
|
@@ -425,25 +451,6 @@ async function assembleClaudeOutcome(args) {
|
|
|
425
451
|
...(mergedCalls.length ? { callMetrics: mergedCalls } : {}),
|
|
426
452
|
};
|
|
427
453
|
}
|
|
428
|
-
/** Map Claude Code's `TodoWrite` todos array onto subtask counts. */
|
|
429
|
-
function todosToProgress(todos) {
|
|
430
|
-
if (!Array.isArray(todos))
|
|
431
|
-
return undefined;
|
|
432
|
-
const items = todos.filter(isObject).map((t) => ({
|
|
433
|
-
label: typeof t.content === 'string' ? t.content : String(t.content ?? ''),
|
|
434
|
-
status: normalizeStatus(t.status),
|
|
435
|
-
}));
|
|
436
|
-
const completed = items.filter((i) => i.status === 'completed').length;
|
|
437
|
-
const inProgress = items.filter((i) => i.status === 'in_progress').length;
|
|
438
|
-
return { completed, inProgress, total: items.length, items };
|
|
439
|
-
}
|
|
440
|
-
function normalizeStatus(status) {
|
|
441
|
-
if (status === 'completed')
|
|
442
|
-
return 'completed';
|
|
443
|
-
if (status === 'in_progress')
|
|
444
|
-
return 'in_progress';
|
|
445
|
-
return 'pending';
|
|
446
|
-
}
|
|
447
454
|
function claudeUsage(raw) {
|
|
448
455
|
if (!isObject(raw))
|
|
449
456
|
return undefined;
|
|
@@ -537,7 +544,7 @@ export async function runCodex(opts) {
|
|
|
537
544
|
// assistant text seen since the previous turn as one telemetry call.
|
|
538
545
|
const perTurn = codexLastTurnUsage(event);
|
|
539
546
|
if (perTurn) {
|
|
540
|
-
calls
|
|
547
|
+
publishCallMetric(calls, {
|
|
541
548
|
model: opts.model,
|
|
542
549
|
promptText: redactBody(JSON.stringify(messages), secrets),
|
|
543
550
|
messageCount: messages.length,
|
|
@@ -547,7 +554,7 @@ export async function runCodex(opts) {
|
|
|
547
554
|
cachedInputTokens: perTurn.cachedInputTokens,
|
|
548
555
|
outputTokens: perTurn.outputTokens,
|
|
549
556
|
finishReason: null,
|
|
550
|
-
});
|
|
557
|
+
}, opts.onCallMetric);
|
|
551
558
|
if (pendingText)
|
|
552
559
|
messages.push({ role: 'assistant', content: pendingText });
|
|
553
560
|
pendingText = '';
|
|
@@ -571,7 +578,7 @@ export async function runCodex(opts) {
|
|
|
571
578
|
// Fallback for a CLI/version that never emits per-turn `last_token_usage`: record a
|
|
572
579
|
// single call from the cumulative total + final text so the run is still observable.
|
|
573
580
|
if (calls.length === 0 && (usage || summary)) {
|
|
574
|
-
calls
|
|
581
|
+
publishCallMetric(calls, {
|
|
575
582
|
model: opts.model,
|
|
576
583
|
promptText: redactBody(JSON.stringify(messages), secrets),
|
|
577
584
|
messageCount: messages.length,
|
|
@@ -581,7 +588,7 @@ export async function runCodex(opts) {
|
|
|
581
588
|
cachedInputTokens: 0,
|
|
582
589
|
outputTokens: usage?.outputTokens ?? 0,
|
|
583
590
|
finishReason: null,
|
|
584
|
-
});
|
|
591
|
+
}, opts.onCallMetric);
|
|
585
592
|
}
|
|
586
593
|
return {
|
|
587
594
|
summary,
|
|
@@ -653,9 +660,7 @@ function codexPlanProgress(event) {
|
|
|
653
660
|
}));
|
|
654
661
|
if (items.length === 0)
|
|
655
662
|
return undefined;
|
|
656
|
-
|
|
657
|
-
const inProgress = items.filter((i) => i.status === 'in_progress').length;
|
|
658
|
-
return { completed, inProgress, total: items.length, items };
|
|
663
|
+
return toProgress(items);
|
|
659
664
|
}
|
|
660
665
|
/**
|
|
661
666
|
* Best-effort: pull token usage out of a Codex usage event. Codex `exec --json`
|
package/dist/pi-workspace.js
CHANGED
|
@@ -147,6 +147,10 @@ export async function runAgentInWorkspace(spec, opts = {}) {
|
|
|
147
147
|
signal: opts.signal,
|
|
148
148
|
onActivity: opts.onActivity,
|
|
149
149
|
onProgress: opts.onProgress,
|
|
150
|
+
// Stream this run's per-call telemetry to the job's live drain. The subscription
|
|
151
|
+
// harnesses are the only producers of `callMetrics` (Pi's calls are metered by the LLM
|
|
152
|
+
// proxy as they happen), so this is the only path that needs the hook.
|
|
153
|
+
onCallMetric: opts.onCallMetric,
|
|
150
154
|
...(opts.log ? { log: opts.log } : {}),
|
|
151
155
|
});
|
|
152
156
|
return withEffortReport(spec.dir, subOutcome);
|
package/dist/pi.js
CHANGED
|
@@ -344,6 +344,65 @@ export async function writeWebToolsConfig(config) {
|
|
|
344
344
|
function isObject(value) {
|
|
345
345
|
return typeof value === 'object' && value !== null;
|
|
346
346
|
}
|
|
347
|
+
/**
|
|
348
|
+
* Publish one captured model call: append it to the run's list (which becomes the terminal
|
|
349
|
+
* result's `callMetrics`) AND hand the SAME object to the live stream, where the job registry
|
|
350
|
+
* stamps its {@link HarnessCallMetric.seq} and buffers it for the next poll to drain.
|
|
351
|
+
*
|
|
352
|
+
* Every producer goes through here rather than a bare `calls.push`, so the two channels can't
|
|
353
|
+
* drift: a call that reaches the terminal list but never the live stream would be invisible
|
|
354
|
+
* until the job ends, and one that reaches only the live stream would go unrecorded if the
|
|
355
|
+
* poll response were lost.
|
|
356
|
+
*
|
|
357
|
+
* A published call must be FINAL. The backend records it the moment the drain reaches it and
|
|
358
|
+
* IGNORES the terminal repeat (first write wins, so its stored prompt delta stays valid against
|
|
359
|
+
* the chain tip it was written against), which means a field mutated after publishing never
|
|
360
|
+
* reaches the store. A producer whose calls can still change (the cumulative-usage fallback,
|
|
361
|
+
* whose totals arrive with the CLI's terminal `result` event) publishes through
|
|
362
|
+
* {@link createCallMetricPublisher} instead, which withholds exactly those.
|
|
363
|
+
*/
|
|
364
|
+
export function publishCallMetric(calls, call, onCallMetric) {
|
|
365
|
+
calls.push(call);
|
|
366
|
+
onCallMetric?.(call);
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* A {@link publishCallMetric} wrapper for a producer whose per-call tokens may be filled in at
|
|
370
|
+
* the END of the run: a CLI that reports only a cumulative total leaves every turn at zero, and
|
|
371
|
+
* `attributeCumulativeUsage` pins the total onto the last call once the terminal `result` event
|
|
372
|
+
* arrives.
|
|
373
|
+
*
|
|
374
|
+
* Since a published call must be final (the backend stores it on the drain and ignores the
|
|
375
|
+
* terminal repeat), a call the CLI did NOT cost is appended to the list but WITHHELD from the
|
|
376
|
+
* live stream — otherwise it records as a zero-token row and the attributed numbers never land.
|
|
377
|
+
* The withholding window closes the moment any call IS costed: attribution can no longer fire, so
|
|
378
|
+
* everything held is final and released at once, in capture order, and every later call streams
|
|
379
|
+
* immediately whatever its tokens. {@link flush} covers the run that was never costed at all.
|
|
380
|
+
*/
|
|
381
|
+
export function createCallMetricPublisher(calls, onCallMetric) {
|
|
382
|
+
const withheld = [];
|
|
383
|
+
let anyCosted = false;
|
|
384
|
+
const flush = () => {
|
|
385
|
+
for (const call of withheld)
|
|
386
|
+
onCallMetric?.(call);
|
|
387
|
+
withheld.length = 0;
|
|
388
|
+
};
|
|
389
|
+
return {
|
|
390
|
+
publish(call) {
|
|
391
|
+
const costed = call.inputTokens > 0 || call.outputTokens > 0;
|
|
392
|
+
if (!costed && !anyCosted) {
|
|
393
|
+
publishCallMetric(calls, call);
|
|
394
|
+
withheld.push(call);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (costed)
|
|
398
|
+
anyCosted = true;
|
|
399
|
+
// Released BEFORE this call so the live sequence stays in capture order.
|
|
400
|
+
flush();
|
|
401
|
+
publishCallMetric(calls, call, onCallMetric);
|
|
402
|
+
},
|
|
403
|
+
flush,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
347
406
|
/**
|
|
348
407
|
* Pull the `todo` tool's result `details` out of a Pi `--mode json` event, or
|
|
349
408
|
* undefined if the event isn't a successful `todo` tool result.
|
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/runner.js
CHANGED
|
@@ -30,7 +30,7 @@ export function loadRunnerLimits(env = process.env) {
|
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
32
|
function toView(entry) {
|
|
33
|
-
const { promise: _promise, spanBuffer: _spanBuffer, followUpBuffer: _followUpBuffer, abort: _abort, ...view } = entry;
|
|
33
|
+
const { promise: _promise, spanBuffer: _spanBuffer, followUpBuffer: _followUpBuffer, callMetricBuffer: _callMetricBuffer, callMetricSeq: _callMetricSeq, abort: _abort, ...view } = entry;
|
|
34
34
|
return { ...view };
|
|
35
35
|
}
|
|
36
36
|
/**
|
|
@@ -75,15 +75,18 @@ export class JobRegistry {
|
|
|
75
75
|
promise: Promise.resolve(),
|
|
76
76
|
spanBuffer: [],
|
|
77
77
|
followUpBuffer: [],
|
|
78
|
+
callMetricBuffer: [],
|
|
79
|
+
callMetricSeq: 0,
|
|
78
80
|
};
|
|
79
81
|
this.jobs.set(id, entry);
|
|
80
82
|
entry.promise = this.drive(entry, job);
|
|
81
83
|
return toView(entry);
|
|
82
84
|
}
|
|
83
85
|
/**
|
|
84
|
-
* Poll the job — and DRAIN its
|
|
85
|
-
* handler is the sole caller, so each poll returns the spans
|
|
86
|
-
* previous poll and clears them, bounding the harness
|
|
86
|
+
* Poll the job — and DRAIN its observability buffers (drain-on-read). The GET /jobs/{id}
|
|
87
|
+
* handler is the sole caller, so each poll returns the spans / follow-ups / call metrics
|
|
88
|
+
* accumulated since the previous poll and clears them, bounding the harness buffers to one
|
|
89
|
+
* poll interval.
|
|
87
90
|
*/
|
|
88
91
|
get(id) {
|
|
89
92
|
const entry = this.jobs.get(id);
|
|
@@ -98,6 +101,10 @@ export class JobRegistry {
|
|
|
98
101
|
view.followUps = entry.followUpBuffer;
|
|
99
102
|
entry.followUpBuffer = [];
|
|
100
103
|
}
|
|
104
|
+
if (entry.callMetricBuffer.length > 0) {
|
|
105
|
+
view.callMetrics = entry.callMetricBuffer;
|
|
106
|
+
entry.callMetricBuffer = [];
|
|
107
|
+
}
|
|
101
108
|
return view;
|
|
102
109
|
}
|
|
103
110
|
/**
|
|
@@ -206,6 +213,13 @@ export class JobRegistry {
|
|
|
206
213
|
onFollowUp: (items) => {
|
|
207
214
|
entry.followUpBuffer.push(...items);
|
|
208
215
|
},
|
|
216
|
+
onCallMetric: (call) => {
|
|
217
|
+
// Stamp the job-scoped sequence on the metric OBJECT: the handler keeps the same
|
|
218
|
+
// instance for its terminal result, so both channels carry the same `seq` and the
|
|
219
|
+
// backend mints one stable row id per call.
|
|
220
|
+
call.seq = entry.callMetricSeq++;
|
|
221
|
+
entry.callMetricBuffer.push(call);
|
|
222
|
+
},
|
|
209
223
|
onPhase: (next) => markPhase(next),
|
|
210
224
|
log: jobLog,
|
|
211
225
|
});
|