mergestorm 0.3.48 → 0.3.49

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.48 │
48
+ │ ▀▀▀▀▀▀▀▀▀▀▘ mergestorm v0.3.49 │
49
49
  │ ▀▀▀▀▀▀▀▀▀ ● msk_live_… · maelstrom │
50
50
  │ ▀▀▀▀▀▀ [████████░░░░░░░░░░░░░░░░░░░░░░] 25% used │
51
51
  │ ▝▀▀▀▀▀ local reviews + stacked PRs · mergestorm.ai │
package/dist/api.d.ts CHANGED
@@ -242,7 +242,15 @@ export declare function ensureUpperPark(stackId: string, cfg?: Config): Promise<
242
242
  }>;
243
243
  export declare const SURGE_INSTALL_URL = "https://github.com/apps/mergestorm-surge/installations/new";
244
244
  export declare function surgeNotInstalledMessage(owner: string, repo: string, installUrl?: string): string;
245
- export declare function readLandedQueueEntry(cfg: Config, stackId: string): Promise<MergeQueueEntryDto | null>;
245
+ export declare class LandedQueueReadError extends CommandError {
246
+ readonly status: number;
247
+ readonly retryAfterSeconds?: number | undefined;
248
+ constructor(status: number, retryAfterSeconds?: number | undefined);
249
+ }
250
+ export declare function readLandedQueueEntry(cfg: Config, stackId: string, opts?: {
251
+ signal?: AbortSignal;
252
+ timeoutMs?: number;
253
+ }): Promise<MergeQueueEntryDto | null>;
246
254
  export declare function findStackPull(cfg: Config, input: {
247
255
  owner: string;
248
256
  repo: string;
package/dist/api.js CHANGED
@@ -510,10 +510,19 @@ function stackPullFailure(status, body, owner, repo, failLabel) {
510
510
  const detail = typeof rejection.message === "string" ? rejection.message : JSON.stringify(body);
511
511
  return new CommandError(`${failLabel} (HTTP ${status}): ${detail}`);
512
512
  }
513
- export async function readLandedQueueEntry(cfg, stackId) {
514
- const { status, body } = await apiFetch(cfg, `/api/v1/stacks/queue?stackId=${encodeURIComponent(stackId)}&landed=1`);
513
+ export class LandedQueueReadError extends CommandError {
514
+ status;
515
+ retryAfterSeconds;
516
+ constructor(status, retryAfterSeconds) {
517
+ super(`Failed to read the landed merge queue entry (HTTP ${status})`);
518
+ this.status = status;
519
+ this.retryAfterSeconds = retryAfterSeconds;
520
+ }
521
+ }
522
+ export async function readLandedQueueEntry(cfg, stackId, opts = {}) {
523
+ const { status, body, retryAfterSeconds } = await apiFetch(cfg, `/api/v1/stacks/queue?stackId=${encodeURIComponent(stackId)}&landed=1`, opts);
515
524
  if (status !== 200)
516
- throw new CommandError(`Failed to read the landed merge queue entry (HTTP ${status})`);
525
+ throw new LandedQueueReadError(status, retryAfterSeconds);
517
526
  const landed = body?.landed;
518
527
  return landed && landed.state === "landed" ? landed : null;
519
528
  }
@@ -16,6 +16,7 @@ import { ansi } from "../ui/ansi.js";
16
16
  import { runLineTabsBrowser } from "../ui/line-tabs.js";
17
17
  import { present } from "../ui/present.js";
18
18
  import { canBrowse } from "./browse.js";
19
+ import { isTransientReviewPollError } from "./review-client.js";
19
20
  import { openHelpBrowser } from "./help.js";
20
21
  export function stackHeldLine(held) {
21
22
  if (!held)
@@ -85,6 +86,16 @@ const STACK_USAGE = `usage:
85
86
  otherwise lands the bottom open PR (unit-less stacks).`;
86
87
  /** Cap the registered-stack guard's API probe so it cannot stall local authoring. */
87
88
  const STACK_GUARD_TIMEOUT_MS = 8_000;
89
+ async function listRegisteredStacksRetryingOnce(list, cfg) {
90
+ try {
91
+ return await list(cfg, { timeoutMs: STACK_GUARD_TIMEOUT_MS });
92
+ }
93
+ catch (err) {
94
+ if (!isTransientReviewPollError(err))
95
+ throw err;
96
+ return list(cfg, { timeoutMs: STACK_GUARD_TIMEOUT_MS });
97
+ }
98
+ }
88
99
  /** Find a registered stack layer whose branch matches `parentBranch`. */
89
100
  export function findRegisteredParent(stacks, parentBranch, owner, repo) {
90
101
  const want = parentBranch.trim();
@@ -544,7 +555,7 @@ export async function cmdStackCreate(args, deps = {}) {
544
555
  if (origin) {
545
556
  try {
546
557
  const cfg = await loadConfigFn();
547
- const registered = await listStacksFn(cfg, { timeoutMs: STACK_GUARD_TIMEOUT_MS });
558
+ const registered = await listRegisteredStacksRetryingOnce(listStacksFn, cfg);
548
559
  registeredHit = assertMayParentOntoRegistered(parentBranch, registered, extend, "create", origin.owner, origin.repo);
549
560
  }
550
561
  catch (err) {
@@ -694,7 +705,7 @@ export async function cmdStackSubmit(args, deps = {}) {
694
705
  let registered = [];
695
706
  try {
696
707
  const cfg = await loadConfigFn();
697
- registered = await listStacksFn(cfg, { timeoutMs: STACK_GUARD_TIMEOUT_MS });
708
+ registered = await listRegisteredStacksRetryingOnce(listStacksFn, cfg);
698
709
  for (const parent of parents) {
699
710
  if (assertMayParentOntoRegistered(parent, registered, extend, "submit", owner, repo)) {
700
711
  registeredHits += 1;
@@ -133,7 +133,8 @@ export function autoFixCiActive(stack, layer) {
133
133
  }
134
134
  export const CI_DID_NOT_FINISH_BLOCKER = "CI did not finish — left for a person";
135
135
  export function pendingCiLeftForPerson(stack, layer) {
136
- return layer.ciStatus === "pending" && !isLandPrLayer(stack, layer) &&
136
+ return layer.ciStatus === "pending" && layer.checks?.cancelledOnly === true &&
137
+ sameHead(layer.checks.namedRunsCompleteHeadSha, layer.headSha) && !isLandPrLayer(stack, layer) &&
137
138
  sameHead(layer.cycloneCiHandoff?.headSha, layer.headSha);
138
139
  }
139
140
  function ciHandoffNote(stack, layer) {
@@ -277,12 +278,12 @@ function layerAttention(stack, layer, queue, cursor) {
277
278
  const name = layer.checks?.failingName?.trim();
278
279
  return hardBlock(`CI failed${name ? ` — ${name}` : ""}`, "ci_failed");
279
280
  }
280
- if (pendingCiLeftForPerson(stack, layer))
281
- return hardBlock(CI_DID_NOT_FINISH_BLOCKER, "ci_failed");
282
281
  if (layer.vortexStatus === "failed" && !vortexRetryScheduled(layer) &&
283
282
  (!layer.vortexReview?.head_sha || sameHead(layer.vortexReview.head_sha, layer.headSha))) {
284
283
  return hardBlock(VORTEX_REVIEW_FAILED, "vortex_failed");
285
284
  }
285
+ if (pendingCiLeftForPerson(stack, layer))
286
+ return hardBlock(CI_DID_NOT_FINISH_BLOCKER, "ci_failed");
286
287
  if (quotaSkippedReview(layer))
287
288
  return hardBlock(VORTEX_REVIEW_OUT_OF_QUOTA, "vortex_out_of_quota");
288
289
  const vortexSkip = vortexIdleSkipBlocker(layer);
@@ -788,7 +789,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
788
789
  branch: layer.branch,
789
790
  failingCheck,
790
791
  steps: pendingCiLeftForPerson(stack, layer)
791
- ? `CI on ${layer.branch} at ${layer.headSha ?? "its live head"} ended cancelled or interrupted and did not finish after Cyclone's one re-run. Find the cancelled run (gh run list --branch ${layer.branch}), read why it stopped (gh run view <run-id>), and re-run it (gh run rerun <run-id>) if it was a runner or infrastructure stop. If the job itself cancels, fix that on ${layer.branch} and push. Do not retarget the PR base.`
792
+ ? `CI on ${layer.branch} at ${layer.headSha ?? "its live head"} has completed cancelled checks with no checks still running after Cyclone's handoff. Find the cancelled run (gh run list --branch ${layer.branch}), read why it stopped (gh run view <run-id>), and re-run it (gh run rerun <run-id>) if it was a runner or infrastructure stop. If the job itself cancels, fix that on ${layer.branch} and push. Do not retarget the PR base.`
792
793
  : `Read the failing ${failingCheck ? `check ${failingCheck}` : "checks"} on ${layer.branch} (gh run list --branch ${layer.branch}, then gh run view <run-id> --log-failed), fix it on ${layer.branch} with the smallest patch, run that check locally, confirm the remote head is still ${layer.headSha ?? "the head you started from"}, then an ordinary git push. Do not retarget the PR base.`,
793
794
  ...(note ? { note } : {}),
794
795
  };
@@ -137,6 +137,8 @@ export type StackLayerChecks = {
137
137
  * them (#1602).
138
138
  */
139
139
  namedRunsSeeded?: boolean;
140
+ cancelledOnly?: boolean;
141
+ namedRunsCompleteHeadSha?: string;
140
142
  /**
141
143
  * True when the persisted `namedRuns` carries the reserved synthetic
142
144
  * "commit status" run at a pending/failure verdict. Webhook deliveries
@@ -1,5 +1,5 @@
1
1
  import type { Config } from "./config.js";
2
- import type { MergeQueueEntryDto } from "./stack-dto.js";
2
+ import { readLandedQueueEntry } from "./api.js";
3
3
  import { pollStackWatch, type StackWatchCursor, type StackWatchEnvelope } from "./stack-watch.js";
4
4
  export declare const STACK_WATCH_MARKER = "MS-WATCH";
5
5
  export declare const STACK_WATCH_EXIT: {
@@ -26,7 +26,7 @@ export type RunStackWatchOptions = {
26
26
  write?: (line: string) => void;
27
27
  writeMarker?: (line: string) => void;
28
28
  poll?: typeof pollStackWatch;
29
- readLanded?: (cfg: Config, stackId: string) => Promise<MergeQueueEntryDto | null>;
29
+ readLanded?: typeof readLandedQueueEntry;
30
30
  sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
31
31
  now?: () => number;
32
32
  };
@@ -1,5 +1,5 @@
1
1
  import { setTimeout as sleep } from "node:timers/promises";
2
- import { API_TIMEOUT_PREFIX, readLandedQueueEntry } from "./api.js";
2
+ import { API_TIMEOUT_PREFIX, DEFAULT_API_TIMEOUT_MS, LandedQueueReadError, readLandedQueueEntry } from "./api.js";
3
3
  import { isTransientReviewPollError } from "./commands/review-client.js";
4
4
  import { pollStackWatch, StackWatchError, StackWatchTimeoutError, } from "./stack-watch.js";
5
5
  export const STACK_WATCH_MARKER = "MS-WATCH";
@@ -14,6 +14,8 @@ const TRANSIENT_WATCH_FAILURE = /HTTP (?:408|429|5\d\d)|snapshot missing|fetch f
14
14
  export function isTransientWatchFailure(err) {
15
15
  if (!(err instanceof Error))
16
16
  return false;
17
+ if (err instanceof LandedQueueReadError)
18
+ return err.status === 408 || err.status === 429 || (err.status >= 500 && err.status <= 599);
17
19
  if (isTransientReviewPollError(err))
18
20
  return true;
19
21
  if (err.cause !== undefined && isTransientReviewPollError(err.cause))
@@ -79,19 +81,55 @@ export async function runStackWatch(cfg, stackId, opts = {}) {
79
81
  const confirmedLanding = async (envelope) => {
80
82
  if (envelope.watch.reason !== "not_found")
81
83
  return envelope;
84
+ failures = 0;
85
+ permanentFailures = 0;
86
+ transientSince = null;
87
+ const unconfirmed = (failureMessage) => ({
88
+ ...envelope,
89
+ watch: {
90
+ ...envelope.watch,
91
+ reason: "landing_unconfirmed",
92
+ message: `The stack is gone, but its landed merge queue entry could not be read: ${failureMessage}. Landing is unconfirmed. Confirm a matching landed merge queue entry or that the actual land PR merged into its land target before reporting it landed. A layer PR merged into the owned trunk does not confirm landing. The watch is done; do not report it landed.`,
93
+ },
94
+ });
82
95
  let entry = null;
83
- try {
84
- entry = await readLanded(cfg, id);
85
- }
86
- catch {
87
- return {
88
- ...envelope,
89
- watch: {
90
- ...envelope.watch,
91
- reason: "landing_unconfirmed",
92
- message: "The stack is gone, but its landed merge queue entry could not be read. Landing is unconfirmed. Confirm each PR is merged (gh pr view <n> --json state) before reporting it landed. The watch is done; do not report it landed.",
93
- },
94
- };
96
+ while (true) {
97
+ if (now() >= deadline)
98
+ return unconfirmed("The watch deadline was reached before landing confirmation");
99
+ if (opts.signal?.aborted)
100
+ throw new DOMException("The operation was aborted", "AbortError");
101
+ try {
102
+ entry = await readLanded(cfg, id, {
103
+ signal: opts.signal,
104
+ timeoutMs: Math.min(DEFAULT_API_TIMEOUT_MS, deadline - now(), transientSince === null ? STACK_WATCH_TRANSIENT_WINDOW_MS : Math.max(1, STACK_WATCH_TRANSIENT_WINDOW_MS - (now() - transientSince))),
105
+ });
106
+ if (opts.signal?.aborted)
107
+ throw new DOMException("The operation was aborted", "AbortError");
108
+ break;
109
+ }
110
+ catch (err) {
111
+ if (isAbort(err, opts.signal))
112
+ throw err;
113
+ let failureMessage = err instanceof Error ? err.message : String(err);
114
+ if (isTransientWatchFailure(err)) {
115
+ failures += 1;
116
+ permanentFailures = 0;
117
+ transientSince ??= now();
118
+ const remaining = STACK_WATCH_TRANSIENT_WINDOW_MS - (now() - transientSince);
119
+ if (remaining > 0) {
120
+ const retryAfterSeconds = err instanceof LandedQueueReadError ? err.retryAfterSeconds : undefined;
121
+ await pause(Math.min(remaining, retryAfterSeconds !== undefined
122
+ ? Math.max(1_000, retryAfterSeconds * 1_000)
123
+ : Math.min(STACK_WATCH_TRANSIENT_MAX_WAIT_MS, STACK_WATCH_FAILED_RETRY_MS * 2 ** Math.min(failures - 1, 4))));
124
+ if (now() < deadline && now() - transientSince < STACK_WATCH_TRANSIENT_WINDOW_MS)
125
+ continue;
126
+ if (now() >= deadline)
127
+ return unconfirmed(`The watch deadline was reached while retrying landing confirmation: ${failureMessage}`);
128
+ }
129
+ failureMessage = `Transient landing confirmation retry budget exhausted: ${failureMessage}`;
130
+ }
131
+ return unconfirmed(failureMessage);
132
+ }
95
133
  }
96
134
  const enrolledHead = envelope.cursor?.enrolledHeadSha ?? null;
97
135
  const ours = Boolean(entry) && (entry.landedPrNumbers.some((pr) => seenPrs.has(pr)) ||
@@ -165,13 +203,14 @@ export async function runStackWatch(cfg, stackId, opts = {}) {
165
203
  return result;
166
204
  continue;
167
205
  }
168
- failures = 0;
169
- permanentFailures = 0;
170
- transientSince = null;
171
206
  cursor = envelope.cursor;
172
207
  notePrs(envelope);
173
- if (envelope.watch.done)
208
+ if (envelope.watch.done) {
174
209
  return await finished(envelope);
210
+ }
211
+ failures = 0;
212
+ permanentFailures = 0;
213
+ transientSince = null;
175
214
  if (envelope.status === "attention") {
176
215
  const key = stackWatchAttentionKey(envelope);
177
216
  const blocker = (envelope.blocker ?? "").toLowerCase();
package/dist/ui/banner.js CHANGED
@@ -182,6 +182,9 @@ const WHATS_NEW = {
182
182
  "0.3.48": [
183
183
  "Watch names CI Cyclone could not finish",
184
184
  ],
185
+ "0.3.49": [
186
+ "Submit retries a stalled stacks check",
187
+ ],
185
188
  };
186
189
  function densityFromVariant(variant) {
187
190
  if (variant === "mini" || variant === "nano")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mergestorm",
3
- "version": "0.3.48",
3
+ "version": "0.3.49",
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",
@@ -47,8 +47,9 @@ mg skill install --claude --cursor --agents
47
47
  - Verify it against the current checkout. Do not treat the finding text as proven.
48
48
  - Prefer the smallest correct patch. Do not refactor around a finding.
49
49
  - Patch concrete bugs. A chat-only explanation is not a dismiss.
50
- - If you skip a finding (not reproducible, policy fork, or needs a human), post a public GitHub PR comment before you stop. First line: `mergestorm-loop: dismiss`. Then one line per skipped finding (path, severity, why). That first line is the only dismiss marker Vortex reads.
51
- - If you verified a finding is wrong (not merely skipped) and it blocks the PR, for example a seam finding holding `Seam findings`, record the dismissal with `review_dismiss` (CLI: `mg dismiss`):
50
+ - A finding a human must decide (Decision required, policy fork, needs a human) stays open. Do not dismiss it and do not post the dismiss marker for it: tell the human which finding it is and stop.
51
+ - A PR comment whose first line is `mergestorm-loop: dismiss` is not a note about one finding. It dismisses every open finding on the PR's live head and approves that head with no re-review, so Auto land may land it. Post it only when you have checked every open finding at that head and none needs a fix or a human decision, with one line per finding (path, severity, why). Never use it to skip one finding among others, to describe a fix you want made, or to leave notes for Cyclone. That first line is the only dismiss marker Vortex reads.
52
+ - If you verified a single finding is wrong or not reproducible, for example a seam finding holding `Seam findings`, dismiss that finding by id with `review_dismiss` (CLI: `mg dismiss`):
52
53
  - First call it with `preview: true`, the PR's live `head_sha` and the review id (review_id) of the Vortex review that raised the finding. It lists that review's finding ids and the seam gate and writes nothing.
53
54
  - Then call it with the `finding_ids` you checked (or `scope: "review"` when you checked every finding of that review), a `reason` that says why each is wrong, and an `evidence_url` when you have one.
54
55
  - A refusal (stale_head, review_mismatch, unknown_finding, forbidden) means nothing was written. Re-read the pass at the live head; do not retry with another review's ids.