mergestorm 0.3.34 → 0.3.36

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -45,7 +45,7 @@ On a TTY, bare `mergestorm` (or `mergestorm shell`) opens a branded REPL: a comp
45
45
 
46
46
  ```
47
47
  ╭──────────────────────────────────────────────────────────────────────────────╮
48
- │ ▀▀▀▀▀▀▀▀▀▀▘ mergestorm v0.3.34 │
48
+ │ ▀▀▀▀▀▀▀▀▀▀▘ mergestorm v0.3.36 │
49
49
  │ ▀▀▀▀▀▀▀▀▀ ● msk_live_… · maelstrom │
50
50
  │ ▀▀▀▀▀▀ [████████░░░░░░░░░░░░░░░░░░░░░░] 25% used │
51
51
  │ ▝▀▀▀▀▀ local reviews + stacked PRs · mergestorm.ai │
@@ -1,6 +1,6 @@
1
1
  import { type StackHeldBlocker, type StackRepairHint } from "../stack-blockers.js";
2
2
  import { type StackWatchObligation } from "../stack-watch-obligation.js";
3
- import { adoptStack, ensureUpperPark, findStackPull, getEnrichedStack, listStacks, openStackPull, setStackPolicy, type StackDto, type StackPolicyPatch } from "../api.js";
3
+ import { adoptStack, ensureUpperPark, findStackPull, getEnrichedStack, listMergeQueueEntries, listStacks, openStackPull, setStackPolicy, type StackDto, type StackPolicyPatch } from "../api.js";
4
4
  import { loadConfig } from "../config.js";
5
5
  import { createPrs, findOpenPrNumber } from "../gh.js";
6
6
  import { parseGithubOriginRepo } from "../git.js";
@@ -147,6 +147,7 @@ export declare function cmdStackWatch(args: string[], deps?: {
147
147
  export declare function cmdStackStatus(args: string[], deps?: {
148
148
  loadConfig?: typeof loadConfig;
149
149
  getEnrichedStack?: typeof getEnrichedStack;
150
+ listMergeQueueEntries?: typeof listMergeQueueEntries;
150
151
  }): Promise<void>;
151
152
  export type StackSetDeps = {
152
153
  loadConfig?: typeof loadConfig;
@@ -1,6 +1,6 @@
1
1
  import { stackBlockers, stackBlockersSummary } from "../stack-blockers.js";
2
2
  import { STACK_WATCH_NOT_DONE_SENTENCE, STACK_WATCH_NOTIFY_PATTERN, stackTerminalReason, stackWatchObligation, } from "../stack-watch-obligation.js";
3
- import { adoptStack, ensureUpperPark, findStackPull, getEnrichedStack, landNextStack, listStacks, openStackPull, restackStack, setStackPolicy, } from "../api.js";
3
+ import { adoptStack, ensureUpperPark, findStackPull, getEnrichedStack, landNextStack, listMergeQueueEntries, listStacks, openStackPull, restackStack, setStackPolicy, } from "../api.js";
4
4
  import { loadConfig } from "../config.js";
5
5
  import { rateLimitedMessage, REVIEW_EXIT, CommandError } from "../errors.js";
6
6
  import { createPrs, findOpenPrNumber, ghReady, githubApiFromEnv, } from "../gh.js";
@@ -910,7 +910,7 @@ export async function cmdStackWait(args, deps = {}) {
910
910
  ...(retryAfterSeconds !== undefined ? { retry_after_seconds: retryAfterSeconds } : {}),
911
911
  }, null, 2)
912
912
  : [
913
- `Stack ${envelope.stackId} · ${envelope.status}${stackBlockersSummary(envelope.blocker && envelope.prNumber != null ? { prNumber: envelope.prNumber, blocker: envelope.blocker } : null, envelope.issues, envelope.busy, envelope.landGatePending ?? null, envelope.actAfter ?? null)}${envelope.assessment === "unavailable" ? " · assessment unavailable" : ""}`,
913
+ `Stack ${envelope.stackId} · ${envelope.status}${stackBlockersSummary(envelope.blocker && envelope.prNumber != null ? { prNumber: envelope.prNumber, blocker: envelope.blocker } : null, envelope.issues, envelope.busy, envelope.landGatePending ?? null, envelope.actAfter ?? null, envelope.queueWait ?? null)}${envelope.assessment === "unavailable" ? " · assessment unavailable" : ""}`,
914
914
  ...stackRepairLine(envelope.repair),
915
915
  ...stackWatchTextLines(envelope.watch),
916
916
  ].join("\n"));
@@ -1018,7 +1018,10 @@ export async function cmdStackStatus(args, deps = {}) {
1018
1018
  console.log(watch.message);
1019
1019
  throw new CommandError(`Stack not found or not owned by the current user: ${stackId}`, 1, "not_found");
1020
1020
  }
1021
- const { attention, held, currentCandidate, repair } = stackBlockers(stack);
1021
+ const entries = await (deps.listMergeQueueEntries ?? listMergeQueueEntries)(cfg, {
1022
+ stackId: stack.id,
1023
+ });
1024
+ const { attention, held, currentCandidate, repair, queueWait } = stackBlockers(stack, entries);
1022
1025
  const watch = stackWatchObligation({
1023
1026
  stackId: stack.id,
1024
1027
  terminal: stackTerminalReason(stack),
@@ -1027,7 +1030,7 @@ export async function cmdStackStatus(args, deps = {}) {
1027
1030
  freshCursor: true,
1028
1031
  });
1029
1032
  if (asJson) {
1030
- console.log(JSON.stringify({ ...stack, held, repair, watch }, null, 2));
1033
+ console.log(JSON.stringify({ ...stack, held, repair, queueWait, watch }, null, 2));
1031
1034
  return;
1032
1035
  }
1033
1036
  const lines = formatStackHuman(stack, false);
@@ -1040,6 +1043,8 @@ export async function cmdStackStatus(args, deps = {}) {
1040
1043
  (layer.cycloneStatus ? ` Cyclone: ${layer.cycloneStatus}` : "") +
1041
1044
  (layer.tempestStatus ? ` Tempest: ${layer.tempestStatus}` : ""));
1042
1045
  }
1046
+ if (queueWait && !attention)
1047
+ lines.push(` Queue: #${queueWait.prNumber} ${queueWait.text}`);
1043
1048
  lines.push(...[...stackHeldLine(held), ...stackRepairLine(repair), ...stackWatchTextLines(watch)].map((line) => ` ${line}`));
1044
1049
  await present("Stack status", lines);
1045
1050
  }
