merge-steward 0.5.5 → 0.6.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.
@@ -97,6 +97,7 @@ export class SqliteStore {
97
97
  getEntryByPR(repoId, prNumber) {
98
98
  const row = this.conn.prepare(`SELECT * FROM queue_entries
99
99
  WHERE repo_id = ? AND pr_number = ? AND status NOT IN (${NOT_TERMINAL_SQL})
100
+ ORDER BY position ASC
100
101
  LIMIT 1`).get(repoId, prNumber, ...TERMINAL_STATUSES);
101
102
  return row ? mapEntry(row) : undefined;
102
103
  }
@@ -16,6 +16,7 @@ export declare class GitHubPRClient implements GitHubPRApi {
16
16
  getStatus(prNumber: number): Promise<PRStatus>;
17
17
  listChecks(prNumber: number): Promise<CheckResult[]>;
18
18
  listChecksForRef(ref: string): Promise<CheckResult[]>;
19
+ deleteBranch(prNumber: number): Promise<void>;
19
20
  findPRByBranch(branch: string): Promise<number | null>;
20
21
  listLabels(prNumber: number): Promise<string[]>;
21
22
  }
@@ -24,7 +24,7 @@ export class GitHubPRClient {
24
24
  const result = await exec("gh", [
25
25
  "pr", "view", String(prNumber),
26
26
  "--repo", this.repoFullName,
27
- "--json", "number,headRefName,headRefOid,reviewDecision,state",
27
+ "--json", "number,headRefName,headRefOid,reviewDecision,state,mergeStateStatus",
28
28
  ], { githubRepoFullName: this.repoFullName });
29
29
  const data = JSON.parse(result.stdout);
30
30
  return {
@@ -32,6 +32,7 @@ export class GitHubPRClient {
32
32
  branch: data.headRefName,
33
33
  headSha: data.headRefOid,
34
34
  mergeable: data.state === "OPEN",
35
+ mergeStateStatus: data.mergeStateStatus,
35
36
  reviewApproved: data.reviewDecision === "APPROVED",
36
37
  merged: data.state === "MERGED",
37
38
  };
@@ -63,6 +64,13 @@ export class GitHubPRClient {
63
64
  return [];
64
65
  }
65
66
  }
67
+ async deleteBranch(prNumber) {
68
+ const status = await this.getStatus(prNumber);
69
+ await exec("gh", [
70
+ "api", "--method", "DELETE",
71
+ `repos/${this.repoFullName}/git/refs/heads/${status.branch}`,
72
+ ], { allowNonZero: true, githubRepoFullName: this.repoFullName });
73
+ }
66
74
  async findPRByBranch(branch) {
67
75
  const result = await exec("gh", [
68
76
  "pr", "list",
@@ -10,7 +10,7 @@ export declare class ShellGitOperations implements GitOperations, SpeculativeBra
10
10
  headSha(branch: string): Promise<string>;
11
11
  isAncestor(ancestor: string, descendant: string): Promise<boolean>;
12
12
  mergeBaseInto(branch: string, base: string): Promise<MergeResult>;
13
- push(branch: string, force?: boolean): Promise<void>;
13
+ push(branch: string, force?: boolean, targetBranch?: string): Promise<void>;
14
14
  buildSpeculative(prBranch: string, baseBranch: string, specName: string): Promise<MergeResult>;
15
15
  deleteSpeculative(specName: string): Promise<void>;
16
16
  }
@@ -58,11 +58,12 @@ export class ShellGitOperations {
58
58
  const newSha = await this.headSha("HEAD");
59
59
  return { success: true, sha: newSha };
60
60
  }
61
- async push(branch, force = false) {
61
+ async push(branch, force = false, targetBranch) {
62
62
  const args = ["push"];
63
63
  if (force)
64
64
  args.push("--force-with-lease");
65
- args.push("origin", branch);
65
+ const refspec = targetBranch ? `${branch}:${targetBranch}` : branch;
66
+ args.push("origin", refspec);
66
67
  await this.git(args, { timeoutMs: 60_000 });
67
68
  }
68
69
  // ─── SpeculativeBranchBuilder ───────────────────────────────
@@ -143,6 +143,8 @@ export async function buildMultiRepoHttpServer(options) {
143
143
  if (body.priority !== undefined)
144
144
  params.priority = body.priority;
145
145
  const entry = inst.service.enqueue(params);
146
+ if (!entry)
147
+ return reply.status(409).send({ ok: false, error: "Failed to enqueue PR" });
146
148
  return reply.status(201).send({ ok: true, entryId: entry.id });
147
149
  }
148
150
  catch (err) {
@@ -8,7 +8,7 @@ export interface GitOperations {
8
8
  headSha(branch: string): Promise<string>;
9
9
  isAncestor(ancestor: string, descendant: string): Promise<boolean>;
10
10
  mergeBaseInto(branch: string, base: string): Promise<MergeResult>;
11
- push(branch: string, force?: boolean): Promise<void>;
11
+ push(branch: string, force?: boolean, targetBranch?: string): Promise<void>;
12
12
  }
13
13
  /**
14
14
  * Builds and manages speculative cumulative branches.
@@ -43,6 +43,8 @@ export interface GitHubPRApi {
43
43
  listLabels(prNumber: number): Promise<string[]>;
44
44
  /** Find the open PR number for a branch, or null if none exists. */
45
45
  findPRByBranch(branch: string): Promise<number | null>;
46
+ /** Delete the PR's head branch from the remote (best-effort cleanup). */
47
+ deleteBranch(prNumber: number): Promise<void>;
46
48
  }
47
49
  /**
48
50
  * Reports evictions to external systems. The production implementation
@@ -10,7 +10,7 @@ export interface ReconcileContext {
10
10
  ci: CIRunner;
11
11
  github: GitHubPRApi;
12
12
  eviction: EvictionReporter;
13
- specBuilder: SpeculativeBranchBuilder | null;
13
+ specBuilder: SpeculativeBranchBuilder;
14
14
  speculativeDepth: number;
15
15
  flakyRetries: number;
16
16
  onEvent: (event: ReconcileEvent) => void;
@@ -22,122 +22,180 @@ function isBudgetExhausted(entry) {
22
22
  function isRetryGated(entry, currentBaseSha) {
23
23
  return entry.lastFailedBaseSha === currentBaseSha;
24
24
  }
25
- /** The branch and SHA to use for CI — spec branch if available, else PR branch. */
26
- function ciTarget(entry) {
27
- return {
28
- branch: entry.specBranch ?? entry.branch,
29
- sha: entry.specSha ?? entry.headSha,
30
- };
31
- }
32
25
  // ─── Main reconcile loop ────────────────────────────────────────
33
26
  export async function reconcile(ctx) {
34
27
  const allActive = ctx.store.listActive(ctx.repoId);
35
28
  if (allActive.length === 0)
36
29
  return;
37
- const depth = ctx.specBuilder ? Math.min(ctx.speculativeDepth, allActive.length) : 1;
30
+ // Process up to speculativeDepth entries. GitHub truth checks are
31
+ // bounded by this window — we never scan the full queue.
32
+ const depth = Math.min(ctx.speculativeDepth, allActive.length);
38
33
  for (let i = 0; i < depth; i++) {
39
34
  const entryId = allActive[i].id;
40
35
  const entry = ctx.store.getEntry(entryId);
41
36
  if (!entry || TERMINAL_STATUSES.includes(entry.status))
42
37
  continue;
38
+ // Truth guard: verify entry against GitHub before processing.
39
+ if (await sanitizeEntry(ctx, entry))
40
+ continue;
43
41
  const isHead = i === 0;
44
- const prevEntry = i > 0 ? ctx.store.getEntry(allActive[i - 1].id) : null;
45
- switch (entry.status) {
46
- case "queued":
47
- emit(ctx, entry, "promoted");
48
- ctx.store.transition(entry.id, "preparing_head", undefined, "promoted to head");
49
- break;
50
- case "preparing_head":
51
- if (isHead) {
52
- await prepareHead(ctx, entry);
53
- }
54
- else if (ctx.specBuilder && prevEntry) {
55
- await prepareSpeculative(ctx, entry, prevEntry);
42
+ const prevEntry = i > 0 ? ctx.store.getEntry(allActive[i - 1].id) ?? null : null;
43
+ const phase = entry.status;
44
+ try {
45
+ switch (phase) {
46
+ case "queued":
47
+ emit(ctx, entry, "promoted");
48
+ ctx.store.transition(entry.id, "preparing_head", undefined, "promoted");
49
+ break;
50
+ case "preparing_head":
51
+ await prepareEntry(ctx, entry, isHead, prevEntry);
52
+ break;
53
+ case "validating": {
54
+ const freshActive = ctx.store.listActive(ctx.repoId);
55
+ const freshIdx = freshActive.findIndex((e) => e.id === entry.id);
56
+ await checkValidation(ctx, entry, freshActive, freshIdx >= 0 ? freshIdx : i);
57
+ break;
56
58
  }
57
- break;
58
- case "validating": {
59
- const freshActive = ctx.store.listActive(ctx.repoId);
60
- const freshIdx = freshActive.findIndex((e) => e.id === entry.id);
61
- await checkValidation(ctx, entry, freshActive, freshIdx >= 0 ? freshIdx : i);
62
- break;
59
+ case "merging":
60
+ if (isHead) {
61
+ await mergeHead(ctx, entry);
62
+ }
63
+ break;
64
+ default:
65
+ break;
63
66
  }
64
- case "merging":
65
- if (isHead) {
66
- await mergeHead(ctx, entry, ctx.store.listActive(ctx.repoId));
67
- }
68
- break;
69
- default:
70
- break;
67
+ }
68
+ catch (error) {
69
+ const msg = error instanceof Error ? error.message : String(error);
70
+ const wrapped = new Error(`[PR #${entry.prNumber} ${entry.id} phase=${phase}] ${msg}`);
71
+ if (error instanceof Error && error.stack)
72
+ wrapped.stack = error.stack;
73
+ throw wrapped;
74
+ }
75
+ }
76
+ }
77
+ // ─── Truth guard ────────────────────────────────────────────────
78
+ async function sanitizeEntry(ctx, entry) {
79
+ const canonical = ctx.store.getEntryByPR(ctx.repoId, entry.prNumber);
80
+ if (canonical && canonical.id !== entry.id) {
81
+ emit(ctx, entry, "sanitized_duplicate", {
82
+ detail: `superseded by entry ${canonical.id}`,
83
+ });
84
+ await cleanupSpec(ctx, entry);
85
+ ctx.store.dequeue(entry.id);
86
+ return true;
87
+ }
88
+ try {
89
+ const prStatus = await ctx.github.getStatus(entry.prNumber);
90
+ if (prStatus.merged) {
91
+ emit(ctx, entry, "merge_external", {
92
+ detail: `PR #${entry.prNumber} already merged on GitHub (detected in sanitize)`,
93
+ });
94
+ await cleanupSpec(ctx, entry);
95
+ ctx.store.transition(entry.id, "merged", CLEAN_SPEC, "merged externally (sanitize)");
96
+ return true;
97
+ }
98
+ if (!prStatus.mergeable && !prStatus.merged) {
99
+ emit(ctx, entry, "sanitized_closed", {
100
+ detail: `PR #${entry.prNumber} is closed on GitHub`,
101
+ });
102
+ await cleanupSpec(ctx, entry);
103
+ ctx.store.dequeue(entry.id);
104
+ return true;
71
105
  }
72
106
  }
107
+ catch {
108
+ // GitHub probe failed — don't block the tick.
109
+ }
110
+ return false;
73
111
  }
74
- // ─── Head entry: fetch + gate + branch refresh ──────────────────
75
- async function prepareHead(ctx, entry) {
112
+ // ─── Entry preparation (unified for head and non-head) ──────────
113
+ async function prepareEntry(ctx, entry, isHead, prevEntry) {
76
114
  emit(ctx, entry, "fetch_started");
77
115
  await ctx.git.fetch();
78
- const baseSha = await ctx.git.headSha(ref(ctx, ctx.baseBranch));
79
- // Gate: main CI must be green.
80
- if (ctx.ci.getMainStatus) {
81
- const mainStatus = await ctx.ci.getMainStatus(ctx.baseBranch);
82
- if (mainStatus === "fail") {
83
- let mainChecks = [];
116
+ // Determine base: head merges onto main, non-head onto prev entry's spec.
117
+ const base = isHead ? ref(ctx, ctx.baseBranch) : prevEntry?.specBranch ?? null;
118
+ if (!base)
119
+ return; // Non-head: prev hasn't built its spec yet, wait.
120
+ const baseSha = await ctx.git.headSha(base);
121
+ // ── Head-only gates ───────────────────────────────────────────
122
+ if (isHead) {
123
+ // Gate: main CI must be green.
124
+ if (ctx.ci.getMainStatus) {
125
+ const mainStatus = await ctx.ci.getMainStatus(ctx.baseBranch);
126
+ if (mainStatus === "fail") {
127
+ let mainChecks = [];
128
+ try {
129
+ mainChecks = await ctx.github.listChecksForRef(ref(ctx, ctx.baseBranch));
130
+ }
131
+ catch {
132
+ mainChecks = [];
133
+ }
134
+ const failingChecks = mainChecks.filter((check) => check.conclusion === "failure");
135
+ const pendingChecks = mainChecks.filter((check) => check.conclusion === "pending");
136
+ emit(ctx, entry, "main_broken", {
137
+ baseSha,
138
+ failingChecks,
139
+ pendingChecks,
140
+ detail: describeMainBroken(failingChecks, pendingChecks),
141
+ });
142
+ return;
143
+ }
144
+ }
145
+ // Gate: detect external pushes to the PR branch.
146
+ const currentRef = await ctx.git.headSha(ref(ctx, entry.branch));
147
+ if (currentRef !== entry.headSha) {
148
+ emit(ctx, entry, "branch_mismatch", { detail: `expected ${entry.headSha.slice(0, 8)}, got ${currentRef.slice(0, 8)}` });
149
+ ctx.store.updateHead(entry.id, currentRef);
150
+ return;
151
+ }
152
+ // Gate: budget exhausted after previous conflict.
153
+ if (isBudgetExhausted(entry) && entry.lastFailedBaseSha !== null) {
154
+ emit(ctx, entry, "budget_exhausted", { baseSha });
155
+ await evictEntry(ctx, entry, "integration_conflict");
156
+ return;
157
+ }
158
+ // Gate: non-spinning — skip if base hasn't changed since last conflict.
159
+ if (isRetryGated(entry, baseSha)) {
160
+ emit(ctx, entry, "retry_gated", { baseSha, detail: "base unchanged since last conflict" });
84
161
  try {
85
- mainChecks = await ctx.github.listChecksForRef(ref(ctx, ctx.baseBranch));
162
+ const prStatus = await ctx.github.getStatus(entry.prNumber);
163
+ if (prStatus.mergeStateStatus === "DIRTY") {
164
+ emit(ctx, entry, "budget_exhausted", {
165
+ baseSha,
166
+ detail: "retry gated and GitHub still reports merge conflict",
167
+ });
168
+ await evictEntry(ctx, entry, "integration_conflict");
169
+ }
86
170
  }
87
171
  catch {
88
- mainChecks = [];
172
+ // Best-effort check.
89
173
  }
90
- const failingChecks = mainChecks.filter((check) => check.conclusion === "failure");
91
- const pendingChecks = mainChecks.filter((check) => check.conclusion === "pending");
92
- emit(ctx, entry, "main_broken", {
93
- baseSha,
94
- failingChecks,
95
- pendingChecks,
96
- detail: describeMainBroken(failingChecks, pendingChecks),
97
- });
98
174
  return;
99
175
  }
100
176
  }
101
- // Gate: branch must match expected SHA (detect external pushes).
102
- const currentRef = await ctx.git.headSha(ref(ctx, entry.branch));
103
- if (currentRef !== entry.headSha) {
104
- emit(ctx, entry, "branch_mismatch", { detail: `expected ${entry.headSha.slice(0, 8)}, got ${currentRef.slice(0, 8)}` });
105
- ctx.store.updateHead(entry.id, currentRef);
106
- return;
107
- }
108
- // Gate: budget exhausted after previous conflict.
109
- if (isBudgetExhausted(entry) && entry.lastFailedBaseSha !== null) {
110
- emit(ctx, entry, "budget_exhausted", { baseSha });
111
- await evictEntry(ctx, entry, "integration_conflict");
112
- return;
177
+ // ── Build spec branch: merge PR into base ─────────────────────
178
+ const specName = specBranchName(entry.id);
179
+ emit(ctx, entry, "spec_build_started", { specBranch: specName, baseSha, ...(prevEntry ? { dependsOn: prevEntry.id } : {}) });
180
+ let result;
181
+ try {
182
+ result = await ctx.specBuilder.buildSpeculative(entry.branch, base, specName);
113
183
  }
114
- // Gate: non-spinning — skip if base hasn't changed since last conflict.
115
- if (isRetryGated(entry, baseSha)) {
116
- emit(ctx, entry, "retry_gated", { baseSha, detail: "base unchanged since last conflict" });
184
+ catch (err) {
185
+ if (isHead) {
186
+ // Branch gone or unreachable.
187
+ const detail = `git error during spec build: ${err instanceof Error ? err.message : String(err)}`;
188
+ emit(ctx, entry, "branch_unreachable", { baseSha, detail });
189
+ await evictEntry(ctx, entry, "branch_local");
190
+ }
191
+ else {
192
+ // Stale spec from prev entry — reset both.
193
+ emit(ctx, entry, "invalidated", { detail: "stale spec branch, rebuilding" });
194
+ ctx.store.transition(prevEntry.id, "preparing_head", { ...CLEAN_CI, ...CLEAN_SPEC }, "spec branch missing, rebuilding");
195
+ ctx.store.transition(entry.id, "preparing_head", { ...CLEAN_CI, ...CLEAN_SPEC }, "stale dependency, rebuilding");
196
+ }
117
197
  return;
118
198
  }
119
- await performBranchRefresh(ctx, entry, baseSha);
120
- }
121
- function describeMainBroken(failingChecks, pendingChecks) {
122
- const parts = [];
123
- if (failingChecks.length > 0) {
124
- parts.push(`failing ${summarizeCheckNames(failingChecks)}`);
125
- }
126
- if (pendingChecks.length > 0) {
127
- parts.push(`pending ${summarizeCheckNames(pendingChecks)}`);
128
- }
129
- return parts.length > 0 ? `main checks unhealthy: ${parts.join("; ")}` : "main checks unhealthy";
130
- }
131
- function summarizeCheckNames(checks, limit = 3) {
132
- const names = [...new Set(checks.map((check) => check.name))];
133
- if (names.length <= limit) {
134
- return names.join(", ");
135
- }
136
- return `${names.slice(0, limit).join(", ")} +${names.length - limit} more`;
137
- }
138
- async function performBranchRefresh(ctx, entry, baseSha) {
139
- emit(ctx, entry, "rebase_started", { baseSha, detail: `refreshing ${entry.branch} with ${ctx.baseBranch}` });
140
- const result = await ctx.git.mergeBaseInto(entry.branch, ref(ctx, ctx.baseBranch));
141
199
  if (!result.success) {
142
200
  emit(ctx, entry, "rebase_conflict", { baseSha, conflictFiles: result.conflictFiles });
143
201
  if (isBudgetExhausted(entry)) {
@@ -153,85 +211,39 @@ async function performBranchRefresh(ctx, entry, baseSha) {
153
211
  }
154
212
  return;
155
213
  }
156
- const headSha = result.sha ?? entry.headSha;
157
- await ctx.git.fetch();
158
- const latestRemoteHead = await ctx.git.headSha(ref(ctx, entry.branch));
159
- if (latestRemoteHead !== entry.headSha) {
160
- const detail = `remote advanced during refresh: expected ${entry.headSha.slice(0, 8)}, ` +
161
- `latest ${latestRemoteHead.slice(0, 8)}, candidate ${headSha.slice(0, 8)}`;
162
- emit(ctx, entry, "branch_mismatch", { detail });
163
- ctx.store.updateHead(entry.id, latestRemoteHead);
164
- return;
165
- }
166
- await ctx.git.push(entry.branch, false);
167
- emit(ctx, entry, "rebase_succeeded", { baseSha, detail: `refreshed ${entry.branch} with ${ctx.baseBranch}` });
168
- // Build speculative branch for downstream entries.
169
- let specBranch = null;
170
- let specSha = null;
171
- if (ctx.specBuilder) {
172
- specBranch = specBranchName(entry.id);
173
- emit(ctx, entry, "spec_build_started", { specBranch, baseSha });
174
- const specResult = await ctx.specBuilder.buildSpeculative(entry.branch, ref(ctx, ctx.baseBranch), specBranch);
175
- if (specResult.success) {
176
- specSha = specResult.sha ?? headSha;
177
- emit(ctx, entry, "spec_build_succeeded", { specBranch });
178
- }
179
- else {
180
- emit(ctx, entry, "spec_build_conflict", { specBranch });
181
- specBranch = null;
182
- }
183
- }
184
- const runId = await ctx.ci.triggerRun(entry.branch, headSha);
185
- emit(ctx, entry, "ci_triggered", { ciRunId: runId });
214
+ const specSha = result.sha ?? entry.headSha;
215
+ emit(ctx, entry, "spec_build_succeeded", { specBranch: specName, ...(prevEntry ? { dependsOn: prevEntry.id } : {}) });
216
+ // Trigger CI on the spec branch.
217
+ const runId = await ctx.ci.triggerRun(specName, specSha);
218
+ emit(ctx, entry, "ci_triggered", { ciRunId: runId, specBranch: specName });
186
219
  ctx.store.transition(entry.id, "validating", {
187
- headSha, baseSha, ciRunId: runId, lastFailedBaseSha: null,
188
- specBranch, specSha, specBasedOn: null,
189
- }, `refreshed with ${ctx.baseBranch} @ ${baseSha.slice(0, 8)}, CI ${runId}`);
220
+ baseSha, ciRunId: runId, lastFailedBaseSha: null,
221
+ specBranch: specName, specSha, specBasedOn: isHead ? null : prevEntry.id,
222
+ }, `spec ${specName} ready, CI ${runId}`);
190
223
  }
191
- // ─── Non-head entry: speculative branch ─────────────────────────
192
- async function prepareSpeculative(ctx, entry, prevEntry) {
193
- if (!ctx.specBuilder || !prevEntry.specBranch)
194
- return;
195
- const specName = specBranchName(entry.id);
196
- emit(ctx, entry, "spec_build_started", { specBranch: specName, dependsOn: prevEntry.id });
197
- let result;
198
- try {
199
- result = await ctx.specBuilder.buildSpeculative(entry.branch, prevEntry.specBranch, specName);
200
- }
201
- catch {
202
- // Stale spec branch — the previous entry's branch doesn't exist
203
- // (e.g., after restart with fresh clone). Reset both entries.
204
- emit(ctx, entry, "invalidated", { detail: "stale spec branch, rebuilding" });
205
- ctx.store.transition(prevEntry.id, "preparing_head", { ...CLEAN_CI, ...CLEAN_SPEC }, "spec branch missing, rebuilding");
206
- ctx.store.transition(entry.id, "preparing_head", { ...CLEAN_CI, ...CLEAN_SPEC }, "stale dependency, rebuilding");
207
- return;
224
+ function describeMainBroken(failingChecks, pendingChecks) {
225
+ const parts = [];
226
+ if (failingChecks.length > 0) {
227
+ parts.push(`failing ${summarizeCheckNames(failingChecks)}`);
208
228
  }
209
- if (result.success) {
210
- const specSha = result.sha ?? entry.headSha;
211
- emit(ctx, entry, "spec_build_succeeded", { specBranch: specName, dependsOn: prevEntry.id });
212
- const runId = await ctx.ci.triggerRun(specName, specSha);
213
- emit(ctx, entry, "ci_triggered", { ciRunId: runId, specBranch: specName });
214
- ctx.store.transition(entry.id, "validating", {
215
- ciRunId: runId, specBranch: specName, specSha, specBasedOn: prevEntry.id,
216
- }, `spec ${specName} based on ${prevEntry.id}, CI ${runId}`);
229
+ if (pendingChecks.length > 0) {
230
+ parts.push(`pending ${summarizeCheckNames(pendingChecks)}`);
217
231
  }
218
- else {
219
- emit(ctx, entry, "spec_build_conflict", { specBranch: specName, dependsOn: prevEntry.id });
220
- if (!isBudgetExhausted(entry)) {
221
- ctx.store.transition(entry.id, "preparing_head", {
222
- retryAttempts: entry.retryAttempts + 1, lastFailedBaseSha: prevEntry.specSha,
223
- }, `spec conflict, retry ${entry.retryAttempts + 1}/${entry.maxRetries}`);
224
- }
225
- else {
226
- await evictEntry(ctx, entry, "integration_conflict");
227
- }
232
+ return parts.length > 0 ? `main checks unhealthy: ${parts.join("; ")}` : "main checks unhealthy";
233
+ }
234
+ function summarizeCheckNames(checks, limit = 3) {
235
+ const names = [...new Set(checks.map((check) => check.name))];
236
+ if (names.length <= limit) {
237
+ return names.join(", ");
228
238
  }
239
+ return `${names.slice(0, limit).join(", ")} +${names.length - limit} more`;
229
240
  }
230
241
  // ─── CI validation ──────────────────────────────────────────────
231
242
  async function checkValidation(ctx, entry, allActive, index) {
232
243
  if (!entry.ciRunId) {
233
- const target = ciTarget(entry);
234
- const runId = await ctx.ci.triggerRun(target.branch, target.sha);
244
+ const branch = entry.specBranch ?? entry.branch;
245
+ const sha = entry.specSha ?? entry.headSha;
246
+ const runId = await ctx.ci.triggerRun(branch, sha);
235
247
  emit(ctx, entry, "ci_triggered", { ciRunId: runId });
236
248
  ctx.store.transition(entry.id, "validating", { ciRunId: runId }, `CI triggered: ${runId}`);
237
249
  return;
@@ -252,8 +264,9 @@ async function checkValidation(ctx, entry, allActive, index) {
252
264
  emit(ctx, entry, "ci_failed", { ciRunId: entry.ciRunId });
253
265
  if (entry.ciRetries < ctx.flakyRetries) {
254
266
  emit(ctx, entry, "ci_flaky_retry", { detail: `retry ${entry.ciRetries + 1}/${ctx.flakyRetries}` });
255
- const target = ciTarget(entry);
256
- const runId = await ctx.ci.triggerRun(target.branch, target.sha);
267
+ const branch = entry.specBranch ?? entry.branch;
268
+ const sha = entry.specSha ?? entry.headSha;
269
+ const runId = await ctx.ci.triggerRun(branch, sha);
257
270
  ctx.store.transition(entry.id, "validating", {
258
271
  ciRunId: runId, ciRetries: entry.ciRetries + 1,
259
272
  }, `flaky retry ${entry.ciRetries + 1}/${ctx.flakyRetries}`);
@@ -278,8 +291,8 @@ async function checkValidation(ctx, entry, allActive, index) {
278
291
  }
279
292
  }
280
293
  }
281
- // ─── Merge (head only) ──────────────────────────────────────────
282
- async function mergeHead(ctx, entry, allActive) {
294
+ // ─── Merge: push spec branch to main (head only) ───────────────
295
+ async function mergeHead(ctx, entry) {
283
296
  emit(ctx, entry, "merge_revalidating");
284
297
  const prStatus = await ctx.github.getStatus(entry.prNumber);
285
298
  if (prStatus.merged) {
@@ -290,36 +303,46 @@ async function mergeHead(ctx, entry, allActive) {
290
303
  }
291
304
  if (!prStatus.reviewApproved) {
292
305
  emit(ctx, entry, "merge_rejected", { detail: "approval withdrawn" });
306
+ const allActive = ctx.store.listActive(ctx.repoId);
293
307
  await evictEntry(ctx, entry, "policy_blocked");
294
308
  await invalidateDownstream(ctx, allActive, 0);
295
309
  return;
296
310
  }
297
311
  if (prStatus.headSha !== entry.headSha) {
298
312
  emit(ctx, entry, "branch_mismatch", { detail: `PR head: expected ${entry.headSha.slice(0, 8)}, got ${prStatus.headSha.slice(0, 8)}` });
313
+ const allActive = ctx.store.listActive(ctx.repoId);
299
314
  ctx.store.updateHead(entry.id, prStatus.headSha);
300
315
  await invalidateDownstream(ctx, allActive, 0);
301
316
  return;
302
317
  }
303
- // Check if base branch moved since validation — if so, re-prepare.
304
- if (entry.baseSha) {
305
- try {
306
- const currentBase = await ctx.git.headSha(ref(ctx, ctx.baseBranch));
307
- if (currentBase !== entry.baseSha) {
308
- emit(ctx, entry, "branch_mismatch", { detail: `base: expected ${entry.baseSha.slice(0, 8)}, got ${currentBase.slice(0, 8)}` });
309
- ctx.store.transition(entry.id, "preparing_head", { ...CLEAN_CI, ...CLEAN_SPEC }, "base moved, re-prepare");
310
- await invalidateDownstream(ctx, allActive, 0);
311
- return;
312
- }
313
- }
314
- catch {
315
- // Can't resolve base — proceed and let GitHub enforce.
318
+ if (!entry.specBranch || !entry.specSha) {
319
+ ctx.store.transition(entry.id, "preparing_head", { ...CLEAN_CI, ...CLEAN_SPEC }, "no spec branch, re-prepare");
320
+ return;
321
+ }
322
+ // Guard: verify our spec is a fast-forward from current main.
323
+ // If someone pushed directly to main outside the queue, this catches it.
324
+ try {
325
+ await ctx.git.fetch();
326
+ const currentBase = await ctx.git.headSha(ref(ctx, ctx.baseBranch));
327
+ const isFF = await ctx.git.isAncestor(currentBase, entry.specSha);
328
+ if (!isFF) {
329
+ emit(ctx, entry, "branch_mismatch", { detail: `spec is not a fast-forward from main (${currentBase.slice(0, 8)})` });
330
+ const allActive = ctx.store.listActive(ctx.repoId);
331
+ ctx.store.transition(entry.id, "preparing_head", { ...CLEAN_CI, ...CLEAN_SPEC }, "main diverged, re-prepare");
332
+ await invalidateDownstream(ctx, allActive, 0);
333
+ return;
316
334
  }
317
335
  }
336
+ catch {
337
+ // Can't verify — proceed and let push fail if needed.
338
+ }
339
+ // Push the spec branch to main (fast-forward).
318
340
  try {
319
- await ctx.github.mergePR(entry.prNumber);
341
+ await ctx.git.push(entry.specBranch, false, ctx.baseBranch);
320
342
  }
321
343
  catch {
322
- emit(ctx, entry, "merge_rejected", { detail: "GitHub API rejected merge" });
344
+ emit(ctx, entry, "merge_rejected", { detail: "push to main failed" });
345
+ const allActive = ctx.store.listActive(ctx.repoId);
323
346
  if (isBudgetExhausted(entry)) {
324
347
  emit(ctx, entry, "budget_exhausted");
325
348
  await evictEntry(ctx, entry, "integration_conflict");
@@ -327,14 +350,21 @@ async function mergeHead(ctx, entry, allActive) {
327
350
  else {
328
351
  ctx.store.transition(entry.id, "preparing_head", {
329
352
  retryAttempts: entry.retryAttempts + 1, ...CLEAN_CI, ...CLEAN_SPEC,
330
- }, `merge rejected, retry ${entry.retryAttempts + 1}/${entry.maxRetries}`);
353
+ }, `push failed, retry ${entry.retryAttempts + 1}/${entry.maxRetries}`);
331
354
  }
355
+ // Head is rebuilding — downstream specs are stale (they were built
356
+ // on the old head spec which will change after re-preparation).
332
357
  await invalidateDownstream(ctx, allActive, 0);
333
358
  return;
334
359
  }
335
360
  emit(ctx, entry, "merge_succeeded");
336
- ctx.store.transition(entry.id, "merged", CLEAN_SPEC, "merged to main");
361
+ ctx.store.transition(entry.id, "merged", CLEAN_SPEC, "spec pushed to main");
337
362
  await cleanupSpec(ctx, entry);
363
+ // Best-effort: delete the PR branch from remote.
364
+ try {
365
+ await ctx.github.deleteBranch(entry.prNumber);
366
+ }
367
+ catch { /* cosmetic */ }
338
368
  }
339
369
  // ─── Invalidation + eviction ────────────────────────────────────
340
370
  async function invalidateDownstream(ctx, allActive, afterIndex) {
@@ -348,7 +378,7 @@ async function invalidateDownstream(ctx, allActive, afterIndex) {
348
378
  }
349
379
  }
350
380
  async function cleanupSpec(ctx, entry) {
351
- if (entry.specBranch && ctx.specBuilder) {
381
+ if (entry.specBranch) {
352
382
  await ctx.specBuilder.deleteSpeculative(entry.specBranch).catch(() => {
353
383
  // Best-effort cleanup — branch may not exist.
354
384
  });
@@ -356,7 +386,6 @@ async function cleanupSpec(ctx, entry) {
356
386
  }
357
387
  async function evictEntry(ctx, entry, failureClass, extra) {
358
388
  await cleanupSpec(ctx, entry);
359
- // Use recorded baseSha if available, else resolve current base.
360
389
  let baseSha = entry.baseSha;
361
390
  if (!baseSha) {
362
391
  try {
@@ -366,7 +395,6 @@ async function evictEntry(ctx, entry, failureClass, extra) {
366
395
  baseSha = "unknown";
367
396
  }
368
397
  }
369
- // Build retry history from queue events (each event snapshots baseSha at transition time).
370
398
  const events = ctx.store.listEvents(entry.id);
371
399
  const retryHistory = [];
372
400
  for (const event of events) {
package/dist/server.js CHANGED
@@ -25,8 +25,7 @@ async function createRepoInstance(config, logger) {
25
25
  const ci = new GitHubActionsRunner(config.repoFullName, config.requiredChecks);
26
26
  const github = new GitHubPRClient(config.repoFullName);
27
27
  const eviction = new GitHubCheckRunReporter(config.repoFullName, config.server.bind, config.server.port, config.server.publicBaseUrl, config.admissionLabel, config.mergeQueueCheckName);
28
- const specBuilder = config.speculativeDepth > 1 ? git : null;
29
- const service = new MergeStewardService(config, store, git, ci, github, eviction, specBuilder, logger);
28
+ const service = new MergeStewardService(config, store, git, ci, github, eviction, git, logger);
30
29
  return { config, service, store };
31
30
  }
32
31
  export async function startMultiServer() {
package/dist/service.d.ts CHANGED
@@ -24,7 +24,7 @@ export declare class MergeStewardService {
24
24
  private lastTickOutcome;
25
25
  private lastTickError;
26
26
  private currentQueueBlock;
27
- constructor(config: StewardConfig, store: QueueStore, git: GitOperations, ci: CIRunner, github: GitHubPRApi, eviction: EvictionReporter, specBuilder: import("./interfaces.ts").SpeculativeBranchBuilder | null, logger: Logger);
27
+ constructor(config: StewardConfig, store: QueueStore, git: GitOperations, ci: CIRunner, github: GitHubPRApi, eviction: EvictionReporter, specBuilder: import("./interfaces.ts").SpeculativeBranchBuilder, logger: Logger);
28
28
  /** Expose the GitHub client for webhook handler branch→PR lookups. */
29
29
  get githubApi(): GitHubPRApi;
30
30
  start(): void;
@@ -35,7 +35,7 @@ export declare class MergeStewardService {
35
35
  headSha: string;
36
36
  issueKey?: string;
37
37
  priority?: number;
38
- }): QueueEntry;
38
+ }): QueueEntry | undefined;
39
39
  dequeueEntry(entryId: string): boolean;
40
40
  updateEntryHead(entryId: string, headSha: string): boolean;
41
41
  getStatus(): QueueEntry[];
package/dist/service.js CHANGED
@@ -58,6 +58,15 @@ export class MergeStewardService {
58
58
  this.logger.info("Steward service stopped");
59
59
  }
60
60
  enqueue(params) {
61
+ // Pre-check: if an active entry already exists for this PR, don't
62
+ // attempt the insert. This is also enforced by the UNIQUE partial
63
+ // index (idx_one_active_per_pr), but handling it here gives a clear
64
+ // log message instead of a raw constraint error.
65
+ const existing = this.store.getEntryByPR(this.config.repoId, params.prNumber);
66
+ if (existing) {
67
+ this.logger.warn({ prNumber: params.prNumber, existingEntryId: existing.id }, "Duplicate enqueue rejected: active entry already exists for PR");
68
+ return existing;
69
+ }
61
70
  const entry = {
62
71
  id: randomUUID(),
63
72
  repoId: this.config.repoId,
@@ -81,7 +90,19 @@ export class MergeStewardService {
81
90
  enqueuedAt: new Date().toISOString(),
82
91
  updatedAt: new Date().toISOString(),
83
92
  };
84
- this.store.insert(entry);
93
+ try {
94
+ this.store.insert(entry);
95
+ }
96
+ catch (err) {
97
+ // UNIQUE constraint race: another entry was inserted between our
98
+ // pre-check and the insert. Return the existing entry.
99
+ const raced = this.store.getEntryByPR(this.config.repoId, params.prNumber);
100
+ if (raced) {
101
+ this.logger.warn({ prNumber: params.prNumber, existingEntryId: raced.id }, "Duplicate enqueue caught by constraint: returning existing entry");
102
+ return raced;
103
+ }
104
+ throw err; // Not a duplicate constraint — re-throw the real error.
105
+ }
85
106
  this.logger.info({ prNumber: params.prNumber, entryId: entry.id }, "PR enqueued");
86
107
  return entry;
87
108
  }
@@ -277,8 +298,12 @@ export class MergeStewardService {
277
298
  }
278
299
  catch (error) {
279
300
  this.lastTickOutcome = "failed";
280
- this.lastTickError = error instanceof Error ? error.message : String(error);
281
- this.logger.error({ error }, "Reconcile tick failed");
301
+ // Preserve stack + message for the watch API. The reconciler wraps
302
+ // per-entry errors with [PR #N entryId phase=X] context.
303
+ this.lastTickError = error instanceof Error
304
+ ? `${error.message}${error.stack ? `\n${error.stack}` : ""}`
305
+ : String(error);
306
+ this.logger.error({ err: error instanceof Error ? { message: error.message, stack: error.stack } : error }, "Reconcile tick failed");
282
307
  }
283
308
  finally {
284
309
  this.tickInProgress = false;
package/dist/types.d.ts CHANGED
@@ -3,6 +3,9 @@
3
3
  *
4
4
  * queued → preparing_head → validating → merging → merged
5
5
  *
6
+ * Every entry builds a cumulative spec branch and runs CI on it.
7
+ * Head entry pushes its spec to main on merge (fast-forward).
8
+ *
6
9
  * Failure: any state → evicted (after retry budget exhausted).
7
10
  * Conflict retries are gated on base SHA change (non-spinning).
8
11
  *
@@ -158,6 +161,7 @@ export interface PRStatus {
158
161
  branch: string;
159
162
  headSha: string;
160
163
  mergeable: boolean;
164
+ mergeStateStatus?: string | undefined;
161
165
  reviewApproved: boolean;
162
166
  merged: boolean;
163
167
  }
@@ -169,7 +173,7 @@ export interface QueueConfig {
169
173
  pollIntervalMs: number;
170
174
  requiredChecks: string[];
171
175
  }
172
- export type ReconcileAction = "promoted" | "fetch_started" | "main_broken" | "branch_mismatch" | "rebase_started" | "rebase_succeeded" | "rebase_conflict" | "spec_build_started" | "spec_build_succeeded" | "spec_build_conflict" | "ci_triggered" | "ci_pending" | "ci_passed" | "ci_failed" | "ci_flaky_retry" | "merge_revalidating" | "merge_succeeded" | "merge_rejected" | "merge_external" | "evicted" | "invalidated" | "retry_gated" | "budget_exhausted";
176
+ export type ReconcileAction = "promoted" | "fetch_started" | "main_broken" | "branch_mismatch" | "rebase_started" | "rebase_succeeded" | "rebase_conflict" | "spec_build_started" | "spec_build_succeeded" | "spec_build_conflict" | "ci_triggered" | "ci_pending" | "ci_passed" | "ci_failed" | "ci_flaky_retry" | "merge_revalidating" | "merge_succeeded" | "merge_rejected" | "merge_external" | "evicted" | "invalidated" | "retry_gated" | "budget_exhausted" | "sanitized_closed" | "sanitized_duplicate" | "branch_unreachable";
173
177
  export interface ReconcileEvent {
174
178
  at: string;
175
179
  entryId: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.5.5",
3
+ "version": "0.6.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": {