mergestorm 0.3.63 → 0.3.66

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.
Files changed (79) hide show
  1. package/README.md +30 -10
  2. package/dist/api.d.ts +68 -11
  3. package/dist/api.js +206 -8
  4. package/dist/automation-catalog.d.ts +17 -3
  5. package/dist/automation-catalog.js +4 -1
  6. package/dist/cli.js +21 -19
  7. package/dist/client/index.d.ts +6 -4
  8. package/dist/client/index.js +6 -4
  9. package/dist/commands/account.d.ts +3 -0
  10. package/dist/commands/account.js +75 -0
  11. package/dist/commands/browse.d.ts +1 -9
  12. package/dist/commands/browse.js +10 -8
  13. package/dist/commands/dismiss.d.ts +0 -2
  14. package/dist/commands/dismiss.js +2 -2
  15. package/dist/commands/help.js +3 -2
  16. package/dist/commands/issue.d.ts +24 -6
  17. package/dist/commands/issue.js +153 -31
  18. package/dist/commands/login.d.ts +2 -1
  19. package/dist/commands/login.js +50 -14
  20. package/dist/commands/logout.js +6 -7
  21. package/dist/commands/memory.d.ts +29 -0
  22. package/dist/commands/memory.js +154 -0
  23. package/dist/commands/registry.d.ts +1 -0
  24. package/dist/commands/registry.js +52 -11
  25. package/dist/commands/review-client.d.ts +0 -3
  26. package/dist/commands/review-client.js +3 -3
  27. package/dist/commands/review.d.ts +1 -1
  28. package/dist/commands/review.js +1 -1
  29. package/dist/commands/settings.d.ts +3 -4
  30. package/dist/commands/settings.js +51 -5
  31. package/dist/commands/stack.d.ts +20 -5
  32. package/dist/commands/stack.js +106 -7
  33. package/dist/commands/whoami.js +14 -10
  34. package/dist/config.d.ts +56 -2
  35. package/dist/config.js +271 -13
  36. package/dist/errors.d.ts +1 -1
  37. package/dist/gh.d.ts +0 -4
  38. package/dist/gh.js +4 -4
  39. package/dist/git.d.ts +1 -1
  40. package/dist/git.js +2 -2
  41. package/dist/memory-api.d.ts +74 -0
  42. package/dist/memory-api.js +69 -0
  43. package/dist/node-version.d.ts +0 -2
  44. package/dist/node-version.js +1 -1
  45. package/dist/pr-body.d.ts +4 -0
  46. package/dist/pr-body.js +15 -0
  47. package/dist/repo-identity.d.ts +2 -1
  48. package/dist/repo-identity.js +8 -4
  49. package/dist/server-publish.d.ts +5 -26
  50. package/dist/server-publish.js +47 -40
  51. package/dist/stack-blockers.d.ts +15 -36
  52. package/dist/stack-blockers.js +69 -33
  53. package/dist/stack-dto.d.ts +4 -0
  54. package/dist/stack-dto.js +15 -2
  55. package/dist/stack-meta.d.ts +2 -2
  56. package/dist/stack-meta.js +1 -1
  57. package/dist/stack-watch-loop.d.ts +2 -13
  58. package/dist/stack-watch-loop.js +35 -14
  59. package/dist/stack-watch.d.ts +2 -1
  60. package/dist/stack-watch.js +6 -2
  61. package/dist/submit-pr-base.d.ts +0 -1
  62. package/dist/submit-pr-base.js +1 -1
  63. package/dist/ui/ansi.d.ts +0 -10
  64. package/dist/ui/ansi.js +2 -2
  65. package/dist/ui/banner.d.ts +3 -1
  66. package/dist/ui/banner.js +13 -3
  67. package/dist/ui/box.d.ts +0 -2
  68. package/dist/ui/box.js +1 -1
  69. package/dist/ui/prompt.d.ts +0 -11
  70. package/dist/ui/prompt.js +7 -7
  71. package/dist/ui/tabs.d.ts +2 -2
  72. package/dist/ui/tabs.js +1 -1
  73. package/dist/ui/width.d.ts +0 -9
  74. package/dist/ui/width.js +3 -3
  75. package/dist/web-api.d.ts +17 -0
  76. package/dist/web-api.js +30 -0
  77. package/package.json +3 -1
  78. package/skill/SKILL.md +4 -0
  79. package/skill/mergestorm-bounce-watch/SKILL.md +1 -1
@@ -6,8 +6,8 @@ export type StackBlocker = {
6
6
  blocker: string;
7
7
  bounceKind: string | null;
8
8
  };
