merge-steward 0.35.0 → 0.35.2

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 CHANGED
@@ -15,6 +15,8 @@ The queue keeps delivery fast without pretending branch CI is always enough. For
15
15
  ## How it works
16
16
 
17
17
  1. A PR becomes eligible when GitHub says it is approved and its required checks are green.
18
+ If GitHub names no required checks, every observed check on the exact head
19
+ must be settled without failure; one early green job is not enough.
18
20
  2. The steward notices through webhook wakeups or startup reconcile scans, and admits the PR to the queue.
19
21
  3. It freezes the approved PR head and resolves the exact future-`main`
20
22
  candidate. If the prospective base is its ancestor, that head is the
@@ -22,6 +24,8 @@ The queue keeps delivery fast without pretending branch CI is always enough. For
22
24
  `merge-steward/<base>/pr-<number>` as a cumulative integration workspace.
23
25
  4. It validates checks on that exact SHA. Only newly-created integration candidates trigger synthetic CI.
24
26
  5. Immediately before landing, it refreshes policy, approval, head, checks, and ancestry, then non-force pushes the same immutable SHA to `main`. It never substitutes a mutable branch ref.
27
+ If the PR head changes, the old admission becomes `superseded`; the new
28
+ head returns to feature review and branch CI before receiving a new place.
25
29
  6. On conflict, the workspace remains at the prospective base; PatchRelay
26
30
  derives the missing ancestry and non-force pushes a resolved candidate.
27
31
  7. On candidate-CI failure, PatchRelay repairs the candidate rather than the PR
@@ -5,7 +5,7 @@ import { defaultResolveRunner, resolvePrNumber, resolveRepo } from "../resolve.j
5
5
  import { parseIntegerFlag } from "../args.js";
6
6
  import { fetchPrGitHubOverview } from "./pr-github.js";
7
7
  import { formatRuntimeActivity } from "../../runtime-format.js";
8
- const TERMINAL_QUEUE_STATUSES = new Set(["merged", "evicted", "dequeued"]);
8
+ const TERMINAL_QUEUE_STATUSES = new Set(["merged", "evicted", "dequeued", "superseded"]);
9
9
  export function computeActiveRank(entries, target) {
10
10
  if (TERMINAL_QUEUE_STATUSES.has(target.status))
11
11
  return undefined;
@@ -26,6 +26,7 @@ export function classifyQueueEntry(entry) {
26
26
  case "merging": return "merging";
27
27
  case "evicted": return "evicted";
28
28
  case "dequeued": return "dequeued";
29
+ case "superseded": return "not_queued";
29
30
  }
30
31
  }
