@codyswann/lisa 4.26.0 → 4.26.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 (62) hide show
  1. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  2. package/dist/core/lisa-owned-hash-ledger.js +12 -0
  3. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  4. package/dist/core/nightly-e2e-guard-behavior-certificate.d.ts +4 -4
  5. package/dist/core/nightly-e2e-guard-behavior-certificate.js +7 -7
  6. package/dist/core/nightly-e2e-guard-behavior-certificate.js.map +1 -1
  7. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  8. package/dist/core/upstream-evidence-manifest.js +4 -2
  9. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  10. package/package.json +3 -3
  11. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  12. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  13. package/plugins/lisa-agy/plugin.json +1 -1
  14. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  15. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  16. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  17. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  18. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  19. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  23. package/plugins/lisa-expo-agy/plugin.json +1 -1
  24. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  26. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  27. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  28. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  29. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  30. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  33. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  34. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  35. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  38. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  39. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  40. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  43. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  44. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  48. package/plugins/lisa-rails-agy/plugin.json +1 -1
  49. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  53. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  54. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  58. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  59. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  61. package/scripts/generate-nightly-e2e-guard-certificate.mjs +5 -0
  62. package/typescript/copy-overwrite/scripts/check-nightly-e2e-health.mjs +338 -39
@@ -96,6 +96,42 @@
96
96
  * label, because manufacturing a bypass surface in a repository that never
97
97
  * adopted §6 is a new hole rather than a closed one.
98
98
  *
99
+ * ## The newest run is not the newest CONCLUSIVE run (contract 1.8.0, rows 41-42)
100
+ *
101
+ * Measured on 2026-08-29 in a consuming repository: two runs of the same native
102
+ * suite, seven seconds apart. The first concluded `success` with all eight jobs
103
+ * green — the whole suite executed. The second concluded `cancelled` with one
104
+ * cancelled preflight job and nothing else, because the suite's `concurrency`
105
+ * group keeps only ONE pending run and displaces the rest. The second run tested
106
+ * nothing, but it was the newest completed run, so the gate scored it and
107
+ * reported `⚪ UNKNOWN [cancelled]` over a suite that had genuinely passed. The
108
+ * gate is a required check, so that blocked every merge for three hours.
109
+ *
110
+ * Refusing to score a cancellation green stays exactly as it was (rows 6-8).
111
+ * The defect is one layer upstream: SELECTION. `observe` now walks candidates
112
+ * newest-first and stops at the first that produced evidence.
113
+ *
114
+ * "Produced evidence" is decided from the run's JOBS, not from a status
115
+ * allowlist, because `cancelled` is overloaded across at least three causes —
116
+ * a duplicate displaced by the concurrency group (tested nothing), a job killed
117
+ * at its own `timeout-minutes` ceiling (tested most of the suite), and an
118
+ * operator cancel (anywhere in between) — and only the first may ever be walked
119
+ * past. See `runProducedEvidence`.
120
+ *
121
+ * Bounded twice over: the walk stops at the first run with evidence, and it
122
+ * never leaves the arm's own freshness window. Without the second bound a stale
123
+ * green could hold the gate open indefinitely, which is the opposite failure and
124
+ * a worse one. When nothing conclusive is found inside the window it falls back
125
+ * to the newest run, so `stale_run` / `indecisive_conclusion` / `no_run` still
126
+ * fire and the gate still blocks. That fallback is what makes this change unable
127
+ * to invent a pass: it may only ever promote an older FRESH, CONCLUSIVE run over
128
+ * a run that tested nothing.
129
+ *
130
+ * And it SAYS SO. The original failure was silent — a real verdict was replaced
131
+ * with no trace — so every finding carries a `selection` record naming the run
132
+ * that was scored and every run walked past with the reason. A gate that quietly
133
+ * changes which run it scores is the same class of defect one layer down.
134
+ *
99
135
  * ## Inherited from three implementations, with one path closed
100
136
  *
101
137
  * `DECISIVE_CONCLUSIONS` comes from acmeorgb's `check-nightly-e2e.mjs` and is
@@ -118,7 +154,7 @@ import { invokedAsScript } from "./lib/invoked-as-script.mjs";
118
154
  * rather than running a contract neither half agrees on. See §8 of