9
- export type StackBlockerReason = "restack_conflict" | "restack_failed" | "draft_pr" | "merge_conflict" | "ci_failed" | "vortex_failed" | "vortex_out_of_quota" | "vortex_skipped" | "vortex_findings_need_person" | "seam_failed" | "seam_findings" | "seam_not_running" | "seam_review_stuck" | "seam_rereview_not_running" | "seam_verdict_stale" | "unit_abandoned" | "land_gate" | "bounce" | "auto_land_off" | "auto_land_switched_off" | "auto_land_ci_pending" | "auto_land_members_remaining" | "auto_land_vortex_wait" | "auto_land_promote_failed" | "auto_land_ci_unreadable" | "auto_land_waited" | "queue_stalled" | "land_pr_missing";
10
- export type ClassifiedStackBlocker = StackBlocker & {
9
+ export type StackBlockerReason = "restack_conflict" | "restack_failed" | "draft_pr" | "merge_conflict" | "ci_failed" | "vortex_failed" | "vortex_out_of_quota" | "vortex_skipped" | "vortex_findings_need_person" | "seam_failed" | "seam_findings" | "seam_not_running" | "seam_review_stuck" | "seam_rereview_not_running" | "seam_verdict_stale" | "unit_abandoned" | "land_gate" | "bounce" | "auto_land_off" | "auto_land_switched_off" | "auto_land_ci_pending" | "auto_land_members_remaining" | "auto_land_vortex_wait" | "auto_land_promote_failed" | "auto_land_ci_unreadable" | "auto_land_waited" | "queue_stalled" | "cyclone_stalled" | "land_pr_missing";
10
+ type ClassifiedStackBlocker = StackBlocker & {
11
11
  reason: StackBlockerReason;
12
12
  };
13
13
  export type StackAgentBusy = {
@@ -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";
@@ -111,6 +113,14 @@ export type StackRepairHint = {
111
113
  since: string;
112
114
  reason: string;
113
115
  steps: string;
116
+ } | {
117
+ kind: "cyclone_stalled";
118
+ prNumber: number;
119
+ headSha: string | null;
120
+ branch: string;
121
+ runSha: string | null;
122
+ since: string | null;
123
+ steps: string;
114
124
  } | {
115
125
  kind: "queue_stalled";
116
126
  prNumber: number;
@@ -157,42 +167,14 @@ export type StackLandGatePending = {
157
167
  export declare function landGateIsPending(reason: string | null | undefined): boolean;
158
168
  export declare function layerAgentsBusy(layer: StackLayerDto): StackAgentsBusy;
159
169
  export declare function layerAgents(layer: StackLayerDto): StackLayerAgents;
160
- export declare function isMgParkBase(branch: string | null | undefined): boolean;
161
- /** GitHub DIRTY vs an mg-park-* freeze is not a merge conflict, verified or not. */
162
- export declare function boundDirty(layer: StackLayerDto): boolean;
163
- export declare function mergeabilityPending(layer: StackLayerDto): boolean;
164
170
  export declare function sameHead(a: string | null | undefined, b: string | null | undefined): boolean;
165
171
  export declare function currentLayer(stack: StackDto): StackLayerDto | undefined;
166
- export declare const RESTACK_FAILED = "Restack failed";
167
- export declare const SEAM_REVIEW_NOT_RUNNING = "Seam review pending, no review running";
168
- export declare const VORTEX_REVIEW_OUT_OF_QUOTA = "Review skipped, out of review quota";
169
- export declare const SEAM_REVIEW_STUCK = "Seam review stuck in reviewing, no review running";
170
- export declare const SEAM_REREVIEW_NOT_RUNNING = "Seam re-review pending, no review running";
171
- export declare const SEAM_VERDICT_STALE = "Seam verdict is for an older head, no review running";
172
172
  export declare const VORTEX_FINDINGS_NEED_PERSON = "Vortex findings need a person";
173
- export declare const CYCLONE_NOT_RUN_ATTENTION_MS: number;
174
- export declare const CYCLONE_NOT_RUN_REASON = "not_run";
175
173
  export declare function cycloneStatusWords(layer: Pick<StackLayerDto, "cycloneStatus" | "cycloneHandoff" | "headSha">): string | null;
176
174
  export declare function autoResolveActive(stack: StackDto, layer: StackLayerDto): boolean;
177
175
  export declare function autoFixCiActive(stack: StackDto, layer: StackLayerDto): boolean;
178
176
  export declare const CI_DID_NOT_FINISH_BLOCKER = "CI did not finish \u2014 left for a person";
179
- export declare function pendingCiLeftForPerson(stack: StackDto, layer: StackLayerDto): boolean;
180
- export declare const VORTEX_AUTO_REVIEW_OFF = "Vortex auto-review is off, so this head was not reviewed";
181
- export declare const VORTEX_BILLING_SKIPPED = "Vortex skipped this head: it could not confirm billing";
182
- export declare const VORTEX_REVIEW_INCOMPLETE = "Vortex reviewed only part of this PR";
183
- export declare const VORTEX_IGNORED_AUTHOR = "Vortex skipped this head: the PR author is on the ignored authors list";
184
- export declare const VORTEX_REPO_DISABLED = "Vortex skipped this head: reviews are turned off for this repository";
185
- export declare const VORTEX_RETRY_SCHEDULED = "Vortex hit a temporary error on this head and will retry by itself";
186
- export declare const VORTEX_REVIEW_FAILED = "Review failed";
187
- export declare function vortexRetryScheduled(layer: StackLayerDto): boolean;
188
- export declare const AUTO_LAND_CI_PENDING_ATTENTION_MS: number;
189
- export declare const AUTO_LAND_VORTEX_WAIT_ATTENTION_MS: number;
190
- export declare const AUTO_LAND_PROMOTE_FAILED_ATTEMPTS = 3;
191
- export declare const AUTO_LAND_CI_FAILED_BLOCKER = "CI failed \u2014 Auto land reads a failing check on GitHub at this head";
192
- export declare const AUTO_LAND_WAIT_ATTENTION_MS: number;
193
- export declare const AUTO_LAND_CI_UNREADABLE_ATTENTION_MS: number;
194
- export declare function autoLandWaitLayer(stack: StackDto): StackLayerDto | undefined;
195
- export declare const MERGE_QUEUE_STALL_ATTENTION_MS: number;
177
+ export declare const CYCLONE_STALLED_PREFIX = "Cyclone stalled: ";
196
178
  export type StackQueueWait = {
197
179
  prNumber: number;
198
180
  headSha: string | null;
@@ -213,13 +195,9 @@ export type MergeQueueWait = {
213
195
  export declare function mergeQueueWait(queue: readonly MergeQueueEntryDto[], nowMs: number): MergeQueueWait | null;
214
196
  export declare function conflictLiveParent(stack: StackDto, layer: StackLayerDto): string | null;
215
197
  export declare function conflictRepairSteps(branch: string, liveParent: string | null): string;
216
- export declare function restackFailedRepairSteps(branch: string, liveParent: string | null, failure: {
217
- kind: string;
218
- detail?: string | null;
219
- }): string;
198
+ export declare const BREEZE_BRANCH_PREFIX = "breeze/";
220
199
  export declare function agentsCannotClear(reason: StackBlockerReason, stack?: StackDto, layer?: StackLayerDto): boolean;
221
200
  export declare function stackRepair(stack: StackDto, attention: ClassifiedStackBlocker | null, layer: StackLayerDto | undefined, bounce: MergeQueueEntryDto | undefined, queueWait?: MergeQueueWait | null, nowMs?: number): StackRepairHint | null;
222
- export declare const LAND_PR_MISSING_PREFIX = "No open land PR for ";
223
201
  /** Pair-gate attention plus live hard blocks on every other open layer. */
224
202
  export declare function stackBlockers(stack: StackDto, entries?: MergeQueueEntryDto[], cursor?: Pick<StackWatchCursor, "bounceId" | "afterFinishedAt">, nowMs?: number): {
225
203
  attention: StackBlocker | null;
@@ -238,3 +216,4 @@ export declare function stackBlockers(stack: StackDto, entries?: MergeQueueEntry
238
216
  };
239
217
  /** Compact contract text shared by status and wait summaries. */
240
218
  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;
219
+ export {};
@@ -1,4 +1,4 @@
1
- import { CYCLONE_CI_FIX_HELD_REASON, CYCLONE_FIX_HELD_REASON, deriveStackAgentsBusy, mergeQueueBounceLabel, restackRetryPending, } from "./stack-dto.js";
1
+ import { CYCLONE_CI_FIX_HELD_REASON, CYCLONE_FIX_HELD_REASON, deriveStackAgentsBusy, mergeQueueBounceLabel, restackRetryPending, STACK_AGENT_PATCHING_STALE_MS, stalledCycloneRun, } from "./stack-dto.js";
2
2
  export function landGateIsPending(reason) {
3
3
  const token = reason?.trim().replace(/^landing blocked:\s*/i, "").match(/^[a-z_]+/i)?.[0] ?? "";
4
4
  return /_(?:pending|running)$/i.test(token);
@@ -18,11 +18,11 @@ export function layerAgents(layer) {
18
18
  busy: { vortex: busy.vortex, cyclone: busy.cyclone },
19
19
  };
20
20
  }
21
- export function isMgParkBase(branch) {
21
+ function isMgParkBase(branch) {
22
22
  return (branch ?? "").trim().toLowerCase().startsWith("mg-park-");
23
23
  }
24
24
  /** GitHub DIRTY vs an mg-park-* freeze is not a merge conflict, verified or not. */
25
- export function boundDirty(layer) {
25
+ function boundDirty(layer) {
26
26
  if (isMgParkBase(layer.parentBranch))
27
27
  return false;
28
28
  if (layer.mergeable == null)
@@ -30,7 +30,7 @@ export function boundDirty(layer) {
30
30
  return (!layer.headSha || layer.mergeableHeadSha === layer.headSha) &&
31
31
  (layer.mergeable === false || layer.mergeableState?.toLowerCase() === "dirty");
32
32
  }
33
- export function mergeabilityPending(layer) {
33
+ function mergeabilityPending(layer) {
34
34
  if (isMgParkBase(layer.parentBranch))
35
35
  return false;
36
36
  return layer.mergeable == null && !!layer.headSha && layer.mergeableHeadSha === layer.headSha &&
@@ -51,12 +51,12 @@ 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";
55
- export const SEAM_REVIEW_NOT_RUNNING = "Seam review pending, no review running";
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";
54
+ const RESTACK_FAILED = "Restack failed";
55
+ const SEAM_REVIEW_NOT_RUNNING = "Seam review pending, no review running";
56
+ const VORTEX_REVIEW_OUT_OF_QUOTA = "Review skipped, out of review quota";
57
+ const SEAM_REVIEW_STUCK = "Seam review stuck in reviewing, no review running";
58
+ const SEAM_REREVIEW_NOT_RUNNING = "Seam re-review pending, no review running";
59
+ const SEAM_VERDICT_STALE = "Seam verdict is for an older head, no review running";
60
60
  const STUCK_SEAM_REASONS = ["seam_review_stuck", "seam_rereview_not_running", "seam_verdict_stale"];
61
61
  function coreReviewDoneAtHead(layer) {
62
62
  const review = layer.vortexReview;
@@ -72,8 +72,8 @@ function vortexFindingsRestAtHead(layer) {
72
72
  function vortexChangesRequestedAtHead(layer) {
73
73
  return vortexFindingsRestAtHead(layer) && layer.reviewStatus === "changes_requested";
74
74
  }
75
- export const CYCLONE_NOT_RUN_ATTENTION_MS = 30 * 60_000;
76
- export const CYCLONE_NOT_RUN_REASON = "not_run";
75
+ const CYCLONE_NOT_RUN_ATTENTION_MS = 30 * 60_000;
76
+ const CYCLONE_NOT_RUN_REASON = "not_run";
77
77
  function cycloneNotRunAtHead(layer, nowMs) {
78
78
  const review = layer.vortexReview;
79
79
  if (layer.cycloneFindingsTakenAtHead !== false || layer.vortexStatus !== "findings")
@@ -109,6 +109,7 @@ const CYCLONE_HANDOFF_WORDS = {
109
109
  unsupported_language: "the findings are in a language Cyclone does not patch",
110
110
  policy_owner_changed: "the stack's owner changed while the patch was queued",
111
111
  patch_job_lost: "Cyclone's patch job was lost before it finished",
112
+ run_deadline: "Cyclone's patch ran past its time limit and stopped without pushing",
112
113
  pr_state_unavailable: "Cyclone could not read the PR's state, so it pushed nothing",
113
114
  left_for_human: "Cyclone did not patch these findings and pushed nothing",
114
115
  hold: "the review told Cyclone to hold, so it did not patch these findings",
@@ -175,7 +176,7 @@ export function autoFixCiActive(stack, layer) {
175
176
  !sameHead(layer.cycloneCiHandoff?.headSha, layer.headSha);
176
177
  }
177
178
  export const CI_DID_NOT_FINISH_BLOCKER = "CI did not finish — left for a person";
178
- export function pendingCiLeftForPerson(stack, layer) {
179
+ function pendingCiLeftForPerson(stack, layer) {
179
180
  return layer.ciStatus === "pending" && layer.checks?.cancelledOnly === true &&
180
181
  sameHead(layer.checks.namedRunsCompleteHeadSha, layer.headSha) && !isLandPrLayer(stack, layer) &&
181
182
  sameHead(layer.cycloneCiHandoff?.headSha, layer.headSha);
@@ -200,6 +201,7 @@ const CYCLONE_HELD_FINDINGS_REASONS = [
200
201
  "pr_state_unavailable",
201
202
  "vortex_patch_cap",
202
203
  "patch_job_lost",
204
+ "run_deadline",
203
205
  "unsupported_language",
204
206
  "credits_exhausted",
205
207
  "no_billing",
@@ -227,13 +229,13 @@ function quotaSkippedReview(layer) {
227
229
  return review?.status === "skipped" && review.skip_reason === "quota_exceeded" &&
228
230
  sameHead(review.head_sha, layer.headSha);
229
231
  }
230
- export const VORTEX_AUTO_REVIEW_OFF = "Vortex auto-review is off, so this head was not reviewed";
231
- export const VORTEX_BILLING_SKIPPED = "Vortex skipped this head: it could not confirm billing";
232
- export const VORTEX_REVIEW_INCOMPLETE = "Vortex reviewed only part of this PR";
233
- export const VORTEX_IGNORED_AUTHOR = "Vortex skipped this head: the PR author is on the ignored authors list";
234
- export const VORTEX_REPO_DISABLED = "Vortex skipped this head: reviews are turned off for this repository";
235
- export const VORTEX_RETRY_SCHEDULED = "Vortex hit a temporary error on this head and will retry by itself";
236
- export const VORTEX_REVIEW_FAILED = "Review failed";
232
+ const VORTEX_AUTO_REVIEW_OFF = "Vortex auto-review is off, so this head was not reviewed";
233
+ const VORTEX_BILLING_SKIPPED = "Vortex skipped this head: it could not confirm billing";
234
+ const VORTEX_REVIEW_INCOMPLETE = "Vortex reviewed only part of this PR";
235
+ const VORTEX_IGNORED_AUTHOR = "Vortex skipped this head: the PR author is on the ignored authors list";
236
+ const VORTEX_REPO_DISABLED = "Vortex skipped this head: reviews are turned off for this repository";
237
+ const VORTEX_RETRY_SCHEDULED = "Vortex hit a temporary error on this head and will retry by itself";
238
+ const VORTEX_REVIEW_FAILED = "Review failed";
237
239
  const VORTEX_SKIP_BLOCKERS = {
238
240
  auto_review_off: VORTEX_AUTO_REVIEW_OFF,
239
241
  billing_blocked: VORTEX_BILLING_SKIPPED,
@@ -241,7 +243,7 @@ const VORTEX_SKIP_BLOCKERS = {
241
243
  ignored_author: VORTEX_IGNORED_AUTHOR,
242
244
  repo_disabled: VORTEX_REPO_DISABLED,
243
245
  };
244
- export function vortexRetryScheduled(layer) {
246
+ function vortexRetryScheduled(layer) {
245
247
  const review = layer.vortexReview;
246
248
  const reason = review?.skip_reason?.trim();
247
249
  return (reason === "retry_scheduled" || reason === "post_delayed") && sameHead(review?.head_sha, layer.headSha);
@@ -388,6 +390,9 @@ function layerAttention(stack, layer, queue, cursor, nowMs) {
388
390
  if (landing && !landGateIsPending(landing))
389
391
  return hardBlock(landing, "land_gate");
390
392
  }
393
+ const stalled = stalledCycloneRun(layer, nowMs);
394
+ if (stalled)
395
+ return hardBlock(cycloneStalledBlocker(stalled, nowMs), "cyclone_stalled");
391
396
  if (vortexFindingsLeftForPerson(stack, layer, nowMs))
392
397
  return hardBlock(VORTEX_FINDINGS_NEED_PERSON, "vortex_findings_need_person");
393
398
  }
@@ -408,6 +413,11 @@ function layerAttention(stack, layer, queue, cursor, nowMs) {
408
413
  return { blocker: label, reason: "bounce", bounceKind: kind, bounce };
409
414
  }
410
415
  const UNIT_ABANDONED_BLOCKER_PREFIX = "Review unit abandoned: based on ";
416
+ export const CYCLONE_STALLED_PREFIX = "Cyclone stalled: ";
417
+ function cycloneStalledBlocker(run, nowMs) {
418
+ const minutes = Math.floor((nowMs - Date.parse(run.startedAt ?? "")) / 60_000);
419
+ return `${CYCLONE_STALLED_PREFIX}patching ${run.sha?.slice(0, 7) ?? "this PR"} for ${minutes}m with no push or result`;
420
+ }
411
421
  function unitAbandonedUnder(stack, layer) {
412
422
  if (stack.unit?.state !== "abandoned")
413
423
  return false;
@@ -447,15 +457,15 @@ function switchedOffByAnotherActor(stack) {
447
457
  const reason = stack.autoLandOff?.reason;
448
458
  return reason && AUTO_LAND_SWITCHED_OFF_BY[reason] ? reason : undefined;
449
459
  }
450
- export const AUTO_LAND_CI_PENDING_ATTENTION_MS = 45 * 60_000;
451
- export const AUTO_LAND_VORTEX_WAIT_ATTENTION_MS = 30 * 60_000;
452
- export const AUTO_LAND_PROMOTE_FAILED_ATTEMPTS = 3;
453
- export const AUTO_LAND_CI_FAILED_BLOCKER = "CI failed — Auto land reads a failing check on GitHub at this head";
460
+ const AUTO_LAND_CI_PENDING_ATTENTION_MS = 45 * 60_000;
461
+ const AUTO_LAND_VORTEX_WAIT_ATTENTION_MS = 30 * 60_000;
462
+ const AUTO_LAND_PROMOTE_FAILED_ATTEMPTS = 3;
463
+ const AUTO_LAND_CI_FAILED_BLOCKER = "CI failed — Auto land reads a failing check on GitHub at this head";
454
464
  const AUTO_LAND_CI_PENDING_PREFIX = "CI pending for ";
455
465
  const AUTO_LAND_VORTEX_WAIT_PREFIX = "No green Vortex review for ";
456
466
  const AUTO_LAND_PROMOTE_FAILED_PREFIX = "Promote failed ";
457
- export const AUTO_LAND_WAIT_ATTENTION_MS = 45 * 60_000;
458
- export const AUTO_LAND_CI_UNREADABLE_ATTENTION_MS = 10 * 60_000;
467
+ const AUTO_LAND_WAIT_ATTENTION_MS = 45 * 60_000;
468
+ const AUTO_LAND_CI_UNREADABLE_ATTENTION_MS = 10 * 60_000;
459
469
  const AUTO_LAND_CI_UNREADABLE_PREFIX = "Mergestorm cannot read ";
460
470
  const AUTO_LAND_WAITED_PREFIX = "Auto land has waited ";
461
471
  const AUTO_LAND_MEMBERS_REMAINING_PREFIX = "The land PR cannot be queued: ";
@@ -510,7 +520,7 @@ function lastPrLayer(stack) {
510
520
  return land;
511
521
  return stack.layers.filter((layer) => layer.prNumber > 0).sort((a, b) => b.position - a.position)[0];
512
522
  }
513
- export function autoLandWaitLayer(stack) {
523
+ function autoLandWaitLayer(stack) {
514
524
  const wait = armedAutoLandWait(stack);
515
525
  if (!wait)
516
526
  return undefined;
@@ -578,7 +588,7 @@ function autoLandWaitNamed(stack, layer, nowMs) {
578
588
  ? { blocker: `${AUTO_LAND_WAITED_PREFIX}${minutes}m: ${autoLandWaitWords(wait.reason)}`, reason: "auto_land_waited" }
579
589
  : null;
580
590
  }
581
- export const MERGE_QUEUE_STALL_ATTENTION_MS = 45 * 60_000;
591
+ const MERGE_QUEUE_STALL_ATTENTION_MS = 45 * 60_000;
582
592
  const MERGE_QUEUE_STALLED_PREFIX = "Merge queue stuck: ";
583
593
  const LIVE_QUEUE_STATES = ["queued", "running", "waiting"];
584
594
  function minutesOf(ms) {
@@ -679,11 +689,22 @@ export function conflictRepairSteps(branch, liveParent) {
679
689
  }
680
690
  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.`;
681
691
  }
682
- export function restackFailedRepairSteps(branch, liveParent, failure) {
692
+ export const BREEZE_BRANCH_PREFIX = "breeze/";
693
+ function isBreezeBranch(branch) {
694
+ return typeof branch === "string" && branch.startsWith(BREEZE_BRANCH_PREFIX);
695
+ }
696
+ function breezeConflictRepairSteps(branch, liveParent, prNumber) {
697
+ if (!liveParent)
698
+ return conflictRepairSteps(branch, liveParent);
699
+ 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.`;
700
+ }
701
+ function restackFailedRepairSteps(branch, liveParent, failure, prNumber) {
683
702
  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.`;
684
703
  if (!liveParent) {
685
704
  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.`;
686
705
  }
706
+ if (prNumber !== undefined && isBreezeBranch(branch))
707
+ return `${why} ${breezeConflictRepairSteps(branch, liveParent, prNumber)}`;
687
708
  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.`;
688
709
  }
689
710
  function landPrLiveParent(stack, layer) {
@@ -725,6 +746,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null, n
725
746
  const landPr = !!stack.unit?.branch && layer.prNumber === stack.unit.landPrNumber && layer.branch === stack.unit.branch;
726
747
  const restackConflict = reason === "restack_conflict" || attention.bounceKind === "restack_conflict";
727
748
  const mergeConflict = reason === "merge_conflict";
749
+ const breeze = !landPr && isBreezeBranch(layer.branch);
728
750
  if (restackConflict || mergeConflict) {
729
751
  const liveParent = landPr ? landPrLiveParent(stack, layer) : conflictLiveParent(stack, layer);
730
752
  const note = conflictHandoffNote(stack, layer);
@@ -735,7 +757,8 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null, n
735
757
  branch: layer.branch,
736
758
  liveParent,
737
759
  files: conflictFiles(layer, bounce),
738
- steps: conflictRepairSteps(layer.branch, liveParent),
760
+ ...(breeze ? { publishedBy: "breeze" } : {}),
761
+ steps: breeze ? breezeConflictRepairSteps(layer.branch, liveParent, layer.prNumber) : conflictRepairSteps(layer.branch, liveParent),
739
762
  ...(note ? { note } : {}),
740
763
  };
741
764
  }
@@ -748,7 +771,8 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null, n
748
771
  branch: layer.branch,
749
772
  liveParent,
750
773
  restackKind: layer.restackError.kind,
751
- steps: restackFailedRepairSteps(layer.branch, liveParent, layer.restackError),
774
+ ...(breeze ? { publishedBy: "breeze" } : {}),
775
+ steps: restackFailedRepairSteps(layer.branch, liveParent, layer.restackError, layer.prNumber),
752
776
  };
753
777
  }
754
778
  if (reason === "unit_abandoned") {
@@ -801,6 +825,18 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null, n
801
825
  steps: `Auto land is off for this stack (autoLandOff reason ${offReason}: ${AUTO_LAND_SWITCHED_OFF_BY[offReason] ?? offReason}), so nothing will enqueue it and it will not land by itself. No bounce did this and nothing in the layers needs a change: do not patch or push for it. A person or another agent session may have switched it off on purpose, so do not turn Auto land back on yourself. Tell the human that Auto land is off and why, and turn it back on (mg stack set ${stack.id} --auto-land on) only when they say so.`,
802
826
  };
803
827
  }
828
+ if (reason === "cyclone_stalled") {
829
+ const run = stalledCycloneRun(layer, nowMs);
830
+ return {
831
+ kind: "cyclone_stalled",
832
+ prNumber: layer.prNumber,
833
+ headSha: layer.headSha ?? null,
834
+ branch: layer.branch,
835
+ runSha: run?.sha ?? null,
836
+ since: run?.startedAt ?? null,
837
+ steps: `Cyclone has been patching #${layer.prNumber}${run?.sha ? ` (a run for ${run.sha.slice(0, 7)})` : ""} since ${run?.startedAt ?? "an unknown time"} without pushing or finishing, past the ${STACK_AGENT_PATCHING_STALE_MS / 60_000}-minute limit, so the watch no longer waits for it. Patch the open Vortex findings at ${layer.headSha ?? "the head"} yourself: verify each one against a checkout of ${layer.branch}, fix the real ones with the smallest patch and an ordinary push, and dismiss the ones you judge wrong with mg dismiss. If ${layer.branch} moves before you push, Cyclone pushed after all: re-read stack_status instead of pushing. Tell the human Cyclone stalled on this PR.`,
838
+ };
839
+ }
804
840
  if (reason === "vortex_failed") {
805
841
  return {
806
842
  kind: "vortex_failed",
@@ -919,7 +955,7 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null, n
919
955
  }
920
956
  return null;
921
957
  }
922
- export const LAND_PR_MISSING_PREFIX = "No open land PR for ";
958
+ const LAND_PR_MISSING_PREFIX = "No open land PR for ";
923
959
  function landPrMissing(stack, candidate) {
924
960
  const unit = stack.unit;
925
961
  if (!unit || candidate || stack.archivedAt || unit.state === "landed" || unit.state === "abandoned")
@@ -120,7 +120,9 @@ export type StackAgentsBusy = {
120
120
  headSha: string | null;
121
121
  };
122
122
  export declare const STACK_AGENT_QUEUED_STALE_MS: number;
123
+ export declare const STACK_AGENT_PATCHING_STALE_MS: number;
123
124
  export declare const AUTO_RESOLVE_ENQUEUE_GRACE_MS: number;
125
+ export declare function stalledCycloneRun(layer: Pick<StackLayerDto, "agentRuns">, nowMs?: number): StackAgentRun | null;
124
126
  export declare function deriveStackAgentsBusy(layer: Pick<StackLayerDto, "headSha" | "vortexStatus" | "cycloneStatus" | "agentRuns" | "vortexReview" | "cycloneConflictPendingSince" | "cycloneCiPendingSince">, opts?: {
125
127
  cycloneClaimed?: boolean;
126
128
  nowMs?: number;
@@ -238,6 +240,7 @@ export type StackLayerDto = {
238
240
  headSha?: string | null;
239
241
  /** Head SHA that produced mergeable / mergeableState. */
240
242
  mergeableHeadSha?: string | null;
243
+ staleBaseMergeable?: boolean;
241
244
  /** GitHub PR draft flag from enrich; the auto-land watcher never enqueues a draft. */
242
245
  draft?: boolean | null;
243
246
  /**
@@ -497,6 +500,7 @@ export type MergeQueueEntryDto = {
497
500
  verifyBaseSha: string | null;
498
501
  cancelRequestedAt?: string | null;
499
502
  batch?: MergeQueueEntryBatchDto | null;
503
+ ci?: MergeQueueBatchCiDto | null;
500
504
  finishedAt: string | null;
501
505
  claimedAt?: string | null;
502
506
  aheadInRepo?: MergeQueueAheadDto | null;
package/dist/stack-dto.js CHANGED
@@ -26,6 +26,7 @@ export const AUTO_LAND_OFF_REASONS = ["user", "archive", "watcher", "cancelled",
26
26
  export const CYCLONE_FIX_HELD_REASON = "fix_held";
27
27
  export const CYCLONE_CI_FIX_HELD_REASON = "ci_fix_held";
28
28
  export const STACK_AGENT_QUEUED_STALE_MS = 15 * 60 * 1000;
29
+ export const STACK_AGENT_PATCHING_STALE_MS = 60 * 60 * 1000;
29
30
  export const AUTO_RESOLVE_ENQUEUE_GRACE_MS = 2 * 60_000;
30
31
  function agentShaMayBeHead(sha, head) {
31
32
  const left = sha?.trim().toLowerCase();
@@ -34,6 +35,17 @@ function agentShaMayBeHead(sha, head) {
34
35
  return false;
35
36
  return left.startsWith(right) || right.startsWith(left);
36
37
  }
38
+ export function stalledCycloneRun(layer, nowMs = Date.now()) {
39
+ const patching = (layer.agentRuns ?? [])
40
+ .filter((run) => !run.finishedAt && run.agent === "cyclone" && run.status === "patching")
41
+ .map((run) => ({ run, startedMs: Date.parse(run.startedAt ?? "") }));
42
+ if (!patching.length)
43
+ return null;
44
+ if (!patching.every(({ startedMs }) => Number.isFinite(startedMs) && nowMs - startedMs > STACK_AGENT_PATCHING_STALE_MS)) {
45
+ return null;
46
+ }
47
+ return patching.reduce((newest, entry) => (entry.startedMs > newest.startedMs ? entry : newest)).run;
48
+ }
37
49
  export function deriveStackAgentsBusy(layer, opts = {}) {
38
50
  const headSha = layer.headSha?.trim() || null;
39
51
  const nowMs = opts.nowMs ?? Date.now();
@@ -54,8 +66,9 @@ export function deriveStackAgentsBusy(layer, opts = {}) {
54
66
  const sinceMs = Date.parse(since ?? "");
55
67
  return Number.isFinite(sinceMs) && nowMs - sinceMs <= AUTO_RESOLVE_ENQUEUE_GRACE_MS;
56
68
  };
57
- const cyclone = opts.cycloneClaimed === true || layer.cycloneStatus === "patching" ||
58
- runs.some((run) => run.agent === "cyclone" && run.status === "patching") ||
69
+ const stalled = stalledCycloneRun(layer, nowMs) !== null;
70
+ const cyclone = (!stalled && (opts.cycloneClaimed === true || layer.cycloneStatus === "patching" ||
71
+ runs.some((run) => run.agent === "cyclone" && run.status === "patching"))) ||
59
72
  runs.some((run) => run.agent === "cyclone" && agentShaMayBeHead(run.sha, headSha) && queuedFresh(run)) ||
60
73
  withinGrace(layer.cycloneConflictPendingSince) ||
61
74
  withinGrace(layer.cycloneCiPendingSince);
@@ -3,7 +3,7 @@ export type StackLayerMeta = {
3
3
  /** Parent branch name (trunk for the bottom layer). */
4
4
  parentBranch: string;
5
5
  };
6
- export type LocalStack = {
6
+ type LocalStack = {
7
7
  layers: StackLayerMeta[];
8
8
  autoEnqueueWhenReady?: boolean;
9
9
  /** Per-open override carried from `stack create` to `stack submit`; absent = account flag. */
@@ -22,7 +22,6 @@ export type StackMeta = {
22
22
  export declare function stackMetaPath(cwd?: string, stacksRoot?: string): string;
23
23
  export declare function loadStackMeta(cwd?: string, stacksRoot?: string): Promise<StackMeta | null>;
24
24
  export declare function emptyStackMeta(trunk: string): StackMeta;
25
- export declare function activeStack(meta: StackMeta): LocalStack;
26
25
  export declare function activeLayers(meta: StackMeta): StackLayerMeta[];
27
26
  export declare function setActiveAutoEnqueue(meta: StackMeta, enabled: boolean | undefined): StackMeta;
28
27
  /** Per-open automation flags remembered on the active local stack until submit. */
@@ -45,3 +44,4 @@ export declare function appendLayer(meta: StackMeta, layer: StackLayerMeta): Sta
45
44
  export declare function selectStackForCreate(meta: StackMeta, parentBranch: string): StackMeta;
46
45
  /** Drop the active stack after a successful submit. */
47
46
  export declare function removeActiveStack(meta: StackMeta): StackMeta;
47
+ export {};
@@ -102,7 +102,7 @@ export function emptyStackMeta(trunk) {
102
102
  active: 0,
103
103
  };
104
104
  }
105
- export function activeStack(meta) {
105
+ function activeStack(meta) {
106
106
  const stack = meta.stacks[meta.active];
107
107
  if (!stack) {
108
108
  throw new Error("Stack authoring state has an out-of-range active stack; run `mg stack reset --force` to clear it.");
@@ -1,21 +1,14 @@
1
1
  import type { Config } from "./config.js";
2
2
  import { readLandedQueueEntry } from "./api.js";
3
3
  import { pollStackWatch, type StackWatchCursor, type StackWatchEnvelope } from "./stack-watch.js";
4
- export declare const STACK_WATCH_MARKER = "MS-WATCH";
5
4
  export declare const STACK_WATCH_EXIT: {
6
5
  readonly landed: 0;
7
6
  readonly attention: 3;
8
7
  readonly timeout: 5;
9
8
  };
10
9
  export declare const STACK_WATCH_SLICE_MS = 300000;
11
- export declare const STACK_WATCH_RECHECK_MS = 30000;
12
- export declare const STACK_WATCH_FAILED_RETRY_MS = 15000;
13
- export declare const STACK_WATCH_RATE_LIMIT_WAIT_MS = 30000;
14
- export declare const STACK_WATCH_TRANSIENT_WINDOW_MS: number;
15
- export declare const STACK_WATCH_TRANSIENT_MAX_WAIT_MS = 60000;
16
- export declare function isTransientWatchFailure(err: unknown): boolean;
17
10
  export type StackWatchUntil = "attention" | "landed";
18
- export type StackWatchOutcome = "attention" | "landed" | "failed" | "timeout" | "aborted";
11
+ type StackWatchOutcome = "attention" | "landed" | "failed" | "timeout" | "aborted";
19
12
  export type RunStackWatchOptions = {
20
13
  until?: StackWatchUntil;
21
14
  ignore?: readonly string[];
@@ -35,10 +28,6 @@ export type StackWatchLoopResult = {
35
28
  exitCode: number;
36
29
  envelope: StackWatchEnvelope | null;
37
30
  };
38
- export declare function blockerWithoutElapsedMinutes(blocker: string | null | undefined): string;
39
- export declare function stackWatchAttentionKey(envelope: Pick<StackWatchEnvelope, "prNumber" | "headSha" | "blocker">): string;
40
- export declare function stackWatchAttentionLine(envelope: StackWatchEnvelope): string;
41
- export declare function stackWatchLandedLine(envelope: StackWatchEnvelope): string;
42
31
  export declare function stackWatchFailedLine(stackId: string, message: string): string;
43
- export declare function stackWatchTimeoutLine(stackId: string, maxMs: number): string;
44
32
  export declare function runStackWatch(cfg: Config, stackId: string, opts?: RunStackWatchOptions): Promise<StackWatchLoopResult>;
33
+ export {};
@@ -3,16 +3,16 @@ import { API_TIMEOUT_PREFIX, DEFAULT_API_TIMEOUT_MS, LandedQueueReadError, readL
3
3
  import { isTransientReviewPollError } from "./commands/review-client.js";
4
4
  import { stackWatchBackgroundCommand } from "./stack-watch-obligation.js";
5
5
  import { pollStackWatch, StackWatchError, StackWatchTimeoutError, } from "./stack-watch.js";
6
- export const STACK_WATCH_MARKER = "MS-WATCH";
6
+ const STACK_WATCH_MARKER = "MS-WATCH";
7
7
  export const STACK_WATCH_EXIT = { landed: 0, attention: 3, timeout: 5 };
8
8
  export const STACK_WATCH_SLICE_MS = 300_000;
9
- export const STACK_WATCH_RECHECK_MS = 30_000;
10
- export const STACK_WATCH_FAILED_RETRY_MS = 15_000;
11
- export const STACK_WATCH_RATE_LIMIT_WAIT_MS = 30_000;
12
- export const STACK_WATCH_TRANSIENT_WINDOW_MS = 15 * 60_000;
13
- export const STACK_WATCH_TRANSIENT_MAX_WAIT_MS = 60_000;
9
+ const STACK_WATCH_RECHECK_MS = 30_000;
10
+ const STACK_WATCH_FAILED_RETRY_MS = 15_000;
11
+ const STACK_WATCH_RATE_LIMIT_WAIT_MS = 30_000;
12
+ const STACK_WATCH_TRANSIENT_WINDOW_MS = 15 * 60_000;
13
+ const STACK_WATCH_TRANSIENT_MAX_WAIT_MS = 60_000;
14
14
  const TRANSIENT_WATCH_FAILURE = /HTTP (?:408|429|5\d\d)|snapshot missing|fetch failed|ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN|socket hang up|network|connection/i;
15
- export function isTransientWatchFailure(err) {
15
+ function isTransientWatchFailure(err) {
16
16
  if (!(err instanceof Error))
17
17
  return false;
18
18
  if (err instanceof LandedQueueReadError)
@@ -24,24 +24,24 @@ export function isTransientWatchFailure(err) {
24
24
  return err.message.startsWith(API_TIMEOUT_PREFIX) || TRANSIENT_WATCH_FAILURE.test(err.message);
25
25
  }
26
26
  const ELAPSED_MINUTES = /\b\d+m\b/g;
27
- export function blockerWithoutElapsedMinutes(blocker) {
27
+ function blockerWithoutElapsedMinutes(blocker) {
28
28
  return (blocker ?? "").replace(ELAPSED_MINUTES, "Nm");
29
29
  }
30
- export function stackWatchAttentionKey(envelope) {
30
+ function stackWatchAttentionKey(envelope) {
31
31
  return `${envelope.prNumber ?? ""}|${envelope.headSha ?? ""}|${blockerWithoutElapsedMinutes(envelope.blocker)}`;
32
32
  }
33
- export function stackWatchAttentionLine(envelope) {
33
+ function stackWatchAttentionLine(envelope) {
34
34
  const restart = envelope.cursor.bounceId
35
35
  ? ` restart=${JSON.stringify(stackWatchBackgroundCommand(envelope.stackId, envelope.cursor))}` : "";
36
36
  return `${STACK_WATCH_MARKER} ATTENTION pr=${envelope.prNumber ?? "none"} head=${envelope.headSha ?? "none"} blocker=${JSON.stringify(envelope.blocker ?? "")} repair=${envelope.repair?.kind ?? "none"}${restart}`;
37
37
  }
38
- export function stackWatchLandedLine(envelope) {
38
+ function stackWatchLandedLine(envelope) {
39
39
  return `${STACK_WATCH_MARKER} LANDED stack=${envelope.stackId} reason=${envelope.watch.reason}`;
40
40
  }
41
41
  export function stackWatchFailedLine(stackId, message) {
42
42
  return `${STACK_WATCH_MARKER} ATTENTION failed stack=${stackId} error=${JSON.stringify(message)}`;
43
43
  }
44
- export function stackWatchTimeoutLine(stackId, maxMs) {
44
+ function stackWatchTimeoutLine(stackId, maxMs) {
45
45
  return `${STACK_WATCH_MARKER} TIMEOUT stack=${stackId} after=${Math.round(maxMs / 60_000)}m`;
46
46
  }
47
47
  function isAbort(err, signal) {
@@ -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 } : {}) });