31
32
  function exitCodeForKind(kind) {
@@ -78,7 +79,7 @@ export function classifyGitHubOverview(overview) {
78
79
  return { kind: "not_queued", reason: "PR is not admitted to the merge queue" };
79
80
  }
80
81
  function findEntryForPr(snapshot, prNumber) {
81
- const matches = snapshot.entries.filter((entry) => entry.prNumber === prNumber);
82
+ const matches = snapshot.entries.filter((entry) => entry.prNumber === prNumber && entry.status !== "superseded");
82
83
  if (matches.length === 0)
83
84
  return undefined;
84
85
  matches.sort((left, right) => {
@@ -114,7 +115,7 @@ async function loadQueueEntry(config, prNumber) {
114
115
  const store = new SqliteStore(config.database.path);
115
116
  try {
116
117
  const all = store.listAll(config.repoId);
117
- const entries = all.filter((entry) => entry.prNumber === prNumber);
118
+ const entries = all.filter((entry) => entry.prNumber === prNumber && entry.status !== "superseded");
118
119
  if (entries.length === 0)
119
120
  return { kind: "not_found" };
120
121
  entries.sort((left, right) => {
@@ -82,7 +82,7 @@ export function formatQueueStatusText(source, snapshot) {
82
82
  ...(githubPolicy.fetchedAt ? [`GitHub policy fetched: ${githubPolicy.fetchedAt}`] : []),
83
83
  `Active entries: ${snapshot.summary.active}`,
84
84
  `Queued: ${snapshot.summary.queued} preparing: ${snapshot.summary.preparingHead} validating: ${snapshot.summary.validating} merging: ${snapshot.summary.merging}`,
85
- `Merged: ${snapshot.summary.merged} evicted: ${snapshot.summary.evicted} dequeued: ${snapshot.summary.dequeued}`,
85
+ `Merged: ${snapshot.summary.merged} evicted: ${snapshot.summary.evicted} dequeued: ${snapshot.summary.dequeued} superseded: ${snapshot.summary.superseded}`,
86
86
  snapshot.summary.headPrNumber ? `Head PR: #${snapshot.summary.headPrNumber}` : "Head PR: none",
87
87
  ...formatRuntimeActivity(snapshot.runtime),
88
88
  ...(snapshot.runtime.lastTickOutcome === "failed"
package/dist/config.js CHANGED
@@ -14,7 +14,7 @@ export const stewardConfigSchema = z.object({
14
14
  maxRetries: z.number().int().min(0).default(2),
15
15
  flakyRetries: z.number().int().min(0).default(1),
16
16
  /** Max speculative branches to maintain in parallel. 1 = serial mode. */
17
- speculativeDepth: z.number().int().min(1).default(10),
17
+ speculativeDepth: z.number().int().min(1).default(3),
18
18
  pollIntervalMs: z.number().int().min(1000).default(30_000),
19
19
  reconcileStaleAfterMs: z.number().int().min(1000).default(5 * 60_000),
20
20
  server: z.object({
package/dist/db/schema.js CHANGED
@@ -6,6 +6,7 @@ export function ensureSchema(connection) {
6
6
  `).all();
7
7
  if (existingTables.length > 0) {
8
8
  assertCurrentSchema(connection);
9
+ ensureActiveEntryIndex(connection);
9
10
  return;
10
11
  }
11
12
  connection.exec(`
@@ -80,14 +81,27 @@ export function ensureSchema(connection) {
80
81
  CREATE INDEX IF NOT EXISTS idx_queue_events_entry
81
82
  ON queue_events(entry_id, id)
82
83
  `);
83
- // Must match TERMINAL_STATUSES in types.ts: merged, evicted, dequeued
84
- connection.exec(`
85
- CREATE UNIQUE INDEX IF NOT EXISTS idx_one_active_per_pr
86
- ON queue_entries(repo_id, pr_number)
87
- WHERE status NOT IN ('merged', 'evicted', 'dequeued')
88
- `);
84
+ ensureActiveEntryIndex(connection);
89
85
  assertCurrentSchema(connection);
90
86
  }
87
+ function ensureActiveEntryIndex(connection) {
88
+ // Must match TERMINAL_STATUSES in types.ts. Recreate this partial index so
89
+ // an existing database learns about newly-added terminal states.
90
+ const current = connection.prepare(`
91
+ SELECT sql FROM sqlite_master
92
+ WHERE type = 'index' AND name = 'idx_one_active_per_pr'
93
+ `).get();
94
+ if (String(current?.sql ?? "").toLowerCase().includes("'superseded'"))
95
+ return;
96
+ connection.transaction(() => {
97
+ connection.exec(`DROP INDEX IF EXISTS idx_one_active_per_pr`);
98
+ connection.exec(`
99
+ CREATE UNIQUE INDEX idx_one_active_per_pr
100
+ ON queue_entries(repo_id, pr_number)
101
+ WHERE status NOT IN ('merged', 'evicted', 'dequeued', 'superseded')
102
+ `);
103
+ })();
104
+ }
91
105
  const QUEUE_ENTRY_COLUMNS = [
92
106
  "id", "repo_id", "pr_number", "branch", "head_sha", "base_sha", "status",
93
107
  "position", "priority", "generation", "ci_run_id", "ci_retries",
@@ -1,5 +1,11 @@
1
1
  import type { GitHubPRApi } from "../interfaces.ts";
2
2
  import type { CheckResult, PRStatus } from "../types.ts";
3
+ export declare function hasApprovalForHead(reviews: Array<{
4
+ state?: string;
5
+ commit?: {
6
+ oid?: string;
7
+ };
8
+ }> | undefined, headSha: string): boolean;
3
9
  /**
4
10
  * GitHub PR operations via gh CLI and REST API.
5
11
  *
@@ -1,5 +1,8 @@
1
1
  import { mapGitHubCheckConclusion } from "../check-policy.js";
2
2
  import { exec } from "../exec.js";
3
+ export function hasApprovalForHead(reviews, headSha) {
4
+ return (reviews ?? []).some((review) => review.state === "APPROVED" && review.commit?.oid === headSha);
5
+ }
3
6
  /**
4
7
  * GitHub PR operations via gh CLI and REST API.
5
8
  *
@@ -26,7 +29,7 @@ export class GitHubPRClient {
26
29
  "pr", "view", String(prNumber),
27
30
  "--repo", this.repoFullName,
28
31
  // The base ref lets admission detect stacked PRs.
29
- "--json", "number,title,headRefName,headRefOid,baseRefName,reviewDecision,state,mergeStateStatus",
32
+ "--json", "number,title,headRefName,headRefOid,baseRefName,reviewDecision,reviews,state,mergeStateStatus",
30
33
  ], { githubRepoFullName: this.repoFullName });
31
34
  const data = JSON.parse(result.stdout);
32
35
  return {
@@ -38,7 +41,8 @@ export class GitHubPRClient {
38
41
  mergeable: data.state === "OPEN",
39
42
  mergeStateStatus: data.mergeStateStatus,
40
43
  reviewDecision: data.reviewDecision,
41
- reviewApproved: data.reviewDecision === "APPROVED",
44
+ reviewApproved: data.reviewDecision === "APPROVED"
45
+ && hasApprovalForHead(data.reviews, data.headRefOid),
42
46
  merged: data.state === "MERGED",
43
47
  };
44
48
  }
package/dist/install.js CHANGED
@@ -163,7 +163,7 @@ export async function upsertRepoConfig(options) {
163
163
  gitBin: existing?.gitBin ?? "git",
164
164
  maxRetries: existing?.maxRetries ?? 2,
165
165
  flakyRetries: existing?.flakyRetries ?? 1,
166
- speculativeDepth: existing?.speculativeDepth ?? 10,
166
+ speculativeDepth: existing?.speculativeDepth ?? 3,
167
167
  pollIntervalMs: existing?.pollIntervalMs ?? 30_000,
168
168
  server: {
169
169
  bind: existing?.server.bind ?? (homeConfig.server?.bind ?? "127.0.0.1"),
@@ -2,6 +2,8 @@ import type { FailureClass, QueueEntry } from "./types.ts";
2
2
  import type { ReconcileContext } from "./reconciler-core.ts";
3
3
  export declare function cleanupCandidate(ctx: ReconcileContext, entry: QueueEntry): Promise<void>;
4
4
  export declare function invalidateDownstream(ctx: ReconcileContext, allActive: QueueEntry[], afterIndex: number): Promise<void>;
5
+ /** Retire the immutable admission when GitHub exposes a different PR head. */
6
+ export declare function supersedeAdmittedHead(ctx: ReconcileContext, entry: QueueEntry, newHeadSha: string): Promise<void>;
5
7
  export declare function evictEntry(ctx: ReconcileContext, entry: QueueEntry, failureClass: FailureClass, extra?: {
6
8
  conflictFiles?: string[];
7
9
  failedChecks?: Array<{
@@ -17,6 +17,26 @@ export async function invalidateDownstream(ctx, allActive, afterIndex) {
17
17
  ctx.store.transition(downstream.id, "preparing_head", INVALIDATION_PATCH, "invalidated: base changed");
18
18
  }
19
19
  }
20
+ /** Retire the immutable admission when GitHub exposes a different PR head. */
21
+ export async function supersedeAdmittedHead(ctx, entry, newHeadSha) {
22
+ const allActive = ctx.store.listActive(ctx.repoId);
23
+ const index = allActive.findIndex((candidate) => candidate.id === entry.id);
24
+ emit(ctx, entry, "branch_mismatch", {
25
+ detail: `admitted head ${entry.headSha.slice(0, 12)} superseded by ${newHeadSha.slice(0, 12)}`,
26
+ });
27
+ await cleanupCandidate(ctx, entry);
28
+ ctx.store.transition(entry.id, "superseded", {
29
+ ...CLEAN_CANDIDATE_REF,
30
+ candidateKind: null,
31
+ candidatePolicyFingerprint: null,
32
+ candidateSha: null,
33
+ ciRunId: null,
34
+ ciRetries: 0,
35
+ waitDetail: null,
36
+ }, `admitted head ${entry.headSha.slice(0, 12)} superseded by ${newHeadSha.slice(0, 12)}; new head must pass admission`);
37
+ if (index >= 0)
38
+ await invalidateDownstream(ctx, allActive, index);
39
+ }
20
40
  export async function evictEntry(ctx, entry, failureClass, extra) {
21
41
  await cleanupCandidate(ctx, entry);
22
42
  let baseSha = entry.baseSha;
@@ -1,5 +1,5 @@
1
1
  import { CLEAN_CANDIDATE_REF, CLEAN_CI, CLEAR_CANDIDATE, emit, isBudgetExhausted, ref } from "./reconciler-core.js";
2
- import { cleanupCandidate, evictEntry, invalidateDownstream } from "./reconciler-evict.js";
2
+ import { cleanupCandidate, evictEntry, invalidateDownstream, supersedeAdmittedHead } from "./reconciler-evict.js";
3
3
  import { verifyPostMergeStatus } from "./reconciler-post-merge.js";
4
4
  import { evaluateCheckPolicy, formatRequiredCheck } from "./check-policy.js";
5
5
  import { describeOpenPrAncestors, findUnlandedOpenPrAncestors } from "./open-pr-ancestry.js";
@@ -157,11 +157,7 @@ export async function mergeHead(ctx, entry) {
157
157
  return;
158
158
  }
159
159
  if (prStatus.headSha !== entry.headSha) {
160
- emit(ctx, entry, "branch_mismatch", { detail: `PR head: expected ${entry.headSha.slice(0, 8)}, got ${prStatus.headSha.slice(0, 8)}` });
161
- const allActive = ctx.store.listActive(ctx.repoId);
162
- await cleanupCandidate(ctx, entry);
163
- ctx.store.updateHead(entry.id, prStatus.headSha);
164
- await invalidateDownstream(ctx, allActive, 0);
160
+ await supersedeAdmittedHead(ctx, entry, prStatus.headSha);
165
161
  return;
166
162
  }
167
163
  const liveBaseRefName = prStatus.baseRefName ?? ctx.baseBranch;
@@ -243,13 +239,7 @@ export async function mergeHead(ctx, entry) {
243
239
  return;
244
240
  }
245
241
  if (landingPrStatus.headSha !== entry.headSha) {
246
- emit(ctx, entry, "branch_mismatch", {
247
- detail: `PR head changed during landing: expected ${entry.headSha.slice(0, 8)}, got ${landingPrStatus.headSha.slice(0, 8)}`,
248
- });
249
- const allActive = ctx.store.listActive(ctx.repoId);
250
- await cleanupCandidate(ctx, entry);
251
- ctx.store.updateHead(entry.id, landingPrStatus.headSha);
252
- await invalidateDownstream(ctx, allActive, 0);
242
+ await supersedeAdmittedHead(ctx, entry, landingPrStatus.headSha);
253
243
  return;
254
244
  }
255
245
  const landingBaseRefName = landingPrStatus.baseRefName ?? ctx.baseBranch;
@@ -1,5 +1,5 @@
1
1
  import { CLEAN_CI, CLEAR_CANDIDATE, emit, ref, candidateRefName } from "./reconciler-core.js";
2
- import { evictEntry } from "./reconciler-evict.js";
2
+ import { evictEntry, supersedeAdmittedHead } from "./reconciler-evict.js";
3
3
  import { describeOpenPrAncestors, findUnlandedOpenPrAncestors } from "./open-pr-ancestry.js";
4
4
  export async function prepareEntry(ctx, entry, isHead, prevEntry) {
5
5
  emit(ctx, entry, "fetch_started");
@@ -23,8 +23,7 @@ export async function prepareEntry(ctx, entry, isHead, prevEntry) {
23
23
  const baseSha = await ctx.git.headSha(base);
24
24
  const currentRef = await ctx.git.headSha(ref(ctx, entry.branch));
25
25
  if (currentRef !== entry.headSha) {
26
- emit(ctx, entry, "branch_mismatch", { detail: `expected ${entry.headSha.slice(0, 8)}, got ${currentRef.slice(0, 8)}` });
27
- ctx.store.updateHead(entry.id, currentRef);
26
+ await supersedeAdmittedHead(ctx, entry, currentRef);
28
27
  return;
29
28
  }
30
29
  if (isHead) {
@@ -1,5 +1,5 @@
1
1
  import { CLEAN_CANDIDATE_REF, emit } from "./reconciler-core.js";
2
- import { cleanupCandidate } from "./reconciler-evict.js";
2
+ import { cleanupCandidate, supersedeAdmittedHead } from "./reconciler-evict.js";
3
3
  import { verifyPostMergeStatus } from "./reconciler-post-merge.js";
4
4
  export async function sanitizeEntry(ctx, entry) {
5
5
  const canonical = ctx.store.getEntryByPR(ctx.repoId, entry.prNumber);
@@ -31,6 +31,10 @@ export async function sanitizeEntry(ctx, entry) {
31
31
  }, "merged externally (sanitize)");
32
32
  return true;
33
33
  }
34
+ if (prStatus.headSha !== entry.headSha) {
35
+ await supersedeAdmittedHead(ctx, entry, prStatus.headSha);
36
+ return true;
37
+ }
34
38
  const liveBaseRefName = prStatus.baseRefName ?? ctx.baseBranch;
35
39
  const recordedBaseRefName = entry.baseRefName ?? ctx.baseBranch;
36
40
  if (liveBaseRefName !== recordedBaseRefName) {
@@ -13,7 +13,9 @@ async function holdForIntegrationRepair(ctx, entry, allActive, index, checks) {
13
13
  .map((check) => check.name)
14
14
  .join(", ");
15
15
  ctx.store.transition(entry.id, "validating", {
16
- candidateKind: "integration",
16
+ // A failed repaired candidate is still a repaired candidate. Preserve the
17
+ // provenance so a same-SHA rerun remains gated by integration review.
18
+ candidateKind: entry.candidateKind === "integration_repair" ? "integration_repair" : "integration",
17
19
  ciRunId: entry.ciRunId ?? `head:${entry.candidateSha ?? entry.headSha}`,
18
20
  candidateRef,
19
21
  candidateSha: entry.candidateSha ?? entry.headSha,
@@ -142,9 +144,21 @@ export async function checkValidation(ctx, entry, allActive, index, isLandingHea
142
144
  if (entry.candidateRef && !await ctx.git.isAncestor(entry.headSha, entry.candidateSha)) {
143
145
  return;
144
146
  }
145
- if (entry.candidateRef && entry.candidateKind === "integration" && entry.lastFailedBaseSha && entry.ciRunId) {
146
- // The same failed SHA is intentionally quiescent until PatchRelay moves
147
- // the workspace ref. Do not manufacture another CI run on every wakeup.
147
+ if (entry.candidateRef && entry.lastFailedBaseSha && entry.ciRunId) {
148
+ // Do not manufacture another CI run on every wakeup, but do re-read the
149
+ // exact SHA: an operator or GitHub may have rerun the failed workflow in
150
+ // place. A green rerun must resume the queue without a no-op commit.
151
+ const checks = (await ctx.github.listChecksForRef(entry.candidateSha))
152
+ .filter((check) => check.name.toLowerCase() !== INTEGRATION_REVIEW_CHECK);
153
+ const evaluation = evaluateCheckPolicy(ctx.policy.getRequiredCheckRules(), ctx.policy.shouldRequireAllChecksOnEmptyRequiredSet(), checks);
154
+ if (evaluation.status === "pass") {
155
+ await acceptPassingIntegrationCandidate(ctx, entry, allActive, index, isLandingHead, entry.ciRunId);
156
+ }
157
+ else if (evaluation.status === "pending") {
158
+ ctx.store.transition(entry.id, "validating", {
159
+ waitDetail: "waiting for exact-candidate rerun",
160
+ }, "exact-candidate rerun pending");
161
+ }
148
162
  return;
149
163
  }
150
164
  if (entry.candidateKind === "head") {
@@ -63,7 +63,7 @@ export async function reconcile(ctx) {
63
63
  // spec, so our cumulative spec is still valid (speculative consistency).
64
64
  if (entry.candidateBasedOn) {
65
65
  const dep = ctx.store.getEntry(entry.candidateBasedOn);
66
- if (!dep || dep.status === "dequeued" || dep.status === "evicted") {
66
+ if (!dep || dep.status === "dequeued" || dep.status === "evicted" || dep.status === "superseded") {
67
67
  emit(ctx, entry, "invalidated", {
68
68
  detail: `dependency ${entry.candidateBasedOn} is ${dep?.status ?? "removed"}`,
69
69
  });
@@ -140,6 +140,10 @@ export class MergeStewardQueueCommands {
140
140
  }
141
141
  try {
142
142
  const status = await this.github.getStatus(prNumber);
143
+ if (status.headSha !== headSha) {
144
+ this.logger.debug({ prNumber, eventHeadSha: headSha, liveHeadSha: status.headSha }, "Admission wakeup refers to a stale PR head");
145
+ return false;
146
+ }
143
147
  if (!status.reviewApproved) {
144
148
  this.logger.debug({ prNumber, reviewDecision: status.reviewDecision }, "PR review gate is not satisfied, skipping admission");
145
149
  return false;
@@ -147,42 +151,17 @@ export class MergeStewardQueueCommands {
147
151
  // Admission and ordering are derived from review/check/PR truth. Labels
148
152
  // remain available for presentation but are not control inputs.
149
153
  const priority = 0;
150
- const checks = await this.github.listChecks(prNumber);
154
+ const checks = await this.github.listChecksForRef(status.headSha);
151
155
  const requiredCheckRules = this.policy.getRequiredCheckRules();
152
- if (requiredCheckRules.length > 0) {
153
- const evaluation = evaluateCheckPolicy(requiredCheckRules, false, checks);
154
- if (evaluation.status !== "pass") {
155
- this.logger.debug({
156
- prNumber,
157
- checkNames: checks.map((check) => check.name),
158
- requiredChecks: requiredCheckRules.map(formatRequiredCheck),
159
- checkPolicyStatus: evaluation.status,
160
- }, "Required checks not all green");
161
- return false;
162
- }
163
- }
164
- else if (this.policy.shouldRequireAllChecksOnEmptyRequiredSet()) {
165
- if (checks.length === 0) {
166
- this.logger.debug({ prNumber }, "GitHub requires checks but none are visible yet, skipping admission");
167
- return false;
168
- }
169
- const hasPending = checks.some((check) => check.conclusion === "pending");
170
- const hasFailures = checks.some((check) => check.conclusion === "failure");
171
- if (hasPending || hasFailures) {
172
- this.logger.debug({
173
- prNumber,
174
- checkNames: checks.map((check) => `${check.name}:${check.conclusion}`),
175
- }, "GitHub requires all observed checks to pass before admission");
176
- return false;
177
- }
178
- }
179
- else {
180
- const nonSteward = checks.filter((c) => !c.name.startsWith("merge-steward"));
181
- const hasGreen = nonSteward.some((c) => c.conclusion === "success");
182
- if (!hasGreen) {
183
- this.logger.debug({ prNumber }, "No green CI checks, skipping admission");
184
- return false;
185
- }
156
+ const evaluation = evaluateCheckPolicy(requiredCheckRules, this.policy.shouldRequireAllChecksOnEmptyRequiredSet(), checks);
157
+ if (evaluation.status !== "pass") {
158
+ this.logger.debug({
159
+ prNumber,
160
+ checks: checks.map((check) => `${check.name}:${check.conclusion}`),
161
+ requiredChecks: requiredCheckRules.map(formatRequiredCheck),
162
+ checkPolicyStatus: evaluation.status,
163
+ }, "Branch checks are not settled green, skipping admission");
164
+ return false;
186
165
  }
187
166
  // A stacked PR waits for its parent queue entry. Monotonic positions
188
167
  // guarantee parent-before-child ordering, not strict adjacency.
@@ -228,8 +207,22 @@ export class MergeStewardQueueCommands {
228
207
  this.logger.debug({ prNumber, entryId: entry.id, headSha }, "Ignoring synchronize webhook for unchanged head");
229
208
  return;
230
209
  }
231
- this.store.updateHead(entry.id, headSha);
232
- this.logger.info({ prNumber, entryId: entry.id, headSha }, "PR head updated via webhook");
210
+ if (entry.candidateRef) {
211
+ this.specBuilder.deleteSpeculative(entry.candidateRef).catch(() => { });
212
+ }
213
+ this.store.transition(entry.id, "superseded", {
214
+ candidateKind: null,
215
+ candidatePolicyFingerprint: null,
216
+ candidateRef: null,
217
+ candidateSha: null,
218
+ candidateBasedOn: null,
219
+ ciRunId: null,
220
+ ciRetries: 0,
221
+ waitDetail: null,
222
+ }, `admitted head ${entry.headSha.slice(0, 12)} superseded by ${headSha.slice(0, 12)}; new head must pass admission`);
223
+ this.invalidateDownstreamOf(entry);
224
+ this.clearQueueStateLabels(prNumber).catch(() => { });
225
+ this.logger.info({ prNumber, entryId: entry.id, previousHeadSha: entry.headSha, headSha }, "PR head changed; admission revoked");
233
226
  }
234
227
  }
235
228
  async acknowledgeExternalMerge(prNumber) {
@@ -275,10 +268,10 @@ export class MergeStewardQueueCommands {
275
268
  if (downstream.candidateRef) {
276
269
  this.specBuilder.deleteSpeculative(downstream.candidateRef).catch(() => { });
277
270
  }
278
- this.store.transition(downstream.id, "preparing_head", INVALIDATION_PATCH, `invalidated: entry ${removedEntry.id.slice(0, 8)} dequeued`);
271
+ this.store.transition(downstream.id, "preparing_head", INVALIDATION_PATCH, `invalidated: entry ${removedEntry.id.slice(0, 8)} left the train`);
279
272
  }
280
273
  if (targets.length > 0) {
281
- this.logger.info({ removedEntryId: removedEntry.id, invalidated: targets.length }, "Invalidated downstream entries after dequeue");
274
+ this.logger.info({ removedEntryId: removedEntry.id, invalidated: targets.length }, "Invalidated downstream entries after train removal");
282
275
  }
283
276
  }
284
277
  findAffectedEntriesAfterPriorityChange(before, after) {
package/dist/types.d.ts CHANGED
@@ -9,9 +9,9 @@
9
9
  * Failure: any state → evicted (after retry budget exhausted).
10
10
  * Conflict retries are gated on base SHA change (non-spinning).
11
11
  *
12
- * Terminal states: merged, evicted, dequeued.
12
+ * Terminal states: merged, evicted, dequeued, superseded.
13
13
  */
14
- export type QueueEntryStatus = "queued" | "preparing_head" | "validating" | "merging" | "evicted" | "merged" | "dequeued";
14
+ export type QueueEntryStatus = "queued" | "preparing_head" | "validating" | "merging" | "evicted" | "merged" | "dequeued" | "superseded";
15
15
  export declare const TERMINAL_STATUSES: QueueEntryStatus[];
16
16
  export type PostMergeStatus = "pending" | "pass" | "fail" | "unknown";
17
17
  export type CandidateKind = "head" | "integration" | "integration_repair";
@@ -66,7 +66,7 @@ export interface QueueEntry {
66
66
  updatedAt: string;
67
67
  /**
68
68
  * Set once, when the entry first reaches a terminal status
69
- * (merged/evicted/dequeued), and never bumped afterward — unlike
69
+ * (merged/evicted/dequeued/superseded), and never bumped afterward — unlike
70
70
  * updatedAt, which post-merge re-verification keeps moving. Lets the
71
71
  * dashboard report an accurate "how long it took" (decidedAt - enqueuedAt)
72
72
  * and "how long ago" (now - decidedAt). Null while still in flight.
@@ -185,6 +185,7 @@ export interface QueueStatusSummary {
185
185
  merged: number;
186
186
  evicted: number;
187
187
  dequeued: number;
188
+ superseded: number;
188
189
  headEntryId: string | null;
189
190
  headPrNumber: number | null;
190
191
  }
package/dist/types.js CHANGED
@@ -1 +1 @@
1
- export const TERMINAL_STATUSES = ["merged", "evicted", "dequeued"];
1
+ export const TERMINAL_STATUSES = ["merged", "evicted", "dequeued", "superseded"];
@@ -59,6 +59,8 @@ function entryKind(entry) {
59
59
  return "error";
60
60
  case "dequeued":
61
61
  return "cancelled";
62
+ case "superseded":
63
+ return "cancelled";
62
64
  }
63
65
  }
64
66
  function entryPhrase(entry, isHead) {
@@ -87,6 +89,8 @@ function entryPhrase(entry, isHead) {
87
89
  return "evicted";
88
90
  case "dequeued":
89
91
  return "dequeued";
92
+ case "superseded":
93
+ return "superseded by a newer PR head";
90
94
  }
91
95
  }
92
96
  function entrySummary(entry) {
@@ -130,7 +134,7 @@ function repoEntriesFromSnapshot(snapshot, cutoff, now) {
130
134
  const active = isActive(entry.status);
131
135
  if (!active && timestamp(entry.updatedAt) < cutoff)
132
136
  continue;
133
- if (entry.status === "dequeued")
137
+ if (entry.status === "dequeued" || entry.status === "superseded")
134
138
  continue;
135
139
  const isHead = head !== null && entry.id === head.id;
136
140
  const kind = entryKind(entry);
@@ -325,6 +329,7 @@ export function buildQueueSummary(entries) {
325
329
  merged: 0,
326
330
  evicted: 0,
327
331
  dequeued: 0,
332
+ superseded: 0,
328
333
  headEntryId: null,
329
334
  headPrNumber: null,
330
335
  };
@@ -357,6 +362,9 @@ export function buildQueueSummary(entries) {
357
362
  case "dequeued":
358
363
  summary.dequeued += 1;
359
364
  break;
365
+ case "superseded":
366
+ summary.superseded += 1;
367
+ break;
360
368
  }
361
369
  }
362
370
  return summary;
@@ -72,6 +72,7 @@ export function statusColor(status, entry) {
72
72
  case "evicted":
73
73
  return "red";
74
74
  case "dequeued":
75
+ case "superseded":
75
76
  return "gray";
76
77
  }
77
78
  }
@@ -99,6 +100,8 @@ export function humanStatus(status, entry) {
99
100
  return "needs repair";
100
101
  case "dequeued":
101
102
  return "removed";
103
+ case "superseded":
104
+ return "superseded";
102
105
  }
103
106
  }
104
107
  export function queueProgress(status) {
@@ -113,6 +116,7 @@ export function queueProgress(status) {
113
116
  case "merged":
114
117
  case "evicted":
115
118
  case "dequeued":
119
+ case "superseded":
116
120
  return { current: 4, total: 4 };
117
121
  }
118
122
  }
@@ -142,6 +146,8 @@ export function nextStepLabel(status, entry) {
142
146
  return "needs branch repair before re-admission";
143
147
  case "dequeued":
144
148
  return "removed from queue";
149
+ case "superseded":
150
+ return "new PR head must pass approval and branch CI before admission";
145
151
  }
146
152
  }
147
153
  /** Describe the immutable candidate chain for a queue entry. */
@@ -173,6 +179,7 @@ const STATUS_DISPLAY = {
173
179
  merged: "merged",
174
180
  evicted: "evicted",
175
181
  dequeued: "removed",
182
+ superseded: "superseded",
176
183
  };
177
184
  function displayStatus(status) {
178
185
  return STATUS_DISPLAY[status] ?? status;
@@ -233,6 +240,9 @@ export function formatEventNarrative(event, options = {}) {
233
240
  if (event.toStatus === "dequeued") {
234
241
  return withDetail(`${prPrefix}was removed from the queue.`, event.detail);
235
242
  }
243
+ if (event.toStatus === "superseded") {
244
+ return withDetail(`${prPrefix}left the queue because its admitted head changed.`, event.detail);
245
+ }
236
246
  if (event.toStatus === "queued" && event.fromStatus) {
237
247
  return withDetail(`${prPrefix}was re-queued for another attempt.`, event.detail);
238
248
  }
@@ -129,7 +129,8 @@ export async function processWebhookEvent(event, service, config, logger) {
129
129
  break;
130
130
  }
131
131
  case "pr_synchronize": {
132
- // PR was force-pushed. Update head if queued.
132
+ // A queued admission is immutable. Retire it when the PR head changes;
133
+ // the new head may re-enter only after its own approval and green CI.
133
134
  service.updateHeadByPR(event.prNumber, event.headSha);
134
135
  break;
135
136
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.35.0",
3
+ "version": "0.35.2",
4
4
  "description": "Serial merge queue for GitHub — rebase, CI-gate, and merge PRs one at a time",
5
5
  "type": "module",
6
6
  "repository": {