mergestorm 0.3.34 → 0.3.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/commands/stack.d.ts +2 -1
- package/dist/commands/stack.js +9 -4
- package/dist/stack-blockers.d.ts +99 -3
- package/dist/stack-blockers.js +343 -15
- package/dist/stack-dto.d.ts +24 -0
- package/dist/stack-watch.d.ts +2 -1
- package/dist/stack-watch.js +3 -3
- package/dist/ui/banner.js +10 -0
- package/package.json +1 -1
- package/skill/mergestorm-bounce-watch/SKILL.md +2 -2
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ On a TTY, bare `mergestorm` (or `mergestorm shell`) opens a branded REPL: a comp
|
|
|
45
45
|
|
|
46
46
|
```
|
|
47
47
|
╭──────────────────────────────────────────────────────────────────────────────╮
|
|
48
|
-
│ ▀▀▀▀▀▀▀▀▀▀▘ mergestorm v0.3.
|
|
48
|
+
│ ▀▀▀▀▀▀▀▀▀▀▘ mergestorm v0.3.36 │
|
|
49
49
|
│ ▀▀▀▀▀▀▀▀▀ ● msk_live_… · maelstrom │
|
|
50
50
|
│ ▀▀▀▀▀▀ [████████░░░░░░░░░░░░░░░░░░░░░░] 25% used │
|
|
51
51
|
│ ▝▀▀▀▀▀ local reviews + stacked PRs · mergestorm.ai │
|
package/dist/commands/stack.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type StackHeldBlocker, type StackRepairHint } from "../stack-blockers.js";
|
|
2
2
|
import { type StackWatchObligation } from "../stack-watch-obligation.js";
|
|
3
|
-
import { adoptStack, ensureUpperPark, findStackPull, getEnrichedStack, listStacks, openStackPull, setStackPolicy, type StackDto, type StackPolicyPatch } from "../api.js";
|
|
3
|
+
import { adoptStack, ensureUpperPark, findStackPull, getEnrichedStack, listMergeQueueEntries, listStacks, openStackPull, setStackPolicy, type StackDto, type StackPolicyPatch } from "../api.js";
|
|
4
4
|
import { loadConfig } from "../config.js";
|
|
5
5
|
import { createPrs, findOpenPrNumber } from "../gh.js";
|
|
6
6
|
import { parseGithubOriginRepo } from "../git.js";
|
|
@@ -147,6 +147,7 @@ export declare function cmdStackWatch(args: string[], deps?: {
|
|
|
147
147
|
export declare function cmdStackStatus(args: string[], deps?: {
|
|
148
148
|
loadConfig?: typeof loadConfig;
|
|
149
149
|
getEnrichedStack?: typeof getEnrichedStack;
|
|
150
|
+
listMergeQueueEntries?: typeof listMergeQueueEntries;
|
|
150
151
|
}): Promise<void>;
|
|
151
152
|
export type StackSetDeps = {
|
|
152
153
|
loadConfig?: typeof loadConfig;
|
package/dist/commands/stack.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { stackBlockers, stackBlockersSummary } from "../stack-blockers.js";
|
|
2
2
|
import { STACK_WATCH_NOT_DONE_SENTENCE, STACK_WATCH_NOTIFY_PATTERN, stackTerminalReason, stackWatchObligation, } from "../stack-watch-obligation.js";
|
|
3
|
-
import { adoptStack, ensureUpperPark, findStackPull, getEnrichedStack, landNextStack, listStacks, openStackPull, restackStack, setStackPolicy, } from "../api.js";
|
|
3
|
+
import { adoptStack, ensureUpperPark, findStackPull, getEnrichedStack, landNextStack, listMergeQueueEntries, listStacks, openStackPull, restackStack, setStackPolicy, } from "../api.js";
|
|
4
4
|
import { loadConfig } from "../config.js";
|
|
5
5
|
import { rateLimitedMessage, REVIEW_EXIT, CommandError } from "../errors.js";
|
|
6
6
|
import { createPrs, findOpenPrNumber, ghReady, githubApiFromEnv, } from "../gh.js";
|
|
@@ -910,7 +910,7 @@ export async function cmdStackWait(args, deps = {}) {
|
|
|
910
910
|
...(retryAfterSeconds !== undefined ? { retry_after_seconds: retryAfterSeconds } : {}),
|
|
911
911
|
}, null, 2)
|
|
912
912
|
: [
|
|
913
|
-
`Stack ${envelope.stackId} · ${envelope.status}${stackBlockersSummary(envelope.blocker && envelope.prNumber != null ? { prNumber: envelope.prNumber, blocker: envelope.blocker } : null, envelope.issues, envelope.busy, envelope.landGatePending ?? null, envelope.actAfter ?? null)}${envelope.assessment === "unavailable" ? " · assessment unavailable" : ""}`,
|
|
913
|
+
`Stack ${envelope.stackId} · ${envelope.status}${stackBlockersSummary(envelope.blocker && envelope.prNumber != null ? { prNumber: envelope.prNumber, blocker: envelope.blocker } : null, envelope.issues, envelope.busy, envelope.landGatePending ?? null, envelope.actAfter ?? null, envelope.queueWait ?? null)}${envelope.assessment === "unavailable" ? " · assessment unavailable" : ""}`,
|
|
914
914
|
...stackRepairLine(envelope.repair),
|
|
915
915
|
...stackWatchTextLines(envelope.watch),
|
|
916
916
|
].join("\n"));
|
|
@@ -1018,7 +1018,10 @@ export async function cmdStackStatus(args, deps = {}) {
|
|
|
1018
1018
|
console.log(watch.message);
|
|
1019
1019
|
throw new CommandError(`Stack not found or not owned by the current user: ${stackId}`, 1, "not_found");
|
|
1020
1020
|
}
|
|
1021
|
-
const
|
|
1021
|
+
const entries = await (deps.listMergeQueueEntries ?? listMergeQueueEntries)(cfg, {
|
|
1022
|
+
stackId: stack.id,
|
|
1023
|
+
});
|
|
1024
|
+
const { attention, held, currentCandidate, repair, queueWait } = stackBlockers(stack, entries);
|
|
1022
1025
|
const watch = stackWatchObligation({
|
|
1023
1026
|
stackId: stack.id,
|
|
1024
1027
|
terminal: stackTerminalReason(stack),
|
|
@@ -1027,7 +1030,7 @@ export async function cmdStackStatus(args, deps = {}) {
|
|
|
1027
1030
|
freshCursor: true,
|
|
1028
1031
|
});
|
|
1029
1032
|
if (asJson) {
|
|
1030
|
-
console.log(JSON.stringify({ ...stack, held, repair, watch }, null, 2));
|
|
1033
|
+
console.log(JSON.stringify({ ...stack, held, repair, queueWait, watch }, null, 2));
|
|
1031
1034
|
return;
|
|
1032
1035
|
}
|
|
1033
1036
|
const lines = formatStackHuman(stack, false);
|
|
@@ -1040,6 +1043,8 @@ export async function cmdStackStatus(args, deps = {}) {
|
|
|
1040
1043
|
(layer.cycloneStatus ? ` Cyclone: ${layer.cycloneStatus}` : "") +
|
|
1041
1044
|
(layer.tempestStatus ? ` Tempest: ${layer.tempestStatus}` : ""));
|
|
1042
1045
|
}
|
|
1046
|
+
if (queueWait && !attention)
|
|
1047
|
+
lines.push(` Queue: #${queueWait.prNumber} ${queueWait.text}`);
|
|
1043
1048
|
lines.push(...[...stackHeldLine(held), ...stackRepairLine(repair), ...stackWatchTextLines(watch)].map((line) => ` ${line}`));
|
|
1044
1049
|
await present("Stack status", lines);
|
|
1045
1050
|
}
|
package/dist/stack-blockers.d.ts
CHANGED
|
@@ -54,12 +54,42 @@ export type StackRepairHint = {
|
|
|
54
54
|
branch: string;
|
|
55
55
|
findings: string;
|
|
56
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
|
+
} | {
|
|
65
|
+
kind: "vortex_findings";
|
|
66
|
+
prNumber: number;
|
|
67
|
+
headSha: string | null;
|
|
68
|
+
branch: string;
|
|
69
|
+
handoff: string | null;
|
|
70
|
+
findings: string;
|
|
71
|
+
steps: string;
|
|
57
72
|
} | {
|
|
58
73
|
kind: "unit_abandoned";
|
|
59
74
|
prNumber: number;
|
|
60
75
|
headSha: string | null;
|
|
61
76
|
branch: string;
|
|
62
77
|
steps: string;
|
|
78
|
+
} | {
|
|
79
|
+
kind: "restack_failed";
|
|
80
|
+
prNumber: number;
|
|
81
|
+
headSha: string | null;
|
|
82
|
+
branch: string;
|
|
83
|
+
liveParent: string | null;
|
|
84
|
+
restackKind: string;
|
|
85
|
+
steps: string;
|
|
86
|
+
} | {
|
|
87
|
+
kind: "seam_review_stuck";
|
|
88
|
+
prNumber: number;
|
|
89
|
+
headSha: string | null;
|
|
90
|
+
branch: string;
|
|
91
|
+
seamState: string;
|
|
92
|
+
steps: string;
|
|
63
93
|
} | {
|
|
64
94
|
kind: "auto_land_off";
|
|
65
95
|
prNumber: number;
|
|
@@ -67,6 +97,36 @@ export type StackRepairHint = {
|
|
|
67
97
|
branch: string;
|
|
68
98
|
bounceKind: string;
|
|
69
99
|
steps: string;
|
|
100
|
+
} | {
|
|
101
|
+
kind: "ci_pending" | "vortex_not_green";
|
|
102
|
+
prNumber: number;
|
|
103
|
+
headSha: string | null;
|
|
104
|
+
branch: string;
|
|
105
|
+
since: string;
|
|
106
|
+
steps: string;
|
|
107
|
+
} | {
|
|
108
|
+
kind: "queue_stalled";
|
|
109
|
+
prNumber: number;
|
|
110
|
+
headSha: string | null;
|
|
111
|
+
branch: string;
|
|
112
|
+
since: string | null;
|
|
113
|
+
waitReason: string | null;
|
|
114
|
+
steps: string;
|
|
115
|
+
} | {
|
|
116
|
+
kind: "vortex_skipped";
|
|
117
|
+
prNumber: number;
|
|
118
|
+
headSha: string | null;
|
|
119
|
+
branch: string;
|
|
120
|
+
skipReason: string;
|
|
121
|
+
steps: string;
|
|
122
|
+
} | {
|
|
123
|
+
kind: "promote_failed";
|
|
124
|
+
prNumber: number;
|
|
125
|
+
headSha: string | null;
|
|
126
|
+
branch: string;
|
|
127
|
+
attempts: number;
|
|
128
|
+
message: string | null;
|
|
129
|
+
steps: string;
|
|
70
130
|
};
|
|
71
131
|
export type StackLandGatePending = {
|
|
72
132
|
prNumber: number;
|
|
@@ -82,14 +142,48 @@ export declare function boundDirty(layer: StackLayerDto): boolean;
|
|
|
82
142
|
export declare function mergeabilityPending(layer: StackLayerDto): boolean;
|
|
83
143
|
export declare function sameHead(a: string | null | undefined, b: string | null | undefined): boolean;
|
|
84
144
|
export declare function currentLayer(stack: StackDto): StackLayerDto | undefined;
|
|
145
|
+
export declare const RESTACK_FAILED = "Restack failed";
|
|
85
146
|
export declare const SEAM_REVIEW_NOT_RUNNING = "Seam review pending, no review running";
|
|
86
147
|
export declare const VORTEX_REVIEW_OUT_OF_QUOTA = "Review skipped, out of review quota";
|
|
148
|
+
export declare const SEAM_REVIEW_STUCK = "Seam review stuck in reviewing, no review running";
|
|
149
|
+
export declare const SEAM_REREVIEW_NOT_RUNNING = "Seam re-review pending, no review running";
|
|
150
|
+
export declare const SEAM_VERDICT_STALE = "Seam verdict is for an older head, no review running";
|
|
151
|
+
export declare const VORTEX_FINDINGS_NEED_PERSON = "Vortex findings need a person";
|
|
152
|
+
export declare const VORTEX_AUTO_REVIEW_OFF = "Vortex auto-review is off, so this head was not reviewed";
|
|
153
|
+
export declare const VORTEX_BILLING_SKIPPED = "Vortex skipped this head: it could not confirm billing";
|
|
154
|
+
export declare const VORTEX_REVIEW_INCOMPLETE = "Vortex reviewed only part of this PR";
|
|
155
|
+
export declare const AUTO_LAND_CI_PENDING_ATTENTION_MS: number;
|
|
156
|
+
export declare const AUTO_LAND_VORTEX_WAIT_ATTENTION_MS: number;
|
|
157
|
+
export declare const AUTO_LAND_PROMOTE_FAILED_ATTEMPTS = 3;
|
|
158
|
+
export declare const AUTO_LAND_CI_FAILED_BLOCKER = "CI failed \u2014 Auto land reads a failing check on GitHub at this head";
|
|
159
|
+
export declare function autoLandWaitBlocker(stack: StackDto, layer: StackLayerDto, nowMs: number): string | null;
|
|
160
|
+
export declare const MERGE_QUEUE_STALL_ATTENTION_MS: number;
|
|
161
|
+
export type StackQueueWait = {
|
|
162
|
+
prNumber: number;
|
|
163
|
+
headSha: string | null;
|
|
164
|
+
text: string;
|
|
165
|
+
since: string | null;
|
|
166
|
+
waitReason: string | null;
|
|
167
|
+
};
|
|
168
|
+
export type MergeQueueWait = {
|
|
169
|
+
text: string;
|
|
170
|
+
stalled: boolean;
|
|
171
|
+
since: string | null;
|
|
172
|
+
waitReason: string | null;
|
|
173
|
+
aheadStackId: string | null;
|
|
174
|
+
aheadOther: boolean;
|
|
175
|
+
};
|
|
176
|
+
export declare function mergeQueueWait(queue: readonly MergeQueueEntryDto[], nowMs: number): MergeQueueWait | null;
|
|
87
177
|
export declare function conflictLiveParent(stack: StackDto, layer: StackLayerDto): string | null;
|
|
88
178
|
export declare function conflictRepairSteps(branch: string, liveParent: string | null): string;
|
|
179
|
+
export declare function restackFailedRepairSteps(branch: string, liveParent: string | null, failure: {
|
|
180
|
+
kind: string;
|
|
181
|
+
detail?: string | null;
|
|
182
|
+
}): string;
|
|
89
183
|
export declare function agentsCannotClear(blocker: string): boolean;
|
|
90
|
-
export declare function stackRepair(stack: StackDto, attention: StackBlocker | null, layer: StackLayerDto | undefined, bounce: MergeQueueEntryDto | undefined): StackRepairHint | null;
|
|
184
|
+
export declare function stackRepair(stack: StackDto, attention: StackBlocker | null, layer: StackLayerDto | undefined, bounce: MergeQueueEntryDto | undefined, queueWait?: MergeQueueWait | null): StackRepairHint | null;
|
|
91
185
|
/** Pair-gate attention plus live hard blocks on every other open layer. */
|
|
92
|
-
export declare function stackBlockers(stack: StackDto, entries?: MergeQueueEntryDto[], cursor?: Pick<StackWatchCursor, "bounceId" | "afterFinishedAt"
|
|
186
|
+
export declare function stackBlockers(stack: StackDto, entries?: MergeQueueEntryDto[], cursor?: Pick<StackWatchCursor, "bounceId" | "afterFinishedAt">, nowMs?: number): {
|
|
93
187
|
attention: null;
|
|
94
188
|
held: StackHeldBlocker;
|
|
95
189
|
issues: StackBlocker[];
|
|
@@ -102,6 +196,7 @@ export declare function stackBlockers(stack: StackDto, entries?: MergeQueueEntry
|
|
|
102
196
|
agents: StackLayerAgents | null;
|
|
103
197
|
repair: StackRepairHint | null;
|
|
104
198
|
landGatePending: StackLandGatePending | null;
|
|
199
|
+
queueWait: StackQueueWait | null;
|
|
105
200
|
} | {
|
|
106
201
|
attention: StackBlocker | null;
|
|
107
202
|
held: null;
|
|
@@ -115,6 +210,7 @@ export declare function stackBlockers(stack: StackDto, entries?: MergeQueueEntry
|
|
|
115
210
|
agents: StackLayerAgents | null;
|
|
116
211
|
repair: StackRepairHint | null;
|
|
117
212
|
landGatePending: StackLandGatePending | null;
|
|
213
|
+
queueWait: StackQueueWait | null;
|
|
118
214
|
};
|
|
119
215
|
/** Compact contract text shared by status and wait summaries. */
|
|
120
|
-
export declare function stackBlockersSummary(attention: Pick<StackBlocker, "prNumber" | "blocker"> | null, issues: StackBlocker[], busy?: readonly StackAgentBusy[], landGatePending?: Pick<StackLandGatePending, "prNumber" | "reason"> | null, actAfter?: StackHeldBlocker["actAfter"] | null): string;
|
|
216
|
+
export declare function stackBlockersSummary(attention: Pick<StackBlocker, "prNumber" | "blocker"> | null, issues: StackBlocker[], busy?: readonly StackAgentBusy[], landGatePending?: Pick<StackLandGatePending, "prNumber" | "reason"> | null, actAfter?: StackHeldBlocker["actAfter"] | null, queueWait?: Pick<StackQueueWait, "prNumber" | "text"> | null): string;
|
package/dist/stack-blockers.js
CHANGED
|
@@ -51,13 +51,81 @@ 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 RESTACK_FAILED = "Restack failed";
|
|
54
55
|
export const SEAM_REVIEW_NOT_RUNNING = "Seam review pending, no review running";
|
|
55
56
|
export const VORTEX_REVIEW_OUT_OF_QUOTA = "Review skipped, out of review quota";
|
|
57
|
+
export const SEAM_REVIEW_STUCK = "Seam review stuck in reviewing, no review running";
|
|
58
|
+
export const SEAM_REREVIEW_NOT_RUNNING = "Seam re-review pending, no review running";
|
|
59
|
+
export const SEAM_VERDICT_STALE = "Seam verdict is for an older head, no review running";
|
|
60
|
+
const STUCK_SEAM_BLOCKERS = [SEAM_REVIEW_STUCK, SEAM_REREVIEW_NOT_RUNNING, SEAM_VERDICT_STALE];
|
|
61
|
+
function coreReviewDoneAtHead(layer) {
|
|
62
|
+
const review = layer.vortexReview;
|
|
63
|
+
return review?.status === "done" && sameHead(review.head_sha, layer.headSha);
|
|
64
|
+
}
|
|
65
|
+
export const VORTEX_FINDINGS_NEED_PERSON = "Vortex findings need a person";
|
|
66
|
+
function vortexChangesRequestedAtHead(layer) {
|
|
67
|
+
const review = layer.vortexReview;
|
|
68
|
+
if (review && !(review.status === "done" && sameHead(review.head_sha, layer.headSha)))
|
|
69
|
+
return false;
|
|
70
|
+
return layer.vortexStatus === "findings" && layer.reviewStatus === "changes_requested";
|
|
71
|
+
}
|
|
72
|
+
function cycloneHandoffAtHead(stack, layer) {
|
|
73
|
+
if (sameHead(layer.cycloneHandoff?.headSha, layer.headSha))
|
|
74
|
+
return layer.cycloneHandoff.reason;
|
|
75
|
+
return stack.cyclonePatchOff ?? (stack.autoPatchOverride === false ? "auto_patch_off" : null);
|
|
76
|
+
}
|
|
77
|
+
const CYCLONE_HANDOFF_WORDS = {
|
|
78
|
+
auto_patch_off: "auto-patch is off for this stack",
|
|
79
|
+
stack_auto_patch_off: "auto-patch is off for this stack",
|
|
80
|
+
account_auto_patch_off: "auto-patch is off for the account and this stack does not turn it on",
|
|
81
|
+
cyclone_not_connected: "Cyclone is not connected to this repo",
|
|
82
|
+
};
|
|
83
|
+
function cycloneHandoffWords(reason) {
|
|
84
|
+
return CYCLONE_HANDOFF_WORDS[reason] ?? reason.replaceAll("_", " ");
|
|
85
|
+
}
|
|
86
|
+
function vortexFindingsLeftForPerson(stack, layer) {
|
|
87
|
+
return vortexChangesRequestedAtHead(layer) && !layerAgentsBusy(layer).cyclone &&
|
|
88
|
+
cycloneHandoffAtHead(stack, layer) !== null;
|
|
89
|
+
}
|
|
56
90
|
function quotaSkippedReview(layer) {
|
|
57
91
|
const review = layer.vortexReview;
|
|
58
92
|
return review?.status === "skipped" && review.skip_reason === "quota_exceeded" &&
|
|
59
93
|
sameHead(review.head_sha, layer.headSha);
|
|
60
94
|
}
|
|
95
|
+
export const VORTEX_AUTO_REVIEW_OFF = "Vortex auto-review is off, so this head was not reviewed";
|
|
96
|
+
export const VORTEX_BILLING_SKIPPED = "Vortex skipped this head: it could not confirm billing";
|
|
97
|
+
export const VORTEX_REVIEW_INCOMPLETE = "Vortex reviewed only part of this PR";
|
|
98
|
+
const VORTEX_SKIP_BLOCKERS = {
|
|
99
|
+
auto_review_off: VORTEX_AUTO_REVIEW_OFF,
|
|
100
|
+
billing_blocked: VORTEX_BILLING_SKIPPED,
|
|
101
|
+
billing_unavailable: VORTEX_BILLING_SKIPPED,
|
|
102
|
+
};
|
|
103
|
+
function vortexIdleSkipBlocker(layer) {
|
|
104
|
+
if (layerAgentsBusy(layer).vortex)
|
|
105
|
+
return null;
|
|
106
|
+
const review = layer.vortexReview;
|
|
107
|
+
if (review?.status === "skipped" && sameHead(review.head_sha, layer.headSha)) {
|
|
108
|
+
const reason = review.skip_reason?.trim() ?? "";
|
|
109
|
+
const blocker = VORTEX_SKIP_BLOCKERS[reason];
|
|
110
|
+
if (blocker)
|
|
111
|
+
return { blocker, skipReason: reason };
|
|
112
|
+
}
|
|
113
|
+
if (layer.vortexStatus === "incomplete" && sameHead(review?.head_sha, layer.headSha)) {
|
|
114
|
+
return { blocker: VORTEX_REVIEW_INCOMPLETE, skipReason: "incomplete" };
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
function vortexSkippedSteps(stack, layer, skipReason) {
|
|
119
|
+
const head = layer.headSha ?? "its live head";
|
|
120
|
+
const mention = `comment @mergestorm-vortex review on #${layer.prNumber}`;
|
|
121
|
+
if (skipReason === "auto_review_off") {
|
|
122
|
+
return `Vortex auto-review is off for ${stack.owner}/${stack.repo} or for this stack, so it skipped #${layer.prNumber} at ${head} and Auto land waits for a review that will not come. Do not patch or push for this. Tell the human: they can turn auto-review on (mg stack set ${stack.id} --auto-review on, or the repo setting in the dashboard), which does not review this head by itself, and then ${mention} to review it now.`;
|
|
123
|
+
}
|
|
124
|
+
if (skipReason === "incomplete") {
|
|
125
|
+
return `Vortex reviewed only some of the files in #${layer.prNumber} at ${head} and left the rest unreviewed, so Auto land waits for full coverage. Do not patch or push for this. To review the remaining files, ${mention} (the Continue button on the stack is unrelated), or tell the human to do it; turning on automatic overflow reviews in the dashboard settings covers this for later heads.`;
|
|
126
|
+
}
|
|
127
|
+
return `Vortex skipped #${layer.prNumber} at ${head} because it could not confirm billing for this account, so Auto land waits for a review that will not come. Do not patch or push for this. Re-run the review: ${mention}. If it is skipped again, tell the human to check the plan and billing in the Mergestorm dashboard.`;
|
|
128
|
+
}
|
|
61
129
|
function seamGateBlocker(stack, layer) {
|
|
62
130
|
const members = stack.unit?.members ?? [];
|
|
63
131
|
if (layer.prNumber === stack.unit?.landPrNumber)
|
|
@@ -72,8 +140,20 @@ function seamGateBlocker(stack, layer) {
|
|
|
72
140
|
return "Seam review failed";
|
|
73
141
|
if (seamState === "findings" && sameHead(member.seamReviewedSha, layer.headSha))
|
|
74
142
|
return "Seam findings";
|
|
75
|
-
if (
|
|
143
|
+
if (layerAgentsBusy(layer).vortex)
|
|
144
|
+
return null;
|
|
145
|
+
if (seamState === "pending")
|
|
76
146
|
return SEAM_REVIEW_NOT_RUNNING;
|
|
147
|
+
if (seamState === "reviewing")
|
|
148
|
+
return SEAM_REVIEW_STUCK;
|
|
149
|
+
if (seamState === "pending_rereview")
|
|
150
|
+
return SEAM_REREVIEW_NOT_RUNNING;
|
|
151
|
+
if (!layer.headSha?.trim() || sameHead(member.seamReviewedSha, layer.headSha))
|
|
152
|
+
return null;
|
|
153
|
+
if (seamState === "findings")
|
|
154
|
+
return SEAM_VERDICT_STALE;
|
|
155
|
+
if (seamState === "approved" && !coreReviewDoneAtHead(layer))
|
|
156
|
+
return SEAM_VERDICT_STALE;
|
|
77
157
|
return null;
|
|
78
158
|
}
|
|
79
159
|
function headChecksAllGreen(layer) {
|
|
@@ -96,7 +176,7 @@ function layerAttention(stack, layer, queue, cursor) {
|
|
|
96
176
|
if (layer.restackError.kind === "rebase_conflict")
|
|
97
177
|
return hardBlock("Conflict");
|
|
98
178
|
if (!restackRetryPending(layer.restackError, layer))
|
|
99
|
-
return hardBlock(
|
|
179
|
+
return hardBlock(RESTACK_FAILED);
|
|
100
180
|
}
|
|
101
181
|
if (layer.state === "conflict")
|
|
102
182
|
return hardBlock("Conflict");
|
|
@@ -116,13 +196,16 @@ function layerAttention(stack, layer, queue, cursor) {
|
|
|
116
196
|
sameHead(run.sha, layer.headSha)))
|
|
117
197
|
return hardBlock("Cyclone failed");
|
|
118
198
|
const tempest = layer.agentRuns?.find((run) => run.agent === "tempest" &&
|
|
119
|
-
|
|
199
|
+
run.status === "findings" && sameHead(run.sha, layer.headSha));
|
|
120
200
|
if (tempest)
|
|
121
201
|
return hardBlock(`Tempest ${tempest.status}`);
|
|
122
202
|
if (layer.vortexStatus === "failed")
|
|
123
203
|
return hardBlock("Review failed");
|
|
124
204
|
if (quotaSkippedReview(layer))
|
|
125
205
|
return hardBlock(VORTEX_REVIEW_OUT_OF_QUOTA);
|
|
206
|
+
const vortexSkip = vortexIdleSkipBlocker(layer);
|
|
207
|
+
if (vortexSkip)
|
|
208
|
+
return hardBlock(vortexSkip.blocker);
|
|
126
209
|
const seam = seamGateBlocker(stack, layer);
|
|
127
210
|
if (seam)
|
|
128
211
|
return hardBlock(seam);
|
|
@@ -136,6 +219,8 @@ function layerAttention(stack, layer, queue, cursor) {
|
|
|
136
219
|
if (stack.unit.tempestLandStatus?.toLowerCase() === "failed")
|
|
137
220
|
return hardBlock("Tempest failed");
|
|
138
221
|
}
|
|
222
|
+
if (vortexFindingsLeftForPerson(stack, layer))
|
|
223
|
+
return hardBlock(VORTEX_FINDINGS_NEED_PERSON);
|
|
139
224
|
}
|
|
140
225
|
if (!bounce || !kind || recoverable || !layer || queue.some((entry) => ["queued", "running", "waiting"].includes(entry.state)))
|
|
141
226
|
return none;
|
|
@@ -179,6 +264,101 @@ function disarmingBounce(stack, queue) {
|
|
|
179
264
|
return recorded;
|
|
180
265
|
return undefined;
|
|
181
266
|
}
|
|
267
|
+
export const AUTO_LAND_CI_PENDING_ATTENTION_MS = 45 * 60_000;
|
|
268
|
+
export const AUTO_LAND_VORTEX_WAIT_ATTENTION_MS = 30 * 60_000;
|
|
269
|
+
export const AUTO_LAND_PROMOTE_FAILED_ATTEMPTS = 3;
|
|
270
|
+
export const AUTO_LAND_CI_FAILED_BLOCKER = "CI failed — Auto land reads a failing check on GitHub at this head";
|
|
271
|
+
const AUTO_LAND_CI_PENDING_PREFIX = "CI pending for ";
|
|
272
|
+
const AUTO_LAND_VORTEX_WAIT_PREFIX = "No green Vortex review for ";
|
|
273
|
+
const AUTO_LAND_PROMOTE_FAILED_PREFIX = "Promote failed ";
|
|
274
|
+
function waitedMs(since, nowMs) {
|
|
275
|
+
const sinceMs = Date.parse(since);
|
|
276
|
+
return Number.isFinite(sinceMs) ? Math.max(0, nowMs - sinceMs) : null;
|
|
277
|
+
}
|
|
278
|
+
function autoLandWaitOn(stack, layer) {
|
|
279
|
+
const wait = stack.autoLandWait;
|
|
280
|
+
if (!wait || stack.archivedAt || stack.autoEnqueueWhenReady !== true)
|
|
281
|
+
return null;
|
|
282
|
+
if (wait.prNumber !== layer.prNumber || !sameHead(wait.headSha, layer.headSha))
|
|
283
|
+
return null;
|
|
284
|
+
return wait;
|
|
285
|
+
}
|
|
286
|
+
export function autoLandWaitBlocker(stack, layer, nowMs) {
|
|
287
|
+
const wait = autoLandWaitOn(stack, layer);
|
|
288
|
+
if (!wait)
|
|
289
|
+
return null;
|
|
290
|
+
const waited = waitedMs(wait.since, nowMs);
|
|
291
|
+
if (waited == null)
|
|
292
|
+
return null;
|
|
293
|
+
const minutes = Math.floor(waited / 60_000);
|
|
294
|
+
switch (wait.reason) {
|
|
295
|
+
case "ci_failure":
|
|
296
|
+
return AUTO_LAND_CI_FAILED_BLOCKER;
|
|
297
|
+
case "ci_pending":
|
|
298
|
+
return waited >= AUTO_LAND_CI_PENDING_ATTENTION_MS
|
|
299
|
+
? `${AUTO_LAND_CI_PENDING_PREFIX}${minutes}m — a check on this head has not finished, so Auto land will not queue it`
|
|
300
|
+
: null;
|
|
301
|
+
case "vortex_not_green":
|
|
302
|
+
return waited >= AUTO_LAND_VORTEX_WAIT_ATTENTION_MS
|
|
303
|
+
? `${AUTO_LAND_VORTEX_WAIT_PREFIX}${minutes}m — Auto land is waiting for an approving Vortex review at this head`
|
|
304
|
+
: null;
|
|
305
|
+
case "promote_merge_failed":
|
|
306
|
+
return (wait.attempts ?? 0) >= AUTO_LAND_PROMOTE_FAILED_ATTEMPTS
|
|
307
|
+
? `${AUTO_LAND_PROMOTE_FAILED_PREFIX}${wait.attempts} times (merge_failed) — Auto land stopped retrying this head${wait.detail ? `: ${wait.detail}` : ""}`
|
|
308
|
+
: null;
|
|
309
|
+
default:
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
export const MERGE_QUEUE_STALL_ATTENTION_MS = 45 * 60_000;
|
|
314
|
+
const MERGE_QUEUE_STALLED_PREFIX = "Merge queue stuck: ";
|
|
315
|
+
const LIVE_QUEUE_STATES = ["queued", "running", "waiting"];
|
|
316
|
+
function minutesOf(ms) {
|
|
317
|
+
return ms == null ? 0 : Math.floor(ms / 60_000);
|
|
318
|
+
}
|
|
319
|
+
export function mergeQueueWait(queue, nowMs) {
|
|
320
|
+
const entry = queue.find((candidate) => LIVE_QUEUE_STATES.includes(candidate.state));
|
|
321
|
+
if (!entry)
|
|
322
|
+
return null;
|
|
323
|
+
const reason = entry.waitReason?.trim() || null;
|
|
324
|
+
if (entry.state === "queued") {
|
|
325
|
+
const queuedFor = minutesOf(waitedMs(entry.enqueuedAt, nowMs));
|
|
326
|
+
const ahead = entry.aheadInRepo;
|
|
327
|
+
if (!ahead) {
|
|
328
|
+
return {
|
|
329
|
+
text: `Queued ${queuedFor}m in the merge queue, not picked up yet${reason ? ` (last note: ${reason})` : ""}`,
|
|
330
|
+
stalled: false,
|
|
331
|
+
since: entry.enqueuedAt, waitReason: reason, aheadStackId: null, aheadOther: false,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
const aheadMs = ahead.claimedAt ? waitedMs(ahead.claimedAt, nowMs) : null;
|
|
335
|
+
const aheadReason = ahead.waitReason?.trim() || null;
|
|
336
|
+
const who = ahead.stackId ? `stack ${ahead.stackId}` : "another user's stack";
|
|
337
|
+
const aheadFor = aheadMs == null ? "an unknown duration" : `${minutesOf(aheadMs)}m`;
|
|
338
|
+
return {
|
|
339
|
+
text: `Queued ${queuedFor}m behind ${who}, which has been ${ahead.state} in the merge queue for ${aheadFor}${aheadReason ? `: ${aheadReason}` : ""}`,
|
|
340
|
+
stalled: aheadMs != null && aheadMs >= MERGE_QUEUE_STALL_ATTENTION_MS,
|
|
341
|
+
since: ahead.claimedAt, waitReason: aheadReason, aheadStackId: ahead.stackId, aheadOther: !ahead.stackId,
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
const since = entry.claimedAt ?? null;
|
|
345
|
+
const liveMs = since === null ? null : waitedMs(since, nowMs);
|
|
346
|
+
const liveFor = liveMs == null ? "an unknown duration" : `${minutesOf(liveMs)}m`;
|
|
347
|
+
return {
|
|
348
|
+
text: `Merge queue ${entry.state === "waiting" ? "waiting" : "running"} for ${liveFor}: ${reason ?? "no reason recorded"}`,
|
|
349
|
+
stalled: liveMs != null && liveMs >= MERGE_QUEUE_STALL_ATTENTION_MS,
|
|
350
|
+
since, waitReason: reason, aheadStackId: null, aheadOther: false,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
function queueStalledSteps(stack, layer, wait) {
|
|
354
|
+
if (wait.aheadStackId || wait.aheadOther) {
|
|
355
|
+
const ahead = wait.aheadStackId
|
|
356
|
+
? `Run mg stack status ${wait.aheadStackId} and fix what it names there; this stack moves once that entry lands or bounces.`
|
|
357
|
+
: "That entry belongs to another Mergestorm user, so you cannot fix it from here.";
|
|
358
|
+
return `#${layer.prNumber} is waiting in the merge queue behind another entry in ${stack.owner}/${stack.repo} that has made no progress since ${wait.since ?? "it was claimed"}. Nothing on ${layer.branch} needs a change. ${ahead} If it stays stuck, tell the human: the queue for this repo is held by that entry.`;
|
|
359
|
+
}
|
|
360
|
+
return `The merge queue has held #${layer.prNumber} at ${layer.headSha ?? "its head"} since ${wait.since ?? "it was claimed"} without landing or bouncing it${wait.waitReason ? `; its last note is: ${wait.waitReason}` : ", and it recorded no reason"}. If the note names a failing check or a conflict on ${layer.branch}, fix that with an ordinary push. If it waits on CI, list the head's checks (gh run list --branch ${layer.branch}) and re-run the one that never finished (gh run rerun <run-id>). If nothing moves after that, tell the human: they can take the stack out of the queue and enqueue it again (mg queue rm ${stack.id}, then mg queue add ${stack.id}). Do not retarget the PR base.`;
|
|
361
|
+
}
|
|
182
362
|
function isTerminalLayer(layer) {
|
|
183
363
|
return layer.state === "merged" || layer.state === "closed";
|
|
184
364
|
}
|
|
@@ -215,23 +395,48 @@ export function conflictRepairSteps(branch, liveParent) {
|
|
|
215
395
|
}
|
|
216
396
|
return `Merge ${liveParent} into ${branch}: git fetch origin, check out ${branch} at its live remote head, git merge origin/${liveParent}, resolve the conflicts, run the tests, then an ordinary git push (no force). Do not rebase onto trunk, main, or mg-park-*, and do not retarget the PR base.`;
|
|
217
397
|
}
|
|
398
|
+
export function restackFailedRepairSteps(branch, liveParent, failure) {
|
|
399
|
+
const why = `Mergestorm could not restack ${branch}${liveParent ? ` onto ${liveParent}` : ""} (${failure.kind}${failure.detail?.trim() ? `: ${failure.detail.trim()}` : ""}) and will not try this head again.`;
|
|
400
|
+
if (!liveParent) {
|
|
401
|
+
return `${why} The live parent of ${branch} is unknown (it is never an mg-park-* freeze). Do not merge or rebase anything; stop and tell the human.`;
|
|
402
|
+
}
|
|
403
|
+
return `${why} Merge ${liveParent} into ${branch}: git fetch origin, check out ${branch} at its live remote head, git merge origin/${liveParent}, resolve any conflicts, run the tests, then an ordinary git push (no force). Mergestorm clears Restack failed once the pushed head contains the tip of ${liveParent}. If GitHub refuses the push (branch protection or permissions), stop and tell the human. Do not rebase onto trunk, main, or mg-park-*, and do not retarget the PR base.`;
|
|
404
|
+
}
|
|
218
405
|
function landPrLiveParent(stack, layer) {
|
|
219
406
|
const base = stack.unit?.landTarget?.trim() || layer.parentBranch?.trim();
|
|
220
407
|
return base && !isMgParkBase(base) && base !== layer.branch ? base : null;
|
|
221
408
|
}
|
|
222
409
|
export function agentsCannotClear(blocker) {
|
|
223
|
-
return blocker === "Conflict" || blocker ===
|
|
410
|
+
return blocker === "Conflict" || blocker === RESTACK_FAILED || blocker === "Draft PR" ||
|
|
224
411
|
blocker === SEAM_REVIEW_NOT_RUNNING || blocker === VORTEX_REVIEW_OUT_OF_QUOTA ||
|
|
412
|
+
blocker === VORTEX_AUTO_REVIEW_OFF || blocker === VORTEX_BILLING_SKIPPED ||
|
|
413
|
+
blocker === VORTEX_REVIEW_INCOMPLETE ||
|
|
414
|
+
STUCK_SEAM_BLOCKERS.includes(blocker) ||
|
|
225
415
|
blocker.startsWith("Merge conflicts") || blocker.startsWith(UNIT_ABANDONED_BLOCKER_PREFIX);
|
|
226
416
|
}
|
|
227
|
-
|
|
417
|
+
const TEMPEST_RERUN_BLOCKER = /^(?:landing blocked:\s*)?tempest_(failed|stopped)\b/i;
|
|
418
|
+
function isTempestFindingsBlocker(attention, bounce) {
|
|
228
419
|
const blocker = attention.blocker.trim();
|
|
420
|
+
const reason = bounce?.bounceDetail?.message?.trim();
|
|
421
|
+
if (reason && TEMPEST_RERUN_BLOCKER.test(reason))
|
|
422
|
+
return false;
|
|
229
423
|
return attention.bounceKind === "tempest_findings" || /^Tempest findings\b/i.test(blocker) ||
|
|
230
424
|
/^(?:landing blocked:\s*)?tempest_findings\b/i.test(blocker);
|
|
231
425
|
}
|
|
232
|
-
export function stackRepair(stack, attention, layer, bounce) {
|
|
426
|
+
export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
|
|
233
427
|
if (!attention || !layer || attention.prNumber !== layer.prNumber)
|
|
234
428
|
return null;
|
|
429
|
+
if (queueWait && attention.blocker.startsWith(MERGE_QUEUE_STALLED_PREFIX)) {
|
|
430
|
+
return {
|
|
431
|
+
kind: "queue_stalled",
|
|
432
|
+
prNumber: layer.prNumber,
|
|
433
|
+
headSha: layer.headSha ?? null,
|
|
434
|
+
branch: layer.branch,
|
|
435
|
+
since: queueWait.since,
|
|
436
|
+
waitReason: queueWait.waitReason,
|
|
437
|
+
steps: queueStalledSteps(stack, layer, queueWait),
|
|
438
|
+
};
|
|
439
|
+
}
|
|
235
440
|
const landPr = !!stack.unit?.branch && layer.prNumber === stack.unit.landPrNumber && layer.branch === stack.unit.branch;
|
|
236
441
|
const restackConflict = attention.blocker === "Conflict" || attention.bounceKind === "restack_conflict";
|
|
237
442
|
const mergeConflict = attention.blocker.startsWith("Merge conflicts");
|
|
@@ -247,6 +452,18 @@ export function stackRepair(stack, attention, layer, bounce) {
|
|
|
247
452
|
steps: conflictRepairSteps(layer.branch, liveParent),
|
|
248
453
|
};
|
|
249
454
|
}
|
|
455
|
+
if (attention.blocker === RESTACK_FAILED && layer.restackError) {
|
|
456
|
+
const liveParent = landPr ? landPrLiveParent(stack, layer) : conflictLiveParent(stack, layer);
|
|
457
|
+
return {
|
|
458
|
+
kind: "restack_failed",
|
|
459
|
+
prNumber: layer.prNumber,
|
|
460
|
+
headSha: layer.headSha ?? null,
|
|
461
|
+
branch: layer.branch,
|
|
462
|
+
liveParent,
|
|
463
|
+
restackKind: layer.restackError.kind,
|
|
464
|
+
steps: restackFailedRepairSteps(layer.branch, liveParent, layer.restackError),
|
|
465
|
+
};
|
|
466
|
+
}
|
|
250
467
|
if (attention.blocker.startsWith(UNIT_ABANDONED_BLOCKER_PREFIX)) {
|
|
251
468
|
const openPrCount = stack.layers.filter((candidate) => candidate.prNumber > 0 && !isTerminalLayer(candidate)).length;
|
|
252
469
|
return {
|
|
@@ -257,6 +474,22 @@ export function stackRepair(stack, attention, layer, bounce) {
|
|
|
257
474
|
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.`,
|
|
258
475
|
};
|
|
259
476
|
}
|
|
477
|
+
if (STUCK_SEAM_BLOCKERS.includes(attention.blocker)) {
|
|
478
|
+
const member = stack.unit?.members?.find((entry) => entry.prNumber === layer.prNumber);
|
|
479
|
+
const seamState = member?.seamState?.trim().toLowerCase() || "unknown";
|
|
480
|
+
const reviewed = member?.seamReviewedSha?.trim();
|
|
481
|
+
const what = attention.blocker === SEAM_VERDICT_STALE
|
|
482
|
+
? `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`
|
|
483
|
+
: `The seam (integration) review of #${layer.prNumber} is ${seamState} but no seam review is queued or running at ${layer.headSha ?? "the live head"}`;
|
|
484
|
+
return {
|
|
485
|
+
kind: "seam_review_stuck",
|
|
486
|
+
prNumber: layer.prNumber,
|
|
487
|
+
headSha: layer.headSha ?? null,
|
|
488
|
+
branch: layer.branch,
|
|
489
|
+
seamState,
|
|
490
|
+
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.`,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
260
493
|
if (attention.blocker.startsWith(AUTO_LAND_OFF_BLOCKER_PREFIX) && attention.bounceKind) {
|
|
261
494
|
return {
|
|
262
495
|
kind: "auto_land_off",
|
|
@@ -267,7 +500,7 @@ export function stackRepair(stack, attention, layer, bounce) {
|
|
|
267
500
|
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.`,
|
|
268
501
|
};
|
|
269
502
|
}
|
|
270
|
-
if (landPr && isTempestFindingsBlocker(attention)) {
|
|
503
|
+
if (landPr && isTempestFindingsBlocker(attention, bounce)) {
|
|
271
504
|
const findings = `gh api repos/${stack.owner}/${stack.repo}/issues/${layer.prNumber}/comments`;
|
|
272
505
|
return {
|
|
273
506
|
kind: "tempest_findings",
|
|
@@ -278,6 +511,81 @@ export function stackRepair(stack, attention, layer, bounce) {
|
|
|
278
511
|
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.`,
|
|
279
512
|
};
|
|
280
513
|
}
|
|
514
|
+
const rerunBlocker = attention.blocker.trim().match(TEMPEST_RERUN_BLOCKER) ??
|
|
515
|
+
bounce?.bounceDetail?.message?.trim().match(TEMPEST_RERUN_BLOCKER);
|
|
516
|
+
const rerun = landPr ? rerunBlocker : null;
|
|
517
|
+
if (rerun) {
|
|
518
|
+
const stoppedByPerson = rerun[1].toLowerCase() === "stopped";
|
|
519
|
+
const comment = `gh pr comment ${layer.prNumber} --repo ${stack.owner}/${stack.repo} --body`;
|
|
520
|
+
const head = layer.headSha ?? "its live head";
|
|
521
|
+
return {
|
|
522
|
+
kind: "tempest_rerun",
|
|
523
|
+
prNumber: layer.prNumber,
|
|
524
|
+
headSha: layer.headSha ?? null,
|
|
525
|
+
branch: layer.branch,
|
|
526
|
+
stoppedByPerson,
|
|
527
|
+
steps: stoppedByPerson
|
|
528
|
+
? `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.`
|
|
529
|
+
: `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.`,
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
const vortexSkip = vortexIdleSkipBlocker(layer);
|
|
533
|
+
if (vortexSkip && attention.blocker === vortexSkip.blocker) {
|
|
534
|
+
return {
|
|
535
|
+
kind: "vortex_skipped",
|
|
536
|
+
prNumber: layer.prNumber,
|
|
537
|
+
headSha: layer.headSha ?? null,
|
|
538
|
+
branch: layer.branch,
|
|
539
|
+
skipReason: vortexSkip.skipReason,
|
|
540
|
+
steps: vortexSkippedSteps(stack, layer, vortexSkip.skipReason),
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
const wait = autoLandWaitOn(stack, layer);
|
|
544
|
+
if (wait && attention.blocker.startsWith(AUTO_LAND_CI_PENDING_PREFIX)) {
|
|
545
|
+
return {
|
|
546
|
+
kind: "ci_pending",
|
|
547
|
+
prNumber: layer.prNumber,
|
|
548
|
+
headSha: layer.headSha ?? null,
|
|
549
|
+
branch: layer.branch,
|
|
550
|
+
since: wait.since,
|
|
551
|
+
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.`,
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
if (wait && attention.blocker.startsWith(AUTO_LAND_VORTEX_WAIT_PREFIX)) {
|
|
555
|
+
return {
|
|
556
|
+
kind: "vortex_not_green",
|
|
557
|
+
prNumber: layer.prNumber,
|
|
558
|
+
headSha: layer.headSha ?? null,
|
|
559
|
+
branch: layer.branch,
|
|
560
|
+
since: wait.since,
|
|
561
|
+
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.`,
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
if (wait && attention.blocker.startsWith(AUTO_LAND_PROMOTE_FAILED_PREFIX)) {
|
|
565
|
+
const attempts = wait.attempts ?? AUTO_LAND_PROMOTE_FAILED_ATTEMPTS;
|
|
566
|
+
return {
|
|
567
|
+
kind: "promote_failed",
|
|
568
|
+
prNumber: layer.prNumber,
|
|
569
|
+
headSha: layer.headSha ?? null,
|
|
570
|
+
branch: layer.branch,
|
|
571
|
+
attempts,
|
|
572
|
+
message: wait.detail,
|
|
573
|
+
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.`,
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
if (attention.blocker === VORTEX_FINDINGS_NEED_PERSON) {
|
|
577
|
+
const findings = `gh api repos/${stack.owner}/${stack.repo}/pulls/${layer.prNumber}/reviews`;
|
|
578
|
+
const handoff = cycloneHandoffAtHead(stack, layer);
|
|
579
|
+
return {
|
|
580
|
+
kind: "vortex_findings",
|
|
581
|
+
prNumber: layer.prNumber,
|
|
582
|
+
headSha: layer.headSha ?? null,
|
|
583
|
+
branch: layer.branch,
|
|
584
|
+
handoff,
|
|
585
|
+
findings,
|
|
586
|
+
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.`,
|
|
587
|
+
};
|
|
588
|
+
}
|
|
281
589
|
if (attention.blocker.startsWith("CI failed") || attention.bounceKind === "ci_failure") {
|
|
282
590
|
const failingCheck = bounce?.bounceDetail?.failingCheck?.trim() || layer.checks?.failingName?.trim() || null;
|
|
283
591
|
return {
|
|
@@ -292,7 +600,7 @@ export function stackRepair(stack, attention, layer, bounce) {
|
|
|
292
600
|
return null;
|
|
293
601
|
}
|
|
294
602
|
/** Pair-gate attention plus live hard blocks on every other open layer. */
|
|
295
|
-
export function stackBlockers(stack, entries = [], cursor = {}) {
|
|
603
|
+
export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now()) {
|
|
296
604
|
const candidate = currentLayer(stack);
|
|
297
605
|
const currentCandidate = candidate ? { prNumber: candidate.prNumber, headSha: candidate.headSha ?? null } : null;
|
|
298
606
|
const queue = entries.filter((entry) => entry.stackId.trim().toLowerCase() === stack.id.trim().toLowerCase());
|
|
@@ -307,6 +615,7 @@ export function stackBlockers(stack, entries = [], cursor = {}) {
|
|
|
307
615
|
let attentionLayer;
|
|
308
616
|
let bounce;
|
|
309
617
|
const issues = [];
|
|
618
|
+
const queueWait = stack.archivedAt ? null : mergeQueueWait(queue, nowMs);
|
|
310
619
|
for (const layer of open) {
|
|
311
620
|
const selected = layer.prNumber === candidate?.prNumber;
|
|
312
621
|
let result = layerAttention(stack, layer, selected ? queue : [], cursor);
|
|
@@ -318,6 +627,17 @@ export function stackBlockers(stack, entries = [], cursor = {}) {
|
|
|
318
627
|
result = { blocker: `${AUTO_LAND_OFF_BLOCKER_PREFIX}${kind} bounce`, bounceKind: kind, bounce: disarmed };
|
|
319
628
|
}
|
|
320
629
|
}
|
|
630
|
+
let waitNamed = false;
|
|
631
|
+
if (!result.blocker) {
|
|
632
|
+
const waiting = autoLandWaitBlocker(stack, layer, nowMs);
|
|
633
|
+
if (waiting) {
|
|
634
|
+
result = { blocker: waiting, bounceKind: null };
|
|
635
|
+
waitNamed = true;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
if (!result.blocker && selected && queueWait?.stalled) {
|
|
639
|
+
result = { blocker: `${MERGE_QUEUE_STALLED_PREFIX}${queueWait.text}`, bounceKind: null };
|
|
640
|
+
}
|
|
321
641
|
if (!result.blocker) {
|
|
322
642
|
if (mergeabilityPending(layer) && (selected || !candidate || layer.position > candidate.position)) {
|
|
323
643
|
issues.push({ prNumber: layer.prNumber, headSha: layer.headSha ?? null,
|
|
@@ -329,7 +649,7 @@ export function stackBlockers(stack, entries = [], cursor = {}) {
|
|
|
329
649
|
blocker: result.blocker, bounceKind: result.bounceKind };
|
|
330
650
|
if (selected ||
|
|
331
651
|
result.blocker.startsWith(UNIT_ABANDONED_BLOCKER_PREFIX) ||
|
|
332
|
-
(!attention && layer === child)) {
|
|
652
|
+
(!attention && (layer === child || waitNamed))) {
|
|
333
653
|
attention = issue;
|
|
334
654
|
attentionLayer = layer;
|
|
335
655
|
bounce = result.bounce;
|
|
@@ -340,6 +660,10 @@ export function stackBlockers(stack, entries = [], cursor = {}) {
|
|
|
340
660
|
else if (!candidate || layer.position > candidate.position)
|
|
341
661
|
issues.push(issue);
|
|
342
662
|
}
|
|
663
|
+
const queued = queueWait && candidate && !queueWait.stalled
|
|
664
|
+
? { prNumber: candidate.prNumber, headSha: candidate.headSha ?? null, text: queueWait.text,
|
|
665
|
+
since: queueWait.since, waitReason: queueWait.waitReason }
|
|
666
|
+
: null;
|
|
343
667
|
const settleIssue = mergeabilitySettleIssue(stack, open, issues, attention);
|
|
344
668
|
if (settleIssue)
|
|
345
669
|
issues.push(settleIssue);
|
|
@@ -362,11 +686,14 @@ export function stackBlockers(stack, entries = [], cursor = {}) {
|
|
|
362
686
|
if (busy.length && attention && agentsCannotClear(attention.blocker)) {
|
|
363
687
|
const held = { ...attention, actAfter: "agents_idle", waitingOn: busy.map((entry) => entry.agent) };
|
|
364
688
|
return { attention: null, held, issues: [attention, ...issues], currentCandidate, bounce: undefined, busy, agents,
|
|
365
|
-
repair: stackRepair(stack, attention, attentionLayer, undefined), landGatePending };
|
|
689
|
+
repair: stackRepair(stack, attention, attentionLayer, undefined, queueWait), landGatePending, queueWait: queued };
|
|
690
|
+
}
|
|
691
|
+
if (busy.length) {
|
|
692
|
+
return { attention: null, held: null, issues, currentCandidate, bounce: undefined, busy, agents, repair: null, landGatePending,
|
|
693
|
+
queueWait: queued };
|
|
366
694
|
}
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
return { attention, held: null, issues, currentCandidate, bounce, busy, agents, repair: stackRepair(stack, attention, attentionLayer, bounce), landGatePending };
|
|
695
|
+
return { attention, held: null, issues, currentCandidate, bounce, busy, agents,
|
|
696
|
+
repair: stackRepair(stack, attention, attentionLayer, bounce, queueWait), landGatePending, queueWait: queued };
|
|
370
697
|
}
|
|
371
698
|
function mergeabilitySettleIssue(stack, open, issues, attention) {
|
|
372
699
|
const settle = stack.autoEnqueueSettle;
|
|
@@ -384,7 +711,7 @@ function mergeabilitySettleIssue(stack, open, issues, attention) {
|
|
|
384
711
|
blocker: `Merge state unknown${vs}; Auto land is waiting for a mergeability verdict`, bounceKind: null };
|
|
385
712
|
}
|
|
386
713
|
/** Compact contract text shared by status and wait summaries. */
|
|
387
|
-
export function stackBlockersSummary(attention, issues, busy = [], landGatePending = null, actAfter = null) {
|
|
714
|
+
export function stackBlockersSummary(attention, issues, busy = [], landGatePending = null, actAfter = null, queueWait = null) {
|
|
388
715
|
const agentWork = (entry) => entry.agent === "cyclone" ? "Cyclone patches" : "Vortex reviews";
|
|
389
716
|
const blocked = attention && actAfter && busy.length
|
|
390
717
|
? ` · blocked after the agents: #${attention.prNumber} ${attention.blocker} (plan the fix; act once ${busy.map(agentWork).join(" and ")} finish and the watch returns attention)`
|
|
@@ -397,5 +724,6 @@ export function stackBlockersSummary(attention, issues, busy = [], landGatePendi
|
|
|
397
724
|
const gate = landGatePending && !attention
|
|
398
725
|
? ` · land gate: #${landGatePending.prNumber} ${landGatePending.reason} (nothing to fix; wait)`
|
|
399
726
|
: "";
|
|
400
|
-
|
|
727
|
+
const queue = queueWait && !attention ? ` · queue: #${queueWait.prNumber} ${queueWait.text}` : "";
|
|
728
|
+
return blocked + working + gate + queue + (listed.length ? ` · issues: ${labels}${listed.length > 3 ? `; +${listed.length - 3} more` : ""}` : "");
|
|
401
729
|
}
|
package/dist/stack-dto.d.ts
CHANGED
|
@@ -64,6 +64,11 @@ export type StackVortexReviewView = {
|
|
|
64
64
|
source: "pr_reviews" | "github_webhook_jobs" | "lease" | "none";
|
|
65
65
|
};
|
|
66
66
|
export type StackCycloneStatus = "patching" | "awaiting_fix" | "failed";
|
|
67
|
+
export type StackCycloneHandoff = {
|
|
68
|
+
headSha: string;
|
|
69
|
+
reason: string;
|
|
70
|
+
at: string | null;
|
|
71
|
+
};
|
|
67
72
|
export type StackTempestStatus = "reviewing" | "findings" | "clear" | "failed" | "stale";
|
|
68
73
|
export type StackAgentName = "vortex" | "cyclone" | "tempest";
|
|
69
74
|
/**
|
|
@@ -208,6 +213,7 @@ export type StackLayerDto = {
|
|
|
208
213
|
*/
|
|
209
214
|
vortexReview?: StackVortexReviewView;
|
|
210
215
|
agentsBusy?: StackAgentsBusy | null;
|
|
216
|
+
cycloneHandoff?: StackCycloneHandoff | null;
|
|
211
217
|
conflictDetail: string | null;
|
|
212
218
|
restackError?: RestackError | null;
|
|
213
219
|
lastRestackedSha: string | null;
|
|
@@ -301,6 +307,7 @@ export type StackUnitDto = {
|
|
|
301
307
|
landPr: StackLayerDto | null;
|
|
302
308
|
};
|
|
303
309
|
export type CycloneOwnerMatch = "same" | "different" | "none" | "lookup_failed";
|
|
310
|
+
export type CyclonePatchOffReason = "stack_auto_patch_off" | "account_auto_patch_off" | "cyclone_not_connected";
|
|
304
311
|
export type StackDto = {
|
|
305
312
|
id: string;
|
|
306
313
|
owner: string;
|
|
@@ -339,6 +346,14 @@ export type StackDto = {
|
|
|
339
346
|
startedAt: string;
|
|
340
347
|
windowSeconds?: number;
|
|
341
348
|
} | null;
|
|
349
|
+
autoLandWait?: {
|
|
350
|
+
reason: string;
|
|
351
|
+
prNumber: number | null;
|
|
352
|
+
headSha: string | null;
|
|
353
|
+
since: string;
|
|
354
|
+
attempts: number | null;
|
|
355
|
+
detail: string | null;
|
|
356
|
+
} | null;
|
|
342
357
|
/**
|
|
343
358
|
* Per-stack Vortex auto-review override. `null` (or missing) follows the
|
|
344
359
|
* account `auto_review_enabled` flag; a boolean wins in both directions.
|
|
@@ -369,6 +384,7 @@ export type StackDto = {
|
|
|
369
384
|
* `lookup_failed`: the comparison could not be loaded. Fail closed.
|
|
370
385
|
*/
|
|
371
386
|
cycloneOwnerMatch?: CycloneOwnerMatch;
|
|
387
|
+
cyclonePatchOff?: CyclonePatchOffReason;
|
|
372
388
|
layers: StackLayerDto[];
|
|
373
389
|
/** Present when a review-unit row exists for this stack. */
|
|
374
390
|
unit?: StackUnitDto;
|
|
@@ -408,6 +424,12 @@ export type MergeQueueEntryBatchDto = {
|
|
|
408
424
|
state: MergeQueueBatchState;
|
|
409
425
|
landedCount: number;
|
|
410
426
|
};
|
|
427
|
+
export type MergeQueueAheadDto = {
|
|
428
|
+
stackId: string | null;
|
|
429
|
+
state: "running" | "waiting";
|
|
430
|
+
claimedAt: string | null;
|
|
431
|
+
waitReason: string | null;
|
|
432
|
+
};
|
|
411
433
|
export type MergeQueueEntryDto = {
|
|
412
434
|
id: string;
|
|
413
435
|
stackId: string;
|
|
@@ -431,4 +453,6 @@ export type MergeQueueEntryDto = {
|
|
|
431
453
|
cancelRequestedAt?: string | null;
|
|
432
454
|
batch?: MergeQueueEntryBatchDto | null;
|
|
433
455
|
finishedAt: string | null;
|
|
456
|
+
claimedAt?: string | null;
|
|
457
|
+
aheadInRepo?: MergeQueueAheadDto | null;
|
|
434
458
|
};
|
package/dist/stack-watch.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type StackAgentBusy, type StackBlocker, type StackBusyAgent, type StackHeldBlocker, type StackLayerAgents, type StackLandGatePending, type StackRepairHint } from "./stack-blockers.js";
|
|
1
|
+
import { type StackAgentBusy, type StackBlocker, type StackBusyAgent, type StackHeldBlocker, type StackLayerAgents, type StackLandGatePending, type StackQueueWait, type StackRepairHint } from "./stack-blockers.js";
|
|
2
2
|
import { type StackWatchObligation } from "./stack-watch-obligation.js";
|
|
3
3
|
import { apiFetch } from "./api.js";
|
|
4
4
|
import type { Config } from "./config.js";
|
|
@@ -33,6 +33,7 @@ export type StackWatchEnvelope = {
|
|
|
33
33
|
agents: StackLayerAgents | null;
|
|
34
34
|
repair: StackRepairHint | null;
|
|
35
35
|
landGatePending: StackLandGatePending | null;
|
|
36
|
+
queueWait?: StackQueueWait | null;
|
|
36
37
|
watch: StackWatchObligation;
|
|
37
38
|
};
|
|
38
39
|
export type PollStackWatchOptions = {
|
package/dist/stack-watch.js
CHANGED
|
@@ -120,7 +120,7 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
|
|
|
120
120
|
if (snapshot && Array.isArray(snapshot.stacks) && !stack) {
|
|
121
121
|
terminal = "not_found";
|
|
122
122
|
throw new StackWatchError("Stack not found or not owned by the current user", seal({ ...lastEnvelope, status: "failed", assessment: "unavailable", blocker: null, issues: [], busy: [], actAfter: null, waitingOn: [],
|
|
123
|
-
prNumber: null, headSha: null, repair: null, agents: null, currentCandidate: null, landGatePending: null }));
|
|
123
|
+
prNumber: null, headSha: null, repair: null, agents: null, currentCandidate: null, landGatePending: null, queueWait: null }));
|
|
124
124
|
}
|
|
125
125
|
if (!stack || !Array.isArray(stack.layers)) {
|
|
126
126
|
throw new StackWatchError("Stack snapshot missing or invalid", seal({ ...lastEnvelope, status: "failed", assessment: "unavailable" }));
|
|
@@ -140,13 +140,13 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
|
|
|
140
140
|
};
|
|
141
141
|
};
|
|
142
142
|
const evaluate = (stack, entries) => {
|
|
143
|
-
const { attention, held: named, issues, currentCandidate, bounce, busy, agents, repair, landGatePending } = stackBlockers(stack, entries, cursor);
|
|
143
|
+
const { attention, held: named, issues, currentCandidate, bounce, busy, agents, repair, landGatePending, queueWait } = stackBlockers(stack, entries, cursor, now());
|
|
144
144
|
if (bounce)
|
|
145
145
|
cursor = Object.freeze({ ...cursor, bounceId: bounce.id,
|
|
146
146
|
...(bounce.finishedAt !== undefined ? { afterFinishedAt: bounce.finishedAt } : {}) });
|
|
147
147
|
const held = busy[0];
|
|
148
148
|
const shown = attention ?? named;
|
|
149
|
-
lastEnvelope = { ...lastEnvelope, cursor, issues, currentCandidate, assessment: "available", busy, agents, repair, landGatePending,
|
|
149
|
+
lastEnvelope = { ...lastEnvelope, cursor, issues, currentCandidate, assessment: "available", busy, agents, repair, landGatePending, queueWait,
|
|
150
150
|
actAfter: named?.actAfter ?? null, waitingOn: named?.waitingOn ?? [],
|
|
151
151
|
blocker: shown?.blocker ?? null, bounceKind: shown?.bounceKind ?? null,
|
|
152
152
|
prNumber: shown?.prNumber ?? held?.prNumber ?? currentCandidate?.prNumber ?? null,
|
package/dist/ui/banner.js
CHANGED
|
@@ -136,6 +136,16 @@ const WHATS_NEW = {
|
|
|
136
136
|
"stack watch names an abandoned review unit",
|
|
137
137
|
"stack watch names Auto land's mergeability wait",
|
|
138
138
|
],
|
|
139
|
+
"0.3.35": [
|
|
140
|
+
"stack watch names why Auto land is waiting",
|
|
141
|
+
"stack watch names a stuck seam review",
|
|
142
|
+
"stack watch names findings Cyclone left for you",
|
|
143
|
+
],
|
|
144
|
+
"0.3.36": [
|
|
145
|
+
"stack watch names what the merge queue waits on",
|
|
146
|
+
"stack watch names why Vortex skipped a head",
|
|
147
|
+
"stack watch says how to re-run Tempest",
|
|
148
|
+
],
|
|
139
149
|
};
|
|
140
150
|
function densityFromVariant(variant) {
|
|
141
151
|
if (variant === "mini" || variant === "nano")
|
package/package.json
CHANGED
|
@@ -35,9 +35,9 @@ Call `stack_status` with `stack_id`. Require that stack's `autoEnqueueWhenReady`
|
|
|
35
35
|
|
|
36
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.
|
|
37
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. 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. `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.
|
|
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. 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). 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 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
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.
|
|
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 `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.
|
|
41
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
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
43
|
|