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 +1 -1
- package/dist/api.d.ts +9 -1
- package/dist/api.js +12 -3
- package/dist/commands/stack.js +13 -2
- package/dist/stack-blockers.js +5 -4
- package/dist/stack-dto.d.ts +2 -0
- package/dist/stack-watch-loop.d.ts +2 -2
- package/dist/stack-watch-loop.js +56 -17
- package/dist/ui/banner.js +3 -0
- package/package.json +1 -1
- package/skill/mergestorm-pr-loop/SKILL.md +3 -2
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
|
+
│ ▀▀▀▀▀▀▀▀▀▀▘ 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
|
|
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
|
|
514
|
-
|
|
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
|
|
525
|
+
throw new LandedQueueReadError(status, retryAfterSeconds);
|
|
517
526
|
const landed = body?.landed;
|
|
518
527
|
return landed && landed.state === "landed" ? landed : null;
|
|
519
528
|
}
|
package/dist/commands/stack.js
CHANGED
|
@@ -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
|
|
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
|
|
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;
|
package/dist/stack-blockers.js
CHANGED
|
@@ -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" &&
|
|
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"}
|
|
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
|
};
|
package/dist/stack-dto.d.ts
CHANGED
|
@@ -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
|
|
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?:
|
|
29
|
+
readLanded?: typeof readLandedQueueEntry;
|
|
30
30
|
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
31
31
|
now?: () => number;
|
|
32
32
|
};
|
package/dist/stack-watch-loop.js
CHANGED
|
@@ -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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
@@ -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
|
-
-
|
|
51
|
-
-
|
|
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.
|