mergestorm 0.3.35 → 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.35 │
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,6 +54,13 @@ 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;
57
64
  } | {
58
65
  kind: "vortex_findings";
59
66
  prNumber: number;
@@ -68,6 +75,14 @@ export type StackRepairHint = {
68
75
  headSha: string | null;
69
76
  branch: string;
70
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;
71
86
  } | {
72
87
  kind: "seam_review_stuck";
73
88
  prNumber: number;
@@ -89,6 +104,21 @@ export type StackRepairHint = {
89
104
  branch: string;
90
105
  since: string;
91
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;
92
122
  } | {
93
123
  kind: "promote_failed";
94
124
  prNumber: number;
@@ -112,21 +142,46 @@ export declare function boundDirty(layer: StackLayerDto): boolean;
112
142
  export declare function mergeabilityPending(layer: StackLayerDto): boolean;
113
143
  export declare function sameHead(a: string | null | undefined, b: string | null | undefined): boolean;
114
144
  export declare function currentLayer(stack: StackDto): StackLayerDto | undefined;
145
+ export declare const RESTACK_FAILED = "Restack failed";
115
146
  export declare const SEAM_REVIEW_NOT_RUNNING = "Seam review pending, no review running";
116
147
  export declare const VORTEX_REVIEW_OUT_OF_QUOTA = "Review skipped, out of review quota";
117
148
  export declare const SEAM_REVIEW_STUCK = "Seam review stuck in reviewing, no review running";
118
149
  export declare const SEAM_REREVIEW_NOT_RUNNING = "Seam re-review pending, no review running";
119
150
  export declare const SEAM_VERDICT_STALE = "Seam verdict is for an older head, no review running";
120
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";
121
155
  export declare const AUTO_LAND_CI_PENDING_ATTENTION_MS: number;
122
156
  export declare const AUTO_LAND_VORTEX_WAIT_ATTENTION_MS: number;
123
157
  export declare const AUTO_LAND_PROMOTE_FAILED_ATTEMPTS = 3;
124
158
  export declare const AUTO_LAND_CI_FAILED_BLOCKER = "CI failed \u2014 Auto land reads a failing check on GitHub at this head";
125
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;
126
177
  export declare function conflictLiveParent(stack: StackDto, layer: StackLayerDto): string | null;
127
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;
128
183
  export declare function agentsCannotClear(blocker: string): boolean;
129
- 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;
130
185
  /** Pair-gate attention plus live hard blocks on every other open layer. */
131
186
  export declare function stackBlockers(stack: StackDto, entries?: MergeQueueEntryDto[], cursor?: Pick<StackWatchCursor, "bounceId" | "afterFinishedAt">, nowMs?: number): {
132
187
  attention: null;
@@ -141,6 +196,7 @@ export declare function stackBlockers(stack: StackDto, entries?: MergeQueueEntry
141
196
  agents: StackLayerAgents | null;
142
197
  repair: StackRepairHint | null;
143
198
  landGatePending: StackLandGatePending | null;
199
+ queueWait: StackQueueWait | null;
144
200
  } | {
145
201
  attention: StackBlocker | null;
146
202
  held: null;
@@ -154,6 +210,7 @@ export declare function stackBlockers(stack: StackDto, entries?: MergeQueueEntry
154
210
  agents: StackLayerAgents | null;
155
211
  repair: StackRepairHint | null;
156
212
  landGatePending: StackLandGatePending | null;
213
+ queueWait: StackQueueWait | null;
157
214
  };
158
215
  /** Compact contract text shared by status and wait summaries. */
159
- 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,6 +51,7 @@ 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";
56
57
  export const SEAM_REVIEW_STUCK = "Seam review stuck in reviewing, no review running";
@@ -71,7 +72,16 @@ function vortexChangesRequestedAtHead(layer) {
71
72
  function cycloneHandoffAtHead(stack, layer) {
72
73
  if (sameHead(layer.cycloneHandoff?.headSha, layer.headSha))
73
74
  return layer.cycloneHandoff.reason;
74
- return stack.autoPatchOverride === false ? "auto_patch_off" : null;
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("_", " ");
75
85
  }
76
86
  function vortexFindingsLeftForPerson(stack, layer) {
77
87
  return vortexChangesRequestedAtHead(layer) && !layerAgentsBusy(layer).cyclone &&
@@ -82,6 +92,40 @@ function quotaSkippedReview(layer) {
82
92
  return review?.status === "skipped" && review.skip_reason === "quota_exceeded" &&
83
93
  sameHead(review.head_sha, layer.headSha);
84
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
+ }
85
129
  function seamGateBlocker(stack, layer) {
86
130
  const members = stack.unit?.members ?? [];
87
131
  if (layer.prNumber === stack.unit?.landPrNumber)
@@ -132,7 +176,7 @@ function layerAttention(stack, layer, queue, cursor) {
132
176
  if (layer.restackError.kind === "rebase_conflict")
133
177
  return hardBlock("Conflict");
134
178
  if (!restackRetryPending(layer.restackError, layer))
135
- return hardBlock("Restack failed");
179
+ return hardBlock(RESTACK_FAILED);
136
180
  }
137
181
  if (layer.state === "conflict")
138
182
  return hardBlock("Conflict");
@@ -152,13 +196,16 @@ function layerAttention(stack, layer, queue, cursor) {
152
196
  sameHead(run.sha, layer.headSha)))
153
197
  return hardBlock("Cyclone failed");
154
198
  const tempest = layer.agentRuns?.find((run) => run.agent === "tempest" &&
155
- ["findings", "failed"].includes(run.status) && sameHead(run.sha, layer.headSha));
199
+ run.status === "findings" && sameHead(run.sha, layer.headSha));
156
200
  if (tempest)
157
201
  return hardBlock(`Tempest ${tempest.status}`);
158
202
  if (layer.vortexStatus === "failed")
159
203
  return hardBlock("Review failed");
160
204
  if (quotaSkippedReview(layer))
161
205
  return hardBlock(VORTEX_REVIEW_OUT_OF_QUOTA);
206
+ const vortexSkip = vortexIdleSkipBlocker(layer);
207
+ if (vortexSkip)
208
+ return hardBlock(vortexSkip.blocker);
162
209
  const seam = seamGateBlocker(stack, layer);
163
210
  if (seam)
164
211
  return hardBlock(seam);
@@ -263,6 +310,55 @@ export function autoLandWaitBlocker(stack, layer, nowMs) {
263
310
  return null;
264
311
  }
265
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
+ }
266
362
  function isTerminalLayer(layer) {
267
363
  return layer.state === "merged" || layer.state === "closed";
268
364
  }
@@ -299,24 +395,48 @@ export function conflictRepairSteps(branch, liveParent) {
299
395
  }
300
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.`;
301
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
+ }
302
405
  function landPrLiveParent(stack, layer) {
303
406
  const base = stack.unit?.landTarget?.trim() || layer.parentBranch?.trim();
304
407
  return base && !isMgParkBase(base) && base !== layer.branch ? base : null;
305
408
  }
306
409
  export function agentsCannotClear(blocker) {
307
- return blocker === "Conflict" || blocker === "Restack failed" || blocker === "Draft PR" ||
410
+ return blocker === "Conflict" || blocker === RESTACK_FAILED || blocker === "Draft PR" ||
308
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 ||
309
414
  STUCK_SEAM_BLOCKERS.includes(blocker) ||
310
415
  blocker.startsWith("Merge conflicts") || blocker.startsWith(UNIT_ABANDONED_BLOCKER_PREFIX);
311
416
  }
312
- function isTempestFindingsBlocker(attention) {
417
+ const TEMPEST_RERUN_BLOCKER = /^(?:landing blocked:\s*)?tempest_(failed|stopped)\b/i;
418
+ function isTempestFindingsBlocker(attention, bounce) {
313
419
  const blocker = attention.blocker.trim();
420
+ const reason = bounce?.bounceDetail?.message?.trim();
421
+ if (reason && TEMPEST_RERUN_BLOCKER.test(reason))
422
+ return false;
314
423
  return attention.bounceKind === "tempest_findings" || /^Tempest findings\b/i.test(blocker) ||
315
424
  /^(?:landing blocked:\s*)?tempest_findings\b/i.test(blocker);
316
425
  }
317
- export function stackRepair(stack, attention, layer, bounce) {
426
+ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
318
427
  if (!attention || !layer || attention.prNumber !== layer.prNumber)
319
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
+ }
320
440
  const landPr = !!stack.unit?.branch && layer.prNumber === stack.unit.landPrNumber && layer.branch === stack.unit.branch;
321
441
  const restackConflict = attention.blocker === "Conflict" || attention.bounceKind === "restack_conflict";
322
442
  const mergeConflict = attention.blocker.startsWith("Merge conflicts");
@@ -332,6 +452,18 @@ export function stackRepair(stack, attention, layer, bounce) {
332
452
  steps: conflictRepairSteps(layer.branch, liveParent),
333
453
  };
334
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
+ }
335
467
  if (attention.blocker.startsWith(UNIT_ABANDONED_BLOCKER_PREFIX)) {
336
468
  const openPrCount = stack.layers.filter((candidate) => candidate.prNumber > 0 && !isTerminalLayer(candidate)).length;
337
469
  return {
@@ -368,7 +500,7 @@ export function stackRepair(stack, attention, layer, bounce) {
368
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.`,
369
501
  };
370
502
  }
371
- if (landPr && isTempestFindingsBlocker(attention)) {
503
+ if (landPr && isTempestFindingsBlocker(attention, bounce)) {
372
504
  const findings = `gh api repos/${stack.owner}/${stack.repo}/issues/${layer.prNumber}/comments`;
373
505
  return {
374
506
  kind: "tempest_findings",
@@ -379,6 +511,35 @@ export function stackRepair(stack, attention, layer, bounce) {
379
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.`,
380
512
  };
381
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
+ }
382
543
  const wait = autoLandWaitOn(stack, layer);
383
544
  if (wait && attention.blocker.startsWith(AUTO_LAND_CI_PENDING_PREFIX)) {
384
545
  return {
@@ -422,7 +583,7 @@ export function stackRepair(stack, attention, layer, bounce) {
422
583
  branch: layer.branch,
423
584
  handoff,
424
585
  findings,
425
- steps: `Vortex requested changes on ${layer.branch} at ${layer.headSha ?? "its live head"} and Cyclone left them for a person${handoff ? ` (${handoff.replaceAll("_", " ")})` : ""}, 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.`,
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.`,
426
587
  };
427
588
  }
428
589
  if (attention.blocker.startsWith("CI failed") || attention.bounceKind === "ci_failure") {
@@ -454,6 +615,7 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
454
615
  let attentionLayer;
455
616
  let bounce;
456
617
  const issues = [];
618
+ const queueWait = stack.archivedAt ? null : mergeQueueWait(queue, nowMs);
457
619
  for (const layer of open) {
458
620
  const selected = layer.prNumber === candidate?.prNumber;
459
621
  let result = layerAttention(stack, layer, selected ? queue : [], cursor);
@@ -473,6 +635,9 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
473
635
  waitNamed = true;
474
636
  }
475
637
  }
638
+ if (!result.blocker && selected && queueWait?.stalled) {
639
+ result = { blocker: `${MERGE_QUEUE_STALLED_PREFIX}${queueWait.text}`, bounceKind: null };
640
+ }
476
641
  if (!result.blocker) {
477
642
  if (mergeabilityPending(layer) && (selected || !candidate || layer.position > candidate.position)) {
478
643
  issues.push({ prNumber: layer.prNumber, headSha: layer.headSha ?? null,
@@ -495,6 +660,10 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
495
660
  else if (!candidate || layer.position > candidate.position)
496
661
  issues.push(issue);
497
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;
498
667
  const settleIssue = mergeabilitySettleIssue(stack, open, issues, attention);
499
668
  if (settleIssue)
500
669
  issues.push(settleIssue);
@@ -517,11 +686,14 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
517
686
  if (busy.length && attention && agentsCannotClear(attention.blocker)) {
518
687
  const held = { ...attention, actAfter: "agents_idle", waitingOn: busy.map((entry) => entry.agent) };
519
688
  return { attention: null, held, issues: [attention, ...issues], currentCandidate, bounce: undefined, busy, agents,
520
- 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 };
521
694
  }
522
- if (busy.length)
523
- return { attention: null, held: null, issues, currentCandidate, bounce: undefined, busy, agents, repair: null, landGatePending };
524
- 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 };
525
697
  }
526
698
  function mergeabilitySettleIssue(stack, open, issues, attention) {
527
699
  const settle = stack.autoEnqueueSettle;
@@ -539,7 +711,7 @@ function mergeabilitySettleIssue(stack, open, issues, attention) {
539
711
  blocker: `Merge state unknown${vs}; Auto land is waiting for a mergeability verdict`, bounceKind: null };
540
712
  }
541
713
  /** Compact contract text shared by status and wait summaries. */
542
- export function stackBlockersSummary(attention, issues, busy = [], landGatePending = null, actAfter = null) {
714
+ export function stackBlockersSummary(attention, issues, busy = [], landGatePending = null, actAfter = null, queueWait = null) {
543
715
  const agentWork = (entry) => entry.agent === "cyclone" ? "Cyclone patches" : "Vortex reviews";
544
716
  const blocked = attention && actAfter && busy.length
545
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)`
@@ -552,5 +724,6 @@ export function stackBlockersSummary(attention, issues, busy = [], landGatePendi
552
724
  const gate = landGatePending && !attention
553
725
  ? ` · land gate: #${landGatePending.prNumber} ${landGatePending.reason} (nothing to fix; wait)`
554
726
  : "";
555
- 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` : ""}` : "");
556
729
  }
@@ -307,6 +307,7 @@ export type StackUnitDto = {
307
307
  landPr: StackLayerDto | null;
308
308
  };
309
309
  export type CycloneOwnerMatch = "same" | "different" | "none" | "lookup_failed";
310
+ export type CyclonePatchOffReason = "stack_auto_patch_off" | "account_auto_patch_off" | "cyclone_not_connected";
310
311
  export type StackDto = {
311
312
  id: string;
312
313
  owner: string;
@@ -383,6 +384,7 @@ export type StackDto = {
383
384
  * `lookup_failed`: the comparison could not be loaded. Fail closed.
384
385
  */
385
386
  cycloneOwnerMatch?: CycloneOwnerMatch;
387
+ cyclonePatchOff?: CyclonePatchOffReason;
386
388
  layers: StackLayerDto[];
387
389
  /** Present when a review-unit row exists for this stack. */
388
390
  unit?: StackUnitDto;
@@ -422,6 +424,12 @@ export type MergeQueueEntryBatchDto = {
422
424
  state: MergeQueueBatchState;
423
425
  landedCount: number;
424
426
  };
427
+ export type MergeQueueAheadDto = {
428
+ stackId: string | null;
429
+ state: "running" | "waiting";
430
+ claimedAt: string | null;
431
+ waitReason: string | null;
432
+ };
425
433
  export type MergeQueueEntryDto = {
426
434
  id: string;
427
435
  stackId: string;
@@ -445,4 +453,6 @@ export type MergeQueueEntryDto = {
445
453
  cancelRequestedAt?: string | null;
446
454
  batch?: MergeQueueEntryBatchDto | null;
447
455
  finishedAt: string | null;
456
+ claimedAt?: string | null;
457
+ aheadInRepo?: MergeQueueAheadDto | null;
448
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, now());
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
@@ -141,6 +141,11 @@ const WHATS_NEW = {
141
141
  "stack watch names a stuck seam review",
142
142
  "stack watch names findings Cyclone left for you",
143
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
+ ],
144
149
  };
145
150
  function densityFromVariant(variant) {
146
151
  if (variant === "mini" || variant === "nano")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mergestorm",
3
- "version": "0.3.35",
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",