mergestorm 0.3.39 → 0.3.41

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);
@@ -159,24 +159,26 @@ function seamGateBlocker(stack, layer) {
159
159
  if (!member || member.promotedHeadSha?.trim())
160
160
  return null;
161
161
  const seamState = member.seamState?.trim().toLowerCase();
162
+ const stale = { blocker: SEAM_VERDICT_STALE, reason: "seam_verdict_stale" };
162
163
  if (seamState === "failed")
163
- return "Seam review failed";
164
- if (seamState === "findings" && sameHead(member.seamReviewedSha, layer.headSha))
165
- 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
+ }
166
168
  if (layerAgentsBusy(layer).vortex)
167
169
  return null;
168
170
  if (seamState === "pending")
169
- return SEAM_REVIEW_NOT_RUNNING;
171
+ return { blocker: SEAM_REVIEW_NOT_RUNNING, reason: "seam_not_running" };
170
172
  if (seamState === "reviewing")
171
- return SEAM_REVIEW_STUCK;
173
+ return { blocker: SEAM_REVIEW_STUCK, reason: "seam_review_stuck" };
172
174
  if (seamState === "pending_rereview")
173
- return SEAM_REREVIEW_NOT_RUNNING;
175
+ return { blocker: SEAM_REREVIEW_NOT_RUNNING, reason: "seam_rereview_not_running" };
174
176
  if (!layer.headSha?.trim() || sameHead(member.seamReviewedSha, layer.headSha))
175
177
  return null;
176
178
  if (seamState === "findings")
177
- return SEAM_VERDICT_STALE;
179
+ return stale;
178
180
  if (seamState === "approved" && !coreReviewDoneAtHead(layer))
179
- return SEAM_VERDICT_STALE;
181
+ return stale;
180
182
  return null;
181
183
  }
