@cat-factory/executor-harness 1.78.0 → 1.80.0
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 +15 -1
- package/dist/claude-stream.js +23 -0
- package/dist/pi-workspace.js +4 -0
- package/dist/runner.js +3 -0
- package/dist/subagents.js +74 -4
- package/package.json +4 -4
- package/src/agent-runner.ts +21 -2
- package/src/claude-stream.ts +21 -0
- package/src/pi-workspace.ts +4 -0
- package/src/runner.ts +24 -0
- package/src/subagents.ts +57 -3
package/dist/agent-runner.js
CHANGED
|
@@ -384,7 +384,7 @@ export async function runClaudeCode(opts) {
|
|
|
384
384
|
// either/or; the plan then MERGES with the dispatch view (`mergeProgress`) rather than
|
|
385
385
|
// competing with it — picking the further-along view collapsed the list to the dispatched
|
|
386
386
|
// slices alone the moment the first subagent returned. See ./progress.ts.
|
|
387
|
-
const sliceTracker = createSliceTracker();
|
|
387
|
+
const sliceTracker = createSliceTracker(secrets);
|
|
388
388
|
const planTracker = createTaskPlanTracker();
|
|
389
389
|
let lastTodo;
|
|
390
390
|
const emitProgress = () => {
|
|
@@ -394,6 +394,17 @@ export async function runClaudeCode(opts) {
|
|
|
394
394
|
if (progress)
|
|
395
395
|
opts.onProgress(progress);
|
|
396
396
|
};
|
|
397
|
+
// Publish the per-slice reviews the tracker has captured. Separate from `emitProgress` because
|
|
398
|
+
// the two answer different questions and have different lifetimes: progress is a disposable
|
|
399
|
+
// count the UI renders, while these carry the slices' actual review WORK and are persisted so a
|
|
400
|
+
// run that dies before its aggregation can be resumed from them.
|
|
401
|
+
const emitSliceReviews = () => {
|
|
402
|
+
if (!opts.onSliceReviews)
|
|
403
|
+
return;
|
|
404
|
+
const reviews = sliceTracker.sliceReviews();
|
|
405
|
+
if (reviews.length > 0)
|
|
406
|
+
opts.onSliceReviews(reviews);
|
|
407
|
+
};
|
|
397
408
|
// No-progress guard on the CLI's own tool stream — the claude-code analogue of runPi's guard,
|
|
398
409
|
// absent on this path until now. Claude Code reports a tool CALL (its name) on the `assistant`
|
|
399
410
|
// turn and that call's RESULT (`is_error`) on the following `user` turn, so correlate them by
|
|
@@ -471,6 +482,9 @@ export async function runClaudeCode(opts) {
|
|
|
471
482
|
sliceTracker.onUser(content);
|
|
472
483
|
planTracker.onUser(content);
|
|
473
484
|
emitProgress();
|
|
485
|
+
// A slice's report lands on exactly this turn, so publish here: waiting for the next
|
|
486
|
+
// progress tick would risk the job dying with the report captured but never surfaced.
|
|
487
|
+
emitSliceReviews();
|
|
474
488
|
// Not on the at-close flush: the CLI has already exited, so tripping the guard there
|
|
475
489
|
// would kill nothing and only convert a clean exit into a spurious failure.
|
|
476
490
|
if (!meta?.final)
|
package/dist/claude-stream.js
CHANGED
|
@@ -49,6 +49,29 @@ export function claudeAssistantContent(content) {
|
|
|
49
49
|
}
|
|
50
50
|
return { text, reasoning, toolUses };
|
|
51
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* The text a `tool_result` block carries. The CLI writes it either as a bare string or as an
|
|
54
|
+
* array of content blocks (the shape a subagent's terminal report arrives in), so both are read
|
|
55
|
+
* here rather than at each call site. Non-text blocks (an image a tool returned) contribute
|
|
56
|
+
* nothing. Returns '' when the block carries no readable text.
|
|
57
|
+
*
|
|
58
|
+
* This is what makes a parallel subagent's work observable to the harness at all: the parent
|
|
59
|
+
* stream shows a subagent's dispatch and its terminal `tool_result` and nothing in between, so
|
|
60
|
+
* this text is the ONLY place its findings surface outside its own untailed transcript.
|
|
61
|
+
*/
|
|
62
|
+
export function claudeToolResultText(block) {
|
|
63
|
+
const content = block.content;
|
|
64
|
+
if (typeof content === 'string')
|
|
65
|
+
return content;
|
|
66
|
+
if (!Array.isArray(content))
|
|
67
|
+
return '';
|
|
68
|
+
let text = '';
|
|
69
|
+
for (const part of content) {
|
|
70
|
+
if (isObject(part) && part.type === 'text' && typeof part.text === 'string')
|
|
71
|
+
text += part.text;
|
|
72
|
+
}
|
|
73
|
+
return text;
|
|
74
|
+
}
|
|
52
75
|
/**
|
|
53
76
|
* Per-CALL token usage off a Claude `assistant` message's `usage` (this turn only, not
|
|
54
77
|
* the cumulative `result` total).
|
package/dist/pi-workspace.js
CHANGED
|
@@ -182,6 +182,10 @@ export async function runAgentInWorkspace(spec, opts = {}) {
|
|
|
182
182
|
expectsEdits: spec.expectsEdits ?? true,
|
|
183
183
|
onActivity: opts.onActivity,
|
|
184
184
|
onProgress: opts.onProgress,
|
|
185
|
+
// Per-slice review capture, so a parallel review's finished slices are persisted as they
|
|
186
|
+
// land rather than only in the terminal output. Only the subscription runners fan work out
|
|
187
|
+
// across subagents, so this is the only path that can produce it.
|
|
188
|
+
onSliceReviews: opts.onSliceReviews,
|
|
185
189
|
// Stream this run's per-call telemetry to the job's live drain. The subscription
|
|
186
190
|
// harnesses are the only producers of `callMetrics` (Pi's calls are metered by the LLM
|
|
187
191
|
// proxy as they happen), so this is the only path that needs the hook.
|
package/dist/runner.js
CHANGED
|
@@ -220,6 +220,9 @@ export class JobRegistry {
|
|
|
220
220
|
onValidationReport: (report) => {
|
|
221
221
|
entry.validationReport = report;
|
|
222
222
|
},
|
|
223
|
+
onSliceReviews: (reviews) => {
|
|
224
|
+
entry.sliceReviews = reviews;
|
|
225
|
+
},
|
|
223
226
|
onReproductionProof: (report) => {
|
|
224
227
|
entry.reproductionReport = report;
|
|
225
228
|
},
|
package/dist/subagents.js
CHANGED
|
@@ -1,9 +1,62 @@
|
|
|
1
1
|
import { readdir, stat } from 'node:fs/promises';
|
|
2
2
|
import { createReadStream } from 'node:fs';
|
|
3
3
|
import { basename, join } from 'node:path';
|
|
4
|
-
import { claudeAssistantContent, claudeCallUsage, isObject, redactBody, SUBAGENT_TOOL_NAMES, } from './claude-stream.js';
|
|
4
|
+
import { claudeAssistantContent, claudeCallUsage, claudeToolResultText, isObject, redactBody, SUBAGENT_TOOL_NAMES, } from './claude-stream.js';
|
|
5
5
|
import { publishCallMetric } from './pi.js';
|
|
6
|
-
|
|
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 in the sense that a missing directory, an unreadable file, or an
|
|
36
|
+
// unparseable line is swallowed rather than failing the run — the CLI's subagent transcript layout
|
|
37
|
+
// is not a stable contract. But note what that costs SINCE the per-call fold landed: the parent
|
|
38
|
+
// loop's telemetry now filters the subagent turns the CLI tags onto its stdout (they were being
|
|
39
|
+
// counted twice and spliced into the parent's message chain), so when this watcher is wired and
|
|
40
|
+
// yields nothing, the run's subagent calls are recorded by NEITHER channel. `runClaudeCode` warns
|
|
41
|
+
// on exactly that shape, and an `ambientAuth` run — which has no config home to watch, so no
|
|
42
|
+
// watcher — keeps recording them off the parent stream instead
|
|
43
|
+
// (`createSubagentStreamTelemetry`). Do not "simplify" that fallback away.
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// Slice / progress tracking off the PARENT stream (D2.1)
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
/**
|
|
48
|
+
* How much of one slice's terminal report is kept. A slice review is prose (findings for a handful
|
|
49
|
+
* of files), not a transcript, so this is far above a real report while still bounding what a
|
|
50
|
+
* runaway subagent can push onto the step — the reports ride the job view on every poll and are
|
|
51
|
+
* persisted on the run.
|
|
52
|
+
*/
|
|
53
|
+
export const SLICE_REPORT_MAX_CHARS = 24_000;
|
|
54
|
+
/**
|
|
55
|
+
* @param secrets Leased-credential strings scrubbed from every captured report. A subagent can
|
|
56
|
+
* echo a token it saw in the checkout, and these reports are persisted on the run, so they are
|
|
57
|
+
* redacted on the way in rather than trusting each consumer to do it.
|
|
58
|
+
*/
|
|
59
|
+
export function createSliceTracker(secrets = []) {
|
|
7
60
|
// Insertion-ordered so the progress `items` render in dispatch order.
|
|
8
61
|
const slices = new Map();
|
|
9
62
|
return {
|
|
@@ -33,10 +86,27 @@ export function createSliceTracker() {
|
|
|
33
86
|
continue;
|
|
34
87
|
const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined;
|
|
35
88
|
const slice = id ? slices.get(id) : undefined;
|
|
36
|
-
if (slice)
|
|
37
|
-
|
|
89
|
+
if (!slice)
|
|
90
|
+
continue;
|
|
91
|
+
slice.done = true;
|
|
92
|
+
// The report is captured here or nowhere: this `tool_result` is the only place the
|
|
93
|
+
// subagent's findings appear on the parent stream, and the next poll may be the last one
|
|
94
|
+
// this job ever answers.
|
|
95
|
+
const report = redactBody(claudeToolResultText(block), secrets).trim();
|
|
96
|
+
if (report)
|
|
97
|
+
slice.report = report.slice(0, SLICE_REPORT_MAX_CHARS);
|
|
38
98
|
}
|
|
39
99
|
},
|
|
100
|
+
sliceReviews() {
|
|
101
|
+
return [...slices.values()].map((s) => ({
|
|
102
|
+
label: s.description,
|
|
103
|
+
status: (s.done ? 'completed' : 'in_progress'),
|
|
104
|
+
// A slice that finished but whose result carried no readable text is reported as
|
|
105
|
+
// completed with a null report rather than being dropped: a resume must still know it
|
|
106
|
+
// does not need re-reviewing, and silently omitting it would send it round again.
|
|
107
|
+
report: s.report ?? null,
|
|
108
|
+
}));
|
|
109
|
+
},
|
|
40
110
|
hasSlices() {
|
|
41
111
|
return slices.size > 0;
|
|
42
112
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/executor-harness",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.80.0",
|
|
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/
|
|
30
|
-
"@cat-factory/
|
|
31
|
-
"@cat-factory/
|
|
29
|
+
"@cat-factory/server": "0.183.0",
|
|
30
|
+
"@cat-factory/kernel": "0.197.0",
|
|
31
|
+
"@cat-factory/spend": "0.12.127"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
34
|
"build": "tsc -p tsconfig.json",
|
package/src/agent-runner.ts
CHANGED
|
@@ -26,7 +26,7 @@ import { ProgressGuard, type ProgressGuardLimits } from './progress-guard.js'
|
|
|
26
26
|
import { killChildProcess, spawnDetached } from './process.js'
|
|
27
27
|
import { describeProcessExit } from './process-exit.js'
|
|
28
28
|
import { redact, registerKnownSecrets, secretsToRedact } from './redact.js'
|
|
29
|
-
import { createSliceTracker, startSubagentWatcher } from './subagents.js'
|
|
29
|
+
import { createSliceTracker, startSubagentWatcher, type SliceReview } from './subagents.js'
|
|
30
30
|
import {
|
|
31
31
|
createTaskPlanTracker,
|
|
32
32
|
mergeProgress,
|
|
@@ -123,6 +123,13 @@ export interface SubscriptionRunOptions {
|
|
|
123
123
|
onActivity?: () => void
|
|
124
124
|
/** Called with the latest subtask counts each time the CLI updates its todo/plan list. */
|
|
125
125
|
onProgress?: (progress: TodoProgress) => void
|
|
126
|
+
/**
|
|
127
|
+
* Called with the FULL set of per-slice reviews each time one lands, so the backend can persist
|
|
128
|
+
* a parallel review's completed work as it happens instead of only from the terminal result.
|
|
129
|
+
* A whole value rather than a delta: the set only grows and losing a finished slice's report to
|
|
130
|
+
* a dropped poll would defeat the point (see `SliceTracker.sliceReviews`).
|
|
131
|
+
*/
|
|
132
|
+
onSliceReviews?: (reviews: SliceReview[]) => void
|
|
126
133
|
/**
|
|
127
134
|
* Called with each per-call telemetry row as the CLI stream yields it, so the backend can
|
|
128
135
|
* record the run's model calls WHILE it runs instead of only from its terminal result. The
|
|
@@ -542,7 +549,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
542
549
|
// either/or; the plan then MERGES with the dispatch view (`mergeProgress`) rather than
|
|
543
550
|
// competing with it — picking the further-along view collapsed the list to the dispatched
|
|
544
551
|
// slices alone the moment the first subagent returned. See ./progress.ts.
|
|
545
|
-
const sliceTracker = createSliceTracker()
|
|
552
|
+
const sliceTracker = createSliceTracker(secrets)
|
|
546
553
|
const planTracker = createTaskPlanTracker()
|
|
547
554
|
let lastTodo: TodoProgress | undefined
|
|
548
555
|
const emitProgress = (): void => {
|
|
@@ -553,6 +560,15 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
553
560
|
)
|
|
554
561
|
if (progress) opts.onProgress(progress)
|
|
555
562
|
}
|
|
563
|
+
// Publish the per-slice reviews the tracker has captured. Separate from `emitProgress` because
|
|
564
|
+
// the two answer different questions and have different lifetimes: progress is a disposable
|
|
565
|
+
// count the UI renders, while these carry the slices' actual review WORK and are persisted so a
|
|
566
|
+
// run that dies before its aggregation can be resumed from them.
|
|
567
|
+
const emitSliceReviews = (): void => {
|
|
568
|
+
if (!opts.onSliceReviews) return
|
|
569
|
+
const reviews = sliceTracker.sliceReviews()
|
|
570
|
+
if (reviews.length > 0) opts.onSliceReviews(reviews)
|
|
571
|
+
}
|
|
556
572
|
|
|
557
573
|
// No-progress guard on the CLI's own tool stream — the claude-code analogue of runPi's guard,
|
|
558
574
|
// absent on this path until now. Claude Code reports a tool CALL (its name) on the `assistant`
|
|
@@ -626,6 +642,9 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
626
642
|
sliceTracker.onUser(content)
|
|
627
643
|
planTracker.onUser(content)
|
|
628
644
|
emitProgress()
|
|
645
|
+
// A slice's report lands on exactly this turn, so publish here: waiting for the next
|
|
646
|
+
// progress tick would risk the job dying with the report captured but never surfaced.
|
|
647
|
+
emitSliceReviews()
|
|
629
648
|
// Not on the at-close flush: the CLI has already exited, so tripping the guard there
|
|
630
649
|
// would kill nothing and only convert a clean exit into a spurious failure.
|
|
631
650
|
if (!meta?.final) feedGuard(content)
|
package/src/claude-stream.ts
CHANGED
|
@@ -57,6 +57,27 @@ export function claudeAssistantContent(content: unknown[]): {
|
|
|
57
57
|
return { text, reasoning, toolUses }
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* The text a `tool_result` block carries. The CLI writes it either as a bare string or as an
|
|
62
|
+
* array of content blocks (the shape a subagent's terminal report arrives in), so both are read
|
|
63
|
+
* here rather than at each call site. Non-text blocks (an image a tool returned) contribute
|
|
64
|
+
* nothing. Returns '' when the block carries no readable text.
|
|
65
|
+
*
|
|
66
|
+
* This is what makes a parallel subagent's work observable to the harness at all: the parent
|
|
67
|
+
* stream shows a subagent's dispatch and its terminal `tool_result` and nothing in between, so
|
|
68
|
+
* this text is the ONLY place its findings surface outside its own untailed transcript.
|
|
69
|
+
*/
|
|
70
|
+
export function claudeToolResultText(block: Record<string, unknown>): string {
|
|
71
|
+
const content = block.content
|
|
72
|
+
if (typeof content === 'string') return content
|
|
73
|
+
if (!Array.isArray(content)) return ''
|
|
74
|
+
let text = ''
|
|
75
|
+
for (const part of content) {
|
|
76
|
+
if (isObject(part) && part.type === 'text' && typeof part.text === 'string') text += part.text
|
|
77
|
+
}
|
|
78
|
+
return text
|
|
79
|
+
}
|
|
80
|
+
|
|
60
81
|
/**
|
|
61
82
|
* Per-CALL token usage off a Claude `assistant` message's `usage` (this turn only, not
|
|
62
83
|
* the cumulative `result` total).
|
package/src/pi-workspace.ts
CHANGED
|
@@ -325,6 +325,10 @@ export async function runAgentInWorkspace(
|
|
|
325
325
|
expectsEdits: spec.expectsEdits ?? true,
|
|
326
326
|
onActivity: opts.onActivity,
|
|
327
327
|
onProgress: opts.onProgress,
|
|
328
|
+
// Per-slice review capture, so a parallel review's finished slices are persisted as they
|
|
329
|
+
// land rather than only in the terminal output. Only the subscription runners fan work out
|
|
330
|
+
// across subagents, so this is the only path that can produce it.
|
|
331
|
+
onSliceReviews: opts.onSliceReviews,
|
|
328
332
|
// Stream this run's per-call telemetry to the job's live drain. The subscription
|
|
329
333
|
// harnesses are the only producers of `callMetrics` (Pi's calls are metered by the LLM
|
|
330
334
|
// proxy as they happen), so this is the only path that needs the hook.
|
package/src/runner.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { redactSecrets } from './redact.js'
|
|
|
2
2
|
import type { FollowUpLine } from './follow-ups.js'
|
|
3
3
|
import type { ValidationReport } from './validation-checks.js'
|
|
4
4
|
import type { ReproductionReport } from './reproduction-proof.js'
|
|
5
|
+
import type { SliceReview } from './subagents.js'
|
|
5
6
|
import type { HarnessCallMetric, TodoProgress, ToolSpan } from './pi.js'
|
|
6
7
|
import { log, type Logger } from './logger.js'
|
|
7
8
|
import {
|
|
@@ -47,6 +48,14 @@ export interface RunOptions {
|
|
|
47
48
|
* attempt is final, and the loop republishes a whole new one — with a fresh `at` — per round.
|
|
48
49
|
*/
|
|
49
50
|
onReproductionProof?: (report: ReproductionReport) => void
|
|
51
|
+
/**
|
|
52
|
+
* Receives the full set of per-slice reviews a parallel review has captured, republished each
|
|
53
|
+
* time a slice's subagent returns. Latest-wins (NOT a drain buffer), for the same reason as
|
|
54
|
+
* {@link onValidationReport} but with more at stake: these carry the slices' actual review work,
|
|
55
|
+
* and a review whose aggregation never finishes is recoverable ONLY from what the backend
|
|
56
|
+
* already persisted. Absent for a job that dispatched no subagents.
|
|
57
|
+
*/
|
|
58
|
+
onSliceReviews?: (reviews: SliceReview[]) => void
|
|
50
59
|
/**
|
|
51
60
|
* Receives each per-call telemetry row the moment the agent's CLI stream yields it, so a
|
|
52
61
|
* run's model calls reach `llm_call_metrics` WHILE it runs rather than only in its terminal
|
|
@@ -203,6 +212,18 @@ export interface JobView<TResult extends JobResultBase = JobResultBase> {
|
|
|
203
212
|
* that carried no reproduction declaration.
|
|
204
213
|
*/
|
|
205
214
|
reproductionReport?: ReproductionReport
|
|
215
|
+
/**
|
|
216
|
+
* The per-slice reviews captured so far on a parallel (subagent-fanned) review — each slice's
|
|
217
|
+
* label, whether its subagent returned, and its verbatim report. A whole-value latest publish
|
|
218
|
+
* like {@link validationReport}, not drain-on-read.
|
|
219
|
+
*
|
|
220
|
+
* This is the durable half of a PR review. The reviewer returns `slices`/`findings` only in its
|
|
221
|
+
* TERMINAL structured output, so before this existed a review killed mid-run (or one whose
|
|
222
|
+
* aggregation pass wedged) lost every finished slice and could only be re-run from zero. The
|
|
223
|
+
* backend persists these onto the step as they arrive, which is what a manual resume re-aggregates
|
|
224
|
+
* from. Absent for a job that dispatched no subagents.
|
|
225
|
+
*/
|
|
226
|
+
sliceReviews?: SliceReview[]
|
|
206
227
|
}
|
|
207
228
|
|
|
208
229
|
interface JobEntry<TResult extends JobResultBase> extends JobView<TResult> {
|
|
@@ -473,6 +494,9 @@ export class JobRegistry<TJob = unknown, TResult extends JobResultBase = JobResu
|
|
|
473
494
|
onValidationReport: (report) => {
|
|
474
495
|
entry.validationReport = report
|
|
475
496
|
},
|
|
497
|
+
onSliceReviews: (reviews) => {
|
|
498
|
+
entry.sliceReviews = reviews
|
|
499
|
+
},
|
|
476
500
|
onReproductionProof: (report) => {
|
|
477
501
|
entry.reproductionReport = report
|
|
478
502
|
},
|
package/src/subagents.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { basename, join } from 'node:path'
|
|
|
4
4
|
import {
|
|
5
5
|
claudeAssistantContent,
|
|
6
6
|
claudeCallUsage,
|
|
7
|
+
claudeToolResultText,
|
|
7
8
|
isObject,
|
|
8
9
|
redactBody,
|
|
9
10
|
SUBAGENT_TOOL_NAMES,
|
|
@@ -54,20 +55,52 @@ import { publishCallMetric, type HarnessCallMetric, type TodoProgress } from './
|
|
|
54
55
|
// Slice / progress tracking off the PARENT stream (D2.1)
|
|
55
56
|
// ---------------------------------------------------------------------------
|
|
56
57
|
|
|
58
|
+
/**
|
|
59
|
+
* How much of one slice's terminal report is kept. A slice review is prose (findings for a handful
|
|
60
|
+
* of files), not a transcript, so this is far above a real report while still bounding what a
|
|
61
|
+
* runaway subagent can push onto the step — the reports ride the job view on every poll and are
|
|
62
|
+
* persisted on the run.
|
|
63
|
+
*/
|
|
64
|
+
export const SLICE_REPORT_MAX_CHARS = 24_000
|
|
65
|
+
|
|
57
66
|
interface TrackedSlice {
|
|
58
67
|
/** The dispatch's tool_use id, used to pair the terminal tool_result. */
|
|
59
68
|
toolUseId: string
|
|
60
69
|
/** The subagent's description (`Review <slice> slice`), rendered as the progress label. */
|
|
61
70
|
description: string
|
|
62
71
|
done: boolean
|
|
72
|
+
/**
|
|
73
|
+
* The subagent's verbatim terminal report, captured from the paired `tool_result`; undefined
|
|
74
|
+
* until it lands. This is the slice's actual review work — the reason a stuck aggregation no
|
|
75
|
+
* longer costs the whole run (see `prReviewSliceReviewSchema`). Truncated to
|
|
76
|
+
* {@link SLICE_REPORT_MAX_CHARS} and scrubbed of leased credentials before it leaves here.
|
|
77
|
+
*/
|
|
78
|
+
report?: string
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** One slice's live review, as published on the job view. Mirrors `prReviewSliceReviewSchema`. */
|
|
82
|
+
export interface SliceReview {
|
|
83
|
+
label: string
|
|
84
|
+
status: 'in_progress' | 'completed'
|
|
85
|
+
report?: string | null
|
|
63
86
|
}
|
|
64
87
|
|
|
65
88
|
/** Tracks parallel subagents seen on the parent stream to derive slice progress. */
|
|
66
89
|
export interface SliceTracker {
|
|
67
90
|
/** Feed an `assistant` message's content blocks: registers any subagent dispatches. */
|
|
68
91
|
onAssistant(content: unknown[]): void
|
|
69
|
-
/**
|
|
92
|
+
/**
|
|
93
|
+
* Feed a `user` message's content blocks: marks the paired subagent(s) complete AND captures
|
|
94
|
+
* each one's terminal report (see {@link SliceTracker.sliceReviews}).
|
|
95
|
+
*/
|
|
70
96
|
onUser(content: unknown[]): void
|
|
97
|
+
/**
|
|
98
|
+
* Every dispatched slice with its status and captured report, in dispatch order — the durable
|
|
99
|
+
* half of this tracker. Published as a whole value on each poll (NOT drain-on-read): the set
|
|
100
|
+
* only grows, and a dropped poll response must never permanently lose a finished slice's
|
|
101
|
+
* review, which is the entire point of capturing it. Empty when nothing was dispatched.
|
|
102
|
+
*/
|
|
103
|
+
sliceReviews(): SliceReview[]
|
|
71
104
|
/** Whether any `Task` subagent has been dispatched (⇒ this run parallelised). */
|
|
72
105
|
hasSlices(): boolean
|
|
73
106
|
/**
|
|
@@ -80,7 +113,12 @@ export interface SliceTracker {
|
|
|
80
113
|
progress(): TodoProgress | undefined
|
|
81
114
|
}
|
|
82
115
|
|
|
83
|
-
|
|
116
|
+
/**
|
|
117
|
+
* @param secrets Leased-credential strings scrubbed from every captured report. A subagent can
|
|
118
|
+
* echo a token it saw in the checkout, and these reports are persisted on the run, so they are
|
|
119
|
+
* redacted on the way in rather than trusting each consumer to do it.
|
|
120
|
+
*/
|
|
121
|
+
export function createSliceTracker(secrets: string[] = []): SliceTracker {
|
|
84
122
|
// Insertion-ordered so the progress `items` render in dispatch order.
|
|
85
123
|
const slices = new Map<string, TrackedSlice>()
|
|
86
124
|
|
|
@@ -106,9 +144,25 @@ export function createSliceTracker(): SliceTracker {
|
|
|
106
144
|
if (!isObject(block) || block.type !== 'tool_result') continue
|
|
107
145
|
const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined
|
|
108
146
|
const slice = id ? slices.get(id) : undefined
|
|
109
|
-
if (slice)
|
|
147
|
+
if (!slice) continue
|
|
148
|
+
slice.done = true
|
|
149
|
+
// The report is captured here or nowhere: this `tool_result` is the only place the
|
|
150
|
+
// subagent's findings appear on the parent stream, and the next poll may be the last one
|
|
151
|
+
// this job ever answers.
|
|
152
|
+
const report = redactBody(claudeToolResultText(block), secrets).trim()
|
|
153
|
+
if (report) slice.report = report.slice(0, SLICE_REPORT_MAX_CHARS)
|
|
110
154
|
}
|
|
111
155
|
},
|
|
156
|
+
sliceReviews() {
|
|
157
|
+
return [...slices.values()].map((s) => ({
|
|
158
|
+
label: s.description,
|
|
159
|
+
status: (s.done ? 'completed' : 'in_progress') as 'completed' | 'in_progress',
|
|
160
|
+
// A slice that finished but whose result carried no readable text is reported as
|
|
161
|
+
// completed with a null report rather than being dropped: a resume must still know it
|
|
162
|
+
// does not need re-reviewing, and silently omitting it would send it round again.
|
|
163
|
+
report: s.report ?? null,
|
|
164
|
+
}))
|
|
165
|
+
},
|
|
112
166
|
hasSlices() {
|
|
113
167
|
return slices.size > 0
|
|
114
168
|
},
|