@@ -54,12 +54,42 @@ export type StackRepairHint = {
54
54
  branch: string;
55
55
  findings: string;
56
56
  steps: string;
57
+ } | {
58
+ kind: "tempest_rerun";
59
+ prNumber: number;
60
+ headSha: string | null;
61
+ branch: string;
62
+ stoppedByPerson: boolean;
63
+ steps: string;
64
+ } | {
65
+ kind: "vortex_findings";
66
+ prNumber: number;
67
+ headSha: string | null;
68
+ branch: string;
69
+ handoff: string | null;
70
+ findings: string;
71
+ steps: string;
57
72
  } | {
58
73
  kind: "unit_abandoned";
59
74
  prNumber: number;
60
75
  headSha: string | null;
61
76
  branch: string;
62
77
  steps: string;
78
+ } | {
79
+ kind: "restack_failed";
80
+ prNumber: number;
81
+ headSha: string | null;
82
+ branch: string;
83
+ liveParent: string | null;
84
+ restackKind: string;
85
+ steps: string;
86
+ } | {
87
+ kind: "seam_review_stuck";
88
+ prNumber: number;
89
+ headSha: string | null;
90
+ branch: string;
91
+ seamState: string;
92
+ steps: string;
63
93
  } | {
64
94
  kind: "auto_land_off";
65
95
  prNumber: number;
@@ -67,6 +97,36 @@ export type StackRepairHint = {
67
97
  branch: string;
68
98
  bounceKind: string;
69
99
  steps: string;
100
+ } | {
101
+ kind: "ci_pending" | "vortex_not_green";
102
+ prNumber: number;
103
+ headSha: string | null;
104
+ branch: string;
105
+ since: string;
106
+ steps: string;
107
+ } | {
108
+ kind: "queue_stalled";
109
+ prNumber: number;
110
+ headSha: string | null;
111
+ branch: string;
112
+ since: string | null;
113
+ waitReason: string | null;
114
+ steps: string;
115
+ } | {
116
+ kind: "vortex_skipped";
117
+ prNumber: number;
118
+ headSha: string | null;
119
+ branch: string;
120
+ skipReason: string;
121
+ steps: string;
122
+ } | {
123
+ kind: "promote_failed";
124
+ prNumber: number;
125
+ headSha: string | null;
126
+ branch: string;
127
+ attempts: number;
128
+ message: string | null;
129
+ steps: string;
70
130
  };
71
131
  export type StackLandGatePending = {
72
132
  prNumber: number;
@@ -82,14 +142,48 @@ export declare function boundDirty(layer: StackLayerDto): boolean;
82
142
  export declare function mergeabilityPending(layer: StackLayerDto): boolean;
83
143
  export declare function sameHead(a: string | null | undefined, b: string | null | undefined): boolean;
84
144
  export declare function currentLayer(stack: StackDto): StackLayerDto | undefined;
145
+ export declare const RESTACK_FAILED = "Restack failed";
85
146
  export declare const SEAM_REVIEW_NOT_RUNNING = "Seam review pending, no review running";
86
147
  export declare const VORTEX_REVIEW_OUT_OF_QUOTA = "Review skipped, out of review quota";
148
+ export declare const SEAM_REVIEW_STUCK = "Seam review stuck in reviewing, no review running";
149
+ export declare const SEAM_REREVIEW_NOT_RUNNING = "Seam re-review pending, no review running";
150
+ export declare const SEAM_VERDICT_STALE = "Seam verdict is for an older head, no review running";
151
+ export declare const VORTEX_FINDINGS_NEED_PERSON = "Vortex findings need a person";
152
+ export declare const VORTEX_AUTO_REVIEW_OFF = "Vortex auto-review is off, so this head was not reviewed";
153
+ export declare const VORTEX_BILLING_SKIPPED = "Vortex skipped this head: it could not confirm billing";
154
+ export declare const VORTEX_REVIEW_INCOMPLETE = "Vortex reviewed only part of this PR";
155
+ export declare const AUTO_LAND_CI_PENDING_ATTENTION_MS: number;
156
+ export declare const AUTO_LAND_VORTEX_WAIT_ATTENTION_MS: number;
157
+ export declare const AUTO_LAND_PROMOTE_FAILED_ATTEMPTS = 3;
158
+ export declare const AUTO_LAND_CI_FAILED_BLOCKER = "CI failed \u2014 Auto land reads a failing check on GitHub at this head";
159
+ export declare function autoLandWaitBlocker(stack: StackDto, layer: StackLayerDto, nowMs: number): string | null;
160
+ export declare const MERGE_QUEUE_STALL_ATTENTION_MS: number;
161
+ export type StackQueueWait = {
162
+ prNumber: number;
163
+ headSha: string | null;
164
+ text: string;
165
+ since: string | null;
166
+ waitReason: string | null;
167
+ };
168
+ export type MergeQueueWait = {
169
+ text: string;
170
+ stalled: boolean;
171
+ since: string | null;
172
+ waitReason: string | null;
173
+ aheadStackId: string | null;
174
+ aheadOther: boolean;
175
+ };
176
+ export declare function mergeQueueWait(queue: readonly MergeQueueEntryDto[], nowMs: number): MergeQueueWait | null;
87
177
  export declare function conflictLiveParent(stack: StackDto, layer: StackLayerDto): string | null;
88
178
  export declare function conflictRepairSteps(branch: string, liveParent: string | null): string;
179
+ export declare function restackFailedRepairSteps(branch: string, liveParent: string | null, failure: {
180
+ kind: string;
181
+ detail?: string | null;
182
+ }): string;
89
183
  export declare function agentsCannotClear(blocker: string): boolean;
90
- export declare function stackRepair(stack: StackDto, attention: StackBlocker | null, layer: StackLayerDto | undefined, bounce: MergeQueueEntryDto | undefined): StackRepairHint | null;
184
+ export declare function stackRepair(stack: StackDto, attention: StackBlocker | null, layer: StackLayerDto | undefined, bounce: MergeQueueEntryDto | undefined, queueWait?: MergeQueueWait | null): StackRepairHint | null;
91
185
  /** Pair-gate attention plus live hard blocks on every other open layer. */
92
- export declare function stackBlockers(stack: StackDto, entries?: MergeQueueEntryDto[], cursor?: Pick<StackWatchCursor, "bounceId" | "afterFinishedAt">): {
186
+ export declare function stackBlockers(stack: StackDto, entries?: MergeQueueEntryDto[], cursor?: Pick<StackWatchCursor, "bounceId" | "afterFinishedAt">, nowMs?: number): {
93
187
  attention: null;
94
188
  held: StackHeldBlocker;
95
189
  issues: StackBlocker[];
@@ -102,6 +196,7 @@ export declare function stackBlockers(stack: StackDto, entries?: MergeQueueEntry
102
196
  agents: StackLayerAgents | null;
103
197
  repair: StackRepairHint | null;
104
198
  landGatePending: StackLandGatePending | null;
199
+ queueWait: StackQueueWait | null;
105
200
  } | {
106
201
  attention: StackBlocker | null;
107
202
  held: null;
@@ -115,6 +210,7 @@ export declare function stackBlockers(stack: StackDto, entries?: MergeQueueEntry
115
210
  agents: StackLayerAgents | null;
116
211
  repair: StackRepairHint | null;
117
212
  landGatePending: StackLandGatePending | null;
213
+ queueWait: StackQueueWait | null;
118
214
  };
119
215
  /** Compact contract text shared by status and wait summaries. */
120
- export declare function stackBlockersSummary(attention: Pick<StackBlocker, "prNumber" | "blocker"> | null, issues: StackBlocker[], busy?: readonly StackAgentBusy[], landGatePending?: Pick<StackLandGatePending, "prNumber" | "reason"> | null, actAfter?: StackHeldBlocker["actAfter"] | null): string;
216
+ export declare function stackBlockersSummary(attention: Pick<StackBlocker, "prNumber" | "blocker"> | null, issues: StackBlocker[], busy?: readonly StackAgentBusy[], landGatePending?: Pick<StackLandGatePending, "prNumber" | "reason"> | null, actAfter?: StackHeldBlocker["actAfter"] | null, queueWait?: Pick<StackQueueWait, "prNumber" | "text"> | null): string;
@@ -51,13 +51,81 @@ export function currentLayer(stack) {
51
51
  return promote[0] ?? open.find((layer) => layer.prNumber === stack.unit?.landPrNumber) ??
52
52
  (land && land.prNumber > 0 && land.state !== "merged" && land.state !== "closed" ? land : undefined);
53
53
  }
54
+ export const RESTACK_FAILED = "Restack failed";
54
55
  export const SEAM_REVIEW_NOT_RUNNING = "Seam review pending, no review running";
55
56
  export const VORTEX_REVIEW_OUT_OF_QUOTA = "Review skipped, out of review quota";
57
+ export const SEAM_REVIEW_STUCK = "Seam review stuck in reviewing, no review running";
58
+ export const SEAM_REREVIEW_NOT_RUNNING = "Seam re-review pending, no review running";
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];
61
+ function coreReviewDoneAtHead(layer) {
62
+ const review = layer.vortexReview;
63
+ return review?.status === "done" && sameHead(review.head_sha, layer.headSha);
64
+ }
65
+ export const VORTEX_FINDINGS_NEED_PERSON = "Vortex findings need a person";
66
+ function vortexChangesRequestedAtHead(layer) {
67
+ const review = layer.vortexReview;
68
+ if (review && !(review.status === "done" && sameHead(review.head_sha, layer.headSha)))
69
+ return false;
70
+ return layer.vortexStatus === "findings" && layer.reviewStatus === "changes_requested";
71
+ }
72
+ function cycloneHandoffAtHead(stack, layer) {
73
+ if (sameHead(layer.cycloneHandoff?.headSha, layer.headSha))
74
+ return layer.cycloneHandoff.reason;
75
+ return stack.cyclonePatchOff ?? (stack.autoPatchOverride === false ? "auto_patch_off" : null);
76
+ }
77
+ const CYCLONE_HANDOFF_WORDS = {
78
+ auto_patch_off: "auto-patch is off for this stack",
79
+ stack_auto_patch_off: "auto-patch is off for this stack",
80
+ account_auto_patch_off: "auto-patch is off for the account and this stack does not turn it on",
81
+ cyclone_not_connected: "Cyclone is not connected to this repo",
82
+ };
83
+ function cycloneHandoffWords(reason) {
84
+ return CYCLONE_HANDOFF_WORDS[reason] ?? reason.replaceAll("_", " ");
85
+ }
86
+ function vortexFindingsLeftForPerson(stack, layer) {
87
+ return vortexChangesRequestedAtHead(layer) && !layerAgentsBusy(layer).cyclone &&
88
+ cycloneHandoffAtHead(stack, layer) !== null;
89
+ }
56
90
  function quotaSkippedReview(layer) {
57
91
  const review = layer.vortexReview;
58
92
  return review?.status === "skipped" && review.skip_reason === "quota_exceeded" &&
59
93
  sameHead(review.head_sha, layer.headSha);
60
94
  }
95
+ export const VORTEX_AUTO_REVIEW_OFF = "Vortex auto-review is off, so this head was not reviewed";
96
+ export const VORTEX_BILLING_SKIPPED = "Vortex skipped this head: it could not confirm billing";
97
+ export const VORTEX_REVIEW_INCOMPLETE = "Vortex reviewed only part of this PR";
98
+ const VORTEX_SKIP_BLOCKERS = {
99
+ auto_review_off: VORTEX_AUTO_REVIEW_OFF,
100
+ billing_blocked: VORTEX_BILLING_SKIPPED,
101
+ billing_unavailable: VORTEX_BILLING_SKIPPED,
102
+ };
103
+ function vortexIdleSkipBlocker(layer) {
104
+ if (layerAgentsBusy(layer).vortex)
105
+ return null;
106
+ const review = layer.vortexReview;
107
+ if (review?.status === "skipped" && sameHead(review.head_sha, layer.headSha)) {
108
+ const reason = review.skip_reason?.trim() ?? "";
109
+ const blocker = VORTEX_SKIP_BLOCKERS[reason];
110
+ if (blocker)
111
+ return { blocker, skipReason: reason };
112
+ }
113
+ if (layer.vortexStatus === "incomplete" && sameHead(review?.head_sha, layer.headSha)) {
114
+ return { blocker: VORTEX_REVIEW_INCOMPLETE, skipReason: "incomplete" };
115
+ }
116
+ return null;
117
+ }
118
+ function vortexSkippedSteps(stack, layer, skipReason) {
119
+ const head = layer.headSha ?? "its live head";
120
+ const mention = `comment @mergestorm-vortex review on #${layer.prNumber}`;
121
+ if (skipReason === "auto_review_off") {
122
+ 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: they can turn auto-review on (mg stack set ${stack.id} --auto-review on, or the repo setting in the dashboard), which does not review this head by itself, and then ${mention} to review it now.`;
123
+ }
124
+ if (skipReason === "incomplete") {
125
+ 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
+ }
127
+ return `Vortex skipped #${layer.prNumber} at ${head} because it could not confirm billing for this account, so Auto land waits for a review that will not come. Do not patch or push for this. Re-run the review: ${mention}. If it is skipped again, tell the human to check the plan and billing in the Mergestorm dashboard.`;
128
+ }
61
129
  function seamGateBlocker(stack, layer) {
62
130
  const members = stack.unit?.members ?? [];
63
131
  if (layer.prNumber === stack.unit?.landPrNumber)
@@ -72,8 +140,20 @@ function seamGateBlocker(stack, layer) {
72
140
  return "Seam review failed";
73
141
  if (seamState === "findings" && sameHead(member.seamReviewedSha, layer.headSha))
74
142
  return "Seam findings";
75
- if (seamState === "pending" && !layerAgentsBusy(layer).vortex)
143
+ if (layerAgentsBusy(layer).vortex)
144
+ return null;
145
+ if (seamState === "pending")
76
146
  return SEAM_REVIEW_NOT_RUNNING;
147
+ if (seamState === "reviewing")
148
+ return SEAM_REVIEW_STUCK;
149
+ if (seamState === "pending_rereview")
150
+ return SEAM_REREVIEW_NOT_RUNNING;
151
+ if (!layer.headSha?.trim() || sameHead(member.seamReviewedSha, layer.headSha))
152
+ return null;
153
+ if (seamState === "findings")
154
+ return SEAM_VERDICT_STALE;
155
+ if (seamState === "approved" && !coreReviewDoneAtHead(layer))
156
+ return SEAM_VERDICT_STALE;
77
157
  return null;
78
158
  }
79
159
  function headChecksAllGreen(layer) {
@@ -96,7 +176,7 @@ function layerAttention(stack, layer, queue, cursor) {
96
176
  if (layer.restackError.kind === "rebase_conflict")
97
177
  return hardBlock("Conflict");
98
178
  if (!restackRetryPending(layer.restackError, layer))
99
- return hardBlock("Restack failed");
179
+ return hardBlock(RESTACK_FAILED);
100
180
  }
101
181
  if (layer.state === "conflict")
102
182
  return hardBlock("Conflict");
@@ -116,13 +196,16 @@ function layerAttention(stack, layer, queue, cursor) {
116
196
  sameHead(run.sha, layer.headSha)))
117
197
  return hardBlock("Cyclone failed");
118
198
  const tempest = layer.agentRuns?.find((run) => run.agent === "tempest" &&
119
- ["findings", "failed"].includes(run.status) && sameHead(run.sha, layer.headSha));
199
+ run.status === "findings" && sameHead(run.sha, layer.headSha));
120
200
  if (tempest)
121
201
  return hardBlock(`Tempest ${tempest.status}`);
122
202
  if (layer.vortexStatus === "failed")
123
203
  return hardBlock("Review failed");
124
204
  if (quotaSkippedReview(layer))
125
205
  return hardBlock(VORTEX_REVIEW_OUT_OF_QUOTA);
206
+ const vortexSkip = vortexIdleSkipBlocker(layer);
207
+ if (vortexSkip)
208
+ return hardBlock(vortexSkip.blocker);
126
209
  const seam = seamGateBlocker(stack, layer);
127
210
  if (seam)
128
211
  return hardBlock(seam);
@@ -136,6 +219,8 @@ function layerAttention(stack, layer, queue, cursor) {
136
219
  if (stack.unit.tempestLandStatus?.toLowerCase() === "failed")
137
220
  return hardBlock("Tempest failed");
138
221
  }
222
+ if (vortexFindingsLeftForPerson(stack, layer))
223
+ return hardBlock(VORTEX_FINDINGS_NEED_PERSON);
139
224
  }
140
225
  if (!bounce || !kind || recoverable || !layer || queue.some((entry) => ["queued", "running", "waiting"].includes(entry.state)))
141
226
  return none;
@@ -179,6 +264,101 @@ function disarmingBounce(stack, queue) {
179
264
  return recorded;
180
265
  return undefined;
181
266
  }
267
+ export const AUTO_LAND_CI_PENDING_ATTENTION_MS = 45 * 60_000;
268
+ export const AUTO_LAND_VORTEX_WAIT_ATTENTION_MS = 30 * 60_000;
269
+ export const AUTO_LAND_PROMOTE_FAILED_ATTEMPTS = 3;
270
+ export const AUTO_LAND_CI_FAILED_BLOCKER = "CI failed — Auto land reads a failing check on GitHub at this head";
271
+ const AUTO_LAND_CI_PENDING_PREFIX = "CI pending for ";
272
+ const AUTO_LAND_VORTEX_WAIT_PREFIX = "No green Vortex review for ";
273
+ const AUTO_LAND_PROMOTE_FAILED_PREFIX = "Promote failed ";
274
+ function waitedMs(since, nowMs) {
275
+ const sinceMs = Date.parse(since);
276
+ return Number.isFinite(sinceMs) ? Math.max(0, nowMs - sinceMs) : null;
277
+ }
278
+ function autoLandWaitOn(stack, layer) {
279
+ const wait = stack.autoLandWait;
280
+ if (!wait || stack.archivedAt || stack.autoEnqueueWhenReady !== true)
281
+ return null;
282
+ if (wait.prNumber !== layer.prNumber || !sameHead(wait.headSha, layer.headSha))
283
+ return null;
284
+ return wait;
285
+ }
286
+ export function autoLandWaitBlocker(stack, layer, nowMs) {
287
+ const wait = autoLandWaitOn(stack, layer);
288
+ if (!wait)
289
+ return null;
290
+ const waited = waitedMs(wait.since, nowMs);
291
+ if (waited == null)
292
+ return null;
293
+ const minutes = Math.floor(waited / 60_000);
294
+ switch (wait.reason) {
295
+ case "ci_failure":
296
+ return AUTO_LAND_CI_FAILED_BLOCKER;
297
+ case "ci_pending":
298
+ return waited >= AUTO_LAND_CI_PENDING_ATTENTION_MS
299
+ ? `${AUTO_LAND_CI_PENDING_PREFIX}${minutes}m — a check on this head has not finished, so Auto land will not queue it`
300
+ : null;
301
+ case "vortex_not_green":
302
+ return waited >= AUTO_LAND_VORTEX_WAIT_ATTENTION_MS
303
+ ? `${AUTO_LAND_VORTEX_WAIT_PREFIX}${minutes}m — Auto land is waiting for an approving Vortex review at this head`
304
+ : null;
305
+ case "promote_merge_failed":
306
+ return (wait.attempts ?? 0) >= AUTO_LAND_PROMOTE_FAILED_ATTEMPTS
307
+ ? `${AUTO_LAND_PROMOTE_FAILED_PREFIX}${wait.attempts} times (merge_failed) — Auto land stopped retrying this head${wait.detail ? `: ${wait.detail}` : ""}`
308
+ : null;
309
+ default:
310
+ return null;
311
+ }
312
+ }
313
+ export const MERGE_QUEUE_STALL_ATTENTION_MS = 45 * 60_000;
314
+ const MERGE_QUEUE_STALLED_PREFIX = "Merge queue stuck: ";
315
+ const LIVE_QUEUE_STATES = ["queued", "running", "waiting"];
316
+ function minutesOf(ms) {
317
+ return ms == null ? 0 : Math.floor(ms / 60_000);
318
+ }
319
+ export function mergeQueueWait(queue, nowMs) {
320
+ const entry = queue.find((candidate) => LIVE_QUEUE_STATES.includes(candidate.state));
321
+ if (!entry)
322
+ return null;
323
+ const reason = entry.waitReason?.trim() || null;
324
+ if (entry.state === "queued") {
325
+ const queuedFor = minutesOf(waitedMs(entry.enqueuedAt, nowMs));
326
+ const ahead = entry.aheadInRepo;
327
+ if (!ahead) {
328
+ return {
329
+ text: `Queued ${queuedFor}m in the merge queue, not picked up yet${reason ? ` (last note: ${reason})` : ""}`,
330
+ stalled: false,
331
+ since: entry.enqueuedAt, waitReason: reason, aheadStackId: null, aheadOther: false,
332
+ };
333
+ }
334
+ const aheadMs = ahead.claimedAt ? waitedMs(ahead.claimedAt, nowMs) : null;
335
+ const aheadReason = ahead.waitReason?.trim() || null;
336
+ const who = ahead.stackId ? `stack ${ahead.stackId}` : "another user's stack";
337
+ const aheadFor = aheadMs == null ? "an unknown duration" : `${minutesOf(aheadMs)}m`;
338
+ return {
339
+ text: `Queued ${queuedFor}m behind ${who}, which has been ${ahead.state} in the merge queue for ${aheadFor}${aheadReason ? `: ${aheadReason}` : ""}`,
340
+ stalled: aheadMs != null && aheadMs >= MERGE_QUEUE_STALL_ATTENTION_MS,
341
+ since: ahead.claimedAt, waitReason: aheadReason, aheadStackId: ahead.stackId, aheadOther: !ahead.stackId,
342
+ };
343
+ }
344
+ const since = entry.claimedAt ?? null;
345
+ const liveMs = since === null ? null : waitedMs(since, nowMs);
346
+ const liveFor = liveMs == null ? "an unknown duration" : `${minutesOf(liveMs)}m`;
347
+ return {
348
+ text: `Merge queue ${entry.state === "waiting" ? "waiting" : "running"} for ${liveFor}: ${reason ?? "no reason recorded"}`,
349
+ stalled: liveMs != null && liveMs >= MERGE_QUEUE_STALL_ATTENTION_MS,
350
+ since, waitReason: reason, aheadStackId: null, aheadOther: false,
351
+ };
352
+ }
353
+ function queueStalledSteps(stack, layer, wait) {
354
+ if (wait.aheadStackId || wait.aheadOther) {
355
+ const ahead = wait.aheadStackId
356
+ ? `Run mg stack status ${wait.aheadStackId} and fix what it names there; this stack moves once that entry lands or bounces.`
357
+ : "That entry belongs to another Mergestorm user, so you cannot fix it from here.";
358
+ return `#${layer.prNumber} is waiting in the merge queue behind another entry in ${stack.owner}/${stack.repo} that has made no progress since ${wait.since ?? "it was claimed"}. Nothing on ${layer.branch} needs a change. ${ahead} If it stays stuck, tell the human: the queue for this repo is held by that entry.`;
359
+ }
360
+ return `The merge queue has held #${layer.prNumber} at ${layer.headSha ?? "its head"} since ${wait.since ?? "it was claimed"} without landing or bouncing it${wait.waitReason ? `; its last note is: ${wait.waitReason}` : ", and it recorded no reason"}. If the note names a failing check or a conflict on ${layer.branch}, fix that with an ordinary push. If it waits on CI, list the head's checks (gh run list --branch ${layer.branch}) and re-run the one that never finished (gh run rerun <run-id>). If nothing moves after that, tell the human: they can take the stack out of the queue and enqueue it again (mg queue rm ${stack.id}, then mg queue add ${stack.id}). Do not retarget the PR base.`;
361
+ }
182
362
  function isTerminalLayer(layer) {
183
363
  return layer.state === "merged" || layer.state === "closed";
184
364
  }
@@ -215,23 +395,48 @@ export function conflictRepairSteps(branch, liveParent) {
215
395
  }
216
396
  return `Merge ${liveParent} into ${branch}: git fetch origin, check out ${branch} at its live remote head, git merge origin/${liveParent}, resolve the conflicts, run the tests, then an ordinary git push (no force). Do not rebase onto trunk, main, or mg-park-*, and do not retarget the PR base.`;
217
397
  }
398
+ export function restackFailedRepairSteps(branch, liveParent, failure) {
399
+ const why = `Mergestorm could not restack ${branch}${liveParent ? ` onto ${liveParent}` : ""} (${failure.kind}${failure.detail?.trim() ? `: ${failure.detail.trim()}` : ""}) and will not try this head again.`;
400
+ if (!liveParent) {
401
+ return `${why} The live parent of ${branch} is unknown (it is never an mg-park-* freeze). Do not merge or rebase anything; stop and tell the human.`;
402
+ }
403
+ return `${why} Merge ${liveParent} into ${branch}: git fetch origin, check out ${branch} at its live remote head, git merge origin/${liveParent}, resolve any conflicts, run the tests, then an ordinary git push (no force). Mergestorm clears Restack failed once the pushed head contains the tip of ${liveParent}. If GitHub refuses the push (branch protection or permissions), stop and tell the human. Do not rebase onto trunk, main, or mg-park-*, and do not retarget the PR base.`;
404
+ }
218
405
  function landPrLiveParent(stack, layer) {
219
406
  const base = stack.unit?.landTarget?.trim() || layer.parentBranch?.trim();
220
407
  return base && !isMgParkBase(base) && base !== layer.branch ? base : null;
221
408
  }
222
409
  export function agentsCannotClear(blocker) {
223
- return blocker === "Conflict" || blocker === "Restack failed" || blocker === "Draft PR" ||
410
+ return blocker === "Conflict" || blocker === RESTACK_FAILED || blocker === "Draft PR" ||
224
411
  blocker === SEAM_REVIEW_NOT_RUNNING || blocker === VORTEX_REVIEW_OUT_OF_QUOTA ||
412
+ blocker === VORTEX_AUTO_REVIEW_OFF || blocker === VORTEX_BILLING_SKIPPED ||
413
+ blocker === VORTEX_REVIEW_INCOMPLETE ||
414
+ STUCK_SEAM_BLOCKERS.includes(blocker) ||
225
415
  blocker.startsWith("Merge conflicts") || blocker.startsWith(UNIT_ABANDONED_BLOCKER_PREFIX);
226
416
  }
227
- function isTempestFindingsBlocker(attention) {
417
+ const TEMPEST_RERUN_BLOCKER = /^(?:landing blocked:\s*)?tempest_(failed|stopped)\b/i;
418
+ function isTempestFindingsBlocker(attention, bounce) {
228
419
  const blocker = attention.blocker.trim();
420
+ const reason = bounce?.bounceDetail?.message?.trim();
421
+ if (reason && TEMPEST_RERUN_BLOCKER.test(reason))
422
+ return false;
229
423
  return attention.bounceKind === "tempest_findings" || /^Tempest findings\b/i.test(blocker) ||
230
424
  /^(?:landing blocked:\s*)?tempest_findings\b/i.test(blocker);
231
425
  }
232
- export function stackRepair(stack, attention, layer, bounce) {
426
+ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
233
427
  if (!attention || !layer || attention.prNumber !== layer.prNumber)
234
428
  return null;
429
+ if (queueWait && attention.blocker.startsWith(MERGE_QUEUE_STALLED_PREFIX)) {
430
+ return {
431
+ kind: "queue_stalled",
432
+ prNumber: layer.prNumber,
433
+ headSha: layer.headSha ?? null,
434
+ branch: layer.branch,
435
+ since: queueWait.since,
436
+ waitReason: queueWait.waitReason,
437
+ steps: queueStalledSteps(stack, layer, queueWait),
438
+ };
439
+ }
235
440
  const landPr = !!stack.unit?.branch && layer.prNumber === stack.unit.landPrNumber && layer.branch === stack.unit.branch;
236
441
  const restackConflict = attention.blocker === "Conflict" || attention.bounceKind === "restack_conflict";
237
442
  const mergeConflict = attention.blocker.startsWith("Merge conflicts");
@@ -247,6 +452,18 @@ export function stackRepair(stack, attention, layer, bounce) {
247
452
  steps: conflictRepairSteps(layer.branch, liveParent),
248
453
  };
249
454
  }
455
+ if (attention.blocker === RESTACK_FAILED && layer.restackError) {
456
+ const liveParent = landPr ? landPrLiveParent(stack, layer) : conflictLiveParent(stack, layer);
457
+ return {
458
+ kind: "restack_failed",
459
+ prNumber: layer.prNumber,
460
+ headSha: layer.headSha ?? null,
461
+ branch: layer.branch,
462
+ liveParent,
463
+ restackKind: layer.restackError.kind,
464
+ steps: restackFailedRepairSteps(layer.branch, liveParent, layer.restackError),
465
+ };
466
+ }
250
467
  if (attention.blocker.startsWith(UNIT_ABANDONED_BLOCKER_PREFIX)) {
251
468
  const openPrCount = stack.layers.filter((candidate) => candidate.prNumber > 0 && !isTerminalLayer(candidate)).length;
252
469
  return {
@@ -257,6 +474,22 @@ export function stackRepair(stack, attention, layer, bounce) {
257
474
  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.`,
258
475
  };
259
476
  }
477
+ if (STUCK_SEAM_BLOCKERS.includes(attention.blocker)) {
478
+ const member = stack.unit?.members?.find((entry) => entry.prNumber === layer.prNumber);
479
+ const seamState = member?.seamState?.trim().toLowerCase() || "unknown";
480
+ const reviewed = member?.seamReviewedSha?.trim();
481
+ const what = attention.blocker === SEAM_VERDICT_STALE
482
+ ? `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`
483
+ : `The seam (integration) review of #${layer.prNumber} is ${seamState} but no seam review is queued or running at ${layer.headSha ?? "the live head"}`;
484
+ return {
485
+ kind: "seam_review_stuck",
486
+ prNumber: layer.prNumber,
487
+ headSha: layer.headSha ?? null,
488
+ branch: layer.branch,
489
+ seamState,
490
+ 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.`,
491
+ };
492
+ }
260
493
  if (attention.blocker.startsWith(AUTO_LAND_OFF_BLOCKER_PREFIX) && attention.bounceKind) {
261
494
  return {
262
495
  kind: "auto_land_off",
@@ -267,7 +500,7 @@ export function stackRepair(stack, attention, layer, bounce) {
267
500
  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.`,
268
501
  };
269
502
  }
270
- if (landPr && isTempestFindingsBlocker(attention)) {
503
+ if (landPr && isTempestFindingsBlocker(attention, bounce)) {
271
504
  const findings = `gh api repos/${stack.owner}/${stack.repo}/issues/${layer.prNumber}/comments`;
272
505
  return {
273
506
  kind: "tempest_findings",
@@ -278,6 +511,81 @@ export function stackRepair(stack, attention, layer, bounce) {
278
511
  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.`,
279
512
  };
280
513
  }
514
+ const rerunBlocker = attention.blocker.trim().match(TEMPEST_RERUN_BLOCKER) ??
515
+ bounce?.bounceDetail?.message?.trim().match(TEMPEST_RERUN_BLOCKER);
516
+ const rerun = landPr ? rerunBlocker : null;
517
+ if (rerun) {
518
+ const stoppedByPerson = rerun[1].toLowerCase() === "stopped";
519
+ const comment = `gh pr comment ${layer.prNumber} --repo ${stack.owner}/${stack.repo} --body`;
520
+ const head = layer.headSha ?? "its live head";
521
+ return {
522
+ kind: "tempest_rerun",
523
+ prNumber: layer.prNumber,
524
+ headSha: layer.headSha ?? null,
525
+ branch: layer.branch,
526
+ stoppedByPerson,
527
+ steps: stoppedByPerson
528
+ ? `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.`
529
+ : `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.`,
530
+ };
531
+ }
532
+ const vortexSkip = vortexIdleSkipBlocker(layer);
533
+ if (vortexSkip && attention.blocker === vortexSkip.blocker) {
534
+ return {
535
+ kind: "vortex_skipped",
536
+ prNumber: layer.prNumber,
537
+ headSha: layer.headSha ?? null,
538
+ branch: layer.branch,
539
+ skipReason: vortexSkip.skipReason,
540
+ steps: vortexSkippedSteps(stack, layer, vortexSkip.skipReason),
541
+ };
542
+ }
543
+ const wait = autoLandWaitOn(stack, layer);
544
+ if (wait && attention.blocker.startsWith(AUTO_LAND_CI_PENDING_PREFIX)) {
545
+ return {
546
+ kind: "ci_pending",
547
+ prNumber: layer.prNumber,
548
+ headSha: layer.headSha ?? null,
549
+ branch: layer.branch,
550
+ since: wait.since,
551
+ 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.`,
552
+ };
553
+ }
554
+ if (wait && attention.blocker.startsWith(AUTO_LAND_VORTEX_WAIT_PREFIX)) {
555
+ return {
556
+ kind: "vortex_not_green",
557
+ prNumber: layer.prNumber,
558
+ headSha: layer.headSha ?? null,
559
+ branch: layer.branch,
560
+ since: wait.since,
561
+ 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.`,
562
+ };
563
+ }
564
+ if (wait && attention.blocker.startsWith(AUTO_LAND_PROMOTE_FAILED_PREFIX)) {
565
+ const attempts = wait.attempts ?? AUTO_LAND_PROMOTE_FAILED_ATTEMPTS;
566
+ return {
567
+ kind: "promote_failed",
568
+ prNumber: layer.prNumber,
569
+ headSha: layer.headSha ?? null,
570
+ branch: layer.branch,
571
+ attempts,
572
+ message: wait.detail,
573
+ 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.`,
574
+ };
575
+ }
576
+ if (attention.blocker === VORTEX_FINDINGS_NEED_PERSON) {
577
+ const findings = `gh api repos/${stack.owner}/${stack.repo}/pulls/${layer.prNumber}/reviews`;
578
+ const handoff = cycloneHandoffAtHead(stack, layer);
579
+ return {
580
+ kind: "vortex_findings",
581
+ prNumber: layer.prNumber,
582
+ headSha: layer.headSha ?? null,
583
+ branch: layer.branch,
584
+ handoff,
585
+ findings,
586
+ 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.`,
587
+ };
588
+ }
281
589
  if (attention.blocker.startsWith("CI failed") || attention.bounceKind === "ci_failure") {
282
590
  const failingCheck = bounce?.bounceDetail?.failingCheck?.trim() || layer.checks?.failingName?.trim() || null;
283
591
  return {
@@ -292,7 +600,7 @@ export function stackRepair(stack, attention, layer, bounce) {
292
600
  return null;
293
601
  }
294
602
  /** Pair-gate attention plus live hard blocks on every other open layer. */
295
- export function stackBlockers(stack, entries = [], cursor = {}) {
603
+ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now()) {
296
604
  const candidate = currentLayer(stack);
297
605
  const currentCandidate = candidate ? { prNumber: candidate.prNumber, headSha: candidate.headSha ?? null } : null;
298
606
  const queue = entries.filter((entry) => entry.stackId.trim().toLowerCase() === stack.id.trim().toLowerCase());
@@ -307,6 +615,7 @@ export function stackBlockers(stack, entries = [], cursor = {}) {
307
615
  let attentionLayer;
308
616
  let bounce;
309
617
  const issues = [];
618
+ const queueWait = stack.archivedAt ? null : mergeQueueWait(queue, nowMs);
310
619
  for (const layer of open) {
311
620
  const selected = layer.prNumber === candidate?.prNumber;
312
621
  let result = layerAttention(stack, layer, selected ? queue : [], cursor);
@@ -318,6 +627,17 @@ export function stackBlockers(stack, entries = [], cursor = {}) {
318
627
  result = { blocker: `${AUTO_LAND_OFF_BLOCKER_PREFIX}${kind} bounce`, bounceKind: kind, bounce: disarmed };
319
628
  }
320
629
  }
630
+ let waitNamed = false;
631
+ if (!result.blocker) {
632
+ const waiting = autoLandWaitBlocker(stack, layer, nowMs);
633
+ if (waiting) {
634
+ result = { blocker: waiting, bounceKind: null };
635
+ waitNamed = true;
636
+ }
637
+ }
638
+ if (!result.blocker && selected && queueWait?.stalled) {
639
+ result = { blocker: `${MERGE_QUEUE_STALLED_PREFIX}${queueWait.text}`, bounceKind: null };
640
+ }
321
641
  if (!result.blocker) {
322
642
  if (mergeabilityPending(layer) && (selected || !candidate || layer.position > candidate.position)) {
323
643
  issues.push({ prNumber: layer.prNumber, headSha: layer.headSha ?? null,
@@ -329,7 +649,7 @@ export function stackBlockers(stack, entries = [], cursor = {}) {
329
649
  blocker: result.blocker, bounceKind: result.bounceKind };
330
650
  if (selected ||
331
651
  result.blocker.startsWith(UNIT_ABANDONED_BLOCKER_PREFIX) ||
332
- (!attention && layer === child)) {
652
+ (!attention && (layer === child || waitNamed))) {
333
653
  attention = issue;
334
654
  attentionLayer = layer;
335
655
  bounce = result.bounce;
@@ -340,6 +660,10 @@ export function stackBlockers(stack, entries = [], cursor = {}) {
340
660
  else if (!candidate || layer.position > candidate.position)
341
661
  issues.push(issue);
342
662
  }
663
+ const queued = queueWait && candidate && !queueWait.stalled
664
+ ? { prNumber: candidate.prNumber, headSha: candidate.headSha ?? null, text: queueWait.text,
665
+ since: queueWait.since, waitReason: queueWait.waitReason }
666
+ : null;
343
667
  const settleIssue = mergeabilitySettleIssue(stack, open, issues, attention);
344
668
  if (settleIssue)
345
669
  issues.push(settleIssue);
@@ -362,11 +686,14 @@ export function stackBlockers(stack, entries = [], cursor = {}) {
362
686
  if (busy.length && attention && agentsCannotClear(attention.blocker)) {
363
687
  const held = { ...attention, actAfter: "agents_idle", waitingOn: busy.map((entry) => entry.agent) };
364
688
  return { attention: null, held, issues: [attention, ...issues], currentCandidate, bounce: undefined, busy, agents,
365
- repair: stackRepair(stack, attention, attentionLayer, undefined), landGatePending };
689
+ repair: stackRepair(stack, attention, attentionLayer, undefined, queueWait), landGatePending, queueWait: queued };
690
+ }
691
+ if (busy.length) {
692
+ return { attention: null, held: null, issues, currentCandidate, bounce: undefined, busy, agents, repair: null, landGatePending,
693
+ queueWait: queued };
366
694
  }
367
- if (busy.length)
368
- return { attention: null, held: null, issues, currentCandidate, bounce: undefined, busy, agents, repair: null, landGatePending };
369
- return { attention, held: null, issues, currentCandidate, bounce, busy, agents, repair: stackRepair(stack, attention, attentionLayer, bounce), landGatePending };
695
+ return { attention, held: null, issues, currentCandidate, bounce, busy, agents,
696
+ repair: stackRepair(stack, attention, attentionLayer, bounce, queueWait), landGatePending, queueWait: queued };
370
697
  }
371
698
  function mergeabilitySettleIssue(stack, open, issues, attention) {
372
699
  const settle = stack.autoEnqueueSettle;
@@ -384,7 +711,7 @@ function mergeabilitySettleIssue(stack, open, issues, attention) {
384
711
  blocker: `Merge state unknown${vs}; Auto land is waiting for a mergeability verdict`, bounceKind: null };
385
712
  }
386
713
  /** Compact contract text shared by status and wait summaries. */
387
- export function stackBlockersSummary(attention, issues, busy = [], landGatePending = null, actAfter = null) {
714
+ export function stackBlockersSummary(attention, issues, busy = [], landGatePending = null, actAfter = null, queueWait = null) {
388
715
  const agentWork = (entry) => entry.agent === "cyclone" ? "Cyclone patches" : "Vortex reviews";
389
716
  const blocked = attention && actAfter && busy.length
390
717
  ? ` · blocked after the agents: #${attention.prNumber} ${attention.blocker} (plan the fix; act once ${busy.map(agentWork).join(" and ")} finish and the watch returns attention)`
@@ -397,5 +724,6 @@ export function stackBlockersSummary(attention, issues, busy = [], landGatePendi
397
724
  const gate = landGatePending && !attention
398
725
  ? ` · land gate: #${landGatePending.prNumber} ${landGatePending.reason} (nothing to fix; wait)`
399
726
  : "";
400
- return blocked + working + gate + (listed.length ? ` · issues: ${labels}${listed.length > 3 ? `; +${listed.length - 3} more` : ""}` : "");
727
+ const queue = queueWait && !attention ? ` · queue: #${queueWait.prNumber} ${queueWait.text}` : "";
728
+ return blocked + working + gate + queue + (listed.length ? ` · issues: ${labels}${listed.length > 3 ? `; +${listed.length - 3} more` : ""}` : "");
401
729
  }
@@ -64,6 +64,11 @@ export type StackVortexReviewView = {
64
64
  source: "pr_reviews" | "github_webhook_jobs" | "lease" | "none";
65
65
  };
66
66
  export type StackCycloneStatus = "patching" | "awaiting_fix" | "failed";
67
+ export type StackCycloneHandoff = {
68
+ headSha: string;
69
+ reason: string;
70
+ at: string | null;
71
+ };
67
72
  export type StackTempestStatus = "reviewing" | "findings" | "clear" | "failed" | "stale";
68
73
  export type StackAgentName = "vortex" | "cyclone" | "tempest";
69
74
  /**
@@ -208,6 +213,7 @@ export type StackLayerDto = {
208
213
  */
209
214
  vortexReview?: StackVortexReviewView;
210
215
  agentsBusy?: StackAgentsBusy | null;
216
+ cycloneHandoff?: StackCycloneHandoff | null;
211
217
  conflictDetail: string | null;
212
218
  restackError?: RestackError | null;
213
219
  lastRestackedSha: string | null;
@@ -301,6 +307,7 @@ export type StackUnitDto = {
301
307
  landPr: StackLayerDto | null;
302
308
  };
303
309
  export type CycloneOwnerMatch = "same" | "different" | "none" | "lookup_failed";
310
+ export type CyclonePatchOffReason = "stack_auto_patch_off" | "account_auto_patch_off" | "cyclone_not_connected";
304
311
  export type StackDto = {
305
312
  id: string;
306
313
  owner: string;
@@ -339,6 +346,14 @@ export type StackDto = {
339
346
  startedAt: string;
340
347
  windowSeconds?: number;
341
348
  } | null;
349
+ autoLandWait?: {
350
+ reason: string;
351
+ prNumber: number | null;
352
+ headSha: string | null;
353
+ since: string;
354
+ attempts: number | null;
355
+ detail: string | null;
356
+ } | null;
342
357
  /**
343
358
  * Per-stack Vortex auto-review override. `null` (or missing) follows the
344
359
  * account `auto_review_enabled` flag; a boolean wins in both directions.
@@ -369,6 +384,7 @@ export type StackDto = {
369
384
  * `lookup_failed`: the comparison could not be loaded. Fail closed.
370
385
  */
371
386
  cycloneOwnerMatch?: CycloneOwnerMatch;
387
+ cyclonePatchOff?: CyclonePatchOffReason;
372
388
  layers: StackLayerDto[];
373
389
  /** Present when a review-unit row exists for this stack. */
374
390
  unit?: StackUnitDto;
@@ -408,6 +424,12 @@ export type MergeQueueEntryBatchDto = {
408
424
  state: MergeQueueBatchState;
409
425
  landedCount: number;
410
426
  };
427
+ export type MergeQueueAheadDto = {
428
+ stackId: string | null;
429
+ state: "running" | "waiting";
430
+ claimedAt: string | null;
431
+ waitReason: string | null;
432
+ };
411
433
  export type MergeQueueEntryDto = {
412
434
  id: string;
413
435
  stackId: string;
@@ -431,4 +453,6 @@ export type MergeQueueEntryDto = {
431
453
  cancelRequestedAt?: string | null;
432
454
  batch?: MergeQueueEntryBatchDto | null;
433
455
  finishedAt: string | null;
456
+ claimedAt?: string | null;
457
+ aheadInRepo?: MergeQueueAheadDto | null;
434
458
  };
@@ -1,4 +1,4 @@
1
- import { type StackAgentBusy, type StackBlocker, type StackBusyAgent, type StackHeldBlocker, type StackLayerAgents, type StackLandGatePending, type StackRepairHint } from "./stack-blockers.js";
1
+ import { type StackAgentBusy, type StackBlocker, type StackBusyAgent, type StackHeldBlocker, type StackLayerAgents, type StackLandGatePending, type StackQueueWait, type StackRepairHint } from "./stack-blockers.js";
2
2
  import { type StackWatchObligation } from "./stack-watch-obligation.js";
3
3
  import { apiFetch } from "./api.js";
4
4
  import type { Config } from "./config.js";
@@ -33,6 +33,7 @@ export type StackWatchEnvelope = {
33
33
  agents: StackLayerAgents | null;
34
34
  repair: StackRepairHint | null;
35
35
  landGatePending: StackLandGatePending | null;
36
+ queueWait?: StackQueueWait | null;
36
37
  watch: StackWatchObligation;
37
38
  };
38
39
  export type PollStackWatchOptions = {
@@ -120,7 +120,7 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
120
120
  if (snapshot && Array.isArray(snapshot.stacks) && !stack) {
121
121
  terminal = "not_found";
122
122
  throw new StackWatchError("Stack not found or not owned by the current user", seal({ ...lastEnvelope, status: "failed", assessment: "unavailable", blocker: null, issues: [], busy: [], actAfter: null, waitingOn: [],
123
- prNumber: null, headSha: null, repair: null, agents: null, currentCandidate: null, landGatePending: null }));
123
+ prNumber: null, headSha: null, repair: null, agents: null, currentCandidate: null, landGatePending: null, queueWait: null }));
124
124
  }
125
125
  if (!stack || !Array.isArray(stack.layers)) {
126
126
  throw new StackWatchError("Stack snapshot missing or invalid", seal({ ...lastEnvelope, status: "failed", assessment: "unavailable" }));
@@ -140,13 +140,13 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
140
140
  };
141
141
  };
142
142
  const evaluate = (stack, entries) => {
143
- const { attention, held: named, issues, currentCandidate, bounce, busy, agents, repair, landGatePending } = stackBlockers(stack, entries, cursor);
143
+ const { attention, held: named, issues, currentCandidate, bounce, busy, agents, repair, landGatePending, queueWait } = stackBlockers(stack, entries, cursor, now());
144
144
  if (bounce)
145
145
  cursor = Object.freeze({ ...cursor, bounceId: bounce.id,
146
146
  ...(bounce.finishedAt !== undefined ? { afterFinishedAt: bounce.finishedAt } : {}) });
147
147
  const held = busy[0];
148
148
  const shown = attention ?? named;
149
- lastEnvelope = { ...lastEnvelope, cursor, issues, currentCandidate, assessment: "available", busy, agents, repair, landGatePending,
149
+ lastEnvelope = { ...lastEnvelope, cursor, issues, currentCandidate, assessment: "available", busy, agents, repair, landGatePending, queueWait,
150
150
  actAfter: named?.actAfter ?? null, waitingOn: named?.waitingOn ?? [],
151
151
  blocker: shown?.blocker ?? null, bounceKind: shown?.bounceKind ?? null,
152
152
  prNumber: shown?.prNumber ?? held?.prNumber ?? currentCandidate?.prNumber ?? null,
package/dist/ui/banner.js CHANGED
@@ -136,6 +136,16 @@ const WHATS_NEW = {
136
136
  "stack watch names an abandoned review unit",
137
137
  "stack watch names Auto land's mergeability wait",
138
138
  ],
139
+ "0.3.35": [
140
+ "stack watch names why Auto land is waiting",
141
+ "stack watch names a stuck seam review",
142
+ "stack watch names findings Cyclone left for you",
143
+ ],
144
+ "0.3.36": [
145
+ "stack watch names what the merge queue waits on",
146
+ "stack watch names why Vortex skipped a head",
147
+ "stack watch says how to re-run Tempest",
148
+ ],
139
149
  };
140
150
  function densityFromVariant(variant) {
141
151
  if (variant === "mini" || variant === "nano")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mergestorm",
3
- "version": "0.3.34",
3
+ "version": "0.3.36",
4
4
  "description": "Mergestorm CLI (mg) — local reviews & stacked PRs. MIT; build from source: https://github.com/marginsystems/mergestorm-cli",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -35,9 +35,9 @@ Call `stack_status` with `stack_id`. Require that stack's `autoEnqueueWhenReady`
35
35
 
36
36
  1. Call `stack_wait` with the named `stack_id` and `timeout_s: 45`. On initial enrollment, omit the cursor selectors. Save the returned `cursor`: map `cursor.enrolledHeadSha` to `enrolled_head_sha`, `cursor.afterFinishedAt` to `after_finished_at`, and `cursor.bounceId` to `bounce_id` (omit only absent fields; preserve explicit nulls). Keep `bounce_id` alongside `after_finished_at`; never pass one without the other when the returned cursor has both. The shipped tool accepts these selectors, not a nested cursor argument. `watch.next.args` already holds them in that shape, with `timeout_s: 45`; passing it unchanged is the same call.
37
37
  2. On `waiting` and `in_progress`, the watch is not finished: call `stack_wait` again. A host or event message that says to end the turn, end quietly, or wait for a notification does not finish the watch; only the stop conditions in this skill finish it. Ending your turn while `mg stack watch` runs in the background is waiting, not finishing. Keep the same cursor across retries. An unread deadline times out as `failed`; stop and report. Timeout `waiting` or `in_progress` after a snapshot is not failure — call `stack_wait` again in 45s slices with the same cursor after inspecting `issues[]`. Inspect `issues[]` on `in_progress` too: a live queue does not erase upstack hard blocks. `waiting` with issues is not idle. If `assessment` is `unavailable`, do not infer that the stack is issue-free; refresh `stack_status`, whose `attention`, `issues`, and `currentCandidate` come from the same blocker rules as `stack_wait`. Do not replace the enrolled head with a newer snapshot head or advance the timestamp just because a wait timed out. On a surfaced `rate_limited` error, honor the retry delay and retain the cursor; on `failed` or an unrecoverable tool error, call `stack_wait` once more with the same cursor; if it fails again, stop and report the reason and that the stack is not landed. `queue_status` returning `queue_entry_not_found` is not a failure: an empty queue for the stack is a normal wait.
38
- 3. `attention` always means something for you to fix. A land gate that is only waiting (`landGatePending` with `ci_pending`, `tempest_pending`, `tempest_running`, or another `*_pending` / `*_running` reason on the land PR) returns `in_progress`, not `attention`: keep watching. On `attention`, read `repair` when present: it names the blocked PR's `branch` and the concrete fix. For `restack_conflict` and `merge_conflict` it gives `liveParent` (the sibling PR head, or `mg-stack-N` once the lower layers are merged or promoted, or the land PR's base such as `main` when the blocked PR is the land PR, never `mg-park-*`) and the conflicted `files`; a null `liveParent` means stop and tell the human. For `ci_failure` it names the failing check. For `tempest_findings` on the land PR it names the land PR, its `branch` (`mg-stack-N`), and where the Tempest report is (`findings`). A stack whose `autoLandOff.reason` is `bounced` (a `merge_failed`, `gh_error`, or `ci_timeout` bounce turned Auto land off and nobody took the stack back since) returns `attention` with the blocker `Auto land off after <kind> bounce`, the bounce in `issues[]`, and a `repair` of kind `auto_land_off`; handle it as "Re-arm after a bounce" describes. A stack whose review unit was abandoned because a human moved its bottom PR off `mg-stack-N` returns `attention` with the blocker `Review unit abandoned: based on <base>` and a `repair` of kind `unit_abandoned`: do not patch or retarget anything. Ask the human whether to re-root it: if at least two PRs remain open, tell them that `mg stack adopt owner/repo#<bottom PR>` re-roots it on a new review unit; with only one open PR, re-adopting will not create a review unit, so tell them to close and resubmit it to create a fresh stack, then stop. `repair` does not replace the checks below. On `attention`, fix the named PR: `prNumber` and `headSha` identify the blocked PR, while `currentCandidate` identifies the promote candidate. The gate covers the bottom and its direct child only when the child has current-head DIRTY on the live bottom branch. A GitHub DIRTY whose `parentBranch` starts with `mg-park-` is not that gate. Refresh `stack_status`; use `queue_status` with the same `stack_id` when bounce details are needed. Verify the blocker still applies to the current PR head. Never treat `verifyHeadSha` as the current head: it is a queue verification SHA and can be stale or synthetic. Compare the live PR branch head, the current stack layer head, and the checkout before editing. Read the live PR head with `git ls-remote origin refs/heads/<branch>` or `gh api repos/OWNER/REPO/pulls/N --jq .head.sha`. If you need CI logs, use `gh run list --branch <branch>`, `gh run view <run-id> --log-failed`, or `gh api repos/OWNER/REPO/commits/SHA/check-runs`. Read GitHub only through git and these REST calls; do not use other `gh pr` subcommands. Restack `clean` does not mean mergeable or CI-green. Attention is a diagnosis, not permission to mutate stack or queue policy; the only policy change you make is the re-arm in "Re-arm after a bounce". A bounce is the reason to look, not the moment to commit. `stack_wait` and `stack_status` hold the blocked PR's attention themselves while Vortex or Cyclone is still working on that PR, whatever the blocker (a red CI failure, a merge conflict, a bounce, Draft, a failed restack or review): they return `in_progress` instead of `attention`, with `busy[]` naming the PR (`prNumber`, `headSha`), the `blocker`, and the `agent` (`vortex` or `cyclone`), and `agents` carrying that PR's `vortexStatus`, `cycloneStatus`, `vortexReview`, and `busy` flags. `in_progress` with a nonempty `busy[]` means do not patch yet: keep calling `stack_wait` with the same cursor until that work has finished and the watch returns `attention`, then make the follow-up commit. You do not need an extra `stack_status` refresh each slice for this check. A nonempty `busy[]` reports still-working agents on that PR's current head. Vortex holds when `vortexReview` is `reviewing` at the head, an unfinished vortex `agentRuns` row at the head is `reviewing`, or an unfinished vortex run at the head is `queued` and started within the last 15 minutes (with no `agentRuns` or `vortexReview` on the layer, `vortexStatus` `reviewing` holds). Cyclone holds when `cycloneStatus` is `patching`, an unfinished cyclone `agentRuns` row is `patching`, or the server sees a live Cyclone patch lease or an open Cyclone check run on the head. `seam_pending` alone, and a `queued` run older than 15 minutes, are not treated as work in progress. `all_clear`, `findings`, `skipped`, `failed`, `throttled`, `awaiting_fix`, and a null status are finished. A queued Vortex run holds for at most 15 minutes; reviewing and patching hold until the run finishes or its lease or check run is cleared. A blocker no Vortex or Cyclone run can clear is not hidden by the hold: a merge conflict vs the live parent, a restack `Conflict` or `Restack failed` (a parked freeze included), a Draft PR, `Seam review pending, no review running`, or `Review skipped, out of review quota`. For those the envelope stays `in_progress` but names the blocker in the top-level `blocker`, `prNumber`, `headSha`, and `issues[]`, with `actAfter: "agents_idle"`, `waitingOn` listing the busy agents, and `repair` when there is one. Plan the fix from it, but do not patch, merge, or push while `actAfter` is set: keep calling `stack_wait` with the same cursor, and act when a snapshot after the agents finish returns `attention`; a Cyclone push can change or clear the blocker, so re-read it then. Blockers the agents can change (a red CI failure, a failed review or Tempest run, seam findings, a bounce) keep the plain hold: `blocker` is null, `actAfter` is null, and only `busy[]` names them. If a hold looks stuck, refresh `stack_status` and tell the human. Do not turn Cyclone off or Auto land off, and do not turn Auto land on to end the wait; turn it on only as "Re-arm after a bounce" allows. If the remote head moves while you wait, stop and tell the human.
38
+ 3. `attention` always means something for you to fix. A land gate that is only waiting (`landGatePending` with `ci_pending`, `tempest_pending`, `tempest_running`, or another `*_pending` / `*_running` reason on the land PR) returns `in_progress`, not `attention`: keep watching. On `attention`, read `repair` when present: it names the blocked PR's `branch` and the concrete fix. For `restack_conflict` and `merge_conflict` it gives `liveParent` (the sibling PR head, or `mg-stack-N` once the lower layers are merged or promoted, or the land PR's base such as `main` when the blocked PR is the land PR, never `mg-park-*`) and the conflicted `files`; a null `liveParent` means stop and tell the human. For `ci_failure` it names the failing check. For `tempest_findings` on the land PR it names the land PR, its `branch` (`mg-stack-N`), and where the Tempest report is (`findings`). A stack whose `autoLandOff.reason` is `bounced` (a `merge_failed`, `gh_error`, or `ci_timeout` bounce turned Auto land off and nobody took the stack back since) returns `attention` with the blocker `Auto land off after <kind> bounce`, the bounce in `issues[]`, and a `repair` of kind `auto_land_off`; handle it as "Re-arm after a bounce" describes. A stack whose review unit was abandoned because a human moved its bottom PR off `mg-stack-N` returns `attention` with the blocker `Review unit abandoned: based on <base>` and a `repair` of kind `unit_abandoned`: do not patch or retarget anything. Ask the human whether to re-root it: if at least two PRs remain open, tell them that `mg stack adopt owner/repo#<bottom PR>` re-roots it on a new review unit; with only one open PR, re-adopting will not create a review unit, so tell them to close and resubmit it to create a fresh stack, then stop. Auto land records why it is waiting in the stack's `autoLandWait` (`reason`, `prNumber`, `headSha`, `since`), and the watch returns `attention` on that PR when the wait cannot clear on its own: a failing check Auto land reads live on GitHub while the ledger may still show CI green (blocker `CI failed — Auto land reads a failing check on GitHub at this head`, `repair` kind `ci_failure`); a check pending for 45 minutes (`repair` kind `ci_pending`: find the check that never finished and re-run it, or tell the human when no workflow reports it); no approving Vortex review for 30 minutes with no Vortex run working (`repair` kind `vortex_not_green`: fix or dismiss the findings, or tell the human when no review exists at the head); and a promote GitHub refused 3 times with `merge_failed`, after which Auto land stops retrying that head (`repair` kind `promote_failed`: fix the cause on the PR and push a new head, or tell the human when the cause is outside the PR; never toggle Auto land yourself for it). A layer whose live head has a Vortex verdict that requests changes after Cyclone left those findings for a person (a hold directive, the Vortex patch cap, auto-patch off, no billing, a batch it gave up on, or a PR state it could not read) returns `attention` with the blocker `Vortex findings need a person` and a `repair` of kind `vortex_findings` naming the Cyclone reason (`handoff`) and where the review is (`findings`): verify each finding against the checkout, fix the real ones with the smallest patch and an ordinary push, and tell the human about the ones you judge wrong. `repair` does not replace the checks below. On `attention`, fix the named PR: `prNumber` and `headSha` identify the blocked PR, while `currentCandidate` identifies the promote candidate. The gate covers the bottom and its direct child only when the child has current-head DIRTY on the live bottom branch. A GitHub DIRTY whose `parentBranch` starts with `mg-park-` is not that gate. Refresh `stack_status`; use `queue_status` with the same `stack_id` when bounce details are needed. Verify the blocker still applies to the current PR head. Never treat `verifyHeadSha` as the current head: it is a queue verification SHA and can be stale or synthetic. Compare the live PR branch head, the current stack layer head, and the checkout before editing. Read the live PR head with `git ls-remote origin refs/heads/<branch>` or `gh api repos/OWNER/REPO/pulls/N --jq .head.sha`. If you need CI logs, use `gh run list --branch <branch>`, `gh run view <run-id> --log-failed`, or `gh api repos/OWNER/REPO/commits/SHA/check-runs`. Read GitHub only through git and these REST calls; do not use other `gh pr` subcommands. Restack `clean` does not mean mergeable or CI-green. Attention is a diagnosis, not permission to mutate stack or queue policy; the only policy change you make is the re-arm in "Re-arm after a bounce". A bounce is the reason to look, not the moment to commit. `stack_wait` and `stack_status` hold the blocked PR's attention themselves while Vortex or Cyclone is still working on that PR, whatever the blocker (a red CI failure, a merge conflict, a bounce, Draft, a failed restack or review): they return `in_progress` instead of `attention`, with `busy[]` naming the PR (`prNumber`, `headSha`), the `blocker`, and the `agent` (`vortex` or `cyclone`), and `agents` carrying that PR's `vortexStatus`, `cycloneStatus`, `vortexReview`, and `busy` flags. `in_progress` with a nonempty `busy[]` means do not patch yet: keep calling `stack_wait` with the same cursor until that work has finished and the watch returns `attention`, then make the follow-up commit. You do not need an extra `stack_status` refresh each slice for this check. A nonempty `busy[]` reports still-working agents on that PR's current head. Vortex holds when `vortexReview` is `reviewing` at the head, an unfinished vortex `agentRuns` row at the head is `reviewing`, or an unfinished vortex run at the head is `queued` and started within the last 15 minutes (with no `agentRuns` or `vortexReview` on the layer, `vortexStatus` `reviewing` holds). Cyclone holds when `cycloneStatus` is `patching`, an unfinished cyclone `agentRuns` row is `patching`, or the server sees a live Cyclone patch lease or an open Cyclone check run on the head. `seam_pending` alone, and a `queued` run older than 15 minutes, are not treated as work in progress. `all_clear`, `findings`, `skipped`, `failed`, `throttled`, `awaiting_fix`, and a null status are finished. A queued Vortex run holds for at most 15 minutes; reviewing and patching hold until the run finishes or its lease or check run is cleared. A blocker no Vortex or Cyclone run can clear is not hidden by the hold: a merge conflict vs the live parent, a restack `Conflict` or `Restack failed` (a parked freeze included), a Draft PR, `Seam review pending, no review running`, or `Review skipped, out of review quota`. For those the envelope stays `in_progress` but names the blocker in the top-level `blocker`, `prNumber`, `headSha`, and `issues[]`, with `actAfter: "agents_idle"`, `waitingOn` listing the busy agents, and `repair` when there is one. Plan the fix from it, but do not patch, merge, or push while `actAfter` is set: keep calling `stack_wait` with the same cursor, and act when a snapshot after the agents finish returns `attention`; a Cyclone push can change or clear the blocker, so re-read it then. Blockers the agents can change (a red CI failure, a failed review or Tempest run, seam findings, a bounce) keep the plain hold: `blocker` is null, `actAfter` is null, and only `busy[]` names them. If a hold looks stuck, refresh `stack_status` and tell the human. Do not turn Cyclone off or Auto land off, and do not turn Auto land on to end the wait; turn it on only as "Re-arm after a bounce" allows. If the remote head moves while you wait, stop and tell the human.
39
39
  4. Use a clean worktree on that named PR's head branch and record the remote starting SHA. Fix the current blocker with the smallest patch, and run the relevant checks. For conflicts, the live parent is the sibling PR head or the unit branch (`mg-stack-N`, Mergestorm's owned trunk, not an alias for main) — not the DTO `parentBranch` when that field starts with `mg-park-*`. If `parentBranch` is `mg-park-*`, never merge or rebase onto it; if the real live parent is unknown, stop. The land PR is the one PR whose head is `mg-stack-N`: its live parent is its base (`main`), and merging that base into `mg-stack-N` with an ordinary push is its conflict repair, the same as for any other layer. Tempest never patches: Cyclone patches a land PR when auto-patch is on for that stack, otherwise you do. On `tempest_findings` on the land PR, when `busy[]` shows no Cyclone run on it, read the Tempest report, verify each finding against the checkout as for Vortex findings, fix the real ones with the smallest patch on `mg-stack-N`, and push with an ordinary push, never a force push; while Cyclone is patching it the envelope stays `in_progress` with `busy[]`, so wait. Otherwise prefer merging that live parent into the PR branch, resolving locally, then an ordinary push. Never use Dashboard Continue. When the live parent is a sibling, never rebase onto trunk or `mg-stack-N` instead. Parked-freeze DIRTY alone needs no conflict patch. Leave GitHub bases as Mergestorm set them: the bottom of a 2+ PR stack is based on `mg-stack-N`, layer 2 on layer 1, PR3+ on `mg-park-*`.
40
- 5. CI-only fixes may run while Cyclone auto-patch is still on for the stack. Auto-patch may stay on, and you still wait until the current Vortex run and the current Cyclone run on that PR have finished before the follow-up commit. When the gate is clean, inspect `issues[]` and fix a live upstack CI failure one PR at a time, refreshing the gate before the next PR. Do not use higher-layer bounce history as a patch target. Do not fight Cyclone: if the remote head moved, stop and tell the human. Do not take over Cyclone's review-finding work while it is active; if the blocker requires that or a policy/queue action rather than a concrete patch, stop and explain what needs human attention. On a `Seam findings` blocker, read that PR's seam finding, the Vortex review "Integration review — U<n> seam" (`gh api repos/OWNER/REPO/pulls/N/reviews` and `gh api repos/OWNER/REPO/pulls/N/comments`), and patch its concrete findings on that PR's head; a "Decision required" seam finding needs a human, so tell them and do not guess. A seam finding you verified is wrong is recorded with `mg dismiss` (the audited dismissal in `mergestorm-pr-loop`) at that PR's live head and that seam review's id; a `mergestorm-loop: dismiss` comment alone does not clear the seam gate. On `Seam review failed`, do not patch; tell the human, unless a new push already has a seam re-review held in `busy[]`. On `Seam review pending, no review running`, do not patch; tell the human: the seam review was skipped (usually because the monthly review limit is reached) and nothing re-runs it until they raise the limit and click Continue on the stack, or push a new head. On `Review skipped, out of review quota`, do not patch; tell the human: the review credit limit for the period is reached and nothing re-reviews that head until they raise the limit and push a new head or re-run the review.
40
+ 5. CI-only fixes may run while Cyclone auto-patch is still on for the stack. Auto-patch may stay on, and you still wait until the current Vortex run and the current Cyclone run on that PR have finished before the follow-up commit. When the gate is clean, inspect `issues[]` and fix a live upstack CI failure one PR at a time, refreshing the gate before the next PR. Do not use higher-layer bounce history as a patch target. Do not fight Cyclone: if the remote head moved, stop and tell the human. Do not take over Cyclone's review-finding work while it is active; if the blocker requires that or a policy/queue action rather than a concrete patch, stop and explain what needs human attention. On a `Seam findings` blocker, read that PR's seam finding, the Vortex review "Integration review — U<n> seam" (`gh api repos/OWNER/REPO/pulls/N/reviews` and `gh api repos/OWNER/REPO/pulls/N/comments`), and patch its concrete findings on that PR's head; a "Decision required" seam finding needs a human, so tell them and do not guess. A seam finding you verified is wrong is recorded with `mg dismiss` (the audited dismissal in `mergestorm-pr-loop`) at that PR's live head and that seam review's id; a `mergestorm-loop: dismiss` comment alone does not clear the seam gate. On `Seam review failed`, do not patch; tell the human, unless a new push already has a seam re-review held in `busy[]`. On `Seam review pending, no review running`, do not patch; tell the human: the seam review was skipped (usually because the monthly review limit is reached) and nothing re-runs it until they raise the limit and click Continue on the stack, or push a new head. On `Seam review stuck in reviewing, no review running`, `Seam re-review pending, no review running`, or `Seam verdict is for an older head, no review running` (repair kind `seam_review_stuck`), do not patch or push; tell the human: no seam review is queued or running for that PR's live head, and clicking Continue on the stack re-queues it (a new pushed head also does). On `Review skipped, out of review quota`, do not patch; tell the human: the review credit limit for the period is reached and nothing re-reviews that head until they raise the limit and push a new head or re-run the review.
41
41
  6. Recheck Auto land (off is expected only after a disarming bounce you are handling) and the remote PR head (`git ls-remote origin refs/heads/<branch>`) immediately before pushing. Commit the patch and push the PR head with an ordinary push. A rebase onto that same live parent followed by force-with-lease is allowed only if the remote is still exactly the SHA you started from and Cyclone did not move it; use an explicit lease bound to that starting SHA. This exception never permits rebasing onto a parked freeze or substituting trunk / the unit for a sibling parent. If the remote head moved or the push is rejected because it moved, stop; do not rebase and retry.
42
42
  7. After your successful push, leave a clean worktree and keep watching the named stack. Start a new watch cycle with `enrolled_head_sha` set to the SHA you pushed (in the background: `mg stack watch <stack-id> --head <pushed-sha>`). If you handled a bounce, retain the returned `cursor.afterFinishedAt` as `after_finished_at` and `cursor.bounceId` as `bounce_id` so that attempt is not handled again. Keep these selectors unchanged across subsequent retries. Continue watching; do not enqueue anything. If the bounce you handled turned Auto land off, turn it back on as "Re-arm after a bounce" allows; if that section does not allow it, report and stop.
43
43