182
184
  function headChecksAllGreen(layer) {
@@ -186,66 +188,58 @@ function headChecksAllGreen(layer) {
186
188
  }
187
189
  /** Shared live-fact precedence; bounce history is only passed for the candidate. */
188
190
  function layerAttention(stack, layer, queue, cursor) {
189
- const none = { blocker: null, bounceKind: null };
191
+ const none = { blocker: null, reason: null, bounceKind: null };
190
192
  if (stack.archivedAt)
191
193
  return none;
192
194
  const bounce = queue.filter((entry) => entry.state === "bounced")
193
195
  .sort((a, b) => (Date.parse(b.finishedAt ?? "") || 0) - (Date.parse(a.finishedAt ?? "") || 0))[0];
194
196
  const kind = bounce?.bounceDetail?.kind;
195
197
  const recoverable = kind === "head_moved" || kind === "must_consolidate";
196
- const hardBlock = (blocker) => ({ blocker, bounceKind: null });
198
+ const hardBlock = (blocker, reason) => ({ blocker, reason, bounceKind: null });
197
199
  if (layer) {
198
200
  if (layer.restackError && layer.state !== "restacking") {
199
201
  if (layer.restackError.kind === "rebase_conflict")
200
- return hardBlock("Conflict");
202
+ return hardBlock("Conflict", "restack_conflict");
201
203
  if (!restackRetryPending(layer.restackError, layer))
202
- return hardBlock(RESTACK_FAILED);
204
+ return hardBlock(RESTACK_FAILED, "restack_failed");
203
205
  }
204
206
  if (layer.state === "conflict")
205
- return hardBlock("Conflict");
207
+ return hardBlock("Conflict", "restack_conflict");
206
208
  if (layer.draft)
207
209
  return {
208
- blocker: "Draft PR", bounceKind: "pr_draft",
210
+ blocker: "Draft PR", reason: "draft_pr", bounceKind: "pr_draft",
209
211
  ...(kind === "pr_draft" && sameHead(bounce.bounceDetail?.headSha ?? bounce.verifyHeadSha, layer.headSha) &&
210
212
  (bounce.bounceDetail?.prNumber == null || bounce.bounceDetail.prNumber === layer.prNumber) ? { bounce } : {}),
211
213
  };
212
- if (boundDirty(layer))
213
- 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
+ }
214
217
  if (layer.ciStatus === "failure" || (layer.checks?.failure ?? 0) > 0) {
215
218
  const name = layer.checks?.failingName?.trim();
216
- return hardBlock(`CI failed${name ? ` — ${name}` : ""}`);
219
+ return hardBlock(`CI failed${name ? ` — ${name}` : ""}`, "ci_failed");
217
220
  }
218
- if (layer.agentRuns?.some((run) => run.agent === "cyclone" && run.status === "failed" &&
219
- sameHead(run.sha, layer.headSha)))
220
- return hardBlock("Cyclone failed");
221
- const tempest = layer.agentRuns?.find((run) => run.agent === "tempest" &&
222
- run.status === "findings" && sameHead(run.sha, layer.headSha));
223
- if (tempest)
224
- return hardBlock(`Tempest ${tempest.status}`);
225
221
  if (layer.vortexStatus === "failed" && !vortexRetryScheduled(layer) &&
226
222
  (!layer.vortexReview?.head_sha || sameHead(layer.vortexReview.head_sha, layer.headSha))) {
227
- return hardBlock(VORTEX_REVIEW_FAILED);
223
+ return hardBlock(VORTEX_REVIEW_FAILED, "vortex_failed");
228
224
  }
229
225
  if (quotaSkippedReview(layer))
230
- return hardBlock(VORTEX_REVIEW_OUT_OF_QUOTA);
226
+ return hardBlock(VORTEX_REVIEW_OUT_OF_QUOTA, "vortex_out_of_quota");
231
227
  const vortexSkip = vortexIdleSkipBlocker(layer);
232
228
  if (vortexSkip)
233
- return hardBlock(vortexSkip.blocker);
229
+ return hardBlock(vortexSkip.blocker, "vortex_skipped");
234
230
  const seam = seamGateBlocker(stack, layer);
235
231
  if (seam)
236
- return hardBlock(seam);
232
+ return hardBlock(seam.blocker, seam.reason);
237
233
  if (unitAbandonedUnder(stack, layer)) {
238
- 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");
239
235
  }
240
236
  if (stack.unit?.landPrNumber === layer.prNumber) {
241
237
  const landing = stack.unit.landingBlockReason?.trim();
242
238
  if (landing && !landGateIsPending(landing))
243
- return hardBlock(landing);
244
- if (stack.unit.tempestLandStatus?.toLowerCase() === "failed")
245
- return hardBlock("Tempest failed");
239
+ return hardBlock(landing, "land_gate");
246
240
  }
247
241
  if (vortexFindingsLeftForPerson(stack, layer))
248
- return hardBlock(VORTEX_FINDINGS_NEED_PERSON);
242
+ return hardBlock(VORTEX_FINDINGS_NEED_PERSON, "vortex_findings_need_person");
249
243
  }
250
244
  if (!bounce || !kind || recoverable || !layer || queue.some((entry) => ["queued", "running", "waiting"].includes(entry.state)))
251
245
  return none;
@@ -258,7 +252,8 @@ function layerAttention(stack, layer, queue, cursor) {
258
252
  return none;
259
253
  if (kind === "ci_failure" && !bounce.bounceDetail?.batch && headChecksAllGreen(layer))
260
254
  return none;
261
- return { blocker: mergeQueueBounceLabel(bounce), bounceKind: kind, bounce };
255
+ const label = mergeQueueBounceLabel(bounce);
256
+ return { blocker: label, reason: "bounce", bounceKind: kind, bounce };
262
257
  }
263
258
  const UNIT_ABANDONED_BLOCKER_PREFIX = "Review unit abandoned: based on ";
264
259
  function unitAbandonedUnder(stack, layer) {
@@ -313,15 +308,7 @@ const CI_UNREADABLE_PERMISSION = {
313
308
  };
314
309
  const AUTO_LAND_WAIT_WORDS = {
315
310
  mergeability_unknown: "GitHub has not said whether this PR can merge into its base",
316
- tempest_pending: "Tempest's review of this head has not started",
317
- tempest_running: "Tempest's review of this head has not finished",
318
- tempest_trigger_failed: "Mergestorm could not reach Tempest to review the land PR; it keeps trying every 5 minutes",
319
- tempest_skipped: "Tempest skipped its review of this head",
320
311
  members_remaining: "a PR that is not a layer of this stack is still open on the stack branch",
321
- tempest_stopped: "Tempest's review of this head was stopped",
322
- tempest_failed: "Tempest's review of this head broke without a result",
323
- tempest_findings: "Tempest reported findings on this head",
324
- cyclone_failed: "Cyclone's patch run failed on this head",
325
312
  stack_dirty: "this layer is not clean (it has a conflict or needs a restack)",
326
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",
327
314
  missing_head: "GitHub returned no head commit for this PR",
@@ -376,6 +363,9 @@ function autoLandWaitOn(stack, layer) {
376
363
  return autoLandWaitLayer(stack)?.prNumber === layer.prNumber ? armedAutoLandWait(stack) : null;
377
364
  }
378
365
  export function autoLandWaitBlocker(stack, layer, nowMs) {
366
+ return autoLandWaitNamed(stack, layer, nowMs)?.blocker ?? null;
367
+ }
368
+ function autoLandWaitNamed(stack, layer, nowMs) {
379
369
  const wait = autoLandWaitOn(stack, layer);
380
370
  if (!wait)
381
371
  return null;
@@ -385,20 +375,32 @@ export function autoLandWaitBlocker(stack, layer, nowMs) {
385
375
  const minutes = Math.floor(waited / 60_000);
386
376
  switch (wait.reason) {
387
377
  case "ci_failure":
388
- return AUTO_LAND_CI_FAILED_BLOCKER;
378
+ return { blocker: AUTO_LAND_CI_FAILED_BLOCKER, reason: "ci_failed" };
389
379
  case "ci_pending":
390
380
  return waited >= AUTO_LAND_CI_PENDING_ATTENTION_MS
391
- ? `${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
+ }
392
385
  : null;
393
386
  case "members_remaining":
394
- return `${AUTO_LAND_MEMBERS_REMAINING_PREFIX}${wait.detail ?? AUTO_LAND_WAIT_WORDS.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
+ };
395
391
  case "vortex_not_green":
396
392
  return waited >= AUTO_LAND_VORTEX_WAIT_ATTENTION_MS && !vortexRetryScheduled(layer)
397
- ? `${AUTO_LAND_VORTEX_WAIT_PREFIX}${minutes}m — Auto land is waiting for an approving Vortex review at this head`
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
+ }
398
397
  : null;
399
398
  case "promote_merge_failed":
400
399
  return (wait.attempts ?? 0) >= AUTO_LAND_PROMOTE_FAILED_ATTEMPTS
401
- ? `${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
+ }
402
404
  : null;
403
405
  default:
404
406
  break;
@@ -406,11 +408,14 @@ export function autoLandWaitBlocker(stack, layer, nowMs) {
406
408
  const unreadable = CI_UNREADABLE_WHAT[wait.reason];
407
409
  if (unreadable) {
408
410
  return ciUnreadableForGood(wait) || waited >= AUTO_LAND_CI_UNREADABLE_ATTENTION_MS
409
- ? `${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
+ }
410
415
  : null;
411
416
  }
412
417
  return waited >= AUTO_LAND_WAIT_ATTENTION_MS
413
- ? `${AUTO_LAND_WAITED_PREFIX}${minutes}m: ${autoLandWaitWords(wait.reason)}`
418
+ ? { blocker: `${AUTO_LAND_WAITED_PREFIX}${minutes}m: ${autoLandWaitWords(wait.reason)}`, reason: "auto_land_waited" }
414
419
  : null;
415
420
  }
416
421
  export const MERGE_QUEUE_STALL_ATTENTION_MS = 45 * 60_000;
@@ -514,28 +519,25 @@ function landPrLiveParent(stack, layer) {
514
519
  const base = stack.unit?.landTarget?.trim() || layer.parentBranch?.trim();
515
520
  return base && !isMgParkBase(base) && base !== layer.branch ? base : null;
516
521
  }
517
- export function agentsCannotClear(blocker) {
518
- return blocker === "Conflict" || blocker === RESTACK_FAILED || blocker === "Draft PR" ||
519
- blocker === SEAM_REVIEW_NOT_RUNNING || blocker === VORTEX_REVIEW_OUT_OF_QUOTA ||
520
- blocker === VORTEX_AUTO_REVIEW_OFF || blocker === VORTEX_BILLING_SKIPPED ||
521
- blocker === VORTEX_REVIEW_INCOMPLETE || blocker === VORTEX_IGNORED_AUTHOR || blocker === VORTEX_REPO_DISABLED ||
522
- STUCK_SEAM_BLOCKERS.includes(blocker) ||
523
- blocker.startsWith("Merge conflicts") || blocker.startsWith(UNIT_ABANDONED_BLOCKER_PREFIX);
524
- }
525
- const TEMPEST_RERUN_BLOCKER = /^(?:landing blocked:\s*)?tempest_(failed|stopped|skipped)\b/i;
526
- const TEMPEST_TRIGGER_FAILED_BLOCKER = /^(?:landing blocked:\s*)?tempest_trigger_failed\b/i;
527
- function isTempestFindingsBlocker(attention, bounce) {
528
- const blocker = attention.blocker.trim();
529
- const reason = bounce?.bounceDetail?.message?.trim();
530
- if (reason && TEMPEST_RERUN_BLOCKER.test(reason))
531
- return false;
532
- return attention.bounceKind === "tempest_findings" || /^Tempest findings\b/i.test(blocker) ||
533
- /^(?: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);
534
535
  }
535
536
  export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
536
537
  if (!attention || !layer || attention.prNumber !== layer.prNumber)
537
538
  return null;
538
- if (queueWait && attention.blocker.startsWith(MERGE_QUEUE_STALLED_PREFIX)) {
539
+ const reason = attention.reason;
540
+ if (queueWait && reason === "queue_stalled") {
539
541
  return {
540
542
  kind: "queue_stalled",
541
543
  prNumber: layer.prNumber,
@@ -547,8 +549,8 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
547
549
  };
548
550
  }
549
551
  const landPr = !!stack.unit?.branch && layer.prNumber === stack.unit.landPrNumber && layer.branch === stack.unit.branch;
550
- const restackConflict = attention.blocker === "Conflict" || attention.bounceKind === "restack_conflict";
551
- const mergeConflict = attention.blocker.startsWith("Merge conflicts");
552
+ const restackConflict = reason === "restack_conflict" || attention.bounceKind === "restack_conflict";
553
+ const mergeConflict = reason === "merge_conflict";
552
554
  if (restackConflict || mergeConflict) {
553
555
  const liveParent = landPr ? landPrLiveParent(stack, layer) : conflictLiveParent(stack, layer);
554
556
  return {
@@ -561,7 +563,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
561
563
  steps: conflictRepairSteps(layer.branch, liveParent),
562
564
  };
563
565
  }
564
- if (attention.blocker === RESTACK_FAILED && layer.restackError) {
566
+ if (reason === "restack_failed" && layer.restackError) {
565
567
  const liveParent = landPr ? landPrLiveParent(stack, layer) : conflictLiveParent(stack, layer);
566
568
  return {
567
569
  kind: "restack_failed",
@@ -573,7 +575,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
573
575
  steps: restackFailedRepairSteps(layer.branch, liveParent, layer.restackError),
574
576
  };
575
577
  }
576
- if (attention.blocker.startsWith(UNIT_ABANDONED_BLOCKER_PREFIX)) {
578
+ if (reason === "unit_abandoned") {
577
579
  const openPrCount = stack.layers.filter((candidate) => candidate.prNumber > 0 && !isTerminalLayer(candidate)).length;
578
580
  return {
579
581
  kind: "unit_abandoned",
@@ -583,11 +585,11 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
583
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.`,
584
586
  };
585
587
  }
586
- if (STUCK_SEAM_BLOCKERS.includes(attention.blocker)) {
588
+ if (STUCK_SEAM_REASONS.includes(reason)) {
587
589
  const member = stack.unit?.members?.find((entry) => entry.prNumber === layer.prNumber);
588
590
  const seamState = member?.seamState?.trim().toLowerCase() || "unknown";
589
591
  const reviewed = member?.seamReviewedSha?.trim();
590
- const what = attention.blocker === SEAM_VERDICT_STALE
592
+ const what = reason === "seam_verdict_stale"
591
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`
592
594
  : `The seam (integration) review of #${layer.prNumber} is ${seamState} but no seam review is queued or running at ${layer.headSha ?? "the live head"}`;
593
595
  return {
@@ -599,7 +601,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
599
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.`,
600
602
  };
601
603
  }
602
- if (attention.blocker.startsWith(AUTO_LAND_OFF_BLOCKER_PREFIX) && attention.bounceKind) {
604
+ if (reason === "auto_land_off" && attention.bounceKind) {
603
605
  return {
604
606
  kind: "auto_land_off",
605
607
  prNumber: layer.prNumber,
@@ -609,49 +611,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
609
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.`,
610
612
  };
611
613
  }
612
- if (landPr && isTempestFindingsBlocker(attention, bounce)) {
613
- const findings = `gh api repos/${stack.owner}/${stack.repo}/issues/${layer.prNumber}/comments`;
614
- return {
615
- kind: "tempest_findings",
616
- prNumber: layer.prNumber,
617
- headSha: layer.headSha ?? null,
618
- branch: layer.branch,
619
- findings,
620
- 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.`,
621
- };
622
- }
623
- const rerunBlocker = attention.blocker.trim().match(TEMPEST_RERUN_BLOCKER) ??
624
- bounce?.bounceDetail?.message?.trim().match(TEMPEST_RERUN_BLOCKER);
625
- const rerun = landPr ? rerunBlocker : null;
626
- if (rerun) {
627
- const outcome = rerun[1].toLowerCase();
628
- const stoppedByPerson = outcome === "stopped";
629
- const comment = `gh pr comment ${layer.prNumber} --repo ${stack.owner}/${stack.repo} --body`;
630
- const head = layer.headSha ?? "its live head";
631
- return {
632
- kind: "tempest_rerun",
633
- prNumber: layer.prNumber,
634
- headSha: layer.headSha ?? null,
635
- branch: layer.branch,
636
- stoppedByPerson,
637
- steps: stoppedByPerson
638
- ? `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.`
639
- : outcome === "skipped"
640
- ? `Tempest skipped its review of land PR #${layer.prNumber} at ${head}, so Auto land will not land it. There are no findings to fix: do not patch or push. Ask the human to run ${comment} "@mergestorm-tempest review" and keep watching.`
641
- : `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.`,
642
- };
643
- }
644
- if (landPr && TEMPEST_TRIGGER_FAILED_BLOCKER.test(attention.blocker.trim())) {
645
- return {
646
- kind: "tempest_trigger_failed",
647
- prNumber: layer.prNumber,
648
- headSha: layer.headSha ?? null,
649
- branch: layer.branch,
650
- detail: attention.blocker.trim(),
651
- steps: `Mergestorm could not reach Tempest to review land PR #${layer.prNumber} at ${layer.headSha ?? "its head"} for over 5 minutes, so Auto land will not land it yet. Mergestorm keeps trying every 5 minutes. Nothing in the PR needs a change: do not patch or push. Keep watching; if this stays, tell the human that Tempest cannot be reached.`,
652
- };
653
- }
654
- if (attention.blocker === VORTEX_REVIEW_FAILED) {
614
+ if (reason === "vortex_failed") {
655
615
  return {
656
616
  kind: "vortex_failed",
657
617
  prNumber: layer.prNumber,
@@ -662,7 +622,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
662
622
  };
663
623
  }
664
624
  const vortexSkip = vortexIdleSkipBlocker(layer);
665
- if (vortexSkip && attention.blocker === vortexSkip.blocker) {
625
+ if (vortexSkip && reason === "vortex_skipped") {
666
626
  return {
667
627
  kind: "vortex_skipped",
668
628
  prNumber: layer.prNumber,
@@ -673,7 +633,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
673
633
  };
674
634
  }
675
635
  const wait = autoLandWaitOn(stack, layer);
676
- if (wait?.reason === "members_remaining" && attention.blocker.startsWith(AUTO_LAND_MEMBERS_REMAINING_PREFIX)) {
636
+ if (wait?.reason === "members_remaining" && reason === "auto_land_members_remaining") {
677
637
  return {
678
638
  kind: "members_remaining",
679
639
  prNumber: layer.prNumber,
@@ -683,7 +643,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
683
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}.`,
684
644
  };
685
645
  }
686
- if (wait && attention.blocker.startsWith(AUTO_LAND_CI_PENDING_PREFIX)) {
646
+ if (wait && reason === "auto_land_ci_pending") {
687
647
  return {
688
648
  kind: "ci_pending",
689
649
  prNumber: layer.prNumber,
@@ -693,7 +653,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
693
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.`,
694
654
  };
695
655
  }
696
- if (wait && attention.blocker.startsWith(AUTO_LAND_VORTEX_WAIT_PREFIX)) {
656
+ if (wait && reason === "auto_land_vortex_wait") {
697
657
  return {
698
658
  kind: "vortex_not_green",
699
659
  prNumber: layer.prNumber,
@@ -703,7 +663,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
703
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.`,
704
664
  };
705
665
  }
706
- if (wait && attention.blocker.startsWith(AUTO_LAND_CI_UNREADABLE_PREFIX)) {
666
+ if (wait && reason === "auto_land_ci_unreadable") {
707
667
  const what = CI_UNREADABLE_WHAT[wait.reason] ?? "the checks";
708
668
  const permission = CI_UNREADABLE_PERMISSION[wait.reason] ?? "Checks: read and Commit statuses: read";
709
669
  return {
@@ -716,7 +676,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
716
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.`,
717
677
  };
718
678
  }
719
- if (wait && attention.blocker.startsWith(AUTO_LAND_WAITED_PREFIX)) {
679
+ if (wait && reason === "auto_land_waited") {
720
680
  return {
721
681
  kind: "auto_land_waiting",
722
682
  prNumber: layer.prNumber,
@@ -727,7 +687,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
727
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.`,
728
688
  };
729
689
  }
730
- if (wait && attention.blocker.startsWith(AUTO_LAND_PROMOTE_FAILED_PREFIX)) {
690
+ if (wait && reason === "auto_land_promote_failed") {
731
691
  const attempts = wait.attempts ?? AUTO_LAND_PROMOTE_FAILED_ATTEMPTS;
732
692
  return {
733
693
  kind: "promote_failed",
@@ -739,7 +699,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
739
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.`,
740
700
  };
741
701
  }
742
- if (attention.blocker === VORTEX_FINDINGS_NEED_PERSON) {
702
+ if (reason === "vortex_findings_need_person") {
743
703
  const findings = `gh api repos/${stack.owner}/${stack.repo}/pulls/${layer.prNumber}/reviews`;
744
704
  const handoff = cycloneHandoffAtHead(stack, layer);
745
705
  return {
@@ -752,7 +712,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
752
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.`,
753
713
  };
754
714
  }
755
- if (attention.blocker.startsWith("CI failed") || attention.bounceKind === "ci_failure") {
715
+ if (reason === "ci_failed" || attention.bounceKind === "ci_failure") {
756
716
  const failingCheck = bounce?.bounceDetail?.failingCheck?.trim() || layer.checks?.failingName?.trim() || null;
757
717
  return {
758
718
  kind: "ci_failure",
@@ -788,6 +748,7 @@ function landPrMissing(stack, candidate) {
788
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}`
789
749
  : `restore it from the commit it pointed at (git push origin <sha>:refs/heads/${unit.branch})`;
790
750
  return {
751
+ reason: "land_pr_missing",
791
752
  attention: {
792
753
  prNumber, headSha, bounceKind: null,
793
754
  blocker: `${LAND_PR_MISSING_PREFIX}${unit.branch} — every layer is merged into it, but it cannot land in ${target} without one`,
@@ -815,9 +776,11 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
815
776
  (boundDirty(layer) || layer.state === "conflict" ||
816
777
  (layer.state !== "restacking" && layer.restackError?.kind === "rebase_conflict"))).sort((a, b) => a.position - b.position)[0];
817
778
  let attention = null;
779
+ let attentionReason = null;
818
780
  let attentionLayer;
819
781
  let bounce;
820
782
  const issues = [];
783
+ const mergeStateUnknownPrs = new Set();
821
784
  const queueWait = stack.archivedAt ? null : mergeQueueWait(queue, nowMs);
822
785
  for (const layer of open) {
823
786
  const selected = layer.prNumber === candidate?.prNumber;
@@ -827,25 +790,26 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
827
790
  disarmed = disarmingBounce(stack, queue);
828
791
  if (disarmed) {
829
792
  const kind = disarmed.bounceDetail.kind;
830
- 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 };
831
794
  }
832
795
  }
833
796
  let waitNamed = false;
834
797
  if (!result.blocker) {
835
- const waiting = autoLandWaitBlocker(stack, layer, nowMs);
798
+ const waiting = autoLandWaitNamed(stack, layer, nowMs);
836
799
  if (waiting) {
837
- result = { blocker: waiting, bounceKind: null };
800
+ result = { ...waiting, bounceKind: null };
838
801
  waitNamed = true;
839
802
  }
840
803
  }
841
804
  if (!result.blocker && selected && queueWait?.stalled) {
842
- result = { blocker: `${MERGE_QUEUE_STALLED_PREFIX}${queueWait.text}`, bounceKind: null };
805
+ result = { blocker: `${MERGE_QUEUE_STALLED_PREFIX}${queueWait.text}`, reason: "queue_stalled", bounceKind: null };
843
806
  }
844
807
  if (!result.blocker) {
845
808
  if (vortexRetryScheduled(layer) && (selected || !candidate || layer.position > candidate.position)) {
846
809
  issues.push({ prNumber: layer.prNumber, headSha: layer.headSha ?? null, blocker: VORTEX_RETRY_SCHEDULED, bounceKind: null });
847
810
  }
848
811
  if (mergeabilityPending(layer) && (selected || !candidate || layer.position > candidate.position)) {
812
+ mergeStateUnknownPrs.add(layer.prNumber);
849
813
  issues.push({ prNumber: layer.prNumber, headSha: layer.headSha ?? null,
850
814
  blocker: `Merge state unknown${layer.parentBranch ? ` vs ${layer.parentBranch}` : ""}`, bounceKind: null });
851
815
  }
@@ -854,9 +818,10 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
854
818
  const issue = { prNumber: layer.prNumber, headSha: layer.headSha ?? null,
855
819
  blocker: result.blocker, bounceKind: result.bounceKind };
856
820
  if (selected ||
857
- result.blocker.startsWith(UNIT_ABANDONED_BLOCKER_PREFIX) ||
821
+ result.reason === "unit_abandoned" ||
858
822
  (!attention && (layer === child || waitNamed))) {
859
823
  attention = issue;
824
+ attentionReason = result.reason;
860
825
  attentionLayer = layer;
861
826
  bounce = result.bounce;
862
827
  if (disarmed)
@@ -868,9 +833,10 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
868
833
  }
869
834
  if (!attention) {
870
835
  const unlisted = autoLandWaitLayer(stack);
871
- const waiting = unlisted && !open.includes(unlisted) ? autoLandWaitBlocker(stack, unlisted, nowMs) : null;
836
+ const waiting = unlisted && !open.includes(unlisted) ? autoLandWaitNamed(stack, unlisted, nowMs) : null;
872
837
  if (unlisted && waiting) {
873
- 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;
874
840
  attentionLayer = unlisted;
875
841
  }
876
842
  }
@@ -878,14 +844,18 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
878
844
  ? { prNumber: candidate.prNumber, headSha: candidate.headSha ?? null, text: queueWait.text,
879
845
  since: queueWait.since, waitReason: queueWait.waitReason }
880
846
  : null;
881
- const settleIssue = mergeabilitySettleIssue(stack, open, issues, attention);
847
+ const settleIssue = mergeabilitySettleIssue(stack, open, mergeStateUnknownPrs, attention);
882
848
  if (settleIssue)
883
849
  issues.push(settleIssue);
884
850
  const landMissing = landPrMissing(stack, candidate);
885
851
  if (landMissing) {
886
852
  attention = landMissing.attention;
853
+ attentionReason = landMissing.reason;
887
854
  attentionLayer = undefined;
888
855
  }
856
+ const classified = attention && attentionReason
857
+ ? { ...attention, reason: attentionReason }
858
+ : null;
889
859
  const focus = attentionLayer ?? candidate;
890
860
  const agents = focus ? layerAgents(focus) : null;
891
861
  const busy = [];
@@ -902,26 +872,26 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
902
872
  const landGatePending = landLayer && landing && landGateIsPending(landing)
903
873
  ? { prNumber: landLayer.prNumber, headSha: landLayer.headSha ?? null, reason: landing }
904
874
  : null;
905
- if (busy.length && attention && agentsCannotClear(attention.blocker)) {
875
+ if (busy.length && attention && classified && agentsCannotClear(classified.reason)) {
906
876
  const held = { ...attention, actAfter: "agents_idle", waitingOn: busy.map((entry) => entry.agent) };
907
877
  return { attention: null, held, issues: [attention, ...issues], currentCandidate, bounce: undefined, busy, agents,
908
- repair: stackRepair(stack, attention, attentionLayer, undefined, queueWait), landGatePending, queueWait: queued };
878
+ repair: stackRepair(stack, classified, attentionLayer, undefined, queueWait), landGatePending, queueWait: queued };
909
879
  }
910
880
  if (busy.length) {
911
881
  return { attention: null, held: null, issues, currentCandidate, bounce: undefined, busy, agents, repair: null, landGatePending,
912
882
  queueWait: queued };
913
883
  }
914
884
  return { attention, held: null, issues, currentCandidate, bounce, busy, agents,
915
- repair: landMissing?.repair ?? stackRepair(stack, attention, attentionLayer, bounce, queueWait), landGatePending,
885
+ repair: landMissing?.repair ?? stackRepair(stack, classified, attentionLayer, bounce, queueWait), landGatePending,
916
886
  queueWait: queued };
917
887
  }
918
- function mergeabilitySettleIssue(stack, open, issues, attention) {
888
+ function mergeabilitySettleIssue(stack, open, mergeStateUnknownPrs, attention) {
919
889
  const settle = stack.autoEnqueueSettle;
920
890
  if (stack.archivedAt || settle?.action !== "mergeability")
921
891
  return null;
922
892
  if (attention?.prNumber === settle.prNumber)
923
893
  return null;
924
- if (issues.some((issue) => issue.prNumber === settle.prNumber && issue.blocker.startsWith("Merge state unknown")))
894
+ if (mergeStateUnknownPrs.has(settle.prNumber))
925
895
  return null;
926
896
  const layer = open.find((candidate) => candidate.prNumber === settle.prNumber);
927
897
  if (layer && !sameHead(layer.headSha, settle.headSha))
@@ -9,6 +9,7 @@ export type RestackError = {
9
9
  branch: string;
10
10
  oldSha: string;
11
11
  };
12
+ droppedMergeCommit?: boolean;
12
13
  };
13
14
  export declare function restackAttemptsExhausted(error: Pick<RestackError, "attempts">): boolean;
14
15
  export declare function pushRejectionIsRetryable(message: string): boolean;
@@ -69,8 +70,7 @@ export type StackCycloneHandoff = {
69
70
  reason: string;
70
71
  at: string | null;
71
72
  };
72
- export type StackTempestStatus = "reviewing" | "findings" | "clear" | "failed" | "stale";
73
- export type StackAgentName = "vortex" | "cyclone" | "tempest";
73
+ export type StackAgentName = "vortex" | "cyclone";
74
74
  /**
75
75
  * One live-or-resting agent run for a PR (#1119): a dumb projection of one
76
76
  * ledger row (webhook job / lease / review-state / pr_reviews). Two live
@@ -197,8 +197,6 @@ export type StackLayerDto = {
197
197
  vortexStatus: StackVortexStatus | null;
198
198
  /** null on the fast list or when no Cyclone signal exists. */
199
199
  cycloneStatus: StackCycloneStatus | null;
200
- /** null when no Tempest signal exists. Fast list and enrich both paint this from pr_reviews (#876). */
201
- tempestStatus: StackTempestStatus | null;
202
200
  /**
203
201
  * Ledger-backed agent runs, one per live row (#1119). Fast list and enrich
204
202
  * both paint this from our tables only — never GitHub. The per-agent status
@@ -296,11 +294,10 @@ export type StackUnitDto = {
296
294
  landTarget: string;
297
295
  members: StackUnitMemberDto[];
298
296
  landPrNumber: number | null;
299
- tempestLandStatus: string | null;
300
297
  landingBlockReason: string | null;
301
298
  /**
302
299
  * Land-PR chrome. Fast list hydrates last-known CI/title/mergeable/issues from
303
- * `review_units` (#878 / #892) and may attach a tempest-only stub (#876). Enrich
300
+ * `review_units` (#878 / #892). Enrich
304
301
  * refreshes GitHub and writes back. Null when the land PR is an open stack
305
302
  * layer (chips live on `layers`) or the unit has no land PR.
306
303
  */
@@ -385,6 +382,8 @@ export type StackDto = {
385
382
  */
386
383
  cycloneOwnerMatch?: CycloneOwnerMatch;
387
384
  cyclonePatchOff?: CyclonePatchOffReason;
385
+ infrastructureInstallUserId?: string | null;
386
+ infrastructureOwnerMatch?: CycloneOwnerMatch;
388
387
  layers: StackLayerDto[];
389
388
  /** Present when a review-unit row exists for this stack. */
390
389
  unit?: StackUnitDto;
@@ -392,9 +391,9 @@ export type StackDto = {
392
391
  export type MergeQueueEntryState = "queued" | "running" | "waiting" | "landed" | "bounced" | "cancelled";
393
392
  export type MergeQueueEnqueuedBy = "human" | "agent";
394
393
  export type MergeQueueEnqueuedVia = "dashboard" | "cli" | "chat" | "api" | "mcp";
395
- export type MergeQueueBounceKind = "ci_failure" | "ci_timeout" | "head_moved" | "tempest_findings" | "tempest_rerun" | "seam_findings" | "restack_conflict" | "pr_draft" | "must_consolidate" | "merge_failed" | "gh_error";
394
+ export type MergeQueueBounceKind = "ci_failure" | "ci_timeout" | "head_moved" | "seam_findings" | "restack_conflict" | "pr_draft" | "must_consolidate" | "merge_failed" | "gh_error";
396
395
  /** Every bounce kind understood by the shared contract. */
397
- export declare const MERGE_QUEUE_BOUNCE_KINDS: readonly ["ci_failure", "ci_timeout", "head_moved", "tempest_findings", "tempest_rerun", "seam_findings", "restack_conflict", "pr_draft", "must_consolidate", "merge_failed", "gh_error"];
396
+ export declare const MERGE_QUEUE_BOUNCE_KINDS: readonly ["ci_failure", "ci_timeout", "head_moved", "seam_findings", "restack_conflict", "pr_draft", "must_consolidate", "merge_failed", "gh_error"];
398
397
  export declare const BOUNCE_KIND_LABELS: Readonly<Record<MergeQueueBounceKind, string>>;
399
398
  export declare function mergeQueueBounceLabel(entry: MergeQueueEntryDto): string;
400
399
  /** Structured bounce handoff — enough for the bar chip and the chat quote. */