omp-conductor 0.20.0 → 0.20.2

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/src/to-spec.ts CHANGED
@@ -12,8 +12,9 @@
12
12
  * The five contract verdicts map onto it — PROMOTABLE → `promotable`;
13
13
  * ALREADY DONE and NEEDS DECOMPOSITION → `considered`; BLOCKED and NEEDS
14
14
  * PRODUCT DECISION → `blocked` — and a result that cannot be trusted
15
- * (malformed JSON, a schema violation, no authoritative source, or a stale
16
- * source) persists as `blocked` and never as `promotable`/`considered`.
15
+ * (no answer at all, malformed JSON, a schema violation, no authoritative
16
+ * source, or a stale source) persists as `blocked` and never as
17
+ * `promotable`/`considered`.
17
18
  * Reprocessing the same candidate is deterministic: the same input at the
18
19
  * same observation time produces the same row, and malformed output never
19
20
  * erases a prior valid (promotable/considered) result. Stale or source-less
@@ -98,6 +99,44 @@ export const TO_SPEC_MAX_SOURCE_AGE_MS = 24 * 60 * 60 * 1000;
98
99
  /** `routing: "MULTI"` requires `routingSplit` naming one repo per slice. */
99
100
  const MULTI_ROUTING = "MULTI" as const;
100
101
 
102
+ /**
103
+ * One write-lane entry (#1060): a plausible repository-relative path.
104
+ *
105
+ * A lane entry that is prose — veltro#731's
106
+ * `"docker-compose.dev.yml (only if …)"` parsed as one non-empty string —
107
+ * used to harden into the durable verdict and brick its issue: the ready
108
+ * gate then refuses every promotion whose issue-side lane disagrees, a
109
+ * gate-rejected candidate is deliberately never re-groomed, and the
110
+ * refusal's own remedy (edit the issue to match) would have written the
111
+ * parenthetical into the issue. The grammar is therefore enforced here, at
112
+ * parse time, where a refusal is `malformed`, the retry cooldown can act on
113
+ * it, and nothing unusable becomes an admission contract. Deliberately
114
+ * narrow so no valid lane is newly rejected: dots, hyphens, nested
115
+ * directories, globs and trailing slashes all pass; only a leading `/`
116
+ * (absolute) or whitespace/parentheses inside the entry (prose) is refused.
117
+ */
118
+ const LANE_ENTRY_PROSE = /[\s()]/;
119
+
120
+ /** Every problem with one write lane's entries, each naming the offending
121
+ * entry verbatim (`field[index] "…"`), in array order — the detail a repair
122
+ * round hands back and a refusal records must be readable against the next
123
+ * pass. Empty for a usable lane. */
124
+ function laneEntryProblems(field: string, entries: readonly string[]): string[] {
125
+ const problems: string[] = [];
126
+ for (const [index, entry] of entries.entries()) {
127
+ if (entry.startsWith("/")) {
128
+ problems.push(
129
+ `${field}[${index}] ${JSON.stringify(entry)} is absolute — a write lane names repository-relative paths`,
130
+ );
131
+ } else if (LANE_ENTRY_PROSE.test(entry)) {
132
+ problems.push(
133
+ `${field}[${index}] ${JSON.stringify(entry)} is not a path — write-lane entries carry no whitespace or parentheses`,
134
+ );
135
+ }
136
+ }
137
+ return problems;
138
+ }
139
+
101
140
  const ToSpecSourceSchema = z
102
141
  .object({
103
142
  name: z.string().trim().min(1).describe("The authoritative source that was read: repo or tracker, e.g. `TerrifiedBug/conductor`."),
@@ -145,6 +184,14 @@ const ToSpecDecompositionChildSchema = z
145
184
  .describe("The focused commands that prove this child, each with its cwd when it matters."),
146
185
  })
147
186
  .strict()
187
+ .superRefine((child, ctx) => {
188
+ // #1060: a decomposition child carries the identical admission contract,
189
+ // so its write lane obeys the identical grammar — prose here would
190
+ // harden into a child slice nothing can file.
191
+ for (const problem of laneEntryProblems("writeLane", child.writeLane)) {
192
+ ctx.addIssue({ code: "custom", message: problem });
193
+ }
194
+ })
148
195
  .describe("One ordered child slice a decomposition proposal names (#1041).");
149
196
 
