mergestorm 0.3.47 → 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.d.ts +2 -0
- package/dist/stack-blockers.js +11 -1
- package/dist/stack-dto.d.ts +15 -0
- package/dist/stack-watch-loop.d.ts +2 -2
- package/dist/stack-watch-loop.js +56 -17
- package/dist/ui/banner.js +6 -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.d.ts
CHANGED
|
@@ -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";
|
package/dist/stack-blockers.js
CHANGED
|
@@ -131,6 +131,12 @@ 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" && layer.checks?.cancelledOnly === true &&
|
|
137
|
+
sameHead(layer.checks.namedRunsCompleteHeadSha, layer.headSha) && !isLandPrLayer(stack, layer) &&
|
|
138
|
+
sameHead(layer.cycloneCiHandoff?.headSha, layer.headSha);
|
|
139
|
+
}
|
|
134
140
|
function ciHandoffNote(stack, layer) {
|
|
135
141
|
if (isLandPrLayer(stack, layer))
|
|
136
142
|
return null;
|
|
@@ -276,6 +282,8 @@ function layerAttention(stack, layer, queue, cursor) {
|
|
|
276
282
|
(!layer.vortexReview?.head_sha || sameHead(layer.vortexReview.head_sha, layer.headSha))) {
|
|
277
283
|
return hardBlock(VORTEX_REVIEW_FAILED, "vortex_failed");
|
|
278
284
|
}
|
|
285
|
+
if (pendingCiLeftForPerson(stack, layer))
|
|
286
|
+
return hardBlock(CI_DID_NOT_FINISH_BLOCKER, "ci_failed");
|
|
279
287
|
if (quotaSkippedReview(layer))
|
|
280
288
|
return hardBlock(VORTEX_REVIEW_OUT_OF_QUOTA, "vortex_out_of_quota");
|
|
281
289
|
const vortexSkip = vortexIdleSkipBlocker(layer);
|
|
@@ -780,7 +788,9 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
|
|
|
780
788
|
headSha: layer.headSha ?? null,
|
|
781
789
|
branch: layer.branch,
|
|
782
790
|
failingCheck,
|
|
783
|
-
steps:
|
|
791
|
+
steps: pendingCiLeftForPerson(stack, layer)
|
|
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.`
|
|
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.`,
|
|
784
794
|
...(note ? { note } : {}),
|
|
785
795
|
};
|
|
786
796
|
}
|
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
|
|
@@ -427,12 +429,25 @@ export type MergeQueueBounceBatch = {
|
|
|
427
429
|
withPrNumbers: number[];
|
|
428
430
|
};
|
|
429
431
|
export type MergeQueueBatchState = "verifying" | "landing" | "landed" | "stopped" | "bounced" | "abandoned";
|
|
432
|
+
export type MergeQueueBatchCiCheckState = "passed" | "skipped" | "failed" | "running" | "queued";
|
|
433
|
+
export type MergeQueueBatchCiCheckDto = {
|
|
434
|
+
name: string;
|
|
435
|
+
state: MergeQueueBatchCiCheckState;
|
|
436
|
+
startedAt: string | null;
|
|
437
|
+
completedAt: string | null;
|
|
438
|
+
};
|
|
439
|
+
export type MergeQueueBatchCiDto = {
|
|
440
|
+
sha: string;
|
|
441
|
+
observedAt: string;
|
|
442
|
+
checks: MergeQueueBatchCiCheckDto[];
|
|
443
|
+
};
|
|
430
444
|
export type MergeQueueEntryBatchDto = {
|
|
431
445
|
id: string;
|
|
432
446
|
position: number;
|
|
433
447
|
size: number;
|
|
434
448
|
state: MergeQueueBatchState;
|
|
435
449
|
landedCount: number;
|
|
450
|
+
ci?: MergeQueueBatchCiDto | null;
|
|
436
451
|
};
|
|
437
452
|
export type MergeQueueAheadDto = {
|
|
438
453
|
stackId: string | null;
|
|
@@ -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
|
@@ -179,6 +179,12 @@ const WHATS_NEW = {
|
|
|
179
179
|
"0.3.47": [
|
|
180
180
|
"New stack PRs are adopted on Work at submit",
|
|
181
181
|
],
|
|
182
|
+
"0.3.48": [
|
|
183
|
+
"Watch names CI Cyclone could not finish",
|
|
184
|
+
],
|
|
185
|
+
"0.3.49": [
|
|
186
|
+
"Submit retries a stalled stacks check",
|
|
187
|
+
],
|
|
182
188
|
};
|
|
183
189
|
function densityFromVariant(variant) {
|
|
184
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.
|