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.
Files changed (71) hide show
  1. package/REFERENCE.md +10 -1
  2. package/agents/to-spec.md +76 -9
  3. package/package.json +1 -1
  4. package/schema/config.schema.json +4 -0
  5. package/src/admission.ts +58 -14
  6. package/src/arm-challenge.ts +255 -85
  7. package/src/ask.ts +130 -615
  8. package/src/board.ts +7 -1
  9. package/src/brief-upgrade.ts +24 -0
  10. package/src/briefs/console.md +258 -0
  11. package/src/briefs/correction.md +203 -0
  12. package/src/briefs/orchestrator.md +167 -97
  13. package/src/briefs/policy.md +19 -16
  14. package/src/briefs/to-spec.md +76 -9
  15. package/src/briefs/worker.md +50 -16
  16. package/src/cli.ts +4 -0
  17. package/src/command-manifest.ts +54 -8
  18. package/src/commands/arm.ts +115 -49
  19. package/src/commands/console.ts +70 -0
  20. package/src/commands/context.ts +2 -0
  21. package/src/commands/epic.ts +132 -0
  22. package/src/commands/extend.ts +9 -1
  23. package/src/commands/intake.ts +44 -14
  24. package/src/commands/stats.ts +19 -4
  25. package/src/commands/worker.ts +9 -1
  26. package/src/config-schema.ts +13 -0
  27. package/src/config.ts +27 -0
  28. package/src/daemon/ack.ts +159 -0
  29. package/src/daemon/admission-pass.ts +135 -0
  30. package/src/daemon/brief.ts +461 -0
  31. package/src/daemon/deps.ts +539 -0
  32. package/src/daemon/dispatch.ts +1779 -0
  33. package/src/daemon/drain.ts +185 -0
  34. package/src/daemon/groom-pass.ts +422 -0
  35. package/src/daemon/http.ts +417 -0
  36. package/src/daemon/integrity.ts +108 -0
  37. package/src/daemon/panes.ts +180 -0
  38. package/src/daemon/review.ts +1888 -0
  39. package/src/daemon/runtime.ts +788 -0
  40. package/src/daemon/settle-pass.ts +606 -0
  41. package/src/daemon/supervision.ts +438 -0
  42. package/src/daemon/tick.ts +968 -0
  43. package/src/daemon/views.ts +751 -0
  44. package/src/daemon.ts +105 -7923
  45. package/src/dashboard/app.js +58 -0
  46. package/src/dashboard/controls.ts +22 -3
  47. package/src/dashboard/server.ts +4 -0
  48. package/src/diff-flags.ts +135 -9
  49. package/src/doctor.ts +2 -2
  50. package/src/failure-class.ts +257 -2
  51. package/src/fleet.ts +295 -176
  52. package/src/groom.ts +461 -0
  53. package/src/http-token.ts +142 -0
  54. package/src/knowledge.ts +229 -0
  55. package/src/mining.ts +316 -0
  56. package/src/orchestrator-tick.ts +689 -1670
  57. package/src/ready-gate.ts +267 -0
  58. package/src/settlement.ts +107 -11
  59. package/src/setup-host.ts +32 -9
  60. package/src/setup-wizard.ts +55 -7
  61. package/src/setup.ts +229 -3
  62. package/src/stats.ts +257 -2
  63. package/src/status-render.ts +169 -14
  64. package/src/store.ts +618 -28
  65. package/src/to-spec.ts +426 -44
  66. package/src/tracker/github.ts +50 -0
  67. package/src/types.ts +434 -18
  68. package/src/verbs/protocol.ts +28 -0
  69. package/src/verbs/server.ts +330 -39
  70. package/src/wake.ts +19 -2
  71. package/src/worker.ts +570 -1
@@ -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-brief gate (#1036): a durable `promotable` grooming verdict is
1406
- * an admission contract only while the dispatch brief still matches it, so a
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. Compares the lane admission would enforce (the same parse the echo
1409
- * reports) against the verdict's `fileLane` as deduplicated,
1410
- * order-independent path sets, exact in both directions — missing and extra
1411
- * paths refuse alike. Returns the refusal detail, or `undefined` when
1412
- * promotion may proceed: no grooming row, a non-`promotable` row, or a
1413
- * verified match.
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 both path
1420
- * sets and points at the verdict's `proposedBrief`; correcting the dispatch
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; lane: FileLane | undefined },
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
- if (!read.readable) {
1440
- return (
1441
- `refused: ${project.queueLabel} was not added — #${issue} carries a durable PROMOTABLE grooming verdict, but ` +
1442
- "the issue could not be read, so the current brief cannot be compared with the verdict's file lane. " +
1443
- "Retry when the tracker responds."
1444
- );
1445
- }
1446
- // One closure so both sides of the comparison normalize in lockstep: a set
1447
- // comparison whose halves dedupe differently lies silently (#1036).
1448
- const normalizedPaths = (paths: readonly string[]): string[] =>
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}'s current write lane [${brief.join(", ")}] disagrees ` +
1460
- `with its durable PROMOTABLE verdict's file lane [${verdict.join(", ")}]. The durable verdict is the admission ` +
1461
- "contract: apply the verdict's proposedBrief to the issue (its ## Exact write lane included), then add the " +
1462
- "label again. The gate never rewrites the issue itself."
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 {