mergestorm 0.3.48 → 0.3.50
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 +5 -1
- package/dist/api.d.ts +9 -1
- package/dist/api.js +12 -3
- package/dist/automation-catalog.d.ts +2 -2
- package/dist/automation-catalog.js +2 -2
- package/dist/commands/help.js +1 -1
- package/dist/commands/login.d.ts +1 -0
- package/dist/commands/login.js +112 -24
- package/dist/commands/registry.js +2 -0
- 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 +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.50 │
|
|
49
49
|
│ ▀▀▀▀▀▀▀▀▀ ● msk_live_… · maelstrom │
|
|
50
50
|
│ ▀▀▀▀▀▀ [████████░░░░░░░░░░░░░░░░░░░░░░] 25% used │
|
|
51
51
|
│ ▝▀▀▀▀▀ local reviews + stacked PRs · mergestorm.ai │
|
|
@@ -76,6 +76,8 @@ One-shot subcommands work the same inside the shell and from argv (scripts/CI):
|
|
|
76
76
|
mergestorm Interactive shell (TTY only)
|
|
77
77
|
mergestorm shell Explicit shell entry
|
|
78
78
|
mergestorm login Sign in via browser; stores an API key in ~/.mergestorm/config.json
|
|
79
|
+
mergestorm login --start Print a sign-in URL and code, then exit (agents)
|
|
80
|
+
mergestorm login --finish Store the API key once the code from --start is approved
|
|
79
81
|
mergestorm login --key Paste an existing API key instead (headless/CI)
|
|
80
82
|
mergestorm logout Remove the stored API key
|
|
81
83
|
mergestorm review [base] [head] Review git diff base...head (default: origin/HEAD or main or master)
|
|
@@ -148,6 +150,8 @@ Runs a browser device-authorization flow: prints a short code, opens `https://me
|
|
|
148
150
|
|
|
149
151
|
For headless or CI environments, use `mergestorm login --key` to paste an existing `msk_live_…` key (create one on the dashboard [Settings → API](https://mergestorm.ai/settings#api)). On a TTY the paste is hidden (not echoed); the CLI verifies the key with the API before saving.
|
|
150
152
|
|
|
153
|
+
For an agent, which cannot sit in a prompt while a person signs in, use two steps. `mergestorm login --start` prints the URL and the short code and exits. Give both to the account owner, who opens [mergestorm.ai/cli/auth](https://mergestorm.ai/cli/auth) on any device, signs in, and approves the code. Then `mergestorm login --finish` stores the API key. The code lasts 10 minutes, and `--finish` can be run again if it is interrupted before the approval.
|
|
154
|
+
|
|
151
155
|
### `logout`
|
|
152
156
|
|
|
153
157
|
Removes `~/.mergestorm/config.json` so the stored key is no longer used.
|
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
|
}
|
|
@@ -9,11 +9,11 @@ export declare const BEARER_SETTINGS: readonly [{
|
|
|
9
9
|
}, {
|
|
10
10
|
readonly key: "auto_resolve_conflicts_enabled";
|
|
11
11
|
readonly flag: "--auto-resolve-conflicts";
|
|
12
|
-
readonly label: "Auto-resolve merge conflicts";
|
|
12
|
+
readonly label: "Auto-resolve merge conflicts (alpha)";
|
|
13
13
|
}, {
|
|
14
14
|
readonly key: "auto_fix_ci_enabled";
|
|
15
15
|
readonly flag: "--auto-fix-ci";
|
|
16
|
-
readonly label: "Auto-fix failing CI";
|
|
16
|
+
readonly label: "Auto-fix failing CI (alpha)";
|
|
17
17
|
}, {
|
|
18
18
|
readonly key: "vortex_show_thinking_traces";
|
|
19
19
|
readonly flag: "--vortex-thinking";
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
export const BEARER_SETTINGS = [
|
|
2
2
|
{ key: "auto_review_enabled", flag: "--auto-review", label: "Auto review" },
|
|
3
3
|
{ key: "auto_patch_enabled", flag: "--auto-patch", label: "Auto patch" },
|
|
4
|
-
{ key: "auto_resolve_conflicts_enabled", flag: "--auto-resolve-conflicts", label: "Auto-resolve merge conflicts" },
|
|
5
|
-
{ key: "auto_fix_ci_enabled", flag: "--auto-fix-ci", label: "Auto-fix failing CI" },
|
|
4
|
+
{ key: "auto_resolve_conflicts_enabled", flag: "--auto-resolve-conflicts", label: "Auto-resolve merge conflicts (alpha)" },
|
|
5
|
+
{ key: "auto_fix_ci_enabled", flag: "--auto-fix-ci", label: "Auto-fix failing CI (alpha)" },
|
|
6
6
|
{ key: "vortex_show_thinking_traces", flag: "--vortex-thinking", label: "Vortex thinking traces" },
|
|
7
7
|
{ key: "repo_overview_enabled", flag: "--repo-overview", label: "Repo overview" },
|
|
8
8
|
{ key: "cyclone_skip_ci_enabled", flag: "--cyclone-skip-ci", label: "Skip CI on Cyclone commits" },
|
package/dist/commands/help.js
CHANGED
|
@@ -40,7 +40,7 @@ export function buildHelpTabs() {
|
|
|
40
40
|
cmd("thread <slug>", "jobs in a review thread"),
|
|
41
41
|
"",
|
|
42
42
|
rule("Account"),
|
|
43
|
-
cmd("login", "
|
|
43
|
+
cmd("login", "sign in (--key; agents: --start)"),
|
|
44
44
|
cmd("whoami", "key · plan · API"),
|
|
45
45
|
cmd("settings", "automation toggles · Config tab"),
|
|
46
46
|
cmd("logout", "forget the stored key"),
|
package/dist/commands/login.d.ts
CHANGED
|
@@ -5,4 +5,5 @@ import { type MeResponse } from "../api.js";
|
|
|
5
5
|
* Throws CommandError when the key must not be saved.
|
|
6
6
|
*/
|
|
7
7
|
export declare function validateApiKey(cfg: Config): Promise<MeResponse>;
|
|
8
|
+
export declare function pendingLoginPath(): string;
|
|
8
9
|
export declare function cmdLogin(args: string[]): Promise<void>;
|
package/dist/commands/login.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { platform } from "node:os";
|
|
3
|
-
import {
|
|
3
|
+
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { apiBase, configPath, loadConfig, mergestormHome, saveConfig, } from "../config.js";
|
|
4
6
|
import { devicePost, getMe } from "../api.js";
|
|
5
7
|
import { CommandError } from "../errors.js";
|
|
6
8
|
import { ansi } from "../ui/ansi.js";
|
|
@@ -52,13 +54,40 @@ async function loginWithKey() {
|
|
|
52
54
|
const prefix = me.key?.prefix?.trim() || "msk_live_…";
|
|
53
55
|
await present("Login", [` Verified ${prefix}… · saved to ${configPath()}`]);
|
|
54
56
|
}
|
|
55
|
-
export
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
57
|
+
export function pendingLoginPath() {
|
|
58
|
+
return path.join(mergestormHome(), "login-pending.json");
|
|
59
|
+
}
|
|
60
|
+
async function savePendingLogin(pending) {
|
|
61
|
+
await mkdir(mergestormHome(), { recursive: true });
|
|
62
|
+
await writeFile(pendingLoginPath(), JSON.stringify(pending, null, 2) + "\n", {
|
|
63
|
+
encoding: "utf8",
|
|
64
|
+
mode: 0o600,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
async function loadPendingLogin() {
|
|
68
|
+
try {
|
|
69
|
+
const parsed = JSON.parse(await readFile(pendingLoginPath(), "utf8"));
|
|
70
|
+
if (typeof parsed.deviceCode !== "string" ||
|
|
71
|
+
typeof parsed.userCode !== "string" ||
|
|
72
|
+
typeof parsed.openUrl !== "string" ||
|
|
73
|
+
typeof parsed.apiBase !== "string" ||
|
|
74
|
+
typeof parsed.pollMs !== "number" ||
|
|
75
|
+
typeof parsed.expiresAt !== "number") {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
return parsed;
|
|
59
79
|
}
|
|
60
|
-
|
|
61
|
-
|
|
80
|
+
catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
async function clearPendingLogin(deviceCode) {
|
|
85
|
+
const pending = await loadPendingLogin();
|
|
86
|
+
if (pending?.deviceCode !== deviceCode)
|
|
87
|
+
return;
|
|
88
|
+
await rm(pendingLoginPath(), { force: true });
|
|
89
|
+
}
|
|
90
|
+
async function startDeviceLogin(base) {
|
|
62
91
|
let start;
|
|
63
92
|
let attempt = 0;
|
|
64
93
|
do {
|
|
@@ -87,51 +116,110 @@ export async function cmdLogin(args) {
|
|
|
87
116
|
throw new CommandError(`Could not start device login (HTTP ${start.status}). The API may be unreachable or misconfigured.\n` +
|
|
88
117
|
`Run \`mergestorm login --key\` to paste an existing API key instead.`);
|
|
89
118
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
119
|
+
return start.body;
|
|
120
|
+
}
|
|
121
|
+
function pendingFromStart(base, start) {
|
|
122
|
+
return {
|
|
123
|
+
deviceCode: start.device_code,
|
|
124
|
+
userCode: start.user_code,
|
|
125
|
+
openUrl: start.verification_uri_complete || start.verification_uri,
|
|
126
|
+
apiBase: base,
|
|
127
|
+
pollMs: Math.max(start.interval || 5, 2) * 1000,
|
|
128
|
+
expiresAt: Date.now() + (start.expires_in || 600) * 1000,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
async function pollDeviceLogin(pending, retryCommand, recovery) {
|
|
132
|
+
while (Date.now() < pending.expiresAt) {
|
|
101
133
|
let poll;
|
|
102
134
|
try {
|
|
103
|
-
poll = await devicePost(
|
|
135
|
+
poll = await devicePost(pending.apiBase, "/api/v1/cli/device/token", {
|
|
136
|
+
device_code: pending.deviceCode,
|
|
137
|
+
});
|
|
104
138
|
}
|
|
105
139
|
catch {
|
|
106
|
-
throw new CommandError(
|
|
140
|
+
throw new CommandError(`Login failed: network error. Check your connection and run \`${retryCommand}\` again.`);
|
|
107
141
|
}
|
|
108
142
|
if (poll.status === 200 && poll.body?.api_key) {
|
|
109
143
|
const next = await loadConfig();
|
|
110
144
|
next.apiKey = poll.body.api_key;
|
|
145
|
+
next.apiBase = pending.apiBase;
|
|
111
146
|
await saveConfig(next);
|
|
147
|
+
await clearPendingLogin(pending.deviceCode);
|
|
112
148
|
await present("Login", [` Logged in. Key saved to ${configPath()}`]);
|
|
113
149
|
return;
|
|
114
150
|
}
|
|
115
151
|
const err = poll.body?.error;
|
|
116
152
|
if (err === "authorization_pending") {
|
|
117
153
|
process.stdout.write(".");
|
|
154
|
+
await new Promise((r) => setTimeout(r, pending.pollMs));
|
|
118
155
|
continue;
|
|
119
156
|
}
|
|
120
157
|
if (err === "slow_down" || err === "rate_limited") {
|
|
121
158
|
const backoff = Number(poll.body?.retry_after_seconds);
|
|
122
|
-
await new Promise((r) => setTimeout(r, Number.isFinite(backoff) && backoff > 0 ? backoff * 1000 : pollMs));
|
|
159
|
+
await new Promise((r) => setTimeout(r, Number.isFinite(backoff) && backoff > 0 ? backoff * 1000 : pending.pollMs));
|
|
123
160
|
continue;
|
|
124
161
|
}
|
|
125
162
|
if (err === "key_limit_reached") {
|
|
126
163
|
throw new CommandError("You have reached the max active API keys. Revoke one at https://mergestorm.ai/settings#api and try again.");
|
|
127
164
|
}
|
|
165
|
+
if (err === "expired_token" || err === "already_redeemed" || err === "access_denied") {
|
|
166
|
+
await clearPendingLogin(pending.deviceCode);
|
|
167
|
+
}
|
|
128
168
|
if (err === "expired_token") {
|
|
129
|
-
throw new CommandError(
|
|
169
|
+
throw new CommandError(`Login code expired. ${recovery}`);
|
|
130
170
|
}
|
|
131
171
|
if (err === "already_redeemed") {
|
|
132
|
-
throw new CommandError(
|
|
172
|
+
throw new CommandError(`This code was already used. ${recovery}`);
|
|
133
173
|
}
|
|
134
174
|
throw new CommandError(`Login failed: ${typeof err === "string" ? err : JSON.stringify(poll.body)}`);
|
|
135
175
|
}
|
|
136
|
-
|
|
176
|
+
await clearPendingLogin(pending.deviceCode);
|
|
177
|
+
throw new CommandError(`Login timed out. ${recovery}`);
|
|
178
|
+
}
|
|
179
|
+
async function loginStart() {
|
|
180
|
+
const cfg = await loadConfig();
|
|
181
|
+
const base = apiBase(cfg);
|
|
182
|
+
const pending = pendingFromStart(base, await startDeviceLogin(base));
|
|
183
|
+
await savePendingLogin(pending);
|
|
184
|
+
const minutes = Math.max(1, Math.round((pending.expiresAt - Date.now()) / 60_000));
|
|
185
|
+
console.log("Ask the account owner to open this URL, sign in, and approve the code:\n");
|
|
186
|
+
console.log(` ${pending.openUrl}`);
|
|
187
|
+
console.log(`\n Code: ${pending.userCode}\n`);
|
|
188
|
+
console.log(`The code expires in ${minutes} minutes.`);
|
|
189
|
+
console.log("After they approve, run `mergestorm login --finish` to store the API key.");
|
|
190
|
+
}
|
|
191
|
+
async function loginFinish() {
|
|
192
|
+
const pending = await loadPendingLogin();
|
|
193
|
+
if (!pending) {
|
|
194
|
+
throw new CommandError("No login is waiting. Run `mergestorm login --start` first.");
|
|
195
|
+
}
|
|
196
|
+
if (Date.now() >= pending.expiresAt) {
|
|
197
|
+
await clearPendingLogin(pending.deviceCode);
|
|
198
|
+
throw new CommandError("Login code expired. Run `mergestorm login --start` again.");
|
|
199
|
+
}
|
|
200
|
+
console.log(`Waiting for approval of code ${pending.userCode} at ${pending.openUrl}`);
|
|
201
|
+
await pollDeviceLogin(pending, "mergestorm login --finish", "Run `mergestorm login --start`, then `mergestorm login --finish`.");
|
|
202
|
+
}
|
|
203
|
+
export async function cmdLogin(args) {
|
|
204
|
+
if (args.includes("--key")) {
|
|
205
|
+
await loginWithKey();
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
if (args.includes("--start")) {
|
|
209
|
+
await loginStart();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (args.includes("--finish")) {
|
|
213
|
+
await loginFinish();
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const cfg = await loadConfig();
|
|
217
|
+
const base = apiBase(cfg);
|
|
218
|
+
const pending = pendingFromStart(base, await startDeviceLogin(base));
|
|
219
|
+
console.log("To sign in, open this URL and confirm the code:\n");
|
|
220
|
+
console.log(` ${ansi.brightGreen(pending.openUrl)}`);
|
|
221
|
+
console.log(`\n Code: ${ansi.bold(pending.userCode)}\n`);
|
|
222
|
+
openBrowser(pending.openUrl);
|
|
223
|
+
console.log("Waiting for approval…");
|
|
224
|
+
await pollDeviceLogin(pending, "mergestorm login", "Run `mergestorm login` again.");
|
|
137
225
|
}
|
|
@@ -25,6 +25,8 @@ export const COMMAND_REGISTRY = [
|
|
|
25
25
|
summary: "Sign in via browser (or paste a key)",
|
|
26
26
|
usage: [
|
|
27
27
|
"mergestorm login Sign in via browser and store an API key",
|
|
28
|
+
"mergestorm login --start Print a sign-in URL and code, then exit (agents)",
|
|
29
|
+
"mergestorm login --finish Store the API key once the code from --start is approved",
|
|
28
30
|
"mergestorm login --key Paste an existing API key instead (headless/CI)",
|
|
29
31
|
],
|
|
30
32
|
async run(args, ctx) {
|
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,12 @@ 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
|
+
],
|
|
188
|
+
"0.3.50": [
|
|
189
|
+
"Agent sign-in: login --start, --finish",
|
|
190
|
+
],
|
|
185
191
|
};
|
|
186
192
|
function densityFromVariant(variant) {
|
|
187
193
|
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.
|