mergestorm 0.3.27 → 0.3.29
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 +21 -1
- package/dist/automation-catalog.d.ts +1 -5
- package/dist/automation-catalog.js +0 -1
- package/dist/commands/browse.d.ts +1 -1
- package/dist/commands/registry.js +2 -1
- package/dist/commands/settings.d.ts +1 -1
- package/dist/commands/stack.d.ts +6 -0
- package/dist/commands/stack.js +71 -0
- package/dist/errors.d.ts +1 -1
- package/dist/stack-blockers.d.ts +16 -0
- package/dist/stack-blockers.js +72 -3
- package/dist/stack-dto.d.ts +17 -0
- package/dist/stack-dto.js +7 -0
- package/dist/stack-watch-loop.d.ts +37 -0
- package/dist/stack-watch-loop.js +125 -0
- package/dist/ui/banner.js +6 -0
- package/package.json +1 -1
- package/skill/mergestorm-bounce-watch/SKILL.md +45 -12
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.29 │
|
|
49
49
|
│ ▀▀▀▀▀▀▀▀▀ ● msk_live_… · maelstrom │
|
|
50
50
|
│ ▀▀▀▀▀▀ [████████░░░░░░░░░░░░░░░░░░░░░░] 25% used │
|
|
51
51
|
│ ▝▀▀▀▀▀ local reviews + stacked PRs · mergestorm.ai │
|
|
@@ -95,11 +95,31 @@ mergestorm stack adopt <owner/repo>#<pr> Import an existing open PR chain
|
|
|
95
95
|
mergestorm stack restack <stack-id> Restack descendants
|
|
96
96
|
mergestorm stack land <stack-id> Land / promote (into review unit when present)
|
|
97
97
|
mergestorm stack wait <stack-id> [--timeout <s>] Wait for stack attention (default 45s, max 300s; 0 = one snapshot, no wait)
|
|
98
|
+
mergestorm stack watch <stack-id> [--until attention|landed] [--ignore <text>] [--max <min>] [--head <sha>] [--json] Background stack watcher (see below)
|
|
98
99
|
mergestorm stack reset --force Clear local authoring state (not branches/PRs)
|
|
99
100
|
mergestorm dismiss <owner/repo>#<n> --head <sha> --review <id> --preview List a Vortex review's finding ids and the seam gate
|
|
100
101
|
mergestorm dismiss <owner/repo>#<n> --head <sha> --review <id> --finding <id> --reason <text> Dismiss verified-wrong findings (`--all` for the whole review, `--evidence <url>`, `--json`)
|
|
101
102
|
```
|
|
102
103
|
|
|
104
|
+
### Watching a stack in the background
|
|
105
|
+
|
|
106
|
+
`mg stack watch <stack-id>` is for agents that cannot hold a long turn open. Run it as a background command, have your host notify you on output matching `MS-WATCH (ATTENTION|LANDED)`, and end the turn. While it waits it spends no model tokens and talks only to the Mergestorm API, not GitHub.
|
|
107
|
+
|
|
108
|
+
- It calls `stack wait` in slices of at most 300s and carries the cursor (`enrolled_head_sha`, `after_finished_at`, `bounce_id`) from one slice to the next.
|
|
109
|
+
- It prints nothing while the stack is `waiting` or `in_progress`, including while Cyclone or Vortex holds the blocked PR (`busy[]`, `actAfter: "agents_idle"`).
|
|
110
|
+
- On attention it prints `MS-WATCH ATTENTION pr=<n> head=<sha> blocker="<blocker>" repair=<kind>` and exits 3.
|
|
111
|
+
- When the watch is done it prints `MS-WATCH LANDED stack=<id> reason=<reason>` and exits 0. Only `reason=landed` is a landed stack; `closed`, `archived` and `not_found` end the watch without a land.
|
|
112
|
+
- A failed read is retried once. If the retry fails too, it prints `MS-WATCH ATTENTION failed stack=<id> error="<message>"` and exits 3. A rate limit is waited out.
|
|
113
|
+
- `--head <sha>` enrolls the SHA you just pushed. After fixing a blocker, restart the watcher with it.
|
|
114
|
+
- `--ignore <text>` keeps it silent on the first attention whose blocker contains that text (case-insensitive), until the blocker, its PR or its head changes. Use it for a blocker a human has taken over. Repeat the flag for more than one.
|
|
115
|
+
- `--until landed` keeps running after an attention, printing each new attention once, and exits only when the watch is done or reads keep failing.
|
|
116
|
+
- `--max <minutes>` caps the whole watch; when it runs out it prints `MS-WATCH TIMEOUT` and exits 5. The stack is not landed then.
|
|
117
|
+
- `--json` adds the full `mergestorm.stack_watch/v1` envelope as one JSON line after each marker line.
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
mg stack watch 11111111-1111-4111-8111-111111111111 --head "$(git rev-parse HEAD)"
|
|
121
|
+
```
|
|
122
|
+
|
|
103
123
|
### Dismissing a finding
|
|
104
124
|
|
|
105
125
|
Use `mergestorm dismiss` when a Vortex finding is wrong and you have checked it against the code. It is recorded server side with your GitHub login, the reason, the evidence URL, the scope and the time. Rules:
|
|
@@ -18,10 +18,6 @@ export declare const BEARER_SETTINGS: readonly [{
|
|
|
18
18
|
readonly key: "review_unit_land_prs_enabled";
|
|
19
19
|
readonly flag: "--review-unit-land";
|
|
20
20
|
readonly label: "Review Unit land PRs";
|
|
21
|
-
}, {
|
|
22
|
-
readonly key: "cyclone_review_unit_land_prs_enabled";
|
|
23
|
-
readonly flag: "--cyclone-review-unit-land";
|
|
24
|
-
readonly label: "Cyclone Review Unit land PRs";
|
|
25
21
|
}, {
|
|
26
22
|
readonly key: "cyclone_skip_ci_enabled";
|
|
27
23
|
readonly flag: "--cyclone-skip-ci";
|
|
@@ -91,4 +87,4 @@ export type BearerSettingsValues = {
|
|
|
91
87
|
values: readonly (infer Value)[];
|
|
92
88
|
} ? Value : boolean;
|
|
93
89
|
};
|
|
94
|
-
export declare const BEARER_BOOLEAN_SETTINGS_KEYS: ("auto_review_enabled" | "auto_patch_enabled" | "vortex_show_thinking_traces" | "repo_overview_enabled" | "review_unit_land_prs_enabled" | "
|
|
90
|
+
export declare const BEARER_BOOLEAN_SETTINGS_KEYS: ("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" | "ignored_bot_logins" | "vortex_bot_skip_check" | "vortex_findings_check" | "cyclone_patch_failure_check")[];
|
|
@@ -4,7 +4,6 @@ export const BEARER_SETTINGS = [
|
|
|
4
4
|
{ key: "vortex_show_thinking_traces", flag: "--vortex-thinking", label: "Vortex thinking traces" },
|
|
5
5
|
{ key: "repo_overview_enabled", flag: "--repo-overview", label: "Repo overview" },
|
|
6
6
|
{ key: "review_unit_land_prs_enabled", flag: "--review-unit-land", label: "Review Unit land PRs" },
|
|
7
|
-
{ key: "cyclone_review_unit_land_prs_enabled", flag: "--cyclone-review-unit-land", label: "Cyclone Review Unit land PRs" },
|
|
8
7
|
{ key: "cyclone_skip_ci_enabled", flag: "--cyclone-skip-ci", label: "Skip CI on Cyclone commits" },
|
|
9
8
|
{ key: "cyclone_patch_unverified_languages", flag: "--cyclone-patch-unverified", label: "Patch languages we cannot typecheck" },
|
|
10
9
|
{ key: "vortex_auto_overflow_enabled", flag: "--vortex-auto-overflow", label: "Vortex auto overflow" },
|
|
@@ -27,7 +27,7 @@ export declare function buildStatusTabLines(input: StatusTabInput): string[];
|
|
|
27
27
|
/** In-place job detail for the Jobs tab (same envelope as `status <id>`). */
|
|
28
28
|
export declare function formatJobDetailLines(row: ReviewJobRow): string[];
|
|
29
29
|
/** Human labels for the Config tab and the static `settings` printout. */
|
|
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" | "
|
|
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" | "ignored_bot_logins" | "vortex_bot_skip_check" | "vortex_findings_check" | "cyclone_patch_failure_check", string>;
|
|
31
31
|
/** Config rows in a stable order: writable toggles, then connected flags. */
|
|
32
32
|
export declare function buildConfigRows(settings: SettingsResponse): TabsConfigRow[];
|
|
33
33
|
/**
|
|
@@ -181,7 +181,7 @@ export const COMMAND_REGISTRY = [
|
|
|
181
181
|
"mergestorm settings [--json] Read the automation toggles (Config tab on a TTY)",
|
|
182
182
|
" [--auto-review on|off] [--auto-patch on|off] [--vortex-thinking on|off]",
|
|
183
183
|
" [--repo-overview on|off] [--review-unit-land on|off]",
|
|
184
|
-
" [--cyclone-
|
|
184
|
+
" [--cyclone-skip-ci on|off]",
|
|
185
185
|
" [--cyclone-patch-unverified on|off] Patch languages we cannot typecheck",
|
|
186
186
|
" [--vortex-seam on|off] [--vortex-skip-all-clear on|off]",
|
|
187
187
|
" [--auto-land on|off] Auto land default for new stacks",
|
|
@@ -241,6 +241,7 @@ export const COMMAND_REGISTRY = [
|
|
|
241
241
|
"mergestorm stack list [--json] List registered stacks",
|
|
242
242
|
"mergestorm stack status <stack-id> [--json] Enriched stack status",
|
|
243
243
|
"mergestorm stack wait <stack-id> [--json] [--timeout <s>] Wait for stack attention (default 45s, max 300s; 0 = one snapshot, no wait). --json: one document on stdout; timeout notice on stderr, exit 5",
|
|
244
|
+
"mergestorm stack watch <stack-id> [--until attention|landed] [--ignore <blocker-substring>] [--max <minutes>] [--head <sha>] [--json] Background watcher: silent while waiting; MS-WATCH ATTENTION exits 3, MS-WATCH LANDED exits 0",
|
|
244
245
|
"mergestorm stack set <stack-id> [--auto-land on|off] [--auto-review on|off|default] [--auto-patch on|off|default] [--json] Per-stack policy",
|
|
245
246
|
"mergestorm stack adopt <owner/repo>#<pr> [--auto-land on|off] [--auto-review on|off] [--auto-patch on|off] Import an open PR chain",
|
|
246
247
|
"mergestorm stack restack <stack-id> Restack stack descendants",
|
|
@@ -3,7 +3,7 @@ import { type SettingsPatch, type SettingsResponse } from "../api.js";
|
|
|
3
3
|
* CLI flag → Bearer settings key. Boolean flags take on|off. The connected flags are
|
|
4
4
|
* read-only on the API and deliberately have no flag here.
|
|
5
5
|
*/
|
|
6
|
-
export declare const SETTINGS_FLAGS: Record<string, "auto_review_enabled" | "auto_patch_enabled" | "vortex_show_thinking_traces" | "repo_overview_enabled" | "review_unit_land_prs_enabled" | "
|
|
6
|
+
export declare const SETTINGS_FLAGS: Record<string, "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" | "ignored_bot_logins" | "vortex_bot_skip_check" | "vortex_findings_check" | "cyclone_patch_failure_check">;
|
|
7
7
|
type IgnoreBotOperation = {
|
|
8
8
|
action: "add" | "remove";
|
|
9
9
|
login: string;
|
package/dist/commands/stack.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { buildPrBodyFromCommit } from "../pr-body.js";
|
|
|
9
9
|
import { gitTopLevel } from "../repo-identity.js";
|
|
10
10
|
import { loadStackMeta, saveStackMeta } from "../stack-meta.js";
|
|
11
11
|
import { pollStackWatch } from "../stack-watch.js";
|
|
12
|
+
import { runStackWatch } from "../stack-watch-loop.js";
|
|
12
13
|
export declare function stackHeldLine(held: StackHeldBlocker | null | undefined): string[];
|
|
13
14
|
export declare function stackRepairLine(repair: StackRepairHint | null | undefined): string[];
|
|
14
15
|
export declare function stackWatchTextLines(watch: StackWatchObligation | null | undefined): string[];
|
|
@@ -138,6 +139,11 @@ export declare function cmdStackWait(args: string[], deps?: {
|
|
|
138
139
|
pollStackWatch?: typeof pollStackWatch;
|
|
139
140
|
signal?: AbortSignal;
|
|
140
141
|
}): Promise<void>;
|
|
142
|
+
export declare function cmdStackWatch(args: string[], deps?: {
|
|
143
|
+
loadConfig?: typeof loadConfig;
|
|
144
|
+
runStackWatch?: typeof runStackWatch;
|
|
145
|
+
signal?: AbortSignal;
|
|
146
|
+
}): Promise<void>;
|
|
141
147
|
export declare function cmdStackStatus(args: string[], deps?: {
|
|
142
148
|
loadConfig?: typeof loadConfig;
|
|
143
149
|
getEnrichedStack?: typeof getEnrichedStack;
|
package/dist/commands/stack.js
CHANGED
|
@@ -10,6 +10,7 @@ import { buildPrBodyFromCommit } from "../pr-body.js";
|
|
|
10
10
|
import { gitTopLevel } from "../repo-identity.js";
|
|
11
11
|
import { activeLayers, appendLayer, emptyStackMeta, loadStackMeta, removeActiveStack, resetStackMeta, saveStackMeta, selectStackForCreate, setActivePolicy, } from "../stack-meta.js";
|
|
12
12
|
import { pollStackWatch, StackWatchError, StackWatchTimeoutError, } from "../stack-watch.js";
|
|
13
|
+
import { runStackWatch, STACK_WATCH_EXIT } from "../stack-watch-loop.js";
|
|
13
14
|
import { isMgParkBranch, planSubmitLayerBases } from "../submit-pr-base.js";
|
|
14
15
|
import { ansi } from "../ui/ansi.js";
|
|
15
16
|
import { runLineTabsBrowser } from "../ui/line-tabs.js";
|
|
@@ -51,6 +52,10 @@ const STACK_USAGE = `usage:
|
|
|
51
52
|
mergestorm stack wait <stack-id> [--json] [--timeout <s>]
|
|
52
53
|
--json writes exactly one JSON document to stdout. The timeout or error notice goes to stderr
|
|
53
54
|
(exit 5 on timeout), so do not merge the streams (2>&1) before parsing stdout.
|
|
55
|
+
mergestorm stack watch <stack-id> [--until attention|landed] [--ignore <blocker-substring>] [--max <minutes>] [--head <sha>] [--json]
|
|
56
|
+
Loops stack wait with the cursor and stays silent while the stack is waiting or in progress.
|
|
57
|
+
Prints one MS-WATCH ATTENTION line and exits 3 on attention or a repeated failed read; prints
|
|
58
|
+
MS-WATCH LANDED and exits 0 once the watch is done. Run it as a background command.
|
|
54
59
|
mergestorm stack set <stack-id> [--auto-land on|off] [--auto-review on|off|default] [--auto-patch on|off|default] [--json]
|
|
55
60
|
mergestorm stack adopt <owner/repo>#<pr> [--auto-land on|off] [--auto-review on|off] [--auto-patch on|off] [--json]
|
|
56
61
|
mergestorm stack restack <stack-id> [--json]
|
|
@@ -926,6 +931,70 @@ export async function cmdStackWait(args, deps = {}) {
|
|
|
926
931
|
throw new CommandError(err.message, REVIEW_EXIT.timeout, "review_timeout");
|
|
927
932
|
}
|
|
928
933
|
}
|
|
934
|
+
export async function cmdStackWatch(args, deps = {}) {
|
|
935
|
+
const usage = "usage: mergestorm stack watch <stack-id> [--until attention|landed] [--ignore <blocker-substring>] [--max <minutes>] [--head <sha>] [--json]";
|
|
936
|
+
const fail = (detail) => new CommandError(detail ? `${usage}\n${detail}` : usage, REVIEW_EXIT.usage, "usage");
|
|
937
|
+
const positional = [];
|
|
938
|
+
const ignore = [];
|
|
939
|
+
let until = "attention";
|
|
940
|
+
let maxMinutes;
|
|
941
|
+
let head;
|
|
942
|
+
let json = false;
|
|
943
|
+
for (let i = 0; i < args.length; i++) {
|
|
944
|
+
const arg = args[i];
|
|
945
|
+
const flag = arg.startsWith("--") && arg.includes("=") ? arg.slice(0, arg.indexOf("=")) : arg;
|
|
946
|
+
const value = () => {
|
|
947
|
+
const raw = arg.includes("=") ? arg.slice(arg.indexOf("=") + 1) : args[++i];
|
|
948
|
+
if (raw === undefined || !raw.trim())
|
|
949
|
+
throw fail(`${flag} needs a value`);
|
|
950
|
+
return raw.trim();
|
|
951
|
+
};
|
|
952
|
+
if (arg === "--json")
|
|
953
|
+
json = true;
|
|
954
|
+
else if (flag === "--until") {
|
|
955
|
+
const picked = value();
|
|
956
|
+
if (picked !== "attention" && picked !== "landed")
|
|
957
|
+
throw fail("--until must be attention or landed");
|
|
958
|
+
until = picked;
|
|
959
|
+
}
|
|
960
|
+
else if (flag === "--ignore")
|
|
961
|
+
ignore.push(value());
|
|
962
|
+
else if (flag === "--max") {
|
|
963
|
+
maxMinutes = Number(value());
|
|
964
|
+
if (!Number.isFinite(maxMinutes) || maxMinutes <= 0)
|
|
965
|
+
throw fail("--max must be a positive number of minutes");
|
|
966
|
+
}
|
|
967
|
+
else if (flag === "--head") {
|
|
968
|
+
head = value();
|
|
969
|
+
if (!/^[0-9a-f]{7,40}$/i.test(head))
|
|
970
|
+
throw fail("--head must be a commit SHA");
|
|
971
|
+
}
|
|
972
|
+
else if (arg.startsWith("-"))
|
|
973
|
+
throw fail();
|
|
974
|
+
else
|
|
975
|
+
positional.push(arg);
|
|
976
|
+
}
|
|
977
|
+
if (positional.length !== 1)
|
|
978
|
+
throw fail();
|
|
979
|
+
const stackId = requireStackId(positional[0], usage);
|
|
980
|
+
const cfg = await (deps.loadConfig ?? loadConfig)();
|
|
981
|
+
const result = await (deps.runStackWatch ?? runStackWatch)(cfg, stackId, {
|
|
982
|
+
until,
|
|
983
|
+
ignore,
|
|
984
|
+
json,
|
|
985
|
+
signal: deps.signal,
|
|
986
|
+
...(maxMinutes !== undefined ? { maxMs: maxMinutes * 60_000 } : {}),
|
|
987
|
+
...(head ? { cursor: { stackId: stackId.trim().toLowerCase(), enrolledHeadSha: head.toLowerCase() } } : {}),
|
|
988
|
+
});
|
|
989
|
+
if (result.exitCode === STACK_WATCH_EXIT.landed)
|
|
990
|
+
return;
|
|
991
|
+
const reason = result.outcome === "timeout"
|
|
992
|
+
? `Stopped watching stack ${stackId} after --max; it is not landed.`
|
|
993
|
+
: result.outcome === "aborted"
|
|
994
|
+
? `Stopped watching stack ${stackId}; it is not landed.`
|
|
995
|
+
: `Stack ${stackId} needs attention; it is not landed.`;
|
|
996
|
+
throw new CommandError(reason, result.exitCode, result.outcome === "timeout" ? "review_timeout" : "stack_attention");
|
|
997
|
+
}
|
|
929
998
|
export async function cmdStackStatus(args, deps = {}) {
|
|
930
999
|
const usage = "usage: mergestorm stack status <stack-id> [--json]";
|
|
931
1000
|
const asJson = args.includes("--json");
|
|
@@ -1106,6 +1175,8 @@ export async function cmdStack(args, options = {}) {
|
|
|
1106
1175
|
return cmdStackStatus(rest);
|
|
1107
1176
|
if (sub === "wait")
|
|
1108
1177
|
return cmdStackWait(rest, { signal: options.signal });
|
|
1178
|
+
if (sub === "watch")
|
|
1179
|
+
return cmdStackWatch(rest, { signal: options.signal });
|
|
1109
1180
|
if (sub === "set")
|
|
1110
1181
|
return cmdStackSet(rest);
|
|
1111
1182
|
if (sub === "adopt")
|
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";
|
|
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";
|
|
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;
|
package/dist/stack-blockers.d.ts
CHANGED
|
@@ -47,6 +47,20 @@ export type StackRepairHint = {
|
|
|
47
47
|
branch: string;
|
|
48
48
|
failingCheck: string | null;
|
|
49
49
|
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: "auto_land_off";
|
|
59
|
+
prNumber: number;
|
|
60
|
+
headSha: string | null;
|
|
61
|
+
branch: string;
|
|
62
|
+
bounceKind: string;
|
|
63
|
+
steps: string;
|
|
50
64
|
};
|
|
51
65
|
export type StackLandGatePending = {
|
|
52
66
|
prNumber: number;
|
|
@@ -62,6 +76,8 @@ export declare function boundDirty(layer: StackLayerDto): boolean;
|
|
|
62
76
|
export declare function mergeabilityPending(layer: StackLayerDto): boolean;
|
|
63
77
|
export declare function sameHead(a: string | null | undefined, b: string | null | undefined): boolean;
|
|
64
78
|
export declare function currentLayer(stack: StackDto): StackLayerDto | undefined;
|
|
79
|
+
export declare const SEAM_REVIEW_NOT_RUNNING = "Seam review pending, no review running";
|
|
80
|
+
export declare const VORTEX_REVIEW_OUT_OF_QUOTA = "Review skipped, out of review quota";
|
|
65
81
|
export declare function conflictLiveParent(stack: StackDto, layer: StackLayerDto): string | null;
|
|
66
82
|
export declare function conflictRepairSteps(branch: string, liveParent: string | null): string;
|
|
67
83
|
export declare function agentsCannotClear(blocker: string): boolean;
|
package/dist/stack-blockers.js
CHANGED
|
@@ -51,6 +51,13 @@ export function currentLayer(stack) {
|
|
|
51
51
|
return promote[0] ?? open.find((layer) => layer.prNumber === stack.unit?.landPrNumber) ??
|
|
52
52
|
(land && land.prNumber > 0 && land.state !== "merged" && land.state !== "closed" ? land : undefined);
|
|
53
53
|
}
|
|
54
|
+
export const SEAM_REVIEW_NOT_RUNNING = "Seam review pending, no review running";
|
|
55
|
+
export const VORTEX_REVIEW_OUT_OF_QUOTA = "Review skipped, out of review quota";
|
|
56
|
+
function quotaSkippedReview(layer) {
|
|
57
|
+
const review = layer.vortexReview;
|
|
58
|
+
return review?.status === "skipped" && review.skip_reason === "quota_exceeded" &&
|
|
59
|
+
sameHead(review.head_sha, layer.headSha);
|
|
60
|
+
}
|
|
54
61
|
function seamGateBlocker(stack, layer) {
|
|
55
62
|
const members = stack.unit?.members ?? [];
|
|
56
63
|
if (layer.prNumber === stack.unit?.landPrNumber)
|
|
@@ -65,6 +72,8 @@ function seamGateBlocker(stack, layer) {
|
|
|
65
72
|
return "Seam review failed";
|
|
66
73
|
if (seamState === "findings" && sameHead(member.seamReviewedSha, layer.headSha))
|
|
67
74
|
return "Seam findings";
|
|
75
|
+
if (seamState === "pending" && !layerAgentsBusy(layer).vortex)
|
|
76
|
+
return SEAM_REVIEW_NOT_RUNNING;
|
|
68
77
|
return null;
|
|
69
78
|
}
|
|
70
79
|
/** Shared live-fact precedence; bounce history is only passed for the candidate. */
|
|
@@ -107,6 +116,8 @@ function layerAttention(stack, layer, queue, cursor) {
|
|
|
107
116
|
return hardBlock(`Tempest ${tempest.status}`);
|
|
108
117
|
if (layer.vortexStatus === "failed")
|
|
109
118
|
return hardBlock("Review failed");
|
|
119
|
+
if (quotaSkippedReview(layer))
|
|
120
|
+
return hardBlock(VORTEX_REVIEW_OUT_OF_QUOTA);
|
|
110
121
|
const seam = seamGateBlocker(stack, layer);
|
|
111
122
|
if (seam)
|
|
112
123
|
return hardBlock(seam);
|
|
@@ -129,6 +140,27 @@ function layerAttention(stack, layer, queue, cursor) {
|
|
|
129
140
|
return none;
|
|
130
141
|
return { blocker: mergeQueueBounceLabel(bounce), bounceKind: kind, bounce };
|
|
131
142
|
}
|
|
143
|
+
const AUTO_LAND_DISARMING_BOUNCE_KINDS = ["merge_failed", "gh_error", "ci_timeout"];
|
|
144
|
+
const AUTO_LAND_OFF_BLOCKER_PREFIX = "Auto land off after ";
|
|
145
|
+
function isDisarmingBounce(entry) {
|
|
146
|
+
const kind = entry?.bounceDetail?.kind;
|
|
147
|
+
return entry?.state === "bounced" && !!kind && AUTO_LAND_DISARMING_BOUNCE_KINDS.includes(kind);
|
|
148
|
+
}
|
|
149
|
+
function disarmingBounce(stack, queue) {
|
|
150
|
+
if (stack.archivedAt || stack.autoEnqueueWhenReady !== false)
|
|
151
|
+
return undefined;
|
|
152
|
+
const byRecency = [...queue].sort((a, b) => (Date.parse(b.enqueuedAt) || 0) - (Date.parse(a.enqueuedAt) || 0) ||
|
|
153
|
+
(Date.parse(b.finishedAt ?? "") || 0) - (Date.parse(a.finishedAt ?? "") || 0));
|
|
154
|
+
if (stack.autoLandOff === undefined) {
|
|
155
|
+
return isDisarmingBounce(byRecency[0]) ? byRecency[0] : undefined;
|
|
156
|
+
}
|
|
157
|
+
if (stack.autoLandOff?.reason !== "bounced")
|
|
158
|
+
return undefined;
|
|
159
|
+
const recorded = queue.find((entry) => entry.id === stack.autoLandOff?.entryId);
|
|
160
|
+
if (isDisarmingBounce(recorded))
|
|
161
|
+
return recorded;
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
132
164
|
function isTerminalLayer(layer) {
|
|
133
165
|
return layer.state === "merged" || layer.state === "closed";
|
|
134
166
|
}
|
|
@@ -171,12 +203,17 @@ function landPrLiveParent(stack, layer) {
|
|
|
171
203
|
}
|
|
172
204
|
export function agentsCannotClear(blocker) {
|
|
173
205
|
return blocker === "Conflict" || blocker === "Restack failed" || blocker === "Draft PR" ||
|
|
174
|
-
blocker.startsWith("Merge conflicts");
|
|
206
|
+
blocker === SEAM_REVIEW_NOT_RUNNING || blocker === VORTEX_REVIEW_OUT_OF_QUOTA || blocker.startsWith("Merge conflicts");
|
|
207
|
+
}
|
|
208
|
+
function isTempestFindingsBlocker(attention) {
|
|
209
|
+
const blocker = attention.blocker.trim();
|
|
210
|
+
return attention.bounceKind === "tempest_findings" || /^Tempest findings\b/i.test(blocker) ||
|
|
211
|
+
/^(?:landing blocked:\s*)?tempest_findings\b/i.test(blocker);
|
|
175
212
|
}
|
|
176
213
|
export function stackRepair(stack, attention, layer, bounce) {
|
|
177
214
|
if (!attention || !layer || attention.prNumber !== layer.prNumber)
|
|
178
215
|
return null;
|
|
179
|
-
const landPr = !!stack.unit && layer.prNumber === stack.unit.landPrNumber && layer.branch === stack.unit.branch;
|
|
216
|
+
const landPr = !!stack.unit?.branch && layer.prNumber === stack.unit.landPrNumber && layer.branch === stack.unit.branch;
|
|
180
217
|
const restackConflict = attention.blocker === "Conflict" || attention.bounceKind === "restack_conflict";
|
|
181
218
|
const mergeConflict = attention.blocker.startsWith("Merge conflicts");
|
|
182
219
|
if (restackConflict || mergeConflict) {
|
|
@@ -191,6 +228,27 @@ export function stackRepair(stack, attention, layer, bounce) {
|
|
|
191
228
|
steps: conflictRepairSteps(layer.branch, liveParent),
|
|
192
229
|
};
|
|
193
230
|
}
|
|
231
|
+
if (attention.blocker.startsWith(AUTO_LAND_OFF_BLOCKER_PREFIX) && attention.bounceKind) {
|
|
232
|
+
return {
|
|
233
|
+
kind: "auto_land_off",
|
|
234
|
+
prNumber: layer.prNumber,
|
|
235
|
+
headSha: layer.headSha ?? null,
|
|
236
|
+
branch: layer.branch,
|
|
237
|
+
bounceKind: attention.bounceKind,
|
|
238
|
+
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.`,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
if (landPr && isTempestFindingsBlocker(attention)) {
|
|
242
|
+
const findings = `gh api repos/${stack.owner}/${stack.repo}/issues/${layer.prNumber}/comments`;
|
|
243
|
+
return {
|
|
244
|
+
kind: "tempest_findings",
|
|
245
|
+
prNumber: layer.prNumber,
|
|
246
|
+
headSha: layer.headSha ?? null,
|
|
247
|
+
branch: layer.branch,
|
|
248
|
+
findings,
|
|
249
|
+
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.`,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
194
252
|
if (attention.blocker.startsWith("CI failed") || attention.bounceKind === "ci_failure") {
|
|
195
253
|
const failingCheck = bounce?.bounceDetail?.failingCheck?.trim() || layer.checks?.failingName?.trim() || null;
|
|
196
254
|
return {
|
|
@@ -222,7 +280,15 @@ export function stackBlockers(stack, entries = [], cursor = {}) {
|
|
|
222
280
|
const issues = [];
|
|
223
281
|
for (const layer of open) {
|
|
224
282
|
const selected = layer.prNumber === candidate?.prNumber;
|
|
225
|
-
|
|
283
|
+
let result = layerAttention(stack, layer, selected ? queue : [], cursor);
|
|
284
|
+
let disarmed;
|
|
285
|
+
if (!result.blocker && selected) {
|
|
286
|
+
disarmed = disarmingBounce(stack, queue);
|
|
287
|
+
if (disarmed) {
|
|
288
|
+
const kind = disarmed.bounceDetail.kind;
|
|
289
|
+
result = { blocker: `${AUTO_LAND_OFF_BLOCKER_PREFIX}${kind} bounce`, bounceKind: kind, bounce: disarmed };
|
|
290
|
+
}
|
|
291
|
+
}
|
|
226
292
|
if (!result.blocker) {
|
|
227
293
|
if (mergeabilityPending(layer) && (selected || !candidate || layer.position > candidate.position)) {
|
|
228
294
|
issues.push({ prNumber: layer.prNumber, headSha: layer.headSha ?? null,
|
|
@@ -236,6 +302,9 @@ export function stackBlockers(stack, entries = [], cursor = {}) {
|
|
|
236
302
|
attention = issue;
|
|
237
303
|
attentionLayer = layer;
|
|
238
304
|
bounce = result.bounce;
|
|
305
|
+
if (disarmed)
|
|
306
|
+
issues.push({ prNumber: disarmed.bounceDetail?.prNumber ?? layer.prNumber,
|
|
307
|
+
headSha: disarmed.bounceDetail?.headSha ?? null, blocker: mergeQueueBounceLabel(disarmed), bounceKind: result.bounceKind });
|
|
239
308
|
}
|
|
240
309
|
else if (!candidate || layer.position > candidate.position)
|
|
241
310
|
issues.push(issue);
|
package/dist/stack-dto.d.ts
CHANGED
|
@@ -27,6 +27,8 @@ export declare function restackRetryPending(error: {
|
|
|
27
27
|
* `api/src/stacks.ts#loadStackDtos` is the producer. Keep this file
|
|
28
28
|
* dependency-free and byte-for-byte aligned with the canonical wire contract.
|
|
29
29
|
*/
|
|
30
|
+
export declare const AUTO_LAND_OFF_REASONS: readonly ["user", "archive", "watcher", "cancelled", "landed", "bounced"];
|
|
31
|
+
export type AutoLandOffReason = (typeof AUTO_LAND_OFF_REASONS)[number];
|
|
30
32
|
export type StackBranchState = "clean" | "needs_restack" | "restacking" | "conflict" | "merged" | "closed";
|
|
31
33
|
export type StackCiStatus = "success" | "pending" | "failure" | "neutral" | "none" | "unknown";
|
|
32
34
|
export type StackReviewStatus = "approved" | "changes_requested" | "reviewed" | "failed" | "none" | "unknown";
|
|
@@ -118,6 +120,7 @@ export type StackLayerChecks = {
|
|
|
118
120
|
pending: number;
|
|
119
121
|
failure: number;
|
|
120
122
|
failingName: string | null;
|
|
123
|
+
selfHosted?: number;
|
|
121
124
|
/**
|
|
122
125
|
* True when the persisted `ci_checks` carries a `namedRuns` seed (enrich
|
|
123
126
|
* writes it so CI webhook deliveries fold into the snapshot). Pre-seeding
|
|
@@ -315,6 +318,11 @@ export type StackDto = {
|
|
|
315
318
|
* Missing on older payloads is off.
|
|
316
319
|
*/
|
|
317
320
|
autoEnqueueWhenReady?: boolean;
|
|
321
|
+
autoLandOff?: {
|
|
322
|
+
reason: AutoLandOffReason;
|
|
323
|
+
entryId: string | null;
|
|
324
|
+
at: string;
|
|
325
|
+
} | null;
|
|
318
326
|
/**
|
|
319
327
|
* Auto land Settling certificate. The stacks watcher publishes the identity
|
|
320
328
|
* of the settle clock it actually started (action, exact PR and head, server
|
|
@@ -385,6 +393,14 @@ export type MergeQueueBounceDetail = {
|
|
|
385
393
|
message?: string;
|
|
386
394
|
warning?: string;
|
|
387
395
|
};
|
|
396
|
+
export type MergeQueueBatchState = "verifying" | "landing" | "landed" | "stopped" | "bounced" | "abandoned";
|
|
397
|
+
export type MergeQueueEntryBatchDto = {
|
|
398
|
+
id: string;
|
|
399
|
+
position: number;
|
|
400
|
+
size: number;
|
|
401
|
+
state: MergeQueueBatchState;
|
|
402
|
+
landedCount: number;
|
|
403
|
+
};
|
|
388
404
|
export type MergeQueueEntryDto = {
|
|
389
405
|
id: string;
|
|
390
406
|
stackId: string;
|
|
@@ -406,5 +422,6 @@ export type MergeQueueEntryDto = {
|
|
|
406
422
|
/** Target tip included in the verified PR head. Null until recorded. */
|
|
407
423
|
verifyBaseSha: string | null;
|
|
408
424
|
cancelRequestedAt?: string | null;
|
|
425
|
+
batch?: MergeQueueEntryBatchDto | null;
|
|
409
426
|
finishedAt: string | null;
|
|
410
427
|
};
|
package/dist/stack-dto.js
CHANGED
|
@@ -16,6 +16,13 @@ export function restackRetryPending(error, layer) {
|
|
|
16
16
|
return false;
|
|
17
17
|
return !/\(origin\/\S+ moved to /.test(error.detail) && pushRejectionIsRetryable(error.detail);
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Wire types returned by the stack list/enrich HTTP endpoints.
|
|
21
|
+
*
|
|
22
|
+
* `api/src/stacks.ts#loadStackDtos` is the producer. Keep this file
|
|
23
|
+
* dependency-free and byte-for-byte aligned with the canonical wire contract.
|
|
24
|
+
*/
|
|
25
|
+
export const AUTO_LAND_OFF_REASONS = ["user", "archive", "watcher", "cancelled", "landed", "bounced"];
|
|
19
26
|
export const STACK_AGENT_QUEUED_STALE_MS = 15 * 60 * 1000;
|
|
20
27
|
function agentShaMayBeHead(sha, head) {
|
|
21
28
|
const left = sha?.trim().toLowerCase();
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { Config } from "./config.js";
|
|
2
|
+
import { pollStackWatch, type StackWatchCursor, type StackWatchEnvelope } from "./stack-watch.js";
|
|
3
|
+
export declare const STACK_WATCH_MARKER = "MS-WATCH";
|
|
4
|
+
export declare const STACK_WATCH_EXIT: {
|
|
5
|
+
readonly landed: 0;
|
|
6
|
+
readonly attention: 3;
|
|
7
|
+
readonly timeout: 5;
|
|
8
|
+
};
|
|
9
|
+
export declare const STACK_WATCH_SLICE_MS = 300000;
|
|
10
|
+
export declare const STACK_WATCH_RECHECK_MS = 30000;
|
|
11
|
+
export declare const STACK_WATCH_FAILED_RETRY_MS = 15000;
|
|
12
|
+
export declare const STACK_WATCH_RATE_LIMIT_WAIT_MS = 30000;
|
|
13
|
+
export type StackWatchUntil = "attention" | "landed";
|
|
14
|
+
export type StackWatchOutcome = "attention" | "landed" | "failed" | "timeout" | "aborted";
|
|
15
|
+
export type RunStackWatchOptions = {
|
|
16
|
+
until?: StackWatchUntil;
|
|
17
|
+
ignore?: readonly string[];
|
|
18
|
+
maxMs?: number;
|
|
19
|
+
json?: boolean;
|
|
20
|
+
cursor?: StackWatchCursor;
|
|
21
|
+
signal?: AbortSignal;
|
|
22
|
+
write?: (line: string) => void;
|
|
23
|
+
poll?: typeof pollStackWatch;
|
|
24
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
25
|
+
now?: () => number;
|
|
26
|
+
};
|
|
27
|
+
export type StackWatchLoopResult = {
|
|
28
|
+
outcome: StackWatchOutcome;
|
|
29
|
+
exitCode: number;
|
|
30
|
+
envelope: StackWatchEnvelope | null;
|
|
31
|
+
};
|
|
32
|
+
export declare function stackWatchAttentionKey(envelope: Pick<StackWatchEnvelope, "prNumber" | "headSha" | "blocker">): string;
|
|
33
|
+
export declare function stackWatchAttentionLine(envelope: StackWatchEnvelope): string;
|
|
34
|
+
export declare function stackWatchLandedLine(envelope: StackWatchEnvelope): string;
|
|
35
|
+
export declare function stackWatchFailedLine(stackId: string, message: string): string;
|
|
36
|
+
export declare function stackWatchTimeoutLine(stackId: string, maxMs: number): string;
|
|
37
|
+
export declare function runStackWatch(cfg: Config, stackId: string, opts?: RunStackWatchOptions): Promise<StackWatchLoopResult>;
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
|
+
import { pollStackWatch, StackWatchError, StackWatchTimeoutError, } from "./stack-watch.js";
|
|
3
|
+
export const STACK_WATCH_MARKER = "MS-WATCH";
|
|
4
|
+
export const STACK_WATCH_EXIT = { landed: 0, attention: 3, timeout: 5 };
|
|
5
|
+
export const STACK_WATCH_SLICE_MS = 300_000;
|
|
6
|
+
export const STACK_WATCH_RECHECK_MS = 30_000;
|
|
7
|
+
export const STACK_WATCH_FAILED_RETRY_MS = 15_000;
|
|
8
|
+
export const STACK_WATCH_RATE_LIMIT_WAIT_MS = 30_000;
|
|
9
|
+
export function stackWatchAttentionKey(envelope) {
|
|
10
|
+
return `${envelope.prNumber ?? ""}|${envelope.headSha ?? ""}|${envelope.blocker ?? ""}`;
|
|
11
|
+
}
|
|
12
|
+
export function stackWatchAttentionLine(envelope) {
|
|
13
|
+
return `${STACK_WATCH_MARKER} ATTENTION pr=${envelope.prNumber ?? "none"} head=${envelope.headSha ?? "none"} blocker=${JSON.stringify(envelope.blocker ?? "")} repair=${envelope.repair?.kind ?? "none"}`;
|
|
14
|
+
}
|
|
15
|
+
export function stackWatchLandedLine(envelope) {
|
|
16
|
+
return `${STACK_WATCH_MARKER} LANDED stack=${envelope.stackId} reason=${envelope.watch.reason}`;
|
|
17
|
+
}
|
|
18
|
+
export function stackWatchFailedLine(stackId, message) {
|
|
19
|
+
return `${STACK_WATCH_MARKER} ATTENTION failed stack=${stackId} error=${JSON.stringify(message)}`;
|
|
20
|
+
}
|
|
21
|
+
export function stackWatchTimeoutLine(stackId, maxMs) {
|
|
22
|
+
return `${STACK_WATCH_MARKER} TIMEOUT stack=${stackId} after=${Math.round(maxMs / 60_000)}m`;
|
|
23
|
+
}
|
|
24
|
+
function isAbort(err, signal) {
|
|
25
|
+
return signal?.aborted === true || (err instanceof Error && err.name === "AbortError");
|
|
26
|
+
}
|
|
27
|
+
export async function runStackWatch(cfg, stackId, opts = {}) {
|
|
28
|
+
const id = stackId.trim().toLowerCase();
|
|
29
|
+
const until = opts.until ?? "attention";
|
|
30
|
+
const ignore = opts.ignore ?? [];
|
|
31
|
+
const write = opts.write ?? ((line) => console.log(line));
|
|
32
|
+
const poll = opts.poll ?? pollStackWatch;
|
|
33
|
+
const now = opts.now ?? Date.now;
|
|
34
|
+
const wait = opts.sleep ?? ((ms, signal) => sleep(ms, undefined, { signal }));
|
|
35
|
+
const deadline = opts.maxMs !== undefined ? now() + opts.maxMs : Number.POSITIVE_INFINITY;
|
|
36
|
+
const emit = (line, envelope) => {
|
|
37
|
+
write(line);
|
|
38
|
+
if (opts.json && envelope)
|
|
39
|
+
write(JSON.stringify(envelope));
|
|
40
|
+
};
|
|
41
|
+
const pause = async (ms) => {
|
|
42
|
+
const remaining = Math.min(ms, Math.max(0, deadline - now()));
|
|
43
|
+
if (remaining > 0)
|
|
44
|
+
await wait(remaining, opts.signal);
|
|
45
|
+
};
|
|
46
|
+
let cursor = opts.cursor;
|
|
47
|
+
let failures = 0;
|
|
48
|
+
const ignoredKeys = new Map();
|
|
49
|
+
let reportedKey = null;
|
|
50
|
+
const landed = (envelope) => {
|
|
51
|
+
emit(stackWatchLandedLine(envelope), envelope);
|
|
52
|
+
if (!opts.json)
|
|
53
|
+
write(envelope.watch.message);
|
|
54
|
+
return { outcome: "landed", exitCode: STACK_WATCH_EXIT.landed, envelope };
|
|
55
|
+
};
|
|
56
|
+
const failed = async (message, envelope) => {
|
|
57
|
+
failures += 1;
|
|
58
|
+
if (failures >= 2) {
|
|
59
|
+
emit(stackWatchFailedLine(id, message), envelope);
|
|
60
|
+
return { outcome: "failed", exitCode: STACK_WATCH_EXIT.attention, envelope };
|
|
61
|
+
}
|
|
62
|
+
await pause(STACK_WATCH_FAILED_RETRY_MS);
|
|
63
|
+
return null;
|
|
64
|
+
};
|
|
65
|
+
try {
|
|
66
|
+
while (now() < deadline) {
|
|
67
|
+
let envelope;
|
|
68
|
+
try {
|
|
69
|
+
envelope = await poll(cfg, id, {
|
|
70
|
+
timeoutMs: Math.min(STACK_WATCH_SLICE_MS, Math.max(0, deadline - now())),
|
|
71
|
+
...(cursor ? { cursor } : {}),
|
|
72
|
+
signal: opts.signal,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
catch (err) {
|
|
76
|
+
if (isAbort(err, opts.signal))
|
|
77
|
+
throw err;
|
|
78
|
+
if (err instanceof StackWatchTimeoutError) {
|
|
79
|
+
cursor = err.lastEnvelope.cursor;
|
|
80
|
+
failures = 0;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (err instanceof StackWatchError && err.lastEnvelope.watch.done)
|
|
84
|
+
return landed(err.lastEnvelope);
|
|
85
|
+
if (err instanceof StackWatchError && err.lastEnvelope.status === "rate_limited") {
|
|
86
|
+
await pause(err.retryAfterSeconds !== undefined ? Math.max(1_000, err.retryAfterSeconds * 1000) : STACK_WATCH_RATE_LIMIT_WAIT_MS);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const result = await failed(err instanceof Error ? err.message : String(err), err instanceof StackWatchError ? err.lastEnvelope : null);
|
|
90
|
+
if (result)
|
|
91
|
+
return result;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
failures = 0;
|
|
95
|
+
cursor = envelope.cursor;
|
|
96
|
+
if (envelope.watch.done)
|
|
97
|
+
return landed(envelope);
|
|
98
|
+
if (envelope.status === "attention") {
|
|
99
|
+
const key = stackWatchAttentionKey(envelope);
|
|
100
|
+
const blocker = (envelope.blocker ?? "").toLowerCase();
|
|
101
|
+
for (const needle of ignore) {
|
|
102
|
+
const normalizedNeedle = needle.trim().toLowerCase();
|
|
103
|
+
if (normalizedNeedle && blocker.includes(normalizedNeedle) && !ignoredKeys.has(normalizedNeedle)) {
|
|
104
|
+
ignoredKeys.set(normalizedNeedle, key);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const silent = [...ignoredKeys.values()].includes(key) || key === reportedKey;
|
|
108
|
+
if (!silent) {
|
|
109
|
+
emit(stackWatchAttentionLine(envelope), envelope);
|
|
110
|
+
if (until === "attention")
|
|
111
|
+
return { outcome: "attention", exitCode: STACK_WATCH_EXIT.attention, envelope };
|
|
112
|
+
reportedKey = key;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
await pause(STACK_WATCH_RECHECK_MS);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
if (isAbort(err, opts.signal))
|
|
120
|
+
return { outcome: "aborted", exitCode: 130, envelope: null };
|
|
121
|
+
throw err;
|
|
122
|
+
}
|
|
123
|
+
emit(stackWatchTimeoutLine(id, opts.maxMs ?? 0), null);
|
|
124
|
+
return { outcome: "timeout", exitCode: STACK_WATCH_EXIT.timeout, envelope: null };
|
|
125
|
+
}
|
package/dist/ui/banner.js
CHANGED
|
@@ -114,6 +114,12 @@ const WHATS_NEW = {
|
|
|
114
114
|
"0.3.27": [
|
|
115
115
|
"mg dismiss records an audited finding dismissal",
|
|
116
116
|
],
|
|
117
|
+
"0.3.28": [
|
|
118
|
+
"stack watch reads the Auto land off reason",
|
|
119
|
+
],
|
|
120
|
+
"0.3.29": [
|
|
121
|
+
"mg stack watch waits in the background",
|
|
122
|
+
],
|
|
117
123
|
};
|
|
118
124
|
function densityFromVariant(variant) {
|
|
119
125
|
if (variant === "mini" || variant === "nano")
|
package/package.json
CHANGED
|
@@ -1,31 +1,63 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: mergestorm-bounce-watch
|
|
3
|
-
description: Watch stacks explicitly named by the human while Auto land is on,
|
|
3
|
+
description: Watch stacks explicitly named by the human while Auto land is on, patch the current blocker after a bounce, and turn Auto land back on once a bounce that disarmed it is handled. Use for an ongoing stack watch, without taking over Cyclone or landing the stack.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Mergestorm bounce watch
|
|
7
7
|
|
|
8
|
-
Use only these shipped Mergestorm MCP tools: `stack_wait`, `stack_status`, `queue_status
|
|
8
|
+
Use only these shipped Mergestorm MCP tools: `stack_wait`, `stack_status`, `queue_status`, and `stack_set` with `auto_land: true` only at enrollment on the human's request or as "Re-arm after a bounce" allows. Use local checkout, test, and Git operations to make the patch, and the `mg stack watch` CLI command to wait in the background. Auto land owns landing.
|
|
9
9
|
|
|
10
10
|
## Not done until landed
|
|
11
11
|
|
|
12
|
-
A stack watch is done only when the stack is landed, or when the human takes it back (archives the stack or tells you to stop). A clean push, a submit, an adopt, or one merged layer is not done: merging a lower layer can break a layer above it, so the obligation covers the whole stack, not the layer you touched. Every `stack_wait` and `stack_status` result carries `watch`: while `watch.done` is `false`, call `stack_wait` again with `watch.next.args` and do not end your task. `watch.done` is `true` only when the stack is landed, every PR is merged or closed with nothing left to land, the stack is archived, or it no longer exists (`reason` `not_found`: confirm the PRs merged before reporting it landed). Then stop.
|
|
12
|
+
A stack watch is done only when the stack is landed, or when the human takes it back (archives the stack, turns Auto land off, or tells you to stop). A clean push, a submit, an adopt, or one merged layer is not done: merging a lower layer can break a layer above it, so the obligation covers the whole stack, not the layer you touched. Every `stack_wait` and `stack_status` result carries `watch`: while `watch.done` is `false`, call `stack_wait` again with `watch.next.args` and do not end your task. If your host cannot hold a long turn open, the background watcher in "Waiting in the background" does that waiting for you. `watch.done` is `true` only when the stack is landed, every PR is merged or closed with nothing left to land, the stack is archived, or it no longer exists (`reason` `not_found`: confirm the PRs merged before reporting it landed). Then stop. A layer PR shown as merged is merged into `mg-stack-N`, not main: the stack is landed only when the land PR (head `mg-stack-N`) merges into main or `watch.done` says landed. Never judge landing from GitHub PR state alone.
|
|
13
|
+
|
|
14
|
+
## Waiting in the background
|
|
15
|
+
|
|
16
|
+
If your host cannot hold a long turn open (Cursor, Claude Code, Codex, and other turn-based agents), do not poll `stack_wait` inside your turn and do not subscribe to GitHub events. Hand the waiting to the CLI watcher:
|
|
17
|
+
|
|
18
|
+
1. Run `mg stack watch <stack-id>` as a background command (add `--head <sha>` right after you pushed that SHA) with a notification on output matching `MS-WATCH (ATTENTION|LANDED)`, then end your turn. The watcher runs the same stack wait against the Mergestorm API in slices of at most 300s, carries the cursor (`enrolled_head_sha`, `after_finished_at`, `bounce_id`) from one slice to the next, and prints nothing while the stack is `waiting` or `in_progress`, including `busy[]` holds and `actAfter: "agents_idle"`. It spends no model tokens while it waits. Ending your turn while it runs is not ending the task.
|
|
19
|
+
2. It prints one line and exits for one of three reasons:
|
|
20
|
+
- `MS-WATCH ATTENTION pr=<n> head=<sha> blocker="<blocker>" repair=<kind>` (exit 3): the watch returned `attention`.
|
|
21
|
+
- `MS-WATCH ATTENTION failed stack=<id> error="<message>"` (exit 3): a read failed and its one retry failed too.
|
|
22
|
+
- `MS-WATCH LANDED stack=<id> reason=<reason>` (exit 0): `watch.done` is `true`. Only `reason=landed` is a landed stack; for `closed`, `archived`, or `not_found`, follow "Not done until landed".
|
|
23
|
+
With `--max <minutes>` it can also stop with `MS-WATCH TIMEOUT` (exit 5). The stack is not landed then; start the watcher again. `--json` adds the full envelope as one JSON line after the marker line.
|
|
24
|
+
3. When it wakes you on `MS-WATCH ATTENTION pr=`, call `stack_status` once for that stack, then fix the blocker as steps 3 to 7 of "Watch and patch" say, push, and start the watcher again with `--head` set to the SHA you pushed. On `MS-WATCH ATTENTION failed`, the watcher already retried once: tell the human the stack is not landed and is no longer being watched.
|
|
25
|
+
4. When the human has taken one blocker over and asked you to keep watching the rest, pass `--ignore <blocker-substring>`. The watcher stays silent on the first attention whose blocker contains that text until the blocker, its PR, or its head changes; any other attention still wakes you.
|
|
26
|
+
5. `--until landed` keeps the watcher running after an attention: it prints each new attention line once and exits only when the watch is done or a read fails twice. Use it only when your host streams output lines to you while the command runs.
|
|
13
27
|
|
|
14
28
|
## Scope and enrollment
|
|
15
29
|
|
|
16
30
|
Watch only stacks the human named. If no unambiguous stack ID is available, ask the human to identify it; do not discover and enroll other stacks. Keep a separate cursor for each named stack.
|
|
17
31
|
|
|
18
|
-
Call `stack_status` with `stack_id`. Require that stack's `autoEnqueueWhenReady` is `true` (Auto land on). If Auto land is off, missing, or cannot be verified, stop and tell the human
|
|
32
|
+
Call `stack_status` with `stack_id`. Require that stack's `autoEnqueueWhenReady` is `true` (Auto land on). If Auto land is off, missing, or cannot be verified at enrollment, stop and tell the human, unless the human asked you in this task to turn it on: then call `stack_set` with `stack_id` and `auto_land: true` once and confirm `autoEnqueueWhenReady` is `true` with `stack_status`. Recheck this condition between wait slices and before patching or pushing. If Auto land is off when you recheck, read `autoLandOff` from `stack_status`: `reason` `bounced` means a `merge_failed`, `gh_error`, or `ci_timeout` bounce turned it off, and `entryId` names that queue entry; call `queue_status` with the same `stack_id` for its kind, PR, head, and `finishedAt`. If "Re-arm after a bounce" covers it, keep watching, handle it, and then re-arm; otherwise report `autoLandOff` and the newest bounce and stop. Stop when the stack is archived or its PRs are all merged or closed.
|
|
19
33
|
|
|
20
34
|
## Watch and patch
|
|
21
35
|
|
|
22
36
|
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.
|
|
23
|
-
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. 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.
|
|
24
|
-
3. `attention` always means something for you to fix. A land gate that is only waiting (`landGatePending` with `ci_pending`, `tempest_pending`, `tempest_running`, 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. `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. 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),
|
|
25
|
-
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. Otherwise 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-*`.
|
|
26
|
-
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[]`.
|
|
27
|
-
6. Recheck Auto land 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.
|
|
28
|
-
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. 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
|
|
37
|
+
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.
|
|
38
|
+
3. `attention` always means something for you to fix. A land gate that is only waiting (`landGatePending` with `ci_pending`, `tempest_pending`, `tempest_running`, 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. For `tempest_findings` on the land PR it names the land PR, its `branch` (`mg-stack-N`), and where the Tempest report is (`findings`). 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. `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 or Tempest run, 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.
|
|
39
|
+
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. Tempest never patches: Cyclone patches a land PR when auto-patch is on for that stack, otherwise you do. On `tempest_findings` on the land PR, when `busy[]` shows no Cyclone run on it, read the Tempest report, verify each finding against the checkout as for Vortex findings, fix the real ones 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. Otherwise 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-*`.
|
|
40
|
+
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 `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.
|
|
41
|
+
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.
|
|
42
|
+
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.
|
|
43
|
+
|
|
44
|
+
## Re-arm after a bounce
|
|
45
|
+
|
|
46
|
+
Turning Auto land back on is how you hand a handled stack back to the queue. Turning Auto land on, here or at enrollment when the human asks, is the only stack policy change you make.
|
|
47
|
+
|
|
48
|
+
Re-arm only when all of these hold:
|
|
49
|
+
|
|
50
|
+
- Auto land was on when you enrolled the stack, or the human asked you to turn it on.
|
|
51
|
+
- Auto land is off now, and `stack_status` shows `autoLandOff.reason` `bounced` with a non-null `autoLandOff.entryId`. That entry is the bounce you are handling: its `id` in `queue_status` is the `bounce_id`.
|
|
52
|
+
- You have not already re-armed for that `bounce_id`, and you have re-armed fewer than 3 times for this stack in this watch.
|
|
53
|
+
- You handled that bounce: you pushed a patch for its blocker, or its cause was infra only (a timeout, a flake, a runner fault) and you confirmed the cause is fixed and every required check is green at the current head of the bounced PR (`bounceDetail.prNumber`); rerun a failed or timed-out run there with `gh run rerun <run-id> --failed` if one exists. A timeout alone still does not justify a code change.
|
|
54
|
+
- Nothing needs a human: no "Decision required" seam finding, policy fork, `Seam review failed`, `Seam review pending, no review running`, `Review skipped, out of review quota`, null `liveParent`, or head moved by another actor.
|
|
55
|
+
|
|
56
|
+
Do not re-arm when `autoLandOff` is null or missing or its `reason` is anything but `bounced` (`user` means the human turned Auto land off; `archive`, `watcher`, `cancelled`, and `landed` are not yours to undo), when the stack was never armed, when the stack is archived, or when any condition above fails; report the newest bounce and stop.
|
|
57
|
+
|
|
58
|
+
After a patch, re-arm as soon as your push is confirmed; do not wait for CI or reviews, since Auto land itself waits for green checks and idle agents. First recheck the remote head of the PR you pushed, or of the bounced PR for an infra-only cause (`git ls-remote origin refs/heads/<branch>`), and `stack_status`: if that head is not the SHA you pushed or confirmed green, stop and tell the human; if Auto land is already on, leave it. Then call `stack_set` with `stack_id` and `auto_land: true` (the CLI equivalent is `mg stack set <id> --auto-land on`), and confirm with `stack_status` that `autoEnqueueWhenReady` is `true`. Pass no other `stack_set` key. Keep the `after_finished_at` and `bounce_id` selectors for that bounce and keep watching until the stack lands.
|
|
59
|
+
|
|
60
|
+
The cap is one re-arm per distinct `bounce_id` and 3 per stack per watch. Every arm clears `autoLandOff`, so a disarm after your re-arm reads its own reason, never your old bounce. A human who takes the stack back while it is off (turns Auto land off, archives or unarchives it, or cancels its queue entry) replaces `bounced` with `user`, `archive`, or `cancelled`, so `bounced` also means nobody took it back since that bounce. A fourth disarming bounce after three re-arms means your diagnosis is wrong or the fault is outside your scope. At the cap, leave Auto land off and stop: tell the human the stack is not landed, Auto land is off, and list each bounce you re-armed for (id, kind, PR, head, `finishedAt`) with what you did for it, plus the newest bounce and the current head and check state.
|
|
29
61
|
|
|
30
62
|
## Bounce kinds
|
|
31
63
|
|
|
@@ -42,7 +74,7 @@ The kind list is exactly `MERGE_QUEUE_BOUNCE_KINDS` from stack-dto:
|
|
|
42
74
|
- `merge_failed`
|
|
43
75
|
- `gh_error`
|
|
44
76
|
|
|
45
|
-
A kind is context, not permission to mutate queue or stack policy. Diagnose the current blocker rather than blindly retrying its old bounce. A head-moved bounce requires stopping if another actor moved the PR head; a timeout alone does not justify a code change. If there is no concrete patch within this scope, report the blocker and stop.
|
|
77
|
+
A kind is context, not permission to mutate queue or stack policy beyond the re-arm in "Re-arm after a bounce". Diagnose the current blocker rather than blindly retrying its old bounce. A head-moved bounce requires stopping if another actor moved the PR head; a timeout alone does not justify a code change. If there is no concrete patch within this scope, report the blocker and stop.
|
|
46
78
|
|
|
47
79
|
## Never
|
|
48
80
|
|
|
@@ -50,7 +82,8 @@ A kind is context, not permission to mutate queue or stack policy. Diagnose the
|
|
|
50
82
|
- Do not call `settings_set`, change account auto-patch settings, or turn account auto-patch off.
|
|
51
83
|
- Do not set `auto_patch: true` on a stack.
|
|
52
84
|
- Do not replace Cyclone or change stack auto-patch policy to take over its work.
|
|
53
|
-
- Do not
|
|
85
|
+
- Do not turn Auto land on except at enrollment on the human's request or as "Re-arm after a bounce" allows, never turn it off, and never re-queue a bounced entry yourself.
|
|
86
|
+
- Do not call `stack_set` with any key but `stack_id` and `auto_land: true`: never `auto_land: false`, `auto_review`, or `auto_patch`.
|
|
54
87
|
- Do not force-push except the same-live-parent rebase with the explicit starting-SHA lease above. Never rebase and retry after the remote head moved.
|
|
55
88
|
- Do not change any PR's GitHub base (`gh pr edit --base` or the REST equivalent). Never move a bottom from `mg-stack-N` back to main or a parked PR off `mg-park-*`.
|
|
56
89
|
- Do not push any branch other than the named blocker's PR head (or the one selected live CI issue after the gate is clean).
|