merge-steward 0.5.6 → 0.7.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
  }
@@ -64,6 +64,13 @@ export class GitHubPRClient {
64
64
  return [];
65
65
  }
66
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
+ }
67
74
  async findPRByBranch(branch) {
68
75
  const result = await exec("gh", [
69
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,135 +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;
71
74
  }
72
75
  }
73
76
  }
74
- // ─── Head entry: fetch + gate + branch refresh ──────────────────
75
- async function prepareHead(ctx, entry) {
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;
105
+ }
106
+ }
107
+ catch {
108
+ // GitHub probe failed — don't block the tick.
109
+ }
110
+ return false;
111
+ }
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" });
117
- try {
118
- const prStatus = await ctx.github.getStatus(entry.prNumber);
119
- if (prStatus.mergeStateStatus === "DIRTY") {
120
- emit(ctx, entry, "budget_exhausted", {
121
- baseSha,
122
- detail: "retry gated and GitHub still reports merge conflict",
123
- });
124
- await evictEntry(ctx, entry, "integration_conflict");
125
- }
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");
126
190
  }
127
- catch {
128
- // Best-effort check. If GitHub status is unavailable, preserve the non-spinning wait.
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");
129
196
  }
130
197
  return;
131
198
  }
132
- await performBranchRefresh(ctx, entry, baseSha);
133
- }
134
- function describeMainBroken(failingChecks, pendingChecks) {
135
- const parts = [];
136
- if (failingChecks.length > 0) {
137
- parts.push(`failing ${summarizeCheckNames(failingChecks)}`);
138
- }
139
- if (pendingChecks.length > 0) {
140
- parts.push(`pending ${summarizeCheckNames(pendingChecks)}`);
141
- }
142
- return parts.length > 0 ? `main checks unhealthy: ${parts.join("; ")}` : "main checks unhealthy";
143
- }
144
- function summarizeCheckNames(checks, limit = 3) {
145
- const names = [...new Set(checks.map((check) => check.name))];
146
- if (names.length <= limit) {
147
- return names.join(", ");
148
- }
149
- return `${names.slice(0, limit).join(", ")} +${names.length - limit} more`;
150
- }
151
- async function performBranchRefresh(ctx, entry, baseSha) {
152
- emit(ctx, entry, "rebase_started", { baseSha, detail: `refreshing ${entry.branch} with ${ctx.baseBranch}` });
153
- const result = await ctx.git.mergeBaseInto(entry.branch, ref(ctx, ctx.baseBranch));
154
199
  if (!result.success) {
155
200
  emit(ctx, entry, "rebase_conflict", { baseSha, conflictFiles: result.conflictFiles });
156
201
  if (isBudgetExhausted(entry)) {
@@ -166,85 +211,39 @@ async function performBranchRefresh(ctx, entry, baseSha) {
166
211
  }
167
212
  return;
168
213
  }
169
- const headSha = result.sha ?? entry.headSha;
170
- await ctx.git.fetch();
171
- const latestRemoteHead = await ctx.git.headSha(ref(ctx, entry.branch));
172
- if (latestRemoteHead !== entry.headSha) {
173
- const detail = `remote advanced during refresh: expected ${entry.headSha.slice(0, 8)}, ` +
174
- `latest ${latestRemoteHead.slice(0, 8)}, candidate ${headSha.slice(0, 8)}`;
175
- emit(ctx, entry, "branch_mismatch", { detail });
176
- ctx.store.updateHead(entry.id, latestRemoteHead);
177
- return;
178
- }
179
- await ctx.git.push(entry.branch, false);
180
- emit(ctx, entry, "rebase_succeeded", { baseSha, detail: `refreshed ${entry.branch} with ${ctx.baseBranch}` });
181
- // Build speculative branch for downstream entries.
182
- let specBranch = null;
183
- let specSha = null;
184
- if (ctx.specBuilder) {
185
- specBranch = specBranchName(entry.id);
186
- emit(ctx, entry, "spec_build_started", { specBranch, baseSha });
187
- const specResult = await ctx.specBuilder.buildSpeculative(entry.branch, ref(ctx, ctx.baseBranch), specBranch);
188
- if (specResult.success) {
189
- specSha = specResult.sha ?? headSha;
190
- emit(ctx, entry, "spec_build_succeeded", { specBranch });
191
- }
192
- else {
193
- emit(ctx, entry, "spec_build_conflict", { specBranch });
194
- specBranch = null;
195
- }
196
- }
197
- const runId = await ctx.ci.triggerRun(entry.branch, headSha);
198
- 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 });
199
219
  ctx.store.transition(entry.id, "validating", {
200
- headSha, baseSha, ciRunId: runId, lastFailedBaseSha: null,
201
- specBranch, specSha, specBasedOn: null,
202
- }, `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}`);
203
223
  }
