mergestorm 0.3.39 → 0.3.40
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 +2 -2
- package/dist/api.d.ts +6 -1
- package/dist/api.js +61 -5
- package/dist/commands/browse.d.ts +5 -0
- package/dist/commands/browse.js +18 -1
- package/dist/commands/review.d.ts +2 -1
- package/dist/commands/review.js +1 -0
- package/dist/commands/settings.js +2 -3
- package/dist/commands/stack.js +1 -2
- package/dist/errors.d.ts +4 -1
- package/dist/errors.js +9 -0
- package/dist/stack-blockers.d.ts +7 -17
- package/dist/stack-blockers.js +110 -140
- package/dist/stack-dto.d.ts +7 -8
- package/dist/stack-dto.js +0 -4
- package/dist/ui/banner.js +6 -3
- package/dist/ui/tabs.d.ts +5 -0
- package/dist/ui/tabs.js +5 -3
- package/package.json +1 -1
- package/skill/mergestorm-bounce-watch/SKILL.md +2 -4
- package/skill/mergestorm-pr-loop/SKILL.md +1 -1
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.40 │
|
|
49
49
|
│ ▀▀▀▀▀▀▀▀▀ ● msk_live_… · maelstrom │
|
|
50
50
|
│ ▀▀▀▀▀▀ [████████░░░░░░░░░░░░░░░░░░░░░░] 25% used │
|
|
51
51
|
│ ▝▀▀▀▀▀ local reviews + stacked PRs · mergestorm.ai │
|
|
@@ -137,7 +137,7 @@ Use `mergestorm dismiss` when a Vortex finding is wrong and you have checked it
|
|
|
137
137
|
|
|
138
138
|
If the parent branch is already a layer of a **registered** (submitted) stack, `create` and `submit` refuse unless you pass **`--extend`**. That blocks accidentally gluing an unrelated PR onto an open unit. Growing a registered stack on purpose: `mg stack create --onto <tip> --extend` → commit → `mg stack submit --extend`.
|
|
139
139
|
|
|
140
|
-
`stack submit` walks the **active** local stack: `git push -u origin` each layer, opens a PR with `gh` (`--base` = parent or trunk; adopt then moves the bottom of a 2+ PR stack onto `mg-stack-<n>` — leave it there; skips heads that already have an open PR; body from the tip commit — Summary + Test plan, preserving Fixes/Closes/Resolves), registers the stack with `POST /api/v1/stacks/adopt` internally (Mergestorm API key), then drops that local stack entry so the next create onto trunk is clean. Push is always your local `git` credentials. PRs open as you when `gh` is installed and `gh api user` succeeds; otherwise `stack submit` asks the Mergestorm API (`POST /api/v1/stacks/pulls`) to open them as
|
|
140
|
+
`stack submit` walks the **active** local stack: `git push -u origin` each layer, opens a PR with `gh` (`--base` = parent or trunk; adopt then moves the bottom of a 2+ PR stack onto `mg-stack-<n>` — leave it there; skips heads that already have an open PR; body from the tip commit — Summary + Test plan, preserving Fixes/Closes/Resolves), registers the stack with `POST /api/v1/stacks/adopt` internally (Mergestorm API key), then drops that local stack entry so the next create onto trunk is clean. Push is always your local `git` credentials. PRs open as you when `gh` is installed and `gh api user` succeeds; otherwise `stack submit` asks the Mergestorm API (`POST /api/v1/stacks/pulls`) to open them as the Mergestorm infrastructure App on your behalf, after checking your GitHub account has write access, and assigns you. That App is Mergestorm Surge, or Cyclone on accounts not yet moved to Surge; stacks, the merge queue and Auto land run through it, and Cyclone is the auto-patch App. Without either, submit stops before pushing and prints the server's message, which names the App to install and links its install page. `mg settings` prints a `Stacks, merge queue, Auto land` line when the server reports whether that App is ready.
|
|
141
141
|
|
|
142
142
|
Legacy repo-local `.mergestorm/stack.json` state migrates automatically on the next stack command. If local authoring state is stale or malformed, use `mg stack reset --force`; this clears only pre-submit CLI state and never deletes branches, PRs, or registered stacks.
|
|
143
143
|
|
package/dist/api.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { type Config } from "./config.js";
|
|
2
2
|
import { BEARER_SETTINGS_KEYS as SETTINGS_WRITABLE_KEYS, type BearerSettingsKey as SettingsWritableKey } from "./automation-catalog.js";
|
|
3
|
+
import { CommandError } from "./errors.js";
|
|
3
4
|
import type { MergeQueueEntryDto, StackDto } from "./stack-dto.js";
|
|
4
|
-
export type { MergeQueueBounceDetail, MergeQueueBounceKind, MergeQueueEnqueuedBy, MergeQueueEnqueuedVia, MergeQueueEntryDto, MergeQueueEntryState, StackBranchState, StackCiStatus, StackDto, StackLayerChecks, StackLayerDto, StackReviewStatus,
|
|
5
|
+
export type { MergeQueueBounceDetail, MergeQueueBounceKind, MergeQueueEnqueuedBy, MergeQueueEnqueuedVia, MergeQueueEntryDto, MergeQueueEntryState, StackBranchState, StackCiStatus, StackDto, StackLayerChecks, StackLayerDto, StackReviewStatus, StackUnitDto, StackUnitMemberDto, } from "./stack-dto.js";
|
|
5
6
|
/** Default per-request timeout so a hung API cannot stall the shell/CI forever. */
|
|
6
7
|
export declare const DEFAULT_API_TIMEOUT_MS = 30000;
|
|
7
8
|
/** Message prefix of the CommandError thrown when a request exceeds its timeout. */
|
|
@@ -172,6 +173,7 @@ export type SettingsPatch = Partial<import("./automation-catalog.js").BearerSett
|
|
|
172
173
|
export type SettingsResponse = import("./automation-catalog.js").BearerSettingsValues & {
|
|
173
174
|
cyclone_connected: boolean;
|
|
174
175
|
github_connected: boolean;
|
|
176
|
+
stacks_ready?: boolean;
|
|
175
177
|
};
|
|
176
178
|
/**
|
|
177
179
|
* Fetch Bearer /api/v1/settings; null on 404/network/timeouts so panels can
|
|
@@ -228,6 +230,9 @@ export type StackPolicyPatch = {
|
|
|
228
230
|
export declare function stackPolicyBody(policy: StackPolicyPatch | undefined): StackPolicyPatch;
|
|
229
231
|
/** Adopt an open PR chain (Bearer POST /api/v1/stacks/adopt). */
|
|
230
232
|
export declare function adoptStack(owner: string, repo: string, prNumber: number, cfg?: Config, policy?: StackPolicyPatch): Promise<unknown>;
|
|
233
|
+
export declare const SURGE_REFUSAL_FALLBACK = "Mergestorm Surge is not installed or not ready on this repository. Install it from Agents.";
|
|
234
|
+
export declare function surgeRefusal(body: unknown): CommandError | null;
|
|
235
|
+
export declare const GITHUB_UNREADABLE_MESSAGE = "GitHub could not be read just now; try again.";
|
|
231
236
|
/** Mint or reuse the PR3+ park freeze (Bearer POST /api/v1/stacks/:id/ensure-upper-park). */
|
|
232
237
|
export declare function ensureUpperPark(stackId: string, cfg?: Config): Promise<{
|
|
233
238
|
freezeBranch: string;
|
package/dist/api.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { apiBase, loadConfig, resolveApiKey } from "./config.js";
|
|
2
2
|
import { BEARER_SETTINGS_KEYS as SETTINGS_WRITABLE_KEYS, } from "./automation-catalog.js";
|
|
3
|
-
import { CommandError, REVIEW_EXIT, rateLimitedMessage, } from "./errors.js";
|
|
3
|
+
import { CommandError, REVIEW_EXIT, rateLimitedMessage, plainServerText, } from "./errors.js";
|
|
4
4
|
/** Default per-request timeout so a hung API cannot stall the shell/CI forever. */
|
|
5
5
|
export const DEFAULT_API_TIMEOUT_MS = 30_000;
|
|
6
6
|
/** Message prefix of the CommandError thrown when a request exceeds its timeout. */
|
|
@@ -416,12 +416,53 @@ export async function adoptStack(owner, repo, prNumber, cfg, policy) {
|
|
|
416
416
|
: code === "supabase_unconfigured"
|
|
417
417
|
? "Stacks are not configured on this server."
|
|
418
418
|
: undefined;
|
|
419
|
+
const surge = surgeRefusal(body);
|
|
420
|
+
if (surge)
|
|
421
|
+
throw surge;
|
|
419
422
|
throw new CommandError(message?.trim()
|
|
420
423
|
? message
|
|
421
424
|
: fallbackMessage ?? `Failed to import stack (HTTP ${status}): ${JSON.stringify(body)}`, 1, code);
|
|
422
425
|
}
|
|
423
426
|
return body;
|
|
424
427
|
}
|
|
428
|
+
const INFRASTRUCTURE_REFUSAL_CODES = [
|
|
429
|
+
"cyclone_not_connected",
|
|
430
|
+
"cyclone_not_installed",
|
|
431
|
+
"cyclone_permission_missing",
|
|
432
|
+
];
|
|
433
|
+
function refusalReason(body) {
|
|
434
|
+
const reason = (body && typeof body === "object" ? body.reason : undefined);
|
|
435
|
+
return typeof reason === "string" && plainServerText(reason).trim() ? plainServerText(reason).trim() : undefined;
|
|
436
|
+
}
|
|
437
|
+
export const SURGE_REFUSAL_FALLBACK = "Mergestorm Surge is not installed or not ready on this repository. Install it from Agents.";
|
|
438
|
+
export function surgeRefusal(body) {
|
|
439
|
+
if (!body || typeof body !== "object")
|
|
440
|
+
return null;
|
|
441
|
+
const refusal = body;
|
|
442
|
+
const reason = refusalReason(body);
|
|
443
|
+
if (!reason?.startsWith("surge_"))
|
|
444
|
+
return null;
|
|
445
|
+
const error = INFRASTRUCTURE_REFUSAL_CODES.find((code) => code === refusal.error);
|
|
446
|
+
if (!error)
|
|
447
|
+
return null;
|
|
448
|
+
const message = serverMessage(refusal.message) ?? SURGE_REFUSAL_FALLBACK;
|
|
449
|
+
const installUrl = githubAppInstallUrl(refusal.installUrl) ?? "";
|
|
450
|
+
return new CommandError(installUrl && !message.includes(installUrl) ? `${message} (${installUrl})` : message, 1, error, { reason });
|
|
451
|
+
}
|
|
452
|
+
export const GITHUB_UNREADABLE_MESSAGE = "GitHub could not be read just now; try again.";
|
|
453
|
+
function serverMessage(message) {
|
|
454
|
+
if (typeof message !== "string")
|
|
455
|
+
return null;
|
|
456
|
+
const plain = plainServerText(message).trim();
|
|
457
|
+
return plain ? plain : null;
|
|
458
|
+
}
|
|
459
|
+
const GITHUB_APP_INSTALL_URL = /https:\/\/github\.com\/apps\/[^\s)]+\/installations\/new/;
|
|
460
|
+
function githubAppInstallUrl(value) {
|
|
461
|
+
if (typeof value !== "string")
|
|
462
|
+
return null;
|
|
463
|
+
const match = GITHUB_APP_INSTALL_URL.exec(plainServerText(value).trim());
|
|
464
|
+
return match && match.index === 0 ? match[0] : null;
|
|
465
|
+
}
|
|
425
466
|
/** Mint or reuse the PR3+ park freeze (Bearer POST /api/v1/stacks/:id/ensure-upper-park). */
|
|
426
467
|
export async function ensureUpperPark(stackId, cfg) {
|
|
427
468
|
const body = await stackMutation(stackId, "/ensure-upper-park", "POST", {}, "Failed to ensure upper-park freeze", cfg);
|
|
@@ -445,10 +486,13 @@ export function cycloneNotInstalledMessage(owner, repo, installUrl = CYCLONE_INS
|
|
|
445
486
|
function installUrlFrom(message) {
|
|
446
487
|
if (typeof message !== "string")
|
|
447
488
|
return CYCLONE_INSTALL_URL;
|
|
448
|
-
return message.match(
|
|
489
|
+
return message.match(GITHUB_APP_INSTALL_URL)?.[0] ?? CYCLONE_INSTALL_URL;
|
|
449
490
|
}
|
|
450
491
|
function stackPullFailure(status, body, owner, repo, failLabel) {
|
|
451
492
|
const rejection = (body && typeof body === "object" ? body : {});
|
|
493
|
+
const surge = surgeRefusal(body);
|
|
494
|
+
if (surge)
|
|
495
|
+
return surge;
|
|
452
496
|
const text = `${String(rejection.error ?? "")} ${String(rejection.message ?? "")}`;
|
|
453
497
|
if (rejection.error === "cyclone_not_installed" ||
|
|
454
498
|
/not installed|installation[^\n]*not found/i.test(text)) {
|
|
@@ -500,6 +544,9 @@ async function stackMutation(stackId, pathSuffix, method, json, failLabel, cfg)
|
|
|
500
544
|
throw new CommandError("Stacks API is not available on this server yet. Deploy the API update or use the dashboard.");
|
|
501
545
|
}
|
|
502
546
|
if (status !== 200) {
|
|
547
|
+
const surge = surgeRefusal(body);
|
|
548
|
+
if (surge)
|
|
549
|
+
throw surge;
|
|
503
550
|
const rejection = body;
|
|
504
551
|
if (method === "PATCH" && rejection?.error === "cyclone_not_connected") {
|
|
505
552
|
throw new CommandError(typeof rejection.message === "string" ? rejection.message : rejection.error, 1, "cyclone_not_connected");
|
|
@@ -554,7 +601,7 @@ export async function listMergeQueueEntries(cfg, opts) {
|
|
|
554
601
|
}
|
|
555
602
|
return entries;
|
|
556
603
|
}
|
|
557
|
-
async function queueMutation(route, failLabel, cfg) {
|
|
604
|
+
async function queueMutation(route, failLabel, cfg, opts = {}) {
|
|
558
605
|
const resolved = cfg ?? (await loadConfig());
|
|
559
606
|
const { status, body } = await apiFetch(resolved, route, {
|
|
560
607
|
method: "POST",
|
|
@@ -572,18 +619,27 @@ async function queueMutation(route, failLabel, cfg) {
|
|
|
572
619
|
}
|
|
573
620
|
if (status === 409) {
|
|
574
621
|
const refusal = body;
|
|
575
|
-
if (refusal?.error === "conflict_unchanged"
|
|
622
|
+
if ((refusal?.error === "conflict_unchanged" || refusal?.error === "ci_failed") &&
|
|
623
|
+
typeof refusal.message === "string") {
|
|
576
624
|
throw new CommandError(refusal.message);
|
|
577
625
|
}
|
|
578
626
|
}
|
|
627
|
+
if (opts.githubUnreadableRefusal &&
|
|
628
|
+
status === 503 &&
|
|
629
|
+
body?.error === "github_unreadable") {
|
|
630
|
+
throw new CommandError(serverMessage(body.message) ?? GITHUB_UNREADABLE_MESSAGE, 1, "github_unreadable");
|
|
631
|
+
}
|
|
579
632
|
if (status < 200 || status >= 300) {
|
|
633
|
+
const surge = surgeRefusal(body);
|
|
634
|
+
if (surge)
|
|
635
|
+
throw surge;
|
|
580
636
|
throw new CommandError(`${failLabel} (HTTP ${status}): ${JSON.stringify(body)}`);
|
|
581
637
|
}
|
|
582
638
|
return body;
|
|
583
639
|
}
|
|
584
640
|
/** Enqueue a stack for verified landing. */
|
|
585
641
|
export async function enqueueStack(stackId, cfg) {
|
|
586
|
-
return queueMutation(`/api/v1/stacks/${encodeURIComponent(stackId)}/enqueue`, "Failed to add stack to merge queue", cfg);
|
|
642
|
+
return queueMutation(`/api/v1/stacks/${encodeURIComponent(stackId)}/enqueue`, "Failed to add stack to merge queue", cfg, { githubUnreadableRefusal: true });
|
|
587
643
|
}
|
|
588
644
|
/** Cancel a live merge-queue entry by entry id. */
|
|
589
645
|
export async function cancelMergeQueueEntry(entryId, cfg) {
|
|
@@ -28,6 +28,11 @@ export declare function buildStatusTabLines(input: StatusTabInput): string[];
|
|
|
28
28
|
export declare function formatJobDetailLines(row: ReviewJobRow): string[];
|
|
29
29
|
/** Human labels for the Config tab and the static `settings` printout. */
|
|
30
30
|
export declare const SETTINGS_LABELS: Record<"auto_review_enabled" | "auto_patch_enabled" | "vortex_show_thinking_traces" | "repo_overview_enabled" | "review_unit_land_prs_enabled" | "cyclone_skip_ci_enabled" | "cyclone_patch_unverified_languages" | "vortex_auto_overflow_enabled" | "vortex_skip_all_clear_comments" | "vortex_seam_specialist_enabled" | "auto_land_default" | "auto_land_settle_seconds" | "merge_queue_batch_enabled" | "merge_queue_batch_size" | "ignored_bot_logins" | "vortex_bot_skip_check" | "vortex_findings_check" | "cyclone_patch_failure_check", string>;
|
|
31
|
+
export declare const STACKS_READY_LABEL = "Stacks, merge queue, Auto land";
|
|
32
|
+
export declare const STACKS_READY_WORDS: {
|
|
33
|
+
readonly on: "ready";
|
|
34
|
+
readonly off: "not ready (install Mergestorm Surge)";
|
|
35
|
+
};
|
|
31
36
|
/** Config rows in a stable order: writable toggles, then connected flags. */
|
|
32
37
|
export declare function buildConfigRows(settings: SettingsResponse): TabsConfigRow[];
|
|
33
38
|
/**
|
package/dist/commands/browse.js
CHANGED
|
@@ -121,8 +121,14 @@ export function formatJobDetailLines(row) {
|
|
|
121
121
|
}
|
|
122
122
|
/** Human labels for the Config tab and the static `settings` printout. */
|
|
123
123
|
export const SETTINGS_LABELS = BEARER_SETTINGS_LABELS;
|
|
124
|
+
export const STACKS_READY_LABEL = "Stacks, merge queue, Auto land";
|
|
125
|
+
export const STACKS_READY_WORDS = {
|
|
126
|
+
on: "ready",
|
|
127
|
+
off: "not ready (install Mergestorm Surge)",
|
|
128
|
+
};
|
|
124
129
|
/** Config rows in a stable order: writable toggles, then connected flags. */
|
|
125
130
|
export function buildConfigRows(settings) {
|
|
131
|
+
const stacksReadyKnown = typeof settings.stacks_ready === "boolean";
|
|
126
132
|
return [
|
|
127
133
|
...BEARER_BOOLEAN_SETTINGS_KEYS.map((key) => ({
|
|
128
134
|
key,
|
|
@@ -130,9 +136,20 @@ export function buildConfigRows(settings) {
|
|
|
130
136
|
value: settings[key] === true,
|
|
131
137
|
writable: true,
|
|
132
138
|
})),
|
|
139
|
+
...(stacksReadyKnown
|
|
140
|
+
? [
|
|
141
|
+
{
|
|
142
|
+
key: "stacks_ready",
|
|
143
|
+
label: STACKS_READY_LABEL,
|
|
144
|
+
value: settings.stacks_ready === true,
|
|
145
|
+
writable: false,
|
|
146
|
+
readOnlyWords: STACKS_READY_WORDS,
|
|
147
|
+
},
|
|
148
|
+
]
|
|
149
|
+
: []),
|
|
133
150
|
{
|
|
134
151
|
key: "cyclone_connected",
|
|
135
|
-
label: "Cyclone",
|
|
152
|
+
label: stacksReadyKnown ? "Cyclone (auto patch)" : "Cyclone",
|
|
136
153
|
value: settings.cyclone_connected === true,
|
|
137
154
|
writable: false,
|
|
138
155
|
},
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { REVIEW_POLL_MAX_TRANSIENT_RETRIES, isTransientReviewPollError, isTransientReviewPollStatus } from "./review-client.js";
|
|
1
|
+
import { REVIEW_POLL_MAX_TRANSIENT_RETRIES, isTransientReviewPollError, isTransientReviewPollStatus, type PollReviewOptions } from "./review-client.js";
|
|
2
2
|
export { REVIEW_POLL_MAX_TRANSIENT_RETRIES, isTransientReviewPollError, isTransientReviewPollStatus, };
|
|
3
3
|
/** Same public specialist allowlist as the API contract and migration 117. */
|
|
4
4
|
export declare const VORTEX_ROUTER_MODES: readonly ["off", "standard", "max", "manual"];
|
|
@@ -43,6 +43,7 @@ export type ReviewOptions = {
|
|
|
43
43
|
interactive?: boolean;
|
|
44
44
|
/** Test seam; production uses the two-second poll interval. */
|
|
45
45
|
pollIntervalMs?: number;
|
|
46
|
+
pollClock?: Pick<PollReviewOptions, "sleep" | "now" | "random">;
|
|
46
47
|
};
|
|
47
48
|
export declare function reviewStatusHint(jobId: string): string;
|
|
48
49
|
/** Append recovery hint once a job id exists (idempotent). */
|
package/dist/commands/review.js
CHANGED
|
@@ -357,6 +357,7 @@ export async function cmdReview(args, opts = {}) {
|
|
|
357
357
|
row = await pollReview(cfg, jobId, {
|
|
358
358
|
timeoutMs: parsed.timeoutMs,
|
|
359
359
|
intervalMs: opts.pollIntervalMs,
|
|
360
|
+
...opts.pollClock,
|
|
360
361
|
signal: opts.signal,
|
|
361
362
|
propagateTimeout: interactive,
|
|
362
363
|
onTick(kind) {
|
|
@@ -3,6 +3,7 @@ import { BEARER_SETTINGS, BEARER_SETTINGS_FLAGS } from "../automation-catalog.js
|
|
|
3
3
|
import { loadConfig } from "../config.js";
|
|
4
4
|
import { CommandError } from "../errors.js";
|
|
5
5
|
import { present } from "../ui/present.js";
|
|
6
|
+
import { readOnlyRowWords } from "../ui/tabs.js";
|
|
6
7
|
import { buildConfigRows, canBrowse, openTabsBrowser } from "./browse.js";
|
|
7
8
|
/**
|
|
8
9
|
* CLI flag → Bearer settings key. Boolean flags take on|off. The connected flags are
|
|
@@ -91,9 +92,7 @@ export function formatSettingsLines(settings) {
|
|
|
91
92
|
? row.value
|
|
92
93
|
? "on"
|
|
93
94
|
: "off"
|
|
94
|
-
: row
|
|
95
|
-
? "connected"
|
|
96
|
-
: "not connected";
|
|
95
|
+
: readOnlyRowWords(row);
|
|
97
96
|
return ` ${row.label.padEnd(labelWidth + 2)}${value}`;
|
|
98
97
|
});
|
|
99
98
|
}
|
package/dist/commands/stack.js
CHANGED
|
@@ -1040,8 +1040,7 @@ export async function cmdStackStatus(args, deps = {}) {
|
|
|
1040
1040
|
lines.push(` ${String(layer.position).padStart(2)} ${pr}${head} ${layer.state.padEnd(14)} ${layer.branch}` +
|
|
1041
1041
|
` CI: ${layer.ciStatus ?? "unknown"} review: ${layer.reviewStatus ?? "unknown"}` +
|
|
1042
1042
|
(layer.vortexStatus ? ` Vortex: ${layer.vortexStatus}` : "") +
|
|
1043
|
-
(layer.cycloneStatus ? ` Cyclone: ${layer.cycloneStatus}` : "")
|
|
1044
|
-
(layer.tempestStatus ? ` Tempest: ${layer.tempestStatus}` : ""));
|
|
1043
|
+
(layer.cycloneStatus ? ` Cyclone: ${layer.cycloneStatus}` : ""));
|
|
1045
1044
|
}
|
|
1046
1045
|
if (queueWait && !attention)
|
|
1047
1046
|
lines.push(` Queue: #${queueWait.prNumber} ${queueWait.text}`);
|
package/dist/errors.d.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Stable machine codes for CommandError control flow.
|
|
3
3
|
* UX copy in `message` may change freely; match on `code`, never on message text.
|
|
4
4
|
*/
|
|
5
|
-
export type CommandErrorCode = "auth_invalid" | "api_timeout" | "not_a_repo" | "not_found" | "missing_api_key" | "registered_parent" | "usage" | "review_quota" | "review_failed" | "review_timeout" | "rate_limited" | "cyclone_not_installed" | "pr_merged" | "pr_closed" | "not_stacked" | "draft" | "reserved_branch" | "supabase_unconfigured" | "cyclone_not_connected" | "busy" | "adopt_failed" | "stack_attention";
|
|
5
|
+
export type CommandErrorCode = "auth_invalid" | "api_timeout" | "not_a_repo" | "not_found" | "missing_api_key" | "registered_parent" | "usage" | "review_quota" | "review_failed" | "review_timeout" | "rate_limited" | "cyclone_not_installed" | "pr_merged" | "pr_closed" | "not_stacked" | "draft" | "reserved_branch" | "supabase_unconfigured" | "cyclone_not_connected" | "cyclone_permission_missing" | "busy" | "adopt_failed" | "github_unreadable" | "stack_attention";
|
|
6
6
|
/** Stable one-shot exit codes for machine callers of `mergestorm review`. */
|
|
7
7
|
export declare const REVIEW_EXIT: {
|
|
8
8
|
readonly success: 0;
|
|
@@ -15,14 +15,17 @@ export declare const REVIEW_EXIT: {
|
|
|
15
15
|
};
|
|
16
16
|
export type CommandErrorExtras = {
|
|
17
17
|
retryAfterSeconds?: number;
|
|
18
|
+
reason?: string;
|
|
18
19
|
};
|
|
19
20
|
/** Thrown by command handlers; one-shot mode maps `exitCode` onto process.exitCode. */
|
|
20
21
|
export declare class CommandError extends Error {
|
|
21
22
|
readonly exitCode: number;
|
|
22
23
|
readonly code?: CommandErrorCode;
|
|
23
24
|
readonly retryAfterSeconds?: number;
|
|
25
|
+
readonly reason?: string;
|
|
24
26
|
constructor(message: string, exitCode?: number, code?: CommandErrorCode, extras?: CommandErrorExtras);
|
|
25
27
|
}
|
|
28
|
+
export declare function plainServerText(text: string): string;
|
|
26
29
|
export declare const RATE_LIMITED_HINT = "Rate limited. Wait for a running review or mergestorm jobs.";
|
|
27
30
|
export declare function rateLimitedMessage(retryAfterSeconds?: number): string;
|
|
28
31
|
export declare function isCommandErrorCode(err: unknown, code: CommandErrorCode): err is CommandError;
|
package/dist/errors.js
CHANGED
|
@@ -13,6 +13,7 @@ export class CommandError extends Error {
|
|
|
13
13
|
exitCode;
|
|
14
14
|
code;
|
|
15
15
|
retryAfterSeconds;
|
|
16
|
+
reason;
|
|
16
17
|
constructor(message, exitCode = 1, code, extras) {
|
|
17
18
|
super(message);
|
|
18
19
|
this.name = "CommandError";
|
|
@@ -21,8 +22,16 @@ export class CommandError extends Error {
|
|
|
21
22
|
if (extras?.retryAfterSeconds !== undefined) {
|
|
22
23
|
this.retryAfterSeconds = extras.retryAfterSeconds;
|
|
23
24
|
}
|
|
25
|
+
if (extras?.reason !== undefined) {
|
|
26
|
+
this.reason = extras.reason;
|
|
27
|
+
}
|
|
24
28
|
}
|
|
25
29
|
}
|
|
30
|
+
const ANSI_SEQUENCE = /\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b\[[0-?]*[ -/]*[@-~]/g;
|
|
31
|
+
const CONTROL_CHARACTER = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
|
|
32
|
+
export function plainServerText(text) {
|
|
33
|
+
return text.replace(ANSI_SEQUENCE, "").replace(CONTROL_CHARACTER, "");
|
|
34
|
+
}
|
|
26
35
|
export const RATE_LIMITED_HINT = "Rate limited. Wait for a running review or mergestorm jobs.";
|
|
27
36
|
export function rateLimitedMessage(retryAfterSeconds) {
|
|
28
37
|
if (retryAfterSeconds == null || !Number.isFinite(retryAfterSeconds)) {
|
package/dist/stack-blockers.d.ts
CHANGED
|
@@ -6,6 +6,10 @@ export type StackBlocker = {
|
|
|
6
6
|
blocker: string;
|
|
7
7
|
bounceKind: string | null;
|
|
8
8
|
};
|
|
9
|
+
export type StackBlockerReason = "restack_conflict" | "restack_failed" | "draft_pr" | "merge_conflict" | "ci_failed" | "vortex_failed" | "vortex_out_of_quota" | "vortex_skipped" | "vortex_findings_need_person" | "seam_failed" | "seam_findings" | "seam_not_running" | "seam_review_stuck" | "seam_rereview_not_running" | "seam_verdict_stale" | "unit_abandoned" | "land_gate" | "bounce" | "auto_land_off" | "auto_land_ci_pending" | "auto_land_members_remaining" | "auto_land_vortex_wait" | "auto_land_promote_failed" | "auto_land_ci_unreadable" | "auto_land_waited" | "queue_stalled" | "land_pr_missing";
|
|
10
|
+
export type ClassifiedStackBlocker = StackBlocker & {
|
|
11
|
+
reason: StackBlockerReason;
|
|
12
|
+
};
|
|
9
13
|
export type StackAgentBusy = {
|
|
10
14
|
prNumber: number;
|
|
11
15
|
headSha: string | null;
|
|
@@ -47,20 +51,6 @@ export type StackRepairHint = {
|
|
|
47
51
|
branch: string;
|
|
48
52
|
failingCheck: string | null;
|
|
49
53
|
steps: string;
|
|
50
|
-
} | {
|
|
51
|
-
kind: "tempest_findings";
|
|
52
|
-
prNumber: number;
|
|
53
|
-
headSha: string | null;
|
|
54
|
-
branch: string;
|
|
55
|
-
findings: string;
|
|
56
|
-
steps: string;
|
|
57
|
-
} | {
|
|
58
|
-
kind: "tempest_rerun";
|
|
59
|
-
prNumber: number;
|
|
60
|
-
headSha: string | null;
|
|
61
|
-
branch: string;
|
|
62
|
-
stoppedByPerson: boolean;
|
|
63
|
-
steps: string;
|
|
64
54
|
} | {
|
|
65
55
|
kind: "vortex_findings";
|
|
66
56
|
prNumber: number;
|
|
@@ -121,7 +111,7 @@ export type StackRepairHint = {
|
|
|
121
111
|
waitReason: string | null;
|
|
122
112
|
steps: string;
|
|
123
113
|
} | {
|
|
124
|
-
kind: "members_remaining" | "vortex_failed"
|
|
114
|
+
kind: "members_remaining" | "vortex_failed";
|
|
125
115
|
prNumber: number;
|
|
126
116
|
headSha: string | null;
|
|
127
117
|
branch: string;
|
|
@@ -211,8 +201,8 @@ export declare function restackFailedRepairSteps(branch: string, liveParent: str
|
|
|
211
201
|
kind: string;
|
|
212
202
|
detail?: string | null;
|
|
213
203
|
}): string;
|
|
214
|
-
export declare function agentsCannotClear(
|
|
215
|
-
export declare function stackRepair(stack: StackDto, attention:
|
|
204
|
+
export declare function agentsCannotClear(reason: StackBlockerReason): boolean;
|
|
205
|
+
export declare function stackRepair(stack: StackDto, attention: ClassifiedStackBlocker | null, layer: StackLayerDto | undefined, bounce: MergeQueueEntryDto | undefined, queueWait?: MergeQueueWait | null): StackRepairHint | null;
|
|
216
206
|
export declare const LAND_PR_MISSING_PREFIX = "No open land PR for ";
|
|
217
207
|
/** Pair-gate attention plus live hard blocks on every other open layer. */
|
|
218
208
|
export declare function stackBlockers(stack: StackDto, entries?: MergeQueueEntryDto[], cursor?: Pick<StackWatchCursor, "bounceId" | "afterFinishedAt">, nowMs?: number): {
|
package/dist/stack-blockers.js
CHANGED
|
@@ -57,7 +57,7 @@ export const VORTEX_REVIEW_OUT_OF_QUOTA = "Review skipped, out of review quota";
|
|
|
57
57
|
export const SEAM_REVIEW_STUCK = "Seam review stuck in reviewing, no review running";
|
|
58
58
|
export const SEAM_REREVIEW_NOT_RUNNING = "Seam re-review pending, no review running";
|
|
59
59
|
export const SEAM_VERDICT_STALE = "Seam verdict is for an older head, no review running";
|
|
60
|
-
const
|
|
60
|
+
const STUCK_SEAM_REASONS = ["seam_review_stuck", "seam_rereview_not_running", "seam_verdict_stale"];
|
|
61
61
|
function coreReviewDoneAtHead(layer) {
|
|
62
62
|
const review = layer.vortexReview;
|
|
63
63
|
return review?.status === "done" && sameHead(review.head_sha, layer.headSha);
|
|
@@ -159,24 +159,26 @@ function seamGateBlocker(stack, layer) {
|
|
|
159
159
|
if (!member || member.promotedHeadSha?.trim())
|
|
160
160
|
return null;
|
|
161
161
|
const seamState = member.seamState?.trim().toLowerCase();
|
|
162
|
+
const stale = { blocker: SEAM_VERDICT_STALE, reason: "seam_verdict_stale" };
|
|
162
163
|
if (seamState === "failed")
|
|
163
|
-
return "Seam review failed";
|
|
164
|
-
if (seamState === "findings" && sameHead(member.seamReviewedSha, layer.headSha))
|
|
165
|
-
return "Seam findings";
|
|
164
|
+
return { blocker: "Seam review failed", reason: "seam_failed" };
|
|
165
|
+
if (seamState === "findings" && sameHead(member.seamReviewedSha, layer.headSha)) {
|
|
166
|
+
return { blocker: "Seam findings", reason: "seam_findings" };
|
|
167
|
+
}
|
|
166
168
|
if (layerAgentsBusy(layer).vortex)
|
|
167
169
|
return null;
|
|
168
170
|
if (seamState === "pending")
|
|
169
|
-
return SEAM_REVIEW_NOT_RUNNING;
|
|
171
|
+
return { blocker: SEAM_REVIEW_NOT_RUNNING, reason: "seam_not_running" };
|
|
170
172
|
if (seamState === "reviewing")
|
|
171
|
-
return SEAM_REVIEW_STUCK;
|
|
173
|
+
return { blocker: SEAM_REVIEW_STUCK, reason: "seam_review_stuck" };
|
|
172
174
|
if (seamState === "pending_rereview")
|
|
173
|
-
return SEAM_REREVIEW_NOT_RUNNING;
|
|
175
|
+
return { blocker: SEAM_REREVIEW_NOT_RUNNING, reason: "seam_rereview_not_running" };
|
|
174
176
|
if (!layer.headSha?.trim() || sameHead(member.seamReviewedSha, layer.headSha))
|
|
175
177
|
return null;
|
|
176
178
|
if (seamState === "findings")
|
|
177
|
-
return
|
|
179
|
+
return stale;
|
|
178
180
|
if (seamState === "approved" && !coreReviewDoneAtHead(layer))
|
|
179
|
-
return
|
|
181
|
+
return stale;
|
|
180
182
|
return null;
|
|
181
183
|
}
|
|
182
184
|
function headChecksAllGreen(layer) {
|
|
@@ -186,66 +188,58 @@ function headChecksAllGreen(layer) {
|
|
|
186
188
|
}
|
|
187
189
|
/** Shared live-fact precedence; bounce history is only passed for the candidate. */
|
|
188
190
|
function layerAttention(stack, layer, queue, cursor) {
|
|
189
|
-
const none = { blocker: null, bounceKind: null };
|
|
191
|
+
const none = { blocker: null, reason: null, bounceKind: null };
|
|
190
192
|
if (stack.archivedAt)
|
|
191
193
|
return none;
|
|
192
194
|
const bounce = queue.filter((entry) => entry.state === "bounced")
|
|
193
195
|
.sort((a, b) => (Date.parse(b.finishedAt ?? "") || 0) - (Date.parse(a.finishedAt ?? "") || 0))[0];
|
|
194
196
|
const kind = bounce?.bounceDetail?.kind;
|
|
195
197
|
const recoverable = kind === "head_moved" || kind === "must_consolidate";
|
|
196
|
-
const hardBlock = (blocker) => ({ blocker, bounceKind: null });
|
|
198
|
+
const hardBlock = (blocker, reason) => ({ blocker, reason, bounceKind: null });
|
|
197
199
|
if (layer) {
|
|
198
200
|
if (layer.restackError && layer.state !== "restacking") {
|
|
199
201
|
if (layer.restackError.kind === "rebase_conflict")
|
|
200
|
-
return hardBlock("Conflict");
|
|
202
|
+
return hardBlock("Conflict", "restack_conflict");
|
|
201
203
|
if (!restackRetryPending(layer.restackError, layer))
|
|
202
|
-
return hardBlock(RESTACK_FAILED);
|
|
204
|
+
return hardBlock(RESTACK_FAILED, "restack_failed");
|
|
203
205
|
}
|
|
204
206
|
if (layer.state === "conflict")
|
|
205
|
-
return hardBlock("Conflict");
|
|
207
|
+
return hardBlock("Conflict", "restack_conflict");
|
|
206
208
|
if (layer.draft)
|
|
207
209
|
return {
|
|
208
|
-
blocker: "Draft PR", bounceKind: "pr_draft",
|
|
210
|
+
blocker: "Draft PR", reason: "draft_pr", bounceKind: "pr_draft",
|
|
209
211
|
...(kind === "pr_draft" && sameHead(bounce.bounceDetail?.headSha ?? bounce.verifyHeadSha, layer.headSha) &&
|
|
210
212
|
(bounce.bounceDetail?.prNumber == null || bounce.bounceDetail.prNumber === layer.prNumber) ? { bounce } : {}),
|
|
211
213
|
};
|
|
212
|
-
if (boundDirty(layer))
|
|
213
|
-
return hardBlock(`Merge conflicts${layer.parentBranch ? ` vs ${layer.parentBranch}` : ""}
|
|
214
|
+
if (boundDirty(layer)) {
|
|
215
|
+
return hardBlock(`Merge conflicts${layer.parentBranch ? ` vs ${layer.parentBranch}` : ""}`, "merge_conflict");
|
|
216
|
+
}
|
|
214
217
|
if (layer.ciStatus === "failure" || (layer.checks?.failure ?? 0) > 0) {
|
|
215
218
|
const name = layer.checks?.failingName?.trim();
|
|
216
|
-
return hardBlock(`CI failed${name ? ` — ${name}` : ""}
|
|
219
|
+
return hardBlock(`CI failed${name ? ` — ${name}` : ""}`, "ci_failed");
|
|
217
220
|
}
|
|
218
|
-
if (layer.agentRuns?.some((run) => run.agent === "cyclone" && run.status === "failed" &&
|
|
219
|
-
sameHead(run.sha, layer.headSha)))
|
|
220
|
-
return hardBlock("Cyclone failed");
|
|
221
|
-
const tempest = layer.agentRuns?.find((run) => run.agent === "tempest" &&
|
|
222
|
-
run.status === "findings" && sameHead(run.sha, layer.headSha));
|
|
223
|
-
if (tempest)
|
|
224
|
-
return hardBlock(`Tempest ${tempest.status}`);
|
|
225
221
|
if (layer.vortexStatus === "failed" && !vortexRetryScheduled(layer) &&
|
|
226
222
|
(!layer.vortexReview?.head_sha || sameHead(layer.vortexReview.head_sha, layer.headSha))) {
|
|
227
|
-
return hardBlock(VORTEX_REVIEW_FAILED);
|
|
223
|
+
return hardBlock(VORTEX_REVIEW_FAILED, "vortex_failed");
|
|
228
224
|
}
|
|
229
225
|
if (quotaSkippedReview(layer))
|
|
230
|
-
return hardBlock(VORTEX_REVIEW_OUT_OF_QUOTA);
|
|
226
|
+
return hardBlock(VORTEX_REVIEW_OUT_OF_QUOTA, "vortex_out_of_quota");
|
|
231
227
|
const vortexSkip = vortexIdleSkipBlocker(layer);
|
|
232
228
|
if (vortexSkip)
|
|
233
|
-
return hardBlock(vortexSkip.blocker);
|
|
229
|
+
return hardBlock(vortexSkip.blocker, "vortex_skipped");
|
|
234
230
|
const seam = seamGateBlocker(stack, layer);
|
|
235
231
|
if (seam)
|
|
236
|
-
return hardBlock(seam);
|
|
232
|
+
return hardBlock(seam.blocker, seam.reason);
|
|
237
233
|
if (unitAbandonedUnder(stack, layer)) {
|
|
238
|
-
return hardBlock(`${UNIT_ABANDONED_BLOCKER_PREFIX}${layer.parentBranch?.trim() || stack.trunkBranch}
|
|
234
|
+
return hardBlock(`${UNIT_ABANDONED_BLOCKER_PREFIX}${layer.parentBranch?.trim() || stack.trunkBranch}`, "unit_abandoned");
|
|
239
235
|
}
|
|
240
236
|
if (stack.unit?.landPrNumber === layer.prNumber) {
|
|
241
237
|
const landing = stack.unit.landingBlockReason?.trim();
|
|
242
238
|
if (landing && !landGateIsPending(landing))
|
|
243
|
-
return hardBlock(landing);
|
|
244
|
-
if (stack.unit.tempestLandStatus?.toLowerCase() === "failed")
|
|
245
|
-
return hardBlock("Tempest failed");
|
|
239
|
+
return hardBlock(landing, "land_gate");
|
|
246
240
|
}
|
|
247
241
|
if (vortexFindingsLeftForPerson(stack, layer))
|
|
248
|
-
return hardBlock(VORTEX_FINDINGS_NEED_PERSON);
|
|
242
|
+
return hardBlock(VORTEX_FINDINGS_NEED_PERSON, "vortex_findings_need_person");
|
|
249
243
|
}
|
|
250
244
|
if (!bounce || !kind || recoverable || !layer || queue.some((entry) => ["queued", "running", "waiting"].includes(entry.state)))
|
|
251
245
|
return none;
|
|
@@ -258,7 +252,8 @@ function layerAttention(stack, layer, queue, cursor) {
|
|
|
258
252
|
return none;
|
|
259
253
|
if (kind === "ci_failure" && !bounce.bounceDetail?.batch && headChecksAllGreen(layer))
|
|
260
254
|
return none;
|
|
261
|
-
|
|
255
|
+
const label = mergeQueueBounceLabel(bounce);
|
|
256
|
+
return { blocker: label, reason: "bounce", bounceKind: kind, bounce };
|
|
262
257
|
}
|
|
263
258
|
const UNIT_ABANDONED_BLOCKER_PREFIX = "Review unit abandoned: based on ";
|
|
264
259
|
function unitAbandonedUnder(stack, layer) {
|
|
@@ -313,15 +308,7 @@ const CI_UNREADABLE_PERMISSION = {
|
|
|
313
308
|
};
|
|
314
309
|
const AUTO_LAND_WAIT_WORDS = {
|
|
315
310
|
mergeability_unknown: "GitHub has not said whether this PR can merge into its base",
|
|
316
|
-
tempest_pending: "Tempest's review of this head has not started",
|
|
317
|
-
tempest_running: "Tempest's review of this head has not finished",
|
|
318
|
-
tempest_trigger_failed: "Mergestorm could not reach Tempest to review the land PR; it keeps trying every 5 minutes",
|
|
319
|
-
tempest_skipped: "Tempest skipped its review of this head",
|
|
320
311
|
members_remaining: "a PR that is not a layer of this stack is still open on the stack branch",
|
|
321
|
-
tempest_stopped: "Tempest's review of this head was stopped",
|
|
322
|
-
tempest_failed: "Tempest's review of this head broke without a result",
|
|
323
|
-
tempest_findings: "Tempest reported findings on this head",
|
|
324
|
-
cyclone_failed: "Cyclone's patch run failed on this head",
|
|
325
312
|
stack_dirty: "this layer is not clean (it has a conflict or needs a restack)",
|
|
326
313
|
must_consolidate: "this PR's base is not the stack's land target, and the stack has no review unit to promote it into",
|
|
327
314
|
missing_head: "GitHub returned no head commit for this PR",
|
|
@@ -376,6 +363,9 @@ function autoLandWaitOn(stack, layer) {
|
|
|
376
363
|
return autoLandWaitLayer(stack)?.prNumber === layer.prNumber ? armedAutoLandWait(stack) : null;
|
|
377
364
|
}
|
|
378
365
|
export function autoLandWaitBlocker(stack, layer, nowMs) {
|
|
366
|
+
return autoLandWaitNamed(stack, layer, nowMs)?.blocker ?? null;
|
|
367
|
+
}
|
|
368
|
+
function autoLandWaitNamed(stack, layer, nowMs) {
|
|
379
369
|
const wait = autoLandWaitOn(stack, layer);
|
|
380
370
|
if (!wait)
|
|
381
371
|
return null;
|
|
@@ -385,20 +375,32 @@ export function autoLandWaitBlocker(stack, layer, nowMs) {
|
|
|
385
375
|
const minutes = Math.floor(waited / 60_000);
|
|
386
376
|
switch (wait.reason) {
|
|
387
377
|
case "ci_failure":
|
|
388
|
-
return AUTO_LAND_CI_FAILED_BLOCKER;
|
|
378
|
+
return { blocker: AUTO_LAND_CI_FAILED_BLOCKER, reason: "ci_failed" };
|
|
389
379
|
case "ci_pending":
|
|
390
380
|
return waited >= AUTO_LAND_CI_PENDING_ATTENTION_MS
|
|
391
|
-
?
|
|
381
|
+
? {
|
|
382
|
+
blocker: `${AUTO_LAND_CI_PENDING_PREFIX}${minutes}m — a check on this head has not finished, so Auto land will not queue it`,
|
|
383
|
+
reason: "auto_land_ci_pending",
|
|
384
|
+
}
|
|
392
385
|
: null;
|
|
393
386
|
case "members_remaining":
|
|
394
|
-
return
|
|
387
|
+
return {
|
|
388
|
+
blocker: `${AUTO_LAND_MEMBERS_REMAINING_PREFIX}${wait.detail ?? AUTO_LAND_WAIT_WORDS.members_remaining}`,
|
|
389
|
+
reason: "auto_land_members_remaining",
|
|
390
|
+
};
|
|
395
391
|
case "vortex_not_green":
|
|
396
392
|
return waited >= AUTO_LAND_VORTEX_WAIT_ATTENTION_MS && !vortexRetryScheduled(layer)
|
|
397
|
-
?
|
|
393
|
+
? {
|
|
394
|
+
blocker: `${AUTO_LAND_VORTEX_WAIT_PREFIX}${minutes}m — Auto land is waiting for an approving Vortex review at this head`,
|
|
395
|
+
reason: "auto_land_vortex_wait",
|
|
396
|
+
}
|
|
398
397
|
: null;
|
|
399
398
|
case "promote_merge_failed":
|
|
400
399
|
return (wait.attempts ?? 0) >= AUTO_LAND_PROMOTE_FAILED_ATTEMPTS
|
|
401
|
-
?
|
|
400
|
+
? {
|
|
401
|
+
blocker: `${AUTO_LAND_PROMOTE_FAILED_PREFIX}${wait.attempts} times (merge_failed) — Auto land stopped retrying this head${wait.detail ? `: ${wait.detail}` : ""}`,
|
|
402
|
+
reason: "auto_land_promote_failed",
|
|
403
|
+
}
|
|
402
404
|
: null;
|
|
403
405
|
default:
|
|
404
406
|
break;
|
|
@@ -406,11 +408,14 @@ export function autoLandWaitBlocker(stack, layer, nowMs) {
|
|
|
406
408
|
const unreadable = CI_UNREADABLE_WHAT[wait.reason];
|
|
407
409
|
if (unreadable) {
|
|
408
410
|
return ciUnreadableForGood(wait) || waited >= AUTO_LAND_CI_UNREADABLE_ATTENTION_MS
|
|
409
|
-
?
|
|
411
|
+
? {
|
|
412
|
+
blocker: `${AUTO_LAND_CI_UNREADABLE_PREFIX}${unreadable} on this head${wait.detail ? ` (${wait.detail})` : ""} — Auto land cannot tell whether CI passed`,
|
|
413
|
+
reason: "auto_land_ci_unreadable",
|
|
414
|
+
}
|
|
410
415
|
: null;
|
|
411
416
|
}
|
|
412
417
|
return waited >= AUTO_LAND_WAIT_ATTENTION_MS
|
|
413
|
-
? `${AUTO_LAND_WAITED_PREFIX}${minutes}m: ${autoLandWaitWords(wait.reason)}
|
|
418
|
+
? { blocker: `${AUTO_LAND_WAITED_PREFIX}${minutes}m: ${autoLandWaitWords(wait.reason)}`, reason: "auto_land_waited" }
|
|
414
419
|
: null;
|
|
415
420
|
}
|
|
416
421
|
export const MERGE_QUEUE_STALL_ATTENTION_MS = 45 * 60_000;
|
|
@@ -514,28 +519,25 @@ function landPrLiveParent(stack, layer) {
|
|
|
514
519
|
const base = stack.unit?.landTarget?.trim() || layer.parentBranch?.trim();
|
|
515
520
|
return base && !isMgParkBase(base) && base !== layer.branch ? base : null;
|
|
516
521
|
}
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
if (reason && TEMPEST_RERUN_BLOCKER.test(reason))
|
|
531
|
-
return false;
|
|
532
|
-
return attention.bounceKind === "tempest_findings" || /^Tempest findings\b/i.test(blocker) ||
|
|
533
|
-
/^(?:landing blocked:\s*)?tempest_findings\b/i.test(blocker);
|
|
522
|
+
const AGENTS_CANNOT_CLEAR = [
|
|
523
|
+
"restack_conflict",
|
|
524
|
+
"restack_failed",
|
|
525
|
+
"draft_pr",
|
|
526
|
+
"merge_conflict",
|
|
527
|
+
"seam_not_running",
|
|
528
|
+
...STUCK_SEAM_REASONS,
|
|
529
|
+
"vortex_out_of_quota",
|
|
530
|
+
"vortex_skipped",
|
|
531
|
+
"unit_abandoned",
|
|
532
|
+
];
|
|
533
|
+
export function agentsCannotClear(reason) {
|
|
534
|
+
return AGENTS_CANNOT_CLEAR.includes(reason);
|
|
534
535
|
}
|
|
535
536
|
export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
|
|
536
537
|
if (!attention || !layer || attention.prNumber !== layer.prNumber)
|
|
537
538
|
return null;
|
|
538
|
-
|
|
539
|
+
const reason = attention.reason;
|
|
540
|
+
if (queueWait && reason === "queue_stalled") {
|
|
539
541
|
return {
|
|
540
542
|
kind: "queue_stalled",
|
|
541
543
|
prNumber: layer.prNumber,
|
|
@@ -547,8 +549,8 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
|
|
|
547
549
|
};
|
|
548
550
|
}
|
|
549
551
|
const landPr = !!stack.unit?.branch && layer.prNumber === stack.unit.landPrNumber && layer.branch === stack.unit.branch;
|
|
550
|
-
const restackConflict =
|
|
551
|
-
const mergeConflict =
|
|
552
|
+
const restackConflict = reason === "restack_conflict" || attention.bounceKind === "restack_conflict";
|
|
553
|
+
const mergeConflict = reason === "merge_conflict";
|
|
552
554
|
if (restackConflict || mergeConflict) {
|
|
553
555
|
const liveParent = landPr ? landPrLiveParent(stack, layer) : conflictLiveParent(stack, layer);
|
|
554
556
|
return {
|
|
@@ -561,7 +563,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
|
|
|
561
563
|
steps: conflictRepairSteps(layer.branch, liveParent),
|
|
562
564
|
};
|
|
563
565
|
}
|
|
564
|
-
if (
|
|
566
|
+
if (reason === "restack_failed" && layer.restackError) {
|
|
565
567
|
const liveParent = landPr ? landPrLiveParent(stack, layer) : conflictLiveParent(stack, layer);
|
|
566
568
|
return {
|
|
567
569
|
kind: "restack_failed",
|
|
@@ -573,7 +575,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
|
|
|
573
575
|
steps: restackFailedRepairSteps(layer.branch, liveParent, layer.restackError),
|
|
574
576
|
};
|
|
575
577
|
}
|
|
576
|
-
if (
|
|
578
|
+
if (reason === "unit_abandoned") {
|
|
577
579
|
const openPrCount = stack.layers.filter((candidate) => candidate.prNumber > 0 && !isTerminalLayer(candidate)).length;
|
|
578
580
|
return {
|
|
579
581
|
kind: "unit_abandoned",
|
|
@@ -583,11 +585,11 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
|
|
|
583
585
|
steps: `The stack's review unit was abandoned when #${layer.prNumber}'s base on GitHub was changed off its mg-stack trunk, so Auto land cannot promote or land this stack. Ask the human whether to re-root it. ${openPrCount < 2 ? `With only one open PR, re-adopting #${layer.prNumber} will not create a review unit; close and resubmit it to create a fresh stack.` : `If yes, tell the human that mg stack adopt ${stack.owner}/${stack.repo}#${layer.prNumber} re-roots it on a new review unit.`} Do not retarget the PR base yourself.`,
|
|
584
586
|
};
|
|
585
587
|
}
|
|
586
|
-
if (
|
|
588
|
+
if (STUCK_SEAM_REASONS.includes(reason)) {
|
|
587
589
|
const member = stack.unit?.members?.find((entry) => entry.prNumber === layer.prNumber);
|
|
588
590
|
const seamState = member?.seamState?.trim().toLowerCase() || "unknown";
|
|
589
591
|
const reviewed = member?.seamReviewedSha?.trim();
|
|
590
|
-
const what =
|
|
592
|
+
const what = reason === "seam_verdict_stale"
|
|
591
593
|
? `The seam (integration) review verdict on #${layer.prNumber} (${seamState}) was made at ${reviewed ? reviewed.slice(0, 7) : "an older head"}, not the live head ${layer.headSha ?? "(unknown)"}, and no seam review is queued or running to replace it`
|
|
592
594
|
: `The seam (integration) review of #${layer.prNumber} is ${seamState} but no seam review is queued or running at ${layer.headSha ?? "the live head"}`;
|
|
593
595
|
return {
|
|
@@ -599,7 +601,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
|
|
|
599
601
|
steps: `${what}, so Auto land cannot promote it. Do not patch or push for this. Tell the human to click Continue on the stack in the Mergestorm dashboard, which re-queues the seam review at the live head of ${layer.branch}; a new commit pushed to ${layer.branch} also queues one. Then keep watching.`,
|
|
600
602
|
};
|
|
601
603
|
}
|
|
602
|
-
if (
|
|
604
|
+
if (reason === "auto_land_off" && attention.bounceKind) {
|
|
603
605
|
return {
|
|
604
606
|
kind: "auto_land_off",
|
|
605
607
|
prNumber: layer.prNumber,
|
|
@@ -609,49 +611,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
|
|
|
609
611
|
steps: `The ${attention.bounceKind} bounce turned Auto land off, so nothing will enqueue this stack. Fix the cause of the bounce on ${layer.branch}, then turn Auto land back on (mg stack set ${stack.id} --auto-land on) or enqueue the stack.`,
|
|
610
612
|
};
|
|
611
613
|
}
|
|
612
|
-
if (
|
|
613
|
-
const findings = `gh api repos/${stack.owner}/${stack.repo}/issues/${layer.prNumber}/comments`;
|
|
614
|
-
return {
|
|
615
|
-
kind: "tempest_findings",
|
|
616
|
-
prNumber: layer.prNumber,
|
|
617
|
-
headSha: layer.headSha ?? null,
|
|
618
|
-
branch: layer.branch,
|
|
619
|
-
findings,
|
|
620
|
-
steps: `Tempest never patches, and Cyclone is not patching this land PR. Read the Tempest report on it (${findings}), verify each finding against ${layer.branch} at its live remote head, fix the real ones with the smallest patch on ${layer.branch}, run the tests, confirm the remote head is still ${layer.headSha ?? "the head you started from"}, then an ordinary git push (no force). Do not retarget the PR base.`,
|
|
621
|
-
};
|
|
622
|
-
}
|
|
623
|
-
const rerunBlocker = attention.blocker.trim().match(TEMPEST_RERUN_BLOCKER) ??
|
|
624
|
-
bounce?.bounceDetail?.message?.trim().match(TEMPEST_RERUN_BLOCKER);
|
|
625
|
-
const rerun = landPr ? rerunBlocker : null;
|
|
626
|
-
if (rerun) {
|
|
627
|
-
const outcome = rerun[1].toLowerCase();
|
|
628
|
-
const stoppedByPerson = outcome === "stopped";
|
|
629
|
-
const comment = `gh pr comment ${layer.prNumber} --repo ${stack.owner}/${stack.repo} --body`;
|
|
630
|
-
const head = layer.headSha ?? "its live head";
|
|
631
|
-
return {
|
|
632
|
-
kind: "tempest_rerun",
|
|
633
|
-
prNumber: layer.prNumber,
|
|
634
|
-
headSha: layer.headSha ?? null,
|
|
635
|
-
branch: layer.branch,
|
|
636
|
-
stoppedByPerson,
|
|
637
|
-
steps: stoppedByPerson
|
|
638
|
-
? `Tempest's review of land PR #${layer.prNumber} at ${head} was stopped, so Auto land will not land it. There are no findings to fix: do not patch or push. Ask the human whether to run Tempest again. If they say yes, run ${comment} "@mergestorm-tempest review --force" and keep watching.`
|
|
639
|
-
: outcome === "skipped"
|
|
640
|
-
? `Tempest skipped its review of land PR #${layer.prNumber} at ${head}, so Auto land will not land it. There are no findings to fix: do not patch or push. Ask the human to run ${comment} "@mergestorm-tempest review" and keep watching.`
|
|
641
|
-
: `Tempest's review of land PR #${layer.prNumber} at ${head} broke before it produced a result, and Mergestorm already re-ran it the most times it will on its own. There are no findings to fix: do not patch or push. Run it once more with ${comment} "@mergestorm-tempest review" and keep watching. If it breaks again, tell the human.`,
|
|
642
|
-
};
|
|
643
|
-
}
|
|
644
|
-
if (landPr && TEMPEST_TRIGGER_FAILED_BLOCKER.test(attention.blocker.trim())) {
|
|
645
|
-
return {
|
|
646
|
-
kind: "tempest_trigger_failed",
|
|
647
|
-
prNumber: layer.prNumber,
|
|
648
|
-
headSha: layer.headSha ?? null,
|
|
649
|
-
branch: layer.branch,
|
|
650
|
-
detail: attention.blocker.trim(),
|
|
651
|
-
steps: `Mergestorm could not reach Tempest to review land PR #${layer.prNumber} at ${layer.headSha ?? "its head"} for over 5 minutes, so Auto land will not land it yet. Mergestorm keeps trying every 5 minutes. Nothing in the PR needs a change: do not patch or push. Keep watching; if this stays, tell the human that Tempest cannot be reached.`,
|
|
652
|
-
};
|
|
653
|
-
}
|
|
654
|
-
if (attention.blocker === VORTEX_REVIEW_FAILED) {
|
|
614
|
+
if (reason === "vortex_failed") {
|
|
655
615
|
return {
|
|
656
616
|
kind: "vortex_failed",
|
|
657
617
|
prNumber: layer.prNumber,
|
|
@@ -662,7 +622,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
|
|
|
662
622
|
};
|
|
663
623
|
}
|
|
664
624
|
const vortexSkip = vortexIdleSkipBlocker(layer);
|
|
665
|
-
if (vortexSkip &&
|
|
625
|
+
if (vortexSkip && reason === "vortex_skipped") {
|
|
666
626
|
return {
|
|
667
627
|
kind: "vortex_skipped",
|
|
668
628
|
prNumber: layer.prNumber,
|
|
@@ -673,7 +633,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
|
|
|
673
633
|
};
|
|
674
634
|
}
|
|
675
635
|
const wait = autoLandWaitOn(stack, layer);
|
|
676
|
-
if (wait?.reason === "members_remaining" &&
|
|
636
|
+
if (wait?.reason === "members_remaining" && reason === "auto_land_members_remaining") {
|
|
677
637
|
return {
|
|
678
638
|
kind: "members_remaining",
|
|
679
639
|
prNumber: layer.prNumber,
|
|
@@ -683,7 +643,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
|
|
|
683
643
|
steps: `Auto land will not send land PR #${layer.prNumber} to the merge queue while another PR is open on ${layer.branch} that is not a layer of this stack${wait.detail ? ` (${wait.detail})` : ""}. Nothing on ${layer.branch} needs a change: do not patch or push for this. Close that PR, retarget it to another base, or adopt it into the stack (mg stack adopt ${stack.owner}/${stack.repo}#<that PR>); a draft PR or a PR from a fork cannot be adopted, so close or retarget it. If the PR is not yours, tell the human. Auto land sends the land PR to the queue by itself once that PR is gone from ${layer.branch}.`,
|
|
684
644
|
};
|
|
685
645
|
}
|
|
686
|
-
if (wait &&
|
|
646
|
+
if (wait && reason === "auto_land_ci_pending") {
|
|
687
647
|
return {
|
|
688
648
|
kind: "ci_pending",
|
|
689
649
|
prNumber: layer.prNumber,
|
|
@@ -693,7 +653,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
|
|
|
693
653
|
steps: `A check on ${layer.branch} at ${layer.headSha ?? "its head"} has been pending since ${wait.since}, and Auto land will not queue an unfinished head. List the head's checks (gh api repos/${stack.owner}/${stack.repo}/commits/${layer.headSha ?? "<sha>"}/check-runs, and gh run list --branch ${layer.branch}) and find the one that never finished. Re-run it (gh run rerun <run-id>) if it is stuck or was never picked up. If it is a required check that no workflow reports, or it fails again, tell the human. Push a fix only when the check is red for a reason in the code. Do not retarget the PR base.`,
|
|
694
654
|
};
|
|
695
655
|
}
|
|
696
|
-
if (wait &&
|
|
656
|
+
if (wait && reason === "auto_land_vortex_wait") {
|
|
697
657
|
return {
|
|
698
658
|
kind: "vortex_not_green",
|
|
699
659
|
prNumber: layer.prNumber,
|
|
@@ -703,7 +663,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
|
|
|
703
663
|
steps: `Auto land has waited since ${wait.since} for an approving Vortex review of ${layer.branch} at ${layer.headSha ?? "its head"}, and no Vortex run is working on it. Read the PR's latest Vortex review (gh api repos/${stack.owner}/${stack.repo}/pulls/${layer.prNumber}/reviews). If it has findings, verify and fix the real ones on ${layer.branch} with an ordinary push, or record a wrong one with mg dismiss as mergestorm-pr-loop describes. If no review exists at this head, or it failed or was skipped, tell the human: nothing re-reviews this head until they re-run the review or push a new head.`,
|
|
704
664
|
};
|
|
705
665
|
}
|
|
706
|
-
if (wait &&
|
|
666
|
+
if (wait && reason === "auto_land_ci_unreadable") {
|
|
707
667
|
const what = CI_UNREADABLE_WHAT[wait.reason] ?? "the checks";
|
|
708
668
|
const permission = CI_UNREADABLE_PERMISSION[wait.reason] ?? "Checks: read and Commit statuses: read";
|
|
709
669
|
return {
|
|
@@ -716,7 +676,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
|
|
|
716
676
|
steps: `GitHub has not let Mergestorm read ${what} for #${layer.prNumber} at ${layer.headSha ?? "its head"} since ${wait.since}${wait.detail ? ` (${wait.detail})` : ""}, so Auto land cannot tell whether CI passed and will not move this PR. Nothing on ${layer.branch} needs a change: do not patch or push for this. Tell the human: the Mergestorm GitHub App needs the permission ${permission} on ${stack.owner}/${stack.repo}. They open the app's installation on GitHub (Settings, then GitHub Apps or Installed GitHub Apps), accept the pending permission request, and check that ${stack.repo} is in the app's repository access. Auto land continues by itself once the read works. If GitHub itself is having an outage, the wait clears when it ends.`,
|
|
717
677
|
};
|
|
718
678
|
}
|
|
719
|
-
if (wait &&
|
|
679
|
+
if (wait && reason === "auto_land_waited") {
|
|
720
680
|
return {
|
|
721
681
|
kind: "auto_land_waiting",
|
|
722
682
|
prNumber: layer.prNumber,
|
|
@@ -727,7 +687,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
|
|
|
727
687
|
steps: `Auto land has been waiting on this stack since ${wait.since} because ${autoLandWaitWords(wait.reason)} (reason ${wait.reason}${wait.detail ? `: ${wait.detail}` : ""}), and nothing has changed that in the meantime. Run mg stack status ${stack.id} and read what it shows for #${layer.prNumber}. If it names something on ${layer.branch} that a commit fixes, fix it there with an ordinary push. Otherwise do not patch or push: tell the human what Auto land is waiting for and since when. Do not retarget the PR base.`,
|
|
728
688
|
};
|
|
729
689
|
}
|
|
730
|
-
if (wait &&
|
|
690
|
+
if (wait && reason === "auto_land_promote_failed") {
|
|
731
691
|
const attempts = wait.attempts ?? AUTO_LAND_PROMOTE_FAILED_ATTEMPTS;
|
|
732
692
|
return {
|
|
733
693
|
kind: "promote_failed",
|
|
@@ -739,7 +699,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
|
|
|
739
699
|
steps: `Auto land tried to promote #${layer.prNumber} at ${layer.headSha ?? "its head"} ${attempts} times and GitHub refused the merge each time${wait.detail ? ` (${wait.detail})` : ""}, so it stopped retrying this head. Read why: gh api repos/${stack.owner}/${stack.repo}/pulls/${layer.prNumber} --jq '{mergeable, mergeable_state, base: .base.ref}', and the branch protection on that base. If the cause is in ${layer.branch}, fix it and push with an ordinary push: a new head is retried. If it is outside the PR (branch protection, permissions, a required review), tell the human, and that once it is fixed they can retry the promote by turning Auto land off and on (mg stack set ${stack.id} --auto-land off, then --auto-land on). Do not toggle Auto land yourself, and do not retarget the PR base.`,
|
|
740
700
|
};
|
|
741
701
|
}
|
|
742
|
-
if (
|
|
702
|
+
if (reason === "vortex_findings_need_person") {
|
|
743
703
|
const findings = `gh api repos/${stack.owner}/${stack.repo}/pulls/${layer.prNumber}/reviews`;
|
|
744
704
|
const handoff = cycloneHandoffAtHead(stack, layer);
|
|
745
705
|
return {
|
|
@@ -752,7 +712,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
|
|
|
752
712
|
steps: `Vortex requested changes on ${layer.branch} at ${layer.headSha ?? "its live head"} and Cyclone left them for a person${handoff ? ` (${cycloneHandoffWords(handoff)})` : ""}, so nothing will patch them and Auto land waits. Read the newest Vortex review and its inline comments (${findings}), verify each finding against ${layer.branch} at its live remote head, fix the real ones with the smallest patch, run the tests, confirm the remote head is still ${layer.headSha ?? "the head you started from"}, then an ordinary git push (no force). If a finding is wrong, tell the human; mg dismiss ${stack.owner}/${stack.repo}#${layer.prNumber} --head <sha> --review <id> dismisses that review. Do not retarget the PR base.`,
|
|
753
713
|
};
|
|
754
714
|
}
|
|
755
|
-
if (
|
|
715
|
+
if (reason === "ci_failed" || attention.bounceKind === "ci_failure") {
|
|
756
716
|
const failingCheck = bounce?.bounceDetail?.failingCheck?.trim() || layer.checks?.failingName?.trim() || null;
|
|
757
717
|
return {
|
|
758
718
|
kind: "ci_failure",
|
|
@@ -788,6 +748,7 @@ function landPrMissing(stack, candidate) {
|
|
|
788
748
|
? `restore it from the closed land PR's head (gh pr view ${landPrRef} --repo ${stack.owner}/${stack.repo} --json headRefOid,state, then git push origin <that sha>:refs/heads/${unit.branch}) and reopen #${landPrRef}`
|
|
789
749
|
: `restore it from the commit it pointed at (git push origin <sha>:refs/heads/${unit.branch})`;
|
|
790
750
|
return {
|
|
751
|
+
reason: "land_pr_missing",
|
|
791
752
|
attention: {
|
|
792
753
|
prNumber, headSha, bounceKind: null,
|
|
793
754
|
blocker: `${LAND_PR_MISSING_PREFIX}${unit.branch} — every layer is merged into it, but it cannot land in ${target} without one`,
|
|
@@ -815,9 +776,11 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
|
|
|
815
776
|
(boundDirty(layer) || layer.state === "conflict" ||
|
|
816
777
|
(layer.state !== "restacking" && layer.restackError?.kind === "rebase_conflict"))).sort((a, b) => a.position - b.position)[0];
|
|
817
778
|
let attention = null;
|
|
779
|
+
let attentionReason = null;
|
|
818
780
|
let attentionLayer;
|
|
819
781
|
let bounce;
|
|
820
782
|
const issues = [];
|
|
783
|
+
const mergeStateUnknownPrs = new Set();
|
|
821
784
|
const queueWait = stack.archivedAt ? null : mergeQueueWait(queue, nowMs);
|
|
822
785
|
for (const layer of open) {
|
|
823
786
|
const selected = layer.prNumber === candidate?.prNumber;
|
|
@@ -827,25 +790,26 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
|
|
|
827
790
|
disarmed = disarmingBounce(stack, queue);
|
|
828
791
|
if (disarmed) {
|
|
829
792
|
const kind = disarmed.bounceDetail.kind;
|
|
830
|
-
result = { blocker: `${AUTO_LAND_OFF_BLOCKER_PREFIX}${kind} bounce`, bounceKind: kind, bounce: disarmed };
|
|
793
|
+
result = { blocker: `${AUTO_LAND_OFF_BLOCKER_PREFIX}${kind} bounce`, reason: "auto_land_off", bounceKind: kind, bounce: disarmed };
|
|
831
794
|
}
|
|
832
795
|
}
|
|
833
796
|
let waitNamed = false;
|
|
834
797
|
if (!result.blocker) {
|
|
835
|
-
const waiting =
|
|
798
|
+
const waiting = autoLandWaitNamed(stack, layer, nowMs);
|
|
836
799
|
if (waiting) {
|
|
837
|
-
result = {
|
|
800
|
+
result = { ...waiting, bounceKind: null };
|
|
838
801
|
waitNamed = true;
|
|
839
802
|
}
|
|
840
803
|
}
|
|
841
804
|
if (!result.blocker && selected && queueWait?.stalled) {
|
|
842
|
-
result = { blocker: `${MERGE_QUEUE_STALLED_PREFIX}${queueWait.text}`, bounceKind: null };
|
|
805
|
+
result = { blocker: `${MERGE_QUEUE_STALLED_PREFIX}${queueWait.text}`, reason: "queue_stalled", bounceKind: null };
|
|
843
806
|
}
|
|
844
807
|
if (!result.blocker) {
|
|
845
808
|
if (vortexRetryScheduled(layer) && (selected || !candidate || layer.position > candidate.position)) {
|
|
846
809
|
issues.push({ prNumber: layer.prNumber, headSha: layer.headSha ?? null, blocker: VORTEX_RETRY_SCHEDULED, bounceKind: null });
|
|
847
810
|
}
|
|
848
811
|
if (mergeabilityPending(layer) && (selected || !candidate || layer.position > candidate.position)) {
|
|
812
|
+
mergeStateUnknownPrs.add(layer.prNumber);
|
|
849
813
|
issues.push({ prNumber: layer.prNumber, headSha: layer.headSha ?? null,
|
|
850
814
|
blocker: `Merge state unknown${layer.parentBranch ? ` vs ${layer.parentBranch}` : ""}`, bounceKind: null });
|
|
851
815
|
}
|
|
@@ -854,9 +818,10 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
|
|
|
854
818
|
const issue = { prNumber: layer.prNumber, headSha: layer.headSha ?? null,
|
|
855
819
|
blocker: result.blocker, bounceKind: result.bounceKind };
|
|
856
820
|
if (selected ||
|
|
857
|
-
result.
|
|
821
|
+
result.reason === "unit_abandoned" ||
|
|
858
822
|
(!attention && (layer === child || waitNamed))) {
|
|
859
823
|
attention = issue;
|
|
824
|
+
attentionReason = result.reason;
|
|
860
825
|
attentionLayer = layer;
|
|
861
826
|
bounce = result.bounce;
|
|
862
827
|
if (disarmed)
|
|
@@ -868,9 +833,10 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
|
|
|
868
833
|
}
|
|
869
834
|
if (!attention) {
|
|
870
835
|
const unlisted = autoLandWaitLayer(stack);
|
|
871
|
-
const waiting = unlisted && !open.includes(unlisted) ?
|
|
836
|
+
const waiting = unlisted && !open.includes(unlisted) ? autoLandWaitNamed(stack, unlisted, nowMs) : null;
|
|
872
837
|
if (unlisted && waiting) {
|
|
873
|
-
attention = { prNumber: unlisted.prNumber, headSha: unlisted.headSha ?? null, blocker: waiting, bounceKind: null };
|
|
838
|
+
attention = { prNumber: unlisted.prNumber, headSha: unlisted.headSha ?? null, blocker: waiting.blocker, bounceKind: null };
|
|
839
|
+
attentionReason = waiting.reason;
|
|
874
840
|
attentionLayer = unlisted;
|
|
875
841
|
}
|
|
876
842
|
}
|
|
@@ -878,14 +844,18 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
|
|
|
878
844
|
? { prNumber: candidate.prNumber, headSha: candidate.headSha ?? null, text: queueWait.text,
|
|
879
845
|
since: queueWait.since, waitReason: queueWait.waitReason }
|
|
880
846
|
: null;
|
|
881
|
-
const settleIssue = mergeabilitySettleIssue(stack, open,
|
|
847
|
+
const settleIssue = mergeabilitySettleIssue(stack, open, mergeStateUnknownPrs, attention);
|
|
882
848
|
if (settleIssue)
|
|
883
849
|
issues.push(settleIssue);
|
|
884
850
|
const landMissing = landPrMissing(stack, candidate);
|
|
885
851
|
if (landMissing) {
|
|
886
852
|
attention = landMissing.attention;
|
|
853
|
+
attentionReason = landMissing.reason;
|
|
887
854
|
attentionLayer = undefined;
|
|
888
855
|
}
|
|
856
|
+
const classified = attention && attentionReason
|
|
857
|
+
? { ...attention, reason: attentionReason }
|
|
858
|
+
: null;
|
|
889
859
|
const focus = attentionLayer ?? candidate;
|
|
890
860
|
const agents = focus ? layerAgents(focus) : null;
|
|
891
861
|
const busy = [];
|
|
@@ -902,26 +872,26 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
|
|
|
902
872
|
const landGatePending = landLayer && landing && landGateIsPending(landing)
|
|
903
873
|
? { prNumber: landLayer.prNumber, headSha: landLayer.headSha ?? null, reason: landing }
|
|
904
874
|
: null;
|
|
905
|
-
if (busy.length && attention && agentsCannotClear(
|
|
875
|
+
if (busy.length && attention && classified && agentsCannotClear(classified.reason)) {
|
|
906
876
|
const held = { ...attention, actAfter: "agents_idle", waitingOn: busy.map((entry) => entry.agent) };
|
|
907
877
|
return { attention: null, held, issues: [attention, ...issues], currentCandidate, bounce: undefined, busy, agents,
|
|
908
|
-
repair: stackRepair(stack,
|
|
878
|
+
repair: stackRepair(stack, classified, attentionLayer, undefined, queueWait), landGatePending, queueWait: queued };
|
|
909
879
|
}
|
|
910
880
|
if (busy.length) {
|
|
911
881
|
return { attention: null, held: null, issues, currentCandidate, bounce: undefined, busy, agents, repair: null, landGatePending,
|
|
912
882
|
queueWait: queued };
|
|
913
883
|
}
|
|
914
884
|
return { attention, held: null, issues, currentCandidate, bounce, busy, agents,
|
|
915
|
-
repair: landMissing?.repair ?? stackRepair(stack,
|
|
885
|
+
repair: landMissing?.repair ?? stackRepair(stack, classified, attentionLayer, bounce, queueWait), landGatePending,
|
|
916
886
|
queueWait: queued };
|
|
917
887
|
}
|
|
918
|
-
function mergeabilitySettleIssue(stack, open,
|
|
888
|
+
function mergeabilitySettleIssue(stack, open, mergeStateUnknownPrs, attention) {
|
|
919
889
|
const settle = stack.autoEnqueueSettle;
|
|
920
890
|
if (stack.archivedAt || settle?.action !== "mergeability")
|
|
921
891
|
return null;
|
|
922
892
|
if (attention?.prNumber === settle.prNumber)
|
|
923
893
|
return null;
|
|
924
|
-
if (
|
|
894
|
+
if (mergeStateUnknownPrs.has(settle.prNumber))
|
|
925
895
|
return null;
|
|
926
896
|
const layer = open.find((candidate) => candidate.prNumber === settle.prNumber);
|
|
927
897
|
if (layer && !sameHead(layer.headSha, settle.headSha))
|
package/dist/stack-dto.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export type RestackError = {
|
|
|
9
9
|
branch: string;
|
|
10
10
|
oldSha: string;
|
|
11
11
|
};
|
|
12
|
+
droppedMergeCommit?: boolean;
|
|
12
13
|
};
|
|
13
14
|
export declare function restackAttemptsExhausted(error: Pick<RestackError, "attempts">): boolean;
|
|
14
15
|
export declare function pushRejectionIsRetryable(message: string): boolean;
|
|
@@ -69,8 +70,7 @@ export type StackCycloneHandoff = {
|
|
|
69
70
|
reason: string;
|
|
70
71
|
at: string | null;
|
|
71
72
|
};
|
|
72
|
-
export type
|
|
73
|
-
export type StackAgentName = "vortex" | "cyclone" | "tempest";
|
|
73
|
+
export type StackAgentName = "vortex" | "cyclone";
|
|
74
74
|
/**
|
|
75
75
|
* One live-or-resting agent run for a PR (#1119): a dumb projection of one
|
|
76
76
|
* ledger row (webhook job / lease / review-state / pr_reviews). Two live
|
|
@@ -197,8 +197,6 @@ export type StackLayerDto = {
|
|
|
197
197
|
vortexStatus: StackVortexStatus | null;
|
|
198
198
|
/** null on the fast list or when no Cyclone signal exists. */
|
|
199
199
|
cycloneStatus: StackCycloneStatus | null;
|
|
200
|
-
/** null when no Tempest signal exists. Fast list and enrich both paint this from pr_reviews (#876). */
|
|
201
|
-
tempestStatus: StackTempestStatus | null;
|
|
202
200
|
/**
|
|
203
201
|
* Ledger-backed agent runs, one per live row (#1119). Fast list and enrich
|
|
204
202
|
* both paint this from our tables only — never GitHub. The per-agent status
|
|
@@ -296,11 +294,10 @@ export type StackUnitDto = {
|
|
|
296
294
|
landTarget: string;
|
|
297
295
|
members: StackUnitMemberDto[];
|
|
298
296
|
landPrNumber: number | null;
|
|
299
|
-
tempestLandStatus: string | null;
|
|
300
297
|
landingBlockReason: string | null;
|
|
301
298
|
/**
|
|
302
299
|
* Land-PR chrome. Fast list hydrates last-known CI/title/mergeable/issues from
|
|
303
|
-
* `review_units` (#878 / #892)
|
|
300
|
+
* `review_units` (#878 / #892). Enrich
|
|
304
301
|
* refreshes GitHub and writes back. Null when the land PR is an open stack
|
|
305
302
|
* layer (chips live on `layers`) or the unit has no land PR.
|
|
306
303
|
*/
|
|
@@ -385,6 +382,8 @@ export type StackDto = {
|
|
|
385
382
|
*/
|
|
386
383
|
cycloneOwnerMatch?: CycloneOwnerMatch;
|
|
387
384
|
cyclonePatchOff?: CyclonePatchOffReason;
|
|
385
|
+
infrastructureInstallUserId?: string | null;
|
|
386
|
+
infrastructureOwnerMatch?: CycloneOwnerMatch;
|
|
388
387
|
layers: StackLayerDto[];
|
|
389
388
|
/** Present when a review-unit row exists for this stack. */
|
|
390
389
|
unit?: StackUnitDto;
|
|
@@ -392,9 +391,9 @@ export type StackDto = {
|
|
|
392
391
|
export type MergeQueueEntryState = "queued" | "running" | "waiting" | "landed" | "bounced" | "cancelled";
|
|
393
392
|
export type MergeQueueEnqueuedBy = "human" | "agent";
|
|
394
393
|
export type MergeQueueEnqueuedVia = "dashboard" | "cli" | "chat" | "api" | "mcp";
|
|
395
|
-
export type MergeQueueBounceKind = "ci_failure" | "ci_timeout" | "head_moved" | "
|
|
394
|
+
export type MergeQueueBounceKind = "ci_failure" | "ci_timeout" | "head_moved" | "seam_findings" | "restack_conflict" | "pr_draft" | "must_consolidate" | "merge_failed" | "gh_error";
|
|
396
395
|
/** Every bounce kind understood by the shared contract. */
|
|
397
|
-
export declare const MERGE_QUEUE_BOUNCE_KINDS: readonly ["ci_failure", "ci_timeout", "head_moved", "
|
|
396
|
+
export declare const MERGE_QUEUE_BOUNCE_KINDS: readonly ["ci_failure", "ci_timeout", "head_moved", "seam_findings", "restack_conflict", "pr_draft", "must_consolidate", "merge_failed", "gh_error"];
|
|
398
397
|
export declare const BOUNCE_KIND_LABELS: Readonly<Record<MergeQueueBounceKind, string>>;
|
|
399
398
|
export declare function mergeQueueBounceLabel(entry: MergeQueueEntryDto): string;
|
|
400
399
|
/** Structured bounce handoff — enough for the bar chip and the chat quote. */
|
package/dist/stack-dto.js
CHANGED
|
@@ -55,8 +55,6 @@ export const MERGE_QUEUE_BOUNCE_KINDS = [
|
|
|
55
55
|
"ci_failure",
|
|
56
56
|
"ci_timeout",
|
|
57
57
|
"head_moved",
|
|
58
|
-
"tempest_findings",
|
|
59
|
-
"tempest_rerun",
|
|
60
58
|
"seam_findings",
|
|
61
59
|
"restack_conflict",
|
|
62
60
|
"pr_draft",
|
|
@@ -68,8 +66,6 @@ export const BOUNCE_KIND_LABELS = {
|
|
|
68
66
|
ci_failure: "CI failed",
|
|
69
67
|
ci_timeout: "CI timed out",
|
|
70
68
|
head_moved: "head moved",
|
|
71
|
-
tempest_findings: "Tempest findings",
|
|
72
|
-
tempest_rerun: "Tempest needs rerun",
|
|
73
69
|
seam_findings: "seam review findings",
|
|
74
70
|
restack_conflict: "restack conflict",
|
|
75
71
|
pr_draft: "draft PR",
|
package/dist/ui/banner.js
CHANGED
|
@@ -144,16 +144,19 @@ const WHATS_NEW = {
|
|
|
144
144
|
"0.3.36": [
|
|
145
145
|
"stack watch names what the merge queue waits on",
|
|
146
146
|
"stack watch names why Vortex skipped a head",
|
|
147
|
-
"stack watch says how to re-run Tempest",
|
|
148
147
|
],
|
|
149
148
|
"0.3.37": [
|
|
150
149
|
"turning auto-review on reviews skipped heads",
|
|
151
150
|
],
|
|
151
|
+
"0.3.38": [
|
|
152
|
+
"mg settings turns merge queue batching on or off",
|
|
153
|
+
],
|
|
152
154
|
"0.3.39": [
|
|
153
155
|
"the watch names more waits and what to do",
|
|
154
156
|
],
|
|
155
|
-
"0.3.
|
|
156
|
-
"
|
|
157
|
+
"0.3.40": [
|
|
158
|
+
"refusals name Surge when it is the App to install",
|
|
159
|
+
"mg settings says whether stacks are ready",
|
|
157
160
|
],
|
|
158
161
|
};
|
|
159
162
|
function densityFromVariant(variant) {
|
package/dist/ui/tabs.d.ts
CHANGED
|
@@ -18,7 +18,12 @@ export type TabsConfigRow = {
|
|
|
18
18
|
label: string;
|
|
19
19
|
value: boolean;
|
|
20
20
|
writable: boolean;
|
|
21
|
+
readOnlyWords?: {
|
|
22
|
+
on: string;
|
|
23
|
+
off: string;
|
|
24
|
+
};
|
|
21
25
|
};
|
|
26
|
+
export declare function readOnlyRowWords(row: TabsConfigRow): string;
|
|
22
27
|
export type TabsData = {
|
|
23
28
|
/** Prebuilt whoami + credits lines for the Status tab. */
|
|
24
29
|
statusLines: string[];
|
package/dist/ui/tabs.js
CHANGED
|
@@ -12,6 +12,10 @@ const TAB_LABELS = {
|
|
|
12
12
|
jobs: "Jobs",
|
|
13
13
|
config: "Config",
|
|
14
14
|
};
|
|
15
|
+
export function readOnlyRowWords(row) {
|
|
16
|
+
const words = row.readOnlyWords ?? { on: "connected", off: "not connected" };
|
|
17
|
+
return row.value ? words.on : words.off;
|
|
18
|
+
}
|
|
15
19
|
export function initialTabsState(tab = "status") {
|
|
16
20
|
return {
|
|
17
21
|
tab,
|
|
@@ -180,9 +184,7 @@ function configBody(state, data, bodyRows, width, color) {
|
|
|
180
184
|
? row.value
|
|
181
185
|
? "on"
|
|
182
186
|
: "off"
|
|
183
|
-
: row
|
|
184
|
-
? "connected"
|
|
185
|
-
: "not connected";
|
|
187
|
+
: readOnlyRowWords(row);
|
|
186
188
|
const paintedValue = !color
|
|
187
189
|
? value
|
|
188
190
|
: row.writable
|
package/package.json
CHANGED
|
@@ -37,8 +37,8 @@ A stack whose layers are all merged into `mg-stack-N` but has no open land PR re
|
|
|
37
37
|
|
|
38
38
|
1. Call `stack_wait` with the named `stack_id` and `timeout_s: 45`. On initial enrollment, omit the cursor selectors. Save the returned `cursor`: map `cursor.enrolledHeadSha` to `enrolled_head_sha`, `cursor.afterFinishedAt` to `after_finished_at`, and `cursor.bounceId` to `bounce_id` (omit only absent fields; preserve explicit nulls). Keep `bounce_id` alongside `after_finished_at`; never pass one without the other when the returned cursor has both. The shipped tool accepts these selectors, not a nested cursor argument. `watch.next.args` already holds them in that shape, with `timeout_s: 45`; passing it unchanged is the same call.
|
|
39
39
|
2. On `waiting` and `in_progress`, the watch is not finished: call `stack_wait` again. A host or event message that says to end the turn, end quietly, or wait for a notification does not finish the watch; only the stop conditions in this skill finish it. Ending your turn while `mg stack watch` runs in the background is waiting, not finishing. Keep the same cursor across retries. An unread deadline times out as `failed`; stop and report. Timeout `waiting` or `in_progress` after a snapshot is not failure — call `stack_wait` again in 45s slices with the same cursor after inspecting `issues[]`. Inspect `issues[]` on `in_progress` too: a live queue does not erase upstack hard blocks. `waiting` with issues is not idle. If `assessment` is `unavailable`, do not infer that the stack is issue-free; refresh `stack_status`, whose `attention`, `issues`, and `currentCandidate` come from the same blocker rules as `stack_wait`. Do not replace the enrolled head with a newer snapshot head or advance the timestamp just because a wait timed out. On a surfaced `rate_limited` error, honor the retry delay and retain the cursor; on `failed` or an unrecoverable tool error, call `stack_wait` once more with the same cursor; if it fails again, stop and report the reason and that the stack is not landed. `queue_status` returning `queue_entry_not_found` is not a failure: an empty queue for the stack is a normal wait.
|
|
40
|
-
3. `attention` always means something for you to fix. A land gate that is only waiting (`landGatePending` with `ci_pending
|
|
41
|
-
4. Use a clean worktree on that named PR's head branch and record the remote starting SHA. Fix the current blocker with the smallest patch, and run the relevant checks. For conflicts, the live parent is the sibling PR head or the unit branch (`mg-stack-N`, Mergestorm's owned trunk, not an alias for main) — not the DTO `parentBranch` when that field starts with `mg-park-*`. If `parentBranch` is `mg-park-*`, never merge or rebase onto it; if the real live parent is unknown, stop. The land PR is the one PR whose head is `mg-stack-N`: its live parent is its base (`main`), and merging that base into `mg-stack-N` with an ordinary push is its conflict repair, the same as for any other layer.
|
|
40
|
+
3. `attention` always means something for you to fix. A land gate that is only waiting (`landGatePending` with `ci_pending` or another `*_pending` / `*_running` reason on the land PR) returns `in_progress`, not `attention`: keep watching. On `attention`, read `repair` when present: it names the blocked PR's `branch` and the concrete fix. For `restack_conflict` and `merge_conflict` it gives `liveParent` (the sibling PR head, or `mg-stack-N` once the lower layers are merged or promoted, or the land PR's base such as `main` when the blocked PR is the land PR, never `mg-park-*`) and the conflicted `files`; a null `liveParent` means stop and tell the human. For `ci_failure` it names the failing check. A stack whose `autoLandOff.reason` is `bounced` (a `merge_failed`, `gh_error`, or `ci_timeout` bounce turned Auto land off and nobody took the stack back since) returns `attention` with the blocker `Auto land off after <kind> bounce`, the bounce in `issues[]`, and a `repair` of kind `auto_land_off`; handle it as "Re-arm after a bounce" describes. A stack whose review unit was abandoned because a human moved its bottom PR off `mg-stack-N` returns `attention` with the blocker `Review unit abandoned: based on <base>` and a `repair` of kind `unit_abandoned`: do not patch or retarget anything. Ask the human whether to re-root it: if at least two PRs remain open, tell them that `mg stack adopt owner/repo#<bottom PR>` re-roots it on a new review unit; with only one open PR, re-adopting will not create a review unit, so tell them to close and resubmit it to create a fresh stack, then stop. Auto land records why it is waiting in the stack's `autoLandWait` (`reason`, `prNumber`, `headSha`, `since`), and the watch returns `attention` on that PR when the wait cannot clear on its own: a failing check Auto land reads live on GitHub while the ledger may still show CI green (blocker `CI failed — Auto land reads a failing check on GitHub at this head`, `repair` kind `ci_failure`); a check pending for 45 minutes (`repair` kind `ci_pending`: find the check that never finished and re-run it, or tell the human when no workflow reports it); no approving Vortex review for 30 minutes with no Vortex run working (`repair` kind `vortex_not_green`: fix or dismiss the findings, or tell the human when no review exists at the head); and a promote GitHub refused 3 times with `merge_failed`, after which Auto land stops retrying that head (`repair` kind `promote_failed`: fix the cause on the PR and push a new head, or tell the human when the cause is outside the PR; never toggle Auto land yourself for it). The watch also returns `attention` when GitHub refuses to let Mergestorm read the head's check runs or commit statuses (blocker `Mergestorm cannot read ... on this head`, `repair` kind `ci_unreadable`: do not patch or push; tell the human to grant the Mergestorm GitHub App the permission the repair names), and when any other wait reason has held for 45 minutes, a land gate left pending or running on the land PR included (blocker `Auto land has waited <N>m: <reason in plain words>`, `repair` kind `auto_land_waiting`: read `stack_status`, fix what a commit on the named branch can fix, otherwise tell the human what Auto land waits for and since when). A merge queue entry that has sat queued for 45 minutes with nothing working on it or ahead of it returns `attention` with the blocker `Merge queue stuck: ...` and a `repair` whose kind is queue_stalled: do not patch or push; tell the human. A Vortex review skipped because the PR author is on the ignored authors list, or because reviews are turned off for the repository, returns `attention` at once with a `repair` of kind `vortex_skipped`; a Vortex retry that is scheduled (`Vortex hit a temporary error on this head and will retry by itself`) is a wait listed in `issues[]`, never `attention`. `Review failed` carries a `repair` of kind `vortex_failed`: comment `@mergestorm-vortex review` on the PR or push a new commit, and tell the human if it fails again. A PR open on `mg-stack-N` that is not a layer of the stack returns `attention` at once with the blocker `The land PR cannot be queued: ...` and a `repair` of kind `members_remaining`: do not patch or push; close, retarget or adopt that PR (a draft or fork PR cannot be adopted). A layer whose live head has a Vortex verdict that requests changes after Cyclone left those findings for a person (a hold directive, the Vortex patch cap, auto-patch off, no billing, a batch it gave up on, or a PR state it could not read) returns `attention` with the blocker `Vortex findings need a person` and a `repair` of kind `vortex_findings` naming the Cyclone reason (`handoff`) and where the review is (`findings`): verify each finding against the checkout, fix the real ones with the smallest patch and an ordinary push, and tell the human about the ones you judge wrong. `repair` does not replace the checks below. On `attention`, fix the named PR: `prNumber` and `headSha` identify the blocked PR, while `currentCandidate` identifies the promote candidate. The gate covers the bottom and its direct child only when the child has current-head DIRTY on the live bottom branch. A GitHub DIRTY whose `parentBranch` starts with `mg-park-` is not that gate. Refresh `stack_status`; use `queue_status` with the same `stack_id` when bounce details are needed. Verify the blocker still applies to the current PR head. Never treat `verifyHeadSha` as the current head: it is a queue verification SHA and can be stale or synthetic. Compare the live PR branch head, the current stack layer head, and the checkout before editing. Read the live PR head with `git ls-remote origin refs/heads/<branch>` or `gh api repos/OWNER/REPO/pulls/N --jq .head.sha`. If you need CI logs, use `gh run list --branch <branch>`, `gh run view <run-id> --log-failed`, or `gh api repos/OWNER/REPO/commits/SHA/check-runs`. Read GitHub only through git and these REST calls; do not use other `gh pr` subcommands. Restack `clean` does not mean mergeable or CI-green. Attention is a diagnosis, not permission to mutate stack or queue policy; the only policy change you make is the re-arm in "Re-arm after a bounce". A bounce is the reason to look, not the moment to commit. `stack_wait` and `stack_status` hold the blocked PR's attention themselves while Vortex or Cyclone is still working on that PR, whatever the blocker (a red CI failure, a merge conflict, a bounce, Draft, a failed restack or review): they return `in_progress` instead of `attention`, with `busy[]` naming the PR (`prNumber`, `headSha`), the `blocker`, and the `agent` (`vortex` or `cyclone`), and `agents` carrying that PR's `vortexStatus`, `cycloneStatus`, `vortexReview`, and `busy` flags. `in_progress` with a nonempty `busy[]` means do not patch yet: keep calling `stack_wait` with the same cursor until that work has finished and the watch returns `attention`, then make the follow-up commit. You do not need an extra `stack_status` refresh each slice for this check. A nonempty `busy[]` reports still-working agents on that PR's current head. Vortex holds when `vortexReview` is `reviewing` at the head, an unfinished vortex `agentRuns` row at the head is `reviewing`, or an unfinished vortex run at the head is `queued` and started within the last 15 minutes (with no `agentRuns` or `vortexReview` on the layer, `vortexStatus` `reviewing` holds). Cyclone holds when `cycloneStatus` is `patching`, an unfinished cyclone `agentRuns` row is `patching`, or the server sees a live Cyclone patch lease or an open Cyclone check run on the head. `seam_pending` alone, and a `queued` run older than 15 minutes, are not treated as work in progress. `all_clear`, `findings`, `skipped`, `failed`, `throttled`, `awaiting_fix`, and a null status are finished. A queued Vortex run holds for at most 15 minutes; reviewing and patching hold until the run finishes or its lease or check run is cleared. A blocker no Vortex or Cyclone run can clear is not hidden by the hold: a merge conflict vs the live parent, a restack `Conflict` or `Restack failed` (a parked freeze included), a Draft PR, `Seam review pending, no review running`, or `Review skipped, out of review quota`. For those the envelope stays `in_progress` but names the blocker in the top-level `blocker`, `prNumber`, `headSha`, and `issues[]`, with `actAfter: "agents_idle"`, `waitingOn` listing the busy agents, and `repair` when there is one. Plan the fix from it, but do not patch, merge, or push while `actAfter` is set: keep calling `stack_wait` with the same cursor, and act when a snapshot after the agents finish returns `attention`; a Cyclone push can change or clear the blocker, so re-read it then. Blockers the agents can change (a red CI failure, a failed review, seam findings, a bounce) keep the plain hold: `blocker` is null, `actAfter` is null, and only `busy[]` names them. If a hold looks stuck, refresh `stack_status` and tell the human. Do not turn Cyclone off or Auto land off, and do not turn Auto land on to end the wait; turn it on only as "Re-arm after a bounce" allows. If the remote head moves while you wait, stop and tell the human.
|
|
41
|
+
4. Use a clean worktree on that named PR's head branch and record the remote starting SHA. Fix the current blocker with the smallest patch, and run the relevant checks. For conflicts, the live parent is the sibling PR head or the unit branch (`mg-stack-N`, Mergestorm's owned trunk, not an alias for main) — not the DTO `parentBranch` when that field starts with `mg-park-*`. If `parentBranch` is `mg-park-*`, never merge or rebase onto it; if the real live parent is unknown, stop. The land PR is the one PR whose head is `mg-stack-N`: its live parent is its base (`main`), and merging that base into `mg-stack-N` with an ordinary push is its conflict repair, the same as for any other layer. Cyclone patches a land PR when auto-patch is on for that stack, otherwise you do: when `busy[]` shows no Cyclone run on it, fix its blocker with the smallest patch on `mg-stack-N` and push with an ordinary push, never a force push; while Cyclone is patching it the envelope stays `in_progress` with `busy[]`, so wait. For a conflict, prefer merging that live parent into the PR branch, resolving locally, then an ordinary push. Never use Dashboard Continue. When the live parent is a sibling, never rebase onto trunk or `mg-stack-N` instead. Parked-freeze DIRTY alone needs no conflict patch. Leave GitHub bases as Mergestorm set them: the bottom of a 2+ PR stack is based on `mg-stack-N`, layer 2 on layer 1, PR3+ on `mg-park-*`.
|
|
42
42
|
5. CI-only fixes may run while Cyclone auto-patch is still on for the stack. Auto-patch may stay on, and you still wait until the current Vortex run and the current Cyclone run on that PR have finished before the follow-up commit. When the gate is clean, inspect `issues[]` and fix a live upstack CI failure one PR at a time, refreshing the gate before the next PR. Do not use higher-layer bounce history as a patch target. Do not fight Cyclone: if the remote head moved, stop and tell the human. Do not take over Cyclone's review-finding work while it is active; if the blocker requires that or a policy/queue action rather than a concrete patch, stop and explain what needs human attention. On a `Seam findings` blocker, read that PR's seam finding, the Vortex review "Integration review — U<n> seam" (`gh api repos/OWNER/REPO/pulls/N/reviews` and `gh api repos/OWNER/REPO/pulls/N/comments`), and patch its concrete findings on that PR's head; a "Decision required" seam finding needs a human, so tell them and do not guess. A seam finding you verified is wrong is recorded with `mg dismiss` (the audited dismissal in `mergestorm-pr-loop`) at that PR's live head and that seam review's id; a `mergestorm-loop: dismiss` comment alone does not clear the seam gate. On `Seam review failed`, do not patch; tell the human, unless a new push already has a seam re-review held in `busy[]`. On `Seam review pending, no review running`, do not patch; tell the human: the seam review was skipped (usually because the monthly review limit is reached) and nothing re-runs it until they raise the limit and click Continue on the stack, or push a new head. On `Seam review stuck in reviewing, no review running`, `Seam re-review pending, no review running`, or `Seam verdict is for an older head, no review running` (repair kind `seam_review_stuck`), do not patch or push; tell the human: no seam review is queued or running for that PR's live head, and clicking Continue on the stack re-queues it (a new pushed head also does). On `Review skipped, out of review quota`, do not patch; tell the human: the review credit limit for the period is reached and nothing re-reviews that head until they raise the limit and push a new head or re-run the review.
|
|
43
43
|
6. Recheck Auto land (off is expected only after a disarming bounce you are handling) and the remote PR head (`git ls-remote origin refs/heads/<branch>`) immediately before pushing. Commit the patch and push the PR head with an ordinary push. A rebase onto that same live parent followed by force-with-lease is allowed only if the remote is still exactly the SHA you started from and Cyclone did not move it; use an explicit lease bound to that starting SHA. This exception never permits rebasing onto a parked freeze or substituting trunk / the unit for a sibling parent. If the remote head moved or the push is rejected because it moved, stop; do not rebase and retry.
|
|
44
44
|
7. After your successful push, leave a clean worktree and keep watching the named stack. Start a new watch cycle with `enrolled_head_sha` set to the SHA you pushed (in the background: `mg stack watch <stack-id> --head <pushed-sha>`). If you handled a bounce, retain the returned `cursor.afterFinishedAt` as `after_finished_at` and `cursor.bounceId` as `bounce_id` so that attempt is not handled again. Keep these selectors unchanged across subsequent retries. Continue watching; do not enqueue anything. If the bounce you handled turned Auto land off, turn it back on as "Re-arm after a bounce" allows; if that section does not allow it, report and stop.
|
|
@@ -68,8 +68,6 @@ The kind list is exactly `MERGE_QUEUE_BOUNCE_KINDS` from stack-dto:
|
|
|
68
68
|
- `ci_failure`
|
|
69
69
|
- `ci_timeout`
|
|
70
70
|
- `head_moved`
|
|
71
|
-
- `tempest_findings`
|
|
72
|
-
- `tempest_rerun`
|
|
73
71
|
- `seam_findings`
|
|
74
72
|
- `restack_conflict`
|
|
75
73
|
- `pr_draft`
|
|
@@ -27,7 +27,7 @@ mg skill install --claude --cursor --agents
|
|
|
27
27
|
## Loop (in order)
|
|
28
28
|
|
|
29
29
|
1. Find the PR's stack with `stack_list` (the stack whose `layers` contain this PR's `prNumber` for this owner/repo). If you already have its `stack_id`, `stack_status` is the same payload.
|
|
30
|
-
- Adoption needs the
|
|
30
|
+
- Adoption needs the infrastructure GitHub App on the repository: Mergestorm Surge, or Cyclone on accounts not yet moved to Surge. Stacks, the merge queue and Auto land run through that App; Cyclone is the auto-patch App. Without it `stack_adopt` returns `cyclone_not_connected` or `cyclone_not_installed`. The code keeps its name in both cases: the message, and a `reason` starting with `surge_` when Surge is the one to install, say which App is missing. Stop and tell the human what the message says, do not retry.
|
|
31
31
|
- If there is no stack and the human asked you to mute Cyclone or continue the loop: call `stack_adopt` with `{ owner, repo, pr_number, auto_patch: false }` (equivalent to `mg stack adopt … --auto-patch off`). Then call `stack_status` with the returned `result.stack.id`, confirm this PR is in the stack and `autoPatchOverride` is `false`. If adoption or verification fails, refuse the loop. Otherwise continue through the ownership check below and the remaining loop steps; do not stop and ask the human to adopt. Do not turn account auto-patch off.
|
|
32
32
|
- Adoption does not fix installer ≠ key. After adoption, the same `cycloneOwnerMatch` refusal below still applies.
|
|
33
33
|
- After adopting 2+ PRs the bottom PR's GitHub base is `mg-stack-<n>` and PR3+ sit on an `mg-park-*` freeze; a 1-PR stack stays on main. That is expected; leave every base as Mergestorm set it.
|