patchrelay 0.32.2 → 0.33.0

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "service": "patchrelay",
3
- "version": "0.32.2",
4
- "commit": "a1095c2643be",
5
- "builtAt": "2026-04-01T21:58:19.644Z"
3
+ "version": "0.33.0",
4
+ "commit": "90189d83b85f",
5
+ "builtAt": "2026-04-02T00:44:41.741Z"
6
6
  }
@@ -59,6 +59,17 @@ function buildPrStatusSummary(issue, issueContext) {
59
59
  else if (checkState) {
60
60
  summary.push(checkState);
61
61
  }
62
+ if (issue.prChecksSummary?.total) {
63
+ if (issue.prChecksSummary.failed > 0) {
64
+ summary.push(`${issue.prChecksSummary.failed}/${issue.prChecksSummary.total} checks failing`);
65
+ }
66
+ else if (issue.prChecksSummary.pending > 0) {
67
+ summary.push(`${issue.prChecksSummary.completed}/${issue.prChecksSummary.total} checks settled`);
68
+ }
69
+ else {
70
+ summary.push(`${issue.prChecksSummary.passed}/${issue.prChecksSummary.total} checks passed`);
71
+ }
72
+ }
62
73
  if (reviewState) {
63
74
  summary.push(`review ${reviewState}`);
64
75
  }
@@ -86,22 +97,31 @@ function resolvePrimaryBlocker(issue, issueContext) {
86
97
  color: "yellow",
87
98
  };
88
99
  }
