@gethmy/harness 1.2.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +489 -225
- package/dist/index.js +1222 -401
- package/package.json +2 -2
- package/src/ci-failure.ts +465 -0
- package/src/cli.ts +11 -1
- package/src/confine-to-repo.test.ts +324 -1
- package/src/confine-to-repo.ts +274 -22
- package/src/error-classifier.ts +52 -1
- package/src/gate-collectors.ts +11 -3
- package/src/git-pr.ts +461 -8
- package/src/index.ts +2 -0
- package/src/model-tier.test.ts +11 -6
- package/src/model-tier.ts +4 -4
- package/src/oracle-collector.ts +244 -23
- package/src/oracle.ts +856 -108
- package/src/pm.ts +15 -5
- package/src/repair-sandbox.test.ts +116 -0
- package/src/repair-sandbox.ts +303 -0
- package/src/run-sizing.test.ts +264 -66
- package/src/run-sizing.ts +146 -26
- package/src/sdk-agent-runner.ts +22 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gethmy/harness",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"description": "Execution motor for Harmony playbook stages. Runs exactly one stage per invocation: worktree, role-separated subagents, held oracle, gate evidence. It never routes, never judges, never pushes.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -55,7 +55,7 @@
|
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
57
|
"@anthropic-ai/claude-agent-sdk": "^0.3.178",
|
|
58
|
-
"@gethmy/mcp": "
|
|
58
|
+
"@gethmy/mcp": "3.0.0",
|
|
59
59
|
"@supabase/supabase-js": "2.95.3"
|
|
60
60
|
},
|
|
61
61
|
"devDependencies": {
|
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import type { GitProvider } from "./git-pr.js";
|
|
4
|
+
import { log } from "./log.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Reading a red CI in enough detail to act on it (card #981).
|
|
8
|
+
*
|
|
9
|
+
* `getPrStatus` collapses `statusCheckRollup` into one of four strings, which is
|
|
10
|
+
* everything the merge gate needs and nothing a repair can use: it cannot say
|
|
11
|
+
* WHICH check failed, and it throws away the `detailsUrl` that names the
|
|
12
|
+
* workflow run. This module keeps both, and adds the one distinction that
|
|
13
|
+
* decides how much a red CI is worth spending on.
|
|
14
|
+
*
|
|
15
|
+
* **A flaky or infrastructure failure is a re-run, not a code change.** The repo
|
|
16
|
+
* has a documented history of them — `deploy-supabase` failing on an esm.sh 522,
|
|
17
|
+
* a GitHub Actions job dying in "Set up job" on DNS, `setup-bun` taking a 503
|
|
18
|
+
* from the runner's IP. Editing source to "fix" any of those produces a wrong
|
|
19
|
+
* change that happens to go green. Re-dispatching them costs a `gh run rerun`.
|
|
20
|
+
*
|
|
21
|
+
* The split mirrors `error-classifier.ts`, which does this job for API errors:
|
|
22
|
+
* {@link classifyCiFailure} is a pure ordered-regex matcher, so the whole
|
|
23
|
+
* taxonomy is table-testable, and the three `gh` wrappers below are the only
|
|
24
|
+
* part that touches a network.
|
|
25
|
+
*
|
|
26
|
+
* GitHub-only, like every other consumer of `statusCheckRollup`. A non-GitHub
|
|
27
|
+
* provider gets an empty list and the caller falls back to today's behaviour.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
// Lazy + memoised for the same reason git-pr.ts does it: this module sits in the
|
|
31
|
+
// harness barrel, and constructing a promisified execFile at module load would
|
|
32
|
+
// charge every consumer of every harness export for it.
|
|
33
|
+
function createExecFileAsync() {
|
|
34
|
+
return promisify(execFile);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let cachedExecFileAsync: ReturnType<typeof createExecFileAsync> | undefined;
|
|
38
|
+
|
|
39
|
+
function execFileAsync() {
|
|
40
|
+
return (cachedExecFileAsync ??= createExecFileAsync());
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const TAG = "ci-failure";
|
|
44
|
+
|
|
45
|
+
/** How much of a failing job's log the classifier reads. */
|
|
46
|
+
const LOG_TAIL_BYTES = 16_000;
|
|
47
|
+
|
|
48
|
+
/** A check's display name is a label, not a document. Anything longer is noise. */
|
|
49
|
+
const MAX_CHECK_NAME = 200;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Everything this module returns is UNTRUSTED REMOTE TEXT, and the repair loop
|
|
53
|
+
* feeds it to an LLM that runs with file-editing tools inside the checkout. That
|
|
54
|
+
* makes prompt injection a real code path rather than a theoretical one, so the
|
|
55
|
+
* text is neutralised HERE, at the boundary where it enters the process, rather
|
|
56
|
+
* than at the point of use where a future caller could forget.
|
|
57
|
+
*
|
|
58
|
+
* Two properties, both load-bearing:
|
|
59
|
+
*
|
|
60
|
+
* - **No fence can be closed.** The consumer embeds this text in a ``` block.
|
|
61
|
+
* A check name or log line containing its own ``` would end that block and
|
|
62
|
+
* let whatever follows read as instructions to the model. Backticks are
|
|
63
|
+
* replaced outright — nothing downstream needs them, and stripping is safer
|
|
64
|
+
* than escaping, which has to be right at every layer.
|
|
65
|
+
* - **No control characters.** ANSI escapes and carriage returns let text
|
|
66
|
+
* rewrite what a reader (human or model) sees above it.
|
|
67
|
+
*
|
|
68
|
+
* This is defence in depth, not the primary control: {@link isPrOnOrigin} keeps
|
|
69
|
+
* a foreign PR out of the loop altogether, and the repair spawns without `Bash`.
|
|
70
|
+
* It is here because a compromised third-party action can put arbitrary text
|
|
71
|
+
* into the daemon's OWN job log, where neither of those two helps.
|
|
72
|
+
*/
|
|
73
|
+
export function sanitizeCiText(text: string, maxLength: number): string {
|
|
74
|
+
return (
|
|
75
|
+
text
|
|
76
|
+
// Backtick = fence escape. Replaced rather than escaped: nothing
|
|
77
|
+
// downstream needs one, and stripping cannot be got subtly wrong the
|
|
78
|
+
// way escaping can at each layer that re-renders the string.
|
|
79
|
+
.replace(/`/g, "'")
|
|
80
|
+
// ANSI escapes and other control bytes, keeping \t and \n. They let
|
|
81
|
+
// text rewrite what a reader sees above it.
|
|
82
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: neutralising terminal control bytes is the point
|
|
83
|
+
.replace(/[\u0000-\u0008\u000B-\u001F\u007F]/g, " ")
|
|
84
|
+
.slice(0, maxLength)
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** `owner/repo` out of a GitHub remote URL (ssh or https), or null. */
|
|
89
|
+
function githubSlug(remoteOrPrUrl: string): string | null {
|
|
90
|
+
const m = remoteOrPrUrl.match(
|
|
91
|
+
/github\.com[:/]([^/\s]+)\/([^/\s]+?)(?:\.git)?(?:\/|$)/i,
|
|
92
|
+
);
|
|
93
|
+
return m ? `${m[1].toLowerCase()}/${m[2].toLowerCase()}` : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The only PR url shape the repair loop will act on: `https://github.com/<owner>/<repo>/pull/<n>`,
|
|
98
|
+
* with nothing after the number.
|
|
99
|
+
*
|
|
100
|
+
* The slug check alone was not enough. `extractPrUrl` captures any run of
|
|
101
|
+
* non-whitespace after the card's `PR:` line, and a query component legally
|
|
102
|
+
* carries characters a path does not — including U+0060 backtick, which WHATWG
|
|
103
|
+
* URL normalisation percent-encodes in the path and fragment but NOT in the
|
|
104
|
+
* query. `gh pr view` ignores the query when resolving the PR, so
|
|
105
|
+
* `…/pull/123?x=<backticks and text>` addressed the right PR while smuggling
|
|
106
|
+
* fence-breaking text into anything that interpolated the url. Requiring the
|
|
107
|
+
* exact shape drops query and fragment entirely rather than trying to clean them.
|
|
108
|
+
*/
|
|
109
|
+
const STRICT_PR_URL_RE =
|
|
110
|
+
/^https:\/\/github\.com\/[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+\/pull\/\d+$/;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Is this PR in the repository the daemon itself is checked out from?
|
|
114
|
+
*
|
|
115
|
+
* **The primary trust control for the repair loop.** The PR url reaching the
|
|
116
|
+
* merge monitor comes from the card's `PR:` line — ordinary board data that any
|
|
117
|
+
* workspace member can edit. Without this check, editing that line to a pull
|
|
118
|
+
* request in a repository the editor controls would point the repair at a
|
|
119
|
+
* `statusCheckRollup` they compose: check-run names are free text via the GitHub
|
|
120
|
+
* Checks API, and job logs are whatever their workflow prints. That text is read
|
|
121
|
+
* by an LLM that runs with file-editing tools inside the daemon's checkout, on a
|
|
122
|
+
* host holding `gh` credentials and the daemon's own API key.
|
|
123
|
+
*
|
|
124
|
+
* So the loop reads checks and logs only from its OWN origin, and only from a url
|
|
125
|
+
* of the exact {@link STRICT_PR_URL_RE} shape — the slug alone let a query string
|
|
126
|
+
* ride along, which is its own problem (see that constant). Checked before any
|
|
127
|
+
* remote read, and fails CLOSED: an unreadable remote, an off-shape or
|
|
128
|
+
* unparseable url, or a non-GitHub remote all answer `false`. A daemon that
|
|
129
|
+
* cannot prove the PR is its own does not read it.
|
|
130
|
+
*
|
|
131
|
+
* Deliberately narrower than `isValidPrUrl` in git-pr.ts, which only asserts the
|
|
132
|
+
* HOST is a known forge — enough to decide whether to poll a CI status, not
|
|
133
|
+
* enough to decide whose text to feed a model.
|
|
134
|
+
*/
|
|
135
|
+
export function isPrOnOrigin(prUrl: string, cwd: string): boolean {
|
|
136
|
+
if (!STRICT_PR_URL_RE.test(prUrl)) return false;
|
|
137
|
+
const prSlug = githubSlug(prUrl);
|
|
138
|
+
if (!prSlug) return false;
|
|
139
|
+
try {
|
|
140
|
+
const remote = execFileSync("git", ["remote", "get-url", "origin"], {
|
|
141
|
+
cwd,
|
|
142
|
+
encoding: "utf-8",
|
|
143
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
144
|
+
}).trim();
|
|
145
|
+
return githubSlug(remote) === prSlug;
|
|
146
|
+
} catch {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** One failing entry of a PR's `statusCheckRollup`. */
|
|
152
|
+
export interface FailedCheck {
|
|
153
|
+
/** The check's display name, e.g. "Type Check" or "deploy-supabase". */
|
|
154
|
+
name: string;
|
|
155
|
+
/** The upper-cased conclusion, e.g. "FAILURE", "TIMED_OUT", "CANCELLED". */
|
|
156
|
+
conclusion: string;
|
|
157
|
+
/** The check's link, e.g. `https://github.com/o/r/actions/runs/123/job/456`. */
|
|
158
|
+
detailsUrl: string | null;
|
|
159
|
+
/** The workflow run id parsed out of {@link detailsUrl}, when it has one. */
|
|
160
|
+
workflowRunId: string | null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const RUN_ID_RE = /\/actions\/runs\/(\d+)/;
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The workflow run id inside a check's `detailsUrl`, or null.
|
|
167
|
+
*
|
|
168
|
+
* This is what makes a re-dispatch addressable: `gh run rerun` takes a RUN id,
|
|
169
|
+
* while `statusCheckRollup` hands out a JOB url. A check that is not an Actions
|
|
170
|
+
* run at all (an external status context, a third-party app) has no run id, and
|
|
171
|
+
* gets null rather than a guess.
|
|
172
|
+
*/
|
|
173
|
+
export function extractWorkflowRunId(
|
|
174
|
+
detailsUrl: string | null | undefined,
|
|
175
|
+
): string | null {
|
|
176
|
+
if (!detailsUrl) return null;
|
|
177
|
+
return detailsUrl.match(RUN_ID_RE)?.[1] ?? null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The FAILING entries of a `gh pr view --json statusCheckRollup` array, with the
|
|
182
|
+
* per-check fields kept.
|
|
183
|
+
*
|
|
184
|
+
* Pure, and deliberately the same shape-handling as `deriveCiStatus`: a CheckRun
|
|
185
|
+
* (`{status, conclusion}`) and a legacy StatusContext (`{state}`) both appear in
|
|
186
|
+
* the rollup, and a repair that only understood one of them would read half a
|
|
187
|
+
* red CI as green. A still-running check is NOT a failure and never appears
|
|
188
|
+
* here — that is `pending`, which the merge gate already waits on.
|
|
189
|
+
*/
|
|
190
|
+
export function parseFailedChecks(rollup: unknown): FailedCheck[] {
|
|
191
|
+
if (!Array.isArray(rollup)) return [];
|
|
192
|
+
const failed: FailedCheck[] = [];
|
|
193
|
+
for (const check of rollup) {
|
|
194
|
+
if (typeof check !== "object" || check === null) continue;
|
|
195
|
+
const c = check as Record<string, unknown>;
|
|
196
|
+
// Sanitised HERE, not at the point of use. A check-run name is
|
|
197
|
+
// attacker-chosen text — anyone who can create a check run via the GitHub
|
|
198
|
+
// Checks API sets it freely — and it ends up inside an LLM prompt.
|
|
199
|
+
const name = sanitizeCiText(
|
|
200
|
+
(typeof c.name === "string" && c.name) ||
|
|
201
|
+
(typeof c.context === "string" && c.context) ||
|
|
202
|
+
"unnamed check",
|
|
203
|
+
MAX_CHECK_NAME,
|
|
204
|
+
);
|
|
205
|
+
const detailsUrl =
|
|
206
|
+
(typeof c.detailsUrl === "string" && c.detailsUrl) ||
|
|
207
|
+
(typeof c.targetUrl === "string" && c.targetUrl) ||
|
|
208
|
+
null;
|
|
209
|
+
|
|
210
|
+
if (typeof c.status === "string") {
|
|
211
|
+
// CheckRun — only a COMPLETED run has a verdict to read.
|
|
212
|
+
if (c.status.toUpperCase() !== "COMPLETED") continue;
|
|
213
|
+
const conclusion =
|
|
214
|
+
typeof c.conclusion === "string" ? c.conclusion.toUpperCase() : "";
|
|
215
|
+
if (["SUCCESS", "NEUTRAL", "SKIPPED"].includes(conclusion)) continue;
|
|
216
|
+
failed.push({
|
|
217
|
+
name,
|
|
218
|
+
conclusion: conclusion || "FAILURE",
|
|
219
|
+
detailsUrl,
|
|
220
|
+
workflowRunId: extractWorkflowRunId(detailsUrl),
|
|
221
|
+
});
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (typeof c.state === "string") {
|
|
226
|
+
// Legacy StatusContext.
|
|
227
|
+
const state = c.state.toUpperCase();
|
|
228
|
+
if (state === "SUCCESS" || state === "PENDING") continue;
|
|
229
|
+
failed.push({
|
|
230
|
+
name,
|
|
231
|
+
conclusion: state,
|
|
232
|
+
detailsUrl,
|
|
233
|
+
workflowRunId: extractWorkflowRunId(detailsUrl),
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return failed;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* What kind of red this is.
|
|
242
|
+
*
|
|
243
|
+
* `infra` means nothing in the branch caused it and nothing in the branch can
|
|
244
|
+
* fix it — re-dispatch. `code` means the branch is genuinely broken and only an
|
|
245
|
+
* edit resolves it.
|
|
246
|
+
*/
|
|
247
|
+
export type CiFailureKind = "infra" | "code";
|
|
248
|
+
|
|
249
|
+
export interface CiFailureClass {
|
|
250
|
+
kind: CiFailureKind;
|
|
251
|
+
/**
|
|
252
|
+
* The signature that matched, for the log line and the board event. Null on
|
|
253
|
+
* the `code` default, where nothing matched and there is no signature to name.
|
|
254
|
+
*/
|
|
255
|
+
signal: string | null;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* The infrastructure signatures, most specific first.
|
|
260
|
+
*
|
|
261
|
+
* Every one is a failure this repo has actually taken, recorded where the
|
|
262
|
+
* evidence for it lives. They are matched against the failing job's log and the
|
|
263
|
+
* check's own name, because some of them (a job that dies in "Set up job")
|
|
264
|
+
* produce almost no log at all.
|
|
265
|
+
*
|
|
266
|
+
* Order matters, as in `error-classifier.ts`: a narrow signature must precede a
|
|
267
|
+
* broad one, or the broad one claims its matches and the log line names the
|
|
268
|
+
* wrong cause. Keep additions in the same style — a named signal, a comment
|
|
269
|
+
* saying where the case came from, and a pattern narrow enough that a genuine
|
|
270
|
+
* test failure quoting the same words cannot trip it.
|
|
271
|
+
*/
|
|
272
|
+
const INFRA_SIGNATURES: { signal: string; pattern: RegExp }[] = [
|
|
273
|
+
// deploy-supabase pulls Deno deps from esm.sh, which returns 522 under load.
|
|
274
|
+
{ signal: "esm.sh 5xx", pattern: /esm\.sh[^\n]{0,200}?\b5\d\d\b/i },
|
|
275
|
+
// setup-bun 503s from runner IPs; pinning an exact version is the workaround.
|
|
276
|
+
{
|
|
277
|
+
signal: "setup-bun unavailable",
|
|
278
|
+
pattern: /setup-bun[^\n]{0,200}?\b(?:503|502|504)\b/i,
|
|
279
|
+
},
|
|
280
|
+
// A job that fails inside "Set up job" never ran a step of ours. NOT anchored
|
|
281
|
+
// to a line start: `gh run view --log-failed` prefixes every line with
|
|
282
|
+
// `<job>\t<step>\t<timestamp> `, so an anchored pattern could only ever match
|
|
283
|
+
// the check NAME half of the haystack and would be dead against the log it was
|
|
284
|
+
// written for.
|
|
285
|
+
{
|
|
286
|
+
signal: "runner setup failed",
|
|
287
|
+
pattern: /(?:##\[error\])?Set up job[^\n]{0,120}?(?:fail|error)/i,
|
|
288
|
+
},
|
|
289
|
+
// Runner DNS / TLS / connection resets reaching a host.
|
|
290
|
+
//
|
|
291
|
+
// Deliberately NOT a bare alternation of the socket error codes. A genuine
|
|
292
|
+
// test failure prints its assertion values, and a suite that exercises network
|
|
293
|
+
// error handling — this repo's own `ci-failure.test.ts` among them — puts
|
|
294
|
+
// `ECONNRESET` or `EAI_AGAIN` straight into the failing output. Reading that
|
|
295
|
+
// as infrastructure is the DANGEROUS direction: it re-runs a branch that is
|
|
296
|
+
// really broken until the cap is gone and never looks at the diff. So each
|
|
297
|
+
// token must arrive the way the runner actually emits it — as a syscall
|
|
298
|
+
// failure naming a host, or a Node error line — rather than as a bare word
|
|
299
|
+
// anywhere in a log.
|
|
300
|
+
{
|
|
301
|
+
signal: "runner network",
|
|
302
|
+
pattern:
|
|
303
|
+
/(?:getaddrinfo\s+(?:EAI_AGAIN|ENOTFOUND)|connect\s+(?:ETIMEDOUT|ECONNREFUSED)\s+[\d.:]|(?:read|write)\s+ECONNRESET\b[^\n]{0,80}(?:https?:|\.com|\.org|\.io|\.sh|\.dev)|TLS handshake timeout)/i,
|
|
304
|
+
},
|
|
305
|
+
// GitHub's own infrastructure telling us so.
|
|
306
|
+
{
|
|
307
|
+
signal: "actions infrastructure",
|
|
308
|
+
pattern:
|
|
309
|
+
/(?:The (?:self-hosted )?runner[^\n]{0,80}lost communication|The operation was canceled[^\n]{0,80}runner|We are experiencing (?:a )?(?:degraded|high) )/i,
|
|
310
|
+
},
|
|
311
|
+
// A registry or proxy 5xx during dependency install.
|
|
312
|
+
{
|
|
313
|
+
signal: "registry 5xx",
|
|
314
|
+
pattern:
|
|
315
|
+
/(?:npm|bun|yarn|pnpm)[^\n]{0,200}?\b(?:502 Bad Gateway|503 Service Unavailable|504 Gateway Time-?out)\b/i,
|
|
316
|
+
},
|
|
317
|
+
];
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Is this red worth an edit, or only a re-dispatch?
|
|
321
|
+
*
|
|
322
|
+
* Pure. **Defaults to `code`**, and that asymmetry is deliberate: an
|
|
323
|
+
* unrecognised failure is far more often a real one, and the two mistakes do not
|
|
324
|
+
* cost the same. Reading infra as code spends one bounded repair attempt that
|
|
325
|
+
* finds nothing to change (and, finding nothing, commits nothing and pushes
|
|
326
|
+
* nothing). Reading code as infra re-dispatches a genuinely broken branch until
|
|
327
|
+
* the cap runs out and never looks at the diff. The cap bounds the first
|
|
328
|
+
* mistake; nothing bounds the damage of the second except this default.
|
|
329
|
+
*/
|
|
330
|
+
export function classifyCiFailure(input: {
|
|
331
|
+
/** The failing check's name. */
|
|
332
|
+
checkName: string;
|
|
333
|
+
/** The failing job's log, or as much of it as could be fetched. May be empty. */
|
|
334
|
+
log: string;
|
|
335
|
+
}): CiFailureClass {
|
|
336
|
+
const haystack = `${input.checkName}\n${input.log}`;
|
|
337
|
+
for (const { signal, pattern } of INFRA_SIGNATURES) {
|
|
338
|
+
if (pattern.test(haystack)) return { kind: "infra", signal };
|
|
339
|
+
}
|
|
340
|
+
return { kind: "code", signal: null };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** One sentence naming what the classifier decided, for a log line or the board. */
|
|
344
|
+
export function describeCiFailure(failure: CiFailureClass): string {
|
|
345
|
+
return failure.kind === "infra"
|
|
346
|
+
? `infrastructure failure (${failure.signal}) — re-dispatching, no code change`
|
|
347
|
+
: "a real build or test failure — repairing the branch";
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* The verdict for a red CI made of SEVERAL failing checks.
|
|
352
|
+
*
|
|
353
|
+
* `infra` only when EVERY failing check is infrastructure. One genuinely broken
|
|
354
|
+
* check among a set of flakes still needs an edit, and re-dispatching the batch
|
|
355
|
+
* would spend the repair budget re-running a break that can never pass — the
|
|
356
|
+
* "misreading code as infra" direction the module doc names as the dangerous
|
|
357
|
+
* one. This repo makes that concrete: `deploy-supabase` flakes on an esm.sh 522
|
|
358
|
+
* about a fifth of the time, so a PR can easily show that flake beside a real
|
|
359
|
+
* `Type Check` failure, and classifying from whichever the rollup happened to
|
|
360
|
+
* list first would park the card having never read the diff.
|
|
361
|
+
*
|
|
362
|
+
* An empty list is `code` — the same conservative default a single unrecognised
|
|
363
|
+
* failure gets.
|
|
364
|
+
*/
|
|
365
|
+
export function classifyCiFailures(failures: CiFailureClass[]): CiFailureClass {
|
|
366
|
+
if (failures.length === 0) return { kind: "code", signal: null };
|
|
367
|
+
const infra = failures.filter((f) => f.kind === "infra");
|
|
368
|
+
if (infra.length !== failures.length) return { kind: "code", signal: null };
|
|
369
|
+
// Name every distinct signature, so the log and the board event say which
|
|
370
|
+
// failures were re-dispatched rather than only the first one.
|
|
371
|
+
const signals = [...new Set(infra.map((f) => f.signal).filter(Boolean))];
|
|
372
|
+
return { kind: "infra", signal: signals.join(", ") || null };
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* The failing checks on a PR, with their per-check detail.
|
|
377
|
+
*
|
|
378
|
+
* Best-effort in the same posture as `getPrStatus`: any failure — a missing
|
|
379
|
+
* `gh`, an auth problem, a rate limit, unparseable JSON — returns an empty list,
|
|
380
|
+
* and the caller then does nothing rather than acting on half a picture.
|
|
381
|
+
*/
|
|
382
|
+
export async function getPrFailedChecks(
|
|
383
|
+
prUrl: string,
|
|
384
|
+
cwd: string,
|
|
385
|
+
provider: GitProvider,
|
|
386
|
+
): Promise<FailedCheck[]> {
|
|
387
|
+
if (provider !== "github") return [];
|
|
388
|
+
try {
|
|
389
|
+
const { stdout } = await execFileAsync()(
|
|
390
|
+
"gh",
|
|
391
|
+
["pr", "view", prUrl, "--json", "statusCheckRollup"],
|
|
392
|
+
{ cwd, encoding: "utf-8", timeout: 15_000 },
|
|
393
|
+
);
|
|
394
|
+
const parsed = JSON.parse(stdout.trim()) as { statusCheckRollup?: unknown };
|
|
395
|
+
return parseFailedChecks(parsed.statusCheckRollup);
|
|
396
|
+
} catch (err) {
|
|
397
|
+
log.warn(
|
|
398
|
+
TAG,
|
|
399
|
+
`could not read failing checks for ${prUrl}: ${err instanceof Error ? err.message : err}`,
|
|
400
|
+
);
|
|
401
|
+
return [];
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* The tail of a workflow run's FAILED steps — the classifier's evidence.
|
|
407
|
+
*
|
|
408
|
+
* `--log-failed` is the whole point: a full run log is megabytes of successful
|
|
409
|
+
* steps, and the signature we are looking for is in the step that died. Returns
|
|
410
|
+
* "" on any failure, which the classifier then reads as "no evidence" and
|
|
411
|
+
* resolves to `code` — the safe default.
|
|
412
|
+
*/
|
|
413
|
+
export async function getFailedRunLog(
|
|
414
|
+
runId: string,
|
|
415
|
+
cwd: string,
|
|
416
|
+
): Promise<string> {
|
|
417
|
+
try {
|
|
418
|
+
const { stdout } = await execFileAsync()(
|
|
419
|
+
"gh",
|
|
420
|
+
["run", "view", runId, "--log-failed"],
|
|
421
|
+
{ cwd, encoding: "utf-8", timeout: 60_000, maxBuffer: 32 * 1024 * 1024 },
|
|
422
|
+
);
|
|
423
|
+
// Tail first, then sanitise — the cap inside `sanitizeCiText` takes a
|
|
424
|
+
// PREFIX, and the evidence worth reading is at the end of a failing job.
|
|
425
|
+
return sanitizeCiText(stdout.slice(-LOG_TAIL_BYTES), LOG_TAIL_BYTES);
|
|
426
|
+
} catch (err) {
|
|
427
|
+
// A run whose logs expired, a job still uploading, a `gh` without scope —
|
|
428
|
+
// all of them mean "no evidence", never "not a failure".
|
|
429
|
+
log.debug(
|
|
430
|
+
TAG,
|
|
431
|
+
`could not read failed-step log for run ${runId}: ${err instanceof Error ? err.message : err}`,
|
|
432
|
+
);
|
|
433
|
+
return "";
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Re-dispatch only the failed jobs of a workflow run.
|
|
439
|
+
*
|
|
440
|
+
* `--failed` rather than a whole re-run: the successful jobs already proved
|
|
441
|
+
* themselves against this exact head, and re-running them spends runner minutes
|
|
442
|
+
* to learn nothing. Returns whether the re-dispatch was accepted, so the caller
|
|
443
|
+
* can tell "asked GitHub to try again" from "could not ask" and count only the
|
|
444
|
+
* former as an attempt.
|
|
445
|
+
*/
|
|
446
|
+
export async function rerunFailedJobs(
|
|
447
|
+
runId: string,
|
|
448
|
+
cwd: string,
|
|
449
|
+
): Promise<boolean> {
|
|
450
|
+
try {
|
|
451
|
+
await execFileAsync()("gh", ["run", "rerun", runId, "--failed"], {
|
|
452
|
+
cwd,
|
|
453
|
+
encoding: "utf-8",
|
|
454
|
+
timeout: 30_000,
|
|
455
|
+
});
|
|
456
|
+
log.info(TAG, `re-dispatched failed jobs of run ${runId}`);
|
|
457
|
+
return true;
|
|
458
|
+
} catch (err) {
|
|
459
|
+
log.warn(
|
|
460
|
+
TAG,
|
|
461
|
+
`could not re-dispatch run ${runId}: ${err instanceof Error ? err.message : err}`,
|
|
462
|
+
);
|
|
463
|
+
return false;
|
|
464
|
+
}
|
|
465
|
+
}
|
package/src/cli.ts
CHANGED
|
@@ -306,12 +306,19 @@ async function main(): Promise<void> {
|
|
|
306
306
|
// accident.
|
|
307
307
|
assertStageIsAgentRunnable(pinned.stage);
|
|
308
308
|
|
|
309
|
+
// Resolved ONCE and used twice: the author's prompt needs it to know which
|
|
310
|
+
// stage to POST its held test against, and an `oracle_red` gate needs it to
|
|
311
|
+
// read that same row back (the row is keyed to the TARGET, not to the writer).
|
|
312
|
+
// Two independent resolutions could disagree and would put the author's write
|
|
313
|
+
// and the red gate's read on different keys.
|
|
314
|
+
const oracleTargetStageId = findOracleTargetStage(version, stageId);
|
|
315
|
+
|
|
309
316
|
const prompt = buildStagePrompt({
|
|
310
317
|
cardId,
|
|
311
318
|
stageId,
|
|
312
319
|
stage: pinned.stage,
|
|
313
320
|
sessionId,
|
|
314
|
-
oracleTargetStageId
|
|
321
|
+
oracleTargetStageId,
|
|
315
322
|
});
|
|
316
323
|
|
|
317
324
|
const request: StageRunRequest = {
|
|
@@ -354,6 +361,9 @@ async function main(): Promise<void> {
|
|
|
354
361
|
oracle: {
|
|
355
362
|
repoPath: req.repoPath,
|
|
356
363
|
sessionId: req.sessionId,
|
|
364
|
+
// Only `oracle_red` reads this (its own stage is not the key); the
|
|
365
|
+
// green collector addresses the row by `context.stageId`.
|
|
366
|
+
targetStageId: oracleTargetStageId,
|
|
357
367
|
fetchOracle: (oracleCardId, oracleStageId, oracleSessionId) =>
|
|
358
368
|
client.fetchOracle(oracleCardId, oracleStageId, oracleSessionId),
|
|
359
369
|
place,
|