mergestorm 0.3.46 → 0.3.48

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.46 │
48
+ │ ▀▀▀▀▀▀▀▀▀▀▘ mergestorm v0.3.48 │
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 = {}) {
@@ -165,6 +165,8 @@ export declare const SEAM_VERDICT_STALE = "Seam verdict is for an older head, no
165
165
  export declare const VORTEX_FINDINGS_NEED_PERSON = "Vortex findings need a person";
166
166
  export declare function autoResolveActive(stack: StackDto, layer: StackLayerDto): boolean;
167
167
  export declare function autoFixCiActive(stack: StackDto, layer: StackLayerDto): boolean;
168
+ export declare const CI_DID_NOT_FINISH_BLOCKER = "CI did not finish \u2014 left for a person";
169
+ export declare function pendingCiLeftForPerson(stack: StackDto, layer: StackLayerDto): boolean;
168
170
  export declare const VORTEX_AUTO_REVIEW_OFF = "Vortex auto-review is off, so this head was not reviewed";
169
171
  export declare const VORTEX_BILLING_SKIPPED = "Vortex skipped this head: it could not confirm billing";
170
172
  export declare const VORTEX_REVIEW_INCOMPLETE = "Vortex reviewed only part of this PR";
@@ -131,6 +131,11 @@ export function autoFixCiActive(stack, layer) {
131
131
  return !stack.cycloneCiOff && !isLandPrLayer(stack, layer) &&
132
132
  !sameHead(layer.cycloneCiHandoff?.headSha, layer.headSha);
133
133
  }
134
+ export const CI_DID_NOT_FINISH_BLOCKER = "CI did not finish — left for a person";
135
+ export function pendingCiLeftForPerson(stack, layer) {
136
+ return layer.ciStatus === "pending" && !isLandPrLayer(stack, layer) &&
137
+ sameHead(layer.cycloneCiHandoff?.headSha, layer.headSha);
138
+ }
134
139
  function ciHandoffNote(stack, layer) {
135
140
  if (isLandPrLayer(stack, layer))
136
141
  return null;
@@ -272,6 +277,8 @@ function layerAttention(stack, layer, queue, cursor) {
272
277
  const name = layer.checks?.failingName?.trim();
273
278
  return hardBlock(`CI failed${name ? ` — ${name}` : ""}`, "ci_failed");
274
279
  }
280
+ if (pendingCiLeftForPerson(stack, layer))
281
+ return hardBlock(CI_DID_NOT_FINISH_BLOCKER, "ci_failed");
275
282
  if (layer.vortexStatus === "failed" && !vortexRetryScheduled(layer) &&
276
283
  (!layer.vortexReview?.head_sha || sameHead(layer.vortexReview.head_sha, layer.headSha))) {
277
284
  return hardBlock(VORTEX_REVIEW_FAILED, "vortex_failed");
@@ -780,7 +787,9 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
780
787
  headSha: layer.headSha ?? null,
781
788
  branch: layer.branch,
782
789
  failingCheck,
783
- steps: `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.`,
790
+ 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
+ : `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.`,
784
793
  ...(note ? { note } : {}),
785
794
  };
786
795
  }
@@ -427,12 +427,25 @@ export type MergeQueueBounceBatch = {
427
427
  withPrNumbers: number[];
428
428
  };
429
429
  export type MergeQueueBatchState = "verifying" | "landing" | "landed" | "stopped" | "bounced" | "abandoned";
430
+ export type MergeQueueBatchCiCheckState = "passed" | "skipped" | "failed" | "running" | "queued";
431
+ export type MergeQueueBatchCiCheckDto = {
432
+ name: string;
433
+ state: MergeQueueBatchCiCheckState;
434
+ startedAt: string | null;
435
+ completedAt: string | null;
436
+ };
437
+ export type MergeQueueBatchCiDto = {
438
+ sha: string;
439
+ observedAt: string;
440
+ checks: MergeQueueBatchCiCheckDto[];
441
+ };
430
442
  export type MergeQueueEntryBatchDto = {
431
443
  id: string;
432
444
  position: number;
433
445
  size: number;
434
446
  state: MergeQueueBatchState;
435
447
  landedCount: number;
448
+ ci?: MergeQueueBatchCiDto | null;
436
449
  };
437
450
  export type MergeQueueAheadDto = {
438
451
  stackId: string | null;
@@ -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
@@ -176,6 +176,12 @@ const WHATS_NEW = {
176
176
  "0.3.46": [
177
177
  "Watch waits while GitHub delays a review",
178
178
  ],
179
+ "0.3.47": [
180
+ "New stack PRs are adopted on Work at submit",
181
+ ],
182
+ "0.3.48": [
183
+ "Watch names CI Cyclone could not finish",
184
+ ],
179
185
  };
180
186
  function densityFromVariant(variant) {
181
187
  if (variant === "mini" || variant === "nano")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mergestorm",
3
- "version": "0.3.46",
3
+ "version": "0.3.48",
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",