119
155
  * `docs/nightly-e2e-gate.md` for what counts as major / minor / patch.
120
156
  */
121
- export const NIGHTLY_E2E_CONTRACT_VERSION = "1.7.0";
157
+ export const NIGHTLY_E2E_CONTRACT_VERSION = "1.8.0";
122
158
 
123
159
  /**
124
160
  * The conclusions that constitute a verdict about the code.
@@ -152,6 +188,19 @@ export const GREEN_CONCLUSION = "success";
152
188
  */
153
189
  export const COUNTED_EVENTS = Object.freeze(["schedule", "workflow_dispatch"]);
154
190
 
191
+ /**
192
+ * How many completed runs per event the selection walk (rows 41-42) may read.
193
+ *
194
+ * Bounded on purpose. The walk exists to step over runs that tested nothing, and
195
+ * every candidate it examines costs a job read on the merge-gate path; an
196
+ * unbounded walk would turn a long chain of displaced duplicates into an
197
+ * unbounded number of requests. Ten is ample — the measured displacement chain
198
+ * that motivated this was two runs, seven seconds apart — and in the ordinary
199
+ * case where the newest run is conclusive the walk still reads exactly one run's
200
+ * jobs, the same as before. The freshness window bounds it a second time.
201
+ */
202
+ export const RUN_CANDIDATE_PAGE_SIZE = 10;
203
+
155
204
  /** Suite states. `unknown` is "no readable verdict", which is not a pass. */
