mergestorm 0.3.45 → 0.3.47

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.45 │
48
+ │ ▀▀▀▀▀▀▀▀▀▀▘ mergestorm v0.3.47 │
49
49
  │ ▀▀▀▀▀▀▀▀▀ ● msk_live_… · maelstrom │
50
50
  │ ▀▀▀▀▀▀ [████████░░░░░░░░░░░░░░░░░░░░░░] 25% used │
51
51
  │ ▝▀▀▀▀▀ local reviews + stacked PRs · mergestorm.ai │
package/dist/api.d.ts CHANGED
@@ -242,11 +242,17 @@ 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
246
  export declare function findStackPull(cfg: Config, input: {
246
247
  owner: string;
247
248
  repo: string;
248
249
  head: string;
249
250
  }): Promise<number | null>;
251
+ export declare function claimStackPulls(cfg: Config, input: {
252
+ owner: string;
253
+ repo: string;
254
+ heads: string[];
255
+ }): Promise<void>;
250
256
  export declare function openStackPull(cfg: Config, input: {
251
257
  owner: string;
252
258
  repo: string;
package/dist/api.js CHANGED
@@ -510,6 +510,13 @@ 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`);
515
+ if (status !== 200)
516
+ throw new CommandError(`Failed to read the landed merge queue entry (HTTP ${status})`);
517
+ const landed = body?.landed;
518
+ return landed && landed.state === "landed" ? landed : null;
519
+ }
513
520
  export async function findStackPull(cfg, input) {
514
521
  const query = new URLSearchParams(input).toString();
515
522
  const { status, body } = await apiFetch(cfg, `/api/v1/stacks/pulls?${query}`);
@@ -519,6 +526,18 @@ export async function findStackPull(cfg, input) {
519
526
  const n = body?.number;
520
527
  return typeof n === "number" && n > 0 ? n : null;
521
528
  }
529
+ export async function claimStackPulls(cfg, input) {
530
+ const { status, body } = await apiFetch(cfg, "/api/v1/stacks/pull-claims", {
531
+ method: "POST",
532
+ json: input,
533
+ });
534
+ if (status !== 200) {
535
+ const detail = body?.message
536
+ ?? body?.error
537
+ ?? `HTTP ${status}`;
538
+ throw new CommandError(`Could not claim ${input.heads.join(", ")}: ${String(detail)}`);
539
+ }
540
+ }
522
541
  export async function openStackPull(cfg, input) {
523
542
  const { status, body } = await apiFetch(cfg, "/api/v1/stacks/pulls", {
524
543
  method: "POST",
@@ -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, listMergeQueueEntries, listStacks, openStackPull, setStackPolicy, type StackDto, type StackPolicyPatch } from "../api.js";
3
+ import { adoptStack, ensureUpperPark, findStackPull, getEnrichedStack, listMergeQueueEntries, listStacks, openStackPull, claimStackPulls, 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";
@@ -137,6 +137,7 @@ export type StackSubmitDeps = {
137
137
  createPrs?: typeof createPrs;
138
138
  findStackPull?: typeof findStackPull;
139
139
  openStackPull?: typeof openStackPull;
140
+ claimStackPulls?: typeof claimStackPulls;
140
141
  loadConfig?: typeof loadConfig;
141
142
  listStacks?: typeof listStacks;
142
143
  adoptStack?: typeof adoptStack;
@@ -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, listMergeQueueEntries, listStacks, openStackPull, restackStack, setStackPolicy, } from "../api.js";
3
+ import { adoptStack, ensureUpperPark, findStackPull, getEnrichedStack, landNextStack, listMergeQueueEntries, listStacks, openStackPull, claimStackPulls, 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";
@@ -58,8 +58,9 @@ const STACK_USAGE = `usage:
58
58
  (exit 5 on timeout), so do not merge the streams (2>&1) before parsing stdout.
59
59
  mergestorm stack watch <stack-id> [--until attention|landed] [--ignore <blocker-substring>] [--max <minutes>] [--head <sha>] [--json]
60
60
  Loops stack wait with the cursor and stays silent while the stack is waiting or in progress.
61
- Prints one MS-WATCH ATTENTION line and exits 3 on attention or a repeated failed read; prints
62
- MS-WATCH LANDED and exits 0 only for a confirmed landing. Other terminal reasons exit 3 with ATTENTION. Run it as a background command.
61
+ Prints one MS-WATCH ATTENTION line and exits 3 on attention or on reads that keep failing (a permanent
62
+ error twice, or 15 minutes of timeouts and 5xx, which it retries with backoff); prints MS-WATCH LANDED
63
+ and exits 0 only for a confirmed landing, including a stack Mergestorm deleted after its queue entry landed. Other terminal reasons exit 3 with ATTENTION. Run it as a background command.
63
64
  --json writes exactly one JSON document to stdout and sends the MS-WATCH lines to stderr.
64
65
  mergestorm stack set <stack-id> [--auto-land on|off] [--auto-review on|off|default] [--auto-patch on|off|default] [--auto-resolve-conflicts on|off|default] [--auto-fix-ci on|off|default] [--json]
65
66
  mergestorm stack adopt <owner/repo>#<pr> [--auto-land on|off] [--auto-review on|off] [--auto-patch on|off] [--auto-resolve-conflicts on|off] [--auto-fix-ci on|off] [--json]
@@ -640,6 +641,7 @@ export async function cmdStackSubmit(args, deps = {}) {
640
641
  const createPrsFn = deps.createPrs ?? createPrs;
641
642
  const findStackPullFn = deps.findStackPull ?? findStackPull;
642
643
  const openStackPullFn = deps.openStackPull ?? openStackPull;
644
+ const claimStackPullsFn = deps.claimStackPulls ?? claimStackPulls;
643
645
  const loadConfigFn = deps.loadConfig ?? loadConfig;
644
646
  const listStacksFn = deps.listStacks ?? listStacks;
645
647
  const adoptStackFn = deps.adoptStack ?? adoptStack;
@@ -736,6 +738,20 @@ export async function cmdStackSubmit(args, deps = {}) {
736
738
  for (const plan of plans) {
737
739
  existingPrs.set(plan.branch, await opener.findOpen(plan.branch));
738
740
  }
741
+ const newHeads = plans
742
+ .filter((plan) => existingPrs.get(plan.branch) == null)
743
+ .map((plan) => plan.branch);
744
+ if (newHeads.length > 0) {
745
+ try {
746
+ const config = await loadConfigFn();
747
+ for (let i = 0; i < newHeads.length; i += 20) {
748
+ await claimStackPullsFn(config, { owner, repo, heads: newHeads.slice(i, i + 20) });
749
+ }
750
+ }
751
+ catch (err) {
752
+ progress(ansi.dim(` Could not hold the new PRs off the dashboard until adopt (${err instanceof Error ? err.message : String(err)}); continuing.`));
753
+ }
754
+ }
739
755
  for (const layer of layers) {
740
756
  if (!branchExistsFn(layer.branch, cwd)) {
741
757
  throw new CommandError(`Stack layer branch missing locally: ${layer.branch}`);
@@ -1042,7 +1058,9 @@ export async function cmdStackWatch(args, deps = {}) {
1042
1058
  ? `Stopped watching stack ${stackId}; it is not landed.`
1043
1059
  : result.envelope?.watch.reason === "not_found"
1044
1060
  ? `Stack ${stackId} was not found; landing is unconfirmed. Verify each PR before reporting it landed.`
1045
- : `Stack ${stackId} needs attention; it is not landed.`;
1061
+ : result.envelope?.watch.reason === "landing_unconfirmed"
1062
+ ? `Stack ${stackId} is gone, but landing is unconfirmed. Verify each PR before reporting it landed.`
1063
+ : `Stack ${stackId} needs attention; it is not landed.`;
1046
1064
  throw new CommandError(reason, result.exitCode, result.outcome === "timeout" ? "review_timeout" : "stack_attention");
1047
1065
  }
1048
1066
  export async function cmdStackStatus(args, deps = {}) {
@@ -168,7 +168,8 @@ const VORTEX_SKIP_BLOCKERS = {
168
168
  };
169
169
  export function vortexRetryScheduled(layer) {
170
170
  const review = layer.vortexReview;
171
- return review?.skip_reason?.trim() === "retry_scheduled" && sameHead(review.head_sha, layer.headSha);
171
+ const reason = review?.skip_reason?.trim();
172
+ return (reason === "retry_scheduled" || reason === "post_delayed") && sameHead(review?.head_sha, layer.headSha);
172
173
  }
173
174
  function vortexIdleSkipBlocker(layer) {
174
175
  if (layerAgentsBusy(layer).vortex)
@@ -1,4 +1,5 @@
1
1
  import type { Config } from "./config.js";
2
+ import type { MergeQueueEntryDto } from "./stack-dto.js";
2
3
  import { pollStackWatch, type StackWatchCursor, type StackWatchEnvelope } from "./stack-watch.js";
3
4
  export declare const STACK_WATCH_MARKER = "MS-WATCH";
4
5
  export declare const STACK_WATCH_EXIT: {
@@ -10,6 +11,9 @@ export declare const STACK_WATCH_SLICE_MS = 300000;
10
11
  export declare const STACK_WATCH_RECHECK_MS = 30000;
11
12
  export declare const STACK_WATCH_FAILED_RETRY_MS = 15000;
12
13
  export declare const STACK_WATCH_RATE_LIMIT_WAIT_MS = 30000;
14
+ export declare const STACK_WATCH_TRANSIENT_WINDOW_MS: number;
15
+ export declare const STACK_WATCH_TRANSIENT_MAX_WAIT_MS = 60000;
16
+ export declare function isTransientWatchFailure(err: unknown): boolean;
13
17
  export type StackWatchUntil = "attention" | "landed";
14
18
  export type StackWatchOutcome = "attention" | "landed" | "failed" | "timeout" | "aborted";
15
19
  export type RunStackWatchOptions = {
@@ -22,6 +26,7 @@ export type RunStackWatchOptions = {
22
26
  write?: (line: string) => void;
23
27
  writeMarker?: (line: string) => void;
24
28
  poll?: typeof pollStackWatch;
29
+ readLanded?: (cfg: Config, stackId: string) => Promise<MergeQueueEntryDto | null>;
25
30
  sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
26
31
  now?: () => number;
27
32
  };
@@ -1,4 +1,6 @@
1
1
  import { setTimeout as sleep } from "node:timers/promises";
2
+ import { API_TIMEOUT_PREFIX, readLandedQueueEntry } from "./api.js";
3
+ import { isTransientReviewPollError } from "./commands/review-client.js";
2
4
  import { pollStackWatch, StackWatchError, StackWatchTimeoutError, } from "./stack-watch.js";
3
5
  export const STACK_WATCH_MARKER = "MS-WATCH";
4
6
  export const STACK_WATCH_EXIT = { landed: 0, attention: 3, timeout: 5 };
@@ -6,6 +8,18 @@ export const STACK_WATCH_SLICE_MS = 300_000;
6
8
  export const STACK_WATCH_RECHECK_MS = 30_000;
7
9
  export const STACK_WATCH_FAILED_RETRY_MS = 15_000;
8
10
  export const STACK_WATCH_RATE_LIMIT_WAIT_MS = 30_000;
11
+ export const STACK_WATCH_TRANSIENT_WINDOW_MS = 15 * 60_000;
12
+ export const STACK_WATCH_TRANSIENT_MAX_WAIT_MS = 60_000;
13
+ const TRANSIENT_WATCH_FAILURE = /HTTP (?:408|429|5\d\d)|snapshot missing|fetch failed|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket hang up|network|connection/i;
14
+ export function isTransientWatchFailure(err) {
15
+ if (!(err instanceof Error))
16
+ return false;
17
+ if (isTransientReviewPollError(err))
18
+ return true;
19
+ if (err.cause !== undefined && isTransientReviewPollError(err.cause))
20
+ return true;
21
+ return err.message.startsWith(API_TIMEOUT_PREFIX) || TRANSIENT_WATCH_FAILURE.test(err.message);
22
+ }
9
23
  const ELAPSED_MINUTES = /\b\d+m\b/g;
10
24
  export function blockerWithoutElapsedMinutes(blocker) {
11
25
  return (blocker ?? "").replace(ELAPSED_MINUTES, "Nm");
@@ -35,6 +49,7 @@ export async function runStackWatch(cfg, stackId, opts = {}) {
35
49
  const write = opts.write ?? ((line) => console.log(line));
36
50
  const writeMarker = opts.json ? opts.writeMarker ?? ((line) => console.error(line)) : write;
37
51
  const poll = opts.poll ?? pollStackWatch;
52
+ const readLanded = opts.readLanded ?? readLandedQueueEntry;
38
53
  const now = opts.now ?? Date.now;
39
54
  const wait = opts.sleep ?? ((ms, signal) => sleep(ms, undefined, { signal }));
40
55
  const deadline = opts.maxMs !== undefined ? now() + opts.maxMs : Number.POSITIVE_INFINITY;
@@ -50,9 +65,43 @@ export async function runStackWatch(cfg, stackId, opts = {}) {
50
65
  };
51
66
  let cursor = opts.cursor;
52
67
  let failures = 0;
68
+ let permanentFailures = 0;
69
+ const seenPrs = new Set();
70
+ const notePrs = (envelope) => {
71
+ if (envelope.prNumber)
72
+ seenPrs.add(envelope.prNumber);
73
+ if (envelope.currentCandidate?.prNumber)
74
+ seenPrs.add(envelope.currentCandidate.prNumber);
75
+ };
76
+ let transientSince = null;
53
77
  const ignoredKeys = new Map();
54
78
  let reportedKey = null;
55
- const finished = (envelope) => {
79
+ const confirmedLanding = async (envelope) => {
80
+ if (envelope.watch.reason !== "not_found")
81
+ return envelope;
82
+ 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
+ };
95
+ }
96
+ const enrolledHead = envelope.cursor?.enrolledHeadSha ?? null;
97
+ const ours = Boolean(entry) && (entry.landedPrNumbers.some((pr) => seenPrs.has(pr)) ||
98
+ (enrolledHead !== null && entry.verifyHeadSha === enrolledHead));
99
+ if (!entry || !ours)
100
+ return envelope;
101
+ return { ...envelope, watch: { ...envelope.watch, reason: "landed", message: `This stack is landed (merge queue landed #${entry.landedPrNumbers.join(", #")}). The watch is done; stop calling stack_wait for it.` } };
102
+ };
103
+ const finished = async (seen) => {
104
+ const envelope = await confirmedLanding(seen);
56
105
  const landed = envelope.watch.reason === "landed";
57
106
  const outcome = landed ? "landed" : "attention";
58
107
  emit(landed ? stackWatchLandedLine(envelope)
@@ -61,9 +110,20 @@ export async function runStackWatch(cfg, stackId, opts = {}) {
61
110
  write(envelope.watch.message);
62
111
  return { outcome, exitCode: landed ? STACK_WATCH_EXIT.landed : STACK_WATCH_EXIT.attention, envelope };
63
112
  };
64
- const failed = async (message, envelope) => {
113
+ const failed = async (message, envelope, transient) => {
65
114
  failures += 1;
66
- if (failures >= 2) {
115
+ if (transient) {
116
+ permanentFailures = 0;
117
+ transientSince ??= now();
118
+ if (now() - transientSince < STACK_WATCH_TRANSIENT_WINDOW_MS) {
119
+ await pause(Math.min(STACK_WATCH_TRANSIENT_MAX_WAIT_MS, STACK_WATCH_FAILED_RETRY_MS * 2 ** Math.min(failures - 1, 4)));
120
+ return null;
121
+ }
122
+ }
123
+ else {
124
+ permanentFailures += 1;
125
+ }
126
+ if (permanentFailures >= 2 || transient) {
67
127
  emit(stackWatchFailedLine(id, message), envelope, "failed");
68
128
  return { outcome: "failed", exitCode: STACK_WATCH_EXIT.attention, envelope };
69
129
  }
@@ -84,25 +144,34 @@ export async function runStackWatch(cfg, stackId, opts = {}) {
84
144
  if (isAbort(err, opts.signal))
85
145
  throw err;
86
146
  if (err instanceof StackWatchTimeoutError) {
147
+ notePrs(err.lastEnvelope);
87
148
  cursor = err.lastEnvelope.cursor;
88
149
  failures = 0;
150
+ permanentFailures = 0;
151
+ if (err.lastEnvelope.assessment === "available")
152
+ transientSince = null;
89
153
  continue;
90
154
  }
91
- if (err instanceof StackWatchError && err.lastEnvelope.watch.done)
92
- return finished(err.lastEnvelope);
155
+ if (err instanceof StackWatchError && err.lastEnvelope.watch.done) {
156
+ notePrs(err.lastEnvelope);
157
+ return await finished(err.lastEnvelope);
158
+ }
93
159
  if (err instanceof StackWatchError && err.lastEnvelope.status === "rate_limited") {
94
160
  await pause(err.retryAfterSeconds !== undefined ? Math.max(1_000, err.retryAfterSeconds * 1000) : STACK_WATCH_RATE_LIMIT_WAIT_MS);
95
161
  continue;
96
162
  }
97
- const result = await failed(err instanceof Error ? err.message : String(err), err instanceof StackWatchError ? err.lastEnvelope : null);
163
+ const result = await failed(err instanceof Error ? err.message : String(err), err instanceof StackWatchError ? err.lastEnvelope : null, isTransientWatchFailure(err));
98
164
  if (result)
99
165
  return result;
100
166
  continue;
101
167
  }
102
168
  failures = 0;
169
+ permanentFailures = 0;
170
+ transientSince = null;
103
171
  cursor = envelope.cursor;
172
+ notePrs(envelope);
104
173
  if (envelope.watch.done)
105
- return finished(envelope);
174
+ return await finished(envelope);
106
175
  if (envelope.status === "attention") {
107
176
  const key = stackWatchAttentionKey(envelope);
108
177
  const blocker = (envelope.blocker ?? "").toLowerCase();
@@ -3,7 +3,7 @@ export declare const STACK_WATCH_NOT_DONE_SENTENCE = "This stack is not landed.
3
3
  export declare const STACK_WATCH_NOTIFY_PATTERN = "MS-WATCH (ATTENTION|LANDED)";
4
4
  export declare function stackWatchBackgroundCommand(stackId: string): string;
5
5
  export type StackWatchDoneReason = "landed" | "closed" | "archived" | "not_found";
6
- export type StackWatchReason = StackWatchDoneReason | "open" | "attention" | "unread" | "rate_limited" | "failed";
6
+ export type StackWatchReason = StackWatchDoneReason | "open" | "attention" | "unread" | "rate_limited" | "failed" | "landing_unconfirmed";
7
7
  export type StackWatchNextArgs = {
8
8
  stack_id: string;
9
9
  enrolled_head_sha?: string | null;
@@ -83,6 +83,7 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
83
83
  catch (err) {
84
84
  if (opts.signal?.aborted || (err instanceof Error && err.name === "AbortError"))
85
85
  throw err;
86
+ lastEnvelope = { ...lastEnvelope, assessment: "unavailable" };
86
87
  if (now() >= deadline && timeoutMs !== 0)
87
88
  throw timeout();
88
89
  if (timeoutMs !== 0 && isTransientReviewPollError(err) && transientFailures < REVIEW_POLL_MAX_TRANSIENT_RETRIES) {
@@ -95,6 +96,7 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
95
96
  }
96
97
  if (response.status === 200)
97
98
  return response.body;
99
+ lastEnvelope = { ...lastEnvelope, assessment: "unavailable" };
98
100
  const retryWaitMs = transientRetryWaitMs({
99
101
  failureCount: transientFailures + 1, retryAfterSeconds: response.retryAfterSeconds, random: opts.random,
100
102
  });
package/dist/ui/banner.js CHANGED
@@ -173,6 +173,12 @@ const WHATS_NEW = {
173
173
  "0.3.45": [
174
174
  "Cyclone can auto-fix red stack CI",
175
175
  ],
176
+ "0.3.46": [
177
+ "Watch waits while GitHub delays a review",
178
+ ],
179
+ "0.3.47": [
180
+ "New stack PRs are adopted on Work at submit",
181
+ ],
176
182
  };
177
183
  function densityFromVariant(variant) {
178
184
  if (variant === "mini" || variant === "nano")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mergestorm",
3
- "version": "0.3.45",
3
+ "version": "0.3.47",
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",