204
- // ─── Non-head entry: speculative branch ─────────────────────────
205
- async function prepareSpeculative(ctx, entry, prevEntry) {
206
- if (!ctx.specBuilder || !prevEntry.specBranch)
207
- return;
208
- const specName = specBranchName(entry.id);
209
- emit(ctx, entry, "spec_build_started", { specBranch: specName, dependsOn: prevEntry.id });
210
- let result;
211
- try {
212
- result = await ctx.specBuilder.buildSpeculative(entry.branch, prevEntry.specBranch, specName);
213
- }
214
- catch {
215
- // Stale spec branch — the previous entry's branch doesn't exist
216
- // (e.g., after restart with fresh clone). Reset both entries.
217
- emit(ctx, entry, "invalidated", { detail: "stale spec branch, rebuilding" });
218
- ctx.store.transition(prevEntry.id, "preparing_head", { ...CLEAN_CI, ...CLEAN_SPEC }, "spec branch missing, rebuilding");
219
- ctx.store.transition(entry.id, "preparing_head", { ...CLEAN_CI, ...CLEAN_SPEC }, "stale dependency, rebuilding");
220
- return;
224
+ function describeMainBroken(failingChecks, pendingChecks) {
225
+ const parts = [];
226
+ if (failingChecks.length > 0) {
227
+ parts.push(`failing ${summarizeCheckNames(failingChecks)}`);
221
228
  }
222
- if (result.success) {
223
- const specSha = result.sha ?? entry.headSha;
224
- emit(ctx, entry, "spec_build_succeeded", { specBranch: specName, dependsOn: prevEntry.id });
225
- const runId = await ctx.ci.triggerRun(specName, specSha);
226
- emit(ctx, entry, "ci_triggered", { ciRunId: runId, specBranch: specName });
227
- ctx.store.transition(entry.id, "validating", {
228
- ciRunId: runId, specBranch: specName, specSha, specBasedOn: prevEntry.id,
229
- }, `spec ${specName} based on ${prevEntry.id}, CI ${runId}`);
229
+ if (pendingChecks.length > 0) {
230
+ parts.push(`pending ${summarizeCheckNames(pendingChecks)}`);
230
231
  }
231
- else {
232
- emit(ctx, entry, "spec_build_conflict", { specBranch: specName, dependsOn: prevEntry.id });
233
- if (!isBudgetExhausted(entry)) {
234
- ctx.store.transition(entry.id, "preparing_head", {
235
- retryAttempts: entry.retryAttempts + 1, lastFailedBaseSha: prevEntry.specSha,
236
- }, `spec conflict, retry ${entry.retryAttempts + 1}/${entry.maxRetries}`);
237
- }
238
- else {
239
- await evictEntry(ctx, entry, "integration_conflict");
240
- }
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(", ");
241
238
  }
239
+ return `${names.slice(0, limit).join(", ")} +${names.length - limit} more`;
242
240
  }
243
241
  // ─── CI validation ──────────────────────────────────────────────
244
242
  async function checkValidation(ctx, entry, allActive, index) {
245
243
  if (!entry.ciRunId) {
246
- const target = ciTarget(entry);
247
- 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);
248
247
  emit(ctx, entry, "ci_triggered", { ciRunId: runId });
249
248
  ctx.store.transition(entry.id, "validating", { ciRunId: runId }, `CI triggered: ${runId}`);
250
249
  return;
@@ -265,8 +264,9 @@ async function checkValidation(ctx, entry, allActive, index) {
265
264
  emit(ctx, entry, "ci_failed", { ciRunId: entry.ciRunId });
266
265
  if (entry.ciRetries < ctx.flakyRetries) {
267
266
  emit(ctx, entry, "ci_flaky_retry", { detail: `retry ${entry.ciRetries + 1}/${ctx.flakyRetries}` });
268
- const target = ciTarget(entry);
269
- 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);
270
270
  ctx.store.transition(entry.id, "validating", {
271
271
  ciRunId: runId, ciRetries: entry.ciRetries + 1,
272
272
  }, `flaky retry ${entry.ciRetries + 1}/${ctx.flakyRetries}`);
@@ -291,8 +291,8 @@ async function checkValidation(ctx, entry, allActive, index) {
291
291
  }
292
292
  }
293
293
  }
294
- // ─── Merge (head only) ──────────────────────────────────────────
295
- async function mergeHead(ctx, entry, allActive) {
294
+ // ─── Merge: push spec branch to main (head only) ───────────────
295
+ async function mergeHead(ctx, entry) {
296
296
  emit(ctx, entry, "merge_revalidating");
297
297
  const prStatus = await ctx.github.getStatus(entry.prNumber);
298
298
  if (prStatus.merged) {
@@ -303,36 +303,46 @@ async function mergeHead(ctx, entry, allActive) {
303
303
  }
304
304
  if (!prStatus.reviewApproved) {
305
305
  emit(ctx, entry, "merge_rejected", { detail: "approval withdrawn" });
306
+ const allActive = ctx.store.listActive(ctx.repoId);
306
307
  await evictEntry(ctx, entry, "policy_blocked");
307
308
  await invalidateDownstream(ctx, allActive, 0);
308
309
  return;
309
310
  }
310
311
  if (prStatus.headSha !== entry.headSha) {
311
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);
312
314
  ctx.store.updateHead(entry.id, prStatus.headSha);
313
315
  await invalidateDownstream(ctx, allActive, 0);
314
316
  return;
315
317
  }
316
- // Check if base branch moved since validation — if so, re-prepare.
317
- if (entry.baseSha) {
318
- try {
319
- const currentBase = await ctx.git.headSha(ref(ctx, ctx.baseBranch));
320
- if (currentBase !== entry.baseSha) {
321
- emit(ctx, entry, "branch_mismatch", { detail: `base: expected ${entry.baseSha.slice(0, 8)}, got ${currentBase.slice(0, 8)}` });
322
- ctx.store.transition(entry.id, "preparing_head", { ...CLEAN_CI, ...CLEAN_SPEC }, "base moved, re-prepare");
323
- await invalidateDownstream(ctx, allActive, 0);
324
- return;
325
- }
326
- }
327
- catch {
328
- // 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;
329
334
  }
330
335
  }
336
+ catch {
337
+ // Can't verify — proceed and let push fail if needed.
338
+ }
339
+ // Push the spec branch to main (fast-forward).
331
340
  try {
332
- await ctx.github.mergePR(entry.prNumber);
341
+ await ctx.git.push(entry.specBranch, false, ctx.baseBranch);
333
342
  }
334
343
  catch {
335
- 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);
336
346
  if (isBudgetExhausted(entry)) {
337
347
  emit(ctx, entry, "budget_exhausted");
338
348
  await evictEntry(ctx, entry, "integration_conflict");
@@ -340,14 +350,21 @@ async function mergeHead(ctx, entry, allActive) {
340
350
  else {
341
351
  ctx.store.transition(entry.id, "preparing_head", {
342
352
  retryAttempts: entry.retryAttempts + 1, ...CLEAN_CI, ...CLEAN_SPEC,
343
- }, `merge rejected, retry ${entry.retryAttempts + 1}/${entry.maxRetries}`);
353
+ }, `push failed, retry ${entry.retryAttempts + 1}/${entry.maxRetries}`);
344
354
  }
355
+ // Head is rebuilding — downstream specs are stale (they were built
356
+ // on the old head spec which will change after re-preparation).
345
357
  await invalidateDownstream(ctx, allActive, 0);
346
358
  return;
347
359
  }
348
360
  emit(ctx, entry, "merge_succeeded");
349
- ctx.store.transition(entry.id, "merged", CLEAN_SPEC, "merged to main");
361
+ ctx.store.transition(entry.id, "merged", CLEAN_SPEC, "spec pushed to main");
350
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 */ }
351
368
  }
352
369
  // ─── Invalidation + eviction ────────────────────────────────────
353
370
  async function invalidateDownstream(ctx, allActive, afterIndex) {
@@ -361,7 +378,7 @@ async function invalidateDownstream(ctx, allActive, afterIndex) {
361
378
  }
362
379
  }
363
380
  async function cleanupSpec(ctx, entry) {
364
- if (entry.specBranch && ctx.specBuilder) {
381
+ if (entry.specBranch) {
365
382
  await ctx.specBuilder.deleteSpeculative(entry.specBranch).catch(() => {
366
383
  // Best-effort cleanup — branch may not exist.
367
384
  });
@@ -369,7 +386,6 @@ async function cleanupSpec(ctx, entry) {
369
386
  }
370
387
  async function evictEntry(ctx, entry, failureClass, extra) {
371
388
  await cleanupSpec(ctx, entry);
372
- // Use recorded baseSha if available, else resolve current base.
373
389
  let baseSha = entry.baseSha;
374
390
  if (!baseSha) {
375
391
  try {
@@ -379,7 +395,6 @@ async function evictEntry(ctx, entry, failureClass, extra) {
379
395
  baseSha = "unknown";
380
396
  }
381
397
  }
382
- // Build retry history from queue events (each event snapshots baseSha at transition time).
383
398
  const events = ctx.store.listEvents(entry.id);
384
399
  const retryHistory = [];
385
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
  *
@@ -170,7 +173,7 @@ export interface QueueConfig {
170
173
  pollIntervalMs: number;
171
174
  requiredChecks: string[];
172
175
  }
173
- 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";
174
177
  export interface ReconcileEvent {
175
178
  at: string;
176
179
  entryId: string;
@@ -18,5 +18,5 @@ export function DetailView({ detail, isHead, activeIndex, activeCount, headPrNum
18
18
  queueBlock,
19
19
  });
20
20
  const pipeline = queueProgress(entry.status);
21
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { gap: 2, children: [_jsxs(Text, { bold: true, children: ["#", entry.prNumber] }), entry.issueKey ? _jsx(Text, { children: entry.issueKey }) : null, _jsx(Text, { color: statusColor(entry.status), children: humanStatus(entry.status) }), _jsxs(Text, { dimColor: true, children: ["pos ", entry.position] }), _jsxs(Text, { dimColor: true, children: ["generation ", entry.generation] }), _jsxs(Text, { dimColor: true, children: ["retry ", entry.retryAttempts, "/", entry.maxRetries] })] }), _jsx(Text, { children: entry.branch }), _jsxs(Box, { gap: 2, children: [_jsxs(Text, { dimColor: true, children: ["head ", shortSha(entry.headSha)] }), _jsxs(Text, { dimColor: true, children: ["base ", shortSha(entry.baseSha)] }), entry.issueKey && _jsx(Text, { dimColor: true, children: entry.issueKey })] }), _jsxs(Box, { gap: 1, marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "progress" }), _jsx(Text, { children: progressBar(pipeline.current, pipeline.total, 12) }), _jsx(Text, { dimColor: true, children: nextStepLabel(entry.status) })] }), isHead && queueBlock && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["Queue paused: ", summarizeQueueBlock(queueBlock) ?? "main branch is unhealthy", "."] }), _jsxs(Text, { dimColor: true, children: [queueBlock.baseBranch, queueBlock.baseSha ? ` @ ${shortSha(queueBlock.baseSha)}` : ""] }), _jsxs(Text, { dimColor: true, children: ["Head PR #", queueBlock.headPrNumber ?? entry.prNumber, " will resume automatically once main recovers."] })] })), entry.maxRetries > 0 && (_jsxs(Box, { gap: 1, marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "retry" }), _jsx(Text, { children: progressBar(entry.retryAttempts, entry.maxRetries, 10) }), _jsxs(Text, { dimColor: true, children: [entry.retryAttempts, "/", entry.maxRetries] })] })), _jsx(EntryStateGraph, { main: graph.main, exits: graph.exits }), _jsx(ExternalRepairObservation, { observations: observations }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Incidents" }), incidents.length === 0 ? (_jsx(Text, { dimColor: true, children: "No incidents." })) : (incidents.map((incident) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: relativeTime(incident.at).padStart(4, " ") }), _jsx(Text, { color: "red", children: incident.failureClass }), _jsx(Text, { dimColor: true, children: incident.outcome })] }, incident.id))))] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Events" }), events.length === 0 ? (_jsx(Text, { dimColor: true, children: "No events yet." })) : (events.slice(-16).map((event) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: relativeTime(event.at).padStart(4, " ") }), _jsx(Text, { children: formatEntryEvent(event) })] }, event.id ?? `${event.entryId}-${event.at}`))))] })] }));
21
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Box, { gap: 2, children: [_jsxs(Text, { bold: true, children: ["#", entry.prNumber] }), entry.issueKey ? _jsx(Text, { children: entry.issueKey }) : null, _jsx(Text, { color: statusColor(entry.status), children: humanStatus(entry.status, entry) }), _jsxs(Text, { dimColor: true, children: ["pos ", entry.position] }), _jsxs(Text, { dimColor: true, children: ["retry ", entry.retryAttempts, "/", entry.maxRetries] })] }), _jsx(Text, { children: entry.branch }), _jsxs(Box, { gap: 2, children: [_jsxs(Text, { dimColor: true, children: ["head ", shortSha(entry.headSha)] }), _jsxs(Text, { dimColor: true, children: ["base ", shortSha(entry.baseSha)] })] }), entry.specBranch && (_jsxs(Box, { gap: 2, children: [_jsx(Text, { dimColor: true, children: "spec" }), _jsx(Text, { children: entry.specBranch }), _jsx(Text, { dimColor: true, children: shortSha(entry.specSha) }), _jsx(Text, { dimColor: true, children: "\u2190" }), _jsx(Text, { dimColor: true, children: entry.specBasedOn ? `entry ${shortSha(entry.specBasedOn)}` : "main" })] })), _jsxs(Box, { gap: 1, marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "progress" }), _jsx(Text, { children: progressBar(pipeline.current, pipeline.total, 12) }), _jsx(Text, { dimColor: true, children: nextStepLabel(entry.status, entry) })] }), isHead && queueBlock && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["Queue paused: ", summarizeQueueBlock(queueBlock) ?? "main branch is unhealthy", "."] }), _jsxs(Text, { dimColor: true, children: [queueBlock.baseBranch, queueBlock.baseSha ? ` @ ${shortSha(queueBlock.baseSha)}` : ""] }), _jsxs(Text, { dimColor: true, children: ["Head PR #", queueBlock.headPrNumber ?? entry.prNumber, " will resume automatically once main recovers."] })] })), entry.maxRetries > 0 && (_jsxs(Box, { gap: 1, marginTop: 1, children: [_jsx(Text, { dimColor: true, children: "retry" }), _jsx(Text, { children: progressBar(entry.retryAttempts, entry.maxRetries, 10) }), _jsxs(Text, { dimColor: true, children: [entry.retryAttempts, "/", entry.maxRetries] })] })), _jsx(EntryStateGraph, { main: graph.main, exits: graph.exits }), _jsx(ExternalRepairObservation, { observations: observations }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Incidents" }), incidents.length === 0 ? (_jsx(Text, { dimColor: true, children: "No incidents." })) : (incidents.map((incident) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: relativeTime(incident.at).padStart(4, " ") }), _jsx(Text, { color: "red", children: incident.failureClass }), _jsx(Text, { dimColor: true, children: incident.outcome })] }, incident.id))))] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Events" }), events.length === 0 ? (_jsx(Text, { dimColor: true, children: "No events yet." })) : (events.slice(-16).map((event) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: relativeTime(event.at).padStart(4, " ") }), _jsx(Text, { children: formatEntryEvent(event) })] }, event.id ?? `${event.entryId}-${event.at}`))))] })] }));
22
22
  }
