merge-steward 0.34.4 → 0.35.0

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
 
@@ -14,11 +16,19 @@ The queue keeps delivery fast without pretending branch CI is always enough. For
14
16
 
15
17
  1. A PR becomes eligible when GitHub says it is approved and its required checks are green.
16
18
  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.
19
+ 3. It freezes the approved PR head and resolves the exact future-`main`
20
+ candidate. If the prospective base is its ancestor, that head is the
21
+ candidate. Otherwise it publishes
22
+ `merge-steward/<base>/pr-<number>` as a cumulative integration workspace.
18
23
  4. It validates checks on that exact SHA. Only newly-created integration candidates trigger synthetic CI.
19
24
  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.
25
+ 6. On conflict, the workspace remains at the prospective base; PatchRelay
26
+ derives the missing ancestry and non-force pushes a resolved candidate.
27
+ 7. On candidate-CI failure, PatchRelay repairs the candidate rather than the PR
28
+ branch. Review Quill verifies only that an agent repair preserved the
29
+ approved feature.
30
+ 8. Ordinary integration failures retain queue position. Feature implementation
31
+ reopens only when integration review proves the feature must change.
22
32
 
23
33
  This is structural, not an optional fast path. An exact head is safe precisely
24
34
  when it already contains the prospective base; if it does not, Merge Steward
@@ -26,7 +36,10 @@ creates and tests the integration candidate. Checks never move between SHAs.
26
36
 
27
37
  ## Use with your own agent
28
38
 
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:
39
+ For an agent that drives PRs through the queue and reacts to candidate state and
40
+ failing checks without running PatchRelay's full harness, install the
41
+ [`ship-pr`](https://github.com/krasnoperov/patchrelay-agents) skill from the
42
+ companion Claude Code marketplace:
30
43
 
31
44
  ```
32
45
  /plugin marketplace add krasnoperov/patchrelay-agents
@@ -73,7 +86,7 @@ Each repository reconcile tick is bounded by `reconcileStaleAfterMs` (five minut
73
86
  | Code | Meaning |
74
87
  |-|-|
75
88
  | 0 | merged / approved with green required checks |
76
- | 2 | changes_requested / failing required checks / evicted / closed |
89
+ | 2 | changes_requested / integration review requires feature rework / policy failure / closed |
77
90
  | 3 | still in flight (queued, preparing, validating, merging, pending) |
78
91
  | 4 | `--wait` timed out |
79
92
  | 1 | usage or configuration error |
@@ -95,10 +108,14 @@ The real gate is:
95
108
 
96
109
  Independent services, GitHub as the shared bus:
97
110
 
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.
111
+ 1. The steward admits an open PR when GitHub shows an approved head and green
112
+ branch checks; no label is required.
113
+ 2. It publishes a self-describing candidate ref. Missing approved-head ancestry
114
+ means conflict repair; settled red candidate checks mean test repair.
115
+ 3. PatchRelay derives either condition from GitHub and non-force pushes only the
116
+ candidate ref.
117
+ 4. Review Quill publishes `review-quill/integration` when PatchRelay changed the
118
+ candidate. The steward lands the exact green SHA when that check is satisfied.
102
119
 
103
120
  Neither service calls the other's API.
104
121
 
@@ -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"
@@ -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;
@@ -1,9 +1,22 @@
1
- import { CLEAN_CI, CLEAR_CANDIDATE, emit, isRetryGated, ref, candidateRefName } from "./reconciler-core.js";
1
+ import { CLEAN_CI, CLEAR_CANDIDATE, emit, ref, candidateRefName } from "./reconciler-core.js";
2
2
  import { evictEntry } 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;
@@ -33,24 +46,6 @@ export async function prepareEntry(ctx, entry, isHead, prevEntry) {
33
46
  // Preparing/validating the head never waits on main's CI. The exact
34
47
  // candidate includes current main and is gated solely by its own checks.
35
48
  //
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
49
  // A remote merge can advance main before GitHub's PR API reports `merged`.
55
50
  // The PR head is then already contained in main, so building and testing an
56
51
  // "integration" candidate would only reproduce the current main commit.
@@ -92,7 +87,7 @@ export async function prepareEntry(ctx, entry, isHead, prevEntry) {
92
87
  }, `head candidate ${entry.headSha.slice(0, 12)} selected on ${baseSha.slice(0, 12)}`);
93
88
  return;
94
89
  }
95
- const specName = candidateRefName(entry.id);
90
+ const specName = candidateRefName(ctx.baseBranch, entry.prNumber);
96
91
  emit(ctx, entry, "integration_build_started", { candidateRef: specName, baseSha, ...(prevEntry ? { dependsOn: prevEntry.id } : {}) });
97
92
  const branchSuffix = entry.branch.replace(/^.*\//, "").replace(/-/g, " ");
98
93
  const mergeMessage = `Merge PR #${entry.prNumber}: ${branchSuffix}`;
@@ -115,17 +110,21 @@ export async function prepareEntry(ctx, entry, isHead, prevEntry) {
115
110
  }
116
111
  if (!result.success) {
117
112
  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
- }
113
+ // buildSpeculative deliberately leaves the local workspace ref at the
114
+ // prospective base. Publish it so PatchRelay can derive the repair from
115
+ // GitHub ancestry alone and add a merge commit with a non-force push.
116
+ await ctx.git.push(specName, true);
117
+ ctx.store.transition(entry.id, "validating", {
118
+ baseSha,
119
+ ...CLEAN_CI,
120
+ candidateKind: "integration",
121
+ candidatePolicyFingerprint: ctx.policy.getFingerprint(),
122
+ candidateRef: specName,
123
+ candidateSha: baseSha,
124
+ candidateBasedOn: isHead ? null : prevEntry.id,
125
+ lastFailedBaseSha: baseSha,
126
+ waitDetail: `integration conflict; workspace awaits repair${result.conflictFiles?.length ? ` in ${result.conflictFiles.join(", ")}` : ""}`,
127
+ }, `integration workspace published at ${baseSha.slice(0, 12)} for conflict repair`);
129
128
  return;
130
129
  }
131
130
  const candidateSha = result.sha ?? entry.headSha;
@@ -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
  }
@@ -144,14 +144,9 @@ export class MergeStewardQueueCommands {
144
144
  this.logger.debug({ prNumber, reviewDecision: status.reviewDecision }, "PR review gate is not satisfied, skipping admission");
145
145
  return false;
146
146
  }
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;
147
+ // Admission and ordering are derived from review/check/PR truth. Labels
148
+ // remain available for presentation but are not control inputs.
149
+ const priority = 0;
155
150
  const checks = await this.github.listChecks(prNumber);
156
151
  const requiredCheckRules = this.policy.getRequiredCheckRules();
157
152
  if (requiredCheckRules.length > 0) {
package/dist/types.d.ts CHANGED
@@ -14,7 +14,7 @@
14
14
  export type QueueEntryStatus = "queued" | "preparing_head" | "validating" | "merging" | "evicted" | "merged" | "dequeued";
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;
@@ -73,7 +73,7 @@ export interface QueueEntry {
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;
@@ -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": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.34.4",
3
+ "version": "0.35.0",
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": {