merge-steward 0.32.6 → 0.33.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.
Files changed (49) hide show
  1. package/README.md +12 -7
  2. package/dist/check-policy.d.ts +11 -0
  3. package/dist/check-policy.js +88 -0
  4. package/dist/config.d.ts +0 -2
  5. package/dist/config.js +1 -12
  6. package/dist/db/schema.d.ts +3 -2
  7. package/dist/db/schema.js +62 -9
  8. package/dist/db/sqlite-store.d.ts +3 -9
  9. package/dist/db/sqlite-store.js +55 -44
  10. package/dist/github/actions-runner.d.ts +6 -2
  11. package/dist/github/actions-runner.js +63 -47
  12. package/dist/github/check-run-reporter.d.ts +1 -3
  13. package/dist/github/check-run-reporter.js +1 -52
  14. package/dist/github/pr-client.d.ts +1 -0
  15. package/dist/github/pr-client.js +4 -16
  16. package/dist/github/shell-git.d.ts +0 -5
  17. package/dist/github/shell-git.js +0 -62
  18. package/dist/github-policy.d.ts +7 -0
  19. package/dist/github-policy.js +37 -0
  20. package/dist/github-repo-discovery.d.ts +2 -0
  21. package/dist/github-repo-discovery.js +44 -9
  22. package/dist/interfaces.d.ts +2 -19
  23. package/dist/invalidation.d.ts +10 -4
  24. package/dist/invalidation.js +21 -8
  25. package/dist/queue-order.d.ts +25 -0
  26. package/dist/queue-order.js +93 -0
  27. package/dist/reconciler-core.d.ts +13 -6
  28. package/dist/reconciler-core.js +12 -4
  29. package/dist/reconciler-evict.d.ts +1 -1
  30. package/dist/reconciler-evict.js +7 -7
  31. package/dist/reconciler-merge.js +171 -56
  32. package/dist/reconciler-post-merge.js +24 -49
  33. package/dist/reconciler-prepare.js +79 -158
  34. package/dist/reconciler-queue-labels.d.ts +1 -1
  35. package/dist/reconciler-queue-labels.js +1 -1
  36. package/dist/reconciler-sanitize.js +16 -6
  37. package/dist/reconciler-validate.d.ts +1 -1
  38. package/dist/reconciler-validate.js +121 -31
  39. package/dist/reconciler.js +35 -12
  40. package/dist/server.js +11 -2
  41. package/dist/service-queue.js +16 -20
  42. package/dist/service-runtime.d.ts +1 -0
  43. package/dist/service-runtime.js +35 -2
  44. package/dist/store.d.ts +4 -19
  45. package/dist/types.d.ts +33 -28
  46. package/dist/watch/dashboard-model.js +2 -2
  47. package/dist/watch/format.d.ts +8 -8
  48. package/dist/watch/format.js +8 -8
  49. package/package.json +1 -1
