mergestorm 0.3.62 → 0.3.64

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.
@@ -1,7 +1,7 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { getStackPublication, submitStackPublication, } from "./api.js";
3
3
  import { loadConfig } from "./config.js";
4
- import { CommandError } from "./errors.js";
4
+ import { CommandError, isCommandErrorCode } from "./errors.js";
5
5
  import { parseGithubOriginRepo } from "./git.js";
6
6
  import { currentBranch, discoverTrunk, tipCommitMessage, tipCommitSubject } from "./git-stack.js";
7
7
  import { buildPrBodyFromCommit } from "./pr-body.js";
@@ -12,26 +12,30 @@ export const SERVER_PUBLISH_MAX_TOTAL_BYTES = 8 * 1024 * 1024;
12
12
  export const SERVER_PUBLISH_MAX_COMMIT_BYTES = 1024 * 1024;
13
13
  export const SERVER_PUBLISH_FILE_MODES = ["100644", "100755", "120000"];
14
14
  export const SERVER_PUBLISH_HISTORY_SCAN = 500;
15
- const READ_ONLY_GIT = new Set(["cat-file", "diff-tree", "merge-base", "rev-list", "rev-parse", "symbolic-ref"]);
15
+ export const READ_ONLY_GIT = new Set(["cat-file", "diff-tree", "merge-base", "rev-list", "rev-parse", "symbolic-ref"]);
16
16
  export function readGit(args, cwd) {
17
17
  if (!READ_ONLY_GIT.has(args[0] ?? "")) {
18
18
  throw new Error(`server-side publication only reads the repository; refusing git ${args[0] ?? ""}`);
19
19
  }
20
20
  const result = spawnSync("git", args, { cwd, maxBuffer: 256 * 1024 * 1024 });
21
+ if (result.error)
22
+ throw result.error;
21
23
  if (result.status !== 0) {
22
- throw new Error(result.stderr?.toString("utf8").trim() || `git ${args.join(" ")} failed`);
24
+ throw new CommandError(result.stderr?.toString("utf8").trim() || `git ${args.join(" ")} failed`, 1, "git_failed");
23
25
  }
24
26
  return result.stdout;
25
27
  }