156
205
  export const SUITE_STATES = Object.freeze({
157
206
  pass: "pass",
@@ -814,6 +863,67 @@ export function stateForConclusion(conclusion) {
814
863
  return SUITE_STATES.unknown;
815
864
  }
816
865
 
866
+ /**
867
+ * Whether a run produced EVIDENCE about the code — rows 41-42's discriminator.
868
+ *
869
+ * This decides only whether a run is worth SCORING, never what its verdict is.
870
+ * A run with evidence goes to `assessSuite` and is judged there exactly as
871
+ * before; a run without evidence is stepped over so the next-older candidate can
872
+ * be judged instead.
873
+ *
874
+ * Evidence is read from the run's JOBS rather than from a status allowlist,
875
+ * because `cancelled` is overloaded across at least three distinct causes — a
876
+ * duplicate displaced by the suite's `concurrency` group (tested nothing), a job
877
+ * killed at its own `timeout-minutes` ceiling (tested most of the suite), and an
878
+ * operator cancel (anywhere in between). A status-only rule cannot tell them
879
+ * apart, and only the first may ever be walked past.
880
+ *
881
+ * Four cases, and three of them answer "scoreable":
882
+ *
883
+ * 1. A DECISIVE run conclusion is evidence whatever it says — including
884
+ * `failure`. Nothing here may skip over a red run to find an older green.
885
+ * 2. An indecisive conclusion whose jobs contain at least one decisive outcome
886
+ * is PARTIAL evidence. Scoreable, and it will block, because a suite killed
887
+ * part-way through reached no verdict. Deliberately not skipped: walking past
888
+ * it to an older green would RELAX the gate, which is the failure mode a fix
889
+ * here must not introduce.
890
+ * 3. An UNREAD job list — empty, or a list that would not load, whether it
891
+ * failed on the first page or part-way through — is scoreable. Fail-closed:
892
+ * a completed run always has at least one job, so an empty list is an unread
893
+ * one, and "we could not check" is never grounds to skip a run. A PARTIAL
894
+ * read belongs here rather than in case 4 for exactly the same reason: 100
895
+ * indecisive jobs and an unreadable page 2 do not show that the run tested
896
+ * nothing, and skipping it would hide a failure on the page that would not
897
+ * load behind an older green.
898
+ * 4. An indecisive conclusion whose job list was read IN FULL and holds no
899
+ * decisive outcome tested nothing. This is the only case that is skipped.
900
+ *
901
+ * @param {object} run - An Actions run
902
+ * @param {ReadonlyArray<object>|null|undefined} jobs - That run's jobs, as read
903
+ * @param {boolean} [complete] - Whether that job list was read to its end; a partial read is never grounds to skip
904
+ * @returns {boolean} True when the run is worth scoring
905
+ */
906
+ export function runProducedEvidence(run, jobs, complete = true) {
907
+ if (DECISIVE_CONCLUSIONS.has(run?.conclusion)) return true;
908
+ if (!Array.isArray(jobs) || jobs.length === 0) return true;
909
+ if (complete !== true) return true;
910
+ return jobs.some(job => DECISIVE_CONCLUSIONS.has(job?.conclusion));
911
+ }
912
+
913
+ /**
914
+ * Counts how many of a run's jobs reached a verdict.
915
+ *
916
+ * Carried onto the `selection` record so the report can say "0 of 1 job(s)
917
+ * reached a verdict" rather than the unfalsifiable "it tested nothing".
918
+ *
919
+ * @param {ReadonlyArray<object>|null|undefined} jobs - A run's jobs
920
+ * @returns {number} How many carry a decisive conclusion
921
+ */
922
+ export function countDecisiveJobs(jobs) {
923
+ if (!Array.isArray(jobs)) return 0;
924
+ return jobs.filter(job => DECISIVE_CONCLUSIONS.has(job?.conclusion)).length;
925
+ }
926
+
817
927
  /**
818
928
  * Reads the scope markers a run published, from its artifact NAMES.
819
929
  *
@@ -969,7 +1079,16 @@ export function assessSuiteScope(suite, scope) {
969
1079
  * @returns {{label: string, state: string, reason: string, conclusion: string|null, url: string|null, createdAt: string|null, event: string|null}} The finding
970
1080
  */
971
1081
  export function assessSuite(suite, observation, context) {
972
- const base = { label: suite.label, workflow: suite.workflow };
1082
+ // Rows 41-42. Attached to EVERY return path, because the defect being fixed
1083
+ // was a gate silently changing which run it scored — a selection nobody can
1084
+ // see is the same class of defect one layer down. Attached only when the
1085
+ // observation carried one, so a caller that never went through `observe`
1086
+ // produces byte-identical findings.
1087
+ const base = {
1088
+ label: suite.label,
1089
+ workflow: suite.workflow,
1090
+ ...(observation.selection ? { selection: observation.selection } : {}),
1091
+ };
973
1092
  const blank = { conclusion: null, url: null, createdAt: null, event: null };
974
1093
  // An observation with no `scope` at all is the same fact as an artifacts list
975
1094
  // that would not load — "nobody asked" and "the answer did not come back" are
@@ -993,6 +1112,27 @@ export function assessSuite(suite, observation, context) {
993
1112
  * @returns {object} The finding
994
1113
  */
995
1114
  const green = (seen, reason) => {
1115
+ // Row 26's rule, applied to the half of it a job list cannot show you.
1116
+ // Every green below is argued from the jobs that were READ, so a list the
1117
+ // walk could not finish reading cannot support one: a `success` run whose
1118
+ // page 2 would not load looks, from page 1, exactly like a `success` run
1119
+ // with nothing behind it — and the failing shard hides on the page that
1120
+ // 404'd. The empty-list case already landed on `incomplete_run` for this
1121
+ // reason; a PARTIAL read is the same "we could not check", and it must not
1122
+ // render as "it is fine" either.
1123
+ //
1124
+ // Placed inside `green` on purpose, so it can only ever turn a PASS into
1125
+ // `unknown`. A decisive run or job conclusion returns before reaching here,
1126
+ // which keeps `failure` conclusive and unswallowable — an unreadable page
1127
+ // is never allowed to soften a red into an inconclusive.
1128
+ if (observation.jobsComplete === false) {
1129
+ return {
1130
+ ...base,
1131
+ ...seen,
1132
+ state: SUITE_STATES.unknown,
1133
+ reason: INCOMPLETE_EVIDENCE_REASON,
1134
+ };
1135
+ }
996
1136
  const disqualifier = assessSuiteScope(suite, scope);
997
1137
  if (disqualifier) {
998
1138
  return {
@@ -1544,14 +1684,15 @@ const REASON_TEXT = Object.freeze({
1544
1684
  "the workflow file this gate watches does not exist any more. Someone renamed or deleted the suite out from under the gate — fix the `suites` table or restore the workflow.",
1545
1685
  no_run: "no completed run on this branch at all.",
1546
1686
  stale_run: "no completed run inside the freshness window.",
1547
- wrong_branch: "the newest run is on a different branch.",
1548
- stale_sha: "the newest run is for a different commit than the one required.",
1687
+ wrong_branch: "the run this gate scored is on a different branch.",
1688
+ stale_sha:
1689
+ "the run this gate scored is for a different commit than the one required.",
1549
1690
  indecisive_conclusion:
1550
- "the newest run reached no verdict about the code (cancelled / skipped / neutral). That is not a green — cancelling a run must never be a one-click way to clear a merge gate.",
1691
+ "the run this gate scored reached no verdict about the code (cancelled / skipped / neutral). That is not a green — cancelling a run must never be a one-click way to clear a merge gate. The `↳` line beneath names the run that was scored and every run walked past for having tested nothing, so a displaced duplicate is no longer mistaken for last night's verdict.",
1551
1692
  job_not_found:
1552
1693
  "the run completed without ever producing the job this gate reads. The job was renamed, which silently disarms the gate.",
1553
1694
  pattern_matched_nothing:
1554
- "the job pattern matched zero jobs in the newest run. Zero matches is the signature of a renamed job.",
1695
+ "the job pattern matched zero jobs in the run this gate scored. Zero matches is the signature of a renamed job.",
1555
1696
  [INCOMPLETE_EVIDENCE_REASON]:
1556
1697
  "the run reported `success`, but it did not run everything: at least one job was skipped, failed under `continue-on-error`, or could not be read. A run that skipped part of itself did not gather the evidence its green claims — a suite re-run for one platform only is not a verdict about the other one. Re-run the suite WITHOUT narrowing it.",
1557
1698
  [FILTERED_RUN_REASON]:
@@ -1610,6 +1751,53 @@ export function formatFinding(finding) {
1610
1751
  return `${marker} ${finding.label} — ${verdictWord}${conclusion}${when} — ${detail}${scopeDetail}${link}${grace}`;
1611
1752
  }
1612
1753
 
1754
+ /**
1755
+ * Renders which run a finding was scored on, and every run walked past.
1756
+ *
1757
+ * Rows 41-42, and it prints on EVERY finding that has a selection — greens
1758
+ * included. The defect this closes was silent: a run that tested nothing
1759
+ * replaced the verdict of a run that tested everything, with no trace, and three
1760
+ * hours went into debugging a suite that was fine. A gate that quietly changes
1761
+ * which run it scores is the same defect one layer down, so the selection is not
1762
+ * conditional on anything having gone wrong.
1763
+ *
1764
+ * A scored run that reached NO verdict also names its cause, from the job
1765
+ * counts the walk already read. `cancelled` is overloaded across a displaced
1766
+ * duplicate, a job killed at its own `timeout-minutes` ceiling, and an operator
1767
+ * cancel; "0 of 1 job(s) reached a verdict" and "7 of 8" are different enough
1768
+ * that a reader can tell "it never started" from "it ran out of time" without
1769
+ * opening the run. That costs nothing — the counts come from jobs already
1770
+ * fetched, and no workflow job configuration is read to produce them.
1771
+ *
1772
+ * A DECISIVE conclusion gets no such suffix. `success` and `failure` already say
1773
+ * what happened, and appending job arithmetic to every green is noise.
1774
+ *
1775
+ * @param {object|null|undefined} selection - The finding's selection record
1776
+ * @returns {string|null} One trailing line, or null when there is nothing to say
1777
+ */
1778
+ export function formatSelection(selection) {
1779
+ if (!selection) return null;
1780
+ const conclusion = selection.conclusion ?? null;
1781
+ const counted =
1782
+ !DECISIVE_CONCLUSIONS.has(conclusion) &&
1783
+ typeof selection.decisiveJobs === "number" &&
1784
+ typeof selection.totalJobs === "number";
1785
+ const cause = counted
1786
+ ? ` — ${selection.decisiveJobs} of ${selection.totalJobs} job(s) reached a verdict, so it ${selection.decisiveJobs === 0 ? "tested nothing" : "ran but did not finish"}`
1787
+ : "";
1788
+ const scored = `↳ scored run ${selection.runId ?? "?"} (${conclusion ?? "no conclusion"}${cause}, ${selection.createdAt ?? "unknown time"})`;
1789
+ const fallback = selection.fellBack
1790
+ ? " (no conclusive run in the freshness window — fell back to the newest)"
1791
+ : "";
1792
+ const skipped = (selection.skipped ?? [])
1793
+ .map(
1794
+ entry =>
1795
+ `${entry.runId ?? "?"} [${entry.conclusion ?? "no conclusion"} — ${entry.decisiveJobs} of ${entry.totalJobs} job(s) reached a verdict, so it tested nothing]`
1796
+ )
1797
+ .join(", ");
1798
+ return `${scored}${fallback}${skipped ? `; skipped ${skipped}` : ""}`;
1799
+ }
1800
+
1613
1801
  /**
1614
1802
  * Renders the full report.
1615
1803
  *
@@ -1619,7 +1807,13 @@ export function formatFinding(finding) {
1619
1807
  */
1620
1808
  export function formatReport(verdict, context) {
1621
1809
  const lines = ["## 🌙 Nightly E2E Health", ""];
1622
- lines.push(...verdict.findings.map(finding => `- ${formatFinding(finding)}`));
1810
+ lines.push(
1811
+ ...verdict.findings.flatMap(finding => {
1812
+ const line = `- ${formatFinding(finding)}`;
1813
+ const selection = formatSelection(finding.selection);
1814
+ return selection === null ? [line] : [line, ` - ${selection}`];
1815
+ })
1816
+ );
1623
1817
  lines.push("");
1624
1818
 
1625
1819
  // A limit the caller asked for and did not get must be visible, or the gate
@@ -2084,7 +2278,11 @@ function issueBody(finding, context) {
2084
2278
  `| Workflow | \`${finding.workflow ?? "—"}\` |`,
2085
2279
  `| Branch | \`${context.branch}\` |`,
2086
2280
  `| Blocks merges | ${blockingCell} |`,
2087
- `| Newest run | ${finding.conclusion ?? "—"}${finding.createdAt ? ` at ${finding.createdAt}` : ""}${finding.event ? ` via \`${finding.event}\`` : ""} |`,
2281
+ // "Scored", not "Newest": since the selection walk landed, the run this
2282
+ // row describes is the newest CONCLUSIVE one, which may be older than the
2283
+ // newest candidate. Labelling it "Newest" would reintroduce the exact
2284
+ // confusion the walk exists to remove.
2285
+ `| Scored run | ${finding.conclusion ?? "—"}${finding.createdAt ? ` at ${finding.createdAt}` : ""}${finding.event ? ` via \`${finding.event}\`` : ""} |`,
2088
2286
  `| Why it is not green | ${detail || finding.reason} |`,
2089
2287
  `| Last checked | ${context.now.toISOString()} |`,
2090
2288
  "",
@@ -2449,29 +2647,37 @@ export async function apiGet(api, path, wait = sleep) {
2449
2647
  }
2450
2648
 
2451
2649
  /**
2452
- * Newest completed run of one workflow on one branch for one event.
2650
+ * The newest completed runs of one workflow on one branch for one event.
2651
+ *
2652
+ * Reads a bounded PAGE rather than a single run, which is rows 41-42's whole
2653
+ * mechanism. `per_page: "1"` made "the newest run" and "the newest run that
2654
+ * tested anything" the same query; they are not, and a displaced duplicate that
2655
+ * executed nothing is routinely the newer of the two.
2453
2656
  *
2454
2657
  * @param {object} api - API coordinates
2455
2658
  * @param {string} file - Workflow file name
2456
2659
  * @param {string} branch - Branch to read
2457
2660
  * @param {string} event - Run event
2458
2661
  * @param {(ms: number) => Promise<void>} [wait] - Injectable sleep
2459
- * @returns {Promise<{run: object|null, missing: boolean}>} The newest run, or a 404 marker
2662
+ * @returns {Promise<{runs: ReadonlyArray<object>, missing: boolean}>} The candidates, or a 404 marker
2460
2663
  */
2461
- export async function fetchNewestRun(api, file, branch, event, wait) {
2664
+ export async function fetchRunCandidates(api, file, branch, event, wait) {
2462
2665
  const query = new URLSearchParams({
2463
2666
  branch,
2464
2667
  status: "completed",
2465
2668
  event,
2466
- per_page: "1",
2669
+ per_page: String(RUN_CANDIDATE_PAGE_SIZE),
2467
2670
  });
2468
2671
  const result = await apiGet(
2469
2672
  api,
2470
2673
  `/repos/${api.repo}/actions/workflows/${encodeURIComponent(file)}/runs?${query}`,
2471
2674
  wait
2472
2675
  );
2473
- if (result === null) return { run: null, missing: true };
2474
- return { run: result.body.workflow_runs?.[0] ?? null, missing: false };
2676
+ if (result === null) return { runs: Object.freeze([]), missing: true };
2677
+ return {
2678
+ runs: Object.freeze(result.body.workflow_runs ?? []),
2679
+ missing: false,
2680
+ };
2475
2681
  }
2476
2682
 
2477
2683
  /**
@@ -2482,10 +2688,18 @@ export async function fetchNewestRun(api, file, branch, event, wait) {
2482
2688
  * Hitting the page cap while still unread is raised rather than silently
2483
2689
  * truncated.
2484
2690
  *
2691
+ * `complete` says whether the walk reached the end of the list, and it is the
2692
+ * half a caller cannot reconstruct from the jobs alone. A list cut short by a
2693
+ * mid-walk 404 is indistinguishable, by inspection, from a short list that was
2694
+ * read in full — and the two must not be treated alike: 100 indecisive jobs
2695
+ * followed by an unreadable page 2 is *not* proof that the run tested nothing,
2696
+ * because the failing shard may be on the page that would not load. Callers
2697
+ * that decide anything from the ABSENCE of a job outcome must consult it.
2698
+ *
2485
2699
  * @param {object} api - API coordinates
2486
2700
  * @param {number|string} runId - The run
2487
2701
  * @param {(ms: number) => Promise<void>} [wait] - Injectable sleep
2488
- * @returns {Promise<ReadonlyArray<object>>} All jobs
2702
+ * @returns {Promise<{jobs: ReadonlyArray<object>, complete: boolean}>} All jobs read, and whether the list was read to its end
2489
2703
  */
2490
2704
  export async function fetchAllJobs(api, runId, wait) {
2491
2705
  const jobs = [];
@@ -2496,13 +2710,15 @@ export async function fetchAllJobs(api, runId, wait) {
2496
2710
  wait
2497
2711
  );
2498
2712
  // A 404 means the run's job list stopped being readable mid-walk. Return
2499
- // what was read; falling through to the page-cap throw below would blame a
2500
- // pagination limit that had nothing to do with it AND discard every job
2501
- // already collected.
2502
- if (result === null) return Object.freeze(jobs);
2713
+ // what was read, flagged INCOMPLETE; falling through to the page-cap throw
2714
+ // below would blame a pagination limit that had nothing to do with it AND
2715
+ // discard every job already collected.
2716
+ if (result === null)
2717
+ return Object.freeze({ jobs: Object.freeze(jobs), complete: false });
2503
2718
  const batch = result.body.jobs ?? [];
2504
2719
  jobs.push(...batch);
2505
- if (batch.length < 100) return Object.freeze(jobs);
2720
+ if (batch.length < 100)
2721
+ return Object.freeze({ jobs: Object.freeze(jobs), complete: true });
2506
2722
  }
2507
2723
  throw new GateApiError(
2508
2724
  `Run ${runId} reports more jobs than \`api_max_pages\` (${api.maxPages}) allows this gate to read. A truncated job list can hide the failing shard, so this is RED rather than a partial read.`
@@ -2759,58 +2975,139 @@ export async function fetchRunArtifacts(api, runId, wait) {
2759
2975
  return null;
2760
2976
  }
2761
2977
 
2978
+ /**
2979
+ * Selects the run one suite will be scored on, and records what it walked past.
2980
+ *
2981
+ * Rows 41-42. Candidates arrive newest-first; the walk stops at the first that
2982
+ * produced evidence (`runProducedEvidence`) and never leaves the arm's own
2983
+ * freshness window. Both bounds are load-bearing. Without the first the gate
2984
+ * would keep reading runs it has no reason to; without the second a stale green
2985
+ * could hold the gate open indefinitely, which is the opposite failure and a
2986
+ * worse one.
2987
+ *
2988
+ * When nothing conclusive is found inside the window it falls back to the NEWEST
2989
+ * candidate — the run the gate scored before this walk existed — so `stale_run`,
2990
+ * `indecisive_conclusion` and the rest still fire and the gate still blocks.
2991
+ * That fallback is what makes selection unable to invent a pass: it may only
2992
+ * ever promote an older fresh, conclusive run over a run that tested nothing.
2993
+ *
2994
+ * @param {object} api - API coordinates
2995
+ * @param {ReadonlyArray<object>} candidates - Completed runs, newest first
2996
+ * @param {number} freshnessHours - This arm's freshness window
2997
+ * @param {Date} now - Evaluation instant
2998
+ * @param {(ms: number) => Promise<void>} [wait] - Injectable sleep
2999
+ * @returns {Promise<{run: object, jobs: ReadonlyArray<object>, jobsComplete: boolean, selection: object}>} The scored run, its jobs, whether that job list was read to its end, and the audit
3000
+ */
3001
+ async function selectScoredRun(api, candidates, freshnessHours, now, wait) {
3002
+ const jobsById = new Map();
3003
+ const walkedPast = [];
3004
+ let chosen = null;
3005
+ for (const candidate of candidates) {
3006
+ if (!isFresh(candidate, freshnessHours, now)) break;
3007
+ const read = await fetchAllJobs(api, candidate.id, wait);
3008
+ const { jobs, complete } = read;
3009
+ jobsById.set(candidate.id, read);
3010
+ if (runProducedEvidence(candidate, jobs, complete)) {
3011
+ chosen = candidate;
3012
+ break;
3013
+ }
3014
+ walkedPast.push({
3015
+ runId: candidate.id ?? null,
3016
+ conclusion: candidate.conclusion ?? null,
3017
+ createdAt: candidate.created_at ?? null,
3018
+ decisiveJobs: countDecisiveJobs(jobs),
3019
+ totalJobs: jobs.length,
3020
+ });
3021
+ }
3022
+ const run = chosen ?? candidates[0];
3023
+ const read = jobsById.get(run.id) ?? (await fetchAllJobs(api, run.id, wait));
3024
+ const { jobs, complete: jobsComplete } = read;
3025
+ // A run cannot be both scored and skipped. When the walk falls back onto a
3026
+ // candidate it had already stepped over — the single-candidate case — the
3027
+ // honest report is "this was scored because nothing better existed", not a
3028
+ // line contradicting itself.
3029
+ const skipped = walkedPast.filter(entry => entry.runId !== run.id);
3030
+ return {
3031
+ run,
3032
+ jobs,
3033
+ jobsComplete,
3034
+ selection: Object.freeze({
3035
+ runId: run.id ?? null,
3036
+ conclusion: run.conclusion ?? null,
3037
+ createdAt: run.created_at ?? null,
3038
+ // Carried for the SCORED run too, not only the skipped ones, so an
3039
+ // inconclusive verdict can name its cause. See `formatSelection`.
3040
+ decisiveJobs: countDecisiveJobs(jobs),
3041
+ totalJobs: jobs.length,
3042
+ fellBack: chosen === null && walkedPast.length > 0,
3043
+ skipped: Object.freeze(skipped.map(entry => Object.freeze(entry))),
3044
+ }),
3045
+ };
3046
+ }
3047
+
2762
3048
  /**
2763
3049
  * Observes every suite.
2764
3050
  *
2765
3051
  * @param {object} api - API coordinates
2766
3052
  * @param {ReadonlyArray<object>} suites - Validated suites
2767
3053
  * @param {string} branch - Branch to read
3054
+ * @param {{freshnessHours: number, now: Date}} context - Evaluation context, bounding the selection walk
2768
3055
  * @param {(ms: number) => Promise<void>} [wait] - Injectable sleep
2769
3056
  * @returns {Promise<ReadonlyArray<object>>} One observation per suite
2770
3057
  */
2771
- export async function observe(api, suites, branch, wait) {
3058
+ export async function observe(api, suites, branch, context, wait) {
2772
3059
  return await Promise.all(
2773
3060
  suites.map(async suite => {
2774
3061
  const perEvent = await Promise.all(
2775
3062
  COUNTED_EVENTS.map(event =>
2776
- fetchNewestRun(api, suite.workflow, branch, event, wait)
3063
+ fetchRunCandidates(api, suite.workflow, branch, event, wait)
2777
3064
  )
2778
3065
  );
2779
3066
  if (perEvent.every(result => result.missing)) {
2780
3067
  return { workflowMissing: true, run: null, jobs: [] };
2781
3068
  }
2782
- // Newest by created_at, so a fresh dispatch supersedes an older failed
2783
- // schedule. ISO-8601 UTC compares correctly as strings.
2784
- const run = perEvent
2785
- .map(result => result.run)
3069
+ // Newest FIRST by created_at, so a fresh dispatch supersedes an older
3070
+ // failed schedule. ISO-8601 UTC compares correctly as strings.
3071
+ const candidates = perEvent
3072
+ .flatMap(result => result.runs)
2786
3073
  .filter(
2787
3074
  candidate => candidate && typeof candidate.created_at === "string"
2788
3075
  )
2789
- .reduce(
2790
- (newest, candidate) =>
2791
- newest === null || candidate.created_at > newest.created_at
2792
- ? candidate
2793
- : newest,
2794
- null
3076
+ // Equal timestamps compare 0 rather than falling through to -1, which
3077
+ // would make the sort unstable and let two runs created in the same
3078
+ // second swap places between reads of the same history.
3079
+ .sort((left, right) =>
3080
+ left.created_at === right.created_at
3081
+ ? 0
3082
+ : left.created_at < right.created_at
3083
+ ? 1
3084
+ : -1
2795
3085
  );
2796
3086
  // Jobs are read for EVERY match mode, `run` included. A run-scoped suite
2797
3087
  // needs them to prove the run was complete (row 26) — a `success` run
2798
3088
  // that skipped half its jobs is not evidence about the half it skipped.
2799
- if (!run) {
2800
- return { workflowMissing: false, run, jobs: [] };
3089
+ if (candidates.length === 0) {
3090
+ return { workflowMissing: false, run: null, jobs: [] };
2801
3091
  }
3092
+ const { run, jobs, jobsComplete, selection } = await selectScoredRun(
3093
+ api,
3094
+ candidates,
3095
+ suite.freshness_hours ?? context.freshnessHours,
3096
+ context.now,
3097
+ wait
3098
+ );
2802
3099
  // Artifacts are read for every suite, not only the ones declaring
2803
3100
  // `min_flows`: a run that recorded ITSELF as filtered disqualifies with no
2804
3101
  // declaration at all (row 36), and a gate that only looked when asked to
2805
- // would miss exactly the repos that have not adopted the field yet.
2806
- const [jobs, artifacts] = await Promise.all([
2807
- fetchAllJobs(api, run.id, wait),
2808
- fetchRunArtifacts(api, run.id, wait),
2809
- ]);
3102
+ // would miss exactly the repos that have not adopted the field yet. Read
3103
+ // for the SCORED run, which is the run every other row also judges.
3104
+ const artifacts = await fetchRunArtifacts(api, run.id, wait);
2810
3105
  return {
2811
3106
  workflowMissing: false,
2812
3107
  run,
2813
3108
  jobs,
3109
+ jobsComplete,
3110
+ selection,
2814
3111
  scope: readSuiteScope(artifacts),
2815
3112
  };
2816
3113
  })
@@ -3426,6 +3723,7 @@ export async function runGate(env, wait) {
3426
3723
  settings.api,
3427
3724
  settings.suites,
3428
3725
  settings.branch,
3726
+ { freshnessHours: settings.freshnessHours, now },
3429
3727
  wait
3430
3728
  );
3431
3729
  const findings = settings.suites.map((suite, index) => {
@@ -3520,6 +3818,7 @@ export async function runReport(env, wait) {
3520
3818
  settings.api,
3521
3819
  settings.suites,
3522
3820
  settings.branch,
3821
+ { freshnessHours: settings.freshnessHours, now },
3523
3822
  wait
3524
3823
  );
3525
3824
  const findings = settings.suites.map((suite, index) => {