omp-conductor 0.19.7 → 0.20.1
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/REFERENCE.md +10 -1
- package/agents/to-spec.md +76 -9
- package/package.json +1 -1
- package/schema/config.schema.json +4 -0
- package/src/admission.ts +58 -14
- package/src/arm-challenge.ts +255 -85
- package/src/ask.ts +130 -615
- package/src/board.ts +7 -1
- package/src/brief-upgrade.ts +24 -0
- package/src/briefs/console.md +258 -0
- package/src/briefs/correction.md +203 -0
- package/src/briefs/orchestrator.md +167 -97
- package/src/briefs/policy.md +19 -16
- package/src/briefs/to-spec.md +76 -9
- package/src/briefs/worker.md +50 -16
- package/src/cli.ts +4 -0
- package/src/command-manifest.ts +54 -8
- package/src/commands/arm.ts +115 -49
- package/src/commands/console.ts +70 -0
- package/src/commands/context.ts +2 -0
- package/src/commands/epic.ts +132 -0
- package/src/commands/extend.ts +9 -1
- package/src/commands/intake.ts +44 -14
- package/src/commands/stats.ts +19 -4
- package/src/commands/worker.ts +9 -1
- package/src/config-schema.ts +13 -0
- package/src/config.ts +27 -0
- package/src/daemon/ack.ts +159 -0
- package/src/daemon/admission-pass.ts +135 -0
- package/src/daemon/brief.ts +461 -0
- package/src/daemon/deps.ts +539 -0
- package/src/daemon/dispatch.ts +1779 -0
- package/src/daemon/drain.ts +185 -0
- package/src/daemon/groom-pass.ts +422 -0
- package/src/daemon/http.ts +417 -0
- package/src/daemon/integrity.ts +108 -0
- package/src/daemon/panes.ts +180 -0
- package/src/daemon/review.ts +1888 -0
- package/src/daemon/runtime.ts +788 -0
- package/src/daemon/settle-pass.ts +606 -0
- package/src/daemon/supervision.ts +438 -0
- package/src/daemon/tick.ts +968 -0
- package/src/daemon/views.ts +751 -0
- package/src/daemon.ts +105 -7923
- package/src/dashboard/app.js +58 -0
- package/src/dashboard/controls.ts +22 -3
- package/src/dashboard/server.ts +4 -0
- package/src/diff-flags.ts +135 -9
- package/src/doctor.ts +2 -2
- package/src/failure-class.ts +257 -2
- package/src/fleet.ts +295 -176
- package/src/groom.ts +461 -0
- package/src/http-token.ts +142 -0
- package/src/knowledge.ts +229 -0
- package/src/mining.ts +316 -0
- package/src/orchestrator-tick.ts +689 -1670
- package/src/ready-gate.ts +267 -0
- package/src/settlement.ts +107 -11
- package/src/setup-host.ts +32 -9
- package/src/setup-wizard.ts +55 -7
- package/src/setup.ts +229 -3
- package/src/stats.ts +257 -2
- package/src/status-render.ts +169 -14
- package/src/store.ts +618 -28
- package/src/to-spec.ts +426 -44
- package/src/tracker/github.ts +50 -0
- package/src/types.ts +434 -18
- package/src/verbs/protocol.ts +28 -0
- package/src/verbs/server.ts +330 -39
- package/src/wake.ts +19 -2
- package/src/worker.ts +570 -1
package/src/verbs/server.ts
CHANGED
|
@@ -50,6 +50,11 @@ import { wakeDispatch } from "../wake.ts";
|
|
|
50
50
|
import { cancelDrain } from "../fleet.ts";
|
|
51
51
|
import { effectiveLane, laneEcho, writeLaneSectionHeading } from "../admission.ts";
|
|
52
52
|
import { chainEntriesFromDiff, chainViolations } from "../chain-check.ts";
|
|
53
|
+
// The runner-infrastructure waiver a red check's own log can earn (#1043 lane).
|
|
54
|
+
// The worker keeps its two-red corrective budget in session, so this reply is
|
|
55
|
+
// the only place that waiver can be stated — and it is stated from the same
|
|
56
|
+
// classifier the daemon's own infra accounting uses, never a second copy.
|
|
57
|
+
import { runnerInfraFailure } from "../failure-class.ts";
|
|
53
58
|
import {
|
|
54
59
|
repoSlugFor,
|
|
55
60
|
type LaneFile,
|
|
@@ -60,6 +65,7 @@ import {
|
|
|
60
65
|
import { releaseRefusal } from "../release-policy.ts";
|
|
61
66
|
import { PR_LOOKUP_WINDOW_MS, REVISABLE_RUN_STATES, prReviewReadiness } from "../decisions.ts";
|
|
62
67
|
import { LIVE_STATES } from "../store.ts";
|
|
68
|
+
import { readyGate } from "../ready-gate.ts";
|
|
63
69
|
import { parseToSpecEvidence } from "../to-spec.ts";
|
|
64
70
|
import { DENIED_RELEASE_GRANTS } from "../types.ts";
|
|
65
71
|
import type {
|
|
@@ -83,7 +89,7 @@ import type {
|
|
|
83
89
|
VerbName,
|
|
84
90
|
VerbRefusal,
|
|
85
91
|
} from "../types.ts";
|
|
86
|
-
import { prUrlParts, GhPrMissingError } from "../tracker/github.ts";
|
|
92
|
+
import { failedRunIds, prUrlParts, GhPrMissingError } from "../tracker/github.ts";
|
|
87
93
|
import { parseVerbRequest, roleRefusal, VERB_SPECS, type VerbReply } from "./protocol.ts";
|
|
88
94
|
import {
|
|
89
95
|
peerVerdict,
|
|
@@ -652,6 +658,8 @@ async function decide(deps: VerbDeps, channel: VerbChannel, raw: unknown): Promi
|
|
|
652
658
|
return prRecoverVerb(deps, project, channel, args, refuse, allow);
|
|
653
659
|
case "conductor_pr_status":
|
|
654
660
|
return prStatusVerb(deps, project, channel, args, refuse, allow);
|
|
661
|
+
case "conductor_ci_logs":
|
|
662
|
+
return ciLogsVerb(deps, project, channel, args, refuse, allow);
|
|
655
663
|
}
|
|
656
664
|
}
|
|
657
665
|
|
|
@@ -1344,6 +1352,17 @@ async function labelVerb(
|
|
|
1344
1352
|
return refuse("action-failed", `refused: the tracker rejected the label change:\n${why}`, ref.issue);
|
|
1345
1353
|
}
|
|
1346
1354
|
|
|
1355
|
+
// Promotion provenance (#1041): the tick audits what reached the queue
|
|
1356
|
+
// unattended, and "who queued this" is only answerable if every promotion
|
|
1357
|
+
// path stamps it — a verdict promoted by hand that carried no actor would
|
|
1358
|
+
// read in the audit exactly like one the daemon promoted. Stamped after the
|
|
1359
|
+
// mutation committed, so a refused or failed label change leaves no
|
|
1360
|
+
// provenance, and latched inside the store: a re-add of a label the issue
|
|
1361
|
+
// already carries returns false and never restamps.
|
|
1362
|
+
if (action === "add" && label === project.queueLabel) {
|
|
1363
|
+
deps.store.markGroomingPromoted(project.name, ref.issue, Date.now(), "orchestrator");
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1347
1366
|
// A promotion that committed has made an issue claimable *now*, so the
|
|
1348
1367
|
// resident daemon is poked rather than leaving the candidate for the next
|
|
1349
1368
|
// scheduled pass (#878): three claimable issues once sat against `workers
|
|
@@ -1379,52 +1398,63 @@ async function laneForEcho(deps: VerbDeps, issue: number): Promise<{
|
|
|
1379
1398
|
readable: boolean;
|
|
1380
1399
|
lane: FileLane | undefined;
|
|
1381
1400
|
malformed: string | undefined;
|
|
1401
|
+
/** What the gate re-reads: the same body, thread and labels this parse used.
|
|
1402
|
+
* Absent exactly when `readable` is false. */
|
|
1403
|
+
read: { body: string; comments: IssueComment[]; labels: readonly string[] } | undefined;
|
|
1382
1404
|
}> {
|
|
1383
1405
|
let body: string;
|
|
1384
1406
|
let comments: IssueComment[];
|
|
1407
|
+
let labels: readonly string[];
|
|
1385
1408
|
try {
|
|
1386
1409
|
const row = await deps.tracker.getIssue(issue);
|
|
1387
|
-
if (row === undefined) return { readable: false, lane: undefined, malformed: undefined };
|
|
1410
|
+
if (row === undefined) return { readable: false, lane: undefined, malformed: undefined, read: undefined };
|
|
1388
1411
|
body = row.body;
|
|
1412
|
+
labels = row.labels;
|
|
1389
1413
|
comments = await deps.tracker.listComments(issue);
|
|
1390
1414
|
} catch {
|
|
1391
|
-
return { readable: false, lane: undefined, malformed: undefined };
|
|
1415
|
+
return { readable: false, lane: undefined, malformed: undefined, read: undefined };
|
|
1392
1416
|
}
|
|
1417
|
+
const read = { body, comments, labels };
|
|
1393
1418
|
const lane = effectiveLane(body, comments);
|
|
1394
|
-
if (lane !== undefined) return { readable: true, lane, malformed: undefined };
|
|
1419
|
+
if (lane !== undefined) return { readable: true, lane, malformed: undefined, read };
|
|
1395
1420
|
// No declaration parsed anywhere. If a clearly delimited write-lane section
|
|
1396
1421
|
// exists anyway, the section tried to declare and failed — the echo must
|
|
1397
1422
|
// refuse, never claim fail-open.
|
|
1398
1423
|
const heading =
|
|
1399
1424
|
writeLaneSectionHeading(body) ??
|
|
1400
1425
|
comments.map((c) => writeLaneSectionHeading(c.body)).find((h) => h !== undefined);
|
|
1401
|
-
return { readable: true, lane: undefined, malformed: heading };
|
|
1426
|
+
return { readable: true, lane: undefined, malformed: heading, read };
|
|
1402
1427
|
}
|
|
1403
1428
|
|
|
1404
1429
|
/**
|
|
1405
|
-
* The promotion
|
|
1406
|
-
* an admission contract only while the
|
|
1430
|
+
* The promotion gate (#1036, #1041): a durable `promotable` grooming verdict is
|
|
1431
|
+
* an admission contract only while the issue still matches it, so a
|
|
1407
1432
|
* queue-label add must prove the match before any tracker mutation or daemon
|
|
1408
|
-
* wake.
|
|
1409
|
-
*
|
|
1410
|
-
*
|
|
1411
|
-
*
|
|
1412
|
-
*
|
|
1413
|
-
*
|
|
1433
|
+
* wake. Returns the refusal detail, or `undefined` when promotion may proceed:
|
|
1434
|
+
* no grooming row, a non-`promotable` row, or a verdict that passes the gate.
|
|
1435
|
+
*
|
|
1436
|
+
* The judgement itself is {@link readyGate}, not this function. It used to be
|
|
1437
|
+
* a lane comparison written here, which was correct and also the wrong place:
|
|
1438
|
+
* since #1041 the daemon promotes passing verdicts unattended, and two copies
|
|
1439
|
+
* of "is this ready" drift the moment one of them learns something. So this is
|
|
1440
|
+
* the verb-shaped wrapper — recover the result, hand the gate the same body,
|
|
1441
|
+
* thread and labels the echo parsed, and render whatever it refuses for. The
|
|
1442
|
+
* gate names every missing element at once: a promotion that fails four checks
|
|
1443
|
+
* costs one round trip, not four.
|
|
1414
1444
|
*
|
|
1415
1445
|
* Fail-closed by design: evidence behind a `promotable` row that does not
|
|
1416
1446
|
* recover as a strict PROMOTABLE result (`parseToSpecEvidence`, the same
|
|
1417
1447
|
* restart round-trip the selection side trusts), or an issue that cannot be
|
|
1418
1448
|
* read to compare at all, refuses the promotion rather than dispatching on an
|
|
1419
|
-
* unverifiable brief. The gate never edits the issue — it names
|
|
1420
|
-
*
|
|
1449
|
+
* unverifiable brief. The gate never edits the issue — it names what is
|
|
1450
|
+
* missing and points at the verdict's `proposedBrief`; correcting the dispatch
|
|
1421
1451
|
* brief is an explicit orchestrator act.
|
|
1422
1452
|
*/
|
|
1423
1453
|
function promotionBriefRefusal(
|
|
1424
1454
|
deps: VerbDeps,
|
|
1425
1455
|
project: ProjectConfig,
|
|
1426
1456
|
issue: number,
|
|
1427
|
-
read: { readable: boolean;
|
|
1457
|
+
read: { readable: boolean; read: { body: string; comments: IssueComment[]; labels: readonly string[] } | undefined },
|
|
1428
1458
|
): string | undefined {
|
|
1429
1459
|
const row = deps.store.grooming(project.name, issue);
|
|
1430
1460
|
if (row?.verdict !== "promotable") return undefined;
|
|
@@ -1436,30 +1466,22 @@ function promotionBriefRefusal(
|
|
|
1436
1466
|
"brief still matches what was groomed. Re-groom the issue so a fresh verdict replaces the row, then add the label again."
|
|
1437
1467
|
);
|
|
1438
1468
|
}
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
[...new Set(paths.map((path) => path.trim()).filter((path) => path.length > 0))].sort();
|
|
1450
|
-
const brief = normalizedPaths(read.lane?.files ?? []);
|
|
1451
|
-
const verdict = normalizedPaths(result.fileLane);
|
|
1452
|
-
if (
|
|
1453
|
-
brief.length === verdict.length &&
|
|
1454
|
-
brief.every((path, index) => path === verdict[index])
|
|
1455
|
-
) {
|
|
1456
|
-
return undefined;
|
|
1457
|
-
}
|
|
1469
|
+
// `"unread"` rather than an empty thread when the read failed: behind a
|
|
1470
|
+
// promotable row "could not look" is a refusal, not an empty lane (#1036).
|
|
1471
|
+
const verdict = readyGate({
|
|
1472
|
+
result,
|
|
1473
|
+
issueBody: read.read?.body ?? "",
|
|
1474
|
+
comments: read.readable && read.read !== undefined ? read.read.comments : "unread",
|
|
1475
|
+
labels: read.read?.labels ?? [],
|
|
1476
|
+
project,
|
|
1477
|
+
});
|
|
1478
|
+
if (verdict.ok) return undefined;
|
|
1458
1479
|
return (
|
|
1459
|
-
`refused: ${project.queueLabel} was not added — #${issue}
|
|
1460
|
-
`
|
|
1461
|
-
"contract: apply the verdict's proposedBrief to the issue (its
|
|
1462
|
-
"label again. The gate never
|
|
1480
|
+
`refused: ${project.queueLabel} was not added — #${issue} carries a durable PROMOTABLE grooming verdict, but ` +
|
|
1481
|
+
`the promotion is not ready:\n${verdict.missing.map((miss) => `- ${miss}`).join("\n")}\n` +
|
|
1482
|
+
"The durable verdict is the admission contract: apply the verdict's proposedBrief to the issue (its " +
|
|
1483
|
+
"## Exact write lane included) and fix every element above, then add the label again. The gate never " +
|
|
1484
|
+
"rewrites the issue itself."
|
|
1463
1485
|
);
|
|
1464
1486
|
}
|
|
1465
1487
|
|
|
@@ -1901,6 +1923,275 @@ async function prStatusVerb(
|
|
|
1901
1923
|
return allow(`${prUrl} at ${verification.headSha}: ${verification.status} — ${verification.reason}`, undefined, issue);
|
|
1902
1924
|
}
|
|
1903
1925
|
|
|
1926
|
+
/**
|
|
1927
|
+
* How much failing-job log text one `conductor_ci_logs` reply may carry.
|
|
1928
|
+
*
|
|
1929
|
+
* Characters rather than bytes: `gh run view --log-failed` output is
|
|
1930
|
+
* effectively ASCII, so this is the ~32 KB posture the daemon's own log reads
|
|
1931
|
+
* already work at, and a character bound cannot end mid-multibyte-sequence the
|
|
1932
|
+
* way a byte slice can. It bounds the log text; the few framing lines are
|
|
1933
|
+
* small and constant.
|
|
1934
|
+
*/
|
|
1935
|
+
export const CI_LOGS_MAX_CHARS = 32 * 1024;
|
|
1936
|
+
|
|
1937
|
+
/** The smallest slice of one failed job worth showing. A PR whose matrix
|
|
1938
|
+
* exploded into forty failed jobs must not answer with forty useless
|
|
1939
|
+
* fragments — better to show the first few in full and say how many were left
|
|
1940
|
+
* out. */
|
|
1941
|
+
const CI_LOGS_MIN_JOB_CHARS = 2 * 1024;
|
|
1942
|
+
|
|
1943
|
+
/**
|
|
1944
|
+
* How many of the head's failing workflow runs are read, and how many attempts
|
|
1945
|
+
* of each.
|
|
1946
|
+
*
|
|
1947
|
+
* Both exist because every attempt of every failing job is a separate guarded
|
|
1948
|
+
* `gh` call, and one pathologically re-run pull request must not turn a worker's
|
|
1949
|
+
* single diagnostic call into a hundred of them. The attempt bound is the
|
|
1950
|
+
* tracker's own contract (`runFailedAttemptLogs` refuses rather than presenting
|
|
1951
|
+
* attempts 1..N as the whole history), so exceeding it surfaces as the honest
|
|
1952
|
+
* read failure below, never as a partial log the worker would read as complete.
|
|
1953
|
+
*/
|
|
1954
|
+
const CI_LOGS_MAX_RUNS = 5;
|
|
1955
|
+
const CI_LOGS_MAX_ATTEMPTS = 10;
|
|
1956
|
+
|
|
1957
|
+
/**
|
|
1958
|
+
* One bounded rendering of the failing jobs' logs.
|
|
1959
|
+
*
|
|
1960
|
+
* Pure and exported so the bound is tested directly rather than inferred from a
|
|
1961
|
+
* verb reply. Empty chunks are dropped: `runFailedAttemptLogs` deliberately
|
|
1962
|
+
* emits one per failed job even when its failed steps printed nothing, which
|
|
1963
|
+
* the daemon's all-infra guard needs and a reader does not.
|
|
1964
|
+
*
|
|
1965
|
+
* Each shown job gets a fair share of the budget, so one enormous test job
|
|
1966
|
+
* cannot starve the sibling that actually explains the failure. An over-share
|
|
1967
|
+
* job keeps its head *and* its tail — the head carries the step banner and the
|
|
1968
|
+
* runner's own setup sentences, the tail carries the failure — with the elision
|
|
1969
|
+
* stated in the middle rather than left to be guessed at.
|
|
1970
|
+
*
|
|
1971
|
+
* No redaction is applied, deliberately and consistently with every other place
|
|
1972
|
+
* these same chunks are read (escalations, the historical infra repair): the
|
|
1973
|
+
* text is GitHub's own log, which Actions has already masked its registered
|
|
1974
|
+
* secrets out of. A second, weaker masking here would only suggest a guarantee
|
|
1975
|
+
* this does not make.
|
|
1976
|
+
*/
|
|
1977
|
+
export function boundCiLogs(chunks: readonly string[], maxChars = CI_LOGS_MAX_CHARS): string {
|
|
1978
|
+
const failing = chunks.filter((chunk) => chunk.trim() !== "");
|
|
1979
|
+
if (failing.length === 0) return "";
|
|
1980
|
+
const share = Math.max(CI_LOGS_MIN_JOB_CHARS, Math.floor(maxChars / failing.length));
|
|
1981
|
+
const room = Math.max(1, Math.floor(maxChars / share));
|
|
1982
|
+
const shown = failing.slice(0, room);
|
|
1983
|
+
const parts = shown.map((chunk, i) => {
|
|
1984
|
+
// Head a quarter, tail the rest: the head carries the step banner and the
|
|
1985
|
+
// runner's own setup sentences, the tail carries the failure.
|
|
1986
|
+
const head = Math.floor(share / 4);
|
|
1987
|
+
const body =
|
|
1988
|
+
chunk.length <= share
|
|
1989
|
+
? chunk
|
|
1990
|
+
: `${chunk.slice(0, head)}\n--- ${chunk.length - share} characters elided ---\n` +
|
|
1991
|
+
chunk.slice(chunk.length - (share - head));
|
|
1992
|
+
return `--- failing job ${i + 1} of ${failing.length} ---\n${body}`;
|
|
1993
|
+
});
|
|
1994
|
+
if (shown.length < failing.length) {
|
|
1995
|
+
parts.push(
|
|
1996
|
+
`--- ${failing.length - shown.length} further failing job(s) not shown: this reply is bounded to ` +
|
|
1997
|
+
`${maxChars} characters of log. Re-run this call after fixing the ones above, or read the rest in the ` +
|
|
1998
|
+
"Actions UI. ---",
|
|
1999
|
+
);
|
|
2000
|
+
}
|
|
2001
|
+
return parts.join("\n");
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
/**
|
|
2005
|
+
* The second read verb: what CI actually printed when it went red (#1043 lane).
|
|
2006
|
+
*
|
|
2007
|
+
* A worker that has to fix a red check otherwise has three bad options — guess
|
|
2008
|
+
* from the check name, re-run the whole suite locally hoping to reproduce a
|
|
2009
|
+
* runner-only failure, or report blocked. This hands it the failing jobs' own
|
|
2010
|
+
* failed steps through the tracker read the daemon already uses for its infra
|
|
2011
|
+
* classification, so there is exactly one transport for "what did CI say" and
|
|
2012
|
+
* the worker reads the same evidence the classifier does.
|
|
2013
|
+
*
|
|
2014
|
+
* Same resolution as {@link prStatusVerb}: a worker reads its own run's PR and
|
|
2015
|
+
* no other; an orchestrator names one. `headSha` is an assertion, never a
|
|
2016
|
+
* selector — logs from a superseded commit are worse than no logs, because they
|
|
2017
|
+
* describe a failure the current head may not even have.
|
|
2018
|
+
*
|
|
2019
|
+
* An unreadable log is a refusal, never an empty success. "No failing jobs" and
|
|
2020
|
+
* "the log could not be read" invite opposite next moves, and a verb that
|
|
2021
|
+
* spelled them the same way would teach a worker to declare a red check fixed
|
|
2022
|
+
* because nothing came back.
|
|
2023
|
+
*/
|
|
2024
|
+
async function ciLogsVerb(
|
|
2025
|
+
deps: VerbDeps,
|
|
2026
|
+
project: ProjectConfig,
|
|
2027
|
+
channel: VerbChannel,
|
|
2028
|
+
args: Record<string, unknown>,
|
|
2029
|
+
refuse: Refuse,
|
|
2030
|
+
allow: Allow,
|
|
2031
|
+
): Promise<Verdict> {
|
|
2032
|
+
const asked = args["prUrl"];
|
|
2033
|
+
let prUrl: string;
|
|
2034
|
+
let issue: number | undefined;
|
|
2035
|
+
|
|
2036
|
+
if (channel.kind === "run") {
|
|
2037
|
+
const own = deps.store.getRun(channel.runId);
|
|
2038
|
+
if (own?.prUrl === undefined) {
|
|
2039
|
+
return refuse("pr-missing", "refused: this run has no pull request yet, so it has no CI to read.");
|
|
2040
|
+
}
|
|
2041
|
+
if (typeof asked === "string" && asked !== own.prUrl) {
|
|
2042
|
+
return refuse(
|
|
2043
|
+
"pr-not-this-run",
|
|
2044
|
+
`refused: this socket belongs to the run for #${channel.issue}, whose pull request is ${own.prUrl}. ` +
|
|
2045
|
+
"A worker reads only its own run's CI — the daemon resolves the run from the socket.",
|
|
2046
|
+
);
|
|
2047
|
+
}
|
|
2048
|
+
prUrl = own.prUrl;
|
|
2049
|
+
issue = channel.issue;
|
|
2050
|
+
} else {
|
|
2051
|
+
if (typeof asked !== "string") {
|
|
2052
|
+
return refuse("malformed-argument", "refused: conductor_ci_logs needs prUrl when it has no run to infer one from.");
|
|
2053
|
+
}
|
|
2054
|
+
prUrl = asked;
|
|
2055
|
+
issue = runForPr(deps, project.name, asked)?.issue;
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
const parts = prUrlParts(prUrl);
|
|
2059
|
+
if (parts === undefined) {
|
|
2060
|
+
return refuse("malformed-argument", `refused: ${prUrl} is not a pull request URL.`, issue);
|
|
2061
|
+
}
|
|
2062
|
+
const repo = `${parts.owner}/${parts.repo}`;
|
|
2063
|
+
|
|
2064
|
+
const askedHead = args["headSha"];
|
|
2065
|
+
const expectedHead = typeof askedHead === "string" ? askedHead : undefined;
|
|
2066
|
+
let verification: PrVerification | undefined;
|
|
2067
|
+
try {
|
|
2068
|
+
// Read mode, exactly as `conductor_pr_status`: a merged or closed PR is a
|
|
2069
|
+
// fact worth reading logs against, not the merge gate's `expected OPEN`
|
|
2070
|
+
// refusal.
|
|
2071
|
+
verification = await deps.tracker.verifyPr(prUrl, expectedHead, { read: true });
|
|
2072
|
+
} catch (err) {
|
|
2073
|
+
verification = undefined;
|
|
2074
|
+
deps.log(`verb ci_logs could not read ${prUrl}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2075
|
+
}
|
|
2076
|
+
if (verification === undefined) {
|
|
2077
|
+
return refuse(
|
|
2078
|
+
"head-unresolvable",
|
|
2079
|
+
`refused: ${prUrl} could not be read, so there is no head to pin these logs to. Wait and ask again, ` +
|
|
2080
|
+
"and report blocked rather than guessing if it stays unreadable.",
|
|
2081
|
+
issue,
|
|
2082
|
+
);
|
|
2083
|
+
}
|
|
2084
|
+
if (isHeadMismatch(verification.reason)) {
|
|
2085
|
+
return refuse(
|
|
2086
|
+
"head-stale",
|
|
2087
|
+
`refused: ${verification.reason}. These logs would describe a commit that is no longer the head — ` +
|
|
2088
|
+
"read the current head with conductor_pr_status and ask again for that one.",
|
|
2089
|
+
issue,
|
|
2090
|
+
);
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
const runIds = failedRunIds(await deps.tracker.checkConclusions(prUrl));
|
|
2094
|
+
if (runIds.length === 0) {
|
|
2095
|
+
// Honest and distinguishable from an unreadable log: the check rollup was
|
|
2096
|
+
// read and named no non-green run. `verification.status` says which kind of
|
|
2097
|
+
// "nothing to diagnose" this is, because a pending head is worth asking
|
|
2098
|
+
// again about and a green one is not.
|
|
2099
|
+
return allow(
|
|
2100
|
+
`${prUrl} at ${verification.headSha}: no failing workflow run to read logs from — ${verification.reason}. ` +
|
|
2101
|
+
(verification.status === "pending"
|
|
2102
|
+
? "Checks are still running; poll conductor_pr_status and ask again if one goes red."
|
|
2103
|
+
: "Nothing here needs diagnosing."),
|
|
2104
|
+
undefined,
|
|
2105
|
+
issue,
|
|
2106
|
+
);
|
|
2107
|
+
}
|
|
2108
|
+
|
|
2109
|
+
const chunks: string[] = [];
|
|
2110
|
+
for (const runId of runIds.slice(0, CI_LOGS_MAX_RUNS)) {
|
|
2111
|
+
let logs: string[] | undefined;
|
|
2112
|
+
try {
|
|
2113
|
+
logs = await deps.tracker.runFailedAttemptLogs(
|
|
2114
|
+
repo,
|
|
2115
|
+
`https://github.com/${repo}/actions/runs/${runId}`,
|
|
2116
|
+
CI_LOGS_MAX_ATTEMPTS,
|
|
2117
|
+
);
|
|
2118
|
+
} catch (err) {
|
|
2119
|
+
logs = undefined;
|
|
2120
|
+
deps.log(`verb ci_logs could not read run ${runId} of ${repo}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2121
|
+
}
|
|
2122
|
+
if (logs === undefined) {
|
|
2123
|
+
// Fail loudly. The tracker answers `undefined` for a read it could not
|
|
2124
|
+
// make *or* for an attempt history past the bound; either way the chunks
|
|
2125
|
+
// gathered so far are not the evidence set, and handing them over as if
|
|
2126
|
+
// they were is how a worker concludes the wrong thing about a red check.
|
|
2127
|
+
return refuse(
|
|
2128
|
+
"action-failed",
|
|
2129
|
+
`refused: the failing-job logs of ${repo} run ${runId} could not be read (an unreadable job log, a ` +
|
|
2130
|
+
`revoked token, or more than ${CI_LOGS_MAX_ATTEMPTS} re-run attempts to gather). That is not "no ` +
|
|
2131
|
+
`failures": ${prUrl} is ${verification.status} at ${verification.headSha}. Read the run in the Actions ` +
|
|
2132
|
+
"UI, or ask again — this call mutates nothing.",
|
|
2133
|
+
issue,
|
|
2134
|
+
);
|
|
2135
|
+
}
|
|
2136
|
+
chunks.push(...logs);
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
const rendered = boundCiLogs(chunks);
|
|
2140
|
+
const omitted =
|
|
2141
|
+
runIds.length > CI_LOGS_MAX_RUNS
|
|
2142
|
+
? ` (${CI_LOGS_MAX_RUNS} of ${runIds.length} non-green workflow runs read)`
|
|
2143
|
+
: "";
|
|
2144
|
+
if (rendered === "") {
|
|
2145
|
+
// Every failed job's failed steps printed nothing readable. Determinate —
|
|
2146
|
+
// the reads succeeded — and still not a diagnosis, so it says which run to
|
|
2147
|
+
// look at rather than pretending the PR is fine.
|
|
2148
|
+
return allow(
|
|
2149
|
+
`${prUrl} at ${verification.headSha} is ${verification.status}, but its failing jobs printed no log ` +
|
|
2150
|
+
`output${omitted}. Read the run itself: https://github.com/${repo}/actions/runs/${runIds[0]}`,
|
|
2151
|
+
undefined,
|
|
2152
|
+
issue,
|
|
2153
|
+
);
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
// The two-red corrective budget is a rule the *worker* keeps, in session,
|
|
2157
|
+
// from its brief — nothing in the daemon counts a run's reds, so nothing in
|
|
2158
|
+
// the daemon can waive one on the worker's behalf. This reply is therefore
|
|
2159
|
+
// the only surface where "that red was the runner, not your diff" can become
|
|
2160
|
+
// true, and the brief's promise of an infra exemption is kept here or nowhere.
|
|
2161
|
+
//
|
|
2162
|
+
// Classified from the chunks already fetched: no second read, no store write.
|
|
2163
|
+
// The verdict line goes *above* the log, because a worker that has just been
|
|
2164
|
+
// handed 32k of log must not have to reach the end to learn the red was free.
|
|
2165
|
+
const classified = chunks.filter((chunk) => chunk.trim() !== "").map((chunk) => runnerInfraFailure(chunk));
|
|
2166
|
+
const wordings = new Set(classified.filter((match): match is string => match !== undefined));
|
|
2167
|
+
let note = "";
|
|
2168
|
+
if (wordings.size > 0 && classified.every((match) => match !== undefined)) {
|
|
2169
|
+
note =
|
|
2170
|
+
`RUNNER INFRASTRUCTURE: every failing job's own log names the runner, not your diff — ` +
|
|
2171
|
+
`${[...wordings].map((w) => `"${w}"`).join(", ")}. This red does not spend one of your two corrective ` +
|
|
2172
|
+
"pushes. Do not change the diff for it and do not push a fix: wait, poll conductor_pr_status again, and " +
|
|
2173
|
+
"if it is still red stop and report with that wording quoted — the dispatcher re-runs infrastructure " +
|
|
2174
|
+
"checks itself and does not charge the attempt.\n\n";
|
|
2175
|
+
} else if (wordings.size > 0) {
|
|
2176
|
+
// Mixed evidence: the same rule the daemon's own classifier applies — one
|
|
2177
|
+
// real failure outranks any number of infra ones. Said out loud rather than
|
|
2178
|
+
// left silent, because a worker reading a 429 in job 2 would otherwise
|
|
2179
|
+
// claim a waiver the classifier never granted.
|
|
2180
|
+
note =
|
|
2181
|
+
`MIXED: ${wordings.size} of the failing jobs name runner infrastructure ` +
|
|
2182
|
+
`(${[...wordings].map((w) => `"${w}"`).join(", ")}), but at least one failed for a reason that is not ` +
|
|
2183
|
+
"infrastructure. A real failure outranks any number of infra ones, so this red counts: fix the " +
|
|
2184
|
+
"non-infrastructure failure below.\n\n";
|
|
2185
|
+
}
|
|
2186
|
+
// Nothing matched: silence. An unclassifiable red is charged, and a reply that
|
|
2187
|
+
// hedged here would teach a worker to argue for a waiver from any red log.
|
|
2188
|
+
return allow(
|
|
2189
|
+
`${prUrl} at ${verification.headSha}: ${verification.status} — ${verification.reason}${omitted}\n\n${note}${rendered}`,
|
|
2190
|
+
undefined,
|
|
2191
|
+
issue,
|
|
2192
|
+
);
|
|
2193
|
+
}
|
|
2194
|
+
|
|
1904
2195
|
/**
|
|
1905
2196
|
* Update one pull request's title and/or body — nothing else.
|
|
1906
2197
|
*
|
package/src/wake.ts
CHANGED
|
@@ -20,9 +20,13 @@
|
|
|
20
20
|
*
|
|
21
21
|
* Every failure is honest and non-fatal: the caller's own mutation already
|
|
22
22
|
* committed, so a missing or unreachable daemon degrades to "the next scheduled
|
|
23
|
-
* pass will claim" rather than an error that suggests the mutation failed.
|
|
23
|
+
* pass will claim" rather than an error that suggests the mutation failed. That
|
|
24
|
+
* now includes an unreadable bearer token: `POST /wake` is authenticated (Phase
|
|
25
|
+
* 4), and a wake nobody can authenticate is one more reason the pass waits —
|
|
26
|
+
* never an exception thrown through a mutation that already landed.
|
|
24
27
|
*/
|
|
25
28
|
|
|
29
|
+
import { httpAuthHeader, httpTokenPath } from "./http-token.ts";
|
|
26
30
|
import { livingDaemon } from "./lifecycle.ts";
|
|
27
31
|
|
|
28
32
|
/** What the wake attempt did, as the line a human reads after the mutation. */
|
|
@@ -31,11 +35,24 @@ export async function wakeDispatch(projectName: string): Promise<string> {
|
|
|
31
35
|
if (daemon === undefined) {
|
|
32
36
|
return "daemon not running — claiming starts when the daemon next starts or ticks";
|
|
33
37
|
}
|
|
38
|
+
// The daemon mints this at start, and `livingDaemon()` just said one is
|
|
39
|
+
// running — so an absent token is a real anomaly (a different
|
|
40
|
+
// `$OMP_CONDUCTOR_HOME`, or a hand-deleted file), not the ordinary "no daemon
|
|
41
|
+
// has ever run here" that `missingHttpTokenMessage()` describes. Say what is
|
|
42
|
+
// missing and where, and degrade exactly like an unreachable daemon: the
|
|
43
|
+
// mutation stands, the pass is merely not brought forward.
|
|
44
|
+
const auth = httpAuthHeader();
|
|
45
|
+
if (auth === undefined) {
|
|
46
|
+
return (
|
|
47
|
+
`daemon wake failed — no readable HTTP token at ${httpTokenPath()}; ` +
|
|
48
|
+
"the next scheduled pass will claim"
|
|
49
|
+
);
|
|
50
|
+
}
|
|
34
51
|
let response: Response;
|
|
35
52
|
try {
|
|
36
53
|
response = await fetch(`http://127.0.0.1:${daemon.port}/wake`, {
|
|
37
54
|
method: "POST",
|
|
38
|
-
headers: { "content-type": "application/json" },
|
|
55
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
39
56
|
body: JSON.stringify({ project: projectName }),
|
|
40
57
|
});
|
|
41
58
|
} catch {
|