omp-conductor 0.16.2 → 0.17.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 (52) hide show
  1. package/README.md +38 -4
  2. package/REFERENCE.md +18 -12
  3. package/package.json +2 -1
  4. package/schema/config.schema.json +16 -0
  5. package/src/admission.ts +159 -43
  6. package/src/availability.ts +27 -1
  7. package/src/briefs/worker.md +2 -0
  8. package/src/clack-ui.ts +83 -0
  9. package/src/command-manifest.ts +16 -7
  10. package/src/commands/arm.ts +11 -3
  11. package/src/commands/decision.ts +17 -7
  12. package/src/commands/doctor.ts +18 -1
  13. package/src/commands/hold.ts +9 -7
  14. package/src/commands/ledger.ts +25 -4
  15. package/src/commands/message.ts +32 -4
  16. package/src/commands/setup.ts +61 -10
  17. package/src/commands/stats.ts +9 -5
  18. package/src/commands/status.ts +32 -5
  19. package/src/commands/tail.ts +13 -1
  20. package/src/commands/watch.ts +16 -7
  21. package/src/config-schema.ts +20 -0
  22. package/src/config.ts +37 -0
  23. package/src/daemon.ts +1240 -18
  24. package/src/doctor.ts +310 -22
  25. package/src/escalate.ts +560 -57
  26. package/src/failure-class.ts +56 -13
  27. package/src/fleet.ts +224 -47
  28. package/src/gitops.ts +103 -24
  29. package/src/lifecycle.ts +7 -2
  30. package/src/orchestrator-tick.ts +372 -157
  31. package/src/privileged.ts +3 -0
  32. package/src/release-policy.ts +177 -5
  33. package/src/setup-answers.ts +135 -0
  34. package/src/setup-host.ts +193 -4
  35. package/src/setup-install.ts +2 -0
  36. package/src/setup-probe.ts +1 -0
  37. package/src/setup-wizard.ts +1296 -101
  38. package/src/setup.ts +60 -3
  39. package/src/status-render.ts +11 -1
  40. package/src/store.ts +333 -12
  41. package/src/tracker/github.ts +562 -13
  42. package/src/types.ts +204 -2
  43. package/src/ui/progress.ts +32 -0
  44. package/src/ui/style.ts +11 -0
  45. package/src/upgrade.ts +50 -19
  46. package/src/verbs/actions.ts +66 -18
  47. package/src/verbs/protocol.ts +45 -0
  48. package/src/verbs/server.ts +212 -11
  49. package/src/wizard-ui.ts +14 -5
  50. package/src/worker.ts +26 -0
  51. package/systemd/omp-conductor-recover.sh +73 -0
  52. package/systemd/recover-unit-test.sh +61 -0
@@ -385,7 +385,13 @@ function checkVerdict(check: GhCheck): CheckVerdict {
385
385
  return "failed";
386
386
  }
387
387
  if (check.status !== "COMPLETED") return "pending";
388
- if (check.conclusion === "SUCCESS" || check.conclusion === "SKIPPED") return "green";
388
+ if (
389
+ check.conclusion === "SUCCESS" ||
390
+ check.conclusion === "SKIPPED" ||
391
+ check.conclusion === "NEUTRAL"
392
+ ) {
393
+ return "green";
394
+ }
389
395
  return "failed";
390
396
  }
391
397
 
@@ -461,6 +467,311 @@ function failedCheck(raw: string): GhCheck | undefined {
461
467
  return checks.find((check) => checkVerdict(check) === "failed");
462
468
  }
463
469
 