150
197
  /**
@@ -308,6 +355,12 @@ const ToSpecResultSchema = z
308
355
  if (value.routing !== MULTI_ROUTING && value.routingSplit !== undefined) {
309
356
  ctx.addIssue({ code: "custom", message: "routingSplit is only valid with routing `MULTI`" });
310
357
  }
358
+ // #1060: the lane is the admission contract, so its entries must be
359
+ // plausible repository-relative paths — refused here, at parse time,
360
+ // rather than hardening into a durable verdict no promotion can use.
361
+ for (const problem of laneEntryProblems("fileLane", value.fileLane)) {
362
+ ctx.addIssue({ code: "custom", message: problem });
363
+ }
311
364
  })
312
365
  .describe("A complete, to-spec grooming result; nothing outside this shape is accepted.");
313
366
 
@@ -326,9 +379,16 @@ export type ToSpecResult = Omit<z.infer<typeof ToSpecResultSchema>, "source"> &
326
379
  source: { name: string; ref: string; freshAt: number };
327
380
  };
328
381
 
329
- /** Why a result could not be trusted; each persists as a blocked record. */
382
+ /** Why a result could not be trusted; each persists as a blocked record.
383
+ *
384
+ * `no-answer` is the session-level refusal (#1064): the pass produced no
385
+ * payload at all — the scout was killed at its turn ceiling, or the session
386
+ * ended without ever yielding. It is a property of the session, never of
387
+ * output, so it is decided by the pass before anything is parsed and is never
388
+ * `malformed`. */
330
389
  export type ToSpecFailure =
331
390
  | { kind: "malformed"; detail: string }
391
+ | { kind: "no-answer"; detail: string }
332
392
  | { kind: "missing-source"; detail: string }
333
393
  | { kind: "stale-source"; detail: string };
334
394
 
