@cat-factory/executor-harness 1.76.2 → 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/README.md +25 -12
- package/dist/agent-runner.js +15 -1
- package/dist/agent.js +9 -1
- package/dist/claude-stream.js +23 -0
- package/dist/coding-agent.js +52 -10
- package/dist/pi-workspace.js +4 -0
- package/dist/pr-description.js +18 -6
- package/dist/pr-template.js +250 -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/agent.ts +9 -1
- package/src/claude-stream.ts +21 -0
- package/src/coding-agent.ts +74 -7
- package/src/pi-workspace.ts +4 -0
- package/src/pr-description.ts +40 -6
- package/src/pr-template.ts +366 -0
- package/src/runner.ts +24 -0
- package/src/subagents.ts +57 -3
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
import { readdir, readFile, realpath } from 'node:fs/promises'
|
|
2
|
+
import { join, sep } from 'node:path'
|
|
3
|
+
import { fencedOutput } from './captured-command.js'
|
|
4
|
+
import type { RepoSpec } from './job.js'
|
|
5
|
+
import type { Logger } from './logger.js'
|
|
6
|
+
import { MAX_PR_BODY_CHARS, PR_DESCRIPTION_FILE } from './pr-description.js'
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// THE REPOSITORY'S OWN PULL-REQUEST TEMPLATE.
|
|
10
|
+
//
|
|
11
|
+
// A repo that ships `.github/PULL_REQUEST_TEMPLATE.md` (or GitLab's
|
|
12
|
+
// `.gitlab/merge_request_templates/Default.md`) is stating the shape every pull request against
|
|
13
|
+
// it must take — the sections its reviewers read, the checklist its process requires. A platform
|
|
14
|
+
// that opens PRs there and ignores that is a bad citizen: its pull requests are the only ones on
|
|
15
|
+
// the repo missing the structure everyone else follows.
|
|
16
|
+
//
|
|
17
|
+
// The trap this module exists for is that the template is NOT applied for us. Both hosts
|
|
18
|
+
// interpolate the template only into the WEB form a human opens; a PR created through the REST
|
|
19
|
+
// API gets exactly the body the caller sends. So nothing anywhere fails or warns — the platform's
|
|
20
|
+
// pull requests simply, quietly, don't follow the repo's own convention.
|
|
21
|
+
//
|
|
22
|
+
// The template is FILLED IN BY THE AGENT, not by the platform. Mechanically stuffing the
|
|
23
|
+
// briefing under the first heading would produce a document with the template's shape and none
|
|
24
|
+
// of its meaning: the sections are questions ("what is the risk?", "how was this tested?") that
|
|
25
|
+
// only whoever did the work can answer. So the harness discovers the template and hands it to the
|
|
26
|
+
// agent that just did the work, in the same prompt that already asks it for a briefing — and the
|
|
27
|
+
// agent answers the template's questions instead of writing a free-form one. Zero extra model
|
|
28
|
+
// calls, and the answers come from the run's full context rather than a summary of it.
|
|
29
|
+
//
|
|
30
|
+
// Discovery is HARNESS-side, deliberately, and reads from the checkout on disk. The backend could
|
|
31
|
+
// instead resolve the template through the `RepoFiles` port at dispatch, but that is an HTTP round
|
|
32
|
+
// trip per dispatch to answer a question the container can answer for free — and every dispatch
|
|
33
|
+
// that opens a pull request has a checkout by definition.
|
|
34
|
+
//
|
|
35
|
+
// The filled text goes back out through `readPrDescription`, so it crosses `redactSecrets` and
|
|
36
|
+
// `host-markdown.ts` on the way to the PR exactly as a free-form briefing does. Nothing here
|
|
37
|
+
// widens that boundary: a template is repo-committed text on the way IN, and what the agent
|
|
38
|
+
// writes is model-authored text on the way OUT either way. What it DOES change on the way out is
|
|
39
|
+
// the title heuristic: the headings are now the repo's, so the caller reads the sentinel with
|
|
40
|
+
// `titleFromHeading: false` (see `ReadPrDescriptionOptions`).
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
/** The VCS providers a repo can live on. Bound to `RepoSpec` so the two cannot drift. */
|
|
44
|
+
type ProviderName = NonNullable<RepoSpec['provider']>
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* How much template text is inlined into the agent's prompt.
|
|
48
|
+
*
|
|
49
|
+
* Over this, the template is NAMED rather than inlined and the agent is told to read it from the
|
|
50
|
+
* checkout — which it can, because the file is on disk. That is strictly better than the
|
|
51
|
+
* alternatives: truncating a template would have the agent fill a structure whose tail it never
|
|
52
|
+
* saw (silently dropping the repo's last sections), and skipping it entirely would abandon the
|
|
53
|
+
* feature on exactly the repos with the most demanding process.
|
|
54
|
+
*/
|
|
55
|
+
export const MAX_INLINE_PR_TEMPLATE_CHARS = 8_000
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The shared inline budget across a multi-repo run's legs. Each repo's template competes for it in
|
|
59
|
+
* leg order, and a leg that does not fit is NAMED rather than inlined (as above) — so a workspace
|
|
60
|
+
* of four template-carrying repos cannot quietly consume 32k of the agent's prompt.
|
|
61
|
+
*/
|
|
62
|
+
export const MAX_TOTAL_INLINE_PR_TEMPLATE_CHARS = 12_000
|
|
63
|
+
|
|
64
|
+
/** Extensions a template file may carry. Both hosts also accept an extensionless file. */
|
|
65
|
+
const TEMPLATE_EXTENSIONS = new Set(['.md', '.markdown', '.txt'])
|
|
66
|
+
|
|
67
|
+
/** GitHub's single-file template basename, matched case-insensitively as GitHub itself does. */
|
|
68
|
+
const GITHUB_TEMPLATE_STEM = 'pull_request_template'
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Where a template can live, in each host's OWN precedence order. A `stem` entry is a single file
|
|
72
|
+
* in that directory; a `pick` entry is a directory of templates (see {@link chooseFromDirectory}).
|
|
73
|
+
*/
|
|
74
|
+
const GITHUB_LOCATIONS: TemplateLocation[] = [
|
|
75
|
+
{ dir: '.github', stem: GITHUB_TEMPLATE_STEM },
|
|
76
|
+
{ dir: '', stem: GITHUB_TEMPLATE_STEM },
|
|
77
|
+
{ dir: 'docs', stem: GITHUB_TEMPLATE_STEM },
|
|
78
|
+
{ dir: '.github/PULL_REQUEST_TEMPLATE', pick: true },
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* GitLab keeps merge-request templates only in a directory — there is no root single-file
|
|
83
|
+
* convention to probe, so none is invented here.
|
|
84
|
+
*/
|
|
85
|
+
const GITLAB_LOCATIONS: TemplateLocation[] = [
|
|
86
|
+
{ dir: '.gitlab/merge_request_templates', pick: true },
|
|
87
|
+
]
|
|
88
|
+
|
|
89
|
+
interface TemplateLocation {
|
|
90
|
+
/** Repo-root-relative directory ('' = the root). */
|
|
91
|
+
dir: string
|
|
92
|
+
/** Match a single file with this basename (case-insensitive). */
|
|
93
|
+
stem?: string
|
|
94
|
+
/** Treat the directory as a set of templates and pick one. */
|
|
95
|
+
pick?: boolean
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A discovered pull-request template. */
|
|
99
|
+
export interface PrTemplate {
|
|
100
|
+
/** Repo-root-relative path, forward-slashed (it is prose an agent reads). */
|
|
101
|
+
path: string
|
|
102
|
+
/**
|
|
103
|
+
* The template's size. ALWAYS the real one, including when {@link text} is absent: an over-budget
|
|
104
|
+
* template reporting `chars: 0` would read in the log exactly like an empty file, which is the
|
|
105
|
+
* one thing discovery treats as "no template at all".
|
|
106
|
+
*/
|
|
107
|
+
chars: number
|
|
108
|
+
/** The template text. Absent ⇒ over budget, so the agent is told to read {@link path} itself. */
|
|
109
|
+
text?: string
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** One checkout to look for a template in. */
|
|
113
|
+
export interface PrTemplateTarget {
|
|
114
|
+
/** The repository checkout root (NOT a monorepo service subtree — a template is a repo fact). */
|
|
115
|
+
repoDir: string
|
|
116
|
+
provider?: ProviderName
|
|
117
|
+
/** Names this repo in the note. Omit when the run has a single checkout ("this repository"). */
|
|
118
|
+
repoLabel?: string
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** What {@link resolvePrTemplateNote} tells the run about the templates it found. */
|
|
122
|
+
export interface PrTemplateResolution {
|
|
123
|
+
/** The prompt note, or absent when no target ships a template (which is most repos). */
|
|
124
|
+
note?: string
|
|
125
|
+
/**
|
|
126
|
+
* The `repoDir`s whose briefing is a FILLED TEMPLATE. The push phase reads those sentinels with
|
|
127
|
+
* `titleFromHeading: false`, because the headings in them are the repo's — see
|
|
128
|
+
* `ReadPrDescriptionOptions.titleFromHeading`. A SET keyed by directory rather than a boolean
|
|
129
|
+
* because a multi-repo run's legs need not all ship a template.
|
|
130
|
+
*/
|
|
131
|
+
templated: ReadonlySet<string>
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Why a discovered template was named rather than inlined — the two are not the same fix. */
|
|
135
|
+
type NotInlinedReason = 'over-file-budget' | 'over-shared-budget'
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* THE entry point: find each target's pull-request template and build the prompt note that asks
|
|
139
|
+
* the agent to fill it, plus the set of checkouts whose sentinel will therefore hold a filled
|
|
140
|
+
* template rather than a free-form briefing (see {@link PrTemplateResolution}).
|
|
141
|
+
*
|
|
142
|
+
* Pass NO targets for a dispatch that opens no pull request — an in-place fixer amending someone
|
|
143
|
+
* else's PR, a read-only explore run. Asking such a run to fill a template would be asking for a
|
|
144
|
+
* document nothing publishes.
|
|
145
|
+
*
|
|
146
|
+
* Never rejects: a template is an improvement to a PR body, so no failure reading one may cost a
|
|
147
|
+
* run that otherwise succeeded. An unreadable or empty template is simply no template.
|
|
148
|
+
*/
|
|
149
|
+
export async function resolvePrTemplateNote(args: {
|
|
150
|
+
targets: PrTemplateTarget[]
|
|
151
|
+
logger: Logger
|
|
152
|
+
}): Promise<PrTemplateResolution> {
|
|
153
|
+
const { targets, logger } = args
|
|
154
|
+
const notes: string[] = []
|
|
155
|
+
const templated = new Set<string>()
|
|
156
|
+
let inlineBudget = MAX_TOTAL_INLINE_PR_TEMPLATE_CHARS
|
|
157
|
+
for (const target of targets) {
|
|
158
|
+
const found = await discoverPrTemplate(target.repoDir, target.provider)
|
|
159
|
+
if (!found) continue
|
|
160
|
+
// Spend the shared budget in leg order; a template that no longer fits is named, not cut. The
|
|
161
|
+
// per-file budget was already spent inside discovery, so an absent `text` means THAT ceiling —
|
|
162
|
+
// distinguished in the log because the two want different fixes (a smaller template vs a
|
|
163
|
+
// workspace carrying more template text than one prompt should hold).
|
|
164
|
+
const text = found.text
|
|
165
|
+
const inline = text !== undefined && text.length <= inlineBudget
|
|
166
|
+
if (inline) inlineBudget -= text.length
|
|
167
|
+
const reason: NotInlinedReason | undefined = inline
|
|
168
|
+
? undefined
|
|
169
|
+
: text === undefined
|
|
170
|
+
? 'over-file-budget'
|
|
171
|
+
: 'over-shared-budget'
|
|
172
|
+
logger.info('pr template: found', {
|
|
173
|
+
path: found.path,
|
|
174
|
+
chars: found.chars,
|
|
175
|
+
inlined: inline,
|
|
176
|
+
...(reason ? { reason } : {}),
|
|
177
|
+
...(target.repoLabel ? { repo: target.repoLabel } : {}),
|
|
178
|
+
})
|
|
179
|
+
templated.add(target.repoDir)
|
|
180
|
+
notes.push(
|
|
181
|
+
buildPrTemplateNote(
|
|
182
|
+
inline ? found : { path: found.path, chars: found.chars },
|
|
183
|
+
target.repoLabel,
|
|
184
|
+
),
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
return { ...(notes.length > 0 ? { note: notes.join('\n\n') } : {}), templated }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Locate the template in `repoDir`, probing the repo's OWN host convention first and the other
|
|
192
|
+
* host's second — a repo mirrored across both, or one whose provider the dispatcher did not set,
|
|
193
|
+
* still gets its template respected rather than falling to whichever list happened to be first.
|
|
194
|
+
*/
|
|
195
|
+
export async function discoverPrTemplate(
|
|
196
|
+
repoDir: string,
|
|
197
|
+
provider?: ProviderName,
|
|
198
|
+
): Promise<PrTemplate | undefined> {
|
|
199
|
+
const locations =
|
|
200
|
+
provider === 'gitlab'
|
|
201
|
+
? [...GITLAB_LOCATIONS, ...GITHUB_LOCATIONS]
|
|
202
|
+
: [...GITHUB_LOCATIONS, ...GITLAB_LOCATIONS]
|
|
203
|
+
for (const location of locations) {
|
|
204
|
+
const entries = await listDirectory(join(repoDir, location.dir))
|
|
205
|
+
if (entries.length === 0) continue
|
|
206
|
+
const name = location.pick ? chooseFromDirectory(entries) : chooseSingleFile(entries, location)
|
|
207
|
+
if (!name) continue
|
|
208
|
+
const path = location.dir ? `${location.dir}/${name}` : name
|
|
209
|
+
const text = await readTemplate(join(repoDir, location.dir, name), repoDir)
|
|
210
|
+
// An empty (or unreadable) file imposes no structure, so keep probing: a repo can carry a
|
|
211
|
+
// placeholder at one location and its real template at another.
|
|
212
|
+
if (!text) continue
|
|
213
|
+
const chars = text.length
|
|
214
|
+
return chars <= MAX_INLINE_PR_TEMPLATE_CHARS ? { path, chars, text } : { path, chars }
|
|
215
|
+
}
|
|
216
|
+
return undefined
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Directory entries with their kind, or `[]` for a directory that is absent or unreadable. */
|
|
220
|
+
async function listDirectory(path: string): Promise<{ name: string; directory: boolean }[]> {
|
|
221
|
+
try {
|
|
222
|
+
const entries = await readdir(path, { withFileTypes: true })
|
|
223
|
+
// Sorted so a repo carrying several matches always yields the SAME one: readdir order is
|
|
224
|
+
// filesystem-defined, and a PR body that changed shape between two runs of the same repo
|
|
225
|
+
// would be a genuinely baffling thing to debug.
|
|
226
|
+
return entries
|
|
227
|
+
.map((entry) => ({ name: entry.name, directory: entry.isDirectory() }))
|
|
228
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
229
|
+
} catch {
|
|
230
|
+
return []
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* The single-file match. Case-insensitive on both the stem and the extension, and extensionless
|
|
236
|
+
* is allowed because both hosts accept it — which is why the DIRECTORY check matters here:
|
|
237
|
+
* `.github/PULL_REQUEST_TEMPLATE/` is itself an extensionless match for the stem, and reading a
|
|
238
|
+
* directory as a template would produce nothing but a swallowed EISDIR.
|
|
239
|
+
*/
|
|
240
|
+
function chooseSingleFile(
|
|
241
|
+
entries: { name: string; directory: boolean }[],
|
|
242
|
+
location: TemplateLocation,
|
|
243
|
+
): string | undefined {
|
|
244
|
+
return entries.find((entry) => {
|
|
245
|
+
if (entry.directory) return false
|
|
246
|
+
const { stem, extension } = splitName(entry.name)
|
|
247
|
+
return stem === location.stem && (extension === '' || TEMPLATE_EXTENSIONS.has(extension))
|
|
248
|
+
})?.name
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Pick from a directory of templates — GitHub's `.github/PULL_REQUEST_TEMPLATE/`, GitLab's
|
|
253
|
+
* `.gitlab/merge_request_templates/`.
|
|
254
|
+
*
|
|
255
|
+
* A `default` template wins (GitLab applies `Default.md` by itself, so it is unambiguously the
|
|
256
|
+
* one meant for a PR that names none). Failing that, a lone template is taken: a repo with
|
|
257
|
+
* exactly one has expressed exactly one convention.
|
|
258
|
+
*
|
|
259
|
+
* SEVERAL templates with no default yields NOTHING, deliberately. That directory exists so a
|
|
260
|
+
* HUMAN can choose per pull request — "bug report" vs "release" vs "RFC" — and the choice is
|
|
261
|
+
* usually not inferable from a diff. Picking one arbitrarily would file every run's work under
|
|
262
|
+
* whichever name sorts first, which is worse than the free-form briefing: it looks like a
|
|
263
|
+
* deliberate categorisation and is not.
|
|
264
|
+
*/
|
|
265
|
+
function chooseFromDirectory(entries: { name: string; directory: boolean }[]): string | undefined {
|
|
266
|
+
const usable = entries.filter(
|
|
267
|
+
(entry) => !entry.directory && TEMPLATE_EXTENSIONS.has(splitName(entry.name).extension),
|
|
268
|
+
)
|
|
269
|
+
const fallback = usable.find((entry) => splitName(entry.name).stem === 'default')
|
|
270
|
+
if (fallback) return fallback.name
|
|
271
|
+
return usable.length === 1 ? usable[0]!.name : undefined
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Lower-cased stem + extension ('' when the name carries none). */
|
|
275
|
+
function splitName(name: string): { stem: string; extension: string } {
|
|
276
|
+
const lower = name.toLowerCase()
|
|
277
|
+
const dot = lower.lastIndexOf('.')
|
|
278
|
+
if (dot <= 0) return { stem: lower, extension: '' }
|
|
279
|
+
return { stem: lower.slice(0, dot), extension: lower.slice(dot) }
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* The template's text, or undefined when it is unreadable or carries nothing.
|
|
284
|
+
*
|
|
285
|
+
* A checkout is REPO-AUTHORED, symlinks included, and this is the one read the harness performs on
|
|
286
|
+
* a repo-chosen path without the agent asking for it — so the resolved target must stay inside
|
|
287
|
+
* `repoDir`. A link out of the tree would inline an arbitrary container file (the run's own env,
|
|
288
|
+
* a sibling checkout) into the prompt, and from there into a body only `redactSecrets` stands in
|
|
289
|
+
* front of. Containment rather than a blanket symlink refusal, because a monorepo pointing
|
|
290
|
+
* `.github/PULL_REQUEST_TEMPLATE.md` at a doc it keeps elsewhere in the repo is a real and
|
|
291
|
+
* legitimate layout.
|
|
292
|
+
*/
|
|
293
|
+
async function readTemplate(path: string, repoDir: string): Promise<string | undefined> {
|
|
294
|
+
try {
|
|
295
|
+
// Both sides resolved, so the comparison is between two canonical paths — `repoDir` itself is
|
|
296
|
+
// routinely reached through a symlinked temp dir (macOS `/tmp`), which a raw prefix test on the
|
|
297
|
+
// unresolved root would read as an escape.
|
|
298
|
+
const [target, root] = await Promise.all([realpath(path), realpath(repoDir)])
|
|
299
|
+
if (target !== root && !target.startsWith(root.endsWith(sep) ? root : root + sep)) {
|
|
300
|
+
return undefined
|
|
301
|
+
}
|
|
302
|
+
return (await readFile(target, 'utf8')).trim() || undefined
|
|
303
|
+
} catch {
|
|
304
|
+
return undefined
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The prompt note. It has to do more than show the template, because the agent has already been
|
|
310
|
+
* told (by the backend-composed `PR_DESCRIPTION_GUIDANCE`) to write a free-form briefing, and the
|
|
311
|
+
* two genuinely conflict: a template that asks for a test plan or a checklist is asking for
|
|
312
|
+
* exactly the "restated diff" that guidance rules out. So the note states which wins, and states
|
|
313
|
+
* why the template is not already applied — an agent that believes the host will merge the
|
|
314
|
+
* template with its text has no reason to reproduce the structure itself.
|
|
315
|
+
*
|
|
316
|
+
* The template is delimited with `fencedOutput`, the same helper every other captured-text-into-a-
|
|
317
|
+
* prompt path uses. Templates routinely carry fenced blocks of their own, and a fixed three-tick
|
|
318
|
+
* wrapper closes on the first of them — spilling the rest of the template, and the instructions
|
|
319
|
+
* after it, into the prompt as prose. `fencedOutput` sizes the fence one tick longer than the
|
|
320
|
+
* longest run in the body, which is what CommonMark specifies for exactly this, so no template can
|
|
321
|
+
* break out of its own block. A plain `--- BEGIN/END ---` rule would read more nicely and is
|
|
322
|
+
* trivially forgeable by the template's own content, which is the whole thing being defended.
|
|
323
|
+
*/
|
|
324
|
+
export function buildPrTemplateNote(template: PrTemplate, repoLabel?: string): string {
|
|
325
|
+
const subject = repoLabel ? `The \`${repoLabel}\` repository` : 'This repository'
|
|
326
|
+
const lead =
|
|
327
|
+
`PULL REQUEST TEMPLATE — ${subject} ships a pull request template at \`${template.path}\` ` +
|
|
328
|
+
'(relative to the repository root). The platform opens the pull request through the host API, ' +
|
|
329
|
+
'and neither GitHub nor GitLab applies a template to an API-created pull request — that only ' +
|
|
330
|
+
'happens for a human opening one in the web form. So following it is on you.'
|
|
331
|
+
const instructions =
|
|
332
|
+
`Write \`${PR_DESCRIPTION_FILE}\` as that template, FILLED IN${
|
|
333
|
+
repoLabel ? ` (in the \`${repoLabel}\` checkout)` : ''
|
|
334
|
+
}: keep its headings, their order, and any structure it defines; answer every section from ` +
|
|
335
|
+
'the work you actually did; delete its instructional HTML comments and any placeholder text; ' +
|
|
336
|
+
'and complete checklists honestly, ticking only what is true. Leave a section that genuinely ' +
|
|
337
|
+
'does not apply in place with a brief "n/a" and why, rather than deleting the heading — a ' +
|
|
338
|
+
'reviewer looking for it needs to see it was considered. Where the template asks for ' +
|
|
339
|
+
'something the general description guidance does not, the TEMPLATE wins; where it leaves room ' +
|
|
340
|
+
'for prose, brief it as that guidance describes. The platform rules still hold either way: no ' +
|
|
341
|
+
'secrets, and no issue/PR numbers, @-mentions, or issue-closing wording. Do NOT put a title ' +
|
|
342
|
+
"line above the template — the platform titles this pull request itself, so the template's " +
|
|
343
|
+
'own first heading stays the first line of the file. Keep the finished file under ' +
|
|
344
|
+
`${MAX_PR_BODY_CHARS.toLocaleString('en-US')} characters: the platform truncates a longer ` +
|
|
345
|
+
"body, which would cut the template's last sections."
|
|
346
|
+
if (template.text === undefined) {
|
|
347
|
+
// Reached both when the file alone exceeds the inline budget and when a multi-repo run's
|
|
348
|
+
// earlier legs spent the shared one, so the wording states the size and stays true of both.
|
|
349
|
+
return (
|
|
350
|
+
`${lead} ${instructions} The template (${template.chars} characters) is not reproduced ` +
|
|
351
|
+
'here — read it from the checkout.'
|
|
352
|
+
)
|
|
353
|
+
}
|
|
354
|
+
return `${lead} ${instructions}\n\nThe template follows, in a fenced block that is NOT part of it:\n${fencedOutput(template.text)}`
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Fold the note into a prompt. The sibling of `withDependencyNote`, and deliberately not inlined
|
|
359
|
+
* for the same reason: it rides EVERY agent pass, including the validation and reproduction
|
|
360
|
+
* REPAIR passes. Those start a fresh agent that still carries the description guidance in its
|
|
361
|
+
* system prompt, so one that is not also told about the template would rewrite the briefing
|
|
362
|
+
* free-form and undo the filled template the first pass produced.
|
|
363
|
+
*/
|
|
364
|
+
export function withPrTemplateNote(userPrompt: string, note: string | undefined): string {
|
|
365
|
+
return note ? `${userPrompt}\n\n${note}` : userPrompt
|
|
366
|
+
}
|
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
|
},
|