@@ -1,21 +1,26 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useMemo } from "react";
3
3
  import { Box, Text, useStdout } from "ink";
4
- import { formatEventSummary, humanStatus, nextStepLabel, progressBar, queueProgress, relativeTime, statusColor, summarizeQueueBlock, truncate } from "./format.js";
4
+ import { formatEventSummary, humanStatus, nextStepLabel, progressBar, queueProgress, relativeTime, specChainLabel, statusColor, summarizeQueueBlock, truncate } from "./format.js";
5
5
  const ENTRY_ROW_HEIGHT = 2;
6
6
  const CHROME_ROWS = 13;
7
- function QueueRow({ entry, selected, infoWidth, isHead, queueBlock, }) {
7
+ function QueueRow({ entry, selected, infoWidth, isHead, queueBlock, allEntries, }) {
8
8
  const retryText = `${entry.retryAttempts}/${entry.maxRetries}`;
9
9
  const ciText = entry.ciRetries > 0 ? `CI retries ${entry.ciRetries}` : null;
10
10
  const blockedOnMain = isHead && queueBlock?.reason === "main_broken" && queueBlock.headPrNumber === entry.prNumber;
11
- const renderedStatus = blockedOnMain ? "blocked by broken main" : humanStatus(entry.status);
12
- const renderedColor = blockedOnMain ? "red" : statusColor(entry.status);
11
+ const renderedStatus = blockedOnMain ? "blocked by broken main" : humanStatus(entry.status, entry);
12
+ const renderedColor = blockedOnMain
13
+ ? "red"
14
+ : entry.status === "preparing_head" && entry.lastFailedBaseSha ? "yellow"
15
+ : statusColor(entry.status);
13
16
  const progress = queueProgress(entry.status);
14
- const branchLabel = truncate(entry.branch, Math.max(12, infoWidth - 34));
17
+ const specLabel = entry.specBranch
18
+ ? specChainLabel(entry, allEntries)
19
+ : truncate(entry.branch, Math.max(12, infoWidth - 34));
15
20
  const nextStep = blockedOnMain
16
21
  ? summarizeQueueBlock(queueBlock) ?? "waiting for main to recover"
17
- : nextStepLabel(entry.status);
18
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: selected ? "cyan" : "gray", children: selected ? "›" : " " }), _jsx(Text, { color: blockedOnMain ? "red" : isHead ? "green" : "gray", children: blockedOnMain ? "!" : isHead ? "#" : " " }), _jsx(Text, { bold: true, children: ` #${entry.prNumber}` }), entry.issueKey ? _jsx(Text, { children: ` ${entry.issueKey}` }) : null, _jsx(Text, { dimColor: true, children: ` pos ${entry.position}` }), _jsx(Text, { dimColor: true, children: ` ${relativeTime(entry.updatedAt)}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: renderedColor, children: renderedStatus })] }), _jsxs(Box, { paddingLeft: 2, gap: 1, children: [_jsx(Text, { dimColor: true, children: progressBar(progress.current, progress.total, 8) }), _jsx(Text, { dimColor: true, children: branchLabel }), _jsx(Text, { dimColor: true, children: "|" }), _jsx(Text, { dimColor: true, children: nextStep }), _jsx(Text, { dimColor: true, children: ` | retry ${retryText}` }), ciText ? (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: "|" }), _jsx(Text, { dimColor: true, children: ciText })] })) : null] })] }));
22
+ : nextStepLabel(entry.status, entry);
23
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: selected ? "cyan" : "gray", children: selected ? "›" : " " }), _jsx(Text, { color: blockedOnMain ? "red" : isHead ? "green" : "gray", children: blockedOnMain ? "!" : isHead ? "#" : " " }), _jsx(Text, { bold: true, children: ` #${entry.prNumber}` }), entry.issueKey ? _jsx(Text, { children: ` ${entry.issueKey}` }) : null, _jsx(Text, { dimColor: true, children: ` pos ${entry.position}` }), _jsx(Text, { dimColor: true, children: ` ${relativeTime(entry.updatedAt)}` }), _jsx(Text, { children: ` ` }), _jsx(Text, { color: renderedColor, children: renderedStatus })] }), _jsxs(Box, { paddingLeft: 2, gap: 1, children: [_jsx(Text, { dimColor: true, children: progressBar(progress.current, progress.total, 8) }), _jsx(Text, { dimColor: true, children: specLabel }), _jsx(Text, { dimColor: true, children: "|" }), _jsx(Text, { dimColor: true, children: nextStep }), _jsx(Text, { dimColor: true, children: ` | retry ${retryText}` }), ciText ? (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: "|" }), _jsx(Text, { dimColor: true, children: ciText })] })) : null] })] }));
19
24
  }
20
25
  export function QueueListView({ entries, selectedEntryId, recentEvents, headEntryId, queueBlock, }) {
21
26
  const { stdout } = useStdout();
@@ -25,5 +30,5 @@ export function QueueListView({ entries, selectedEntryId, recentEvents, headEntr
25
30
  const eventRows = Math.min(8, Math.max(4, rows - (entries.length * ENTRY_ROW_HEIGHT) - CHROME_ROWS));
26
31
  const displayedEvents = useMemo(() => recentEvents.slice(-eventRows), [eventRows, recentEvents]);
27
32
  const queueBlockLabel = summarizeQueueBlock(queueBlock);
28
- return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [queueBlock && (_jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["Queue paused: ", queueBlockLabel ?? "main is unhealthy", queueBlock.baseSha ? ` at ${truncate(queueBlock.baseSha, 10)}` : "", "."] }), _jsxs(Text, { dimColor: true, children: ["Head PR #", queueBlock.headPrNumber ?? "?", " will resume automatically once main recovers."] })] })), entries.length === 0 ? (_jsx(Text, { dimColor: true, children: "No queue entries in this filter." })) : (entries.map((entry) => (_jsx(QueueRow, { entry: entry, selected: entry.id === selectedEntryId, infoWidth: infoWidth, isHead: entry.id === headEntryId, queueBlock: queueBlock }, entry.id)))), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Recent Events" }), displayedEvents.length === 0 ? (_jsx(Text, { dimColor: true, children: "No queue events yet." })) : (displayedEvents.map((event) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: relativeTime(event.at).padStart(4, " ") }), _jsx(Text, { children: formatEventSummary(event) })] }, event.id ?? `${event.entryId}-${event.at}`))))] })] }));
33
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [queueBlock && (_jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsxs(Text, { color: "yellow", children: ["Queue paused: ", queueBlockLabel ?? "main is unhealthy", queueBlock.baseSha ? ` at ${truncate(queueBlock.baseSha, 10)}` : "", "."] }), _jsxs(Text, { dimColor: true, children: ["Head PR #", queueBlock.headPrNumber ?? "?", " will resume automatically once main recovers."] })] })), entries.length === 0 ? (_jsx(Text, { dimColor: true, children: "No queue entries in this filter." })) : (entries.map((entry) => (_jsx(QueueRow, { entry: entry, selected: entry.id === selectedEntryId, infoWidth: infoWidth, isHead: entry.id === headEntryId, queueBlock: queueBlock, allEntries: entries }, entry.id)))), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "Recent Events" }), displayedEvents.length === 0 ? (_jsx(Text, { dimColor: true, children: "No queue events yet." })) : (displayedEvents.map((event) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { dimColor: true, children: relativeTime(event.at).padStart(4, " ") }), _jsx(Text, { children: formatEventSummary(event) })] }, event.id ?? `${event.entryId}-${event.at}`))))] })] }));
29
34
  }