@@ -338,6 +398,20 @@ export type ParseToSpecOutcome = { ok: true; result: ToSpecResult } | { ok: fals
338
398
  export interface ToSpecEvidence {
339
399
  kind: "to-spec";
340
400
  result: ToSpecResult;
401
+ /**
402
+ * How the answer arrived (#1064): through the `yield` tool, or recovered
403
+ * from the session's text because it never yielded. `"text"` is the record's
404
+ * admission that the verdict did not come through the yield contract — the
405
+ * answer was complete and valid, so it stands, but a later reader can tell
406
+ * the two apart. Absent (or `"yield"`) means it came through the contract.
407
+ */
408
+ via?: "yield" | "text";
409
+ /**
410
+ * Whether the one bounded repair round produced the answer (#1064): the
411
+ * first answer parsed but violated the contract, the validation error was
412
+ * handed back, and the re-answer is what this record carries.
413
+ */
414
+ repaired?: boolean;
341
415
  /**
342
416
  * What the ready gate found missing when a valid PROMOTABLE verdict was
343
417
  * refused mechanical promotion (#1041). A sibling of `result`, never a
@@ -356,12 +430,21 @@ export interface ToSpecFailureEvidence {
356
430
  input: string;
357
431
  }
358
432
 
359
- /** How much raw agent output a failure record may carry. */
360
- export const TO_SPEC_INPUT_SAMPLE_MAX = 2_000;
433
+ /**
434
+ * How much raw agent output a failure record may carry (#1064).
435
+ *
436
+ * Sized for the whole refused payload, not a sample of it: a complete verdict
437
+ * with a proposed brief and sizing analysis routinely exceeds 2 KB, and a
438
+ * discarded verdict should be recoverable from the row rather than
439
+ * re-derived at full session cost. 8 KB holds a full payload with headroom
440
+ * while still keeping a single refused row bounded.
441
+ */
442
+ export const TO_SPEC_INPUT_SAMPLE_MAX = 8_000;
361
443
 
362
444
  /** Refused results always land on the same store surface, named by failure. */
363
445
  const FAILURE_REASON: Record<ToSpecFailure["kind"], string> = {
364
446
  malformed: "malformed",
447
+ "no-answer": "no-answer",
365
448
  "missing-source": "missing-source",
366
449
  "stale-source": "stale-source",
367
450
  };
@@ -411,6 +494,59 @@ function extractJson(input: string): string {
411
494
  return (fenced?.[1] ?? input).trim();
412
495
  }
413
496
 
497
+ /**
498
+ * The three-way gate the scout runner decides a payload against (#1064):
499
+ * whether a string is a payload at all, and whether that payload already
500
+ * conforms to the contract or is a single re-answer away from it.
501
+ *
502
+ * - `conforming` — a JSON object that satisfies the strict contract. For
503
+ * text-recovered blocks this is the criterion that separates "a complete
504
+ * verdict written outside the yield tool" from mere narration.
505
+ * - `repairable` — a JSON object that violates the contract (the #1062
506
+ * shape: every required field present, one key misnamed). The failure
507
+ * carries the first validation issue, which is what the repair round hands
508
+ * back to the scout.
509
+ * - `not-a-payload` — prose, truncated JSON, a non-object: nothing a
510
+ * re-answer can repair, so the pass records no answer rather than grading
511
+ * it.
512
+ *
513
+ * Same parser as {@link parseToSpecResult}, deliberately: the runner probes
514
+ * with the one contract so its repair decision can never drift from what the
515
+ * persistence layer will accept — this is the one validator used twice, not a
516
+ * second, weaker one.
517
+ */
518
+ export type ToSpecPayloadGate =
519
+ | { state: "conforming"; body: string }
520
+ | { state: "repairable"; body: string; failure: ToSpecFailure }
521
+ | { state: "not-a-payload" };
522
+
523
+ export function gateToSpecPayload(payload: string): ToSpecPayloadGate {
524
+ const body = extractJson(payload);
525
+ if (body.length === 0) return { state: "not-a-payload" };
526
+ let raw: unknown;
527
+ try {
528
+ raw = JSON.parse(body);
529
+ } catch {
530
+ return { state: "not-a-payload" };
531
+ }
532
+ if (!isObject(raw)) return { state: "not-a-payload" };
533
+ const parsed = ToSpecResultSchema.safeParse(raw);
534
+ if (!parsed.success) {
535
+ // The field path rides in front of the message: zod says "Required" for a
536
+ // missing field and "Unrecognized key(s)…" for an extra one, and the
537
+ // repair round's whole value is that the scout sees WHICH field failed.
538
+ const first = parsed.error.issues[0];
539
+ const detail =
540
+ first === undefined
541
+ ? "schema violation"
542
+ : first.path.length === 0
543
+ ? first.message
544
+ : `${first.path.join(".")}: ${first.message}`;
545
+ return { state: "repairable", body, failure: { kind: "malformed", detail } };
546
+ }
547
+ return { state: "conforming", body };
548
+ }
549
+
414
550
  /**
415
551
  * Parse and validate a groomer's raw output against the to-spec contract.
416
552
  * `now` is the settle time, explicit so staleness and determinism are
@@ -487,8 +623,31 @@ export type ToSpecGroomingOutcome =
487
623
  export interface ToSpecGroomingRequest {
488
624
  project: string;
489
625
  issue: number;
490
- /** The agent's raw output, exactly as returned. */
626
+ /** The agent's structured answer, exactly as returned: the yield payload,
627
+ * or a conforming block recovered from its text. Empty when the pass
628
+ * produced no answer at all — which is its own refusal class, decided here
629
+ * before anything is parsed (#1064). */
491
630
  input: string;
631
+ /** How the answer arrived: through the yield tool, or recovered from the
632
+ * session's text because it never yielded. `"text"` marks the record so it
633
+ * stays visible that the verdict did not come through the yield contract;
634
+ * omitted reads as a yield, and an empty `input` with no `via` reads as no
635
+ * answer. */
636
+ via?: "yield" | "text";
637
+ /** The session's last narration, recorded as the refusal's context when no
638
+ * answer arrived — never parsed as a verdict (#1064). */
639
+ report?: string;
640
+ /** Whether the one bounded repair round produced the carried answer
641
+ * (#1064); recorded on the row so a repaired verdict is distinguishable. */
642
+ repaired?: boolean;
643
+ /** Whether the session was killed at its turn ceiling before it answered
644
+ * (#1064) — half of the no-answer shape naming. */
645
+ killedAtCeiling?: boolean;
646
+ /** The turn ceiling the pass ran under, and the turns it took: the other
647
+ * half of the no-answer shape naming (`turns >= maxTurns` reads as a cap
648
+ * kill even when the runner did not flag it). */
649
+ maxTurns?: number;
650
+ turns?: number;
492
651
  /** Observation time for staleness and the recorded row; `Date.now()` when
493
652
  * omitted (tests pass it to keep reprocessing deterministic). */
494
653
  now?: number;
@@ -520,24 +679,51 @@ function failureEvidence(failure: ToSpecFailure, input: string): string {
520
679
  * it, not anything the agent wrote, becomes the recorded source observation
521
680
  * time.
522
681
  */
523
- export function recordToSpecGrooming(store: Store, request: ToSpecGroomingRequest): ToSpecGroomingOutcome {
524
- const now = request.now ?? Date.now();
525
- const parsed = parseToSpecResult(request.input, now, { launchedAt: request.launchedAt });
526
- if (parsed.ok) {
527
- const evidence: ToSpecEvidence = { kind: "to-spec", result: parsed.result };
528
- store.upsertGrooming({
529
- project: request.project,
530
- issue: request.issue,
531
- verdict: mapStoreVerdict(parsed.result.verdict),
532
- reason: mapStoreReason(parsed.result.verdict),
533
- evidence: JSON.stringify(evidence),
534
- at: now,
535
- });
536
- return { kind: "persisted", record: store.grooming(request.project, request.issue)! };
682
+ /**
683
+ * The reason a via-text verdict carries: the groomer's own label with a
684
+ * visible marker that the answer arrived outside the yield contract (#1064),
685
+ * so `status` reads `#13 promotable via text` instead of hiding the
686
+ * distinction in an evidence string. Yielded verdicts keep the bare label.
687
+ */
688
+ function mapStoreReasonWithVia(verdict: ToSpecVerdict, via: "yield" | "text" | undefined): string {
689
+ const label = mapStoreReason(verdict);
690
+ return via === "text" ? `${label} via text` : label;
691
+ }
692
+
693
+ /**
694
+ * The no-answer detail names which of the two session shapes produced it
695
+ * (#1064): a turn-ceiling kill (`killedAtCeiling`, or `turns >= maxTurns`
696
+ * when the runner did not flag it) or a session that ended without ever
697
+ * yielding.
698
+ */
699
+ function noAnswerDetail(request: ToSpecGroomingRequest): string {
700
+ const killed =
701
+ request.killedAtCeiling === true ||
702
+ (request.maxTurns !== undefined && request.turns !== undefined && request.turns >= request.maxTurns);
703
+ const turns = request.turns === undefined ? "" : ` after ${request.turns} turn(s)`;
704
+ if (killed) {
705
+ const ceiling = request.maxTurns === undefined ? "its turn ceiling" : `its ${request.maxTurns}-turn ceiling`;
706
+ return `the scout was killed at ${ceiling}${turns} and never yielded an answer`;
537
707
  }
708
+ return `the scout session ended${turns} without yielding an answer`;
709
+ }
710
+
711
+ /**
712
+ * Persist one refusal, or keep a prior valid verdict when the new pass
713
+ * produced something no-answer-like or malformed. `input` is what the failure
714
+ * record carries: the raw payload of the refused pass — or, for a no-answer
715
+ * pass, the session's narration as context (#1064).
716
+ */
717
+ function persistGroomingRefusal(
718
+ store: Store,
719
+ request: ToSpecGroomingRequest,
720
+ failure: ToSpecFailure,
721
+ input: string,
722
+ now: number,
723
+ ): ToSpecGroomingOutcome {
538
724
  const prior = store.grooming(request.project, request.issue);
539
725
  if (
540
- parsed.failure.kind === "malformed" &&
726
+ (failure.kind === "malformed" || failure.kind === "no-answer") &&
541
727
  prior !== undefined &&
542
728
  (prior.verdict === "promotable" || prior.verdict === "considered")
543
729
  ) {
@@ -547,13 +733,83 @@ export function recordToSpecGrooming(store: Store, request: ToSpecGroomingReques
547
733
  project: request.project,
548
734
  issue: request.issue,
549
735
  verdict: "blocked",
550
- reason: FAILURE_REASON[parsed.failure.kind],
551
- evidence: failureEvidence(parsed.failure, request.input),
736
+ reason: FAILURE_REASON[failure.kind],
737
+ evidence: failureEvidence(failure, input),
552
738
  at: now,
553
739
  });
554
740
  return { kind: "persisted", record: store.grooming(request.project, request.issue)! };
555
741
  }
556
742
 
743
+ /**
744
+ * The only way a groomer's output becomes a durable grooming verdict: parse
745
+ * and validate, then upsert through the existing store — no second store, no
746
+ * `GroomingRecord` extension. Valid results map to the store's verdict
747
+ * surface with the full structured result serialized into `evidence`
748
+ * (`{"kind":"to-spec","result":…}`), which is what survives a restart. A
749
+ * refused result persists as `blocked` with the failure named in
750
+ * `evidence` (`to-spec-failure`) — never as promotable/considered — except
751
+ * when a prior valid result exists: malformed and no-answer reprocessing then
752
+ * keep that prior row instead of erasing it. Nothing here touches labels or
753
+ * issues.
754
+ *
755
+ * The refusal classes come from the whole pass, not just its output (#1064):
756
+ *
757
+ * - `no-answer` — the session produced no payload at all (no yield, no
758
+ * conforming text block): a turn-ceiling kill or a session end without a
759
+ * yield, recorded by shape, never as `malformed`. The narration is kept as
760
+ * context only. A text-carried block that fails validation is also
761
+ * `no-answer` — such a session never yielded, and the block is not an
762
+ * answer — except a batch-window staleness, which is a property of the
763
+ * pass rather than the answer.
764
+ * - `malformed` / `missing-source` — a *yielded* payload (or an unjudged
765
+ * one) that violates the contract, with the raw payload persisted so the
766
+ * discarded verdict stays recoverable.
767
+ * - `stale-source` — the batch window outlived the freshness ceiling.
768
+ *
769
+ * `request.launchedAt` is the dispatcher's launch stamp for the batch (#1000);
770
+ * it, not anything the agent wrote, becomes the recorded source observation
771
+ * time.
772
+ */
773
+ export function recordToSpecGrooming(store: Store, request: ToSpecGroomingRequest): ToSpecGroomingOutcome {
774
+ const now = request.now ?? Date.now();
775
+ if (request.input === "") {
776
+ return persistGroomingRefusal(
777
+ store,
778
+ request,
779
+ { kind: "no-answer", detail: noAnswerDetail(request) },
780
+ request.report ?? "",
781
+ now,
782
+ );
783
+ }
784
+ const parsed = parseToSpecResult(request.input, now, { launchedAt: request.launchedAt });
785
+ if (parsed.ok) {
786
+ const evidence: ToSpecEvidence = {
787
+ kind: "to-spec",
788
+ result: parsed.result,
789
+ ...(request.via === undefined ? {} : { via: request.via }),
790
+ ...(request.repaired === true ? { repaired: true } : {}),
791
+ };
792
+ store.upsertGrooming({
793
+ project: request.project,
794
+ issue: request.issue,
795
+ verdict: mapStoreVerdict(parsed.result.verdict),
796
+ reason: mapStoreReasonWithVia(parsed.result.verdict, request.via),
797
+ evidence: JSON.stringify(evidence),
798
+ at: now,
799
+ });
800
+ return { kind: "persisted", record: store.grooming(request.project, request.issue)! };
801
+ }
802
+ // A text-carried answer that fails validation was never yielded and does
803
+ // not conform: that is a no-answer pass, with the block kept as the
804
+ // failure's context — never malformed (#1064). The one exception is a
805
+ // batch-window staleness, whose class is about the pass, not the answer.
806
+ const failure =
807
+ request.via === "text" && parsed.failure.kind !== "stale-source"
808
+ ? { kind: "no-answer" as const, detail: noAnswerDetail(request) }
809
+ : parsed.failure;
810
+ return persistGroomingRefusal(store, request, failure, request.input, now);
811
+ }
812
+
557
813
  /**
558
814
  * Recover the validated result from a record's evidence (the restart
559
815
  * round-trip). Returns undefined for anything that is not a `to-spec`
@@ -601,7 +857,12 @@ export function parseToSpecFailureEvidence(evidence: string): ToSpecFailure | un
601
857
  if (!isObject(raw) || raw.kind !== "to-spec-failure") return undefined;
602
858
  const failure = raw.failure;
603
859
  if (!isObject(failure) || typeof failure.detail !== "string") return undefined;
604
- if (failure.kind !== "malformed" && failure.kind !== "missing-source" && failure.kind !== "stale-source") {
860
+ if (
861
+ failure.kind !== "malformed" &&
862
+ failure.kind !== "no-answer" &&
863
+ failure.kind !== "missing-source" &&
864
+ failure.kind !== "stale-source"
865
+ ) {
605
866
  return undefined;
606
867
  }
607
868
  return { kind: failure.kind, detail: failure.detail };
@@ -94,6 +94,12 @@ interface GhCheck {
94
94
  status?: string;
95
95
  conclusion?: string;
96
96
  state?: string;
97
+ /** When the run started (`startedAt` on a GraphQL CheckRun, `started_at`
98
+ * over REST); a StatusContext stamps `createdAt` instead. Undefined when
99
+ * the payload spelled none — unordered entries resolve pessimistically
100
+ * (#1066), never invented. */
101
+ startedAt?: string;
102
+ createdAt?: string;
97
103
  detailsUrl?: string;
98
104
  }
99
105
 
@@ -419,6 +425,75 @@ function checkVerdict(check: GhCheck): CheckVerdict {
419
425
  return "failed";
420
426
  }
421
427
 
428
+ /** One check normalized onto the verdict vocabulary, carrying whatever run
429
+ * timestamp its payload spelled: undefined means unordered, which
430
+ * {@link resolveNewestPerName} treats pessimistically. */
431
+ interface NamedVerdict {
432
+ readonly name: string;
433
+ readonly verdict: CheckVerdict;
434
+ readonly state: string;
435
+ readonly startedAt: string | undefined;
436
+ }
437
+
438
+ /** Epoch ms of an ISO stamp, or undefined when absent or unreadable — both
439
+ * leave the entry unordered rather than inventing an age for it. */
440
+ function startedAtMs(startedAt: string | undefined): number | undefined {
441
+ if (startedAt === undefined) return undefined;
442
+ const ms = Date.parse(startedAt);
443
+ return Number.isFinite(ms) ? ms : undefined;
444
+ }
445
+
446
+ /** Worse beats better when a name's deciding entries disagree. */
447
+ const VERDICT_RANK: Readonly<Record<CheckVerdict, number>> = { failed: 0, pending: 1, green: 2 };
448
+
449
+ /**
450
+ * Decide every check name by its newest run, never by payload order (#1066).
451
+ *
452
+ * One head can carry several check-runs of one name — a re-run creates a new
453
+ * run record without retiring the old one — and folding them first-match lets
454
+ * a stale `skipped` mask a newer `failure`: exactly how veltro#717 nearly
455
+ * merged green over its only upgrade-path check, whose failing run started
456
+ * five minutes behind the skipped row of the same name. Entries are grouped
457
+ * by name and each group is decided by its newest tier, GitHub's own checks-UI
458
+ * rule:
459
+ *
460
+ * - timed entries at the maximum `started_at` decide; older runs of the name
461
+ * cannot mask them, so a re-run that went green supersedes its red
462
+ * predecessor;
463
+ * - ties at one instant resolve pessimistically — a failure anywhere in the
464
+ * deciding tier fails the name;
465
+ * - an entry without a readable timestamp cannot be proven older than
466
+ * anything, so it belongs to every deciding tier (pessimistic again).
467
+ *
468
+ * The representative kept per name is the deciding entry itself, so the
469
+ * folds' refusal lines render the decided verdict's own spelling.
470
+ */
471
+ function resolveNewestPerName(entries: readonly NamedVerdict[]): NamedVerdict[] {
472
+ const groups = new Map<string, { entry: NamedVerdict; startedMs: number | undefined }[]>();
473
+ for (const entry of entries) {
474
+ const stamped = { entry, startedMs: startedAtMs(entry.startedAt) };
475
+ const group = groups.get(entry.name);
476
+ if (group === undefined) groups.set(entry.name, [stamped]);
477
+ else group.push(stamped);
478
+ }
479
+ const resolved: NamedVerdict[] = [];
480
+ for (const group of groups.values()) {
481
+ let newest = -Infinity;
482
+ for (const { startedMs } of group) {
483
+ if (startedMs !== undefined && startedMs > newest) newest = startedMs;
484
+ }
485
+ let decided: NamedVerdict | undefined;
486
+ for (const { entry, startedMs } of group) {
487
+ if (startedMs !== undefined && startedMs !== newest) continue;
488
+ if (decided === undefined || VERDICT_RANK[entry.verdict] < VERDICT_RANK[decided.verdict]) {
489
+ decided = entry;
490
+ }
491
+ }
492
+ if (decided !== undefined) resolved.push(decided);
493
+ }
494
+ return resolved;
495
+ }
496
+
422
497
  /**
423
498
  * Verify one `gh pr view --json state,isDraft,headRefOid,statusCheckRollup`
424
499
  * payload, optionally against a caller-observed head. A missing rollup is
@@ -430,6 +505,10 @@ function checkVerdict(check: GhCheck): CheckVerdict {
430
505
  * a different question: *what is this PR's state*, and a merged or closed PR
431
506
  * is reported as a fact rather than a failure — the merge gate paths never
432
507
  * pass `read`, so the `expected OPEN` refusal stays exactly where it gates.
508
+ *
509
+ * Same-name entries are decided by recency, not payload order (#1066): only
510
+ * the newest run of a name answers for it, ties and unreadable stamps resolve
511
+ * pessimistically, and the green count names de-duplicated checks.
433
512
  */
434
513
  export function prVerificationFrom(
435
514
  raw: string,
@@ -467,23 +546,34 @@ export function prVerificationFrom(
467
546
  if (checks.length === 0) {
468
547
  return { status: "pending", reason: "GitHub has not reported any checks yet", headSha };
469
548
  }
470
- const failed = checks.filter((check) => checkVerdict(check) === "failed");
549
+ // Same-name runs decide by recency, not payload order (#1066): the rollup
550
+ // can carry an older run of a name beside a newer one, and first-match
551
+ // folding would let the older verdict answer for the name.
552
+ const resolved = resolveNewestPerName(
553
+ checks.map((check): NamedVerdict => ({
554
+ name: checkName(check),
555
+ verdict: checkVerdict(check),
556
+ state: check.conclusion ?? check.state ?? "unknown",
557
+ startedAt: check.startedAt ?? check.createdAt,
558
+ })),
559
+ );
560
+ const failed = resolved.filter((check) => check.verdict === "failed");
471
561
  if (failed.length > 0) {
472
562
  return {
473
563
  status: "failed",
474
- reason: `Checks failed: ${failed.map((check) => `${checkName(check)} (${check.conclusion ?? check.state ?? "unknown"})`).join(", ")}`,
564
+ reason: `Checks failed: ${failed.map((check) => `${check.name} (${check.state})`).join(", ")}`,
475
565
  headSha,
476
566
  };
477
567
  }
478
- const pending = checks.filter((check) => checkVerdict(check) === "pending");
568
+ const pending = resolved.filter((check) => check.verdict === "pending");
479
569
  if (pending.length > 0) {
480
570
  return {
481
571
  status: "pending",
482
- reason: `Checks pending: ${pending.map(checkName).join(", ")}`,
572
+ reason: `Checks pending: ${pending.map((check) => check.name).join(", ")}`,
483
573
  headSha,
484
574
  };
485
575
  }
486
- return { status: "green", reason: `${checks.length} checks succeeded or were skipped`, headSha };
576
+ return { status: "green", reason: `${resolved.length} checks succeeded or were skipped`, headSha };
487
577
  }
488
578
 
489
579
  /**
@@ -538,6 +628,9 @@ interface RestCheckConclusion {
538
628
  * GitHub spelled it — surfaced in refusals exactly like the GraphQL one. */
539
629
  state: string;
540
630
  verdict: CheckVerdict;
631
+ /** The run's `started_at` (or a commit status's `updated_at`), undefined
632
+ * when the page spelled none — unordered, never invented (#1066). */
633
+ startedAt: string | undefined;
541
634
  }
542
635
 
543
636
  /** One REST check plane, parsed strictly. */
@@ -579,6 +672,7 @@ function restRunConclusionFrom(row: { readonly [key: string]: unknown }): RestCh
579
672
  const name = row["name"];
580
673
  const status = row["status"];
581
674
  const conclusion = row["conclusion"];
675
+ const startedAt = row["started_at"];
582
676
  if (typeof name !== "string" || name === "") return undefined;
583
677
  if (typeof status !== "string") return undefined;
584
678
  if (conclusion !== undefined && conclusion !== null && typeof conclusion !== "string") return undefined;
@@ -591,7 +685,12 @@ function restRunConclusionFrom(row: { readonly [key: string]: unknown }): RestCh
591
685
  : conclusion === "success" || conclusion === "skipped" || conclusion === "neutral"
592
686
  ? "green"
593
687
  : "failed";
594
- return { name, state: typeof conclusion === "string" ? conclusion : status, verdict };
688
+ return {
689
+ name,
690
+ state: typeof conclusion === "string" ? conclusion : status,
691
+ verdict,
692
+ startedAt: typeof startedAt === "string" ? startedAt : undefined,
693
+ };
595
694
  }
596
695
 
597
696
  /** One REST commit-status member onto the rollup vocabulary, or undefined when
@@ -599,12 +698,14 @@ function restRunConclusionFrom(row: { readonly [key: string]: unknown }): RestCh
599
698
  function restStatusConclusionFrom(row: { readonly [key: string]: unknown }): RestCheckConclusion | undefined {
600
699
  const context = row["context"];
601
700
  const state = row["state"];
701
+ const updatedAt = typeof row["updated_at"] === "string" ? row["updated_at"] : undefined;
702
+ const createdAt = typeof row["created_at"] === "string" ? row["created_at"] : undefined;
602
703
  if (typeof context !== "string" || context === "") return undefined;
603
704
  if (typeof state !== "string") return undefined;
604
705
  // StatusContext semantics: `success` is green, `pending` pending, anything
605
706
  // else (`failure`/`error`) is failed.
606
707
  const verdict: CheckVerdict = state === "success" ? "green" : state === "pending" ? "pending" : "failed";
607
- return { name: context, state, verdict };
708
+ return { name: context, state, verdict, startedAt: updatedAt ?? createdAt };
608
709
  }
609
710
 
610
711
  /**
@@ -706,6 +807,10 @@ function restProjectionVerdict(
706
807
  * Throws on a payload that cannot prove the head or either check plane, so the
707
808
  * caller maps it to `head-unresolvable` exactly as it does for the GraphQL
708
809
  * parser.
810
+ *
811
+ * Same-name runs are decided by recency, not page order (#1066): the newest
812
+ * attempt of a name answers for it, so a stale `skipped` beside a newer
813
+ * `failure` fails the verdict whatever order the pages arrive in.
709
814
  */
710
815
  export function restVerificationFrom(
711
816
  prRaw: string,
@@ -732,7 +837,11 @@ export function restVerificationFrom(
732
837
  if (checks.length === 0) {
733
838
  return { status: "pending", reason: "GitHub has not reported any checks yet", headSha };
734
839
  }
735
- const failed = checks.filter((c) => c.verdict === "failed");
840
+ // Same-name runs decide by recency, not page order (#1066): the flattened
841
+ // planes carry every attempt of a name, and first-match folding would let
842
+ // the oldest attempt answer for it.
843
+ const resolved = resolveNewestPerName(checks);
844
+ const failed = resolved.filter((c) => c.verdict === "failed");
736
845
  if (failed.length > 0) {
737
846
  return {
738
847
  status: "failed",
@@ -740,7 +849,7 @@ export function restVerificationFrom(
740
849
  headSha,
741
850
  };
742
851
  }
743
- const pending = checks.filter((c) => c.verdict === "pending");
852
+ const pending = resolved.filter((c) => c.verdict === "pending");
744
853
  if (pending.length > 0) {
745
854
  return {
746
855
  status: "pending",
@@ -748,7 +857,7 @@ export function restVerificationFrom(
748
857
  headSha,
749
858
  };
750
859
  }
751
- return { status: "green", reason: `${checks.length} checks succeeded or were skipped`, headSha };
860
+ return { status: "green", reason: `${resolved.length} checks succeeded or were skipped`, headSha };
752
861
  }
753
862
 
754
863
  /**
@@ -2074,6 +2183,19 @@ export function makeTracker(
2074
2183
  const rest = await verifyPrRest(runGh, cache, url, expectedHead, opts, hooks.onNotModified);
2075
2184
  return rest ?? verification;
2076
2185
  }
2186
+ if (verification.status === "green") {
2187
+ // A rollup context is one NAME, not one RUN (#1066): veltro#717
2188
+ // read eighteen green contexts while the same SHA carried a newer
2189
+ // `failure` run of a name whose rollup row still said `skipped`.
2190
+ // Recency ordering helps only when both rows arrive together; a
2191
+ // rollup that omits the newer run entirely cannot be ordered back
2192
+ // to correctness, so green is re-proven from the flattened REST
2193
+ // planes — the same evidence the empty-rollup path above trusts.
2194
+ // REST that cannot answer leaves this verdict standing, exactly as
2195
+ // a degraded plane leaves the #999 pending standing.
2196
+ const rest = await verifyPrRest(runGh, cache, url, expectedHead, opts, hooks.onNotModified);
2197
+ return rest ?? verification;
2198
+ }
2077
2199
  if (verification.status !== "failed") return verification;
2078
2200
 
2079
2201
  const detailsUrl = failedCheck(raw)?.detailsUrl;