mergestorm 0.3.38 → 0.3.40

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.
@@ -57,7 +57,7 @@ export const VORTEX_REVIEW_OUT_OF_QUOTA = "Review skipped, out of review quota";
57
57
  export const SEAM_REVIEW_STUCK = "Seam review stuck in reviewing, no review running";
58
58
  export const SEAM_REREVIEW_NOT_RUNNING = "Seam re-review pending, no review running";
59
59
  export const SEAM_VERDICT_STALE = "Seam verdict is for an older head, no review running";
60
- const STUCK_SEAM_BLOCKERS = [SEAM_REVIEW_STUCK, SEAM_REREVIEW_NOT_RUNNING, SEAM_VERDICT_STALE];
60
+ const STUCK_SEAM_REASONS = ["seam_review_stuck", "seam_rereview_not_running", "seam_verdict_stale"];
61
61
  function coreReviewDoneAtHead(layer) {
62
62
  const review = layer.vortexReview;
63
63
  return review?.status === "done" && sameHead(review.head_sha, layer.headSha);
@@ -79,6 +79,13 @@ const CYCLONE_HANDOFF_WORDS = {
79
79
  stack_auto_patch_off: "auto-patch is off for this stack",
80
80
  account_auto_patch_off: "auto-patch is off for the account and this stack does not turn it on",
81
81
  cyclone_not_connected: "Cyclone is not connected to this repo",
82
+ infra_failure: "Cyclone could not start the patch: GitHub sign-in, comment load or checkout kept failing",
83
+ no_changes: "Cyclone found nothing to change for these findings",
84
+ credits_exhausted: "the account is out of standard credits this month",
85
+ no_billing: "the account has no billing set up",
86
+ unsupported_language: "the findings are in a language Cyclone does not patch",
87
+ policy_owner_changed: "the stack's owner changed while the patch was queued",
88
+ patch_job_lost: "Cyclone's patch job was lost before it finished",
82
89
  };
83
90
  function cycloneHandoffWords(reason) {
84
91
  return CYCLONE_HANDOFF_WORDS[reason] ?? reason.replaceAll("_", " ");
@@ -95,11 +102,21 @@ function quotaSkippedReview(layer) {
95
102
  export const VORTEX_AUTO_REVIEW_OFF = "Vortex auto-review is off, so this head was not reviewed";
96
103
  export const VORTEX_BILLING_SKIPPED = "Vortex skipped this head: it could not confirm billing";
97
104
  export const VORTEX_REVIEW_INCOMPLETE = "Vortex reviewed only part of this PR";
105
+ export const VORTEX_IGNORED_AUTHOR = "Vortex skipped this head: the PR author is on the ignored authors list";
106
+ export const VORTEX_REPO_DISABLED = "Vortex skipped this head: reviews are turned off for this repository";
107
+ export const VORTEX_RETRY_SCHEDULED = "Vortex hit a temporary error on this head and will retry by itself";
108
+ export const VORTEX_REVIEW_FAILED = "Review failed";
98
109
  const VORTEX_SKIP_BLOCKERS = {
99
110
  auto_review_off: VORTEX_AUTO_REVIEW_OFF,
100
111
  billing_blocked: VORTEX_BILLING_SKIPPED,
101
112
  billing_unavailable: VORTEX_BILLING_SKIPPED,
113
+ ignored_author: VORTEX_IGNORED_AUTHOR,
114
+ repo_disabled: VORTEX_REPO_DISABLED,
102
115
  };
116
+ export function vortexRetryScheduled(layer) {
117
+ const review = layer.vortexReview;
118
+ return review?.skip_reason?.trim() === "retry_scheduled" && sameHead(review.head_sha, layer.headSha);
119
+ }
103
120
  function vortexIdleSkipBlocker(layer) {
104
121
  if (layerAgentsBusy(layer).vortex)
105
122
  return null;
@@ -121,6 +138,12 @@ function vortexSkippedSteps(stack, layer, skipReason) {
121
138
  if (skipReason === "auto_review_off") {
122
139
  return `Vortex auto-review is off for ${stack.owner}/${stack.repo} or for this stack, so it skipped #${layer.prNumber} at ${head} and Auto land waits for a review that will not come. Do not patch or push for this. Tell the human: turning auto-review on (mg stack set ${stack.id} --auto-review on, or the account setting in the dashboard) queues a review of this head by itself. To review only this head and leave auto-review off, ${mention}.`;
123
140
  }
141
+ if (skipReason === "ignored_author") {
142
+ return `Vortex skipped #${layer.prNumber} at ${head} because the PR's author is on the account's ignored authors list, so Auto land waits for a review that will not come. Do not patch or push for this. To review this head once, ${mention}. To have this author's PRs reviewed by themselves, tell the human to remove the author from the ignored authors list in the Mergestorm dashboard settings.`;
143
+ }
144
+ if (skipReason === "repo_disabled") {
145
+ return `Vortex skipped #${layer.prNumber} at ${head} because reviews are turned off for ${stack.owner}/${stack.repo}, so Auto land waits for a review that will not come. Do not patch or push for this, and a mention will not run a review while the repository is off. Tell the human to turn the repository on in the Mergestorm dashboard; after that a push or a comment @mergestorm-vortex review on #${layer.prNumber} runs the review.`;
146
+ }
124
147
  if (skipReason === "incomplete") {
125
148
  return `Vortex reviewed only some of the files in #${layer.prNumber} at ${head} and left the rest unreviewed, so Auto land waits for full coverage. Do not patch or push for this. To review the remaining files, ${mention} (the Continue button on the stack is unrelated), or tell the human to do it; turning on automatic overflow reviews in the dashboard settings covers this for later heads.`;
126
149
  }
@@ -136,24 +159,26 @@ function seamGateBlocker(stack, layer) {
136
159
  if (!member || member.promotedHeadSha?.trim())
137
160
  return null;
138
161
  const seamState = member.seamState?.trim().toLowerCase();
162
+ const stale = { blocker: SEAM_VERDICT_STALE, reason: "seam_verdict_stale" };
139
163
  if (seamState === "failed")
140
- return "Seam review failed";
141
- if (seamState === "findings" && sameHead(member.seamReviewedSha, layer.headSha))
142
- return "Seam findings";
164
+ return { blocker: "Seam review failed", reason: "seam_failed" };
165
+ if (seamState === "findings" && sameHead(member.seamReviewedSha, layer.headSha)) {
166
+ return { blocker: "Seam findings", reason: "seam_findings" };
167
+ }
143
168
  if (layerAgentsBusy(layer).vortex)
144
169
  return null;
145
170
  if (seamState === "pending")
146
- return SEAM_REVIEW_NOT_RUNNING;
171
+ return { blocker: SEAM_REVIEW_NOT_RUNNING, reason: "seam_not_running" };
147
172
  if (seamState === "reviewing")
148
- return SEAM_REVIEW_STUCK;
173
+ return { blocker: SEAM_REVIEW_STUCK, reason: "seam_review_stuck" };
149
174
  if (seamState === "pending_rereview")
150
- return SEAM_REREVIEW_NOT_RUNNING;
175
+ return { blocker: SEAM_REREVIEW_NOT_RUNNING, reason: "seam_rereview_not_running" };
151
176
  if (!layer.headSha?.trim() || sameHead(member.seamReviewedSha, layer.headSha))
152
177
  return null;
153
178
  if (seamState === "findings")
154
- return SEAM_VERDICT_STALE;
179
+ return stale;
155
180
  if (seamState === "approved" && !coreReviewDoneAtHead(layer))
156
- return SEAM_VERDICT_STALE;
181
+ return stale;
157
182
  return null;
158
183
  }
159
184
  function headChecksAllGreen(layer) {
@@ -163,64 +188,58 @@ function headChecksAllGreen(layer) {
163
188
  }
164
189
  /** Shared live-fact precedence; bounce history is only passed for the candidate. */
165
190
  function layerAttention(stack, layer, queue, cursor) {
166
- const none = { blocker: null, bounceKind: null };
191
+ const none = { blocker: null, reason: null, bounceKind: null };
167
192
  if (stack.archivedAt)
168
193
  return none;
169
194
  const bounce = queue.filter((entry) => entry.state === "bounced")
170
195
  .sort((a, b) => (Date.parse(b.finishedAt ?? "") || 0) - (Date.parse(a.finishedAt ?? "") || 0))[0];
171
196
  const kind = bounce?.bounceDetail?.kind;
172
197
  const recoverable = kind === "head_moved" || kind === "must_consolidate";
173
- const hardBlock = (blocker) => ({ blocker, bounceKind: null });
198
+ const hardBlock = (blocker, reason) => ({ blocker, reason, bounceKind: null });
174
199
  if (layer) {
175
200
  if (layer.restackError && layer.state !== "restacking") {
176
201
  if (layer.restackError.kind === "rebase_conflict")
177
- return hardBlock("Conflict");
202
+ return hardBlock("Conflict", "restack_conflict");
178
203
  if (!restackRetryPending(layer.restackError, layer))
179
- return hardBlock(RESTACK_FAILED);
204
+ return hardBlock(RESTACK_FAILED, "restack_failed");
180
205
  }
181
206
  if (layer.state === "conflict")
182
- return hardBlock("Conflict");
207
+ return hardBlock("Conflict", "restack_conflict");
183
208
  if (layer.draft)
184
209
  return {
185
- blocker: "Draft PR", bounceKind: "pr_draft",
210
+ blocker: "Draft PR", reason: "draft_pr", bounceKind: "pr_draft",
186
211
  ...(kind === "pr_draft" && sameHead(bounce.bounceDetail?.headSha ?? bounce.verifyHeadSha, layer.headSha) &&
187
212
  (bounce.bounceDetail?.prNumber == null || bounce.bounceDetail.prNumber === layer.prNumber) ? { bounce } : {}),
188
213
  };
189
- if (boundDirty(layer))
190
- return hardBlock(`Merge conflicts${layer.parentBranch ? ` vs ${layer.parentBranch}` : ""}`);
214
+ if (boundDirty(layer)) {
215
+ return hardBlock(`Merge conflicts${layer.parentBranch ? ` vs ${layer.parentBranch}` : ""}`, "merge_conflict");
216
+ }
191
217
  if (layer.ciStatus === "failure" || (layer.checks?.failure ?? 0) > 0) {
192
218
  const name = layer.checks?.failingName?.trim();
193
- return hardBlock(`CI failed${name ? ` — ${name}` : ""}`);
219
+ return hardBlock(`CI failed${name ? ` — ${name}` : ""}`, "ci_failed");
220
+ }
221
+ if (layer.vortexStatus === "failed" && !vortexRetryScheduled(layer) &&
222
+ (!layer.vortexReview?.head_sha || sameHead(layer.vortexReview.head_sha, layer.headSha))) {
223
+ return hardBlock(VORTEX_REVIEW_FAILED, "vortex_failed");
194
224
  }
195
- if (layer.agentRuns?.some((run) => run.agent === "cyclone" && run.status === "failed" &&
196
- sameHead(run.sha, layer.headSha)))
197
- return hardBlock("Cyclone failed");
198
- const tempest = layer.agentRuns?.find((run) => run.agent === "tempest" &&
199
- run.status === "findings" && sameHead(run.sha, layer.headSha));
200
- if (tempest)
201
- return hardBlock(`Tempest ${tempest.status}`);
202
- if (layer.vortexStatus === "failed")
203
- return hardBlock("Review failed");
204
225
  if (quotaSkippedReview(layer))
205
- return hardBlock(VORTEX_REVIEW_OUT_OF_QUOTA);
226
+ return hardBlock(VORTEX_REVIEW_OUT_OF_QUOTA, "vortex_out_of_quota");
206
227
  const vortexSkip = vortexIdleSkipBlocker(layer);
207
228
  if (vortexSkip)
208
- return hardBlock(vortexSkip.blocker);
229
+ return hardBlock(vortexSkip.blocker, "vortex_skipped");
209
230
  const seam = seamGateBlocker(stack, layer);
210
231
  if (seam)
211
- return hardBlock(seam);
232
+ return hardBlock(seam.blocker, seam.reason);
212
233
  if (unitAbandonedUnder(stack, layer)) {
213
- return hardBlock(`${UNIT_ABANDONED_BLOCKER_PREFIX}${layer.parentBranch?.trim() || stack.trunkBranch}`);
234
+ return hardBlock(`${UNIT_ABANDONED_BLOCKER_PREFIX}${layer.parentBranch?.trim() || stack.trunkBranch}`, "unit_abandoned");
214
235
  }
215
236
  if (stack.unit?.landPrNumber === layer.prNumber) {
216
237
  const landing = stack.unit.landingBlockReason?.trim();
217
238
  if (landing && !landGateIsPending(landing))
218
- return hardBlock(landing);
219
- if (stack.unit.tempestLandStatus?.toLowerCase() === "failed")
220
- return hardBlock("Tempest failed");
239
+ return hardBlock(landing, "land_gate");
221
240
  }
222
241
  if (vortexFindingsLeftForPerson(stack, layer))
223
- return hardBlock(VORTEX_FINDINGS_NEED_PERSON);
242
+ return hardBlock(VORTEX_FINDINGS_NEED_PERSON, "vortex_findings_need_person");
224
243
  }
225
244
  if (!bounce || !kind || recoverable || !layer || queue.some((entry) => ["queued", "running", "waiting"].includes(entry.state)))
226
245
  return none;
@@ -233,7 +252,8 @@ function layerAttention(stack, layer, queue, cursor) {
233
252
  return none;
234
253
  if (kind === "ci_failure" && !bounce.bounceDetail?.batch && headChecksAllGreen(layer))
235
254
  return none;
236
- return { blocker: mergeQueueBounceLabel(bounce), bounceKind: kind, bounce };
255
+ const label = mergeQueueBounceLabel(bounce);
256
+ return { blocker: label, reason: "bounce", bounceKind: kind, bounce };
237
257
  }
238
258
  const UNIT_ABANDONED_BLOCKER_PREFIX = "Review unit abandoned: based on ";
239
259
  function unitAbandonedUnder(stack, layer) {
@@ -275,6 +295,7 @@ export const AUTO_LAND_WAIT_ATTENTION_MS = 45 * 60_000;
275
295
  export const AUTO_LAND_CI_UNREADABLE_ATTENTION_MS = 10 * 60_000;
276
296
  const AUTO_LAND_CI_UNREADABLE_PREFIX = "Mergestorm cannot read ";
277
297
  const AUTO_LAND_WAITED_PREFIX = "Auto land has waited ";
298
+ const AUTO_LAND_MEMBERS_REMAINING_PREFIX = "The land PR cannot be queued: ";
278
299
  const CI_UNREADABLE_WHAT = {
279
300
  check_runs_unreadable: "the check runs",
280
301
  commit_statuses_unreadable: "the commit statuses",
@@ -287,13 +308,7 @@ const CI_UNREADABLE_PERMISSION = {
287
308
  };
288
309
  const AUTO_LAND_WAIT_WORDS = {
289
310
  mergeability_unknown: "GitHub has not said whether this PR can merge into its base",
290
- tempest_pending: "Tempest's review of this head has not started",
291
- tempest_running: "Tempest's review of this head has not finished",
292
- tempest_trigger_failed: "Tempest's review of this head could not be started",
293
- tempest_stopped: "Tempest's review of this head was stopped",
294
- tempest_failed: "Tempest's review of this head broke without a result",
295
- tempest_findings: "Tempest reported findings on this head",
296
- cyclone_failed: "Cyclone's patch run failed on this head",
311
+ members_remaining: "a PR that is not a layer of this stack is still open on the stack branch",
297
312
  stack_dirty: "this layer is not clean (it has a conflict or needs a restack)",
298
313
  must_consolidate: "this PR's base is not the stack's land target, and the stack has no review unit to promote it into",
299
314
  missing_head: "GitHub returned no head commit for this PR",
@@ -348,6 +363,9 @@ function autoLandWaitOn(stack, layer) {
348
363
  return autoLandWaitLayer(stack)?.prNumber === layer.prNumber ? armedAutoLandWait(stack) : null;
349
364
  }
350
365
  export function autoLandWaitBlocker(stack, layer, nowMs) {
366
+ return autoLandWaitNamed(stack, layer, nowMs)?.blocker ?? null;
367
+ }
368
+ function autoLandWaitNamed(stack, layer, nowMs) {
351
369
  const wait = autoLandWaitOn(stack, layer);
352
370
  if (!wait)
353
371
  return null;
@@ -357,18 +375,32 @@ export function autoLandWaitBlocker(stack, layer, nowMs) {
357
375
  const minutes = Math.floor(waited / 60_000);
358
376
  switch (wait.reason) {
359
377
  case "ci_failure":
360
- return AUTO_LAND_CI_FAILED_BLOCKER;
378
+ return { blocker: AUTO_LAND_CI_FAILED_BLOCKER, reason: "ci_failed" };
361
379
  case "ci_pending":
362
380
  return waited >= AUTO_LAND_CI_PENDING_ATTENTION_MS
363
- ? `${AUTO_LAND_CI_PENDING_PREFIX}${minutes}m — a check on this head has not finished, so Auto land will not queue it`
381
+ ? {
382
+ blocker: `${AUTO_LAND_CI_PENDING_PREFIX}${minutes}m — a check on this head has not finished, so Auto land will not queue it`,
383
+ reason: "auto_land_ci_pending",
384
+ }
364
385
  : null;
386
+ case "members_remaining":
387
+ return {
388
+ blocker: `${AUTO_LAND_MEMBERS_REMAINING_PREFIX}${wait.detail ?? AUTO_LAND_WAIT_WORDS.members_remaining}`,
389
+ reason: "auto_land_members_remaining",
390
+ };
365
391
  case "vortex_not_green":
366
- return waited >= AUTO_LAND_VORTEX_WAIT_ATTENTION_MS
367
- ? `${AUTO_LAND_VORTEX_WAIT_PREFIX}${minutes}m — Auto land is waiting for an approving Vortex review at this head`
392
+ return waited >= AUTO_LAND_VORTEX_WAIT_ATTENTION_MS && !vortexRetryScheduled(layer)
393
+ ? {
394
+ blocker: `${AUTO_LAND_VORTEX_WAIT_PREFIX}${minutes}m — Auto land is waiting for an approving Vortex review at this head`,
395
+ reason: "auto_land_vortex_wait",
396
+ }
368
397
  : null;
369
398
  case "promote_merge_failed":
370
399
  return (wait.attempts ?? 0) >= AUTO_LAND_PROMOTE_FAILED_ATTEMPTS
371
- ? `${AUTO_LAND_PROMOTE_FAILED_PREFIX}${wait.attempts} times (merge_failed) — Auto land stopped retrying this head${wait.detail ? `: ${wait.detail}` : ""}`
400
+ ? {
401
+ blocker: `${AUTO_LAND_PROMOTE_FAILED_PREFIX}${wait.attempts} times (merge_failed) — Auto land stopped retrying this head${wait.detail ? `: ${wait.detail}` : ""}`,
402
+ reason: "auto_land_promote_failed",
403
+ }
372
404
  : null;
373
405
  default:
374
406
  break;
@@ -376,11 +408,14 @@ export function autoLandWaitBlocker(stack, layer, nowMs) {
376
408
  const unreadable = CI_UNREADABLE_WHAT[wait.reason];
377
409
  if (unreadable) {
378
410
  return ciUnreadableForGood(wait) || waited >= AUTO_LAND_CI_UNREADABLE_ATTENTION_MS
379
- ? `${AUTO_LAND_CI_UNREADABLE_PREFIX}${unreadable} on this head${wait.detail ? ` (${wait.detail})` : ""} — Auto land cannot tell whether CI passed`
411
+ ? {
412
+ blocker: `${AUTO_LAND_CI_UNREADABLE_PREFIX}${unreadable} on this head${wait.detail ? ` (${wait.detail})` : ""} — Auto land cannot tell whether CI passed`,
413
+ reason: "auto_land_ci_unreadable",
414
+ }
380
415
  : null;
381
416
  }
382
417
  return waited >= AUTO_LAND_WAIT_ATTENTION_MS
383
- ? `${AUTO_LAND_WAITED_PREFIX}${minutes}m: ${autoLandWaitWords(wait.reason)}`
418
+ ? { blocker: `${AUTO_LAND_WAITED_PREFIX}${minutes}m: ${autoLandWaitWords(wait.reason)}`, reason: "auto_land_waited" }
384
419
  : null;
385
420
  }
386
421
  export const MERGE_QUEUE_STALL_ATTENTION_MS = 45 * 60_000;
@@ -484,27 +519,25 @@ function landPrLiveParent(stack, layer) {
484
519
  const base = stack.unit?.landTarget?.trim() || layer.parentBranch?.trim();
485
520
  return base && !isMgParkBase(base) && base !== layer.branch ? base : null;
486
521
  }
487
- export function agentsCannotClear(blocker) {
488
- return blocker === "Conflict" || blocker === RESTACK_FAILED || blocker === "Draft PR" ||
489
- blocker === SEAM_REVIEW_NOT_RUNNING || blocker === VORTEX_REVIEW_OUT_OF_QUOTA ||
490
- blocker === VORTEX_AUTO_REVIEW_OFF || blocker === VORTEX_BILLING_SKIPPED ||
491
- blocker === VORTEX_REVIEW_INCOMPLETE ||
492
- STUCK_SEAM_BLOCKERS.includes(blocker) ||
493
- blocker.startsWith("Merge conflicts") || blocker.startsWith(UNIT_ABANDONED_BLOCKER_PREFIX);
494
- }
495
- const TEMPEST_RERUN_BLOCKER = /^(?:landing blocked:\s*)?tempest_(failed|stopped)\b/i;
496
- function isTempestFindingsBlocker(attention, bounce) {
497
- const blocker = attention.blocker.trim();
498
- const reason = bounce?.bounceDetail?.message?.trim();
499
- if (reason && TEMPEST_RERUN_BLOCKER.test(reason))
500
- return false;
501
- return attention.bounceKind === "tempest_findings" || /^Tempest findings\b/i.test(blocker) ||
502
- /^(?:landing blocked:\s*)?tempest_findings\b/i.test(blocker);
522
+ const AGENTS_CANNOT_CLEAR = [
523
+ "restack_conflict",
524
+ "restack_failed",
525
+ "draft_pr",
526
+ "merge_conflict",
527
+ "seam_not_running",
528
+ ...STUCK_SEAM_REASONS,
529
+ "vortex_out_of_quota",
530
+ "vortex_skipped",
531
+ "unit_abandoned",
532
+ ];
533
+ export function agentsCannotClear(reason) {
534
+ return AGENTS_CANNOT_CLEAR.includes(reason);
503
535
  }
504
536
  export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
505
537
  if (!attention || !layer || attention.prNumber !== layer.prNumber)
506
538
  return null;
507
- if (queueWait && attention.blocker.startsWith(MERGE_QUEUE_STALLED_PREFIX)) {
539
+ const reason = attention.reason;
540
+ if (queueWait && reason === "queue_stalled") {
508
541
  return {
509
542
  kind: "queue_stalled",
510
543
  prNumber: layer.prNumber,
@@ -516,8 +549,8 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
516
549
  };
517
550
  }
518
551
  const landPr = !!stack.unit?.branch && layer.prNumber === stack.unit.landPrNumber && layer.branch === stack.unit.branch;
519
- const restackConflict = attention.blocker === "Conflict" || attention.bounceKind === "restack_conflict";
520
- const mergeConflict = attention.blocker.startsWith("Merge conflicts");
552
+ const restackConflict = reason === "restack_conflict" || attention.bounceKind === "restack_conflict";
553
+ const mergeConflict = reason === "merge_conflict";
521
554
  if (restackConflict || mergeConflict) {
522
555
  const liveParent = landPr ? landPrLiveParent(stack, layer) : conflictLiveParent(stack, layer);
523
556
  return {
@@ -530,7 +563,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
530
563
  steps: conflictRepairSteps(layer.branch, liveParent),
531
564
  };
532
565
  }
533
- if (attention.blocker === RESTACK_FAILED && layer.restackError) {
566
+ if (reason === "restack_failed" && layer.restackError) {
534
567
  const liveParent = landPr ? landPrLiveParent(stack, layer) : conflictLiveParent(stack, layer);
535
568
  return {
536
569
  kind: "restack_failed",
@@ -542,7 +575,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
542
575
  steps: restackFailedRepairSteps(layer.branch, liveParent, layer.restackError),
543
576
  };
544
577
  }
545
- if (attention.blocker.startsWith(UNIT_ABANDONED_BLOCKER_PREFIX)) {
578
+ if (reason === "unit_abandoned") {
546
579
  const openPrCount = stack.layers.filter((candidate) => candidate.prNumber > 0 && !isTerminalLayer(candidate)).length;
547
580
  return {
548
581
  kind: "unit_abandoned",
@@ -552,11 +585,11 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
552
585
  steps: `The stack's review unit was abandoned when #${layer.prNumber}'s base on GitHub was changed off its mg-stack trunk, so Auto land cannot promote or land this stack. Ask the human whether to re-root it. ${openPrCount < 2 ? `With only one open PR, re-adopting #${layer.prNumber} will not create a review unit; close and resubmit it to create a fresh stack.` : `If yes, tell the human that mg stack adopt ${stack.owner}/${stack.repo}#${layer.prNumber} re-roots it on a new review unit.`} Do not retarget the PR base yourself.`,
553
586
  };
554
587
  }
555
- if (STUCK_SEAM_BLOCKERS.includes(attention.blocker)) {
588
+ if (STUCK_SEAM_REASONS.includes(reason)) {
556
589
  const member = stack.unit?.members?.find((entry) => entry.prNumber === layer.prNumber);
557
590
  const seamState = member?.seamState?.trim().toLowerCase() || "unknown";
558
591
  const reviewed = member?.seamReviewedSha?.trim();
559
- const what = attention.blocker === SEAM_VERDICT_STALE
592
+ const what = reason === "seam_verdict_stale"
560
593
  ? `The seam (integration) review verdict on #${layer.prNumber} (${seamState}) was made at ${reviewed ? reviewed.slice(0, 7) : "an older head"}, not the live head ${layer.headSha ?? "(unknown)"}, and no seam review is queued or running to replace it`
561
594
  : `The seam (integration) review of #${layer.prNumber} is ${seamState} but no seam review is queued or running at ${layer.headSha ?? "the live head"}`;
562
595
  return {
@@ -568,7 +601,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
568
601
  steps: `${what}, so Auto land cannot promote it. Do not patch or push for this. Tell the human to click Continue on the stack in the Mergestorm dashboard, which re-queues the seam review at the live head of ${layer.branch}; a new commit pushed to ${layer.branch} also queues one. Then keep watching.`,
569
602
  };
570
603
  }
571
- if (attention.blocker.startsWith(AUTO_LAND_OFF_BLOCKER_PREFIX) && attention.bounceKind) {
604
+ if (reason === "auto_land_off" && attention.bounceKind) {
572
605
  return {
573
606
  kind: "auto_land_off",
574
607
  prNumber: layer.prNumber,
@@ -578,48 +611,39 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
578
611
  steps: `The ${attention.bounceKind} bounce turned Auto land off, so nothing will enqueue this stack. Fix the cause of the bounce on ${layer.branch}, then turn Auto land back on (mg stack set ${stack.id} --auto-land on) or enqueue the stack.`,
579
612
  };
580
613
  }
581
- if (landPr && isTempestFindingsBlocker(attention, bounce)) {
582
- const findings = `gh api repos/${stack.owner}/${stack.repo}/issues/${layer.prNumber}/comments`;
614
+ if (reason === "vortex_failed") {
583
615
  return {
584
- kind: "tempest_findings",
616
+ kind: "vortex_failed",
585
617
  prNumber: layer.prNumber,
586
618
  headSha: layer.headSha ?? null,
587
619
  branch: layer.branch,
588
- findings,
589
- steps: `Tempest never patches, and Cyclone is not patching this land PR. Read the Tempest report on it (${findings}), verify each finding against ${layer.branch} at its live remote head, fix the real ones with the smallest patch on ${layer.branch}, run the tests, confirm the remote head is still ${layer.headSha ?? "the head you started from"}, then an ordinary git push (no force). Do not retarget the PR base.`,
620
+ detail: null,
621
+ steps: `Vortex's review of #${layer.prNumber} at ${layer.headSha ?? "its head"} failed and nothing will run it again by itself, so Auto land waits. There are no findings to fix. Run the review again: comment @mergestorm-vortex review on #${layer.prNumber} (gh pr comment ${layer.prNumber} --repo ${stack.owner}/${stack.repo} --body "@mergestorm-vortex review"); a new commit pushed to ${layer.branch} also queues one. If it fails again, tell the human.`,
590
622
  };
591
623
  }
592
- const rerunBlocker = attention.blocker.trim().match(TEMPEST_RERUN_BLOCKER) ??
593
- bounce?.bounceDetail?.message?.trim().match(TEMPEST_RERUN_BLOCKER);
594
- const rerun = landPr ? rerunBlocker : null;
595
- if (rerun) {
596
- const stoppedByPerson = rerun[1].toLowerCase() === "stopped";
597
- const comment = `gh pr comment ${layer.prNumber} --repo ${stack.owner}/${stack.repo} --body`;
598
- const head = layer.headSha ?? "its live head";
624
+ const vortexSkip = vortexIdleSkipBlocker(layer);
625
+ if (vortexSkip && reason === "vortex_skipped") {
599
626
  return {
600
- kind: "tempest_rerun",
627
+ kind: "vortex_skipped",
601
628
  prNumber: layer.prNumber,
602
629
  headSha: layer.headSha ?? null,
603
630
  branch: layer.branch,
604
- stoppedByPerson,
605
- steps: stoppedByPerson
606
- ? `Tempest's review of land PR #${layer.prNumber} at ${head} was stopped, so Auto land will not land it. There are no findings to fix: do not patch or push. Ask the human whether to run Tempest again. If they say yes, run ${comment} "@mergestorm-tempest review --force" and keep watching.`
607
- : `Tempest's review of land PR #${layer.prNumber} at ${head} broke before it produced a result, and Mergestorm already re-ran it the most times it will on its own. There are no findings to fix: do not patch or push. Run it once more with ${comment} "@mergestorm-tempest review" and keep watching. If it breaks again, tell the human.`,
631
+ skipReason: vortexSkip.skipReason,
632
+ steps: vortexSkippedSteps(stack, layer, vortexSkip.skipReason),
608
633
  };
609
634
  }
610
- const vortexSkip = vortexIdleSkipBlocker(layer);
611
- if (vortexSkip && attention.blocker === vortexSkip.blocker) {
635
+ const wait = autoLandWaitOn(stack, layer);
636
+ if (wait?.reason === "members_remaining" && reason === "auto_land_members_remaining") {
612
637
  return {
613
- kind: "vortex_skipped",
638
+ kind: "members_remaining",
614
639
  prNumber: layer.prNumber,
615
640
  headSha: layer.headSha ?? null,
616
641
  branch: layer.branch,
617
- skipReason: vortexSkip.skipReason,
618
- steps: vortexSkippedSteps(stack, layer, vortexSkip.skipReason),
642
+ detail: wait.detail,
643
+ steps: `Auto land will not send land PR #${layer.prNumber} to the merge queue while another PR is open on ${layer.branch} that is not a layer of this stack${wait.detail ? ` (${wait.detail})` : ""}. Nothing on ${layer.branch} needs a change: do not patch or push for this. Close that PR, retarget it to another base, or adopt it into the stack (mg stack adopt ${stack.owner}/${stack.repo}#<that PR>); a draft PR or a PR from a fork cannot be adopted, so close or retarget it. If the PR is not yours, tell the human. Auto land sends the land PR to the queue by itself once that PR is gone from ${layer.branch}.`,
619
644
  };
620
645
  }
621
- const wait = autoLandWaitOn(stack, layer);
622
- if (wait && attention.blocker.startsWith(AUTO_LAND_CI_PENDING_PREFIX)) {
646
+ if (wait && reason === "auto_land_ci_pending") {
623
647
  return {
624
648
  kind: "ci_pending",
625
649
  prNumber: layer.prNumber,
@@ -629,7 +653,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
629
653
  steps: `A check on ${layer.branch} at ${layer.headSha ?? "its head"} has been pending since ${wait.since}, and Auto land will not queue an unfinished head. List the head's checks (gh api repos/${stack.owner}/${stack.repo}/commits/${layer.headSha ?? "<sha>"}/check-runs, and gh run list --branch ${layer.branch}) and find the one that never finished. Re-run it (gh run rerun <run-id>) if it is stuck or was never picked up. If it is a required check that no workflow reports, or it fails again, tell the human. Push a fix only when the check is red for a reason in the code. Do not retarget the PR base.`,
630
654
  };
631
655
  }
632
- if (wait && attention.blocker.startsWith(AUTO_LAND_VORTEX_WAIT_PREFIX)) {
656
+ if (wait && reason === "auto_land_vortex_wait") {
633
657
  return {
634
658
  kind: "vortex_not_green",
635
659
  prNumber: layer.prNumber,
@@ -639,7 +663,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
639
663
  steps: `Auto land has waited since ${wait.since} for an approving Vortex review of ${layer.branch} at ${layer.headSha ?? "its head"}, and no Vortex run is working on it. Read the PR's latest Vortex review (gh api repos/${stack.owner}/${stack.repo}/pulls/${layer.prNumber}/reviews). If it has findings, verify and fix the real ones on ${layer.branch} with an ordinary push, or record a wrong one with mg dismiss as mergestorm-pr-loop describes. If no review exists at this head, or it failed or was skipped, tell the human: nothing re-reviews this head until they re-run the review or push a new head.`,
640
664
  };
641
665
  }
642
- if (wait && attention.blocker.startsWith(AUTO_LAND_CI_UNREADABLE_PREFIX)) {
666
+ if (wait && reason === "auto_land_ci_unreadable") {
643
667
  const what = CI_UNREADABLE_WHAT[wait.reason] ?? "the checks";
644
668
  const permission = CI_UNREADABLE_PERMISSION[wait.reason] ?? "Checks: read and Commit statuses: read";
645
669
  return {
@@ -652,7 +676,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
652
676
  steps: `GitHub has not let Mergestorm read ${what} for #${layer.prNumber} at ${layer.headSha ?? "its head"} since ${wait.since}${wait.detail ? ` (${wait.detail})` : ""}, so Auto land cannot tell whether CI passed and will not move this PR. Nothing on ${layer.branch} needs a change: do not patch or push for this. Tell the human: the Mergestorm GitHub App needs the permission ${permission} on ${stack.owner}/${stack.repo}. They open the app's installation on GitHub (Settings, then GitHub Apps or Installed GitHub Apps), accept the pending permission request, and check that ${stack.repo} is in the app's repository access. Auto land continues by itself once the read works. If GitHub itself is having an outage, the wait clears when it ends.`,
653
677
  };
654
678
  }
655
- if (wait && attention.blocker.startsWith(AUTO_LAND_WAITED_PREFIX)) {
679
+ if (wait && reason === "auto_land_waited") {
656
680
  return {
657
681
  kind: "auto_land_waiting",
658
682
  prNumber: layer.prNumber,
@@ -663,7 +687,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
663
687
  steps: `Auto land has been waiting on this stack since ${wait.since} because ${autoLandWaitWords(wait.reason)} (reason ${wait.reason}${wait.detail ? `: ${wait.detail}` : ""}), and nothing has changed that in the meantime. Run mg stack status ${stack.id} and read what it shows for #${layer.prNumber}. If it names something on ${layer.branch} that a commit fixes, fix it there with an ordinary push. Otherwise do not patch or push: tell the human what Auto land is waiting for and since when. Do not retarget the PR base.`,
664
688
  };
665
689
  }
666
- if (wait && attention.blocker.startsWith(AUTO_LAND_PROMOTE_FAILED_PREFIX)) {
690
+ if (wait && reason === "auto_land_promote_failed") {
667
691
  const attempts = wait.attempts ?? AUTO_LAND_PROMOTE_FAILED_ATTEMPTS;
668
692
  return {
669
693
  kind: "promote_failed",
@@ -675,7 +699,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
675
699
  steps: `Auto land tried to promote #${layer.prNumber} at ${layer.headSha ?? "its head"} ${attempts} times and GitHub refused the merge each time${wait.detail ? ` (${wait.detail})` : ""}, so it stopped retrying this head. Read why: gh api repos/${stack.owner}/${stack.repo}/pulls/${layer.prNumber} --jq '{mergeable, mergeable_state, base: .base.ref}', and the branch protection on that base. If the cause is in ${layer.branch}, fix it and push with an ordinary push: a new head is retried. If it is outside the PR (branch protection, permissions, a required review), tell the human, and that once it is fixed they can retry the promote by turning Auto land off and on (mg stack set ${stack.id} --auto-land off, then --auto-land on). Do not toggle Auto land yourself, and do not retarget the PR base.`,
676
700
  };
677
701
  }
678
- if (attention.blocker === VORTEX_FINDINGS_NEED_PERSON) {
702
+ if (reason === "vortex_findings_need_person") {
679
703
  const findings = `gh api repos/${stack.owner}/${stack.repo}/pulls/${layer.prNumber}/reviews`;
680
704
  const handoff = cycloneHandoffAtHead(stack, layer);
681
705
  return {
@@ -688,7 +712,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
688
712
  steps: `Vortex requested changes on ${layer.branch} at ${layer.headSha ?? "its live head"} and Cyclone left them for a person${handoff ? ` (${cycloneHandoffWords(handoff)})` : ""}, so nothing will patch them and Auto land waits. Read the newest Vortex review and its inline comments (${findings}), verify each finding against ${layer.branch} at its live remote head, fix the real ones with the smallest patch, run the tests, confirm the remote head is still ${layer.headSha ?? "the head you started from"}, then an ordinary git push (no force). If a finding is wrong, tell the human; mg dismiss ${stack.owner}/${stack.repo}#${layer.prNumber} --head <sha> --review <id> dismisses that review. Do not retarget the PR base.`,
689
713
  };
690
714
  }
691
- if (attention.blocker.startsWith("CI failed") || attention.bounceKind === "ci_failure") {
715
+ if (reason === "ci_failed" || attention.bounceKind === "ci_failure") {
692
716
  const failingCheck = bounce?.bounceDetail?.failingCheck?.trim() || layer.checks?.failingName?.trim() || null;
693
717
  return {
694
718
  kind: "ci_failure",
@@ -701,6 +725,44 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
701
725
  }
702
726
  return null;
703
727
  }
728
+ export const LAND_PR_MISSING_PREFIX = "No open land PR for ";
729
+ function landPrMissing(stack, candidate) {
730
+ const unit = stack.unit;
731
+ if (!unit || candidate || stack.archivedAt || unit.state === "landed" || unit.state === "abandoned")
732
+ return null;
733
+ if (stack.layers.some((layer) => !isTerminalLayer(layer)))
734
+ return null;
735
+ const landing = unit.landingBlockReason?.trim();
736
+ if (!landing || landGateIsPending(landing) || unit.landPr?.state === "merged")
737
+ return null;
738
+ const closed = unit.landPr && unit.landPr.prNumber > 0 ? unit.landPr : null;
739
+ const prNumber = unit.landPrNumber ?? closed?.prNumber ??
740
+ stack.layers.filter((layer) => layer.prNumber > 0).sort((a, b) => b.position - a.position)[0]?.prNumber;
741
+ if (prNumber == null || prNumber <= 0)
742
+ return null;
743
+ const headSha = closed?.headSha ?? null;
744
+ const target = unit.landTarget?.trim() || stack.landTarget;
745
+ const detail = landing.replace(/^landing blocked:\s*/i, "").replace(/^land_pr_unavailable:\s*/i, "").trim() || null;
746
+ const landPrRef = unit.landPrNumber ?? closed?.prNumber ?? null;
747
+ const restore = landPrRef != null
748
+ ? `restore it from the closed land PR's head (gh pr view ${landPrRef} --repo ${stack.owner}/${stack.repo} --json headRefOid,state, then git push origin <that sha>:refs/heads/${unit.branch}) and reopen #${landPrRef}`
749
+ : `restore it from the commit it pointed at (git push origin <sha>:refs/heads/${unit.branch})`;
750
+ return {
751
+ reason: "land_pr_missing",
752
+ attention: {
753
+ prNumber, headSha, bounceKind: null,
754
+ blocker: `${LAND_PR_MISSING_PREFIX}${unit.branch} — every layer is merged into it, but it cannot land in ${target} without one`,
755
+ },
756
+ repair: {
757
+ kind: "land_pr_unavailable",
758
+ prNumber,
759
+ headSha,
760
+ branch: unit.branch,
761
+ detail,
762
+ steps: `Every layer of this stack is merged into ${unit.branch}, but there is no open land PR from ${unit.branch} into ${target} and Mergestorm cannot open one${detail ? ` (${detail})` : ""}, so the stack cannot land. Nothing in the layers needs a change: do not patch or push for this. Check whether the stack branch still exists: git ls-remote origin refs/heads/${unit.branch}. If it is gone (deleting it makes GitHub close the land PR), tell the human: ${restore}, or close the stack if the work is no longer wanted. If the branch exists, tell the human that Mergestorm cannot open the land PR and give them the error above. Do not open a PR into ${target} yourself.`,
763
+ },
764
+ };
765
+ }
704
766
  /** Pair-gate attention plus live hard blocks on every other open layer. */
705
767
  export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now()) {
706
768
  const candidate = currentLayer(stack);
@@ -714,9 +776,11 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
714
776
  (boundDirty(layer) || layer.state === "conflict" ||
715
777
  (layer.state !== "restacking" && layer.restackError?.kind === "rebase_conflict"))).sort((a, b) => a.position - b.position)[0];
716
778
  let attention = null;
779
+ let attentionReason = null;
717
780
  let attentionLayer;
718
781
  let bounce;
719
782
  const issues = [];
783
+ const mergeStateUnknownPrs = new Set();
720
784
  const queueWait = stack.archivedAt ? null : mergeQueueWait(queue, nowMs);
721
785
  for (const layer of open) {
722
786
  const selected = layer.prNumber === candidate?.prNumber;
@@ -726,22 +790,26 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
726
790
  disarmed = disarmingBounce(stack, queue);
727
791
  if (disarmed) {
728
792
  const kind = disarmed.bounceDetail.kind;
729
- result = { blocker: `${AUTO_LAND_OFF_BLOCKER_PREFIX}${kind} bounce`, bounceKind: kind, bounce: disarmed };
793
+ result = { blocker: `${AUTO_LAND_OFF_BLOCKER_PREFIX}${kind} bounce`, reason: "auto_land_off", bounceKind: kind, bounce: disarmed };
730
794
  }
731
795
  }
732
796
  let waitNamed = false;
733
797
  if (!result.blocker) {
734
- const waiting = autoLandWaitBlocker(stack, layer, nowMs);
798
+ const waiting = autoLandWaitNamed(stack, layer, nowMs);
735
799
  if (waiting) {
736
- result = { blocker: waiting, bounceKind: null };
800
+ result = { ...waiting, bounceKind: null };
737
801
  waitNamed = true;
738
802
  }
739
803
  }
740
804
  if (!result.blocker && selected && queueWait?.stalled) {
741
- result = { blocker: `${MERGE_QUEUE_STALLED_PREFIX}${queueWait.text}`, bounceKind: null };
805
+ result = { blocker: `${MERGE_QUEUE_STALLED_PREFIX}${queueWait.text}`, reason: "queue_stalled", bounceKind: null };
742
806
  }
743
807
  if (!result.blocker) {
808
+ if (vortexRetryScheduled(layer) && (selected || !candidate || layer.position > candidate.position)) {
809
+ issues.push({ prNumber: layer.prNumber, headSha: layer.headSha ?? null, blocker: VORTEX_RETRY_SCHEDULED, bounceKind: null });
810
+ }
744
811
  if (mergeabilityPending(layer) && (selected || !candidate || layer.position > candidate.position)) {
812
+ mergeStateUnknownPrs.add(layer.prNumber);
745
813
  issues.push({ prNumber: layer.prNumber, headSha: layer.headSha ?? null,
746
814
  blocker: `Merge state unknown${layer.parentBranch ? ` vs ${layer.parentBranch}` : ""}`, bounceKind: null });
747
815
  }
@@ -750,9 +818,10 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
750
818
  const issue = { prNumber: layer.prNumber, headSha: layer.headSha ?? null,
751
819
  blocker: result.blocker, bounceKind: result.bounceKind };
752
820
  if (selected ||
753
- result.blocker.startsWith(UNIT_ABANDONED_BLOCKER_PREFIX) ||
821
+ result.reason === "unit_abandoned" ||
754
822
  (!attention && (layer === child || waitNamed))) {
755
823
  attention = issue;
824
+ attentionReason = result.reason;
756
825
  attentionLayer = layer;
757
826
  bounce = result.bounce;
758
827
  if (disarmed)
@@ -764,9 +833,10 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
764
833
  }
765
834
  if (!attention) {
766
835
  const unlisted = autoLandWaitLayer(stack);
767
- const waiting = unlisted && !open.includes(unlisted) ? autoLandWaitBlocker(stack, unlisted, nowMs) : null;
836
+ const waiting = unlisted && !open.includes(unlisted) ? autoLandWaitNamed(stack, unlisted, nowMs) : null;
768
837
  if (unlisted && waiting) {
769
- attention = { prNumber: unlisted.prNumber, headSha: unlisted.headSha ?? null, blocker: waiting, bounceKind: null };
838
+ attention = { prNumber: unlisted.prNumber, headSha: unlisted.headSha ?? null, blocker: waiting.blocker, bounceKind: null };
839
+ attentionReason = waiting.reason;
770
840
  attentionLayer = unlisted;
771
841
  }
772
842
  }
@@ -774,9 +844,18 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
774
844
  ? { prNumber: candidate.prNumber, headSha: candidate.headSha ?? null, text: queueWait.text,
775
845
  since: queueWait.since, waitReason: queueWait.waitReason }
776
846
  : null;
777
- const settleIssue = mergeabilitySettleIssue(stack, open, issues, attention);
847
+ const settleIssue = mergeabilitySettleIssue(stack, open, mergeStateUnknownPrs, attention);
778
848
  if (settleIssue)
779
849
  issues.push(settleIssue);
850
+ const landMissing = landPrMissing(stack, candidate);
851
+ if (landMissing) {
852
+ attention = landMissing.attention;
853
+ attentionReason = landMissing.reason;
854
+ attentionLayer = undefined;
855
+ }
856
+ const classified = attention && attentionReason
857
+ ? { ...attention, reason: attentionReason }
858
+ : null;
780
859
  const focus = attentionLayer ?? candidate;
781
860
  const agents = focus ? layerAgents(focus) : null;
782
861
  const busy = [];
@@ -793,25 +872,26 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
793
872
  const landGatePending = landLayer && landing && landGateIsPending(landing)
794
873
  ? { prNumber: landLayer.prNumber, headSha: landLayer.headSha ?? null, reason: landing }
795
874
  : null;
796
- if (busy.length && attention && agentsCannotClear(attention.blocker)) {
875
+ if (busy.length && attention && classified && agentsCannotClear(classified.reason)) {
797
876
  const held = { ...attention, actAfter: "agents_idle", waitingOn: busy.map((entry) => entry.agent) };
798
877
  return { attention: null, held, issues: [attention, ...issues], currentCandidate, bounce: undefined, busy, agents,
799
- repair: stackRepair(stack, attention, attentionLayer, undefined, queueWait), landGatePending, queueWait: queued };
878
+ repair: stackRepair(stack, classified, attentionLayer, undefined, queueWait), landGatePending, queueWait: queued };
800
879
  }
801
880
  if (busy.length) {
802
881
  return { attention: null, held: null, issues, currentCandidate, bounce: undefined, busy, agents, repair: null, landGatePending,
803
882
  queueWait: queued };
804
883
  }
805
884
  return { attention, held: null, issues, currentCandidate, bounce, busy, agents,
806
- repair: stackRepair(stack, attention, attentionLayer, bounce, queueWait), landGatePending, queueWait: queued };
885
+ repair: landMissing?.repair ?? stackRepair(stack, classified, attentionLayer, bounce, queueWait), landGatePending,
886
+ queueWait: queued };
807
887
  }
808
- function mergeabilitySettleIssue(stack, open, issues, attention) {
888
+ function mergeabilitySettleIssue(stack, open, mergeStateUnknownPrs, attention) {
809
889
  const settle = stack.autoEnqueueSettle;
810
890
  if (stack.archivedAt || settle?.action !== "mergeability")
811
891
  return null;
812
892
  if (attention?.prNumber === settle.prNumber)
813
893
  return null;
814
- if (issues.some((issue) => issue.prNumber === settle.prNumber && issue.blocker.startsWith("Merge state unknown")))
894
+ if (mergeStateUnknownPrs.has(settle.prNumber))
815
895
  return null;
816
896
  const layer = open.find((candidate) => candidate.prNumber === settle.prNumber);
817
897
  if (layer && !sameHead(layer.headSha, settle.headSha))