26
- function text(read, args, cwd) {
27
- return read(args, cwd).toString("utf8").trim();
28
+ async function text(read, args, cwd) {
29
+ return (await read(args, cwd)).toString("utf8").trim();
28
30
  }
29
- function resolves(read, ref, cwd) {
31
+ async function resolves(read, ref, cwd) {
30
32
  try {
31
- return text(read, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], cwd) || null;
33
+ return await text(read, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], cwd) || null;
32
34
  }
33
- catch {
34
- return null;
35
+ catch (err) {
36
+ if (isCommandErrorCode(err, "git_failed"))
37
+ return null;
38
+ throw err;
35
39
  }
36
40
  }
37
41
  function isoWithOffset(epochSeconds, offset) {
@@ -52,12 +56,12 @@ function parseIdentity(line, sha) {
52
56
  }
53
57
  return { name: match[1].trim() ? match[1] : match[2], email: match[2], date };
54
58
  }
55
- export function readCommitHeader(read, sha, cwd) {
56
- const commitBytes = Number(text(read, ["cat-file", "-s", sha], cwd));
59
+ export async function readCommitHeader(read, sha, cwd) {
60
+ const commitBytes = Number(await text(read, ["cat-file", "-s", sha], cwd));
57
61
  if (commitBytes > SERVER_PUBLISH_MAX_COMMIT_BYTES) {
58
62
  throw new CommandError(`Commit ${sha} is ${commitBytes} bytes; server-side publication reads commits up to ${SERVER_PUBLISH_MAX_COMMIT_BYTES} bytes.`, 1, "payload_too_large");
59
63
  }
60
- const commitObject = read(["cat-file", "commit", sha], cwd);
64
+ const commitObject = await read(["cat-file", "commit", sha], cwd);
61
65
  let raw;
62
66
  try {
63
67
  raw = new TextDecoder("utf-8", { fatal: true }).decode(commitObject);
@@ -87,8 +91,8 @@ export function readCommitHeader(read, sha, cwd) {
87
91
  }
88
92
  return { tree, parents, author: parseIdentity(author, sha), committer: parseIdentity(committer, sha), message };
89
93
  }
90
- export function readCommitChanges(read, parent, sha, cwd, budget) {
91
- const listing = read(["diff-tree", "-r", "-z", "--raw", "--no-renames", "--no-commit-id", "--abbrev=40", parent, sha], cwd);
94
+ export async function readCommitChanges(read, parent, sha, cwd, budget) {
95
+ const listing = await read(["diff-tree", "-r", "-z", "--raw", "--no-renames", "--no-commit-id", "--abbrev=40", parent, sha], cwd);
92
96
  let raw;
93
97
  try {
94
98
  raw = new TextDecoder("utf-8", { fatal: true }).decode(listing);
@@ -114,7 +118,7 @@ export function readCommitChanges(read, parent, sha, cwd, budget) {
114
118
  throw new CommandError(`${path} has Git mode ${newMode}, which server-side publication does not support` +
115
119
  `${newMode === "160000" ? " (it is a submodule)" : ""}. Supported modes are ${SERVER_PUBLISH_FILE_MODES.join(", ")}.`, 1, "unsupported_publication");
116
120
  }
117
- const size = Number(text(read, ["cat-file", "-s", newSha], cwd));
121
+ const size = Number(await text(read, ["cat-file", "-s", newSha], cwd));
118
122
  if (size > SERVER_PUBLISH_MAX_FILE_BYTES) {
119
123
  throw new CommandError(`${path} is ${size} bytes; server-side publication carries files up to ${SERVER_PUBLISH_MAX_FILE_BYTES} bytes.`, 1, "payload_too_large");
120
124
  }
@@ -122,30 +126,32 @@ export function readCommitChanges(read, parent, sha, cwd, budget) {
122
126
  if (budget.bytes > SERVER_PUBLISH_MAX_TOTAL_BYTES) {
123
127
  throw new CommandError(`More than ${SERVER_PUBLISH_MAX_TOTAL_BYTES} bytes of file content; server-side publication cannot carry this branch in one request.`, 1, "payload_too_large");
124
128
  }
125
- const content = read(["cat-file", "blob", newSha], cwd);
129
+ const content = await read(["cat-file", "blob", newSha], cwd);
126
130
  changes.push({ path, mode: newMode, sha: newSha, content: content.toString("base64") });
127
131
  }
128
132
  return changes;
129
133
  }
130
- export function readPublicationCommits(read, from, to, cwd) {
134
+ export async function readPublicationCommits(read, from, to, cwd) {
131
135
  try {
132
- read(["merge-base", "--is-ancestor", from, to], cwd);
136
+ await read(["merge-base", "--is-ancestor", from, to], cwd);
133
137
  }
134
- catch {
138
+ catch (err) {
139
+ if (!isCommandErrorCode(err, "git_failed"))
140
+ throw err;
135
141
  throw new CommandError(`${from} is not an ancestor of ${to}; the commits to publish are not a straight line.`, 1, "unsupported_publication");
136
142
  }
137
- if (text(read, ["rev-list", "--min-parents=2", "--max-count=1", `${from}..${to}`], cwd)) {
143
+ if (await text(read, ["rev-list", "--min-parents=2", "--max-count=1", `${from}..${to}`], cwd)) {
138
144
  throw new CommandError(`A merge commit sits between ${from} and ${to}. Server-side publication carries single-parent commits only; rebase or squash the branch.`, 1, "unsupported_publication");
139
145
  }
140
- const shas = text(read, ["rev-list", "--reverse", `${from}..${to}`], cwd).split("\n").filter(Boolean);
146
+ const shas = (await text(read, ["rev-list", "--reverse", `${from}..${to}`], cwd)).split("\n").filter(Boolean);
141
147
  if (shas.length > SERVER_PUBLISH_MAX_COMMITS) {
142
148
  throw new CommandError(`${shas.length} commits to publish; server-side publication carries at most ${SERVER_PUBLISH_MAX_COMMITS} at once. Squash the branch.`, 1, "payload_too_large");
143
149
  }
144
150
  const commits = [];
145
151
  const budget = { changes: 0, bytes: 0 };
146
152
  for (const sha of shas) {
147
- const header = readCommitHeader(read, sha, cwd);
148
- const changes = readCommitChanges(read, header.parents[0], sha, cwd, budget);
153
+ const header = await readCommitHeader(read, sha, cwd);
154
+ const changes = await readCommitChanges(read, header.parents[0], sha, cwd, budget);
149
155
  commits.push({
150
156
  message: header.message,
151
157
  author: header.author,
@@ -156,8 +162,8 @@ export function readPublicationCommits(read, from, to, cwd) {
156
162
  }
157
163
  return commits;
158
164
  }
159
- function localCommitWithTree(read, from, branch, head, cwd) {
160
- const lines = text(read, ["rev-list", `--max-count=${SERVER_PUBLISH_HISTORY_SCAN}`, "--format=%H %T", "--no-commit-header", `${from}..${branch}`], cwd)
165
+ async function localCommitWithTree(read, from, branch, head, cwd) {
166
+ const lines = (await text(read, ["rev-list", `--max-count=${SERVER_PUBLISH_HISTORY_SCAN}`, "--format=%H %T", "--no-commit-header", `${from}..${branch}`], cwd))
161
167
  .split("\n")
162
168
  .filter(Boolean);
163
169
  let match = null;
@@ -176,21 +182,23 @@ export async function publishBranchServerSide(input, deps = {}) {
176
182
  const status = deps.getStackPublication ?? getStackPublication;
177
183
  const submit = deps.submitStackPublication ?? submitStackPublication;
178
184
  const { cfg, cwd, owner, repo, branch } = input;
179
- if (!resolves(read, branch, cwd))
180
- throw new CommandError(`Branch missing locally: ${branch}`);
185
+ const local = input.localRef ?? branch;
186
+ if (!(await resolves(read, local, cwd)))
187
+ throw new CommandError(`Branch missing locally: ${local}`);
181
188
  const remote = await status(cfg, { owner, repo, branch });
182
189
  const baseBranch = remote.pr?.base ?? input.baseBranch;
190
+ deps.assertBase?.(baseBranch);
183
191
  const localBase = input.localBaseRef ??
184
- (resolves(read, `refs/remotes/origin/${input.baseBranch}`, cwd) ? `refs/remotes/origin/${input.baseBranch}` : input.baseBranch);
185
- if (!resolves(read, localBase, cwd))
192
+ ((await resolves(read, `refs/remotes/origin/${input.baseBranch}`, cwd)) ? `refs/remotes/origin/${input.baseBranch}` : input.baseBranch);
193
+ if (!(await resolves(read, localBase, cwd)))
186
194
  throw new CommandError(`Base branch missing locally: ${localBase}`);
187
- const fork = text(read, ["merge-base", localBase, branch], cwd);
195
+ const fork = await text(read, ["merge-base", localBase, local], cwd);
188
196
  const baseSha = input.remoteBaseSha ?? fork;
189
197
  let onto = "base";
190
198
  let expectedHead = null;
191
199
  let from = fork;
192
200
  if (remote.head) {
193
- const published = remote.head.tree ? localCommitWithTree(read, fork, branch, { sha: remote.head.sha, tree: remote.head.tree }, cwd) : null;
201
+ const published = remote.head.tree ? await localCommitWithTree(read, fork, local, { sha: remote.head.sha, tree: remote.head.tree }, cwd) : null;
194
202
  if (published) {
195
203
  onto = "head";
196
204
  expectedHead = remote.head.sha;
@@ -208,7 +216,7 @@ export async function publishBranchServerSide(input, deps = {}) {
208
216
  `To replace that head with your local branch, pass --replace-head ${remote.head.sha}.`, 1, "head_changed");
209
217
  }
210
218
  }
211
- const commits = readPublicationCommits(read, from, branch, cwd);
219
+ const commits = await readPublicationCommits(read, from, local, cwd);
212
220
  if (commits.length === 0 && onto === "base") {
213
221
  throw new CommandError(`No commits on \`${branch}\` ahead of \`${input.baseBranch}\`. Commit your changes, then retry.`);
214
222
  }
@@ -223,12 +231,19 @@ export async function publishBranchServerSide(input, deps = {}) {
223
231
  pr: { title: input.title, body: input.body },
224
232
  ...(typeof input.autoLand === "boolean" ? { autoLand: input.autoLand } : {}),
225
233
  });
226
- const localTree = text(read, ["rev-parse", `${branch}^{tree}`], cwd);
234
+ const localTree = await text(read, ["rev-parse", `${local}^{tree}`], cwd);
227
235
  if (publication.head.tree !== localTree) {
228
236
  throw new CommandError(`GitHub has tree ${publication.head.tree} at ${branch}, but your local branch has tree ${localTree}.`, 1, "tree_mismatch");
229
237
  }
230
238
  return { ...publication, baseBranch };
231
239
  }
240
+ export function assertExplicitStacking(input) {
241
+ if (input.extend || input.parent === input.trunk)
242
+ return;
243
+ throw new CommandError(`\`${input.parent}\` is not trunk (\`${input.trunk}\`), and stacking onto it needs --extend.\n` +
244
+ `• Stack onto \`${input.parent}\`: ${input.stackCommand}\n` +
245
+ `• New stack from \`${input.trunk}\`: ${input.trunkCommand}`, 1, "implicit_stack");
246
+ }
232
247
  export function workingBranchTarget(input) {
233
248
  const cwd = input.cwd ?? process.cwd();
234
249
  const origin = input.owner && input.repo ? { owner: input.owner, repo: input.repo } : parseGithubOriginRepo(cwd);
@@ -242,17 +257,36 @@ export function workingBranchTarget(input) {
242
257
  }
243
258
  export async function publishWorkingBranch(input, deps = {}) {
244
259
  const { cwd, owner, repo, branch } = workingBranchTarget(input);
245
- let baseBranch = input.base;
260
+ const { publishBranchServerSide: publish = publishBranchServerSide, loadConfig: load = loadConfig, discoverTrunk: findTrunk = discoverTrunk, ...publishDeps } = deps;
261
+ let trunk = null;
262
+ let trunkError = null;
263
+ try {
264
+ trunk = findTrunk(cwd);
265
+ }
266
+ catch (err) {
267
+ trunkError = err;
268
+ }
269
+ const trunkMessage = trunkError instanceof Error ? trunkError.message : String(trunkError);
270
+ const baseBranch = input.base ?? trunk;
246
271
  if (!baseBranch) {
247
- try {
248
- baseBranch = discoverTrunk(cwd);
272
+ throw new CommandError(`${trunkMessage} Name the base branch.`);
273
+ }
274
+ const assertBase = (parent) => {
275
+ if (trunk) {
276
+ assertExplicitStacking({
277
+ parent,
278
+ trunk,
279
+ extend: input.extend === true,
280
+ stackCommand: `\`mg stack publish --base ${parent} --extend\` (MCP stack_publish: extend true)`,
281
+ trunkCommand: "`mg stack publish` with no --base (MCP stack_publish: no base)",
282
+ });
249
283
  }
250
- catch (err) {
251
- throw new CommandError(`${err instanceof Error ? err.message : String(err)} Name the base branch.`);
284
+ else if (input.extend !== true) {
285
+ throw new CommandError(`${trunkMessage} \`${parent}\` cannot be verified as trunk, so stacking onto it needs --extend.`, 1, "implicit_stack");
252
286
  }
253
- }
254
- const { publishBranchServerSide: publish = publishBranchServerSide, loadConfig: load = loadConfig, ...publishDeps } = deps;
255
- return publish({
287
+ };
288
+ assertBase(baseBranch);
289
+ const result = await publish({
256
290
  cfg: input.cfg ?? (await load()),
257
291
  cwd,
258
292
  owner,
@@ -263,7 +297,8 @@ export async function publishWorkingBranch(input, deps = {}) {
263
297
  body: input.body ?? buildPrBodyFromCommit(tipCommitMessage(branch, cwd)),
264
298
  ...(typeof input.autoLand === "boolean" ? { autoLand: input.autoLand } : {}),
265
299
  ...(input.replaceHead ? { replaceHead: input.replaceHead } : {}),
266
- }, publishDeps);
300
+ }, { ...publishDeps, assertBase });
301
+ return { ...result, stacked: !trunk || result.baseBranch !== trunk };
267
302
  }
268
303
  export async function workingBranchPublication(input, deps = {}) {
269
304
  const { owner, repo, branch } = workingBranchTarget(input);
@@ -43,6 +43,7 @@ export type StackRepairHint = {
43
43
  branch: string;
44
44
  liveParent: string | null;
45
45
  files: string[];
46
+ publishedBy?: "breeze";
46
47
  steps: string;
47
48
  note?: string;
48
49
  } | {
@@ -74,6 +75,7 @@ export type StackRepairHint = {
74
75
  branch: string;
75
76
  liveParent: string | null;
76
77
  restackKind: string;
78
+ publishedBy?: "breeze";
77
79
  steps: string;
78
80
  } | {
79
81
  kind: "seam_review_stuck";
@@ -170,6 +172,8 @@ export declare const SEAM_REVIEW_STUCK = "Seam review stuck in reviewing, no rev
170
172
  export declare const SEAM_REREVIEW_NOT_RUNNING = "Seam re-review pending, no review running";
171
173
  export declare const SEAM_VERDICT_STALE = "Seam verdict is for an older head, no review running";
172
174
  export declare const VORTEX_FINDINGS_NEED_PERSON = "Vortex findings need a person";
175
+ export declare const CYCLONE_NOT_RUN_ATTENTION_MS: number;
176
+ export declare const CYCLONE_NOT_RUN_REASON = "not_run";
173
177
  export declare function cycloneStatusWords(layer: Pick<StackLayerDto, "cycloneStatus" | "cycloneHandoff" | "headSha">): string | null;
174
178
  export declare function autoResolveActive(stack: StackDto, layer: StackLayerDto): boolean;
175
179
  export declare function autoFixCiActive(stack: StackDto, layer: StackLayerDto): boolean;
@@ -211,12 +215,15 @@ export type MergeQueueWait = {
211
215
  export declare function mergeQueueWait(queue: readonly MergeQueueEntryDto[], nowMs: number): MergeQueueWait | null;
212
216
  export declare function conflictLiveParent(stack: StackDto, layer: StackLayerDto): string | null;
213
217
  export declare function conflictRepairSteps(branch: string, liveParent: string | null): string;
218
+ export declare const BREEZE_BRANCH_PREFIX = "breeze/";
219
+ export declare function isBreezeBranch(branch: string | null | undefined): boolean;
220
+ export declare function breezeConflictRepairSteps(branch: string, liveParent: string | null, prNumber: number): string;
214
221
  export declare function restackFailedRepairSteps(branch: string, liveParent: string | null, failure: {
215
222
  kind: string;
216
223
  detail?: string | null;
217
- }): string;
224
+ }, prNumber?: number): string;
218
225
  export declare function agentsCannotClear(reason: StackBlockerReason, stack?: StackDto, layer?: StackLayerDto): boolean;
219
- export declare function stackRepair(stack: StackDto, attention: ClassifiedStackBlocker | null, layer: StackLayerDto | undefined, bounce: MergeQueueEntryDto | undefined, queueWait?: MergeQueueWait | null): StackRepairHint | null;
226
+ export declare function stackRepair(stack: StackDto, attention: ClassifiedStackBlocker | null, layer: StackLayerDto | undefined, bounce: MergeQueueEntryDto | undefined, queueWait?: MergeQueueWait | null, nowMs?: number): StackRepairHint | null;
220
227
  export declare const LAND_PR_MISSING_PREFIX = "No open land PR for ";
221
228
  /** Pair-gate attention plus live hard blocks on every other open layer. */
222
229
  export declare function stackBlockers(stack: StackDto, entries?: MergeQueueEntryDto[], cursor?: Pick<StackWatchCursor, "bounceId" | "afterFinishedAt">, nowMs?: number): {
@@ -72,10 +72,26 @@ function vortexFindingsRestAtHead(layer) {
72
72
  function vortexChangesRequestedAtHead(layer) {
73
73
  return vortexFindingsRestAtHead(layer) && layer.reviewStatus === "changes_requested";
74
74
  }
75
- function cycloneHandoffAtHead(stack, layer) {
75
+ export const CYCLONE_NOT_RUN_ATTENTION_MS = 30 * 60_000;
76
+ export const CYCLONE_NOT_RUN_REASON = "not_run";
77
+ function cycloneNotRunAtHead(layer, nowMs) {
78
+ const review = layer.vortexReview;
79
+ if (layer.cycloneFindingsTakenAtHead !== false || layer.vortexStatus !== "findings")
80
+ return false;
81
+ if (review?.status !== "done" || !sameHead(review.head_sha, layer.headSha))
82
+ return false;
83
+ const settledMs = Date.parse(review.settled_at ?? "");
84
+ return Number.isFinite(settledMs) && nowMs - settledMs >= CYCLONE_NOT_RUN_ATTENTION_MS;
85
+ }
86
+ function cycloneHandoffAtHead(stack, layer, nowMs) {
76
87
  if (sameHead(layer.cycloneHandoff?.headSha, layer.headSha))
77
88
  return layer.cycloneHandoff.reason;
78
- return stack.cyclonePatchOff ?? (stack.autoPatchOverride === false ? "auto_patch_off" : null);
89
+ if (layer.cycloneStatus === "failed")
90
+ return "failed";
91
+ const off = stack.cyclonePatchOff ?? (stack.autoPatchOverride === false ? "auto_patch_off" : null);
92
+ if (off)
93
+ return off;
94
+ return cycloneNotRunAtHead(layer, nowMs) ? CYCLONE_NOT_RUN_REASON : null;
79
95
  }
80
96
  const CYCLONE_HANDOFF_WORDS = {
81
97
  failed: "Cyclone's patch failed, so it pushed nothing",
@@ -93,6 +109,11 @@ const CYCLONE_HANDOFF_WORDS = {
93
109
  unsupported_language: "the findings are in a language Cyclone does not patch",
94
110
  policy_owner_changed: "the stack's owner changed while the patch was queued",
95
111
  patch_job_lost: "Cyclone's patch job was lost before it finished",
112
+ pr_state_unavailable: "Cyclone could not read the PR's state, so it pushed nothing",
113
+ left_for_human: "Cyclone did not patch these findings and pushed nothing",
114
+ hold: "the review told Cyclone to hold, so it did not patch these findings",
115
+ vortex_patch_cap: "Cyclone already patched this PR as many times as it may",
116
+ [CYCLONE_NOT_RUN_REASON]: `Cyclone has not run on this head in the ${CYCLONE_NOT_RUN_ATTENTION_MS / 60_000} minutes since Vortex posted these findings, and nothing is patching it`,
96
117
  conflict_needs_person: "Cyclone could not resolve the conflict and left it for a person",
97
118
  conflict_unresolved: "Cyclone's conflict resolution failed",
98
119
  auto_resolve_daily_cap: "Cyclone already resolved conflicts on this PR twice today, its daily cap",
@@ -179,6 +200,10 @@ const CYCLONE_HELD_FINDINGS_REASONS = [
179
200
  "pr_state_unavailable",
180
201
  "vortex_patch_cap",
181
202
  "patch_job_lost",
203
+ "unsupported_language",
204
+ "credits_exhausted",
205
+ "no_billing",
206
+ "policy_owner_changed",
182
207
  ];
183
208
  function vortexImportantFindingsHeldAtHead(layer) {
184
209
  return vortexFindingsRestAtHead(layer) && layer.reviewStatus === "reviewed" &&
@@ -189,13 +214,13 @@ function seamApprovedAtHead(stack, layer) {
189
214
  const member = (stack.unit?.members ?? []).find((entry) => entry.prNumber === layer.prNumber);
190
215
  return member?.seamState?.trim().toLowerCase() === "approved" && sameHead(member.seamReviewedSha, layer.headSha);
191
216
  }
192
- function vortexFindingsLeftForPerson(stack, layer) {
217
+ function vortexFindingsLeftForPerson(stack, layer, nowMs) {
193
218
  if (layerAgentsBusy(layer).cyclone)
194
219
  return false;
195
220
  if (seamApprovedAtHead(stack, layer))
196
221
  return false;
197
222
  return vortexImportantFindingsHeldAtHead(layer) ||
198
- (vortexChangesRequestedAtHead(layer) && cycloneHandoffAtHead(stack, layer) !== null);
223
+ (vortexChangesRequestedAtHead(layer) && cycloneHandoffAtHead(stack, layer, nowMs) !== null);
199
224
  }
200
225
  function quotaSkippedReview(layer) {
201
226
  const review = layer.vortexReview;
@@ -310,7 +335,7 @@ function headChecksRerunning(layer) {
310
335
  return layer.ciStatus === "pending" && !!checks && checks.failure === 0 && checks.pending > 0 && checks.cancelledOnly !== true;
311
336
  }
312
337
  /** Shared live-fact precedence; bounce history is only passed for the candidate. */
313
- function layerAttention(stack, layer, queue, cursor) {
338
+ function layerAttention(stack, layer, queue, cursor, nowMs) {
314
339
  const none = { blocker: null, reason: null, bounceKind: null };
315
340
  if (stack.archivedAt)
316
341
  return none;
@@ -363,7 +388,7 @@ function layerAttention(stack, layer, queue, cursor) {
363
388
  if (landing && !landGateIsPending(landing))
364
389
  return hardBlock(landing, "land_gate");
365
390
  }
366
- if (vortexFindingsLeftForPerson(stack, layer))
391
+ if (vortexFindingsLeftForPerson(stack, layer, nowMs))
367
392
  return hardBlock(VORTEX_FINDINGS_NEED_PERSON, "vortex_findings_need_person");
368
393
  }
369
394
  if (!bounce || !kind || recoverable || !layer || queue.some((entry) => ["queued", "running", "waiting"].includes(entry.state)))
@@ -654,11 +679,22 @@ export function conflictRepairSteps(branch, liveParent) {
654
679
  }
655
680
  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.`;
656
681
  }
657
- export function restackFailedRepairSteps(branch, liveParent, failure) {
682
+ export const BREEZE_BRANCH_PREFIX = "breeze/";
683
+ export function isBreezeBranch(branch) {
684
+ return typeof branch === "string" && branch.startsWith(BREEZE_BRANCH_PREFIX);
685
+ }
686
+ export function breezeConflictRepairSteps(branch, liveParent, prNumber) {
687
+ if (!liveParent)
688
+ return conflictRepairSteps(branch, liveParent);
689
+ return `${branch} is a Breeze branch: Breeze updates it only with open_pull_request, which publishes single-parent commits, so it cannot publish a merge of ${liveParent}. Rebase it in the Breeze chat that opened #${prNumber}: fetch ${liveParent} with fetch_repository, git rebase origin/${liveParent}, resolve the conflicts, run the tests, then call open_pull_request again. Or have Cyclone resolve it: cyclone_start with task resolve_conflicts and pr_number ${prNumber}. Do not retarget the PR base.`;
690
+ }
691
+ export function restackFailedRepairSteps(branch, liveParent, failure, prNumber) {
658
692
  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.`;
659
693
  if (!liveParent) {
660
694
  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.`;
661
695
  }
696
+ if (prNumber !== undefined && isBreezeBranch(branch))
697
+ return `${why} ${breezeConflictRepairSteps(branch, liveParent, prNumber)}`;
662
698
  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.`;
663
699
  }
664
700
  function landPrLiveParent(stack, layer) {
@@ -682,7 +718,7 @@ export function agentsCannotClear(reason, stack, layer) {
682
718
  return false;
683
719
  return AGENTS_CANNOT_CLEAR.includes(reason);
684
720
  }
685
- export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
721
+ export function stackRepair(stack, attention, layer, bounce, queueWait = null, nowMs = Date.now()) {
686
722
  if (!attention || !layer || attention.prNumber !== layer.prNumber)
687
723
  return null;
688
724
  const reason = attention.reason;
@@ -700,6 +736,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
700
736
  const landPr = !!stack.unit?.branch && layer.prNumber === stack.unit.landPrNumber && layer.branch === stack.unit.branch;
701
737
  const restackConflict = reason === "restack_conflict" || attention.bounceKind === "restack_conflict";
702
738
  const mergeConflict = reason === "merge_conflict";
739
+ const breeze = !landPr && isBreezeBranch(layer.branch);
703
740
  if (restackConflict || mergeConflict) {
704
741
  const liveParent = landPr ? landPrLiveParent(stack, layer) : conflictLiveParent(stack, layer);
705
742
  const note = conflictHandoffNote(stack, layer);
@@ -710,7 +747,8 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
710
747
  branch: layer.branch,
711
748
  liveParent,
712
749
  files: conflictFiles(layer, bounce),
713
- steps: conflictRepairSteps(layer.branch, liveParent),
750
+ ...(breeze ? { publishedBy: "breeze" } : {}),
751
+ steps: breeze ? breezeConflictRepairSteps(layer.branch, liveParent, layer.prNumber) : conflictRepairSteps(layer.branch, liveParent),
714
752
  ...(note ? { note } : {}),
715
753
  };
716
754
  }
@@ -723,7 +761,8 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
723
761
  branch: layer.branch,
724
762
  liveParent,
725
763
  restackKind: layer.restackError.kind,
726
- steps: restackFailedRepairSteps(layer.branch, liveParent, layer.restackError),
764
+ ...(breeze ? { publishedBy: "breeze" } : {}),
765
+ steps: restackFailedRepairSteps(layer.branch, liveParent, layer.restackError, layer.prNumber),
727
766
  };
728
767
  }
729
768
  if (reason === "unit_abandoned") {
@@ -866,7 +905,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
866
905
  }
867
906
  if (reason === "vortex_findings_need_person") {
868
907
  const findings = `gh api repos/${stack.owner}/${stack.repo}/pulls/${layer.prNumber}/reviews`;
869
- const handoff = cycloneHandoffAtHead(stack, layer);
908
+ const handoff = cycloneHandoffAtHead(stack, layer, nowMs);
870
909
  return {
871
910
  kind: "vortex_findings",
872
911
  prNumber: layer.prNumber,
@@ -874,7 +913,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
874
913
  branch: layer.branch,
875
914
  handoff,
876
915
  findings,
877
- steps: `Vortex ${vortexImportantFindingsHeldAtHead(layer) ? "left Important findings" : "requested changes"} on ${layer.branch} at ${layer.headSha ?? "its live head"} and Cyclone left them for a person${handoff ? ` (${cycloneHandoffWords(handoff, cycloneHandoffDetailAtHead(layer))})` : ""}, 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.`,
916
+ steps: `Vortex ${vortexImportantFindingsHeldAtHead(layer) ? "left Important findings" : "requested changes"} on ${layer.branch} at ${layer.headSha ?? "its live head"} and Cyclone left them undone${handoff ? ` (${cycloneHandoffWords(handoff, cycloneHandoffDetailAtHead(layer))})` : ""}, so nothing will patch them and Auto land waits. You fix them: 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). Dismiss a finding you verified is wrong yourself: mg dismiss ${stack.owner}/${stack.repo}#${layer.prNumber} --head <sha> --review <id> --finding <id> --reason <why> --evidence <link>. Ask the human only for a product or design decision. Do not retarget the PR base.`,
878
917
  };
879
918
  }
880
919
  if (reason === "ci_failed" || attention.bounceKind === "ci_failure") {
@@ -964,7 +1003,7 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
964
1003
  for (const layer of open) {
965
1004
  const selected = layer.prNumber === candidate?.prNumber;
966
1005
  const inScope = selected || !candidate || layer.position > candidate.position;
967
- let result = layerAttention(stack, layer, selected ? queue : [], cursor);
1006
+ let result = layerAttention(stack, layer, selected ? queue : [], cursor, nowMs);
968
1007
  let disarmed;
969
1008
  if (!result.blocker && selected) {
970
1009
  disarmed = disarmingBounce(stack, queue);
@@ -1071,7 +1110,7 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
1071
1110
  if (busy.length && attention && classified && agentsCannotClear(classified.reason, stack, attentionLayer)) {
1072
1111
  held = { ...attention, actAfter: "agents_idle", waitingOn: busy.map((entry) => entry.agent) };
1073
1112
  shownIssues = [attention, ...issues];
1074
- repair = stackRepair(stack, classified, attentionLayer, undefined, queueWait);
1113
+ repair = stackRepair(stack, classified, attentionLayer, undefined, queueWait, nowMs);
1075
1114
  attention = null;
1076
1115
  bounce = undefined;
1077
1116
  }
@@ -1081,7 +1120,7 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
1081
1120
  bounce = undefined;
1082
1121
  }
1083
1122
  else {
1084
- repair = landMissing?.repair ?? stackRepair(stack, classified, attentionLayer, bounce, queueWait);
1123
+ repair = landMissing?.repair ?? stackRepair(stack, classified, attentionLayer, bounce, queueWait, nowMs);
1085
1124
  }
1086
1125
  if (!attention) {
1087
1126
  const next = found.find((item) => !layerBusy(item.layer));
@@ -1089,7 +1128,7 @@ export function stackBlockers(stack, entries = [], cursor = {}, nowMs = Date.now
1089
1128
  attention = next.issue;
1090
1129
  repair = next.repair !== undefined
1091
1130
  ? next.repair
1092
- : stackRepair(stack, { ...next.issue, reason: next.reason }, next.layer, next.bounce, queueWait);
1131
+ : stackRepair(stack, { ...next.issue, reason: next.reason }, next.layer, next.bounce, queueWait, nowMs);
1093
1132
  bounce = next.bounce;
1094
1133
  if (next.layer)
1095
1134
  agents = layerAgents(next.layer);
@@ -59,6 +59,7 @@ export type StackVortexReviewView = {
59
59
  phase: string | null;
60
60
  /** Earliest live job start while `reviewing`; null when lease-only. */
61
61
  started_at: string | null;
62
+ settled_at?: string | null;
62
63
  /** Stop control visibility: true only while a pass can actually be cancelled. */
63
64
  stoppable: boolean;
64
65
  /** Which table spoke: the authority row, a live job, a lease, or nothing. */
@@ -225,6 +226,7 @@ export type StackLayerDto = {
225
226
  cycloneConflictPendingSince?: string | null;
226
227
  cycloneCiHandoff?: StackCycloneHandoff | null;
227
228
  cycloneCiPendingSince?: string | null;
229
+ cycloneFindingsTakenAtHead?: boolean;
228
230
  conflictDetail: string | null;
229
231
  restackError?: RestackError | null;
230
232
  lastRestackedSha: string | null;
@@ -495,6 +497,7 @@ export type MergeQueueEntryDto = {
495
497
  verifyBaseSha: string | null;
496
498
  cancelRequestedAt?: string | null;
497
499
  batch?: MergeQueueEntryBatchDto | null;
500
+ ci?: MergeQueueBatchCiDto | null;
498
501
  finishedAt: string | null;
499
502
  claimedAt?: string | null;
500
503
  aheadInRepo?: MergeQueueAheadDto | null;
@@ -171,9 +171,17 @@ export async function runStackWatch(cfg, stackId, opts = {}) {
171
171
  await pause(STACK_WATCH_FAILED_RETRY_MS);
172
172
  return null;
173
173
  };
174
+ const unreadTooLong = (message, envelope, since) => {
175
+ transientSince ??= since;
176
+ if (now() - transientSince < STACK_WATCH_TRANSIENT_WINDOW_MS)
177
+ return null;
178
+ emit(stackWatchFailedLine(id, `No stack read for 15 minutes: ${message}`), envelope, "failed");
179
+ return { outcome: "failed", exitCode: STACK_WATCH_EXIT.attention, envelope };
180
+ };
174
181
  try {
175
182
  while (now() < deadline) {
176
183
  let envelope;
184
+ const sliceStartedAt = now();
177
185
  try {
178
186
  envelope = await poll(cfg, id, {
179
187
  timeoutMs: Math.min(STACK_WATCH_SLICE_MS, Math.max(0, deadline - now())),
@@ -189,8 +197,13 @@ export async function runStackWatch(cfg, stackId, opts = {}) {
189
197
  cursor = err.lastEnvelope.cursor;
190
198
  failures = 0;
191
199
  permanentFailures = 0;
192
- if (err.lastEnvelope.assessment === "available")
200
+ if (err.lastEnvelope.status !== "failed")
193
201
  transientSince = null;
202
+ else {
203
+ const unread = unreadTooLong("No stack snapshot was read in a whole watch slice", err.lastEnvelope, sliceStartedAt);
204
+ if (unread)
205
+ return unread;
206
+ }
194
207
  continue;
195
208
  }
196
209
  if (err instanceof StackWatchError && err.lastEnvelope.watch.done) {
@@ -198,7 +211,15 @@ export async function runStackWatch(cfg, stackId, opts = {}) {
198
211
  return await finished(err.lastEnvelope);
199
212
  }
200
213
  if (err instanceof StackWatchError && err.lastEnvelope.status === "rate_limited") {
201
- await pause(err.retryAfterSeconds !== undefined ? Math.max(1_000, err.retryAfterSeconds * 1000) : STACK_WATCH_RATE_LIMIT_WAIT_MS);
214
+ if (err.readSnapshot)
215
+ transientSince = now();
216
+ else {
217
+ const unread = unreadTooLong(err.message, err.lastEnvelope, sliceStartedAt);
218
+ if (unread)
219
+ return unread;
220
+ }
221
+ const retryAfterMs = err.retryAfterSeconds !== undefined ? Math.max(1_000, err.retryAfterSeconds * 1000) : STACK_WATCH_RATE_LIMIT_WAIT_MS;
222
+ await pause(Math.min(retryAfterMs, Math.max(1_000, transientSince + STACK_WATCH_TRANSIENT_WINDOW_MS - now())));
202
223
  continue;
203
224
  }
204
225
  const result = await failed(err instanceof Error ? err.message : String(err), err instanceof StackWatchError ? err.lastEnvelope : null, isTransientWatchFailure(err));
@@ -58,7 +58,8 @@ export declare class StackWatchTimeoutError extends Error {
58
58
  export declare class StackWatchError extends Error {
59
59
  readonly lastEnvelope: StackWatchEnvelope;
60
60
  readonly retryAfterSeconds?: number | undefined;
61
- constructor(message: string, lastEnvelope: StackWatchEnvelope, options?: ErrorOptions, retryAfterSeconds?: number | undefined);
61
+ readonly readSnapshot: boolean;
62
+ constructor(message: string, lastEnvelope: StackWatchEnvelope, options?: ErrorOptions, retryAfterSeconds?: number | undefined, readSnapshot?: boolean);
62
63
  }
63
64
  /** Wait on queue invalidations, then evaluate a fresh stack snapshot. */
64
65
  export declare function pollStackWatch(cfg: Config, stackId: string, opts?: PollStackWatchOptions): Promise<StackWatchEnvelope>;
@@ -20,10 +20,12 @@ export class StackWatchTimeoutError extends Error {
20
20
  export class StackWatchError extends Error {
21
21
  lastEnvelope;
22
22
  retryAfterSeconds;
23
- constructor(message, lastEnvelope, options, retryAfterSeconds) {
23
+ readSnapshot;
24
+ constructor(message, lastEnvelope, options, retryAfterSeconds, readSnapshot = false) {
24
25
  super(message, options);
25
26
  this.lastEnvelope = lastEnvelope;
26
27
  this.retryAfterSeconds = retryAfterSeconds;
28
+ this.readSnapshot = readSnapshot;
27
29
  this.name = "StackWatchError";
28
30
  }
29
31
  }
@@ -55,6 +57,7 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
55
57
  landGatePending: null,
56
58
  };
57
59
  let terminal = null;
60
+ let readSnapshot = false;
58
61
  const seal = (snapshot, retryAfterSeconds) => ({
59
62
  ...snapshot,
60
63
  watch: stackWatchObligation({
@@ -116,7 +119,7 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
116
119
  throw new StackWatchError(`Stack poll failed (HTTP ${response.status})`, seal({
117
120
  ...lastEnvelope, status: response.status === 429 ? "rate_limited" : "failed",
118
121
  assessment: "unavailable",
119
- }, response.retryAfterSeconds), undefined, response.retryAfterSeconds);
122
+ }, response.retryAfterSeconds), undefined, response.retryAfterSeconds, readSnapshot);
120
123
  }
121
124
  throw timeout();
122
125
  };
@@ -149,6 +152,7 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
149
152
  };
150
153
  const evaluate = (stack, entries) => {
151
154
  const { attention, held: named, issues, currentCandidate, bounce, busy, agents, repair, landGatePending, queueWait } = stackBlockers(stack, entries, cursor, now());
155
+ readSnapshot = true;
152
156
  if (bounce)
153
157
  cursor = Object.freeze({ ...cursor, bounceId: bounce.id,
154
158
  ...(bounce.finishedAt !== undefined ? { afterFinishedAt: bounce.finishedAt } : {}) });
package/dist/ui/banner.js CHANGED
@@ -224,6 +224,12 @@ const WHATS_NEW = {
224
224
  "0.3.62": [
225
225
  "Watch wakes for a blocker on any layer",
226
226
  ],
227
+ "0.3.63": [
228
+ "Stack create starts a new stack unless --extend",
229
+ ],
230
+ "0.3.64": [
231
+ "Memory commands and Overdrive merge batches",
232
+ ],
227
233
  };
228
234
  function densityFromVariant(variant) {
229
235
  if (variant === "mini" || variant === "nano")