@@ -2,12 +2,28 @@ import type { CheckResult, QueueBlockState, QueueEntryStatus, QueueEventRecord,
2
2
  export declare function shortSha(value: string | null | undefined): string;
3
3
  export declare function relativeTime(iso: string | null | undefined): string;
4
4
  export declare function statusColor(status: QueueEntryStatus): "yellow" | "cyan" | "green" | "red" | "gray";
5
- export declare function humanStatus(status: QueueEntryStatus): string;
5
+ export declare function humanStatus(status: QueueEntryStatus, entry?: {
6
+ lastFailedBaseSha: string | null;
7
+ specBranch: string | null;
8
+ }): string;
6
9
  export declare function queueProgress(status: QueueEntryStatus): {
7
10
  current: number;
8
11
  total: number;
9
12
  };
10
- export declare function nextStepLabel(status: QueueEntryStatus): string;
13
+ export declare function nextStepLabel(status: QueueEntryStatus, entry?: {
14
+ lastFailedBaseSha: string | null;
15
+ specBasedOn: string | null;
16
+ }): string;
17
+ /** Describe the spec chain for a queue entry. */
18
+ export declare function specChainLabel(entry: {
19
+ specBranch: string | null;
20
+ specBasedOn: string | null;
21
+ specSha: string | null;
22
+ }, allEntries: Array<{
23
+ id: string;
24
+ prNumber: number;
25
+ specBranch: string | null;
26
+ }>): string;
11
27
  export declare function runtimeLabel(runtime: QueueRuntimeStatus): string;
12
28
  export declare function formatEventSummary(event: QueueEventSummary): string;
13
29
  export declare function formatEntryEvent(event: QueueEventRecord): string;
@@ -38,12 +38,14 @@ export function statusColor(status) {
38
38
  return "gray";
39
39
  }
40
40
  }