package/README.md CHANGED
@@ -8,18 +8,22 @@ For the background story and design trade-offs, read [merge-steward: speculative
8
8
 
9
9
  ## Why this matters
10
10
 
11
- The queue keeps delivery fast without pretending branch CI is enough. Each speculative branch is the cumulative queue order on top of the latest base: `main + A`, then `main + A + B`, then `main + A + B + C`. No more "CI was green yesterday, breaks on merge today" — the integration bug is caught before `main` ever sees it.
11
+ The queue keeps delivery fast without pretending branch CI is always enough. For each dependency-ready PR it resolves one immutable candidate: the exact PR head when it already contains the prospective base, or an integration commit when it does not. Downstream candidates are cumulative: `main + A`, then `main + A + B`, then `main + A + B + C`.
12
12
 
13
13
  ## How it works
14
14
 
15
15
  1. A PR becomes eligible when GitHub says it is approved and its required checks are green.
16
16
  2. The steward notices through webhook wakeups or startup reconcile scans, and admits the PR to the queue.
17
- 3. It builds a speculative branch — `main + PR` at the head, cumulative downstream (`main + A + B`, `main + A + B + C`).
18
- 4. CI runs on that speculative SHA.
19
- 5. Once the speculative SHA's checks are green and it is still a fast-forward from current `main`, the steward pushes that SHA to `main` immediately. It does **not** wait for `main`'s own CI to settle, and **never** pauses the queue because `main` is red — the green speculative SHA *is* the next `main`.
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.
18
+ 4. It validates checks on that exact SHA. Only newly-created integration candidates trigger synthetic CI.
19
+ 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
20
  6. On CI failure: retry (gated on base SHA change), then evict with a durable incident record and GitHub check run.
21
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.
22
22
 
23
+ This is structural, not an optional fast path. An exact head is safe precisely
24
+ when it already contains the prospective base; if it does not, Merge Steward
25
+ creates and tests the integration candidate. Checks never move between SHAs.
26
+
23
27
  ## Use with your own agent
24
28
 
25
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:
@@ -78,11 +82,12 @@ The real gate is:
78
82
 
79
83
  - GitHub says the PR review state is approved
80
84
  - configured required checks are green
81
- - the steward's speculative integrated branch also passes CI
85
+ - the exact landing candidate is green under the current check policy
86
+ - current `main` is an ancestor of that same immutable candidate SHA
82
87
 
83
- `review-quill/verdict` only matters if you include it in the repo's required checks. Branch protection is useful as defense in depth, but the steward merges by fast-forwarding `main` to the already-tested speculative SHA — not by pressing GitHub's merge button. Successful merges therefore depend on the steward App being allowed to push to the protected branch. See [docs/merge-steward.md](https://github.com/krasnoperov/patchrelay/blob/main/docs/merge-steward.md) for the full App permission set.
88
+ `review-quill/verdict` only matters if you include it in the repo's required checks. Branch protection is useful as defense in depth, but the steward merges by fast-forwarding `main` to the already-tested candidate SHA — not by pressing GitHub's merge button. Successful merges therefore depend on the steward App being allowed to push to the protected branch. See [docs/merge-steward.md](https://github.com/krasnoperov/patchrelay/blob/main/docs/merge-steward.md) for the full App permission set.
84
89
 
85
- **`main`'s own CI is information-only.** The speculative SHA the steward tests *is* the exact tree that becomes `main`, so re-testing `main` after the push adds no signal — it only catches flakiness or out-of-band changes (direct pushes, hotfixes). The queue therefore **ignores `main`'s CI entirely** for advancement: it does not gate landing on `main` being green, does not wait for `main` CI before the next landing, and is never "paused" by a red `main`. A red `main` with a green speculative SHA simply means the red was flaky or is fixed by landing — so the steward lands. Use `main`'s CI as a project-health canary, not a queue control.
90
+ **`main`'s own CI is information-only.** The candidate SHA the steward validates is exactly what becomes `main`, so re-testing it after the push adds no eligibility signal. Use `main` CI as a project-health canary, not a queue control.
86
91
 
87
92
  ## Interaction with PatchRelay
88
93
 
@@ -0,0 +1,11 @@
1
+ import type { CheckResult, RequiredCheck } from "./types.ts";
2
+ export interface CheckPolicyEvaluation {
3
+ status: "pass" | "pending" | "fail";
4
+ missing: RequiredCheck[];
5
+ failing: CheckResult[];
6
+ pending: CheckResult[];
7
+ }
8
+ export declare function mapGitHubCheckConclusion(status: string | undefined, conclusion: string | null): CheckResult["conclusion"];
9
+ /** Evaluate the exact check runs against the current GitHub policy identity. */
10
+ export declare function evaluateCheckPolicy(requiredChecks: RequiredCheck[], requireAllChecksOnEmptyRequiredSet: boolean, checks: CheckResult[]): CheckPolicyEvaluation;
11
+ export declare function formatRequiredCheck(check: RequiredCheck): string;
@@ -0,0 +1,88 @@
1
+ function normalizeName(name) {
2
+ return name.trim().toLowerCase();
3
+ }
4
+ function matchesRule(check, rule) {
5
+ return normalizeName(check.name) === normalizeName(rule.name)
6
+ && (rule.appId === null || check.appId === rule.appId);
7
+ }
8
+ function selectLatestRerun(checks) {
9
+ if (checks.length <= 1)
10
+ return checks;
11
+ if (checks.some((check) => check.runId === undefined))
12
+ return checks;
13
+ const ids = checks.map((check) => check.runId);
14
+ if (new Set(ids).size !== ids.length)
15
+ return checks;
16
+ return [checks.reduce((latest, check) => check.runId > latest.runId ? check : latest)];
17
+ }
18
+ function coalesceReruns(checks) {
19
+ const groups = new Map();
20
+ for (const check of checks) {
21
+ const key = `${normalizeName(check.name)}\0${check.appId ?? "any"}`;
22
+ const group = groups.get(key) ?? [];
23
+ group.push(check);
24
+ groups.set(key, group);
25
+ }
26
+ return [...groups.values()].flatMap(selectLatestRerun);
27
+ }
28
+ export function mapGitHubCheckConclusion(status, conclusion) {
29
+ if (status !== "completed" || conclusion === null)
30
+ return "pending";
31
+ switch (conclusion) {
32
+ case "success": return "success";
33
+ case "neutral": return "neutral";
34
+ case "skipped": return "skipped";
35
+ case "failure":
36
+ case "cancelled":
37
+ case "timed_out":
38
+ case "stale":
39
+ case "action_required":
40
+ return "failure";
41
+ default:
42
+ return "pending";
43
+ }
44
+ }
45
+ /** Evaluate the exact check runs against the current GitHub policy identity. */
46
+ export function evaluateCheckPolicy(requiredChecks, requireAllChecksOnEmptyRequiredSet, checks) {
47
+ if (requiredChecks.length > 0) {
48
+ const missing = [];
49
+ const failing = [];
50
+ const pending = [];
51
+ for (const required of requiredChecks) {
52
+ const matching = selectLatestRerun(checks.filter((check) => matchesRule(check, required)));
53
+ if (matching.length === 0) {
54
+ missing.push(required);
55
+ continue;
56
+ }
57
+ // A named required job that was skipped did not validate this
58
+ // candidate. Neutral is explicitly accepted by GitHub.
59
+ failing.push(...matching.filter((check) => check.conclusion === "failure" || check.conclusion === "skipped"));
60
+ pending.push(...matching.filter((check) => check.conclusion === "pending"));
61
+ }
62
+ return {
63
+ status: failing.length > 0 ? "fail" : missing.length > 0 || pending.length > 0 ? "pending" : "pass",
64
+ missing,
65
+ failing,
66
+ pending,
67
+ };
68
+ }
69
+ if (checks.length === 0) {
70
+ return { status: "pending", missing: [], failing: [], pending: [] };
71
+ }
72
+ // With no named required set, skipped conditional jobs are non-applicable.
73
+ const currentChecks = coalesceReruns(checks);
74
+ const failing = currentChecks.filter((check) => check.conclusion === "failure");
75
+ const pending = currentChecks.filter((check) => check.conclusion === "pending");
76
+ if (requireAllChecksOnEmptyRequiredSet || requiredChecks.length === 0) {
77
+ return {
78
+ status: failing.length > 0 ? "fail" : pending.length > 0 ? "pending" : "pass",
79
+ missing: [],
80
+ failing,
81
+ pending,
82
+ };
83
+ }
84
+ return { status: "pass", missing: [], failing: [], pending: [] };
85
+ }
86
+ export function formatRequiredCheck(check) {
87
+ return check.appId === null ? check.name : `${check.name} (app ${check.appId})`;
88
+ }
package/dist/config.d.ts CHANGED
@@ -34,8 +34,6 @@ export declare const stewardConfigSchema: z.ZodObject<{
34
34
  queueMergingLabel: z.ZodDefault<z.ZodString>;
35
35
  mergeQueueCheckName: z.ZodDefault<z.ZodString>;
36
36
  evictionCheckName: z.ZodDefault<z.ZodString>;
37
- specReadyCheckName: z.ZodDefault<z.ZodString>;
38
- specBranchPrefix: z.ZodDefault<z.ZodString>;
39
37
  excludeBranches: z.ZodDefault<z.ZodArray<z.ZodString>>;
40
38
  autoResolvePatterns: z.ZodDefault<z.ZodArray<z.ZodObject<{
41
39
  glob: z.ZodString;
package/dist/config.js CHANGED
@@ -34,25 +34,14 @@ export const stewardConfigSchema = z.object({
34
34
  // Queue sub-state labels — applied/cleared by the reconciler so a PR's
35
35
  // live position in the merge queue is visible on GitHub and readable by
36
36
  // patchrelay for its Linear "In Merge Queue" status. `queue:testing` =
37
- // spec CI validating; `queue:merging` = head, merge in progress.
37
+ // candidate checks validating; `queue:merging` = head, landing in progress.
38
38
  queueTestingLabel: z.string().default("queue:testing"),
39
39
  queueMergingLabel: z.string().default("queue:merging"),
40
40
  mergeQueueCheckName: z.string().default(DEFAULT_MERGE_QUEUE_CHECK_NAME),
41
- // Plan §2.4 — bus-contract artifact names exposed for cross-service
42
- // alignment. Defaults preserve current behavior. The steward writes
43
- // `evictionCheckName` (today: same as mergeQueueCheckName, kept for
44
- // contract clarity) and `specReadyCheckName`; consumers read them.
45
41
  /** Eviction check run name. Synonym for mergeQueueCheckName, surfaced
46
42
  * under the bus-contract name so consumer code can be naming-aligned
47
43
  * with patchrelay's resolver. */
48
44
  evictionCheckName: z.string().default(DEFAULT_MERGE_QUEUE_CHECK_NAME),
49
- /** Spec-ready check run name (default: "merge-steward/spec-ready").
50
- * Read by review-quill in integration_tree mode (plan §3.5). */
51
- specReadyCheckName: z.string().default("merge-steward/spec-ready"),
52
- /** Prefix for spec branch names (default: "mq-spec-"); matches the
53
- * existing SPEC_BRANCH_PREFIX in reconciler-core.ts. The pattern
54
- * review-quill matches against is `${prefix}*`. */
55
- specBranchPrefix: z.string().default("mq-spec-"),
56
45
  /** Branch name patterns to exclude from admission (glob-style). */
57
46
  excludeBranches: z.array(z.string()).default([]),
58
47
  /**
@@ -1,6 +1,7 @@
1
1
  import type { DatabaseConnection } from "./shared.ts";
2
2
  /**
3
- * Create the steward's SQLite schema. Idempotent — safe to call on every startup.
4
- * No migration path: this is the final shape. There are no legacy installations.
3
+ * Create and upgrade the steward's SQLite schema. Idempotent — safe to call
4
+ * on every startup. Migrations preserve active queue state, then remove
5
+ * obsolete columns so the runtime has one candidate model.
5
6
  */
6
7
  export declare function ensureSchema(connection: DatabaseConnection): void;
package/dist/db/schema.js CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Create the steward's SQLite schema. Idempotent — safe to call on every startup.
3
- * No migration path: this is the final shape. There are no legacy installations.
2
+ * Create and upgrade the steward's SQLite schema. Idempotent — safe to call
3
+ * on every startup. Migrations preserve active queue state, then remove
4
+ * obsolete columns so the runtime has one candidate model.
4
5
  */
5
6
  export function ensureSchema(connection) {
6
7
  connection.exec(`
@@ -21,9 +22,11 @@ export function ensureSchema(connection) {
21
22
  max_retries INTEGER NOT NULL DEFAULT 3,
22
23
  last_failed_base_sha TEXT,
23
24
  issue_key TEXT,
24
- spec_branch TEXT,
25
- spec_sha TEXT,
26
- spec_based_on TEXT,
25
+ candidate_kind TEXT,
26
+ candidate_policy_fingerprint TEXT,
27
+ candidate_ref TEXT,
28
+ candidate_sha TEXT,
29
+ candidate_based_on TEXT,
27
30
  wait_detail TEXT,
28
31
  post_merge_status TEXT,
29
32
  post_merge_sha TEXT,
@@ -77,12 +80,25 @@ export function ensureSchema(connection) {
77
80
  ensureColumn(connection, "queue_entries", "post_merge_checked_at", "TEXT");
78
81
  ensureColumn(connection, "queue_entries", "wait_detail", "TEXT");
79
82
  ensureColumn(connection, "queue_entries", "pr_title", "TEXT");
80
- // Plan §8.4: stack-aware admission ordering needs the PR's base ref.
83
+ ensureColumn(connection, "queue_entries", "candidate_kind", "TEXT");
84
+ ensureColumn(connection, "queue_entries", "candidate_policy_fingerprint", "TEXT");
85
+ ensureColumn(connection, "queue_entries", "candidate_ref", "TEXT");
86
+ ensureColumn(connection, "queue_entries", "candidate_sha", "TEXT");
87
+ ensureColumn(connection, "queue_entries", "candidate_based_on", "TEXT");
81
88
  ensureColumn(connection, "queue_entries", "base_ref_name", "TEXT");
82
- // Plan §5.3: cached identity for patch-id-aware updateHead.
83
- ensureColumn(connection, "queue_entries", "head_patch_id", "TEXT");
84
- ensureColumn(connection, "queue_entries", "spec_tree_id", "TEXT");
85
89
  ensureColumn(connection, "queue_entries", "decided_at", "TEXT");
90
+ migrateLegacyCandidateColumns(connection);
91
+ // Retire patch-equivalence caches from older installations. Candidate
92
+ // validity is SHA-bound; preserving these columns would imply otherwise.
93
+ dropColumnIfPresent(connection, "queue_entries", "head_patch_id");
94
+ dropColumnIfPresent(connection, "queue_entries", "spec_tree_id");
95
+ connection.exec(`
96
+ UPDATE queue_entries
97
+ SET candidate_kind = 'integration'
98
+ WHERE candidate_kind IS NULL
99
+ AND candidate_sha IS NOT NULL
100
+ AND candidate_ref IS NOT NULL
101
+ `);
86
102
  connection.exec(`
87
103
  UPDATE queue_entries
88
104
  SET post_merge_status = 'pending'
@@ -104,6 +120,37 @@ export function ensureSchema(connection) {
104
120
  WHERE status NOT IN ('merged', 'evicted', 'dequeued')
105
121
  `);
106
122
  }
123
+ function migrateLegacyCandidateColumns(connection) {
124
+ const legacyColumns = [
125
+ ["spec_branch", "candidate_ref"],
126
+ ["spec_sha", "candidate_sha"],
127
+ ["spec_based_on", "candidate_based_on"],
128
+ ];
129
+ for (const [legacy, candidate] of legacyColumns) {
130
+ if (!hasColumn(connection, "queue_entries", legacy))
131
+ continue;
132
+ connection.exec(`
133
+ UPDATE queue_entries
134
+ SET ${candidate} = ${legacy}
135
+ WHERE ${candidate} IS NULL
136
+ AND ${legacy} IS NOT NULL
137
+ `);
138
+ }
139
+ // Derive kind while the legacy ref is still available, then remove all
140
+ // old candidate representations in this same startup migration.
141
+ connection.exec(`
142
+ UPDATE queue_entries
143
+ SET candidate_kind = CASE
144
+ WHEN candidate_sha = head_sha AND candidate_ref IS NULL THEN 'head'
145
+ WHEN candidate_sha IS NOT NULL THEN 'integration'
146
+ ELSE NULL
147
+ END
148
+ WHERE candidate_kind IS NULL
149
+ `);
150
+ for (const [legacy] of legacyColumns) {
151
+ dropColumnIfPresent(connection, "queue_entries", legacy);
152
+ }
153
+ }
107
154
  function hasColumn(connection, table, column) {
108
155
  const rows = connection.prepare(`PRAGMA table_info(${table})`).all();
109
156
  for (const row of rows) {
@@ -119,3 +166,9 @@ function ensureColumn(connection, table, column, type) {
119
166
  }
120
167
  connection.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
121
168
  }
169
+ function dropColumnIfPresent(connection, table, column) {
170
+ if (!hasColumn(connection, table, column)) {
171
+ return;
172
+ }
173
+ connection.exec(`ALTER TABLE ${table} DROP COLUMN ${column}`);
174
+ }
@@ -12,18 +12,12 @@ export declare class SqliteStore implements QueueStore {
12
12
  listActive(repoId: string): QueueEntry[];
13
13
  listAll(repoId: string): QueueEntry[];
14
14
  listPostMergePending(repoId: string): QueueEntry[];
15
+ listRecentTerminal(repoId: string, limit?: number): QueueEntry[];
15
16
  insert(entry: QueueEntry): void;
16
- transition(entryId: string, to: QueueEntryStatus, patch?: Partial<Pick<QueueEntry, "headSha" | "baseSha" | "ciRunId" | "ciRetries" | "retryAttempts" | "lastFailedBaseSha" | "specBranch" | "specSha" | "specBasedOn" | "waitDetail" | "postMergeStatus" | "postMergeSha" | "postMergeSummary" | "postMergeCheckedAt" | "headPatchId" | "specTreeId">>, detail?: string): void;
17
+ transition(entryId: string, to: QueueEntryStatus, patch?: Partial<Pick<QueueEntry, "headSha" | "baseSha" | "ciRunId" | "ciRetries" | "retryAttempts" | "lastFailedBaseSha" | "candidateRef" | "candidateSha" | "candidateBasedOn" | "waitDetail" | "candidateKind" | "candidatePolicyFingerprint" | "postMergeStatus" | "postMergeSha" | "postMergeSummary" | "postMergeCheckedAt">>, detail?: string): void;
17
18
  dequeue(entryId: string): void;
18
19
  updateHead(entryId: string, newHeadSha: string): void;
19
- rebuildSpecHeadEquivalent(entryId: string, patch: {
20
- headSha: string;
21
- specSha: string;
22
- specBranch: string;
23
- headPatchId: string;
24
- specTreeId: string;
25
- ciRunId: string | null;
26
- }, detail?: string): void;
20
+ updateBaseRef(entryId: string, baseRefName: string | null, detail?: string): void;
27
21
  updatePriority(entryId: string, priority: number, detail?: string): void;
28
22
  insertIncident(incident: IncidentRecord): void;
29
23
  listIncidents(entryId: string): IncidentRecord[];
@@ -1,6 +1,7 @@
1
1
  import { TERMINAL_STATUSES } from "../types.js";
2
2
  import { SqliteConnection, isoNow } from "./shared.js";
3
3
  import { ensureSchema } from "./schema.js";
4
+ import { orderActiveQueue } from "../queue-order.js";
4
5
  function mapEntry(row) {
5
6
  return {
6
7
  id: String(row.id),
@@ -19,9 +20,15 @@ function mapEntry(row) {
19
20
  maxRetries: Number(row.max_retries),
20
21
  lastFailedBaseSha: row.last_failed_base_sha === null ? null : String(row.last_failed_base_sha),
21
22
  issueKey: row.issue_key === null ? null : String(row.issue_key),
22
- specBranch: row.spec_branch === null || row.spec_branch === undefined ? null : String(row.spec_branch),
23
- specSha: row.spec_sha === null || row.spec_sha === undefined ? null : String(row.spec_sha),
24
- specBasedOn: row.spec_based_on === null || row.spec_based_on === undefined ? null : String(row.spec_based_on),
23
+ candidateKind: row.candidate_kind === null || row.candidate_kind === undefined
24
+ ? null
25
+ : String(row.candidate_kind),
26
+ candidatePolicyFingerprint: row.candidate_policy_fingerprint === null || row.candidate_policy_fingerprint === undefined
27
+ ? null
28
+ : String(row.candidate_policy_fingerprint),
29
+ candidateRef: row.candidate_ref === null || row.candidate_ref === undefined ? null : String(row.candidate_ref),
30
+ candidateSha: row.candidate_sha === null || row.candidate_sha === undefined ? null : String(row.candidate_sha),
31
+ candidateBasedOn: row.candidate_based_on === null || row.candidate_based_on === undefined ? null : String(row.candidate_based_on),
25
32
  waitDetail: row.wait_detail === null || row.wait_detail === undefined ? null : String(row.wait_detail),
26
33
  postMergeStatus: row.post_merge_status === null || row.post_merge_status === undefined
27
34
  ? null
@@ -31,8 +38,6 @@ function mapEntry(row) {
31
38
  postMergeCheckedAt: row.post_merge_checked_at === null || row.post_merge_checked_at === undefined ? null : String(row.post_merge_checked_at),
32
39
  prTitle: row.pr_title === null || row.pr_title === undefined ? null : String(row.pr_title),
33
40
  baseRefName: row.base_ref_name === null || row.base_ref_name === undefined ? null : String(row.base_ref_name),
34
- headPatchId: row.head_patch_id === null || row.head_patch_id === undefined ? null : String(row.head_patch_id),
35
- specTreeId: row.spec_tree_id === null || row.spec_tree_id === undefined ? null : String(row.spec_tree_id),
36
41
  decidedAt: row.decided_at === null || row.decided_at === undefined ? null : String(row.decided_at),
37
42
  enqueuedAt: String(row.enqueued_at),
38
43
  updatedAt: String(row.updated_at),
@@ -97,10 +102,7 @@ export class SqliteStore {
97
102
  }
98
103
  }
99
104
  getHead(repoId) {
100
- const row = this.conn.prepare(`SELECT * FROM queue_entries
101
- WHERE repo_id = ? AND status NOT IN (${NOT_TERMINAL_SQL})
102
- ORDER BY priority DESC, position ASC LIMIT 1`).get(repoId, ...TERMINAL_STATUSES);
103
- return row ? mapEntry(row) : undefined;
105
+ return this.listActive(repoId)[0];
104
106
  }
105
107
  getEntry(entryId) {
106
108
  const row = this.conn.prepare("SELECT * FROM queue_entries WHERE id = ?").get(entryId);
@@ -116,8 +118,8 @@ export class SqliteStore {
116
118
  listActive(repoId) {
117
119
  const rows = this.conn.prepare(`SELECT * FROM queue_entries
118
120
  WHERE repo_id = ? AND status NOT IN (${NOT_TERMINAL_SQL})
119
- ORDER BY priority DESC, position ASC`).all(repoId, ...TERMINAL_STATUSES);
120
- return rows.map(mapEntry);
121
+ ORDER BY position ASC`).all(repoId, ...TERMINAL_STATUSES);
122
+ return orderActiveQueue(rows.map(mapEntry));
121
123
  }
122
124
  listAll(repoId) {
123
125
  const rows = this.conn.prepare("SELECT * FROM queue_entries WHERE repo_id = ? ORDER BY priority DESC, position ASC").all(repoId);
@@ -130,16 +132,25 @@ export class SqliteStore {
130
132
  ORDER BY position ASC`).all(repoId);
131
133
  return rows.map(mapEntry);
132
134
  }
135
+ listRecentTerminal(repoId, limit = 3) {
136
+ const rows = this.conn.prepare(`SELECT * FROM queue_entries
137
+ WHERE repo_id = ? AND status IN (${TERMINAL_STATUSES.map(() => "?").join(", ")})
138
+ ORDER BY COALESCE(decided_at, updated_at) DESC
139
+ LIMIT ?`).all(repoId, ...TERMINAL_STATUSES, limit);
140
+ return rows.map(mapEntry);
141
+ }
133
142
  insert(entry) {
134
143
  this.conn.transaction(() => {
135
144
  this.conn.prepare(`INSERT INTO queue_entries
136
145
  (id, repo_id, pr_number, branch, head_sha, base_sha, status, position,
137
146
  priority, generation, ci_run_id, ci_retries, retry_attempts,
138
- max_retries, last_failed_base_sha, issue_key, wait_detail,
147
+ max_retries, last_failed_base_sha, issue_key,
148
+ candidate_kind, candidate_policy_fingerprint,
149
+ candidate_ref, candidate_sha, candidate_based_on, wait_detail,
139
150
  post_merge_status, post_merge_sha, post_merge_summary, post_merge_checked_at,
140
151
  pr_title, base_ref_name, decided_at,
141
152
  enqueued_at, updated_at)
142
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(entry.id, entry.repoId, entry.prNumber, entry.branch, entry.headSha, entry.baseSha, entry.status, entry.position, entry.priority, entry.generation, entry.ciRunId, entry.ciRetries, entry.retryAttempts, entry.maxRetries, entry.lastFailedBaseSha, entry.issueKey ?? null, entry.waitDetail ?? null, entry.postMergeStatus ?? null, entry.postMergeSha ?? null, entry.postMergeSummary ?? null, entry.postMergeCheckedAt ?? null, entry.prTitle ?? null, entry.baseRefName ?? null, entry.decidedAt ?? null, entry.enqueuedAt, entry.updatedAt);
153
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(entry.id, entry.repoId, entry.prNumber, entry.branch, entry.headSha, entry.baseSha, entry.status, entry.position, entry.priority, entry.generation, entry.ciRunId, entry.ciRetries, entry.retryAttempts, entry.maxRetries, entry.lastFailedBaseSha, entry.issueKey ?? null, entry.candidateKind ?? null, entry.candidatePolicyFingerprint ?? null, entry.candidateRef ?? null, entry.candidateSha ?? null, entry.candidateBasedOn ?? null, entry.waitDetail ?? null, entry.postMergeStatus ?? null, entry.postMergeSha ?? null, entry.postMergeSummary ?? null, entry.postMergeCheckedAt ?? null, entry.prTitle ?? null, entry.baseRefName ?? null, entry.decidedAt ?? null, entry.enqueuedAt, entry.updatedAt);
143
154
  this.writeEvent(entry.id, null, entry.status);
144
155
  })();
145
156
  }
@@ -182,17 +193,25 @@ export class SqliteStore {
182
193
  sets.push("last_failed_base_sha = ?");
183
194
  values.push(patch.lastFailedBaseSha);
184
195
  }
185
- if (patch?.specBranch !== undefined) {
186
- sets.push("spec_branch = ?");
187
- values.push(patch.specBranch);
196
+ if (patch?.candidateKind !== undefined) {
197
+ sets.push("candidate_kind = ?");
198
+ values.push(patch.candidateKind);
199
+ }
200
+ if (patch?.candidatePolicyFingerprint !== undefined) {
201
+ sets.push("candidate_policy_fingerprint = ?");
202
+ values.push(patch.candidatePolicyFingerprint);
188
203
  }
189
- if (patch?.specSha !== undefined) {
190
- sets.push("spec_sha = ?");
191
- values.push(patch.specSha);
204
+ if (patch?.candidateRef !== undefined) {
205
+ sets.push("candidate_ref = ?");
206
+ values.push(patch.candidateRef);
192
207
  }
193
- if (patch?.specBasedOn !== undefined) {
194
- sets.push("spec_based_on = ?");
195
- values.push(patch.specBasedOn);
208
+ if (patch?.candidateSha !== undefined) {
209
+ sets.push("candidate_sha = ?");
210
+ values.push(patch.candidateSha);
211
+ }
212
+ if (patch?.candidateBasedOn !== undefined) {
213
+ sets.push("candidate_based_on = ?");
214
+ values.push(patch.candidateBasedOn);
196
215
  }
197
216
  if (patch?.waitDetail !== undefined) {
198
217
  sets.push("wait_detail = ?");
@@ -217,14 +236,6 @@ export class SqliteStore {
217
236
  sets.push("post_merge_checked_at = ?");
218
237
  values.push(patch.postMergeCheckedAt);
219
238
  }
220
- if (patch?.headPatchId !== undefined) {
221
- sets.push("head_patch_id = ?");
222
- values.push(patch.headPatchId);
223
- }
224
- if (patch?.specTreeId !== undefined) {
225
- sets.push("spec_tree_id = ?");
226
- values.push(patch.specTreeId);
227
- }
228
239
  this.conn.prepare(`UPDATE queue_entries SET ${sets.join(", ")} WHERE id = ?`).run(...values, entryId);
229
240
  this.writeEvent(entryId, from, to, detail);
230
241
  })();
@@ -245,35 +256,35 @@ export class SqliteStore {
245
256
  head_sha = ?, status = 'queued', generation = ?,
246
257
  ci_run_id = NULL, ci_retries = 0, retry_attempts = 0,
247
258
  last_failed_base_sha = NULL,
248
- spec_branch = NULL, spec_sha = NULL, spec_based_on = NULL,
259
+ candidate_kind = NULL, candidate_policy_fingerprint = NULL,
260
+ candidate_ref = NULL, candidate_sha = NULL, candidate_based_on = NULL,
249
261
  wait_detail = NULL,
250
262
  post_merge_status = NULL, post_merge_sha = NULL, post_merge_summary = NULL, post_merge_checked_at = NULL,
251
- head_patch_id = NULL, spec_tree_id = NULL,
252
263
  updated_at = ?
253
264
  WHERE id = ?`).run(newHeadSha, newGen, isoNow(), entryId);
254
265
  this.writeEvent(entryId, from, "queued", `updateHead: generation ${newGen}`);
255
266
  })();
256
267
  }
257
- rebuildSpecHeadEquivalent(entryId, patch, detail) {
268
+ updateBaseRef(entryId, baseRefName, detail) {
258
269
  this.conn.transaction(() => {
259
- const current = this.conn.prepare("SELECT status, generation FROM queue_entries WHERE id = ?").get(entryId);
270
+ const current = this.conn.prepare("SELECT status, base_ref_name FROM queue_entries WHERE id = ?").get(entryId);
260
271
  if (!current)
261
272
  return;
262
273
  const from = String(current.status);
263
274
  if (TERMINAL_STATUSES.includes(from))
264
275
  return;
265
- const newGen = Number(current.generation) + 1;
276
+ const currentBaseRef = current.base_ref_name === null ? null : String(current.base_ref_name);
277
+ if (currentBaseRef === baseRefName)
278
+ return;
266
279
  this.conn.prepare(`UPDATE queue_entries SET
267
- head_sha = ?, status = 'validating', generation = ?,
268
- ci_run_id = ?, ci_retries = 0, retry_attempts = 0,
280
+ base_ref_name = ?, status = 'queued',
281
+ ci_run_id = NULL, ci_retries = 0, retry_attempts = 0,
269
282
  last_failed_base_sha = NULL,
270
- spec_branch = ?, spec_sha = ?,
271
- head_patch_id = ?, spec_tree_id = ?,
272
- wait_detail = NULL,
273
- post_merge_status = NULL, post_merge_sha = NULL, post_merge_summary = NULL, post_merge_checked_at = NULL,
274
- updated_at = ?
275
- WHERE id = ?`).run(patch.headSha, newGen, patch.ciRunId, patch.specBranch, patch.specSha, patch.headPatchId, patch.specTreeId, isoNow(), entryId);
276
- this.writeEvent(entryId, from, "validating", detail ?? `patch-id-equivalent rebuild: generation ${newGen}`);
283
+ candidate_kind = NULL, candidate_policy_fingerprint = NULL,
284
+ candidate_ref = NULL, candidate_sha = NULL, candidate_based_on = NULL,
285
+ wait_detail = NULL, updated_at = ?
286
+ WHERE id = ?`).run(baseRefName, isoNow(), entryId);
287
+ this.writeEvent(entryId, from, "queued", detail ?? `base ref changed to ${baseRefName ?? "(default)"}`);
277
288
  })();
278
289
  }
279
290
  updatePriority(entryId, priority, detail) {
@@ -1,5 +1,5 @@
1
1
  import type { CIRunner } from "../interfaces.ts";
2
- import type { CIStatus } from "../types.ts";
2
+ import type { CIStatus, RequiredCheck } from "../types.ts";
3
3
  /**
4
4
  * CI runner that polls GitHub Actions via the gh CLI.
5
5
  * triggerRun is a no-op — force-pushing the branch triggers CI automatically.
@@ -9,9 +9,13 @@ import type { CIStatus } from "../types.ts";
9
9
  export declare class GitHubActionsRunner implements CIRunner {
10
10
  private readonly repoFullName;
11
11
  private readonly getRequiredChecks;
12
- constructor(repoFullName: string, getRequiredChecks?: () => string[], _shouldRequireAllChecksOnEmptyRequiredSet?: () => boolean);
12
+ private readonly shouldRequireAllChecksOnEmptyRequiredSet;
13
+ constructor(repoFullName: string, getRequiredChecks?: () => Array<string | RequiredCheck>, shouldRequireAllChecksOnEmptyRequiredSet?: () => boolean);
13
14
  triggerRun(_branch: string, sha: string): Promise<string>;
15
+ rerunRun(_runId: string, _branch: string, sha: string): Promise<string>;
14
16
  getStatus(runId: string): Promise<CIStatus>;
17
+ private listCheckRuns;
18
+ private evaluate;
15
19
  cancelRun(_runId: string): Promise<void>;
16
20
  getMainStatus(baseBranch: string): Promise<CIStatus>;
17
21
  }