89
- if (issue.prCheckStatus === "failed") {
90
- const failedCheck = issueContext?.latestFailureCheckName ?? issue.latestFailureCheckName;
100
+ if (issue.prCheckStatus === "failed" || issue.prCheckStatus === "failure") {
101
+ const failedChecks = issue.prChecksSummary?.failedNames ?? [];
102
+ const failedCheck = issueContext?.latestFailureCheckName
103
+ ?? issue.latestFailureCheckName
104
+ ?? (failedChecks.length > 0 ? failedChecks.slice(0, 2).join(", ") : undefined);
91
105
  return {
92
106
  text: failedCheck ? `Blocked by failed check: ${failedCheck}` : "Blocked by failed PR checks",
93
107
  color: "red",
94
108
  };
95
109
  }
110
+ if (issue.prCheckStatus === "pending" || issue.prCheckStatus === "in_progress" || issue.prCheckStatus === "queued") {
111
+ return { text: "Waiting for PR checks to finish", color: "yellow" };
112
+ }
96
113
  if (issue.prReviewState === "changes_requested") {
97
114
  return { text: "Blocked by requested review changes", color: "yellow" };
98
115
  }
99
- if (issue.prNumber !== undefined && !issue.prReviewState && issue.factoryState !== "done") {
100
- return { text: "Blocked pending review approval", color: "yellow" };
116
+ if (issue.factoryState === "repairing_queue") {
117
+ return { text: "Blocked by merge queue refresh failure", color: "yellow" };
101
118
  }
102
119
  if (issue.factoryState === "awaiting_queue") {
103
120
  return { text: "Waiting in merge queue", color: "yellow" };
104
121
  }
122
+ if (issue.prNumber !== undefined && !issue.prReviewState && issue.factoryState !== "done") {
123
+ return { text: "Blocked pending review approval", color: "yellow" };
124
+ }
105
125
  return null;
106
126
  }
107
127
  function ElapsedTime({ startedAt }) {
@@ -148,10 +148,10 @@ function buildChecksProgressChip(issue) {
148
148
  if (!summary || summary.total <= 0)
149
149
  return null;
150
150
  const text = summary.failed > 0
151
- ? `checks ${summary.completed}/${summary.total} failed`
151
+ ? `checks ${summary.failed}/${summary.total} failed`
152
152
  : summary.pending > 0
153
- ? `checks ${summary.completed}/${summary.total} running`
154
- : `checks ${summary.completed}/${summary.total} passed`;
153
+ ? `checks ${summary.completed}/${summary.total} settled`
154
+ : `checks ${summary.passed}/${summary.total} passed`;
155
155
  const color = summary.failed > 0 ? "red" : summary.pending > 0 ? "yellow" : "green";
156
156
  return { text, color };
157
157
  }
@@ -181,22 +181,31 @@ function buildPrimaryBlocker(issue) {
181
181
  color: "yellow",
182
182
  };
183
183
  }
184
- if (issue.prCheckStatus === "failed") {
185
- const failedCheck = issue.latestFailureCheckName ?? "PR checks";
184
+ if (issue.prCheckStatus === "failed" || issue.prCheckStatus === "failure") {
185
+ const failedChecks = issue.prChecksSummary?.failedNames ?? [];
186
+ const failedCheck = issue.latestFailureCheckName
187
+ ?? (failedChecks.length > 0 ? failedChecks.slice(0, 2).join(", ") : undefined)
188
+ ?? "PR checks";
186
189
  return {
187
190
  text: `${failedCheck} failed`,
188
191
  color: "red",
189
192
  };
190
193
  }
194
+ if (issue.prCheckStatus === "pending" || issue.prCheckStatus === "in_progress" || issue.prCheckStatus === "queued") {
195
+ return {
196
+ text: "Waiting for PR checks to finish",
197
+ color: "yellow",
198
+ };
199
+ }
191
200
  if (issue.prReviewState === "changes_requested") {
192
201
  return {
193
202
  text: "Review changes requested",
194
203
  color: "yellow",
195
204
  };
196
205
  }
197
- if (issue.prNumber !== undefined && !issue.prReviewState && issue.factoryState !== "done") {
206
+ if (issue.factoryState === "repairing_queue") {
198
207
  return {
199
- text: "Waiting for review approval",
208
+ text: "Merge queue reported a branch refresh failure",
200
209
  color: "yellow",
201
210
  };
202
211
  }
@@ -206,6 +215,12 @@ function buildPrimaryBlocker(issue) {
206
215
  color: "yellow",
207
216
  };
208
217
  }
218
+ if (issue.prNumber !== undefined && !issue.prReviewState && issue.factoryState !== "done") {
219
+ return {
220
+ text: "Waiting for review approval",
221
+ color: "yellow",
222
+ };
223
+ }
209
224
  return null;
210
225
  }
211
226
  function buildPipelineProgress(issue) {
package/dist/db.js CHANGED
@@ -484,6 +484,20 @@ export class PatchRelayDatabase {
484
484
  .all();
485
485
  return rows.map(mapIssueRow);
486
486
  }
487
+ /**
488
+ * Issues waiting in the merge queue with no active or pending run.
489
+ * Used by the queue health monitor to probe GitHub for stuck PRs.
490
+ */
491
+ listAwaitingQueueIssues() {
492
+ const rows = this.connection
493
+ .prepare(`SELECT * FROM issues
494
+ WHERE factory_state = 'awaiting_queue'
495
+ AND active_run_id IS NULL
496
+ AND pending_run_type IS NULL
497
+ AND pr_number IS NOT NULL`)
498
+ .all();
499
+ return rows.map(mapIssueRow);
500
+ }
487
501
  listIssuesByState(projectId, state) {
488
502
  const rows = this.connection
489
503
  .prepare("SELECT * FROM issues WHERE project_id = ? AND factory_state = ? ORDER BY pr_number ASC")
@@ -17,6 +17,12 @@ const DEFAULT_QUEUE_REPAIR_BUDGET = 3;
17
17
  const DEFAULT_REVIEW_FIX_BUDGET = 3;
18
18
  const DEFAULT_ZOMBIE_RECOVERY_BUDGET = 5;
19
19
  const ZOMBIE_RECOVERY_BASE_DELAY_MS = 15_000; // 15s, 30s, 60s, 120s, 240s
20
+ // Queue health monitor: wait before probing a freshly-queued PR.
21
+ // TODO: replace updatedAt with a true factory_state_changed_at timestamp —
22
+ // updatedAt can reset on unrelated row mutations (e.g. webhook metadata).
23
+ const QUEUE_HEALTH_GRACE_MS = 120_000;
24
+ // Suppress repeated probe-failure feed events — at most one per issue per window.
25
+ const QUEUE_HEALTH_PROBE_FAILURE_COOLDOWN_MS = 300_000; // 5 minutes
20
26
  function slugify(value) {
21
27
  return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60);
22
28
  }
@@ -98,6 +104,8 @@ export class RunOrchestrator {
98
104
  feed;
99
105
  worktreeManager;
100
106
  progressThrottle = new Map();
107
+ /** Tracks last probe-failure feed event per issue to avoid spamming the operator feed. */
108
+ probeFailureFeedTimes = new Map();
101
109
  activeThreadId;
102
110
  botIdentity;
103
111
  constructor(config, db, codex, linearProvider, enqueueIssue, logger, feed) {
@@ -555,10 +563,112 @@ export class RunOrchestrator {
555
563
  for (const run of this.db.listRunningRuns()) {
556
564
  await this.reconcileRun(run);
557
565
  }
566
+ // Preemptively detect stuck merge-queue PRs (conflicts visible on
567
+ // GitHub) and dispatch queue_repair before the Steward evicts.
568
+ await this.reconcileQueueHealth();
558
569
  // Advance issues stuck in pr_open whose stored PR metadata already
559
570
  // shows they should transition (e.g. approved PR, missed webhook).
560
571
  await this.reconcileIdleIssues();
561
572
  }
573
+ // ─── Queue Health Monitor ──────────────────────────────────────────
574
+ async reconcileQueueHealth() {
575
+ for (const issue of this.db.listAwaitingQueueIssues()) {
576
+ await this.probeQueuedIssue(issue);
577
+ }
578
+ }
579
+ async probeQueuedIssue(issue) {
580
+ if (!issue.prNumber)
581
+ return;
582
+ const project = this.config.projects.find((p) => p.id === issue.projectId);
583
+ if (!project?.github?.repoFullName)
584
+ return;
585
+ // Grace period — don't probe PRs that just entered the queue.
586
+ const age = Date.now() - Date.parse(issue.updatedAt);
587
+ if (age < QUEUE_HEALTH_GRACE_MS)
588
+ return;
589
+ const protocol = resolveMergeQueueProtocol(project);
590
+ let pr;
591
+ try {
592
+ const { stdout } = await execCommand("gh", [
593
+ "pr", "view", String(issue.prNumber),
594
+ "--repo", project.github.repoFullName,
595
+ "--json", "state,mergeable,mergeStateStatus,headRefOid,labels",
596
+ ], { timeoutMs: 10_000 });
597
+ pr = JSON.parse(stdout);
598
+ }
599
+ catch (error) {
600
+ this.logger.debug({ issueKey: issue.issueKey, prNumber: issue.prNumber, error: error instanceof Error ? error.message : String(error) }, "Queue health: failed to probe GitHub PR state");
601
+ // Throttle feed events — at most one per issue per cooldown window.
602
+ const issueKey = `${issue.projectId}::${issue.linearIssueId}`;
603
+ const lastFeedAt = this.probeFailureFeedTimes.get(issueKey) ?? 0;
604
+ if (Date.now() - lastFeedAt >= QUEUE_HEALTH_PROBE_FAILURE_COOLDOWN_MS) {
605
+ this.probeFailureFeedTimes.set(issueKey, Date.now());
606
+ this.feed?.publish({
607
+ level: "info",
608
+ kind: "github",
609
+ issueKey: issue.issueKey,
610
+ projectId: issue.projectId,
611
+ stage: "awaiting_queue",
612
+ status: "queue_health_probe_failed",
613
+ summary: `Queue health: failed to probe PR #${issue.prNumber}`,
614
+ });
615
+ }
616
+ return;
617
+ }
618
+ // Successful probe — clear any probe-failure throttle for this issue.
619
+ this.probeFailureFeedTimes.delete(`${issue.projectId}::${issue.linearIssueId}`);
620
+ // Missed merge webhook — advance to done.
621
+ if (pr.state === "MERGED") {
622
+ this.db.upsertIssue({ projectId: issue.projectId, linearIssueId: issue.linearIssueId, prState: "merged" });
623
+ this.advanceIdleIssue(issue, "done", { clearFailureProvenance: true });
624
+ return;
625
+ }
626
+ // Non-open PRs (closed, draft) — don't enter repair logic.
627
+ if (pr.state !== "OPEN")
628
+ return;
629
+ // Verify admission label is still present — if the Steward removed it
630
+ // (eviction, dequeue) but PatchRelay missed the webhook, we should not
631
+ // treat a DIRTY PR as a queue-health problem.
632
+ const hasQueueLabel = pr.labels?.some((l) => l.name === protocol.admissionLabel) ?? false;
633
+ if (!hasQueueLabel)
634
+ return;
635
+ // Conflict detected — dispatch preemptive queue repair.
636
+ if (pr.mergeStateStatus === "DIRTY" || pr.mergeable === "CONFLICTING") {
637
+ const headRefOid = pr.headRefOid ?? "unknown";
638
+ // TODO: include baseSha in signature (headRefOid + baseSha) so that a
639
+ // main-only advance with the same PR head is recognized as a new conflict.
640
+ const signature = `preemptive_queue_conflict:${headRefOid}`;
641
+ const pendingRunContext = {
642
+ source: "queue_health_monitor",
643
+ failureReason: "preemptive_conflict",
644
+ failureHeadSha: headRefOid,
645
+ failureSignature: signature,
646
+ };
647
+ if (isDuplicateRepairAttempt(issue, pendingRunContext)) {
648
+ return;
649
+ }
650
+ this.db.upsertIssue({
651
+ projectId: issue.projectId,
652
+ linearIssueId: issue.linearIssueId,
653
+ lastAttemptedFailureHeadSha: headRefOid,
654
+ lastAttemptedFailureSignature: signature,
655
+ });
656
+ this.advanceIdleIssue(issue, "repairing_queue", {
657
+ pendingRunType: "queue_repair",
658
+ pendingRunContext,
659
+ });
660
+ this.logger.info({ issueKey: issue.issueKey, prNumber: issue.prNumber, headRefOid }, "Queue health: merge conflict detected, dispatching preemptive repair");
661
+ this.feed?.publish({
662
+ level: "warn",
663
+ kind: "github",
664
+ issueKey: issue.issueKey,
665
+ projectId: issue.projectId,
666
+ stage: "repairing_queue",
667
+ status: "queue_health_conflict_detected",
668
+ summary: `Queue health: merge conflict detected on PR #${issue.prNumber}, dispatching preemptive repair`,
669
+ });
670
+ }
671
+ }
562
672
  async reconcileIdleIssues() {
563
673
  for (const issue of this.db.listIdleNonTerminalIssues()) {
564
674
  // PR already merged — advance to done regardless of current state
package/dist/service.js CHANGED
@@ -35,22 +35,26 @@ function extractStatusNote(summaryJson, reportJson) {
35
35
  }
36
36
  return undefined;
37
37
  }
38
- function parseCiSnapshotSummary(snapshotJson) {
38
+ export function parseCiSnapshotSummary(snapshotJson) {
39
39
  if (!snapshotJson)
40
40
  return undefined;
41
41
  try {
42
42
  const snapshot = JSON.parse(snapshotJson);
43
- const checks = Array.isArray(snapshot.checks) ? snapshot.checks : [];
43
+ const rawChecks = Array.isArray(snapshot.checks) ? snapshot.checks : [];
44
+ const checks = collapseEffectiveChecks(rawChecks);
44
45
  if (checks.length === 0)
45
46
  return undefined;
46
47
  let passed = 0;
47
48
  let failed = 0;
48
49
  let pending = 0;
50
+ const failedNames = [];
49
51
  for (const check of checks) {
50
52
  if (check.status === "success")
51
53
  passed++;
52
- else if (check.status === "failure")
54
+ else if (check.status === "failure") {
53
55
  failed++;
56
+ failedNames.push(check.name);
57
+ }
54
58
  else
55
59
  pending++;
56
60
  }
@@ -61,12 +65,23 @@ function parseCiSnapshotSummary(snapshotJson) {
61
65
  failed,
62
66
  pending,
63
67
  overall: snapshot.gateCheckStatus,
68
+ ...(failedNames.length > 0 ? { failedNames } : {}),
64
69
  };
65
70
  }
66
71
  catch {
67
72
  return undefined;
68
73
  }
69
74
  }
75
+ function collapseEffectiveChecks(checks) {
76
+ const effective = new Map();
77
+ for (const check of checks) {
78
+ const name = typeof check?.name === "string" ? check.name.trim() : "";
79
+ if (!name || effective.has(name))
80
+ continue;
81
+ effective.set(name, check);
82
+ }
83
+ return [...effective.values()];
84
+ }
70
85
  export class PatchRelayService {
71
86
  config;
72
87
  db;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "patchrelay",
3
- "version": "0.32.2",
3
+ "version": "0.33.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "repository": {