41
- export function humanStatus(status) {
41
+ export function humanStatus(status, entry) {
42
42
  switch (status) {
43
43
  case "queued":
44
44
  return "queued";
45
45
  case "preparing_head":
46
- return "refreshing branch";
46
+ if (entry?.lastFailedBaseSha)
47
+ return "retry-gated";
48
+ return "building spec";
47
49
  case "validating":
48
50
  return "running CI";
49
51
  case "merging":
@@ -71,16 +73,18 @@ export function queueProgress(status) {
71
73
  return { current: 4, total: 4 };
72
74
  }
73
75
  }
74
- export function nextStepLabel(status) {
76
+ export function nextStepLabel(status, entry) {
75
77
  switch (status) {
76
78
  case "queued":
77
79
  return "waiting for head-of-line turn";
78
80
  case "preparing_head":
79
- return "refreshing branch with latest main";
81
+ if (entry?.lastFailedBaseSha)
82
+ return "waiting for base to advance";
83
+ return "building cumulative spec branch";
80
84
  case "validating":
81
- return "waiting for CI result";
85
+ return "waiting for CI on spec branch";
82
86
  case "merging":
83
- return "final GitHub merge";
87
+ return "pushing spec to main";
84
88
  case "merged":
85
89
  return "landed on main";
86
90
  case "evicted":
@@ -89,6 +93,18 @@ export function nextStepLabel(status) {
89
93
  return "removed manually";
90
94
  }
91
95
  }
96
+ /** Describe the spec chain for a queue entry. */
97
+ export function specChainLabel(entry, allEntries) {
98
+ if (!entry.specBranch)
99
+ return "no spec yet";
100
+ const parent = entry.specBasedOn
101
+ ? allEntries.find((e) => e.id === entry.specBasedOn)
102
+ : null;
103
+ const base = parent
104
+ ? `#${parent.prNumber}`
105
+ : "main";
106
+ return `${shortSha(entry.specSha)} \u2190 ${base}`;
107
+ }
92
108
  export function runtimeLabel(runtime) {
93
109
  if (runtime.tickInProgress) {
94
110
  return "running";
@@ -96,19 +96,25 @@ export function buildExternalRepairObservations(detail, options) {
96
96
  if (entry.lastFailedBaseSha) {
97
97
  observations.push({
98
98
  tone: "warn",
99
- text: `Waiting for ${entry.lastFailedBaseSha.slice(0, 7)} on base to change before retrying rebase.`,
99
+ text: `Retry-gated: conflict on base ${entry.lastFailedBaseSha.slice(0, 7)}. Waiting for base to advance before rebuilding spec.`,
100
100
  });
101
101
  }
102
102
  else if (entry.status === "validating") {
103
+ const specNote = entry.specBranch
104
+ ? `CI running on spec branch ${entry.specBranch}.`
105
+ : "Waiting on CI for the spec branch.";
106
+ const cascadeNote = entry.specBasedOn
107
+ ? " Will merge automatically when head clears (cascade)."
108
+ : "";
103
109
  observations.push({
104
110
  tone: "info",
105
- text: "Waiting on validation CI for the refreshed head branch.",
111
+ text: `${specNote}${cascadeNote}`,
106
112
  });
107
113
  }
108
114
  else if (entry.status === "merging") {
109
115
  observations.push({
110
116
  tone: "info",
111
- text: "Validation passed; steward is attempting the GitHub merge.",
117
+ text: "CI passed; pushing spec branch to main (fast-forward).",
112
118
  });
113
119
  }
114
120
  if (entry.generation > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "merge-steward",
3
- "version": "0.5.6",
3
+ "version": "0.7.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": {