mergestorm 0.3.46 → 0.3.48
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/api.d.ts +6 -0
- package/dist/api.js +19 -0
- package/dist/commands/stack.d.ts +2 -1
- package/dist/commands/stack.js +22 -4
- package/dist/stack-blockers.d.ts +2 -0
- package/dist/stack-blockers.js +10 -1
- package/dist/stack-dto.d.ts +13 -0
- package/dist/stack-watch-loop.d.ts +5 -0
- package/dist/stack-watch-loop.js +76 -7
- package/dist/stack-watch-obligation.d.ts +1 -1
- package/dist/stack-watch.js +2 -0
- package/dist/ui/banner.js +6 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ On a TTY, bare `mergestorm` (or `mergestorm shell`) opens a branded REPL: a comp
|
|
|
45
45
|
|
|
46
46
|
```
|
|
47
47
|
╭──────────────────────────────────────────────────────────────────────────────╮
|
|
48
|
-
│ ▀▀▀▀▀▀▀▀▀▀▘ mergestorm v0.3.
|
|
48
|
+
│ ▀▀▀▀▀▀▀▀▀▀▘ mergestorm v0.3.48 │
|
|
49
49
|
│ ▀▀▀▀▀▀▀▀▀ ● msk_live_… · maelstrom │
|
|
50
50
|
│ ▀▀▀▀▀▀ [████████░░░░░░░░░░░░░░░░░░░░░░] 25% used │
|
|
51
51
|
│ ▝▀▀▀▀▀ local reviews + stacked PRs · mergestorm.ai │
|
package/dist/api.d.ts
CHANGED
|
@@ -242,11 +242,17 @@ export declare function ensureUpperPark(stackId: string, cfg?: Config): Promise<
|
|
|
242
242
|
}>;
|
|
243
243
|
export declare const SURGE_INSTALL_URL = "https://github.com/apps/mergestorm-surge/installations/new";
|
|
244
244
|
export declare function surgeNotInstalledMessage(owner: string, repo: string, installUrl?: string): string;
|
|
245
|
+
export declare function readLandedQueueEntry(cfg: Config, stackId: string): Promise<MergeQueueEntryDto | null>;
|
|
245
246
|
export declare function findStackPull(cfg: Config, input: {
|
|
246
247
|
owner: string;
|
|
247
248
|
repo: string;
|
|
248
249
|
head: string;
|
|
249
250
|
}): Promise<number | null>;
|
|
251
|
+
export declare function claimStackPulls(cfg: Config, input: {
|
|
252
|
+
owner: string;
|
|
253
|
+
repo: string;
|
|
254
|
+
heads: string[];
|
|
255
|
+
}): Promise<void>;
|
|
250
256
|
export declare function openStackPull(cfg: Config, input: {
|
|
251
257
|
owner: string;
|
|
252
258
|
repo: string;
|
package/dist/api.js
CHANGED
|
@@ -510,6 +510,13 @@ function stackPullFailure(status, body, owner, repo, failLabel) {
|
|
|
510
510
|
const detail = typeof rejection.message === "string" ? rejection.message : JSON.stringify(body);
|
|
511
511
|
return new CommandError(`${failLabel} (HTTP ${status}): ${detail}`);
|
|
512
512
|
}
|
|
513
|
+
export async function readLandedQueueEntry(cfg, stackId) {
|
|
514
|
+
const { status, body } = await apiFetch(cfg, `/api/v1/stacks/queue?stackId=${encodeURIComponent(stackId)}&landed=1`);
|
|
515
|
+
if (status !== 200)
|
|
516
|
+
throw new CommandError(`Failed to read the landed merge queue entry (HTTP ${status})`);
|
|
517
|
+
const landed = body?.landed;
|
|
518
|
+
return landed && landed.state === "landed" ? landed : null;
|
|
519
|
+
}
|
|
513
520
|
export async function findStackPull(cfg, input) {
|
|
514
521
|
const query = new URLSearchParams(input).toString();
|
|
515
522
|
const { status, body } = await apiFetch(cfg, `/api/v1/stacks/pulls?${query}`);
|
|
@@ -519,6 +526,18 @@ export async function findStackPull(cfg, input) {
|
|
|
519
526
|
const n = body?.number;
|
|
520
527
|
return typeof n === "number" && n > 0 ? n : null;
|
|
521
528
|
}
|
|
529
|
+
export async function claimStackPulls(cfg, input) {
|
|
530
|
+
const { status, body } = await apiFetch(cfg, "/api/v1/stacks/pull-claims", {
|
|
531
|
+
method: "POST",
|
|
532
|
+
json: input,
|
|
533
|
+
});
|
|
534
|
+
if (status !== 200) {
|
|
535
|
+
const detail = body?.message
|
|
536
|
+
?? body?.error
|
|
537
|
+
?? `HTTP ${status}`;
|
|
538
|
+
throw new CommandError(`Could not claim ${input.heads.join(", ")}: ${String(detail)}`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
522
541
|
export async function openStackPull(cfg, input) {
|
|
523
542
|
const { status, body } = await apiFetch(cfg, "/api/v1/stacks/pulls", {
|
|
524
543
|
method: "POST",
|
package/dist/commands/stack.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type StackHeldBlocker, type StackRepairHint } from "../stack-blockers.js";
|
|
2
2
|
import { type StackWatchObligation } from "../stack-watch-obligation.js";
|
|
3
|
-
import { adoptStack, ensureUpperPark, findStackPull, getEnrichedStack, listMergeQueueEntries, listStacks, openStackPull, setStackPolicy, type StackDto, type StackPolicyPatch } from "../api.js";
|
|
3
|
+
import { adoptStack, ensureUpperPark, findStackPull, getEnrichedStack, listMergeQueueEntries, listStacks, openStackPull, claimStackPulls, setStackPolicy, type StackDto, type StackPolicyPatch } from "../api.js";
|
|
4
4
|
import { loadConfig } from "../config.js";
|
|
5
5
|
import { createPrs, findOpenPrNumber } from "../gh.js";
|
|
6
6
|
import { parseGithubOriginRepo } from "../git.js";
|
|
@@ -137,6 +137,7 @@ export type StackSubmitDeps = {
|
|
|
137
137
|
createPrs?: typeof createPrs;
|
|
138
138
|
findStackPull?: typeof findStackPull;
|
|
139
139
|
openStackPull?: typeof openStackPull;
|
|
140
|
+
claimStackPulls?: typeof claimStackPulls;
|
|
140
141
|
loadConfig?: typeof loadConfig;
|
|
141
142
|
listStacks?: typeof listStacks;
|
|
142
143
|
adoptStack?: typeof adoptStack;
|
package/dist/commands/stack.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { stackBlockers, stackBlockersSummary } from "../stack-blockers.js";
|
|
2
2
|
import { STACK_WATCH_NOT_DONE_SENTENCE, STACK_WATCH_NOTIFY_PATTERN, stackTerminalReason, stackWatchObligation, } from "../stack-watch-obligation.js";
|
|
3
|
-
import { adoptStack, ensureUpperPark, findStackPull, getEnrichedStack, landNextStack, listMergeQueueEntries, listStacks, openStackPull, restackStack, setStackPolicy, } from "../api.js";
|
|
3
|
+
import { adoptStack, ensureUpperPark, findStackPull, getEnrichedStack, landNextStack, listMergeQueueEntries, listStacks, openStackPull, claimStackPulls, restackStack, setStackPolicy, } from "../api.js";
|
|
4
4
|
import { loadConfig } from "../config.js";
|
|
5
5
|
import { rateLimitedMessage, REVIEW_EXIT, CommandError } from "../errors.js";
|
|
6
6
|
import { createPrs, findOpenPrNumber, ghReady, githubApiFromEnv, } from "../gh.js";
|
|
@@ -58,8 +58,9 @@ const STACK_USAGE = `usage:
|
|
|
58
58
|
(exit 5 on timeout), so do not merge the streams (2>&1) before parsing stdout.
|
|
59
59
|
mergestorm stack watch <stack-id> [--until attention|landed] [--ignore <blocker-substring>] [--max <minutes>] [--head <sha>] [--json]
|
|
60
60
|
Loops stack wait with the cursor and stays silent while the stack is waiting or in progress.
|
|
61
|
-
Prints one MS-WATCH ATTENTION line and exits 3 on attention or
|
|
62
|
-
|
|
61
|
+
Prints one MS-WATCH ATTENTION line and exits 3 on attention or on reads that keep failing (a permanent
|
|
62
|
+
error twice, or 15 minutes of timeouts and 5xx, which it retries with backoff); prints MS-WATCH LANDED
|
|
63
|
+
and exits 0 only for a confirmed landing, including a stack Mergestorm deleted after its queue entry landed. Other terminal reasons exit 3 with ATTENTION. Run it as a background command.
|
|
63
64
|
--json writes exactly one JSON document to stdout and sends the MS-WATCH lines to stderr.
|
|
64
65
|
mergestorm stack set <stack-id> [--auto-land on|off] [--auto-review on|off|default] [--auto-patch on|off|default] [--auto-resolve-conflicts on|off|default] [--auto-fix-ci on|off|default] [--json]
|
|
65
66
|
mergestorm stack adopt <owner/repo>#<pr> [--auto-land on|off] [--auto-review on|off] [--auto-patch on|off] [--auto-resolve-conflicts on|off] [--auto-fix-ci on|off] [--json]
|
|
@@ -640,6 +641,7 @@ export async function cmdStackSubmit(args, deps = {}) {
|
|
|
640
641
|
const createPrsFn = deps.createPrs ?? createPrs;
|
|
641
642
|
const findStackPullFn = deps.findStackPull ?? findStackPull;
|
|
642
643
|
const openStackPullFn = deps.openStackPull ?? openStackPull;
|
|
644
|
+
const claimStackPullsFn = deps.claimStackPulls ?? claimStackPulls;
|
|
643
645
|
const loadConfigFn = deps.loadConfig ?? loadConfig;
|
|
644
646
|
const listStacksFn = deps.listStacks ?? listStacks;
|
|
645
647
|
const adoptStackFn = deps.adoptStack ?? adoptStack;
|
|
@@ -736,6 +738,20 @@ export async function cmdStackSubmit(args, deps = {}) {
|
|
|
736
738
|
for (const plan of plans) {
|
|
737
739
|
existingPrs.set(plan.branch, await opener.findOpen(plan.branch));
|
|
738
740
|
}
|
|
741
|
+
const newHeads = plans
|
|
742
|
+
.filter((plan) => existingPrs.get(plan.branch) == null)
|
|
743
|
+
.map((plan) => plan.branch);
|
|
744
|
+
if (newHeads.length > 0) {
|
|
745
|
+
try {
|
|
746
|
+
const config = await loadConfigFn();
|
|
747
|
+
for (let i = 0; i < newHeads.length; i += 20) {
|
|
748
|
+
await claimStackPullsFn(config, { owner, repo, heads: newHeads.slice(i, i + 20) });
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
catch (err) {
|
|
752
|
+
progress(ansi.dim(` Could not hold the new PRs off the dashboard until adopt (${err instanceof Error ? err.message : String(err)}); continuing.`));
|
|
753
|
+
}
|
|
754
|
+
}
|
|
739
755
|
for (const layer of layers) {
|
|
740
756
|
if (!branchExistsFn(layer.branch, cwd)) {
|
|
741
757
|
throw new CommandError(`Stack layer branch missing locally: ${layer.branch}`);
|
|
@@ -1042,7 +1058,9 @@ export async function cmdStackWatch(args, deps = {}) {
|
|
|
1042
1058
|
? `Stopped watching stack ${stackId}; it is not landed.`
|
|
1043
1059
|
: result.envelope?.watch.reason === "not_found"
|
|
1044
1060
|
? `Stack ${stackId} was not found; landing is unconfirmed. Verify each PR before reporting it landed.`
|
|
1045
|
-
:
|
|
1061
|
+
: result.envelope?.watch.reason === "landing_unconfirmed"
|
|
1062
|
+
? `Stack ${stackId} is gone, but landing is unconfirmed. Verify each PR before reporting it landed.`
|
|
1063
|
+
: `Stack ${stackId} needs attention; it is not landed.`;
|
|
1046
1064
|
throw new CommandError(reason, result.exitCode, result.outcome === "timeout" ? "review_timeout" : "stack_attention");
|
|
1047
1065
|
}
|
|
1048
1066
|
export async function cmdStackStatus(args, deps = {}) {
|
package/dist/stack-blockers.d.ts
CHANGED
|
@@ -165,6 +165,8 @@ export declare const SEAM_VERDICT_STALE = "Seam verdict is for an older head, no
|
|
|
165
165
|
export declare const VORTEX_FINDINGS_NEED_PERSON = "Vortex findings need a person";
|
|
166
166
|
export declare function autoResolveActive(stack: StackDto, layer: StackLayerDto): boolean;
|
|
167
167
|
export declare function autoFixCiActive(stack: StackDto, layer: StackLayerDto): boolean;
|
|
168
|
+
export declare const CI_DID_NOT_FINISH_BLOCKER = "CI did not finish \u2014 left for a person";
|
|
169
|
+
export declare function pendingCiLeftForPerson(stack: StackDto, layer: StackLayerDto): boolean;
|
|
168
170
|
export declare const VORTEX_AUTO_REVIEW_OFF = "Vortex auto-review is off, so this head was not reviewed";
|
|
169
171
|
export declare const VORTEX_BILLING_SKIPPED = "Vortex skipped this head: it could not confirm billing";
|
|
170
172
|
export declare const VORTEX_REVIEW_INCOMPLETE = "Vortex reviewed only part of this PR";
|
package/dist/stack-blockers.js
CHANGED
|
@@ -131,6 +131,11 @@ export function autoFixCiActive(stack, layer) {
|
|
|
131
131
|
return !stack.cycloneCiOff && !isLandPrLayer(stack, layer) &&
|
|
132
132
|
!sameHead(layer.cycloneCiHandoff?.headSha, layer.headSha);
|
|
133
133
|
}
|
|
134
|
+
export const CI_DID_NOT_FINISH_BLOCKER = "CI did not finish — left for a person";
|
|
135
|
+
export function pendingCiLeftForPerson(stack, layer) {
|
|
136
|
+
return layer.ciStatus === "pending" && !isLandPrLayer(stack, layer) &&
|
|
137
|
+
sameHead(layer.cycloneCiHandoff?.headSha, layer.headSha);
|
|
138
|
+
}
|
|
134
139
|
function ciHandoffNote(stack, layer) {
|
|
135
140
|
if (isLandPrLayer(stack, layer))
|
|
136
141
|
return null;
|
|
@@ -272,6 +277,8 @@ function layerAttention(stack, layer, queue, cursor) {
|
|
|
272
277
|
const name = layer.checks?.failingName?.trim();
|
|
273
278
|
return hardBlock(`CI failed${name ? ` — ${name}` : ""}`, "ci_failed");
|
|
274
279
|
}
|
|
280
|
+
if (pendingCiLeftForPerson(stack, layer))
|
|
281
|
+
return hardBlock(CI_DID_NOT_FINISH_BLOCKER, "ci_failed");
|
|
275
282
|
if (layer.vortexStatus === "failed" && !vortexRetryScheduled(layer) &&
|
|
276
283
|
(!layer.vortexReview?.head_sha || sameHead(layer.vortexReview.head_sha, layer.headSha))) {
|
|
277
284
|
return hardBlock(VORTEX_REVIEW_FAILED, "vortex_failed");
|
|
@@ -780,7 +787,9 @@ export function stackRepair(stack, attention, layer, bounce, queueWait = null) {
|
|
|
780
787
|
headSha: layer.headSha ?? null,
|
|
781
788
|
branch: layer.branch,
|
|
782
789
|
failingCheck,
|
|
783
|
-
steps:
|
|
790
|
+
steps: pendingCiLeftForPerson(stack, layer)
|
|
791
|
+
? `CI on ${layer.branch} at ${layer.headSha ?? "its live head"} ended cancelled or interrupted and did not finish after Cyclone's one re-run. Find the cancelled run (gh run list --branch ${layer.branch}), read why it stopped (gh run view <run-id>), and re-run it (gh run rerun <run-id>) if it was a runner or infrastructure stop. If the job itself cancels, fix that on ${layer.branch} and push. Do not retarget the PR base.`
|
|
792
|
+
: `Read the failing ${failingCheck ? `check ${failingCheck}` : "checks"} on ${layer.branch} (gh run list --branch ${layer.branch}, then gh run view <run-id> --log-failed), fix it on ${layer.branch} with the smallest patch, run that check locally, confirm the remote head is still ${layer.headSha ?? "the head you started from"}, then an ordinary git push. Do not retarget the PR base.`,
|
|
784
793
|
...(note ? { note } : {}),
|
|
785
794
|
};
|
|
786
795
|
}
|
package/dist/stack-dto.d.ts
CHANGED
|
@@ -427,12 +427,25 @@ export type MergeQueueBounceBatch = {
|
|
|
427
427
|
withPrNumbers: number[];
|
|
428
428
|
};
|
|
429
429
|
export type MergeQueueBatchState = "verifying" | "landing" | "landed" | "stopped" | "bounced" | "abandoned";
|
|
430
|
+
export type MergeQueueBatchCiCheckState = "passed" | "skipped" | "failed" | "running" | "queued";
|
|
431
|
+
export type MergeQueueBatchCiCheckDto = {
|
|
432
|
+
name: string;
|
|
433
|
+
state: MergeQueueBatchCiCheckState;
|
|
434
|
+
startedAt: string | null;
|
|
435
|
+
completedAt: string | null;
|
|
436
|
+
};
|
|
437
|
+
export type MergeQueueBatchCiDto = {
|
|
438
|
+
sha: string;
|
|
439
|
+
observedAt: string;
|
|
440
|
+
checks: MergeQueueBatchCiCheckDto[];
|
|
441
|
+
};
|
|
430
442
|
export type MergeQueueEntryBatchDto = {
|
|
431
443
|
id: string;
|
|
432
444
|
position: number;
|
|
433
445
|
size: number;
|
|
434
446
|
state: MergeQueueBatchState;
|
|
435
447
|
landedCount: number;
|
|
448
|
+
ci?: MergeQueueBatchCiDto | null;
|
|
436
449
|
};
|
|
437
450
|
export type MergeQueueAheadDto = {
|
|
438
451
|
stackId: string | null;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Config } from "./config.js";
|
|
2
|
+
import type { MergeQueueEntryDto } from "./stack-dto.js";
|
|
2
3
|
import { pollStackWatch, type StackWatchCursor, type StackWatchEnvelope } from "./stack-watch.js";
|
|
3
4
|
export declare const STACK_WATCH_MARKER = "MS-WATCH";
|
|
4
5
|
export declare const STACK_WATCH_EXIT: {
|
|
@@ -10,6 +11,9 @@ export declare const STACK_WATCH_SLICE_MS = 300000;
|
|
|
10
11
|
export declare const STACK_WATCH_RECHECK_MS = 30000;
|
|
11
12
|
export declare const STACK_WATCH_FAILED_RETRY_MS = 15000;
|
|
12
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;
|
|
13
17
|
export type StackWatchUntil = "attention" | "landed";
|
|
14
18
|
export type StackWatchOutcome = "attention" | "landed" | "failed" | "timeout" | "aborted";
|
|
15
19
|
export type RunStackWatchOptions = {
|
|
@@ -22,6 +26,7 @@ export type RunStackWatchOptions = {
|
|
|
22
26
|
write?: (line: string) => void;
|
|
23
27
|
writeMarker?: (line: string) => void;
|
|
24
28
|
poll?: typeof pollStackWatch;
|
|
29
|
+
readLanded?: (cfg: Config, stackId: string) => Promise<MergeQueueEntryDto | null>;
|
|
25
30
|
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
26
31
|
now?: () => number;
|
|
27
32
|
};
|
package/dist/stack-watch-loop.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
|
+
import { API_TIMEOUT_PREFIX, readLandedQueueEntry } from "./api.js";
|
|
3
|
+
import { isTransientReviewPollError } from "./commands/review-client.js";
|
|
2
4
|
import { pollStackWatch, StackWatchError, StackWatchTimeoutError, } from "./stack-watch.js";
|
|
3
5
|
export const STACK_WATCH_MARKER = "MS-WATCH";
|
|
4
6
|
export const STACK_WATCH_EXIT = { landed: 0, attention: 3, timeout: 5 };
|
|
@@ -6,6 +8,18 @@ export const STACK_WATCH_SLICE_MS = 300_000;
|
|
|
6
8
|
export const STACK_WATCH_RECHECK_MS = 30_000;
|
|
7
9
|
export const STACK_WATCH_FAILED_RETRY_MS = 15_000;
|
|
8
10
|
export const STACK_WATCH_RATE_LIMIT_WAIT_MS = 30_000;
|
|
11
|
+
export const STACK_WATCH_TRANSIENT_WINDOW_MS = 15 * 60_000;
|
|
12
|
+
export const STACK_WATCH_TRANSIENT_MAX_WAIT_MS = 60_000;
|
|
13
|
+
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;
|
|
14
|
+
export function isTransientWatchFailure(err) {
|
|
15
|
+
if (!(err instanceof Error))
|
|
16
|
+
return false;
|
|
17
|
+
if (isTransientReviewPollError(err))
|
|
18
|
+
return true;
|
|
19
|
+
if (err.cause !== undefined && isTransientReviewPollError(err.cause))
|
|
20
|
+
return true;
|
|
21
|
+
return err.message.startsWith(API_TIMEOUT_PREFIX) || TRANSIENT_WATCH_FAILURE.test(err.message);
|
|
22
|
+
}
|
|
9
23
|
const ELAPSED_MINUTES = /\b\d+m\b/g;
|
|
10
24
|
export function blockerWithoutElapsedMinutes(blocker) {
|
|
11
25
|
return (blocker ?? "").replace(ELAPSED_MINUTES, "Nm");
|
|
@@ -35,6 +49,7 @@ export async function runStackWatch(cfg, stackId, opts = {}) {
|
|
|
35
49
|
const write = opts.write ?? ((line) => console.log(line));
|
|
36
50
|
const writeMarker = opts.json ? opts.writeMarker ?? ((line) => console.error(line)) : write;
|
|
37
51
|
const poll = opts.poll ?? pollStackWatch;
|
|
52
|
+
const readLanded = opts.readLanded ?? readLandedQueueEntry;
|
|
38
53
|
const now = opts.now ?? Date.now;
|
|
39
54
|
const wait = opts.sleep ?? ((ms, signal) => sleep(ms, undefined, { signal }));
|
|
40
55
|
const deadline = opts.maxMs !== undefined ? now() + opts.maxMs : Number.POSITIVE_INFINITY;
|
|
@@ -50,9 +65,43 @@ export async function runStackWatch(cfg, stackId, opts = {}) {
|
|
|
50
65
|
};
|
|
51
66
|
let cursor = opts.cursor;
|
|
52
67
|
let failures = 0;
|
|
68
|
+
let permanentFailures = 0;
|
|
69
|
+
const seenPrs = new Set();
|
|
70
|
+
const notePrs = (envelope) => {
|
|
71
|
+
if (envelope.prNumber)
|
|
72
|
+
seenPrs.add(envelope.prNumber);
|
|
73
|
+
if (envelope.currentCandidate?.prNumber)
|
|
74
|
+
seenPrs.add(envelope.currentCandidate.prNumber);
|
|
75
|
+
};
|
|
76
|
+
let transientSince = null;
|
|
53
77
|
const ignoredKeys = new Map();
|
|
54
78
|
let reportedKey = null;
|
|
55
|
-
const
|
|
79
|
+
const confirmedLanding = async (envelope) => {
|
|
80
|
+
if (envelope.watch.reason !== "not_found")
|
|
81
|
+
return envelope;
|
|
82
|
+
let entry = null;
|
|
83
|
+
try {
|
|
84
|
+
entry = await readLanded(cfg, id);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return {
|
|
88
|
+
...envelope,
|
|
89
|
+
watch: {
|
|
90
|
+
...envelope.watch,
|
|
91
|
+
reason: "landing_unconfirmed",
|
|
92
|
+
message: "The stack is gone, but its landed merge queue entry could not be read. Landing is unconfirmed. Confirm each PR is merged (gh pr view <n> --json state) before reporting it landed. The watch is done; do not report it landed.",
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const enrolledHead = envelope.cursor?.enrolledHeadSha ?? null;
|
|
97
|
+
const ours = Boolean(entry) && (entry.landedPrNumbers.some((pr) => seenPrs.has(pr)) ||
|
|
98
|
+
(enrolledHead !== null && entry.verifyHeadSha === enrolledHead));
|
|
99
|
+
if (!entry || !ours)
|
|
100
|
+
return envelope;
|
|
101
|
+
return { ...envelope, watch: { ...envelope.watch, reason: "landed", message: `This stack is landed (merge queue landed #${entry.landedPrNumbers.join(", #")}). The watch is done; stop calling stack_wait for it.` } };
|
|
102
|
+
};
|
|
103
|
+
const finished = async (seen) => {
|
|
104
|
+
const envelope = await confirmedLanding(seen);
|
|
56
105
|
const landed = envelope.watch.reason === "landed";
|
|
57
106
|
const outcome = landed ? "landed" : "attention";
|
|
58
107
|
emit(landed ? stackWatchLandedLine(envelope)
|
|
@@ -61,9 +110,20 @@ export async function runStackWatch(cfg, stackId, opts = {}) {
|
|
|
61
110
|
write(envelope.watch.message);
|
|
62
111
|
return { outcome, exitCode: landed ? STACK_WATCH_EXIT.landed : STACK_WATCH_EXIT.attention, envelope };
|
|
63
112
|
};
|
|
64
|
-
const failed = async (message, envelope) => {
|
|
113
|
+
const failed = async (message, envelope, transient) => {
|
|
65
114
|
failures += 1;
|
|
66
|
-
if (
|
|
115
|
+
if (transient) {
|
|
116
|
+
permanentFailures = 0;
|
|
117
|
+
transientSince ??= now();
|
|
118
|
+
if (now() - transientSince < STACK_WATCH_TRANSIENT_WINDOW_MS) {
|
|
119
|
+
await pause(Math.min(STACK_WATCH_TRANSIENT_MAX_WAIT_MS, STACK_WATCH_FAILED_RETRY_MS * 2 ** Math.min(failures - 1, 4)));
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
permanentFailures += 1;
|
|
125
|
+
}
|
|
126
|
+
if (permanentFailures >= 2 || transient) {
|
|
67
127
|
emit(stackWatchFailedLine(id, message), envelope, "failed");
|
|
68
128
|
return { outcome: "failed", exitCode: STACK_WATCH_EXIT.attention, envelope };
|
|
69
129
|
}
|
|
@@ -84,25 +144,34 @@ export async function runStackWatch(cfg, stackId, opts = {}) {
|
|
|
84
144
|
if (isAbort(err, opts.signal))
|
|
85
145
|
throw err;
|
|
86
146
|
if (err instanceof StackWatchTimeoutError) {
|
|
147
|
+
notePrs(err.lastEnvelope);
|
|
87
148
|
cursor = err.lastEnvelope.cursor;
|
|
88
149
|
failures = 0;
|
|
150
|
+
permanentFailures = 0;
|
|
151
|
+
if (err.lastEnvelope.assessment === "available")
|
|
152
|
+
transientSince = null;
|
|
89
153
|
continue;
|
|
90
154
|
}
|
|
91
|
-
if (err instanceof StackWatchError && err.lastEnvelope.watch.done)
|
|
92
|
-
|
|
155
|
+
if (err instanceof StackWatchError && err.lastEnvelope.watch.done) {
|
|
156
|
+
notePrs(err.lastEnvelope);
|
|
157
|
+
return await finished(err.lastEnvelope);
|
|
158
|
+
}
|
|
93
159
|
if (err instanceof StackWatchError && err.lastEnvelope.status === "rate_limited") {
|
|
94
160
|
await pause(err.retryAfterSeconds !== undefined ? Math.max(1_000, err.retryAfterSeconds * 1000) : STACK_WATCH_RATE_LIMIT_WAIT_MS);
|
|
95
161
|
continue;
|
|
96
162
|
}
|
|
97
|
-
const result = await failed(err instanceof Error ? err.message : String(err), err instanceof StackWatchError ? err.lastEnvelope : null);
|
|
163
|
+
const result = await failed(err instanceof Error ? err.message : String(err), err instanceof StackWatchError ? err.lastEnvelope : null, isTransientWatchFailure(err));
|
|
98
164
|
if (result)
|
|
99
165
|
return result;
|
|
100
166
|
continue;
|
|
101
167
|
}
|
|
102
168
|
failures = 0;
|
|
169
|
+
permanentFailures = 0;
|
|
170
|
+
transientSince = null;
|
|
103
171
|
cursor = envelope.cursor;
|
|
172
|
+
notePrs(envelope);
|
|
104
173
|
if (envelope.watch.done)
|
|
105
|
-
return finished(envelope);
|
|
174
|
+
return await finished(envelope);
|
|
106
175
|
if (envelope.status === "attention") {
|
|
107
176
|
const key = stackWatchAttentionKey(envelope);
|
|
108
177
|
const blocker = (envelope.blocker ?? "").toLowerCase();
|
|
@@ -3,7 +3,7 @@ export declare const STACK_WATCH_NOT_DONE_SENTENCE = "This stack is not landed.
|
|
|
3
3
|
export declare const STACK_WATCH_NOTIFY_PATTERN = "MS-WATCH (ATTENTION|LANDED)";
|
|
4
4
|
export declare function stackWatchBackgroundCommand(stackId: string): string;
|
|
5
5
|
export type StackWatchDoneReason = "landed" | "closed" | "archived" | "not_found";
|
|
6
|
-
export type StackWatchReason = StackWatchDoneReason | "open" | "attention" | "unread" | "rate_limited" | "failed";
|
|
6
|
+
export type StackWatchReason = StackWatchDoneReason | "open" | "attention" | "unread" | "rate_limited" | "failed" | "landing_unconfirmed";
|
|
7
7
|
export type StackWatchNextArgs = {
|
|
8
8
|
stack_id: string;
|
|
9
9
|
enrolled_head_sha?: string | null;
|
package/dist/stack-watch.js
CHANGED
|
@@ -83,6 +83,7 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
|
|
|
83
83
|
catch (err) {
|
|
84
84
|
if (opts.signal?.aborted || (err instanceof Error && err.name === "AbortError"))
|
|
85
85
|
throw err;
|
|
86
|
+
lastEnvelope = { ...lastEnvelope, assessment: "unavailable" };
|
|
86
87
|
if (now() >= deadline && timeoutMs !== 0)
|
|
87
88
|
throw timeout();
|
|
88
89
|
if (timeoutMs !== 0 && isTransientReviewPollError(err) && transientFailures < REVIEW_POLL_MAX_TRANSIENT_RETRIES) {
|
|
@@ -95,6 +96,7 @@ export async function pollStackWatch(cfg, stackId, opts = {}) {
|
|
|
95
96
|
}
|
|
96
97
|
if (response.status === 200)
|
|
97
98
|
return response.body;
|
|
99
|
+
lastEnvelope = { ...lastEnvelope, assessment: "unavailable" };
|
|
98
100
|
const retryWaitMs = transientRetryWaitMs({
|
|
99
101
|
failureCount: transientFailures + 1, retryAfterSeconds: response.retryAfterSeconds, random: opts.random,
|
|
100
102
|
});
|
package/dist/ui/banner.js
CHANGED
|
@@ -176,6 +176,12 @@ const WHATS_NEW = {
|
|
|
176
176
|
"0.3.46": [
|
|
177
177
|
"Watch waits while GitHub delays a review",
|
|
178
178
|
],
|
|
179
|
+
"0.3.47": [
|
|
180
|
+
"New stack PRs are adopted on Work at submit",
|
|
181
|
+
],
|
|
182
|
+
"0.3.48": [
|
|
183
|
+
"Watch names CI Cyclone could not finish",
|
|
184
|
+
],
|
|
179
185
|
};
|
|
180
186
|
function densityFromVariant(variant) {
|
|
181
187
|
if (variant === "mini" || variant === "nano")
|
package/package.json
CHANGED