merge-steward 0.34.4 → 0.35.1

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
@@ -2,7 +2,9 @@
2
2
 
3
3
  Self-hosted merge queue for bot-managed and human-managed GitHub pull requests. Merge Steward turns reviewed PRs into a tested landing train: it runs CI on the exact future `main` SHAs, validates several PRs in parallel, and fast-forwards through the green sequence as soon as it is safe.
4
4
 
5
- Independent of PatchRelay. Communicates through GitHub only — PRs, reviews, checks, labels, branches. Pairs with `review-quill`; neither requires the other.
5
+ Independent of PatchRelay. Communicates through GitHub state only — PRs,
6
+ reviews, candidate refs, ancestry, and checks. Labels and comments are not
7
+ control messages. Pairs with `review-quill`; neither requires the other.
6
8
 
7
9
  For the background story and design trade-offs, read [merge-steward: speculative integration, parallel validation, fast-forward landing](https://blog.krasnoperov.me/posts/merge-steward).
8
10
 
@@ -13,12 +15,24 @@ The queue keeps delivery fast without pretending branch CI is always enough. For
13
15
  ## How it works
14
16
 
15
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.
16
20
  2. The steward notices through webhook wakeups or startup reconcile scans, and admits the PR to the queue.
17
- 3. It resolves the exact future-`main` candidate. If the prospective base is an ancestor of the PR head, the candidate is that already-tested head SHA. Otherwise it builds a cumulative integration commit.
21
+ 3. It freezes the approved PR head and resolves the exact future-`main`
22
+ candidate. If the prospective base is its ancestor, that head is the
23
+ candidate. Otherwise it publishes
24
+ `merge-steward/<base>/pr-<number>` as a cumulative integration workspace.
18
25
  4. It validates checks on that exact SHA. Only newly-created integration candidates trigger synthetic CI.
19
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.
20
- 6. On CI failure: retry (gated on base SHA change), then evict with a durable incident record and GitHub check run.
21
- 7. PatchRelay, the `ship-pr` skill, or any agent sees the check run failure and fixes the branch; when CI passes again the PR can be re-admitted.
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.
29
+ 6. On conflict, the workspace remains at the prospective base; PatchRelay
30
+ derives the missing ancestry and non-force pushes a resolved candidate.
31
+ 7. On candidate-CI failure, PatchRelay repairs the candidate rather than the PR
32
+ branch. Review Quill verifies only that an agent repair preserved the
33
+ approved feature.
34
+ 8. Ordinary integration failures retain queue position. Feature implementation
35
+ reopens only when integration review proves the feature must change.
22
36
 
23
37
  This is structural, not an optional fast path. An exact head is safe precisely
24
38
  when it already contains the prospective base; if it does not, Merge Steward
@@ -26,7 +40,10 @@ creates and tests the integration candidate. Checks never move between SHAs.
26
40
 
27
41
  ## Use with your own agent
28
42
 
29
- For an agent that drives PRs through the queue and reacts to evictions / failing checks without running PatchRelay's full harness, install the [`ship-pr`](https://github.com/krasnoperov/patchrelay-agents) skill from the companion Claude Code marketplace:
43
+ For an agent that drives PRs through the queue and reacts to candidate state and
44
+ failing checks without running PatchRelay's full harness, install the
45
+ [`ship-pr`](https://github.com/krasnoperov/patchrelay-agents) skill from the
46
+ companion Claude Code marketplace:
30
47
 
31
48
  ```
32
49
  /plugin marketplace add krasnoperov/patchrelay-agents
@@ -73,7 +90,7 @@ Each repository reconcile tick is bounded by `reconcileStaleAfterMs` (five minut
73
90
  | Code | Meaning |
74
91
  |-|-|
75
92
  | 0 | merged / approved with green required checks |
76
- | 2 | changes_requested / failing required checks / evicted / closed |
93
+ | 2 | changes_requested / integration review requires feature rework / policy failure / closed |
77
94
  | 3 | still in flight (queued, preparing, validating, merging, pending) |
78
95
  | 4 | `--wait` timed out |
79
96
  | 1 | usage or configuration error |
@@ -95,10 +112,14 @@ The real gate is:
95
112
 
96
113
  Independent services, GitHub as the shared bus:
97
114
 
98
- 1. PatchRelay moves an issue to `awaiting_queue` when the linked PR is approved and green, and may add the configured queue label as an admission nudge.
99
- 2. The steward admits from fresh GitHub truth, lands the PR, or evicts and creates the eviction check run (default `merge-steward/queue`).
100
- 3. PatchRelay watches for that check run failure and triggers `queue_repair`.
101
- 4. After repair, PatchRelay pushes a new head; the steward re-admits only after the new head is approved and green.
115
+ 1. The steward admits an open PR when GitHub shows an approved head and green
116
+ branch checks; no label is required.
117
+ 2. It publishes a self-describing candidate ref. Missing approved-head ancestry
118
+ means conflict repair; settled red candidate checks mean test repair.
119
+ 3. PatchRelay derives either condition from GitHub and non-force pushes only the
120
+ candidate ref.
121
+ 4. Review Quill publishes `review-quill/integration` when PatchRelay changed the
122
+ candidate. The steward lands the exact green SHA when that check is satisfied.
102
123
 
103
124
  Neither service calls the other's API.
104
125
 
@@ -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/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",
@@ -87,6 +87,8 @@ function formatTitle(incident) {
87
87
  return "Queue eviction: CI failure (branch-specific)";
88
88
  case "main_broken":
89
89
  return "Queue eviction: main branch CI failing";
90
+ case "feature_changed":
91
+ return "Queue eviction: integration repair changed the approved feature";
90
92
  case "policy_blocked":
91
93
  return incident.context.openPrAncestors?.length
92
94
  ? "Queue eviction: candidate shares unlanded history with another open PR"
@@ -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
  }
@@ -26,6 +26,7 @@ export declare class ShellGitOperations implements GitOperations, SpeculativeBra
26
26
  mergeBase(left: string, right: string): Promise<string>;
27
27
  isAncestor(ancestor: string, descendant: string): Promise<boolean>;
28
28
  push(branch: string, force?: boolean, targetBranch?: string): Promise<void>;
29
+ createWorkspace(specName: string, baseBranch: string): Promise<string>;
29
30
  /**
30
31
  * Build a speculative merge branch using an isolated git worktree.
31
32
  * Each call gets its own working directory — no shared mutable state.
@@ -1,5 +1,5 @@
1
1
  import { mkdirSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { dirname, join } from "node:path";
3
3
  import { exec } from "../exec.js";
4
4
  /** Extract conflict file names from git merge stderr. */
5
5
  function parseConflicts(stderr) {
@@ -98,6 +98,17 @@ export class ShellGitOperations {
98
98
  await this.git(args, { timeoutMs: 60_000 });
99
99
  }
100
100
  // ─── SpeculativeBranchBuilder ───────────────────────────────
101
+ async createWorkspace(specName, baseBranch) {
102
+ const wtPath = join(this.worktreeBase, specName);
103
+ await this.git(["worktree", "remove", "--force", wtPath], { allowNonZero: true });
104
+ await this.git(["branch", "-D", specName], { allowNonZero: true });
105
+ await this.git(["worktree", "prune"], { allowNonZero: true });
106
+ mkdirSync(dirname(wtPath), { recursive: true });
107
+ await this.git(["worktree", "add", "-B", specName, wtPath, baseBranch]);
108
+ const sha = (await this.gitIn(wtPath, ["rev-parse", "HEAD"])).stdout.trim();
109
+ await this.git(["worktree", "remove", "--force", wtPath], { allowNonZero: true });
110
+ return sha;
111
+ }
101
112
  /**
102
113
  * Build a speculative merge branch using an isolated git worktree.
103
114
  * Each call gets its own working directory — no shared mutable state.
@@ -111,7 +122,7 @@ export class ShellGitOperations {
111
122
  await this.git(["branch", "-D", specName], { allowNonZero: true });
112
123
  await this.git(["worktree", "prune"], { allowNonZero: true });
113
124
  // Create isolated worktree with spec branch starting at baseBranch.
114
- mkdirSync(this.worktreeBase, { recursive: true });
125
+ mkdirSync(dirname(wtPath), { recursive: true });
115
126
  await this.git(["worktree", "add", "-B", specName, wtPath, baseBranch]);
116
127
  // Override git identity so merge commits are attributed to the steward, not the clone owner.
117
128
  if (this.botIdentity) {
@@ -137,7 +148,9 @@ export class ShellGitOperations {
137
148
  }
138
149
  await this.gitIn(wtPath, ["merge", "--abort"], { allowNonZero: true });
139
150
  await this.git(["worktree", "remove", "--force", wtPath], { allowNonZero: true });
140
- await this.git(["branch", "-D", specName], { allowNonZero: true });
151
+ // Keep the workspace branch at the prospective base. Publishing this
152
+ // ref is the cross-service repair signal: it exists, but does not yet
153
+ // contain the approved PR head.
141
154
  return { success: false, conflictFiles };
142
155
  }
143
156
  const sha = (await this.gitIn(wtPath, ["rev-parse", "HEAD"])).stdout.trim();
@@ -15,6 +15,8 @@ export interface GitOperations {
15
15
  * Separate from GitOperations to keep the core interface minimal.
16
16
  */
17
17
  export interface SpeculativeBranchBuilder {
18
+ /** Create/reset a candidate workspace at an exact base commit. */
19
+ createWorkspace(specName: string, baseBranch: string): Promise<string>;
18
20
  /** Merge prBranch into baseBranch, store result as specName. */
19
21
  buildSpeculative(prBranch: string, baseBranch: string, specName: string, mergeMessage?: string): Promise<MergeResult>;
20
22
  /** Delete a speculative branch (cleanup after merge/eviction). */
@@ -24,7 +24,8 @@ export interface ReconcileContext {
24
24
  };
25
25
  onEvent: (event: ReconcileEvent) => void;
26
26
  }
27
- export declare const CANDIDATE_REF_PREFIX = "mq-spec-";
27
+ export declare const CANDIDATE_REF_PREFIX = "merge-steward";
28
+ export declare const INTEGRATION_REVIEW_CHECK = "review-quill/integration";
28
29
  export declare const FAILED_CONCLUSIONS: Set<string>;
29
30
  export declare const CLEAR_CANDIDATE: {
30
31
  readonly candidateKind: null;
@@ -44,6 +45,6 @@ export declare const CLEAN_CI: {
44
45
  };
45
46
  export declare function emit(ctx: ReconcileContext, entry: QueueEntry, action: ReconcileAction, extra?: Partial<ReconcileEvent>): void;
46
47
  export declare function ref(ctx: ReconcileContext, name: string): string;
47
- export declare function candidateRefName(entryId: string): string;
48
+ export declare function candidateRefName(baseBranch: string, prNumber: number): string;
48
49
  export declare function isBudgetExhausted(entry: QueueEntry): boolean;
49
50
  export declare function isRetryGated(entry: QueueEntry, currentBaseSha: string): boolean;
@@ -1,4 +1,5 @@
1
- export const CANDIDATE_REF_PREFIX = "mq-spec-";
1
+ export const CANDIDATE_REF_PREFIX = "merge-steward";
2
+ export const INTEGRATION_REVIEW_CHECK = "review-quill/integration";
2
3
  export const FAILED_CONCLUSIONS = new Set(["failure"]);
3
4
  export const CLEAR_CANDIDATE = {
4
5
  candidateKind: null,
@@ -16,8 +17,8 @@ export function emit(ctx, entry, action, extra) {
16
17
  export function ref(ctx, name) {
17
18
  return ctx.remotePrefix + name;
18
19
  }
19
- export function candidateRefName(entryId) {
20
- return `${CANDIDATE_REF_PREFIX}${entryId}`;
20
+ export function candidateRefName(baseBranch, prNumber) {
21
+ return `${CANDIDATE_REF_PREFIX}/${baseBranch}/pr-${prNumber}`;
21
22
  }
22
23
  export function isBudgetExhausted(entry) {
23
24
  return entry.retryAttempts >= entry.maxRetries;
@@ -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,17 +1,29 @@
1
- import { CLEAN_CI, CLEAR_CANDIDATE, emit, isRetryGated, ref, candidateRefName } from "./reconciler-core.js";
2
- import { evictEntry } from "./reconciler-evict.js";
1
+ import { CLEAN_CI, CLEAR_CANDIDATE, emit, ref, candidateRefName } from "./reconciler-core.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");
6
6
  await ctx.git.fetch();
7
+ const predecessorNeedsRepair = prevEntry?.lastFailedBaseSha !== null
8
+ && prevEntry?.lastFailedBaseSha !== undefined
9
+ && prevEntry?.ciRunId !== null
10
+ && prevEntry?.ciRunId !== undefined;
11
+ if (prevEntry?.candidateSha && (predecessorNeedsRepair
12
+ || !await ctx.git.isAncestor(prevEntry.headSha, prevEntry.candidateSha))) {
13
+ const detail = `predecessor PR #${prevEntry.prNumber} is awaiting integration repair`;
14
+ if (entry.waitDetail !== detail) {
15
+ emit(ctx, entry, "stack_dependency_waiting", { detail });
16
+ ctx.store.transition(entry.id, "preparing_head", { waitDetail: detail }, detail);
17
+ }
18
+ return;
19
+ }
7
20
  const base = isHead ? ref(ctx, ctx.baseBranch) : prevEntry?.candidateSha ?? null;
8
21
  if (!base)
9
22
  return;
10
23
  const baseSha = await ctx.git.headSha(base);
11
24
  const currentRef = await ctx.git.headSha(ref(ctx, entry.branch));
12
25
  if (currentRef !== entry.headSha) {
13
- emit(ctx, entry, "branch_mismatch", { detail: `expected ${entry.headSha.slice(0, 8)}, got ${currentRef.slice(0, 8)}` });
14
- ctx.store.updateHead(entry.id, currentRef);
26
+ await supersedeAdmittedHead(ctx, entry, currentRef);
15
27
  return;
16
28
  }
17
29
  if (isHead) {
@@ -33,24 +45,6 @@ export async function prepareEntry(ctx, entry, isHead, prevEntry) {
33
45
  // Preparing/validating the head never waits on main's CI. The exact
34
46
  // candidate includes current main and is gated solely by its own checks.
35
47
  //
36
- // The conflict cache applies at every lookahead depth. A downstream child
37
- // otherwise rebuilds the same impossible merge on every reconcile tick
38
- // while its predecessor is still validating.
39
- if (isRetryGated(entry, baseSha)) {
40
- emit(ctx, entry, "retry_gated", {
41
- baseSha,
42
- detail: "same base and head already produced a deterministic conflict",
43
- });
44
- if (isHead) {
45
- await evictEntry(ctx, entry, "integration_conflict");
46
- }
47
- else if (entry.waitDetail !== "deterministic conflict; waiting for prospective base to change") {
48
- ctx.store.transition(entry.id, "preparing_head", {
49
- waitDetail: "deterministic conflict; waiting for prospective base to change",
50
- }, "deterministic conflict cached; waiting for prospective base to change");
51
- }
52
- return;
53
- }
54
48
  // A remote merge can advance main before GitHub's PR API reports `merged`.
55
49
  // The PR head is then already contained in main, so building and testing an
56
50
  // "integration" candidate would only reproduce the current main commit.
@@ -92,7 +86,7 @@ export async function prepareEntry(ctx, entry, isHead, prevEntry) {
92
86
  }, `head candidate ${entry.headSha.slice(0, 12)} selected on ${baseSha.slice(0, 12)}`);
93
87
  return;
94
88
  }
95
- const specName = candidateRefName(entry.id);
89
+ const specName = candidateRefName(ctx.baseBranch, entry.prNumber);
96
90
  emit(ctx, entry, "integration_build_started", { candidateRef: specName, baseSha, ...(prevEntry ? { dependsOn: prevEntry.id } : {}) });
97
91
  const branchSuffix = entry.branch.replace(/^.*\//, "").replace(/-/g, " ");
98
92
  const mergeMessage = `Merge PR #${entry.prNumber}: ${branchSuffix}`;
@@ -115,17 +109,21 @@ export async function prepareEntry(ctx, entry, isHead, prevEntry) {
115
109
  }
116
110
  if (!result.success) {
117
111
  emit(ctx, entry, "integration_build_conflict", { baseSha, conflictFiles: result.conflictFiles });
118
- if (isHead) {
119
- await evictEntry(ctx, entry, "integration_conflict", result.conflictFiles ? { conflictFiles: result.conflictFiles } : undefined);
120
- }
121
- else {
122
- ctx.store.transition(entry.id, "preparing_head", {
123
- baseSha,
124
- lastFailedBaseSha: baseSha,
125
- ...CLEAN_CI,
126
- ...CLEAR_CANDIDATE,
127
- }, `deterministic conflict cached for ${baseSha.slice(0, 8)} and head ${entry.headSha.slice(0, 8)}`);
128
- }
112
+ // buildSpeculative deliberately leaves the local workspace ref at the
113
+ // prospective base. Publish it so PatchRelay can derive the repair from
114
+ // GitHub ancestry alone and add a merge commit with a non-force push.
115
+ await ctx.git.push(specName, true);
116
+ ctx.store.transition(entry.id, "validating", {
117
+ baseSha,
118
+ ...CLEAN_CI,
119
+ candidateKind: "integration",
120
+ candidatePolicyFingerprint: ctx.policy.getFingerprint(),
121
+ candidateRef: specName,
122
+ candidateSha: baseSha,
123
+ candidateBasedOn: isHead ? null : prevEntry.id,
124
+ lastFailedBaseSha: baseSha,
125
+ waitDetail: `integration conflict; workspace awaits repair${result.conflictFiles?.length ? ` in ${result.conflictFiles.join(", ")}` : ""}`,
126
+ }, `integration workspace published at ${baseSha.slice(0, 12)} for conflict repair`);
129
127
  return;
130
128
  }
131
129
  const candidateSha = result.sha ?? entry.headSha;
@@ -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) {
@@ -1,22 +1,104 @@
1
- import { emit, ref } from "./reconciler-core.js";
2
- import { classifyFailure } from "./classify.js";
1
+ import { CLEAN_CI, CLEAR_CANDIDATE, INTEGRATION_REVIEW_CHECK, candidateRefName, emit, ref } from "./reconciler-core.js";
3
2
  import { evictEntry, invalidateDownstream } from "./reconciler-evict.js";
4
3
  import { evaluateCheckPolicy, formatRequiredCheck } from "./check-policy.js";
5
- async function evictFailedCandidate(ctx, entry, allActive, index, checks) {
6
- const failedChecks = checks
4
+ async function holdForIntegrationRepair(ctx, entry, allActive, index, checks) {
5
+ let candidateRef = entry.candidateRef;
6
+ if (!candidateRef) {
7
+ candidateRef = candidateRefName(ctx.baseBranch, entry.prNumber);
8
+ await ctx.specBuilder.createWorkspace(candidateRef, entry.candidateSha ?? entry.headSha);
9
+ await ctx.git.push(candidateRef, true);
10
+ }
11
+ const failed = checks
7
12
  .filter((check) => check.conclusion === "failure" || check.conclusion === "skipped")
8
- .map((check) => ({
9
- name: check.name,
10
- conclusion: check.conclusion,
11
- ...(check.url ? { url: check.url } : {}),
12
- }));
13
- const mainChecks = await ctx.github.listChecksForRef(ref(ctx, ctx.baseBranch));
14
- const failureClass = checks.some((check) => check.conclusion === "skipped")
15
- ? "policy_blocked"
16
- : classifyFailure(checks, mainChecks);
17
- await evictEntry(ctx, entry, failureClass, { failedChecks });
18
- if (index >= 0) {
13
+ .map((check) => check.name)
14
+ .join(", ");
15
+ ctx.store.transition(entry.id, "validating", {
16
+ candidateKind: "integration",
17
+ ciRunId: entry.ciRunId ?? `head:${entry.candidateSha ?? entry.headSha}`,
18
+ candidateRef,
19
+ candidateSha: entry.candidateSha ?? entry.headSha,
20
+ candidateBasedOn: entry.candidateBasedOn,
21
+ lastFailedBaseSha: entry.baseSha,
22
+ waitDetail: `candidate CI failed; integration workspace awaits repair${failed ? ` (${failed})` : ""}`,
23
+ }, "candidate retained for integration repair");
24
+ if (index >= 0)
19
25
  await invalidateDownstream(ctx, allActive, index);
26
+ }
27
+ async function refreshIntegrationWorkspace(ctx, entry, allActive, index) {
28
+ if (!entry.candidateRef)
29
+ return entry;
30
+ await ctx.git.fetch();
31
+ const liveSha = await ctx.git.headSha(ref(ctx, entry.candidateRef));
32
+ const dependency = entry.candidateBasedOn ? ctx.store.getEntry(entry.candidateBasedOn) : null;
33
+ const expectedBaseSha = dependency && dependency.status !== "merged"
34
+ ? dependency.candidateSha
35
+ : await ctx.git.headSha(ref(ctx, ctx.baseBranch));
36
+ if (!expectedBaseSha || !await ctx.git.isAncestor(expectedBaseSha, liveSha)) {
37
+ emit(ctx, entry, "invalidated", { detail: "integration workspace no longer descends from its prospective base" });
38
+ ctx.store.transition(entry.id, "preparing_head", { ...CLEAN_CI, ...CLEAR_CANDIDATE }, "stale integration workspace; rebuilding");
39
+ if (index >= 0)
40
+ await invalidateDownstream(ctx, allActive, index);
41
+ return null;
42
+ }
43
+ if (liveSha !== entry.candidateSha) {
44
+ emit(ctx, entry, "candidate_selected", {
45
+ candidateRef: entry.candidateRef,
46
+ candidateKind: "integration_repair",
47
+ candidateSha: liveSha,
48
+ baseSha: expectedBaseSha,
49
+ detail: "external non-force integration repair detected",
50
+ });
51
+ ctx.store.transition(entry.id, "validating", {
52
+ baseSha: expectedBaseSha,
53
+ ...CLEAN_CI,
54
+ candidateKind: "integration_repair",
55
+ candidatePolicyFingerprint: ctx.policy.getFingerprint(),
56
+ candidateRef: entry.candidateRef,
57
+ candidateSha: liveSha,
58
+ candidateBasedOn: entry.candidateBasedOn,
59
+ lastFailedBaseSha: entry.lastFailedBaseSha ?? entry.baseSha,
60
+ waitDetail: "integration repair detected; validating exact candidate",
61
+ }, `integration workspace advanced to ${liveSha.slice(0, 12)}`);
62
+ if (index >= 0)
63
+ await invalidateDownstream(ctx, allActive, index);
64
+ return ctx.store.getEntry(entry.id) ?? null;
65
+ }
66
+ return entry;
67
+ }
68
+ async function integrationReviewStatus(ctx, entry) {
69
+ if (entry.candidateKind !== "integration_repair")
70
+ return "pass";
71
+ const checks = await ctx.github.listChecksForRef(entry.candidateSha);
72
+ return evaluateCheckPolicy([{ name: INTEGRATION_REVIEW_CHECK, appId: null }], false, checks).status;
73
+ }
74
+ async function acceptPassingIntegrationCandidate(ctx, entry, allActive, index, isLandingHead, ciRunId) {
75
+ const reviewStatus = await integrationReviewStatus(ctx, entry);
76
+ if (reviewStatus === "pending") {
77
+ ctx.store.transition(entry.id, "validating", {
78
+ waitDetail: `waiting for ${INTEGRATION_REVIEW_CHECK} on repaired candidate`,
79
+ }, `waiting for ${INTEGRATION_REVIEW_CHECK}`);
80
+ return;
81
+ }
82
+ if (reviewStatus === "fail") {
83
+ await evictEntry(ctx, entry, "feature_changed");
84
+ if (index >= 0)
85
+ await invalidateDownstream(ctx, allActive, index);
86
+ return;
87
+ }
88
+ emit(ctx, entry, "ci_passed", {
89
+ ciRunId,
90
+ candidateKind: entry.candidateKind ?? undefined,
91
+ candidateSha: entry.candidateSha ?? undefined,
92
+ policyFingerprint: entry.candidatePolicyFingerprint ?? undefined,
93
+ });
94
+ if (isLandingHead) {
95
+ ctx.store.transition(entry.id, "merging", { lastFailedBaseSha: null, waitDetail: null }, "CI and integration review passed, ready to merge");
96
+ }
97
+ else if (entry.lastFailedBaseSha) {
98
+ ctx.store.transition(entry.id, "validating", {
99
+ lastFailedBaseSha: null,
100
+ waitDetail: null,
101
+ }, "integration repair validated for speculative descendants");
20
102
  }
21
103
  }
22
104
  async function requestBoundedRerun(ctx, entry, params) {
@@ -41,7 +123,7 @@ async function requestBoundedRerun(ctx, entry, params) {
41
123
  detail: `candidate rerun unavailable (${attempt}/${ctx.flakyRetries}): ${detail}`,
42
124
  });
43
125
  if (attempt >= ctx.flakyRetries) {
44
- await evictFailedCandidate(ctx, entry, params.allActive, params.index, params.checks);
126
+ await holdForIntegrationRepair(ctx, entry, params.allActive, params.index, params.checks);
45
127
  }
46
128
  else {
47
129
  ctx.store.transition(entry.id, "validating", {
@@ -53,6 +135,18 @@ async function requestBoundedRerun(ctx, entry, params) {
53
135
  }
54
136
  }
55
137
  export async function checkValidation(ctx, entry, allActive, index, isLandingHead) {
138
+ const refreshed = await refreshIntegrationWorkspace(ctx, entry, allActive, index);
139
+ if (!refreshed)
140
+ return;
141
+ entry = refreshed;
142
+ if (entry.candidateRef && !await ctx.git.isAncestor(entry.headSha, entry.candidateSha)) {
143
+ return;
144
+ }
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.
148
+ return;
149
+ }
56
150
  if (entry.candidateKind === "head") {
57
151
  const checks = await ctx.github.listChecksForRef(entry.candidateSha ?? entry.headSha);
58
152
  const evaluation = evaluateCheckPolicy(ctx.policy.getRequiredCheckRules(), ctx.policy.shouldRequireAllChecksOnEmptyRequiredSet(), checks);
@@ -83,7 +177,7 @@ export async function checkValidation(ctx, entry, allActive, index, isLandingHea
83
177
  });
84
178
  return;
85
179
  }
86
- await evictFailedCandidate(ctx, entry, allActive, index, checks);
180
+ await holdForIntegrationRepair(ctx, entry, allActive, index, checks);
87
181
  return;
88
182
  }
89
183
  emit(ctx, entry, "ci_passed", {
@@ -100,6 +194,21 @@ export async function checkValidation(ctx, entry, allActive, index, isLandingHea
100
194
  if (!entry.ciRunId) {
101
195
  const branch = entry.candidateRef ?? entry.branch;
102
196
  const sha = entry.candidateSha ?? entry.headSha;
197
+ const existingChecks = (await ctx.github.listChecksForRef(sha))
198
+ .filter((check) => check.name.toLowerCase() !== INTEGRATION_REVIEW_CHECK);
199
+ if (existingChecks.length > 0) {
200
+ const evaluation = evaluateCheckPolicy(ctx.policy.getRequiredCheckRules(), ctx.policy.shouldRequireAllChecksOnEmptyRequiredSet(), existingChecks);
201
+ if (evaluation.status === "pass") {
202
+ await acceptPassingIntegrationCandidate(ctx, entry, allActive, index, isLandingHead, `checks:${sha}`);
203
+ }
204
+ else if (evaluation.status === "pending") {
205
+ emit(ctx, entry, "ci_pending", { detail: "existing exact-SHA checks pending" });
206
+ }
207
+ else {
208
+ await holdForIntegrationRepair(ctx, entry, allActive, index, existingChecks);
209
+ }
210
+ return;
211
+ }
103
212
  const runId = await ctx.ci.triggerRun(branch, sha);
104
213
  emit(ctx, entry, "ci_triggered", { ciRunId: runId });
105
214
  ctx.store.transition(entry.id, "validating", { ciRunId: runId }, `CI triggered: ${runId.slice(0, 12)}`);
@@ -111,15 +220,7 @@ export async function checkValidation(ctx, entry, allActive, index, isLandingHea
111
220
  emit(ctx, entry, "ci_pending", { ciRunId: entry.ciRunId });
112
221
  break;
113
222
  case "pass":
114
- emit(ctx, entry, "ci_passed", {
115
- ciRunId: entry.ciRunId,
116
- candidateKind: entry.candidateKind ?? undefined,
117
- candidateSha: entry.candidateSha ?? undefined,
118
- policyFingerprint: entry.candidatePolicyFingerprint ?? undefined,
119
- });
120
- if (isLandingHead) {
121
- ctx.store.transition(entry.id, "merging", undefined, "CI passed, ready to merge");
122
- }
223
+ await acceptPassingIntegrationCandidate(ctx, entry, allActive, index, isLandingHead, entry.ciRunId);
123
224
  break;
124
225
  case "fail": {
125
226
  emit(ctx, entry, "ci_failed", { ciRunId: entry.ciRunId });
@@ -140,7 +241,7 @@ export async function checkValidation(ctx, entry, allActive, index, isLandingHea
140
241
  else {
141
242
  const sha = entry.candidateSha ?? entry.headSha;
142
243
  const checks = await ctx.github.listChecksForRef(sha);
143
- await evictFailedCandidate(ctx, entry, allActive, index, checks);
244
+ await holdForIntegrationRepair(ctx, entry, allActive, index, checks);
144
245
  }
145
246
  break;
146
247
  }
@@ -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,54 +140,28 @@ 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;
146
150
  }
147
- let labels = [];
148
- try {
149
- labels = await this.github.listLabels(prNumber);
150
- }
151
- catch (error) {
152
- this.logger.debug({ prNumber, err: error }, "Could not read labels for priority detection; continuing without priority label");
153
- }
154
- const priority = labels.includes(this.config.priorityQueueLabel) ? 1 : 0;
155
- const checks = await this.github.listChecks(prNumber);
151
+ // Admission and ordering are derived from review/check/PR truth. Labels
152
+ // remain available for presentation but are not control inputs.
153
+ const priority = 0;
154
+ const checks = await this.github.listChecksForRef(status.headSha);
156
155
  const requiredCheckRules = this.policy.getRequiredCheckRules();
157
- if (requiredCheckRules.length > 0) {
158
- const evaluation = evaluateCheckPolicy(requiredCheckRules, false, checks);
159
- if (evaluation.status !== "pass") {
160
- this.logger.debug({
161
- prNumber,
162
- checkNames: checks.map((check) => check.name),
163
- requiredChecks: requiredCheckRules.map(formatRequiredCheck),
164
- checkPolicyStatus: evaluation.status,
165
- }, "Required checks not all green");
166
- return false;
167
- }
168
- }
169
- else if (this.policy.shouldRequireAllChecksOnEmptyRequiredSet()) {
170
- if (checks.length === 0) {
171
- this.logger.debug({ prNumber }, "GitHub requires checks but none are visible yet, skipping admission");
172
- return false;
173
- }
174
- const hasPending = checks.some((check) => check.conclusion === "pending");
175
- const hasFailures = checks.some((check) => check.conclusion === "failure");
176
- if (hasPending || hasFailures) {
177
- this.logger.debug({
178
- prNumber,
179
- checkNames: checks.map((check) => `${check.name}:${check.conclusion}`),
180
- }, "GitHub requires all observed checks to pass before admission");
181
- return false;
182
- }
183
- }
184
- else {
185
- const nonSteward = checks.filter((c) => !c.name.startsWith("merge-steward"));
186
- const hasGreen = nonSteward.some((c) => c.conclusion === "success");
187
- if (!hasGreen) {
188
- this.logger.debug({ prNumber }, "No green CI checks, skipping admission");
189
- return false;
190
- }
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;
191
165
  }
192
166
  // A stacked PR waits for its parent queue entry. Monotonic positions
193
167
  // guarantee parent-before-child ordering, not strict adjacency.
@@ -233,8 +207,22 @@ export class MergeStewardQueueCommands {
233
207
  this.logger.debug({ prNumber, entryId: entry.id, headSha }, "Ignoring synchronize webhook for unchanged head");
234
208
  return;
235
209
  }
236
- this.store.updateHead(entry.id, headSha);
237
- 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");
238
226
  }
239
227
  }
240
228
  async acknowledgeExternalMerge(prNumber) {
@@ -280,10 +268,10 @@ export class MergeStewardQueueCommands {
280
268
  if (downstream.candidateRef) {
281
269
  this.specBuilder.deleteSpeculative(downstream.candidateRef).catch(() => { });
282
270
  }
283
- 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`);
284
272
  }
285
273
  if (targets.length > 0) {
286
- 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");
287
275
  }
288
276
  }
289
277
  findAffectedEntriesAfterPriorityChange(before, after) {
package/dist/types.d.ts CHANGED
@@ -9,12 +9,12 @@
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
- export type CandidateKind = "head" | "integration";
17
+ export type CandidateKind = "head" | "integration" | "integration_repair";
18
18
  export interface QueueEntry {
19
19
  id: string;
20
20
  repoId: string;
@@ -66,14 +66,14 @@ 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.
73
73
  */
74
74
  decidedAt: string | null;
75
75
  }
76
- export type FailureClass = "main_broken" | "branch_local" | "integration_conflict" | "policy_blocked";
76
+ export type FailureClass = "main_broken" | "branch_local" | "integration_conflict" | "feature_changed" | "policy_blocked";
77
77
  export type CheckConclusion = "success" | "failure" | "pending" | "neutral" | "skipped";
78
78
  export interface CheckResult {
79
79
  name: string;
@@ -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
  }
@@ -109,22 +109,15 @@ export async function processWebhookEvent(event, service, config, logger) {
109
109
  case "pr_labeled": {
110
110
  if (event.label !== config.admissionLabel && event.label !== config.priorityQueueLabel)
111
111
  return;
112
- logger.info({ prNumber: event.prNumber, label: event.label }, "Queue label added, checking eligibility");
113
- if (event.label === config.priorityQueueLabel && service.updatePriorityByPR(event.prNumber, 1)) {
114
- break;
115
- }
112
+ logger.info({ prNumber: event.prNumber, label: event.label }, "Presentation label changed, reconciling eligibility");
116
113
  await service.tryAdmit(event.prNumber, event.branch, event.headSha);
117
114
  break;
118
115
  }
119
116
  case "pr_unlabeled": {
120
- if (event.label === config.priorityQueueLabel) {
121
- service.updatePriorityByPR(event.prNumber, 0);
122
- break;
123
- }
124
- if (event.label !== config.admissionLabel)
117
+ // Labels are presentation-only. Losing one must not dequeue, reorder,
118
+ // admit, or otherwise control the queue.
119
+ if (event.label !== config.admissionLabel && event.label !== config.priorityQueueLabel)
125
120
  return;
126
- // Label removed — dequeue if active.
127
- service.dequeueByPR(event.prNumber);
128
121
  break;
129
122
  }
130
123
  case "pr_merged": {
@@ -136,7 +129,8 @@ export async function processWebhookEvent(event, service, config, logger) {
136
129
  break;
137
130
  }
138
131
  case "pr_synchronize": {
139
- // 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.
140
134
  service.updateHeadByPR(event.prNumber, event.headSha);
141
135
  break;
142
136
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.34.4",
3
+ "version": "0.35.1",
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": {