@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
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/spend": "0.12.
|
|
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/agent.ts
CHANGED
|
@@ -962,8 +962,12 @@ async function runCodingMode(job: AgentJob, opts: RunOptions): Promise<AgentResu
|
|
|
962
962
|
* Assemble the {@link runCodingAgent} spec for the ordinary single-repo coding flow. Extracted
|
|
963
963
|
* from {@link runSingleRepoCoding} so the many optional-field spreads don't inflate that
|
|
964
964
|
* function's cyclomatic complexity; the mapping is a straight field copy off `job`.
|
|
965
|
+
*
|
|
966
|
+
* Exported for the `opensPr` assertion: whether a dispatch fills the repo's PR template turns on
|
|
967
|
+
* this one spread, and the in-place fixers reach it through the SAME function as the implementer,
|
|
968
|
+
* so no structural guard can tell their cases apart.
|
|
965
969
|
*/
|
|
966
|
-
function buildSingleRepoCodingSpec(
|
|
970
|
+
export function buildSingleRepoCodingSpec(
|
|
967
971
|
job: AgentJob,
|
|
968
972
|
pushBranch: string,
|
|
969
973
|
): Parameters<typeof runCodingAgent>[0] {
|
|
@@ -991,6 +995,10 @@ function buildSingleRepoCodingSpec(
|
|
|
991
995
|
guardLimits: job.guardLimits,
|
|
992
996
|
...(job.persistentCheckout ? { persistentCheckout: true } : {}),
|
|
993
997
|
...(job.streamFollowUps ? { streamFollowUps: true } : {}),
|
|
998
|
+
// Whether a pull request will open at all is exactly `job.pr` (see the `if (job.pr)` guard in
|
|
999
|
+
// `runSingleRepoCoding`), and it is what decides whether the repo's PR template is worth
|
|
1000
|
+
// resolving. Read off the same field rather than a new job-body flag, so the two can't drift.
|
|
1001
|
+
...(job.pr ? { opensPr: true } : {}),
|
|
994
1002
|
...(job.referenceBranches?.length ? { referenceBranches: job.referenceBranches } : {}),
|
|
995
1003
|
// Skills + tool servers: installed/wired harness-aware by runAgentInWorkspace.
|
|
996
1004
|
...agentCapabilities(job),
|
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/coding-agent.ts
CHANGED
|
@@ -63,6 +63,11 @@ import {
|
|
|
63
63
|
withDependencyNote,
|
|
64
64
|
type DependencyInstallSpec,
|
|
65
65
|
} from './dependency-install.js'
|
|
66
|
+
import {
|
|
67
|
+
resolvePrTemplateNote,
|
|
68
|
+
withPrTemplateNote,
|
|
69
|
+
type PrTemplateResolution,
|
|
70
|
+
} from './pr-template.js'
|
|
66
71
|
|
|
67
72
|
// The shared skeleton for the container coding agents that clone a repo, run Pi
|
|
68
73
|
// against it and push the result on a branch. The implementation (`/run`) and
|
|
@@ -114,6 +119,14 @@ export interface CodingAgentSpec extends HarnessAuthFields {
|
|
|
114
119
|
* only for the implementer (`coder`) dispatch; absent ⇒ no tailing (e.g. the CI-fixer).
|
|
115
120
|
*/
|
|
116
121
|
streamFollowUps?: boolean
|
|
122
|
+
/**
|
|
123
|
+
* Whether this dispatch OPENS a pull request (the caller passes `pr` to `openPullRequest`).
|
|
124
|
+
* Set, the harness looks for the repo's own pull-request template and asks the agent to fill it
|
|
125
|
+
* (see `pr-template.ts`). Absent for a dispatch that amends someone else's PR (the in-place
|
|
126
|
+
* fixers) — a template filled for a pull request nothing opens is wasted prompt and, worse,
|
|
127
|
+
* would have a CI-fixer rewrite the implementer's already-published description.
|
|
128
|
+
*/
|
|
129
|
+
opensPr?: boolean
|
|
117
130
|
/**
|
|
118
131
|
* READ-ONLY reference branches of THIS repo (the apriori-branches reference mode): fetched
|
|
119
132
|
* into `origin/<b>` after the checkout so the agent can inspect them but never commits to
|
|
@@ -365,13 +378,28 @@ export async function runCodingAgent(
|
|
|
365
378
|
opts,
|
|
366
379
|
})
|
|
367
380
|
|
|
381
|
+
// THE REPO'S OWN PR TEMPLATE: when this dispatch opens a pull request and the repo ships a
|
|
382
|
+
// template, the agent is asked to write its briefing AS that template rather than free-form
|
|
383
|
+
// (see `pr-template.ts` for why neither host applies it to an API-created PR for us).
|
|
384
|
+
// Discovered at the CHECKOUT ROOT, never `workDir`: a template is a fact about the
|
|
385
|
+
// repository, so a monorepo service dispatch reads the same one as any other.
|
|
386
|
+
const prTemplate = await resolvePrTemplateNote({
|
|
387
|
+
targets: spec.opensPr
|
|
388
|
+
? [{ repoDir: dir, ...(spec.repo.provider ? { provider: spec.repo.provider } : {}) }]
|
|
389
|
+
: [],
|
|
390
|
+
logger,
|
|
391
|
+
})
|
|
392
|
+
|
|
368
393
|
// One agent pass over this checkout, parameterised only by the prompt — so the pre-PR
|
|
369
394
|
// validation loop below can re-run the agent with a repair instruction without
|
|
370
395
|
// re-deriving (or drifting from) the dispatch's own settings.
|
|
371
396
|
//
|
|
372
397
|
// The dependency note rides EVERY pass, not just the first: a repair round starts a fresh
|
|
373
398
|
// agent, and one that is not told the tree is already installed spends the round it was
|
|
374
|
-
// given to fix something reinstalling it instead.
|
|
399
|
+
// given to fix something reinstalling it instead. The PR-template note rides every pass for
|
|
400
|
+
// the mirror-image reason: a repair-round agent still carries the description guidance, so
|
|
401
|
+
// one that is not told about the template would replace the filled template with a
|
|
402
|
+
// free-form briefing.
|
|
375
403
|
const runAgentPass = (
|
|
376
404
|
userPrompt: string,
|
|
377
405
|
): Promise<Awaited<ReturnType<typeof runAgentInWorkspace>>> =>
|
|
@@ -379,7 +407,10 @@ export async function runCodingAgent(
|
|
|
379
407
|
{
|
|
380
408
|
dir: workDir,
|
|
381
409
|
systemPrompt: spec.systemPrompt,
|
|
382
|
-
userPrompt: withDependencyNote(
|
|
410
|
+
userPrompt: withDependencyNote(
|
|
411
|
+
withPrTemplateNote(userPrompt, prTemplate.note),
|
|
412
|
+
dependencyNote,
|
|
413
|
+
),
|
|
383
414
|
model: spec.model,
|
|
384
415
|
harness: spec.harness,
|
|
385
416
|
subscriptionToken: spec.subscriptionToken,
|
|
@@ -499,6 +530,7 @@ export async function runCodingAgent(
|
|
|
499
530
|
pushWorkOnce,
|
|
500
531
|
inFlightPush,
|
|
501
532
|
agentRun,
|
|
533
|
+
prTemplate,
|
|
502
534
|
})
|
|
503
535
|
} finally {
|
|
504
536
|
// Safety net for the throw path (the happy path already cleared these above).
|
|
@@ -651,6 +683,8 @@ async function finalizeCodingRun(args: {
|
|
|
651
683
|
pushWorkOnce: () => Promise<void>
|
|
652
684
|
inFlightPush: () => Promise<void> | null
|
|
653
685
|
agentRun: Awaited<ReturnType<typeof runAgentInWorkspace>>
|
|
686
|
+
/** The repo's PR template, if it ships one — see the `titleFromHeading` read below. */
|
|
687
|
+
prTemplate: PrTemplateResolution
|
|
654
688
|
}): Promise<CodingAgentOutcome> {
|
|
655
689
|
const {
|
|
656
690
|
validationReport,
|
|
@@ -668,6 +702,7 @@ async function finalizeCodingRun(args: {
|
|
|
668
702
|
pushWorkOnce,
|
|
669
703
|
inFlightPush,
|
|
670
704
|
agentRun,
|
|
705
|
+
prTemplate,
|
|
671
706
|
} = args
|
|
672
707
|
const { signal } = opts
|
|
673
708
|
const { summary, stats, stderrTail, usage, callMetrics, effortReport } = agentRun
|
|
@@ -686,9 +721,15 @@ async function finalizeCodingRun(args: {
|
|
|
686
721
|
// changed what the briefing should say) and removed so it never lingers in the checkout. The
|
|
687
722
|
// prompt asks for it at the top level of the checkout; a monorepo agent working in a service
|
|
688
723
|
// subdirectory may drop it in its cwd instead, so probe the checkout root first, then the cwd.
|
|
724
|
+
//
|
|
725
|
+
// When the repo ships a template the briefing IS that template filled in, so its headings are
|
|
726
|
+
// the REPO's: a leading `# …` there is the template's own top heading, not the title line the
|
|
727
|
+
// description guidance asks a free-form briefing for, and lifting it would retitle the PR after
|
|
728
|
+
// the template and delete the heading from the body.
|
|
729
|
+
const readDescription = (from: string): Promise<AgentPrDescription | undefined> =>
|
|
730
|
+
readPrDescription(from, { titleFromHeading: !prTemplate.templated.has(dir) })
|
|
689
731
|
const prDescription =
|
|
690
|
-
(await
|
|
691
|
-
(workDir !== dir ? await readPrDescription(workDir) : undefined)
|
|
732
|
+
(await readDescription(dir)) ?? (workDir !== dir ? await readDescription(workDir) : undefined)
|
|
692
733
|
|
|
693
734
|
// Stop periodic checkpoints and let any in-flight one settle BEFORE the final
|
|
694
735
|
// push, so the two never run a concurrent `git push` to the same branch (the
|
|
@@ -1093,6 +1134,21 @@ export async function runMultiRepoCoding(
|
|
|
1093
1134
|
})
|
|
1094
1135
|
: undefined
|
|
1095
1136
|
|
|
1137
|
+
// THE REPOS' OWN PR TEMPLATES: one per leg that will actually open a pull request, each named
|
|
1138
|
+
// by its sibling directory so the agent knows which checkout's briefing takes which shape —
|
|
1139
|
+
// the repos in a workspace need not share a template, or ship one at all. A read-only
|
|
1140
|
+
// reference leg is excluded by construction: it carries no `pr`, so nothing publishes for it.
|
|
1141
|
+
const prTemplate = await resolvePrTemplateNote({
|
|
1142
|
+
targets: legs
|
|
1143
|
+
.filter((leg) => leg.pr)
|
|
1144
|
+
.map((leg) => ({
|
|
1145
|
+
repoDir: leg.dir,
|
|
1146
|
+
repoLabel: leg.dirName,
|
|
1147
|
+
...(leg.repo.provider ? { provider: leg.repo.provider } : {}),
|
|
1148
|
+
})),
|
|
1149
|
+
logger,
|
|
1150
|
+
})
|
|
1151
|
+
|
|
1096
1152
|
// Run the agent ONCE with its cwd at the workspace root, so it sees every sibling checkout
|
|
1097
1153
|
// and can change them coherently. No monorepo/service-directory scoping — the multi-repo
|
|
1098
1154
|
// note + the backend system-prompt section explain the layout.
|
|
@@ -1103,7 +1159,10 @@ export async function runMultiRepoCoding(
|
|
|
1103
1159
|
{
|
|
1104
1160
|
dir: root,
|
|
1105
1161
|
systemPrompt: job.systemPrompt,
|
|
1106
|
-
userPrompt: withDependencyNote(
|
|
1162
|
+
userPrompt: withDependencyNote(
|
|
1163
|
+
withPrTemplateNote(job.userPrompt, prTemplate.note),
|
|
1164
|
+
dependencyNote,
|
|
1165
|
+
),
|
|
1107
1166
|
model: job.model,
|
|
1108
1167
|
harness: job.harness,
|
|
1109
1168
|
subscriptionToken: job.subscriptionToken,
|
|
@@ -1132,6 +1191,7 @@ export async function runMultiRepoCoding(
|
|
|
1132
1191
|
logger,
|
|
1133
1192
|
opts,
|
|
1134
1193
|
root,
|
|
1194
|
+
prTemplate,
|
|
1135
1195
|
)
|
|
1136
1196
|
|
|
1137
1197
|
const anyWork = primaryPushed || peerPullRequests.length > 0
|
|
@@ -1307,6 +1367,8 @@ async function pushMultiRepoLegs(
|
|
|
1307
1367
|
opts: RunOptions,
|
|
1308
1368
|
/** The workspace root the agent ran in — the fallback probe for the primary's briefing. */
|
|
1309
1369
|
root: string,
|
|
1370
|
+
/** Which legs' briefings are filled templates — see the `titleFromHeading` read below. */
|
|
1371
|
+
prTemplate: PrTemplateResolution,
|
|
1310
1372
|
): Promise<{
|
|
1311
1373
|
primaryPushed: boolean
|
|
1312
1374
|
primaryPrUrl: string | undefined
|
|
@@ -1327,9 +1389,14 @@ async function pushMultiRepoLegs(
|
|
|
1327
1389
|
// read the prompt loosely may well have written a single briefing there instead. Fall back
|
|
1328
1390
|
// to it for the PRIMARY leg only: at the root there is nothing to say which repo it
|
|
1329
1391
|
// describes, and the primary is the one the run is actually about.
|
|
1392
|
+
//
|
|
1393
|
+
// Per-leg `titleFromHeading`: only a leg whose OWN repo ships a template has repo-authored
|
|
1394
|
+
// headings in its sentinel, and the legs of a workspace need not agree about that — so this
|
|
1395
|
+
// is keyed on the leg, never on whether the run found any template at all.
|
|
1396
|
+
const readOptions = { titleFromHeading: !prTemplate.templated.has(leg.dir) }
|
|
1330
1397
|
const agentPrDescription =
|
|
1331
|
-
(await readPrDescription(leg.dir)) ??
|
|
1332
|
-
(leg.primary ? await readPrDescription(root) : undefined)
|
|
1398
|
+
(await readPrDescription(leg.dir, readOptions)) ??
|
|
1399
|
+
(leg.primary ? await readPrDescription(root, readOptions) : undefined)
|
|
1333
1400
|
await commitTrackedEdits(leg.dir, job.commitMessage ?? leg.pr?.title ?? 'Agent changes', signal)
|
|
1334
1401
|
const advanced = await branchHasCommitsSince(leg.dir, leg.baseSha, signal)
|
|
1335
1402
|
let hasWork = advanced || leg.resumed
|
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/pr-description.ts
CHANGED
|
@@ -32,8 +32,14 @@ export const PR_DESCRIPTION_FILE = '.cat-pr-description.md'
|
|
|
32
32
|
* rejects a body over 65,536 with a 422, and the report publisher swallows its own failures —
|
|
33
33
|
* so a briefing budget that does not leave the report room would surface as a report that
|
|
34
34
|
* silently never publishes. 15,000 + 50,000 stays under the limit with room to join them.
|
|
35
|
+
*
|
|
36
|
+
* Exported because the PR-TEMPLATE note states it to the agent (`pr-template.ts`): a filled
|
|
37
|
+
* template is the one briefing shape whose length is dictated by a file the agent did not write,
|
|
38
|
+
* so an agent that does not know the ceiling can answer a long template past it and have
|
|
39
|
+
* {@link capBody} cut the repo's last sections — the very failure the inline budget avoids on the
|
|
40
|
+
* way IN.
|
|
35
41
|
*/
|
|
36
|
-
const MAX_PR_BODY_CHARS = 15_000
|
|
42
|
+
export const MAX_PR_BODY_CHARS = 15_000
|
|
37
43
|
|
|
38
44
|
/** Ceiling on an agent-supplied title (GitHub truncates around 256; a title should be short). */
|
|
39
45
|
const MAX_PR_TITLE_CHARS = 160
|
|
@@ -57,6 +63,24 @@ export interface AgentPrDescription {
|
|
|
57
63
|
body?: string
|
|
58
64
|
}
|
|
59
65
|
|
|
66
|
+
/** How to read a sentinel. */
|
|
67
|
+
export interface ReadPrDescriptionOptions {
|
|
68
|
+
/**
|
|
69
|
+
* Whether a lone leading `# <title>` heading may be lifted off as the PR title (see
|
|
70
|
+
* {@link splitTitle}). Default `true` — that is what the description guidance asks a free-form
|
|
71
|
+
* briefing for.
|
|
72
|
+
*
|
|
73
|
+
* FALSE when the briefing is a FILLED TEMPLATE (`pr-template.ts`): then the headings are the
|
|
74
|
+
* repo's, not the agent's, and a template whose first heading is its only level-1 one — `#
|
|
75
|
+
* Pull Request` above a set of `##` sections, an entirely ordinary shape — would have that
|
|
76
|
+
* heading silently become the pull request's title, so the PR reads "Pull Request" instead of
|
|
77
|
+
* `<block> (<pipeline>)` and the body loses the heading the repo asked for. The heuristic below
|
|
78
|
+
* is sound for the shape the guidance describes and cannot be made to cover both, so the caller
|
|
79
|
+
* that KNOWS which shape it asked for says so.
|
|
80
|
+
*/
|
|
81
|
+
titleFromHeading?: boolean
|
|
82
|
+
}
|
|
83
|
+
|
|
60
84
|
/**
|
|
61
85
|
* Read + parse + REMOVE the agent's PR-description sentinel from `dir`. Lenient: returns
|
|
62
86
|
* undefined when the file is absent (the agent wrote none) or carries nothing usable. Never
|
|
@@ -64,16 +88,20 @@ export interface AgentPrDescription {
|
|
|
64
88
|
* the dispatch-time text.
|
|
65
89
|
*
|
|
66
90
|
* A SINGLE `# <title>` heading on the first line sets the PR title; everything after it is the
|
|
67
|
-
* body (see {@link splitTitle} for why a LONE heading is required
|
|
68
|
-
*
|
|
69
|
-
*
|
|
91
|
+
* body (see {@link splitTitle} for why a LONE heading is required, and
|
|
92
|
+
* {@link ReadPrDescriptionOptions.titleFromHeading} for the caller that must switch it off). The
|
|
93
|
+
* whole text is secret-scrubbed, an over-budget body is truncated WITH a visible note (a silent
|
|
94
|
+
* cut would read as the complete briefing), and both halves are made inert for the host.
|
|
70
95
|
*
|
|
71
96
|
* On scrubbing: `redactSecrets`'s credential-assignment rule is deliberately eager, so a
|
|
72
97
|
* briefing sentence like "the token: handling changed" loses its next word. That is the right
|
|
73
98
|
* trade for a surface this public — the rule is shared with every other redaction path, and
|
|
74
99
|
* narrowing it so prose reads better would weaken all of them.
|
|
75
100
|
*/
|
|
76
|
-
export async function readPrDescription(
|
|
101
|
+
export async function readPrDescription(
|
|
102
|
+
dir: string,
|
|
103
|
+
opts: ReadPrDescriptionOptions = {},
|
|
104
|
+
): Promise<AgentPrDescription | undefined> {
|
|
77
105
|
const path = join(dir, PR_DESCRIPTION_FILE)
|
|
78
106
|
let raw: string
|
|
79
107
|
try {
|
|
@@ -86,7 +114,8 @@ export async function readPrDescription(dir: string): Promise<AgentPrDescription
|
|
|
86
114
|
const text = redactSecrets(raw).replace(MANAGED_SECTION_MARKER, '').trim()
|
|
87
115
|
if (!text) return undefined
|
|
88
116
|
|
|
89
|
-
const split =
|
|
117
|
+
const split: { title?: string; body: string } =
|
|
118
|
+
opts.titleFromHeading === false ? { body: text } : splitTitle(text)
|
|
90
119
|
// Cap BEFORE the escapes on both halves, so a numeric entity can never be sliced in half.
|
|
91
120
|
const title = split.title ? inertInline(capTitle(split.title)) : undefined
|
|
92
121
|
const body = split.body ? inertMarkdown(capBody(split.body)) : undefined
|
|
@@ -104,6 +133,11 @@ export async function readPrDescription(dir: string): Promise<AgentPrDescription
|
|
|
104
133
|
* silently become the pull request's title, replacing `<block> (<pipeline>)` with the word
|
|
105
134
|
* "Problem". Headings inside fenced code are not headings and are skipped, or a briefing
|
|
106
135
|
* quoting a shell snippet (`# rebuild the image`) would lose its title to the snippet.
|
|
136
|
+
*
|
|
137
|
+
* The "single H1" test is what makes this safe for the free-form shape and is exactly what makes
|
|
138
|
+
* it WRONG for a filled template, whose H1 count is the repo's choice — hence
|
|
139
|
+
* {@link ReadPrDescriptionOptions.titleFromHeading}, which skips this entirely rather than piling
|
|
140
|
+
* another heuristic on top of one that cannot serve both shapes.
|
|
107
141
|
*/
|
|
108
142
|
function splitTitle(text: string): { title?: string; body: string } {
|
|
109
143
|
const lines = text.split('\n')
|