@cat-factory/executor-harness 1.58.0 → 1.62.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 +24 -2
- package/dist/agent.js +28 -14
- package/dist/captured-command.js +112 -0
- package/dist/coding-agent.js +90 -9
- package/dist/git.js +108 -319
- package/dist/host-markdown.js +142 -0
- package/dist/job.js +5 -46
- package/dist/pr-description.js +157 -0
- package/dist/reproduction-proof.js +614 -0
- package/dist/runner.js +3 -0
- package/dist/validation-checks.js +70 -82
- package/dist/vcs-api.js +402 -0
- package/package.json +4 -3
- package/src/agent.ts +28 -14
- package/src/captured-command.ts +144 -0
- package/src/coding-agent.ts +133 -6
- package/src/git.ts +134 -385
- package/src/host-markdown.ts +155 -0
- package/src/job.ts +32 -46
- package/src/pr-description.ts +171 -0
- package/src/reproduction-proof.ts +806 -0
- package/src/runner.ts +20 -0
- package/src/validation-checks.ts +71 -81
- package/src/vcs-api.ts +512 -0
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// The TEXT BOUNDARY for agent-authored text the harness writes onto a VCS host.
|
|
3
|
+
//
|
|
4
|
+
// A pull-request description is NOT an inert string sink. The host parses it: `#123` becomes
|
|
5
|
+
// an issue link, `@name` notifies a real person, a closing keyword in front of an issue
|
|
6
|
+
// reference CLOSES that issue when the PR merges, and an unbalanced code fence swallows
|
|
7
|
+
// everything rendered after it — including the fenced JSON block the engine later appends as
|
|
8
|
+
// the verification report's machine-readable contract.
|
|
9
|
+
//
|
|
10
|
+
// The agent's reviewer briefing (`pr-description.ts`) is model-authored prose that lands
|
|
11
|
+
// verbatim on that surface, so it crosses this boundary first. "This closes #42" is idiomatic
|
|
12
|
+
// for a briefing to emit and must not close issue 42; "@alice owns the rounding rule" is
|
|
13
|
+
// idiomatic and must not page whoever holds that handle.
|
|
14
|
+
//
|
|
15
|
+
// This is a deliberate COPY of `hostMarkdown` in `@cat-factory/kernel`
|
|
16
|
+
// (`src/shared/host-markdown.logic.ts`), for the same reason `isSafeTestPath` is copied: the
|
|
17
|
+
// container image is built from `src/` plus typescript alone (the Dockerfile cannot resolve a
|
|
18
|
+
// `workspace:*` dependency), so the harness carries no runtime dependency on any package here.
|
|
19
|
+
// `test/host-markdown.conformity.test.ts` pins the two implementations to byte-identical
|
|
20
|
+
// output over a shared corpus, so the copy cannot drift — change one, change the other.
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The host's closing keywords. A PR body carrying one of these in front of an issue reference
|
|
25
|
+
* closes that issue on merge — a side effect the harness must never trigger on the agent's
|
|
26
|
+
* behalf. Same list on GitHub and GitLab.
|
|
27
|
+
*/
|
|
28
|
+
const CLOSING_KEYWORDS =
|
|
29
|
+
'close[sd]?|closing|fix|fixe[sd]|fixing|resolve[sd]?|resolving|implement(?:s|ed)?|implementing'
|
|
30
|
+
|
|
31
|
+
/** An issue/MR URL on either host, in the form a closing keyword can reference. */
|
|
32
|
+
const ISSUE_URL = String.raw`https?://\S+?/(?:issues|-/issues|merge_requests|pull)/\d+`
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Every auto-linking trigger, in ONE alternation.
|
|
36
|
+
*
|
|
37
|
+
* Deliberately a single pass rather than chained `.replace()` calls: each escape EMITS a `#`,
|
|
38
|
+
* so a later rule would re-escape the output of an earlier one (`@` → `@` → `@`).
|
|
39
|
+
* One regex means the replacement text is never rescanned.
|
|
40
|
+
*/
|
|
41
|
+
const AUTO_LINK_TRIGGERS = new RegExp(
|
|
42
|
+
[
|
|
43
|
+
// A closing keyword in front of an issue/MR URL. The URL form survives the character
|
|
44
|
+
// escapes below (nothing in it is a trigger), so the KEYWORD is what gets defused.
|
|
45
|
+
String.raw`(?<keyword>\b(?:${CLOSING_KEYWORDS}))(?=\s*:?\s+${ISSUE_URL})`,
|
|
46
|
+
// `@name` / `@org/team` — a mention notifies a real account.
|
|
47
|
+
String.raw`(?<at>@(?=[A-Za-z0-9]))`,
|
|
48
|
+
// `#123` and `owner/repo#123` — an issue/PR cross-reference.
|
|
49
|
+
String.raw`(?<hash>#(?=\d))`,
|
|
50
|
+
// `!123` — GitLab's merge-request reference.
|
|
51
|
+
String.raw`(?<bang>!(?=\d))`,
|
|
52
|
+
].join('|'),
|
|
53
|
+
'gi',
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Neutralise the host's auto-linking triggers in ONE line of untrusted text, leaving inline
|
|
58
|
+
* code spans alone (the host does not auto-link inside them, so escaping there would only
|
|
59
|
+
* show the reader a literal `#`).
|
|
60
|
+
*
|
|
61
|
+
* The escapes are numeric HTML entities, which render as the original character but are
|
|
62
|
+
* invisible to the reference parser — so the reader sees exactly what the agent wrote while
|
|
63
|
+
* the mention/close side effects are defused.
|
|
64
|
+
*/
|
|
65
|
+
function inertLine(line: string): string {
|
|
66
|
+
return mapOutsideCodeSpans(line, (text) =>
|
|
67
|
+
text.replace(AUTO_LINK_TRIGGERS, (match, ...args) => {
|
|
68
|
+
const groups = args[args.length - 1] as Record<string, string | undefined>
|
|
69
|
+
// Entity-escaping the FIRST character is enough to break the parser's match while
|
|
70
|
+
// rendering identically — which matters most for the keyword, whose remaining letters
|
|
71
|
+
// are ordinary prose the reader should still see.
|
|
72
|
+
return `&#${match.charCodeAt(0)};${groups.keyword ? match.slice(1) : ''}`
|
|
73
|
+
}),
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Apply `fn` to the parts of `line` that are NOT inline code spans. Code spans are matched by
|
|
79
|
+
* a backtick run and its matching closer, which is CommonMark's rule and — more to the point
|
|
80
|
+
* — the rule the host renderer applies when deciding where to auto-link.
|
|
81
|
+
*/
|
|
82
|
+
function mapOutsideCodeSpans(line: string, fn: (text: string) => string): string {
|
|
83
|
+
const out: string[] = []
|
|
84
|
+
let index = 0
|
|
85
|
+
const span = /(`+)[\s\S]*?\1/g
|
|
86
|
+
let match: RegExpExecArray | null
|
|
87
|
+
while ((match = span.exec(line)) !== null) {
|
|
88
|
+
out.push(fn(line.slice(index, match.index)), match[0])
|
|
89
|
+
index = match.index + match[0].length
|
|
90
|
+
}
|
|
91
|
+
return out.join('') + fn(line.slice(index))
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** A line that opens or closes a fenced code block, with the fence it uses. */
|
|
95
|
+
function fenceAt(line: string): { char: string; length: number; info: boolean } | null {
|
|
96
|
+
const match = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line)
|
|
97
|
+
if (!match) return null
|
|
98
|
+
const fence = match[1]!
|
|
99
|
+
// A ``` fence's info string may not contain a backtick (CommonMark), which is what stops an
|
|
100
|
+
// inline span from being read as a fence.
|
|
101
|
+
if (fence.startsWith('`') && match[2]!.includes('`')) return null
|
|
102
|
+
return { char: fence[0]!, length: fence.length, info: match[2]!.trim().length > 0 }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Walk `lines`, tracking fenced-code state, and hand each line to `visit` together with
|
|
107
|
+
* whether it sits INSIDE a fenced block. Returns the fence still open at the end, if any.
|
|
108
|
+
*
|
|
109
|
+
* One shared walker so the three things that care about fences — leaving code untouched,
|
|
110
|
+
* closing what the text left open, and finding the briefing's title heading — can never
|
|
111
|
+
* disagree about where a block starts and ends.
|
|
112
|
+
*/
|
|
113
|
+
export function walkFences(
|
|
114
|
+
lines: readonly string[],
|
|
115
|
+
visit: (line: string, insideFence: boolean) => void,
|
|
116
|
+
): { char: string; length: number } | null {
|
|
117
|
+
let open: { char: string; length: number } | null = null
|
|
118
|
+
for (const line of lines) {
|
|
119
|
+
const fence = fenceAt(line)
|
|
120
|
+
// The fence line itself belongs to the code block, so it is never rewritten.
|
|
121
|
+
visit(line, open !== null || fence !== null)
|
|
122
|
+
if (!fence) continue
|
|
123
|
+
if (!open) open = { char: fence.char, length: fence.length }
|
|
124
|
+
else if (fence.char === open.char && fence.length >= open.length && !fence.info) open = null
|
|
125
|
+
}
|
|
126
|
+
return open
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Render untrusted multi-line markdown safe to send to a host: auto-link triggers defused
|
|
131
|
+
* outside fenced code, and any fence the text leaves open closed again.
|
|
132
|
+
*
|
|
133
|
+
* Unlike kernel's `hostMarkdown.prose` this does NOT cap the length — the caller
|
|
134
|
+
* ({@link import('./pr-description.js')}) applies its own budget with its own visible note
|
|
135
|
+
* BEFORE calling here, so an escape entity can never be sliced in half. With that one
|
|
136
|
+
* difference the output is identical, which the conformity test pins.
|
|
137
|
+
*/
|
|
138
|
+
export function inertMarkdown(text: string): string {
|
|
139
|
+
const normalised = text.replace(/\r\n?/g, '\n')
|
|
140
|
+
const rewritten: string[] = []
|
|
141
|
+
const open = walkFences(normalised.split('\n'), (line, insideFence) => {
|
|
142
|
+
rewritten.push(insideFence ? line : inertLine(line))
|
|
143
|
+
})
|
|
144
|
+
const joined = rewritten.join('\n')
|
|
145
|
+
return open ? `${joined}\n${open.char.repeat(open.length)}` : joined
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Render untrusted text INLINE (a pull-request title): newlines folded to spaces because the
|
|
150
|
+
* surrounding line has its own meaning, and auto-link triggers defused. The caller caps the
|
|
151
|
+
* length first, for the same reason as {@link inertMarkdown}.
|
|
152
|
+
*/
|
|
153
|
+
export function inertInline(text: string): string {
|
|
154
|
+
return inertLine(text.replace(/\s+/g, ' '))
|
|
155
|
+
}
|
package/src/job.ts
CHANGED
|
@@ -2,7 +2,16 @@ import type { HarnessCallMetric, PiRunStats } from './pi.js'
|
|
|
2
2
|
import type { HarnessKind } from './pi-workspace.js'
|
|
3
3
|
import type { FailureCause } from './failure.js'
|
|
4
4
|
import type { EffortReport } from './effort.js'
|
|
5
|
-
import
|
|
5
|
+
import {
|
|
6
|
+
parseValidationChecksSpec,
|
|
7
|
+
type ValidationChecksSpec,
|
|
8
|
+
type ValidationReport,
|
|
9
|
+
} from './validation-checks.js'
|
|
10
|
+
import {
|
|
11
|
+
parseReproductionSpec,
|
|
12
|
+
type ReproductionReport,
|
|
13
|
+
type ReproductionSpec,
|
|
14
|
+
} from './reproduction-proof.js'
|
|
6
15
|
|
|
7
16
|
// The job the Worker's ContainerAgentExecutor POSTs to /run. Kept as plain
|
|
8
17
|
// types with a hand-rolled validator so the image needs no schema dependency.
|
|
@@ -175,38 +184,6 @@ function parseValidationSpec(value: unknown): ValidationSpec | undefined {
|
|
|
175
184
|
}
|
|
176
185
|
}
|
|
177
186
|
|
|
178
|
-
/**
|
|
179
|
-
* Parse the optional PRE-PR VALIDATION CHECKS spec (see
|
|
180
|
-
* docs/initiatives/pre-pr-validation.md): the service's ordered `{ label, command }` pairs and
|
|
181
|
-
* the repair-round budget. Every entry needs a non-empty command; entries without one are
|
|
182
|
-
* dropped, and a spec that ends up with no usable check returns `undefined` — so a malformed
|
|
183
|
-
* body degrades to the exact pre-feature behaviour (no loop, PR opens as before) rather than
|
|
184
|
-
* failing an otherwise-good coding run. `maxAttempts` is clamped to a sane range so a bad body
|
|
185
|
-
* can't make a container loop forever.
|
|
186
|
-
*/
|
|
187
|
-
function parseValidationChecksSpec(value: unknown): ValidationChecksSpec | undefined {
|
|
188
|
-
if (typeof value !== 'object' || value === null) return undefined
|
|
189
|
-
const o = value as Record<string, unknown>
|
|
190
|
-
if (!Array.isArray(o.checks)) return undefined
|
|
191
|
-
const checks: { label: string; command: string }[] = []
|
|
192
|
-
for (const raw of o.checks) {
|
|
193
|
-
if (typeof raw !== 'object' || raw === null) continue
|
|
194
|
-
const c = raw as Record<string, unknown>
|
|
195
|
-
if (typeof c.command !== 'string' || c.command.trim() === '') continue
|
|
196
|
-
const label = typeof c.label === 'string' && c.label.trim() ? c.label.trim() : c.command
|
|
197
|
-
checks.push({ label, command: c.command })
|
|
198
|
-
}
|
|
199
|
-
if (checks.length === 0) return undefined
|
|
200
|
-
const parsed = posInt(o.maxAttempts)
|
|
201
|
-
return {
|
|
202
|
-
checks,
|
|
203
|
-
maxAttempts: Math.min(
|
|
204
|
-
parsed ?? VALIDATION_DEFAULT_MAX_ATTEMPTS,
|
|
205
|
-
VALIDATION_MAX_ATTEMPTS_CEILING,
|
|
206
|
-
),
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
|
|
210
187
|
/**
|
|
211
188
|
* Parse the shared per-job auth fields, validating per harness: a subscription
|
|
212
189
|
* harness (`claude-code` / `codex`) requires `subscriptionToken`; the default Pi
|
|
@@ -872,21 +849,18 @@ export interface AgentJob extends HarnessAuthFields {
|
|
|
872
849
|
* job DATA, not the agent kind. See {@link ValidationChecksSpec}.
|
|
873
850
|
*/
|
|
874
851
|
validationChecks?: ValidationChecksSpec
|
|
852
|
+
/**
|
|
853
|
+
* Coding mode: the run's BUGFIX REPRODUCTION PROOF — the declared reproduction command, the
|
|
854
|
+
* test file(s) that constitute it, and an optional setup command. When set, the harness runs
|
|
855
|
+
* that command against the pre-fix tree AND the tree the PR will open from, and reports both
|
|
856
|
+
* exit codes: only red-then-green is proof. Present only on a dispatch that opens a PR and
|
|
857
|
+
* whose run carries a reproduction declaration; absent ⇒ the run behaves exactly as before.
|
|
858
|
+
* Deliberately keyed off job DATA, not the agent kind. See
|
|
859
|
+
* `docs/initiatives/bugfix-reproduction-proof.md`.
|
|
860
|
+
*/
|
|
861
|
+
reproduction?: ReproductionSpec
|
|
875
862
|
}
|
|
876
863
|
|
|
877
|
-
/**
|
|
878
|
-
* The ceiling the harness clamps a body-supplied `validationChecks.maxAttempts` to, and the
|
|
879
|
-
* default it applies when the body omits one.
|
|
880
|
-
*
|
|
881
|
-
* DELIBERATE DUPLICATES of `VALIDATION_MAX_ATTEMPTS_CEILING` / `VALIDATION_DEFAULT_MAX_ATTEMPTS`
|
|
882
|
-
* in `@cat-factory/contracts` — the published image takes no schema dependency, so the harness
|
|
883
|
-
* cannot import them. Keep the two in step: the API validates writes against the contracts
|
|
884
|
-
* values, so a harness clamping to a DIFFERENT ceiling would silently cap a budget an operator
|
|
885
|
-
* was allowed to save, with nothing to flag the mismatch.
|
|
886
|
-
*/
|
|
887
|
-
export const VALIDATION_MAX_ATTEMPTS_CEILING = 10
|
|
888
|
-
export const VALIDATION_DEFAULT_MAX_ATTEMPTS = 3
|
|
889
|
-
|
|
890
864
|
/** Per-job, per-knob progress-guard overrides (see {@link AgentJob.guardLimits}). */
|
|
891
865
|
export interface GuardLimitsSpec {
|
|
892
866
|
maxToolCallsWithoutEdit?: number
|
|
@@ -938,6 +912,14 @@ export interface AgentResult {
|
|
|
938
912
|
* Absent when the job carried no {@link AgentJob.validationChecks}.
|
|
939
913
|
*/
|
|
940
914
|
validationReport?: ValidationReport
|
|
915
|
+
/**
|
|
916
|
+
* The BUGFIX REPRODUCTION PROOF: the declared reproduction command's verdict across the pre-fix
|
|
917
|
+
* tree and the final tree, computed by the harness from exit codes. Present on every outcome of
|
|
918
|
+
* a job that carried {@link AgentJob.reproduction} — a verdict is evidence, not a gate, so an
|
|
919
|
+
* `inconclusive` one accompanies the opened PR exactly like a `reproduced` one does. Absent
|
|
920
|
+
* when the job carried no reproduction declaration.
|
|
921
|
+
*/
|
|
922
|
+
reproductionReport?: ReproductionReport
|
|
941
923
|
/**
|
|
942
924
|
* Preview mode: the in-container URL the built app is served at (e.g. `http://localhost:4173`).
|
|
943
925
|
* This is NOT host-reachable on its own — the container runtime publishes the serve port to an
|
|
@@ -1345,6 +1327,7 @@ export function parseAgentJob(input: unknown): AgentJob {
|
|
|
1345
1327
|
guardLimits: parseGuardLimits(o.guardLimits),
|
|
1346
1328
|
validation: parseValidationSpec(o.validation),
|
|
1347
1329
|
validationChecks: parseValidationChecksSpec(o.validationChecks),
|
|
1330
|
+
reproduction: parseReproductionSpec(o.reproduction),
|
|
1348
1331
|
reviewPrNumber: posInt(o.reviewPrNumber),
|
|
1349
1332
|
})
|
|
1350
1333
|
assertAllowedHost(job.repo.cloneUrl, 'repo.cloneUrl')
|
|
@@ -1383,6 +1366,7 @@ interface ParsedAgentJobParts {
|
|
|
1383
1366
|
guardLimits: ReturnType<typeof parseGuardLimits>
|
|
1384
1367
|
validation: ReturnType<typeof parseValidationSpec>
|
|
1385
1368
|
validationChecks: ReturnType<typeof parseValidationChecksSpec>
|
|
1369
|
+
reproduction: ReturnType<typeof parseReproductionSpec>
|
|
1386
1370
|
reviewPrNumber: number | undefined
|
|
1387
1371
|
}
|
|
1388
1372
|
|
|
@@ -1436,6 +1420,7 @@ function assembleAgentJob(
|
|
|
1436
1420
|
guardLimits,
|
|
1437
1421
|
validation,
|
|
1438
1422
|
validationChecks,
|
|
1423
|
+
reproduction,
|
|
1439
1424
|
reviewPrNumber,
|
|
1440
1425
|
} = parts
|
|
1441
1426
|
const repo = (o.repo ?? {}) as Record<string, unknown>
|
|
@@ -1465,6 +1450,7 @@ function assembleAgentJob(
|
|
|
1465
1450
|
...(guardLimits ? { guardLimits } : {}),
|
|
1466
1451
|
...(validation ? { validation } : {}),
|
|
1467
1452
|
...(validationChecks ? { validationChecks } : {}),
|
|
1453
|
+
...(reproduction ? { reproduction } : {}),
|
|
1468
1454
|
}
|
|
1469
1455
|
}
|
|
1470
1456
|
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import { readFile, rm } from 'node:fs/promises'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { inertInline, inertMarkdown, walkFences } from './host-markdown.js'
|
|
4
|
+
import { redactSecrets } from './redact.js'
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// The agent-authored pull-request description side channel. A coding agent whose
|
|
8
|
+
// dispatch opens a PR is asked (via the backend-composed system prompt) to end its
|
|
9
|
+
// run by writing a reviewer briefing — the problem, the decisions made, what to
|
|
10
|
+
// look out for — to a sentinel file at the root of the checkout the PR belongs to.
|
|
11
|
+
// The harness reads it after the agent settles, removes it (so it never lands in a
|
|
12
|
+
// commit), and uses it as the PR body in place of the generic dispatch-time text
|
|
13
|
+
// the job body carries. Absent or unusable ⇒ the dispatch-time fallback, unchanged.
|
|
14
|
+
//
|
|
15
|
+
// The briefing is MODEL-AUTHORED text landing verbatim on a host-parsed surface, so
|
|
16
|
+
// it crosses `host-markdown.ts` (auto-link triggers defused, open code fences closed)
|
|
17
|
+
// on the way out — see that module for why a PR body is not an inert string sink.
|
|
18
|
+
//
|
|
19
|
+
// The filename is kept in sync with `PR_DESCRIPTION_FILE` in `@cat-factory/agents`
|
|
20
|
+
// (the executor-harness has no dependency on that package), exactly like the
|
|
21
|
+
// effort-report and follow-ups sentinels.
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
/** The sentinel file the agent writes its PR description to (relative to the checkout root). */
|
|
25
|
+
export const PR_DESCRIPTION_FILE = '.cat-pr-description.md'
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Ceiling on the agent-authored body.
|
|
29
|
+
*
|
|
30
|
+
* The engine appends its verification report to the SAME body later, and that section carries
|
|
31
|
+
* its own 50,000-character ceiling (`MAX_SECTION_CHARS` in kernel's `hostMarkdown`). GitHub
|
|
32
|
+
* rejects a body over 65,536 with a 422, and the report publisher swallows its own failures —
|
|
33
|
+
* so a briefing budget that does not leave the report room would surface as a report that
|
|
34
|
+
* silently never publishes. 15,000 + 50,000 stays under the limit with room to join them.
|
|
35
|
+
*/
|
|
36
|
+
const MAX_PR_BODY_CHARS = 15_000
|
|
37
|
+
|
|
38
|
+
/** Ceiling on an agent-supplied title (GitHub truncates around 256; a title should be short). */
|
|
39
|
+
const MAX_PR_TITLE_CHARS = 160
|
|
40
|
+
|
|
41
|
+
/** Opens the engine-managed region of a PR body (kept in sync with `kernel/domain/pr-report.ts`). */
|
|
42
|
+
export const PR_REPORT_MARKER_START = '<!-- cat-factory:verification-report:start -->'
|
|
43
|
+
/** Closes the engine-managed region of a PR body. */
|
|
44
|
+
export const PR_REPORT_MARKER_END = '<!-- cat-factory:verification-report:end -->'
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A marker inside the agent-authored briefing would make the engine's splice treat part of the
|
|
48
|
+
* briefing as its own managed region and rewrite it, so any occurrence is stripped up front.
|
|
49
|
+
* Deliberately laxer than the exact constants above (whitespace-tolerant), so a near-miss the
|
|
50
|
+
* splice itself would not match cannot survive here either.
|
|
51
|
+
*/
|
|
52
|
+
const MANAGED_SECTION_MARKER = /<!--\s*cat-factory:verification-report:(?:start|end)\s*-->/g
|
|
53
|
+
|
|
54
|
+
/** An agent-authored PR description: an optional title plus the briefing body. */
|
|
55
|
+
export interface AgentPrDescription {
|
|
56
|
+
title?: string
|
|
57
|
+
body?: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read + parse + REMOVE the agent's PR-description sentinel from `dir`. Lenient: returns
|
|
62
|
+
* undefined when the file is absent (the agent wrote none) or carries nothing usable. Never
|
|
63
|
+
* throws — a bad description must never fail an otherwise-good run; the caller falls back to
|
|
64
|
+
* the dispatch-time text.
|
|
65
|
+
*
|
|
66
|
+
* 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). The whole text is
|
|
68
|
+
* secret-scrubbed, an over-budget body is truncated WITH a visible note (a silent cut would
|
|
69
|
+
* read as the complete briefing), and both halves are made inert for the host.
|
|
70
|
+
*
|
|
71
|
+
* On scrubbing: `redactSecrets`'s credential-assignment rule is deliberately eager, so a
|
|
72
|
+
* briefing sentence like "the token: handling changed" loses its next word. That is the right
|
|
73
|
+
* trade for a surface this public — the rule is shared with every other redaction path, and
|
|
74
|
+
* narrowing it so prose reads better would weaken all of them.
|
|
75
|
+
*/
|
|
76
|
+
export async function readPrDescription(dir: string): Promise<AgentPrDescription | undefined> {
|
|
77
|
+
const path = join(dir, PR_DESCRIPTION_FILE)
|
|
78
|
+
let raw: string
|
|
79
|
+
try {
|
|
80
|
+
raw = await readFile(path, 'utf8')
|
|
81
|
+
} catch {
|
|
82
|
+
return undefined // no description written — the fallback body applies
|
|
83
|
+
}
|
|
84
|
+
// Remove it so it never lands in a commit (defence in depth; the checkout also excludes it).
|
|
85
|
+
await rm(path, { force: true }).catch(() => {})
|
|
86
|
+
const text = redactSecrets(raw).replace(MANAGED_SECTION_MARKER, '').trim()
|
|
87
|
+
if (!text) return undefined
|
|
88
|
+
|
|
89
|
+
const split = splitTitle(text)
|
|
90
|
+
// Cap BEFORE the escapes on both halves, so a numeric entity can never be sliced in half.
|
|
91
|
+
const title = split.title ? inertInline(capTitle(split.title)) : undefined
|
|
92
|
+
const body = split.body ? inertMarkdown(capBody(split.body)) : undefined
|
|
93
|
+
if (!title && !body) return undefined
|
|
94
|
+
return { ...(title ? { title } : {}), ...(body ? { body } : {}) }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Split a leading `# <title>` heading off the briefing.
|
|
99
|
+
*
|
|
100
|
+
* The heading becomes the title ONLY when it is the single level-1 heading in the whole file,
|
|
101
|
+
* which is exactly what the prompt asks for ("a single `# <title>` heading line"). An agent
|
|
102
|
+
* that instead uses `#` for its section headings — `# Problem`, `# Decisions`, entirely
|
|
103
|
+
* idiomatic for the briefing the prompt describes — would otherwise have its first section
|
|
104
|
+
* silently become the pull request's title, replacing `<block> (<pipeline>)` with the word
|
|
105
|
+
* "Problem". Headings inside fenced code are not headings and are skipped, or a briefing
|
|
106
|
+
* quoting a shell snippet (`# rebuild the image`) would lose its title to the snippet.
|
|
107
|
+
*/
|
|
108
|
+
function splitTitle(text: string): { title?: string; body: string } {
|
|
109
|
+
const lines = text.split('\n')
|
|
110
|
+
const headings: number[] = []
|
|
111
|
+
let index = 0
|
|
112
|
+
walkFences(lines, (line, insideFence) => {
|
|
113
|
+
if (!insideFence && /^#\s+\S/.test(line)) headings.push(index)
|
|
114
|
+
index += 1
|
|
115
|
+
})
|
|
116
|
+
if (headings.length !== 1 || headings[0] !== 0) return { body: text }
|
|
117
|
+
const title = lines[0]!.replace(/^#\s+/, '').trim()
|
|
118
|
+
if (!title) return { body: text }
|
|
119
|
+
return { title, body: lines.slice(1).join('\n').trim() }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Cut an over-long title at a word boundary when one is near, marking the cut. */
|
|
123
|
+
function capTitle(value: string): string {
|
|
124
|
+
const collapsed = value.trim()
|
|
125
|
+
if (collapsed.length <= MAX_PR_TITLE_CHARS) return collapsed
|
|
126
|
+
const head = collapsed.slice(0, MAX_PR_TITLE_CHARS - 1)
|
|
127
|
+
const space = head.lastIndexOf(' ')
|
|
128
|
+
const kept = space > MAX_PR_TITLE_CHARS * 0.6 ? head.slice(0, space) : head
|
|
129
|
+
return `${kept.trimEnd()}…`
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Cut an over-budget body, marking the cut so it is never read as the whole briefing. */
|
|
133
|
+
function capBody(value: string): string {
|
|
134
|
+
if (value.length <= MAX_PR_BODY_CHARS) return value
|
|
135
|
+
return (
|
|
136
|
+
value.slice(0, MAX_PR_BODY_CHARS).trimEnd() +
|
|
137
|
+
'\n\n_Truncated by the platform: the description exceeded the size budget._'
|
|
138
|
+
)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Fold an agent-authored description over the dispatch-time fallback the job body carries.
|
|
143
|
+
* Field-wise: the agent's title/body each win when present, so a body-only briefing keeps the
|
|
144
|
+
* backend-composed title and vice versa.
|
|
145
|
+
*/
|
|
146
|
+
export function applyPrDescription(
|
|
147
|
+
fallback: { title: string; body: string },
|
|
148
|
+
agent: AgentPrDescription | undefined,
|
|
149
|
+
): { title: string; body: string } {
|
|
150
|
+
if (!agent) return fallback
|
|
151
|
+
return { title: agent.title ?? fallback.title, body: agent.body ?? fallback.body }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The body to PATCH onto an ALREADY-OPEN pull request when a resumed run produced a fresh
|
|
156
|
+
* briefing: the new description followed by whatever the engine's managed verification-report
|
|
157
|
+
* region currently holds.
|
|
158
|
+
*
|
|
159
|
+
* Carrying the region across is what makes the refresh safe. The engine re-publishes the report
|
|
160
|
+
* on every step settlement, so dropping it here would usually self-heal — but "usually" is not
|
|
161
|
+
* a property to rest the one artefact a reviewer reads on, and a run that settles no further
|
|
162
|
+
* step (the work is already merged, the run failed after its push) would never restore it.
|
|
163
|
+
*/
|
|
164
|
+
export function preserveManagedSection(currentBody: string | undefined, nextBody: string): string {
|
|
165
|
+
const existing = currentBody ?? ''
|
|
166
|
+
const start = existing.indexOf(PR_REPORT_MARKER_START)
|
|
167
|
+
const end = existing.indexOf(PR_REPORT_MARKER_END)
|
|
168
|
+
if (start === -1 || end <= start) return nextBody
|
|
169
|
+
const region = existing.slice(start, end + PR_REPORT_MARKER_END.length)
|
|
170
|
+
return `${nextBody.trim()}\n\n${region}\n`
|
|
171
|
+
}
|