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/package.json +1 -1
- package/schema/config.schema.json +24 -0
- package/src/admission.ts +58 -14
- package/src/arm-challenge.ts +54 -3
- package/src/briefs/console.md +10 -5
- package/src/commands/arm.ts +7 -5
- package/src/commands/companion.ts +52 -16
- package/src/commands/drain.ts +12 -5
- package/src/commands/worker.ts +5 -0
- package/src/config-schema.ts +8 -0
- package/src/config.ts +17 -2
- package/src/daemon/drain.ts +33 -0
- package/src/daemon/groom-pass.ts +16 -6
- package/src/daemon/http.ts +28 -0
- package/src/daemon/review.ts +11 -11
- package/src/daemon/runtime.ts +55 -3
- package/src/daemon/settle-pass.ts +19 -2
- package/src/daemon/supervision.ts +144 -1
- package/src/daemon/tick.ts +31 -14
- package/src/daemon/views.ts +17 -0
- package/src/daemon.ts +4 -3
- package/src/decisions.ts +20 -9
- package/src/diff-flags.ts +204 -28
- package/src/doctor.ts +27 -9
- package/src/escalate.ts +187 -15
- package/src/failure-class.ts +358 -5
- package/src/fleet.ts +55 -25
- package/src/graph-health.ts +17 -1
- package/src/groom.ts +11 -0
- package/src/orchestrator-tick.ts +302 -24
- package/src/reports.ts +4 -1
- package/src/settlement.ts +133 -12
- package/src/status-render.ts +38 -9
- package/src/store.ts +64 -15
- package/src/to-spec.ts +285 -24
- package/src/tracker/github.ts +132 -10
- package/src/types.ts +49 -3
- package/src/verbs/server.ts +16 -12
- package/src/worker.ts +149 -35
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
|
|
16
|
-
* source) persists as `blocked` and never as
|
|
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
|
-
/**
|
|
360
|
-
|
|
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
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
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[
|
|
551
|
-
evidence: failureEvidence(
|
|
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 (
|
|
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 };
|
package/src/tracker/github.ts
CHANGED
|
@@ -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
|
-
|
|
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) => `${
|
|
564
|
+
reason: `Checks failed: ${failed.map((check) => `${check.name} (${check.state})`).join(", ")}`,
|
|
475
565
|
headSha,
|
|
476
566
|
};
|
|
477
567
|
}
|
|
478
|
-
const 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(
|
|
572
|
+
reason: `Checks pending: ${pending.map((check) => check.name).join(", ")}`,
|
|
483
573
|
headSha,
|
|
484
574
|
};
|
|
485
575
|
}
|
|
486
|
-
return { status: "green", reason: `${
|
|
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 {
|
|
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
|
-
|
|
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 =
|
|
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: `${
|
|
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;
|