@cat-factory/executor-harness 1.60.0 → 1.64.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 +10 -1
- package/dist/agent-runner.js +81 -8
- package/dist/agent.js +9 -3
- package/dist/claude-stream.js +18 -0
- package/dist/coding-agent.js +32 -4
- package/dist/embed.js +2 -1
- package/dist/git.js +0 -319
- package/dist/host-markdown.js +142 -0
- package/dist/pi-workspace.js +35 -2
- package/dist/pi.js +15 -184
- package/dist/pr-description.js +157 -0
- package/dist/progress-guard.js +211 -0
- package/dist/subagents.js +1 -50
- package/dist/vcs-api.js +402 -0
- package/package.json +4 -3
- package/src/agent-runner.ts +88 -8
- package/src/agent.ts +8 -3
- package/src/claude-stream.ts +19 -0
- package/src/coding-agent.ts +45 -3
- package/src/embed.ts +5 -3
- package/src/git.ts +1 -385
- package/src/host-markdown.ts +155 -0
- package/src/pi-workspace.ts +40 -4
- package/src/pi.ts +26 -252
- package/src/pr-description.ts +171 -0
- package/src/progress-guard.ts +285 -0
- package/src/subagents.ts +7 -16
- package/src/vcs-api.ts +512 -0
package/dist/vcs-api.js
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// The VCS HOST REST surface: opening a pull request / merge request, finding the one that is
|
|
3
|
+
// already open, and refreshing its title + description.
|
|
4
|
+
//
|
|
5
|
+
// Split out of `git.ts`, which is otherwise entirely the git CLI. The seam is real rather than
|
|
6
|
+
// arithmetic: nothing here shells out to git, nothing in `git.ts` speaks HTTP, and the two
|
|
7
|
+
// halves fail in completely different ways (a rejected credential and a 403 from an App
|
|
8
|
+
// permission want different remedies). `test/git-pr.test.ts` already covered exactly this
|
|
9
|
+
// surface before it had a file of its own.
|
|
10
|
+
//
|
|
11
|
+
// Provider-agnostic by construction: GitHub and GitLab each get their own request shapes behind
|
|
12
|
+
// one `openPullRequest` entry point, and every capability added to one is added to the other.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
import { HarnessFailure } from './failure.js';
|
|
15
|
+
import { preserveManagedSection } from './pr-description.js';
|
|
16
|
+
import { redactSecrets } from './redact.js';
|
|
17
|
+
/**
|
|
18
|
+
* Classify a PR/MR-open REST failure by its HTTP status into an actionable remedy, else
|
|
19
|
+
* undefined (an unmapped status keeps just the raw `Failed to open … (HTTP n)` line). Like
|
|
20
|
+
* {@link describeGitFailure} this only APPENDS a cause + fix — the raw status line is
|
|
21
|
+
* load-bearing detail and stays. `provider` tailors the scope/permission wording (GitHub App
|
|
22
|
+
* Pull-requests permission / `repo` PAT scope vs GitLab `api` scope) and the noun (pull
|
|
23
|
+
* request vs merge request). Pure, so it is unit-tested per status.
|
|
24
|
+
*/
|
|
25
|
+
export function describePrOpenFailure(status, provider) {
|
|
26
|
+
const noun = provider === 'gitlab' ? 'merge request' : 'pull request';
|
|
27
|
+
if (status === 401) {
|
|
28
|
+
return (`The credential was rejected while opening the ${noun}. The GitHub App installation token ` +
|
|
29
|
+
'(or, in local mode, the GITHUB_PAT) is most likely expired, rotated, or revoked — reconnect ' +
|
|
30
|
+
'the GitHub App for the workspace (or regenerate the PAT), then retry.');
|
|
31
|
+
}
|
|
32
|
+
if (status === 403) {
|
|
33
|
+
const scope = provider === 'gitlab'
|
|
34
|
+
? 'the GitLab token needs the `api` scope and Developer+ access to the project'
|
|
35
|
+
: 'the GitHub App needs the "Pull requests: write" permission (or the PAT the `repo` scope) and write access to the repository';
|
|
36
|
+
return `The credential lacks permission to open a ${noun}: ${scope}. Grant it, then retry.`;
|
|
37
|
+
}
|
|
38
|
+
if (status === 404) {
|
|
39
|
+
return (`The repository could not be found while opening the ${noun} — it may have been deleted, ` +
|
|
40
|
+
'renamed, or made private, or the credential can no longer see it. Confirm the repo and the ' +
|
|
41
|
+
"credential's access to it, then retry.");
|
|
42
|
+
}
|
|
43
|
+
if (status === 422 || status === 400) {
|
|
44
|
+
return (`GitHub/GitLab rejected the ${noun} as invalid. Usually the head or base branch does not ` +
|
|
45
|
+
'exist, the two branches are identical (nothing to compare), or the base branch is protected ' +
|
|
46
|
+
'against direct PRs. Check the branch names and that the head has commits ahead of the base, ' +
|
|
47
|
+
'then retry.');
|
|
48
|
+
}
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* The VCS host a clone URL points at. The harness is otherwise provider-agnostic (its git
|
|
53
|
+
* auth is a host-neutral GIT_ASKPASS credential), but the "open the PR/MR" REST call is not:
|
|
54
|
+
* GitHub and GitLab have different endpoints, so infer which to call from the host. GitHub is
|
|
55
|
+
* the default; a host of `gitlab.com` or one in the `gitlab.*` / `*.gitlab.*` family (covering
|
|
56
|
+
* self-managed instances named that way) is treated as GitLab.
|
|
57
|
+
*/
|
|
58
|
+
export function inferVcsProvider(cloneUrl) {
|
|
59
|
+
let host = '';
|
|
60
|
+
try {
|
|
61
|
+
host = new URL(cloneUrl).host.toLowerCase();
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return 'github';
|
|
65
|
+
}
|
|
66
|
+
if (host === 'gitlab.com' || host.startsWith('gitlab.') || host.includes('.gitlab.')) {
|
|
67
|
+
return 'gitlab';
|
|
68
|
+
}
|
|
69
|
+
return 'github';
|
|
70
|
+
}
|
|
71
|
+
/** The GitLab REST v4 base for a clone URL's host, e.g. `https://gitlab.com/api/v4`. */
|
|
72
|
+
export function gitlabApiBaseFromCloneUrl(cloneUrl) {
|
|
73
|
+
const u = new URL(cloneUrl);
|
|
74
|
+
return `${u.protocol}//${u.host}/api/v4`;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The URL-encoded GitLab project path from a clone URL — the full namespace path (so subgroups
|
|
78
|
+
* survive), with the trailing `.git` stripped, e.g.
|
|
79
|
+
* `https://gitlab.com/group/sub/proj.git` → `group%2Fsub%2Fproj`.
|
|
80
|
+
*/
|
|
81
|
+
export function gitlabProjectPath(cloneUrl) {
|
|
82
|
+
const path = new URL(cloneUrl).pathname.replace(/^\/+/, '').replace(/\.git$/, '');
|
|
83
|
+
return encodeURIComponent(path);
|
|
84
|
+
}
|
|
85
|
+
/** The abort reason as an Error (the watchdog aborts with one), or a generic fallback. */
|
|
86
|
+
function abortError(signal) {
|
|
87
|
+
return signal.reason instanceof Error ? signal.reason : new Error('aborted');
|
|
88
|
+
}
|
|
89
|
+
/** Whether a thrown fetch error is an AbortError (caller-initiated, never retried). */
|
|
90
|
+
function isAbortError(err) {
|
|
91
|
+
return err instanceof Error && err.name === 'AbortError';
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Parse a `Retry-After` header into ms, bounded so it can't stall the job. Accepts BOTH
|
|
95
|
+
* forms the spec allows: integer delay-seconds (`120`) and an HTTP-date (`Wed, 21 Oct 2026
|
|
96
|
+
* 07:28:00 GMT`); the latter is turned into a delay from now. A past/zero/unparseable value
|
|
97
|
+
* yields undefined so the caller falls back to exponential backoff.
|
|
98
|
+
*/
|
|
99
|
+
function retryAfterMs(res) {
|
|
100
|
+
const raw = res.headers.get('retry-after');
|
|
101
|
+
if (!raw)
|
|
102
|
+
return undefined;
|
|
103
|
+
const secs = Number(raw);
|
|
104
|
+
if (Number.isFinite(secs)) {
|
|
105
|
+
return secs > 0 ? Math.min(secs * 1000, MAX_RETRY_AFTER_MS) : undefined;
|
|
106
|
+
}
|
|
107
|
+
const at = Date.parse(raw);
|
|
108
|
+
if (Number.isNaN(at))
|
|
109
|
+
return undefined;
|
|
110
|
+
const ms = at - Date.now();
|
|
111
|
+
return ms > 0 ? Math.min(ms, MAX_RETRY_AFTER_MS) : undefined;
|
|
112
|
+
}
|
|
113
|
+
/** Sleep `ms`, rejecting immediately (with the abort reason) if `signal` aborts meanwhile. */
|
|
114
|
+
function abortableDelay(ms, signal) {
|
|
115
|
+
return new Promise((resolve, reject) => {
|
|
116
|
+
if (signal?.aborted)
|
|
117
|
+
return reject(abortError(signal));
|
|
118
|
+
const onAbort = () => {
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
reject(abortError(signal));
|
|
121
|
+
};
|
|
122
|
+
const timer = setTimeout(() => {
|
|
123
|
+
signal?.removeEventListener('abort', onAbort);
|
|
124
|
+
resolve();
|
|
125
|
+
}, ms);
|
|
126
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
const MAX_RETRY_AFTER_MS = 8_000;
|
|
130
|
+
const RETRY_BASE_MS = 500;
|
|
131
|
+
const RETRY_MAX_DELAY_MS = 4_000;
|
|
132
|
+
/**
|
|
133
|
+
* Run a single HTTP request with bounded retry for TRANSIENT failures, so a momentary
|
|
134
|
+
* upstream blip (a 5xx, a 429 rate-limit, or a dropped connection) no longer fails an
|
|
135
|
+
* otherwise-complete run on its very last step (opening the PR/MR). Up to 3 attempts
|
|
136
|
+
* (2 retries) with exponential backoff + jitter (honoring a `Retry-After` on a 429),
|
|
137
|
+
* every wait abort-aware so the inactivity/max-duration watchdog still cancels promptly.
|
|
138
|
+
*
|
|
139
|
+
* ONLY transient failures retry: a `>=500`/`429` response, or a network-level fetch
|
|
140
|
+
* rejection. A 4xx (incl. the 422/409 "already exists" the callers treat as success) is
|
|
141
|
+
* returned to the caller unretried, and a caller abort is rethrown at once. The response
|
|
142
|
+
* body is never read here, so the caller's existing status handling is unchanged.
|
|
143
|
+
*/
|
|
144
|
+
async function withApiRetry(fn, opts = {}) {
|
|
145
|
+
const maxAttempts = opts.attempts ?? 3;
|
|
146
|
+
let lastError;
|
|
147
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
148
|
+
if (opts.signal?.aborted)
|
|
149
|
+
throw abortError(opts.signal);
|
|
150
|
+
let res;
|
|
151
|
+
try {
|
|
152
|
+
res = await fn();
|
|
153
|
+
}
|
|
154
|
+
catch (err) {
|
|
155
|
+
// A caller/watchdog abort is terminal; a network error is transient → retry.
|
|
156
|
+
if (isAbortError(err) || opts.signal?.aborted)
|
|
157
|
+
throw err;
|
|
158
|
+
lastError = err;
|
|
159
|
+
}
|
|
160
|
+
if (res) {
|
|
161
|
+
const transient = res.status >= 500 || res.status === 429;
|
|
162
|
+
if (!transient || attempt >= maxAttempts)
|
|
163
|
+
return res;
|
|
164
|
+
const after = retryAfterMs(res);
|
|
165
|
+
// Discard the unread body before retrying so the connection can be reused.
|
|
166
|
+
await res.body?.cancel().catch(() => { });
|
|
167
|
+
await abortableDelay(after ?? backoffMs(attempt), opts.signal);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (attempt >= maxAttempts)
|
|
171
|
+
break;
|
|
172
|
+
await abortableDelay(backoffMs(attempt), opts.signal);
|
|
173
|
+
}
|
|
174
|
+
// Exhausted on a network-level rejection (no HTTP response): an upstream API failure.
|
|
175
|
+
const message = lastError instanceof Error ? lastError.message : 'API request failed after retries';
|
|
176
|
+
throw new HarnessFailure('api', redactSecrets(message));
|
|
177
|
+
}
|
|
178
|
+
/** Exponential backoff (base 500ms, capped 4s) with up to 25% positive jitter. */
|
|
179
|
+
function backoffMs(attempt) {
|
|
180
|
+
const base = Math.min(RETRY_MAX_DELAY_MS, RETRY_BASE_MS * 2 ** (attempt - 1));
|
|
181
|
+
return base + Math.floor(base * 0.25 * Math.random());
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Open a PR (GitHub) or merge request (GitLab) for the pushed branch; returns its web URL.
|
|
185
|
+
* The provider is chosen from the EXPLICIT `opts.provider` when the dispatcher set it,
|
|
186
|
+
* falling back to host inference from the clone URL only when it didn't — so a self-managed
|
|
187
|
+
* GitLab whose host isn't named `gitlab.*` still opens an MR instead of being misrouted to
|
|
188
|
+
* GitHub's API. The GitHub path is unchanged.
|
|
189
|
+
*/
|
|
190
|
+
export async function openPullRequest(opts) {
|
|
191
|
+
const provider = opts.provider ?? (opts.cloneUrl ? inferVcsProvider(opts.cloneUrl) : 'github');
|
|
192
|
+
if (provider === 'gitlab') {
|
|
193
|
+
if (!opts.cloneUrl) {
|
|
194
|
+
throw new Error('Cannot open a GitLab merge request without the repo clone URL');
|
|
195
|
+
}
|
|
196
|
+
return openGitLabMergeRequest({ ...opts, cloneUrl: opts.cloneUrl });
|
|
197
|
+
}
|
|
198
|
+
const apiBase = opts.apiBase ?? 'https://api.github.com';
|
|
199
|
+
const path = `${encodeURIComponent(opts.owner)}/${encodeURIComponent(opts.name)}`;
|
|
200
|
+
const res = await withApiRetry(() => fetch(`${apiBase}/repos/${path}/pulls`, {
|
|
201
|
+
method: 'POST',
|
|
202
|
+
headers: {
|
|
203
|
+
authorization: `Bearer ${opts.ghToken}`,
|
|
204
|
+
accept: 'application/vnd.github+json',
|
|
205
|
+
'user-agent': 'cat-factory-executor',
|
|
206
|
+
'x-github-api-version': '2022-11-28',
|
|
207
|
+
'content-type': 'application/json',
|
|
208
|
+
},
|
|
209
|
+
body: JSON.stringify({
|
|
210
|
+
title: opts.pr.title,
|
|
211
|
+
head: opts.head,
|
|
212
|
+
base: opts.base,
|
|
213
|
+
body: opts.pr.body,
|
|
214
|
+
}),
|
|
215
|
+
// Bound on the watchdog so a hung GitHub call can't stall the job.
|
|
216
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
217
|
+
}), { signal: opts.signal });
|
|
218
|
+
if (!res.ok) {
|
|
219
|
+
const detail = await res.text().catch(() => '');
|
|
220
|
+
// A resumed run pushes to a branch that already has an open PR; GitHub answers
|
|
221
|
+
// 422 "A pull request already exists". That's success for us — return the
|
|
222
|
+
// existing PR's url rather than failing the resumed run.
|
|
223
|
+
if (res.status === 422 && /pull request already exists/i.test(detail)) {
|
|
224
|
+
const existing = await findOpenPullRequest(opts);
|
|
225
|
+
if (existing) {
|
|
226
|
+
if (opts.refreshExisting)
|
|
227
|
+
await refreshPullRequest(opts, existing);
|
|
228
|
+
return existing.url;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
// The head branch has nothing ahead of base ("No commits between <base> and <head>").
|
|
232
|
+
// That is not an API failure — there is simply nothing to open a PR for (e.g. a resumed
|
|
233
|
+
// branch whose earlier PR was merged with a merge commit, leaving the branch reachable
|
|
234
|
+
// from base). Signal it with null so the caller records a clean no-op instead of failing
|
|
235
|
+
// the run with GitHub's opaque 422.
|
|
236
|
+
if (res.status === 422 && /no commits between/i.test(detail))
|
|
237
|
+
return null;
|
|
238
|
+
const remedy = describePrOpenFailure(res.status, 'github');
|
|
239
|
+
const base = redactSecrets(`Failed to open PR (HTTP ${res.status}): ${detail.slice(0, 300)}`);
|
|
240
|
+
throw new HarnessFailure('api', remedy ? `${base}\n${remedy}` : base);
|
|
241
|
+
}
|
|
242
|
+
const body = (await res.json());
|
|
243
|
+
if (!body.html_url)
|
|
244
|
+
throw new HarnessFailure('api', 'GitHub did not return a PR url');
|
|
245
|
+
return body.html_url;
|
|
246
|
+
}
|
|
247
|
+
/** GitLab API headers for the PAT (the `PRIVATE-TOKEN` auth GitLab uses). */
|
|
248
|
+
function gitlabHeaders(token) {
|
|
249
|
+
return {
|
|
250
|
+
'private-token': token,
|
|
251
|
+
accept: 'application/json',
|
|
252
|
+
'user-agent': 'cat-factory-executor',
|
|
253
|
+
'content-type': 'application/json',
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Open a GitLab merge request (the analogue of {@link openPullRequest} for GitLab). The REST
|
|
258
|
+
* base + project path are derived from the clone URL's host, so it works for gitlab.com and a
|
|
259
|
+
* self-managed instance alike. `head`→`source_branch`, `base`→`target_branch`. On a duplicate
|
|
260
|
+
* (a resumed run whose branch already has an open MR — GitLab answers 409) the existing MR's
|
|
261
|
+
* web URL is returned instead of failing the run, mirroring the GitHub 422 handling.
|
|
262
|
+
*/
|
|
263
|
+
async function openGitLabMergeRequest(opts) {
|
|
264
|
+
const apiBase = gitlabApiBaseFromCloneUrl(opts.cloneUrl);
|
|
265
|
+
const project = gitlabProjectPath(opts.cloneUrl);
|
|
266
|
+
const res = await withApiRetry(() => fetch(`${apiBase}/projects/${project}/merge_requests`, {
|
|
267
|
+
method: 'POST',
|
|
268
|
+
headers: gitlabHeaders(opts.ghToken),
|
|
269
|
+
body: JSON.stringify({
|
|
270
|
+
source_branch: opts.head,
|
|
271
|
+
target_branch: opts.base,
|
|
272
|
+
title: opts.pr.title,
|
|
273
|
+
description: opts.pr.body,
|
|
274
|
+
}),
|
|
275
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
276
|
+
}), { signal: opts.signal });
|
|
277
|
+
if (!res.ok) {
|
|
278
|
+
const detail = await res.text().catch(() => '');
|
|
279
|
+
// GitLab returns 409 (sometimes 400) when an open MR already exists for this source
|
|
280
|
+
// branch; that is success for a resumed run — return the existing MR's url.
|
|
281
|
+
if ((res.status === 409 || res.status === 400) &&
|
|
282
|
+
/already exists|open merge request/i.test(detail)) {
|
|
283
|
+
const existing = await findOpenMergeRequest(apiBase, project, opts);
|
|
284
|
+
if (existing) {
|
|
285
|
+
if (opts.refreshExisting)
|
|
286
|
+
await refreshMergeRequest(apiBase, project, opts, existing);
|
|
287
|
+
return existing.url;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const remedy = describePrOpenFailure(res.status, 'gitlab');
|
|
291
|
+
const base = redactSecrets(`Failed to open merge request (HTTP ${res.status}): ${detail.slice(0, 300)}`);
|
|
292
|
+
throw new HarnessFailure('api', remedy ? `${base}\n${remedy}` : base);
|
|
293
|
+
}
|
|
294
|
+
const body = (await res.json());
|
|
295
|
+
if (!body.web_url)
|
|
296
|
+
throw new HarnessFailure('api', 'GitLab did not return a merge request url');
|
|
297
|
+
return body.web_url;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Rewrite an already-open MR's title and description — the GitLab half of
|
|
301
|
+
* {@link refreshPullRequest}, so a resumed run's agent briefing reaches both hosts alike.
|
|
302
|
+
* Best-effort for the same reason.
|
|
303
|
+
*/
|
|
304
|
+
async function refreshMergeRequest(apiBase, project, opts, existing) {
|
|
305
|
+
if (existing.number === undefined)
|
|
306
|
+
return;
|
|
307
|
+
await fetch(`${apiBase}/projects/${project}/merge_requests/${existing.number}`, {
|
|
308
|
+
method: 'PUT',
|
|
309
|
+
headers: gitlabHeaders(opts.ghToken),
|
|
310
|
+
body: JSON.stringify({
|
|
311
|
+
title: opts.pr.title,
|
|
312
|
+
description: preserveManagedSection(existing.body, opts.pr.body),
|
|
313
|
+
}),
|
|
314
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
315
|
+
}).catch(() => undefined);
|
|
316
|
+
}
|
|
317
|
+
/** Find the open GitLab MR for `opts.head`→`opts.base`, or undefined when there is none. */
|
|
318
|
+
async function findOpenMergeRequest(apiBase, project, opts) {
|
|
319
|
+
// Filter by BOTH branches: a source branch can have open MRs to several targets, so the
|
|
320
|
+
// source alone could match an MR against a different base than the one we just tried to open.
|
|
321
|
+
const query = new URLSearchParams({
|
|
322
|
+
source_branch: opts.head,
|
|
323
|
+
target_branch: opts.base,
|
|
324
|
+
state: 'opened',
|
|
325
|
+
});
|
|
326
|
+
const res = await fetch(`${apiBase}/projects/${project}/merge_requests?${query}`, {
|
|
327
|
+
headers: gitlabHeaders(opts.ghToken),
|
|
328
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
329
|
+
});
|
|
330
|
+
if (!res.ok)
|
|
331
|
+
return undefined;
|
|
332
|
+
const list = (await res.json().catch(() => []));
|
|
333
|
+
const found = Array.isArray(list) ? list[0] : undefined;
|
|
334
|
+
if (!found?.web_url)
|
|
335
|
+
return undefined;
|
|
336
|
+
return {
|
|
337
|
+
url: found.web_url,
|
|
338
|
+
// `iid` (project-scoped) is what the update endpoint addresses, not the global `id`.
|
|
339
|
+
...(typeof found.iid === 'number' ? { number: found.iid } : {}),
|
|
340
|
+
body: found.description ?? undefined,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Rewrite an already-open PR's title and description from `opts.pr` (a resumed run whose agent
|
|
345
|
+
* wrote a fresh reviewer briefing — see {@link OpenPullRequestOptions.refreshExisting}).
|
|
346
|
+
*
|
|
347
|
+
* Best-effort by construction: the work is already pushed and the PR already exists, so a failed
|
|
348
|
+
* refresh must degrade to the stale description rather than fail the run.
|
|
349
|
+
*/
|
|
350
|
+
async function refreshPullRequest(opts, existing) {
|
|
351
|
+
if (existing.number === undefined)
|
|
352
|
+
return;
|
|
353
|
+
const apiBase = opts.apiBase ?? 'https://api.github.com';
|
|
354
|
+
const path = `${encodeURIComponent(opts.owner)}/${encodeURIComponent(opts.name)}`;
|
|
355
|
+
await fetch(`${apiBase}/repos/${path}/pulls/${existing.number}`, {
|
|
356
|
+
method: 'PATCH',
|
|
357
|
+
headers: {
|
|
358
|
+
authorization: `Bearer ${opts.ghToken}`,
|
|
359
|
+
accept: 'application/vnd.github+json',
|
|
360
|
+
'user-agent': 'cat-factory-executor',
|
|
361
|
+
'x-github-api-version': '2022-11-28',
|
|
362
|
+
'content-type': 'application/json',
|
|
363
|
+
},
|
|
364
|
+
body: JSON.stringify({
|
|
365
|
+
title: opts.pr.title,
|
|
366
|
+
body: preserveManagedSection(existing.body, opts.pr.body),
|
|
367
|
+
}),
|
|
368
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
369
|
+
}).catch(() => undefined);
|
|
370
|
+
}
|
|
371
|
+
/** Find the open PR for `opts.head` on `opts.base`, or undefined when there is none. */
|
|
372
|
+
async function findOpenPullRequest(opts) {
|
|
373
|
+
const apiBase = opts.apiBase ?? 'https://api.github.com';
|
|
374
|
+
// Encode the ref-derived query params: a branch/owner containing `&` or `#` would
|
|
375
|
+
// otherwise split the query string or inject an unintended parameter.
|
|
376
|
+
const query = new URLSearchParams({
|
|
377
|
+
head: `${opts.owner}:${opts.head}`,
|
|
378
|
+
base: opts.base,
|
|
379
|
+
state: 'open',
|
|
380
|
+
});
|
|
381
|
+
const path = `${encodeURIComponent(opts.owner)}/${encodeURIComponent(opts.name)}`;
|
|
382
|
+
const res = await fetch(`${apiBase}/repos/${path}/pulls?${query}`, {
|
|
383
|
+
headers: {
|
|
384
|
+
authorization: `Bearer ${opts.ghToken}`,
|
|
385
|
+
accept: 'application/vnd.github+json',
|
|
386
|
+
'user-agent': 'cat-factory-executor',
|
|
387
|
+
'x-github-api-version': '2022-11-28',
|
|
388
|
+
},
|
|
389
|
+
...(opts.signal ? { signal: opts.signal } : {}),
|
|
390
|
+
});
|
|
391
|
+
if (!res.ok)
|
|
392
|
+
return undefined;
|
|
393
|
+
const list = (await res.json().catch(() => []));
|
|
394
|
+
const found = Array.isArray(list) ? list[0] : undefined;
|
|
395
|
+
if (!found?.html_url)
|
|
396
|
+
return undefined;
|
|
397
|
+
return {
|
|
398
|
+
url: found.html_url,
|
|
399
|
+
...(typeof found.number === 'number' ? { number: found.number } : {}),
|
|
400
|
+
body: found.body ?? undefined,
|
|
401
|
+
};
|
|
402
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/executor-harness",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.64.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,8 +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/
|
|
29
|
+
"@cat-factory/kernel": "0.170.0",
|
|
30
|
+
"@cat-factory/server": "0.160.0",
|
|
31
|
+
"@cat-factory/spend": "0.12.99"
|
|
31
32
|
},
|
|
32
33
|
"scripts": {
|
|
33
34
|
"build": "tsc -p tsconfig.json",
|
package/src/agent-runner.ts
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
type PiRunStats,
|
|
20
20
|
type TodoProgress,
|
|
21
21
|
} from './pi.js'
|
|
22
|
+
import { ProgressGuard, type ProgressGuardLimits } from './progress-guard.js'
|
|
22
23
|
import { killChildProcess, spawnDetached } from './process.js'
|
|
23
24
|
import { redact, secretsToRedact } from './redact.js'
|
|
24
25
|
import { createSliceTracker, startSubagentWatcher } from './subagents.js'
|
|
@@ -101,6 +102,17 @@ export interface SubscriptionRunOptions {
|
|
|
101
102
|
extraEnv?: Record<string, string>
|
|
102
103
|
/** Aborting this kills the CLI (the job's inactivity/max-duration watchdog). */
|
|
103
104
|
signal?: AbortSignal
|
|
105
|
+
/**
|
|
106
|
+
* Fully-resolved no-progress guard limits (env defaults merged loosen-only with the kind's
|
|
107
|
+
* tuning + any complexity-scaled allowance). When set, the claude-code runner runs the SAME
|
|
108
|
+
* {@link ProgressGuard} as Pi over the CLI's tool stream and kills a run that has plainly
|
|
109
|
+
* stopped making progress (no-edit probing, error-retry loop, web rabbit-hole) rather than
|
|
110
|
+
* letting it burn the whole wall-clock budget. Omitted ⇒ the guard is disabled for this run
|
|
111
|
+
* (only the external watchdog bounds it), preserving the pre-guard behaviour.
|
|
112
|
+
*/
|
|
113
|
+
guardLimits?: ProgressGuardLimits
|
|
114
|
+
/** Whether this run is expected to edit files (false for assess-only runs); gates the no-edit bound. */
|
|
115
|
+
expectsEdits?: boolean
|
|
104
116
|
/** Called on every chunk of CLI output, so the watchdog sees the agent is alive. */
|
|
105
117
|
onActivity?: () => void
|
|
106
118
|
/** Called with the latest subtask counts each time the CLI updates its todo/plan list. */
|
|
@@ -154,7 +166,7 @@ function streamCli(
|
|
|
154
166
|
opts: SubscriptionRunOptions,
|
|
155
167
|
env: Record<string, string>,
|
|
156
168
|
secrets: string[],
|
|
157
|
-
onEvent: (event: Record<string, unknown
|
|
169
|
+
onEvent: (event: Record<string, unknown>, meta?: { final?: boolean }) => void,
|
|
158
170
|
): Promise<{ stderrTail: string }> {
|
|
159
171
|
const { command, args } = cli
|
|
160
172
|
return new Promise((resolve, reject) => {
|
|
@@ -178,7 +190,12 @@ function streamCli(
|
|
|
178
190
|
|
|
179
191
|
const killChild = (): void => killChildProcess(child)
|
|
180
192
|
|
|
181
|
-
|
|
193
|
+
// `final` marks the at-close flush of a trailing unterminated line: the CLI has already
|
|
194
|
+
// exited, so an observer must not act on that record in a way that KILLS the run (mirrors
|
|
195
|
+
// `runPi`'s `runGuard = false` flush — without it, a guard tripping on the last buffered
|
|
196
|
+
// record could turn a clean exit into a spurious "no progress" failure). The record's
|
|
197
|
+
// progress/telemetry signal is still delivered; only kill decisions are suppressed.
|
|
198
|
+
const processLine = (line: string, final = false): void => {
|
|
182
199
|
if (!line.startsWith('{')) return
|
|
183
200
|
let event: Record<string, unknown>
|
|
184
201
|
try {
|
|
@@ -187,7 +204,7 @@ function streamCli(
|
|
|
187
204
|
return
|
|
188
205
|
}
|
|
189
206
|
try {
|
|
190
|
-
onEvent(event)
|
|
207
|
+
onEvent(event, { final })
|
|
191
208
|
} catch {
|
|
192
209
|
// A faulty observer must never break the run.
|
|
193
210
|
}
|
|
@@ -226,10 +243,13 @@ function streamCli(
|
|
|
226
243
|
})
|
|
227
244
|
child.on('close', (code) => {
|
|
228
245
|
opts.signal?.removeEventListener('abort', onAbort)
|
|
229
|
-
if (lineBuffer.trim()) processLine(lineBuffer.trim())
|
|
230
246
|
const stderrTail = redact(stderr, secrets).slice(-700)
|
|
247
|
+
if (lineBuffer.trim()) processLine(lineBuffer.trim(), true)
|
|
231
248
|
if (aborted) {
|
|
232
|
-
|
|
249
|
+
// Carry the tail on the rejection so a caller that REPLACES this generic message with a
|
|
250
|
+
// more specific cause (the no-progress guard's diagnostic) can still append it — the
|
|
251
|
+
// stderr is often the only evidence of what the CLI was doing when it was killed.
|
|
252
|
+
reject(Object.assign(new Error('agent run aborted by watchdog'), { stderrTail }))
|
|
233
253
|
return
|
|
234
254
|
}
|
|
235
255
|
if (code !== 0) {
|
|
@@ -382,7 +402,42 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
382
402
|
if (progress) opts.onProgress(progress)
|
|
383
403
|
}
|
|
384
404
|
|
|
385
|
-
|
|
405
|
+
// No-progress guard on the CLI's own tool stream — the claude-code analogue of runPi's guard,
|
|
406
|
+
// absent on this path until now. Claude Code reports a tool CALL (its name) on the `assistant`
|
|
407
|
+
// turn and that call's RESULT (`is_error`) on the following `user` turn, so correlate them by
|
|
408
|
+
// `tool_use` id to feed the guard a {name,isError} signal. A tripped guard aborts the CLI via
|
|
409
|
+
// `guardAbort` (folded into streamCli's signal below) and the run then fails with its
|
|
410
|
+
// diagnostic. Disabled when the caller supplies no limits (only the external watchdog bounds it).
|
|
411
|
+
const guard = opts.guardLimits
|
|
412
|
+
? new ProgressGuard(opts.guardLimits, opts.expectsEdits ?? true)
|
|
413
|
+
: undefined
|
|
414
|
+
const toolNames = new Map<string, string>()
|
|
415
|
+
const guardAbort = new AbortController()
|
|
416
|
+
let guardReason: string | undefined
|
|
417
|
+
|
|
418
|
+
// Feed a user turn's settled tool calls to the guard, pairing each `tool_result`'s `is_error`
|
|
419
|
+
// with the name captured for its `tool_use` id on the assistant turn. The FIRST reason trips
|
|
420
|
+
// it: record the diagnostic and abort the CLI (streamCli's close handler rejects; the catch
|
|
421
|
+
// below surfaces `guardReason` over the generic abort message). A standalone closure so the
|
|
422
|
+
// per-block loop doesn't nest onEvent past the readable-depth limit.
|
|
423
|
+
const feedGuard = (content: unknown[]): void => {
|
|
424
|
+
if (!guard || guardReason) return
|
|
425
|
+
for (const block of content) {
|
|
426
|
+
if (!isObject(block) || block.type !== 'tool_result') continue
|
|
427
|
+
const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : undefined
|
|
428
|
+
const name = id ? toolNames.get(id) : undefined
|
|
429
|
+
if (id) toolNames.delete(id)
|
|
430
|
+
if (!name) continue
|
|
431
|
+
const reason = guard.observeSignal({ name, isError: block.is_error === true })
|
|
432
|
+
if (reason) {
|
|
433
|
+
guardReason = reason
|
|
434
|
+
guardAbort.abort()
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const onEvent = (event: Record<string, unknown>, meta?: { final?: boolean }): void => {
|
|
386
441
|
const type = event.type
|
|
387
442
|
if (type === 'assistant' && isObject(event.message)) {
|
|
388
443
|
const message = event.message as Record<string, unknown>
|
|
@@ -391,7 +446,13 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
391
446
|
stats.assistantChars += text.length
|
|
392
447
|
stats.toolCalls += toolUses
|
|
393
448
|
for (const block of content) {
|
|
394
|
-
if (isObject(block)
|
|
449
|
+
if (!isObject(block) || block.type !== 'tool_use') continue
|
|
450
|
+
// Remember each call's name against its id so the guard can pair it with the
|
|
451
|
+
// `is_error` its `tool_result` carries on the next `user` turn.
|
|
452
|
+
if (typeof block.id === 'string' && typeof block.name === 'string') {
|
|
453
|
+
toolNames.set(block.id, block.name)
|
|
454
|
+
}
|
|
455
|
+
if (block.name === 'TodoWrite') {
|
|
395
456
|
const progress = todosToProgress((block.input as Record<string, unknown>)?.todos)
|
|
396
457
|
if (progress) lastTodo = progress
|
|
397
458
|
}
|
|
@@ -422,6 +483,9 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
422
483
|
sliceTracker.onUser(content)
|
|
423
484
|
planTracker.onUser(content)
|
|
424
485
|
emitProgress()
|
|
486
|
+
// Not on the at-close flush: the CLI has already exited, so tripping the guard there
|
|
487
|
+
// would kill nothing and only convert a clean exit into a spurious failure.
|
|
488
|
+
if (!meta?.final) feedGuard(content)
|
|
425
489
|
messages.push({ role: 'tool', content })
|
|
426
490
|
}
|
|
427
491
|
} else if (type === 'result') {
|
|
@@ -488,6 +552,12 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
488
552
|
})
|
|
489
553
|
: undefined
|
|
490
554
|
|
|
555
|
+
// Fold the guard's abort into the run signal so a tripped guard kills the CLI the same way the
|
|
556
|
+
// external watchdog does; `guardReason` (set above) distinguishes the two at the catch below.
|
|
557
|
+
const runSignal = opts.signal
|
|
558
|
+
? AbortSignal.any([opts.signal, guardAbort.signal])
|
|
559
|
+
: guardAbort.signal
|
|
560
|
+
|
|
491
561
|
try {
|
|
492
562
|
const { stderrTail } = await streamCli(
|
|
493
563
|
{
|
|
@@ -509,7 +579,7 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
509
579
|
],
|
|
510
580
|
},
|
|
511
581
|
prompt,
|
|
512
|
-
opts,
|
|
582
|
+
{ ...opts, signal: runSignal },
|
|
513
583
|
env,
|
|
514
584
|
opts.subscriptionToken ? secretsToRedact(opts.subscriptionToken) : [],
|
|
515
585
|
onEvent,
|
|
@@ -524,6 +594,16 @@ export async function runClaudeCode(opts: SubscriptionRunOptions): Promise<PiRun
|
|
|
524
594
|
usage,
|
|
525
595
|
subagents,
|
|
526
596
|
})
|
|
597
|
+
} catch (err) {
|
|
598
|
+
// A tripped no-progress guard aborted the CLI; streamCli rejects with its generic abort
|
|
599
|
+
// message, so replace it with the guard's actionable diagnostic — carrying the stderr tail
|
|
600
|
+
// it attached, since that is usually the only evidence of what the CLI was doing when it was
|
|
601
|
+
// killed. Byte-for-byte the shape `runPi` fails with.
|
|
602
|
+
if (guardReason) {
|
|
603
|
+
const tail = (err as { stderrTail?: string } | undefined)?.stderrTail
|
|
604
|
+
throw new Error(tail ? `${guardReason} Agent stderr: ${tail}` : guardReason)
|
|
605
|
+
}
|
|
606
|
+
throw err
|
|
527
607
|
} finally {
|
|
528
608
|
await subagents?.stop()
|
|
529
609
|
if (configHome) {
|
package/src/agent.ts
CHANGED
|
@@ -22,16 +22,16 @@ import {
|
|
|
22
22
|
fetchReferenceBranches,
|
|
23
23
|
hasAgentChanges,
|
|
24
24
|
headCommit,
|
|
25
|
-
inferVcsProvider,
|
|
26
25
|
mergeBranch,
|
|
27
|
-
openPullRequest,
|
|
28
26
|
prepareExistingCheckout,
|
|
29
27
|
pushBranch,
|
|
30
28
|
reinitAndPush,
|
|
31
29
|
unmergedPaths,
|
|
32
30
|
} from './git.js'
|
|
31
|
+
import { inferVcsProvider, openPullRequest } from './vcs-api.js'
|
|
33
32
|
import type { PiRunStats, RunDiagnostics } from './pi.js'
|
|
34
33
|
import type { EffortReport } from './effort.js'
|
|
34
|
+
import { applyPrDescription } from './pr-description.js'
|
|
35
35
|
import {
|
|
36
36
|
makeDirClaimer,
|
|
37
37
|
noChangesReason,
|
|
@@ -998,6 +998,7 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
998
998
|
validationReport,
|
|
999
999
|
reproductionReport,
|
|
1000
1000
|
effortReport,
|
|
1001
|
+
prDescription,
|
|
1001
1002
|
} = await runCodingAgent(buildSingleRepoCodingSpec(job, pushBranch), opts)
|
|
1002
1003
|
// Ralph loop: the harness-computed validation verdict, forwarded onto the coding result as
|
|
1003
1004
|
// `ralphVerdict` so the backend's `toRunResult` lifts it onto `AgentRunResult.ralphVerdict`.
|
|
@@ -1073,7 +1074,11 @@ async function runSingleRepoCoding(job: AgentJob, opts: RunOptions): Promise<Age
|
|
|
1073
1074
|
ghToken: job.ghToken,
|
|
1074
1075
|
head: pushBranch,
|
|
1075
1076
|
base: job.repo.baseBranch,
|
|
1076
|
-
|
|
1077
|
+
// The agent-authored briefing (title/body) wins field-wise over the dispatch-time text.
|
|
1078
|
+
pr: applyPrDescription(job.pr, prDescription),
|
|
1079
|
+
// A resumed run's PR is already open, so refresh it rather than lose the briefing to the
|
|
1080
|
+
// duplicate-PR 422 — only from a REAL briefing (see `refreshExisting` for why).
|
|
1081
|
+
...(prDescription ? { refreshExisting: true } : {}),
|
|
1077
1082
|
apiBase: job.githubApiBase,
|
|
1078
1083
|
// The provider (set by the server from the configured backend) selects GitHub-PR vs
|
|
1079
1084
|
// GitLab-MR authoritatively; the clone URL supplies the GitLab REST base + project path.
|
package/src/claude-stream.ts
CHANGED
|
@@ -10,6 +10,25 @@ export function isObject(value: unknown): value is Record<string, unknown> {
|
|
|
10
10
|
return typeof value === 'object' && value !== null
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* The tool names the Claude Code CLI dispatches a parallel subagent under. `Agent` is what the
|
|
15
|
+
* shipped schema declares (`AgentInput` in `sdk-tools.d.ts`, carrying `description` / `prompt` /
|
|
16
|
+
* `subagent_type`); `Task` is the older name for the same dispatch. Both are matched because the
|
|
17
|
+
* harness runs against whatever CLI the image happens to bundle, and matching only the old name
|
|
18
|
+
* is what left a CLI 2.1.x pr-review reporting no slices at all.
|
|
19
|
+
*
|
|
20
|
+
* Note the asymmetry: keeping the legacy `Task` here is the one place a CLI rename could produce a
|
|
21
|
+
* FALSE signal rather than merely no signal — if a future build were to name a plain task-list
|
|
22
|
+
* tool `Task`, its writes would be counted as in-flight slices. We accept that because no shipped
|
|
23
|
+
* build does (the incremental plan tool is `TaskCreate`/`TaskUpdate`, tracked separately in
|
|
24
|
+
* `progress.ts`), and dropping legacy coverage is the more likely regression.
|
|
25
|
+
*
|
|
26
|
+
* Lives here rather than in `subagents.ts` because BOTH the slice tracker and the no-progress
|
|
27
|
+
* guard (`pi.ts`, which `subagents.ts` imports — so it cannot import back) must agree on what a
|
|
28
|
+
* subagent dispatch looks like.
|
|
29
|
+
*/
|
|
30
|
+
export const SUBAGENT_TOOL_NAMES = new Set(['Agent', 'Task'])
|
|
31
|
+
|
|
13
32
|
export function numberOf(value: unknown): number {
|
|
14
33
|
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
|
15
34
|
}
|