470
+ /** REST projection of a pull request read by the verifyPr fallback (#653). */
471
+ interface RestPrProjection {
472
+ draft?: unknown;
473
+ head_sha?: unknown;
474
+ }
475
+
476
+ /** One REST check, normalized onto the rollup verdict vocabulary. */
477
+ interface RestCheckConclusion {
478
+ name: string;
479
+ /** The terminal `conclusion`/`status` (run) or `state` (commit status), as
480
+ * GitHub spelled it — surfaced in refusals exactly like the GraphQL one. */
481
+ state: string;
482
+ verdict: CheckVerdict;
483
+ }
484
+
485
+ /** One REST check plane, parsed strictly. */
486
+ type RestCheckEvidence =
487
+ | { readonly ok: true; readonly checks: RestCheckConclusion[] }
488
+ | { readonly ok: false };
489
+
490
+ /** The live head SHA from a REST pull-request projection, or undefined. */
491
+ function restPrHeadFrom(raw: string): string | undefined {
492
+ let parsed: unknown;
493
+ try {
494
+ parsed = JSON.parse(raw) as unknown;
495
+ } catch {
496
+ return undefined;
497
+ }
498
+ if (parsed === null || typeof parsed !== "object") return undefined;
499
+ const sha = (parsed as RestPrProjection).head_sha;
500
+ return typeof sha === "string" && /^[0-9a-f]{40,64}$/i.test(sha) ? sha : undefined;
501
+ }
502
+
503
+ /** Whether a REST pull-request projection is a draft. Absent or malformed is
504
+ * "draft state unknown", the same fail-closed answer {@link prVerificationFrom}
505
+ * gives for a missing rollup — it must never be read as a clean non-draft. */
506
+ function restPrDraftFrom(raw: string): boolean | undefined {
507
+ let parsed: unknown;
508
+ try {
509
+ parsed = JSON.parse(raw) as unknown;
510
+ } catch {
511
+ return undefined;
512
+ }
513
+ if (parsed === null || typeof parsed !== "object") return undefined;
514
+ const draft = (parsed as RestPrProjection).draft;
515
+ return typeof draft === "boolean" ? draft : undefined;
516
+ }
517
+
518
+ /** One REST check-run member onto the rollup vocabulary, or undefined when the
519
+ * member's identity or lifecycle fields cannot be read. */
520
+ function restRunConclusionFrom(row: { readonly [key: string]: unknown }): RestCheckConclusion | undefined {
521
+ const name = row["name"];
522
+ const status = row["status"];
523
+ const conclusion = row["conclusion"];
524
+ if (typeof name !== "string" || name === "") return undefined;
525
+ if (typeof status !== "string") return undefined;
526
+ if (conclusion !== undefined && conclusion !== null && typeof conclusion !== "string") return undefined;
527
+ // statusCheckRollup semantics: an unfinished run is pending,
528
+ // `success`/`skipped`/`neutral` are green — GitHub's rollup counts a
529
+ // completed `neutral` check as successful — anything else is failed.
530
+ const verdict: CheckVerdict =
531
+ status !== "completed"
532
+ ? "pending"
533
+ : conclusion === "success" || conclusion === "skipped" || conclusion === "neutral"
534
+ ? "green"
535
+ : "failed";
536
+ return { name, state: typeof conclusion === "string" ? conclusion : status, verdict };
537
+ }
538
+
539
+ /** One REST commit-status member onto the rollup vocabulary, or undefined when
540
+ * the identity or state cannot be read. */
541
+ function restStatusConclusionFrom(row: { readonly [key: string]: unknown }): RestCheckConclusion | undefined {
542
+ const context = row["context"];
543
+ const state = row["state"];
544
+ if (typeof context !== "string" || context === "") return undefined;
545
+ if (typeof state !== "string") return undefined;
546
+ // StatusContext semantics: `success` is green, `pending` pending, anything
547
+ // else (`failure`/`error`) is failed.
548
+ const verdict: CheckVerdict = state === "success" ? "green" : state === "pending" ? "pending" : "failed";
549
+ return { name: context, state, verdict };
550
+ }
551
+
552
+ /**
553
+ * Parse one raw REST page body of a check plane (`check_runs` or `statuses`)
554
+ * strictly. Any malformed view answers `{ ok: false }`, never a partial list:
555
+ * unparseable JSON, a top-level body that is not an object, a plane that is
556
+ * missing or not an array, or any member whose identity cannot be read. A
557
+ * garbage entry beside a green one must not let the plane read as green.
558
+ */
559
+ function restPlaneFrom(
560
+ raw: string,
561
+ plane: "check_runs" | "statuses",
562
+ member: (row: { readonly [key: string]: unknown }) => RestCheckConclusion | undefined,
563
+ ): RestCheckEvidence {
564
+ let parsed: unknown;
565
+ try {
566
+ parsed = JSON.parse(raw) as unknown;
567
+ } catch {
568
+ return { ok: false };
569
+ }
570
+ if (parsed === null || typeof parsed !== "object") return { ok: false };
571
+ const list = (parsed as { readonly [key: string]: unknown })[plane];
572
+ if (!Array.isArray(list)) return { ok: false };
573
+ const checks: RestCheckConclusion[] = [];
574
+ for (const entry of list) {
575
+ if (entry === null || typeof entry !== "object") return { ok: false };
576
+ const conclusion = member(entry as { readonly [key: string]: unknown });
577
+ if (conclusion === undefined) return { ok: false };
578
+ checks.push(conclusion);
579
+ }
580
+ return { ok: true, checks };
581
+ }
582
+
583
+ /**
584
+ * The projection-only verdict of a REST pull-request read (#653): state, draft
585
+ * and exact head, decided strictly from the one raw projection. These facts do
586
+ * not need the check endpoints, so a caller proves them as soon as the
587
+ * projection arrives — a stale head or a non-open PR answered here stays that
588
+ * refusal even when the check planes are independently degraded, and is never
589
+ * masked as unresolvable. A projection that cannot prove the head throws, so
590
+ * the caller maps it to `head-unresolvable` exactly as it does for the GraphQL
591
+ * parser's failures.
592
+ */
593
+ function restProjectionVerdict(
594
+ prRaw: string,
595
+ expectedHead?: string,
596
+ opts?: { read?: boolean },
597
+ ): { readonly ok: true; readonly headSha: string } | { readonly ok: false; readonly verdict: PrVerification } {
598
+ const headSha = restPrHeadFrom(prRaw);
599
+ if (headSha === undefined) throw new Error("PR head is unavailable");
600
+ const state = prStateFromRest(prRaw);
601
+ if (state !== "open") {
602
+ const stateName = state ?? "unknown";
603
+ if (opts?.read === true) {
604
+ return {
605
+ ok: false,
606
+ verdict: {
607
+ status: "failed",
608
+ reason: `PR is ${stateName}; checks are unavailable for a non-open PR`,
609
+ headSha,
610
+ },
611
+ };
612
+ }
613
+ return {
614
+ ok: false,
615
+ verdict: { status: "failed", reason: `PR is ${stateName}, expected OPEN`, headSha },
616
+ };
617
+ }
618
+ if (restPrDraftFrom(prRaw) !== false) {
619
+ return {
620
+ ok: false,
621
+ verdict: { status: "failed", reason: "PR is draft or draft state is unknown", headSha },
622
+ };
623
+ }
624
+ if (expectedHead !== undefined && headSha.toLowerCase() !== expectedHead.toLowerCase()) {
625
+ return {
626
+ ok: false,
627
+ verdict: {
628
+ status: "failed",
629
+ reason: `PR head changed: expected ${expectedHead}, found ${headSha}`,
630
+ headSha,
631
+ },
632
+ };
633
+ }
634
+ return { ok: true, headSha };
635
+ }
636
+
637
+ /**
638
+ * REST fallback for {@link prVerificationFrom} (#653): prove the same
639
+ * execution-time contract from the REST PR projection plus the check-runs and
640
+ * commit-status endpoints, when the primary GraphQL read cannot answer.
641
+ *
642
+ * The head is the REST projection's own `head.sha` — the same read that
643
+ * supplied state and draft — and the check evidence is pinned to that head via
644
+ * the commit endpoints, never to the caller's observation. Every page of both
645
+ * planes is flattened before the verdict is calculated: GitHub defaults each
646
+ * endpoint to 30 per page, and a non-green context past page one is exactly as
647
+ * decisive as one on page one. Missing check evidence is pending, never green.
648
+ * Throws on a payload that cannot prove the head or either check plane, so the
649
+ * caller maps it to `head-unresolvable` exactly as it does for the GraphQL
650
+ * parser.
651
+ */
652
+ export function restVerificationFrom(
653
+ prRaw: string,
654
+ checkRunPages: string[],
655
+ statusPages: string[],
656
+ expectedHead?: string,
657
+ opts?: { read?: boolean },
658
+ ): PrVerification {
659
+ const projection = restProjectionVerdict(prRaw, expectedHead, opts);
660
+ if (!projection.ok) return projection.verdict;
661
+ const headSha = projection.headSha;
662
+
663
+ const checks: RestCheckConclusion[] = [];
664
+ for (const page of checkRunPages) {
665
+ const evidence = restPlaneFrom(page, "check_runs", restRunConclusionFrom);
666
+ if (!evidence.ok) throw new Error("check-run evidence is unresolvable");
667
+ checks.push(...evidence.checks);
668
+ }
669
+ for (const page of statusPages) {
670
+ const evidence = restPlaneFrom(page, "statuses", restStatusConclusionFrom);
671
+ if (!evidence.ok) throw new Error("commit-status evidence is unresolvable");
672
+ checks.push(...evidence.checks);
673
+ }
674
+ if (checks.length === 0) {
675
+ return { status: "pending", reason: "GitHub has not reported any checks yet", headSha };
676
+ }
677
+ const failed = checks.filter((c) => c.verdict === "failed");
678
+ if (failed.length > 0) {
679
+ return {
680
+ status: "failed",
681
+ reason: `Checks failed: ${failed.map((c) => `${c.name} (${c.state})`).join(", ")}`,
682
+ headSha,
683
+ };
684
+ }
685
+ const pending = checks.filter((c) => c.verdict === "pending");
686
+ if (pending.length > 0) {
687
+ return {
688
+ status: "pending",
689
+ reason: `Checks pending: ${pending.map((c) => c.name).join(", ")}`,
690
+ headSha,
691
+ };
692
+ }
693
+ return { status: "green", reason: `${checks.length} checks succeeded or were skipped`, headSha };
694
+ }
695
+
696
+ /**
697
+ * The REST half of {@link Tracker.verifyPr} (#653): re-prove the execution-time
698
+ * exact-head and check verdict when the primary GraphQL read could not answer.
699
+ * State, draft and exact head are decided from the PR projection the moment it
700
+ * arrives, before any check-plane fetch — a refusal the projection already
701
+ * proves (a stale head, a non-open or draft PR) is returned as that refusal,
702
+ * never downgraded to unresolvable by an independently degraded check
703
+ * endpoint. Returns `undefined` only when the live head or the complete check
704
+ * evidence cannot be proven from REST, so a degraded verification port never
705
+ * weakens the merge gate — it only ever turns a would-be refusal into the same
706
+ * refusal reported as unresolvable.
707
+ */
708
+ async function verifyPrRest(
709
+ runGh: typeof gh,
710
+ cache: RestListCache,
711
+ url: string,
712
+ expectedHead?: string,
713
+ opts?: { read?: boolean },
714
+ onNotModified?: () => void,
715
+ ): Promise<PrVerification | undefined> {
716
+ const parts = prUrlParts(url);
717
+ if (parts === undefined) return undefined;
718
+ const base = `repos/${parts.owner}/${parts.repo}`;
719
+
720
+ let prRaw: string;
721
+ let headSha: string;
722
+ try {
723
+ prRaw = await runGh([
724
+ "api",
725
+ `${base}/pulls/${parts.number}`,
726
+ "--jq",
727
+ "{state, draft, head_sha: .head.sha, merged_at}",
728
+ ]);
729
+ // The projection alone proves state, draft and exact head, so those
730
+ // verdicts are returned before any check evidence is fetched: a stale
731
+ // head proven here is reported as such even when the check endpoints are
732
+ // independently degraded — the check planes must not be able to mask an
733
+ // already-proven stale head as unresolvable.
734
+ const projection = restProjectionVerdict(prRaw, expectedHead, opts);
735
+ if (!projection.ok) return projection.verdict;
736
+ headSha = projection.headSha;
737
+ } catch {
738
+ return undefined;
739
+ }
740
+
741
+ let checkRunPages: string[];
742
+ let statusPages: string[];
743
+ try {
744
+ // Every page of both planes is fetched through the tracked REST funnel
745
+ // (breakers, rate-limit ledger, conditional revalidation) and flattened
746
+ // before the verdict runs: each endpoint defaults to 30 per page, so a PR
747
+ // with more checks than that would otherwise let a non-green context hide
748
+ // past page one. The evidence is pinned to the head proven by the PR
749
+ // projection — never to the caller's observation, which could be stale.
750
+ checkRunPages = await conditionalListPages(
751
+ runGh,
752
+ cache,
753
+ `${base}/commits/${headSha}/check-runs?per_page=100`,
754
+ /* paginate */ true,
755
+ onNotModified,
756
+ );
757
+ statusPages = await conditionalListPages(
758
+ runGh,
759
+ cache,
760
+ `${base}/commits/${headSha}/status?per_page=100`,
761
+ /* paginate */ true,
762
+ onNotModified,
763
+ );
764
+ } catch {
765
+ return undefined;
766
+ }
767
+
768
+ try {
769
+ return restVerificationFrom(prRaw, checkRunPages, statusPages, expectedHead, opts);
770
+ } catch {
771
+ return undefined;
772
+ }
773
+ }
774
+
464
775
  function runLogArgs(detailsUrl: string): string[] | undefined {
465
776
  const match =
466
777
  /^https:\/\/github\.com\/([^/]+\/[^/]+)\/actions\/runs\/(\d+)(?:\/job\/(\d+))?/.exec(
@@ -972,6 +1283,103 @@ export function isRateLimitRefusal(err: unknown): boolean {
972
1283
  );
973
1284
  }
974
1285
 
1286
+ /**
1287
+ * True when a `gh` stderr text carries a transient GitHub server-side 5xx —
1288
+ * the API answering "try later" rather than judging the request. gh's stderr
1289
+ * carries the status in two spellings: the REST `HTTP 500: Internal Server
1290
+ * Error` style, and the GraphQL `No server is currently available to service
1291
+ * your request. (HTTP 503)` recorded in #642. Both mean "stop asking for a
1292
+ * while", and both open the tracker's bounded GraphQL breaker so admission
1293
+ * does not multiply one provider outage into O(queue size) spawns per pass.
1294
+ *
1295
+ * Deliberately narrower than "anything nonzero": a 4xx answers the request
1296
+ * (bad credentials, not found, an exhausted-but-named budget), and a GraphQL
1297
+ * product error carries no HTTP status at all — neither is a provider outage,
1298
+ * and neither may masquerade as the breaker event that holds every candidate.
1299
+ * The status must be a complete three-digit 5xx token (`HTTP 503)`, `HTTP 500:`):
1300
+ * a malformed suffix like `HTTP 5030` is an invalid response, not a provider
1301
+ * outage, so it stays a plain failure instead of opening the breaker.
1302
+ */
1303
+ export function isTransientServer5xx(stderr: string): boolean {
1304
+ return /HTTP 5\d\d\b/i.test(stderr);
1305
+ }
1306
+
1307
+ export function isTransientServerError(err: unknown): boolean {
1308
+ return err instanceof GhError && isTransientServer5xx(err.stderr);
1309
+ }
1310
+
1311
+ /**
1312
+ * True when a `gh` argv rides the GraphQL API surface rather than core REST.
1313
+ *
1314
+ * `gh api graphql` is the raw surface. The named subcommands ride GraphQL
1315
+ * mutations in the gh version this fleet pins (2.86): `issue edit`
1316
+ * (`addLabelsToLabelable` / `removeLabelsFromLabelable` / `updateIssue`),
1317
+ * `issue comment` (`addComment`), `issue close` (`closeIssue`), `pr create`
1318
+ * (`createPullRequest`), `pr edit` (`updatePullRequest`), `pr merge`
1319
+ * (`mergePullRequest`) and `pr update-branch` (`updatePullRequestBranch`).
1320
+ * gh's transports move between versions, which is why this list is pinned and
1321
+ * tested rather than guessed: a subcommand that stops riding GraphQL only
1322
+ * stops being gated — it never stops working.
1323
+ *
1324
+ * Everything else the tracker and the verb actions shell — `gh api
1325
+ * repos/...`, `gh pr diff`, `gh release create` — is core REST and must keep
1326
+ * running while the GraphQL breaker is open: #642 measured REST healthy
1327
+ * through the whole outage, and the ready-queue read and settle sweeps run on
1328
+ * it.
1329
+ */
1330
+ export function isGraphqlSurface(argv: readonly string[]): boolean {
1331
+ if (argv[0] === "api") return argv[1] === "graphql";
1332
+ if (argv[0] === "issue") {
1333
+ return argv[1] === "edit" || argv[1] === "comment" || argv[1] === "close";
1334
+ }
1335
+ if (argv[0] === "pr") {
1336
+ return argv[1] === "create" || argv[1] === "edit" || argv[1] === "merge" || argv[1] === "update-branch";
1337
+ }
1338
+ return false;
1339
+ }
1340
+
1341
+ /**
1342
+ * The process-local transient-server-error breaker for the GraphQL surface
1343
+ * (#642). One instance is shared by the tracker and the verb actions of a
1344
+ * project, so a 503 observed by either side gates both: admission's
1345
+ * `parentOf` / `openCloserFor` checks AND the orchestrator mutation commands
1346
+ * that ride GraphQL in gh 2.86 (`gh issue edit`, `gh pr merge`,
1347
+ * `gh pr update-branch`, ...) fast-fail instead of each spawning `gh` into
1348
+ * the same unavailable surface.
1349
+ *
1350
+ * Deliberately separate from the rate-limit breaker: a 5xx is a provider
1351
+ * outage, not budget exhaustion, and must not fire `onRefusal` or touch the
1352
+ * `gh_refusals` ledger. The cooldown is the same fixed window, because gh's
1353
+ * stderr carries no `Retry-After` for either class.
1354
+ */
1355
+ export class GraphqlBreaker {
1356
+ private refusedUntil = 0;
1357
+
1358
+ constructor(private readonly cooldownMs: number = RATE_LIMIT_COOLDOWN_MS) {}
1359
+
1360
+ /** Record a transient 5xx: every GraphQL-surface call fast-fails until the
1361
+ * bounded cooldown elapses. */
1362
+ open(at = Date.now()): void {
1363
+ this.refusedUntil = at + this.cooldownMs;
1364
+ }
1365
+
1366
+ /** Whether a GraphQL-surface call must fast-fail right now. */
1367
+ refused(at = Date.now()): boolean {
1368
+ return at < this.refusedUntil;
1369
+ }
1370
+
1371
+ /** Wall-clock ms after which a real call may be attempted again; 0 = closed. */
1372
+ get until(): number {
1373
+ return this.refusedUntil;
1374
+ }
1375
+ }
1376
+
1377
+ /** The fast-fail wording both the tracker's throw and the verb actions'
1378
+ * failed result carry, so a refusal reads the same from either side. */
1379
+ export function graphqlBreakerRefusal(untilMs: number): string {
1380
+ return `circuit breaker open (a prior GitHub server error holds the GraphQL cooldown until ${new Date(untilMs).toISOString()})`;
1381
+ }
1382
+
975
1383
  /**
976
1384
  * A rate-limit refusal rethrown by {@link makeTracker}. This is the one
977
1385
  * failure class the tracker names instead of swallowing: the circuit breaker
@@ -990,7 +1398,9 @@ export class GhRateLimitError extends GhError {
990
1398
  }
991
1399
  }
992
1400
 
993
- /** How long a rate-limit refusal holds the circuit breaker open. */
1401
+ /** How long a rate-limit refusal or a transient server 5xx holds the tracker's
1402
+ * circuit breaker open. One window bounds both breakers: the all-call gate a
1403
+ * rate-limit refusal opens and the GraphQL-only gate a 5xx opens. */
994
1404
  export const RATE_LIMIT_COOLDOWN_MS = 60_000;
995
1405
 
996
1406
  /** Instrumentation hooks the daemon binds the tracker to (#198). */
@@ -998,7 +1408,10 @@ export interface TrackerHooks {
998
1408
  /** Fired immediately before each `gh` spawn, so the daemon can count its own
999
1409
  * API spend. Not fired for a breaker fast-fail, which spawns nothing. */
1000
1410
  onCall?: () => void;
1001
- /** Fired once per observed refusal, with the wall-clock moment. */
1411
+ /** Fired once per observed rate-limit refusal, with the wall-clock moment.
1412
+ * A transient server 5xx opens the GraphQL breaker but is not a rate-limit
1413
+ * refusal and does not fire this hook (#642): the `gh_refusals` ledger and
1414
+ * the rate-limit decisions that read it stay about rate limits. */
1002
1415
  onRefusal?: (at: number) => void;
1003
1416
  /** Fired once per list page GitHub answered 304 for — a spawn that cost no
1004
1417
  * primary-rate-limit budget. Lets the daemon's call counter tell billed
@@ -1007,8 +1420,13 @@ export interface TrackerHooks {
1007
1420
  }
1008
1421
 
1009
1422
  interface TrackerOpts {
1010
- /** Override {@link RATE_LIMIT_COOLDOWN_MS} (tests use a 0ms window). */
1423
+ /** Override {@link RATE_LIMIT_COOLDOWN_MS}, the shared window for both
1424
+ * breakers (tests use a 0ms window). */
1011
1425
  rateLimitCooldownMs?: number;
1426
+ /** The project's shared transient-server-error breaker (#642). Absent, the
1427
+ * tracker owns a fresh one; the daemon passes the same instance to the
1428
+ * verb actions so a 503 observed by either side gates both. */
1429
+ graphqlBreaker?: GraphqlBreaker;
1012
1430
  /** Conditional-request cache for REST list reads; defaults to the shared
1013
1431
  * module-level cache. Tests pass a fresh Map. */
1014
1432
  listCache?: RestListCache;
@@ -1024,15 +1442,39 @@ export function makeTracker(
1024
1442
  const refuseForMs = opts.rateLimitCooldownMs ?? RATE_LIMIT_COOLDOWN_MS;
1025
1443
  const cache = opts.listCache ?? sharedRestListCache;
1026
1444
 
1027
- // The single funnel around the injected `gh`. A caught refusal records the
1028
- // user-observed moment, opens the 60s breaker and rethrows named; while the
1029
- // breaker is open every call fails fast WITHOUT spawning gh a burst under a
1030
- // persistent limit degrades the tick into holds (`open-pr-lookup-error` /
1031
- // `parent-lookup-error` fail closed per candidate and retry next tick)
1032
- // instead of an error storm or a stalled loop. No in-wrapper sleeps.
1445
+ // The single funnel around the injected `gh`, with two bounded breakers:
1446
+ //
1447
+ // - A rate-limit refusal is account-wide every budget the token can spend
1448
+ // (REST and GraphQL) is exhausted or throttled so it opens a breaker
1449
+ // that fast-fails EVERY call.
1450
+ // - A transient server 5xx is surface-specific. #642 measured a GraphQL 503
1451
+ // that admission then re-asked once per candidate (`parentOf` /
1452
+ // `openCloserFor`), multiplying one outage into O(queue size) spawns,
1453
+ // TLS handshakes, failures and latency per pass while REST stayed
1454
+ // healthy. It opens a breaker that fast-fails GraphQL calls only — every
1455
+ // GraphQL-surface call, tracker reads AND the mutation commands the
1456
+ // orchestrator verbs shell (`gh issue edit`, `gh pr merge`, ...), because
1457
+ // both ride the same broken surface — so the ready-queue read and the
1458
+ // settle sweeps keep running and the pass still degrades into holds
1459
+ // instead of dying.
1460
+ //
1461
+ // The GraphQL breaker is shared with the verb actions through the daemon
1462
+ // (#642 comment 4): a 503 observed while serving `conductor_pr_update_branch`
1463
+ // must gate the next admission pass too, and vice versa — one provider
1464
+ // outage must not be re-asked by every candidate AND every mutation.
1465
+ //
1466
+ // While a breaker is open every gated call fails fast WITHOUT spawning gh —
1467
+ // a burst under a persistent outage degrades the tick into holds
1468
+ // (`open-pr-lookup-error` / `parent-lookup-error` fail closed per candidate
1469
+ // and retry next tick) instead of an error storm or a stalled loop. No
1470
+ // in-wrapper sleeps. A 5xx is not a rate-limit refusal: it never fires
1471
+ // `onRefusal`, so the `gh_refusals` ledger and the rate-limit decisions
1472
+ // that read it are untouched by a provider outage.
1473
+ const graphqlBreaker = opts.graphqlBreaker ?? new GraphqlBreaker(refuseForMs);
1033
1474
  let refusedUntil = 0;
1034
1475
  const runGh = async (argv: string[], stdin?: string): Promise<string> => {
1035
1476
  const now = Date.now();
1477
+ const graphql = isGraphqlSurface(argv);
1036
1478
  if (now < refusedUntil) {
1037
1479
  throw new GhRateLimitError(
1038
1480
  argv,
@@ -1041,6 +1483,9 @@ export function makeTracker(
1041
1483
  refusedUntil,
1042
1484
  );
1043
1485
  }
1486
+ if (graphql && graphqlBreaker.refused(now)) {
1487
+ throw new GhError(argv, 0, graphqlBreakerRefusal(graphqlBreaker.until));
1488
+ }
1044
1489
  hooks.onCall?.();
1045
1490
  try {
1046
1491
  return await injectedRunGh(argv, stdin);
@@ -1058,6 +1503,14 @@ export function makeTracker(
1058
1503
  cause?.stdout ?? "",
1059
1504
  );
1060
1505
  }
1506
+ // A transient 5xx on the GraphQL surface proves THAT surface unavailable
1507
+ // for this pass: the next candidate's parent/closer checks AND the next
1508
+ // GraphQL-backed mutation must fail fast instead of re-asking it. The
1509
+ // original error is rethrown unchanged, so the failing candidate holds
1510
+ // on the true provider message.
1511
+ if (graphql && isTransientServerError(err)) {
1512
+ graphqlBreaker.open();
1513
+ }
1061
1514
  throw err;
1062
1515
  }
1063
1516
  };
@@ -1307,6 +1760,98 @@ export function makeTracker(
1307
1760
  }
1308
1761
  },
1309
1762
 
1763
+ async runFailedAttemptLogs(
1764
+ repo: string,
1765
+ runUrl: string,
1766
+ maxAttempts: number,
1767
+ ): Promise<string[] | undefined> {
1768
+ // The attempt-blind Tracker surface cannot reach a failed job that a
1769
+ // rerun-to-green left behind; the attempt-scoped jobs register still
1770
+ // names it. Routed through this adapter's guarded `runGh`, so call/
1771
+ // refusal accounting and the rate-limit breaker apply to the historical
1772
+ // reconciliation's reads exactly as they do to a live tick's (#638).
1773
+ const match =
1774
+ /^https:\/\/github\.com\/([^/\s]+\/[^/\s]+)\/actions\/runs\/(\d+)(?:\/job\/\d+)?/.exec(
1775
+ runUrl,
1776
+ );
1777
+ const runId = match?.[2];
1778
+ if (runId === undefined || !/^[^/\s]+\/[^/\s]+$/.test(repo)) return [];
1779
+ let latest: number;
1780
+ try {
1781
+ const raw = await runGh([
1782
+ "api",
1783
+ `repos/${repo}/actions/runs/${runId}`,
1784
+ "--jq",
1785
+ ".run_attempt",
1786
+ ]);
1787
+ latest = Number.parseInt(raw.trim(), 10);
1788
+ } catch {
1789
+ // The run register could not be read: a no-mutation refusal the next
1790
+ // pass asks again, not a determinately-clean answer.
1791
+ return undefined;
1792
+ }
1793
+ if (!Number.isInteger(latest) || latest <= 0) return undefined;
1794
+ // Refuse rather than classify a prefix as the complete attempt history:
1795
+ // infra evidence in attempts 1..N-1 must not waive a row whose later
1796
+ // attempt carries the real compile/test failure (review #654).
1797
+ if (latest > maxAttempts) return undefined;
1798
+ const chunks: string[] = [];
1799
+ for (let attempt = 1; attempt <= latest; attempt++) {
1800
+ // One chunk per failed job, not one aggregate per attempt: a setup
1801
+ // job's codeload 429 in the same attempt as a sibling matrix job's
1802
+ // compile error must surface as two chunks so the caller's all-infra
1803
+ // guard sees the mixed failure (review #654). Each chunk is the
1804
+ // failed steps of that one job at that attempt — the exact surface
1805
+ // the forward classifier's `checkLog` reads (`--log-failed`), so a
1806
+ // repaired row and a fresh row share one definition of "the log
1807
+ // proves infra". Full and untruncated: a truncated last-400-lines
1808
+ // view can discard contrary failure evidence.
1809
+ let failedJobIds: number[];
1810
+ try {
1811
+ const raw = await runGh([
1812
+ "api",
1813
+ `repos/${repo}/actions/runs/${runId}/attempts/${attempt}/jobs`,
1814
+ "--jq",
1815
+ '[.jobs[] | select(.conclusion == "action_required" or .conclusion == "failure" or .conclusion == "startup_failure" or .conclusion == "timed_out") | .id]',
1816
+ ]);
1817
+ failedJobIds = JSON.parse(raw) as number[];
1818
+ } catch {
1819
+ // The attempt's job register could not be read: undecided, never a
1820
+ // clean answer assembled from whatever earlier attempts yielded.
1821
+ return undefined;
1822
+ }
1823
+ for (const jobId of failedJobIds) {
1824
+ try {
1825
+ const raw = await runGh([
1826
+ "run",
1827
+ "view",
1828
+ runId,
1829
+ "--repo",
1830
+ repo,
1831
+ "--job",
1832
+ String(jobId),
1833
+ "--attempt",
1834
+ String(attempt),
1835
+ "--log-failed",
1836
+ ]);
1837
+ const cleaned = raw.replaceAll(/\u001b\[[0-9;]*m/g, "");
1838
+ // Push even an empty chunk: a failed job whose failed steps carry
1839
+ // no signature is determinately not infrastructure, and omitting
1840
+ // it would let a sibling 429 chunk waive the row on partial
1841
+ // evidence.
1842
+ chunks.push(cleaned);
1843
+ } catch {
1844
+ // Any per-attempt log read failure is a no-mutation refusal: the
1845
+ // earlier chunks are not the complete evidence set, and returning
1846
+ // them would let a later compile/test failure go unseen (review
1847
+ // #654).
1848
+ return undefined;
1849
+ }
1850
+ }
1851
+ }
1852
+ return chunks;
1853
+ },
1854
+
1310
1855
  async previousWorkflowRun(
1311
1856
  repo: string,
1312
1857
  workflowId: number,
@@ -1370,9 +1915,13 @@ export function makeTracker(
1370
1915
  return verification;
1371
1916
  }
1372
1917
  } catch {
1373
- // Lookup or payload failure is not permission to accept success. The
1374
- // daemon records a pending row and asks again on a later tick.
1375
- return undefined;
1918
+ // The primary read is GraphQL (`gh pr view ... statusCheckRollup`).
1919
+ // When that plane is degraded (#653), the verification port is not
1920
+ // single-transport: re-prove the exact head and the complete check
1921
+ // verdict from REST, or report the head unresolvable. A REST read that
1922
+ // cannot prove green never weakens the contract — it only replaces one
1923
+ // refusal with the same refusal reported as unresolvable.
1924
+ return verifyPrRest(runGh, cache, url, expectedHead, opts, hooks.onNotModified);
1376
1925
  }
1377
1926
  },
1378
1927