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.
- package/README.md +30 -10
- package/dist/api.d.ts +68 -11
- package/dist/api.js +206 -8
- package/dist/automation-catalog.d.ts +17 -3
- package/dist/automation-catalog.js +4 -1
- package/dist/cli.js +21 -19
- package/dist/client/index.d.ts +6 -4
- package/dist/client/index.js +6 -4
- package/dist/commands/account.d.ts +3 -0
- package/dist/commands/account.js +75 -0
- package/dist/commands/browse.d.ts +1 -9
- package/dist/commands/browse.js +10 -8
- package/dist/commands/dismiss.d.ts +0 -2
- package/dist/commands/dismiss.js +2 -2
- package/dist/commands/help.js +3 -2
- package/dist/commands/issue.d.ts +24 -6
- package/dist/commands/issue.js +153 -31
- package/dist/commands/login.d.ts +2 -1
- package/dist/commands/login.js +50 -14
- package/dist/commands/logout.js +6 -7
- package/dist/commands/memory.d.ts +29 -0
- package/dist/commands/memory.js +154 -0
- package/dist/commands/registry.d.ts +1 -0
- package/dist/commands/registry.js +52 -11
- package/dist/commands/review-client.d.ts +0 -3
- package/dist/commands/review-client.js +3 -3
- package/dist/commands/review.d.ts +1 -1
- package/dist/commands/review.js +1 -1
- package/dist/commands/settings.d.ts +3 -4
- package/dist/commands/settings.js +51 -5
- package/dist/commands/stack.d.ts +20 -5
- package/dist/commands/stack.js +106 -7
- package/dist/commands/whoami.js +14 -10
- package/dist/config.d.ts +56 -2
- package/dist/config.js +271 -13
- package/dist/errors.d.ts +1 -1
- package/dist/gh.d.ts +0 -4
- package/dist/gh.js +4 -4
- package/dist/git.d.ts +1 -1
- package/dist/git.js +2 -2
- package/dist/memory-api.d.ts +74 -0
- package/dist/memory-api.js +69 -0
- package/dist/node-version.d.ts +0 -2
- package/dist/node-version.js +1 -1
- package/dist/pr-body.d.ts +4 -0
- package/dist/pr-body.js +15 -0
- package/dist/repo-identity.d.ts +2 -1
- package/dist/repo-identity.js +8 -4
- package/dist/server-publish.d.ts +5 -26
- package/dist/server-publish.js +47 -40
- package/dist/stack-blockers.d.ts +15 -36
- package/dist/stack-blockers.js +69 -33
- package/dist/stack-dto.d.ts +4 -0
- package/dist/stack-dto.js +15 -2
- package/dist/stack-meta.d.ts +2 -2
- package/dist/stack-meta.js +1 -1
- package/dist/stack-watch-loop.d.ts +2 -13
- package/dist/stack-watch-loop.js +35 -14
- package/dist/stack-watch.d.ts +2 -1
- package/dist/stack-watch.js +6 -2
- package/dist/submit-pr-base.d.ts +0 -1
- package/dist/submit-pr-base.js +1 -1
- package/dist/ui/ansi.d.ts +0 -10
- package/dist/ui/ansi.js +2 -2
- package/dist/ui/banner.d.ts +3 -1
- package/dist/ui/banner.js +13 -3
- package/dist/ui/box.d.ts +0 -2
- package/dist/ui/box.js +1 -1
- package/dist/ui/prompt.d.ts +0 -11
- package/dist/ui/prompt.js +7 -7
- package/dist/ui/tabs.d.ts +2 -2
- package/dist/ui/tabs.js +1 -1
- package/dist/ui/width.d.ts +0 -9
- package/dist/ui/width.js +3 -3
- package/dist/web-api.d.ts +17 -0
- package/dist/web-api.js +30 -0
- package/package.json +3 -1
- package/skill/SKILL.md +4 -0
- package/skill/mergestorm-bounce-watch/SKILL.md +1 -1
package/dist/stack-blockers.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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 {};
|
package/dist/stack-blockers.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
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
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
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
|
-
|
|
458
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
package/dist/stack-dto.d.ts
CHANGED
|
@@ -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
|
|
58
|
-
|
|
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);
|
package/dist/stack-meta.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export type StackLayerMeta = {
|
|
|
3
3
|
/** Parent branch name (trunk for the bottom layer). */
|
|
4
4
|
parentBranch: string;
|
|
5
5
|
};
|
|
6
|
-
|
|
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 {};
|
package/dist/stack-meta.js
CHANGED
|
@@ -102,7 +102,7 @@ export function emptyStackMeta(trunk) {
|
|
|
102
102
|
active: 0,
|
|
103
103
|
};
|
|
104
104
|
}
|
|
105
|
-
|
|
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
|
-
|
|
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 {};
|
package/dist/stack-watch-loop.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
-
|
|
27
|
+
function blockerWithoutElapsedMinutes(blocker) {
|
|
28
28
|
return (blocker ?? "").replace(ELAPSED_MINUTES, "Nm");
|
|
29
29
|
}
|
|
30
|
-
|
|
30
|
+
function stackWatchAttentionKey(envelope) {
|
|
31
31
|
return `${envelope.prNumber ?? ""}|${envelope.headSha ?? ""}|${blockerWithoutElapsedMinutes(envelope.blocker)}`;
|
|
32
32
|
}
|
|
33
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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));
|
package/dist/stack-watch.d.ts
CHANGED
|
@@ -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
|
-
|
|
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>;
|
package/dist/stack-watch.js
CHANGED
|
@@ -20,10 +20,12 @@ export class StackWatchTimeoutError extends Error {
|
|
|
20
20
|
export class StackWatchError extends Error {
|
|
21
21
|
lastEnvelope;
|
|
22
22
|
retryAfterSeconds;
|
|
23
|
-
|
|
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 } : {}) });
|