@bridge_gpt/mcp-server 0.2.9 → 0.2.12

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 (43) hide show
  1. package/README.md +59 -7
  2. package/build/commands.generated.js +6 -6
  3. package/build/conductor/bridge-api-client.js +263 -35
  4. package/build/conductor/cli.js +38 -17
  5. package/build/conductor/doctor.js +35 -2
  6. package/build/conductor/done-gate.js +301 -58
  7. package/build/conductor/epic-reconcile.js +318 -4
  8. package/build/conductor/epic-runtime.js +382 -18
  9. package/build/conductor/epic-state.js +188 -15
  10. package/build/conductor/errors.js +12 -0
  11. package/build/conductor/git-ci-types.js +16 -0
  12. package/build/conductor/git-producer.js +4 -4
  13. package/build/conductor/merge-ledger.js +7 -7
  14. package/build/conductor/pr-ci-producer.js +118 -19
  15. package/build/conductor/pr-review-producer.js +116 -0
  16. package/build/conductor/producer-ledger.js +5 -5
  17. package/build/conductor/spec-review-producer.js +88 -0
  18. package/build/conductor/store.js +105 -26
  19. package/build/conductor/supervisor-ledger.js +2 -2
  20. package/build/conductor/supervisor-merge.js +5 -5
  21. package/build/conductor/supervisor-message-relay.js +32 -1
  22. package/build/conductor/supervisor-runtime.js +10 -10
  23. package/build/conductor/taxonomy.js +8 -0
  24. package/build/conductor/tools.js +7 -7
  25. package/build/conductor-bin.js +12350 -19
  26. package/build/conductor-claude-hook-bin.js +167 -17
  27. package/build/decision-page-schema.js +26 -0
  28. package/build/doctor.js +200 -0
  29. package/build/index.js +23696 -4351
  30. package/build/init.js +481 -0
  31. package/build/install-bridge.js +772 -0
  32. package/build/mcp-profile.js +43 -0
  33. package/build/pipelines.generated.js +70 -48
  34. package/build/readme.generated.js +1 -1
  35. package/build/start-tickets-conductor.js +1 -0
  36. package/build/start-tickets.js +186 -10
  37. package/build/upgrade-cli.js +154 -0
  38. package/build/version.generated.js +1 -1
  39. package/package.json +7 -4
  40. package/pipelines/check-ci-ticket.json +2 -2
  41. package/pipelines/implement-ticket.json +2 -2
  42. package/pipelines/learn-repository.json +84 -42
  43. package/smoke-test/SMOKE-TEST.md +11 -17
@@ -1,44 +1,93 @@
1
1
  /**
2
2
  * Pure, deterministic observed-state rebuild + ready-set computation for the
3
- * Epic Supervisor (BAPI-408).
3
+ * Epic Supervisor (BAPI-408, BAPI-436).
4
4
  *
5
5
  * This module has NO I/O, NO timers, and NO LLM calls. Every time-dependent
6
6
  * function takes an explicit `now` (epoch ms) so tests are wall-clock
7
7
  * independent. Truth precedence: raw local ledger events override non-terminal
8
8
  * Postgres states.
9
+ *
10
+ * Status mapping (BAPI-436 — merge-gated dependent dispatch):
11
+ * gate.met → ready_for_review (implementation done; awaiting merge)
12
+ * run.stopped → ready_for_review (worker session ended; awaiting merge)
13
+ * merge.succeeded → done (merged; dependents may now dispatch)
14
+ * ci.failed → blocked (unchanged)
9
15
  */
10
16
  // ---------------------------------------------------------------------------
11
17
  // Status sets (module-private)
12
18
  // ---------------------------------------------------------------------------
13
19
  const NOT_STARTED_STATUS = "planned";
14
20
  const DONE_STATUSES = new Set(["done"]);
21
+ // "blocked" is intentionally non-terminal: a merge.succeeded arriving in a
22
+ // subsequent tick (cross-tick) can still advance a blocked ticket to done.
23
+ // This is the expected path when an operator merges a PR despite failed CI.
24
+ // Contrast with the same-tick case: ci.failed wins over merge.succeeded in
25
+ // the same ledger batch (first-signal-wins; see the foldedTicketKeys guard).
15
26
  const NON_TERMINAL_STATUSES = new Set([
16
27
  "planned",
17
28
  "ready",
18
29
  "dispatched",
19
30
  "running",
20
31
  "blocked",
32
+ "ready_for_review",
33
+ // BAPI-445: mid-flight pre-implementation spec re-review. Non-terminal so a
34
+ // review verdict / completion signal can still advance it on a later tick, but
35
+ // deliberately NOT a status computeReadySet returns for normal dispatch — a
36
+ // reviewing ticket is mid-flight, not ready. Stranded reviewing tickets are
37
+ // re-driven by reconcile's dedicated reviewing re-entry branch.
38
+ "reviewing",
21
39
  ]);
22
40
  const TERMINAL_SIGNAL_TYPES = new Set([
23
41
  "gate.met",
24
42
  "merge.succeeded",
25
43
  "ci.failed",
26
44
  "run.stopped",
45
+ "review.changes_requested",
46
+ // BAPI-445: pre-implementation spec re-review verdicts, scoped to review runs.
47
+ "spec_review.passed",
48
+ "spec_review.changes_requested",
27
49
  ]);
28
50
  function isNonTerminal(status) {
29
51
  return NON_TERMINAL_STATUSES.has(status);
30
52
  }
31
- function signalToNextStatus(signalType) {
53
+ /**
54
+ * Map a terminal ledger signal to the next ticket status.
55
+ *
56
+ * `isReviewRun` marks signals correlated to a `:review`-suffixed dispatch
57
+ * (BAPI-445 Obstacle 2). The spec re-review verdicts fold explicitly:
58
+ * - `spec_review.passed` → `ready` (verdict: proceed to implementation)
59
+ * - `spec_review.changes_requested` → `blocked` (verdict: reject; reconcile escalates)
60
+ *
61
+ * FAIL-SAFE (BAPI-445): a review run's INCIDENTAL terminals (`run.stopped`,
62
+ * `gate.met`, …) are filtered out of folding entirely by `rebuildObservedState`
63
+ * BEFORE this function is called — a review session merely *ending* must never
64
+ * advance a `reviewing` ticket, or implementation would proceed regardless of
65
+ * the review's outcome (fail-open). So for a review run this is only ever called
66
+ * with a `spec_review.*` verdict; `isReviewRun` is retained for caller clarity.
67
+ * For implementation runs the BAPI-436 mapping is unchanged.
68
+ */
69
+ export function signalToNextStatus(signalType, isReviewRun = false) {
70
+ void isReviewRun; // review-run incidental terminals are filtered before this call
71
+ if (signalType === "spec_review.passed")
72
+ return "ready";
73
+ if (signalType === "spec_review.changes_requested")
74
+ return "blocked";
32
75
  if (signalType === "ci.failed")
33
76
  return "blocked";
34
- return "done";
77
+ if (signalType === "review.changes_requested")
78
+ return "blocked";
79
+ if (signalType === "merge.succeeded")
80
+ return "done";
81
+ return "ready_for_review"; // gate.met and (implementation) run.stopped: awaiting merge
35
82
  }
36
83
  // ---------------------------------------------------------------------------
37
84
  // computeReadySet
38
85
  // ---------------------------------------------------------------------------
39
86
  /**
40
87
  * Pure deterministic ready-set computation. Returns ticket keys that:
41
- * 1. Have status "planned" (not yet started), AND
88
+ * 1. Have status "planned" (not yet started) or "ready" (crash-recovery —
89
+ * a ticket already advanced to ready on a prior tick that crashed before
90
+ * dispatch must not be silently dropped), AND
42
91
  * 2. Whose full `depends_on` list is satisfied (all deps have "done" status).
43
92
  *
44
93
  * Never calls an LLM or performs I/O. Goal 8 invariant.
@@ -47,7 +96,7 @@ export function computeReadySet(plan, ticketStatuses) {
47
96
  const ready = [];
48
97
  for (const ticket of plan.tickets) {
49
98
  const currentStatus = ticketStatuses.get(ticket.ticket_key) ?? "planned";
50
- if (currentStatus !== NOT_STARTED_STATUS)
99
+ if (currentStatus !== NOT_STARTED_STATUS && currentStatus !== "ready")
51
100
  continue;
52
101
  const allDepsResolved = ticket.depends_on.every((dep) => DONE_STATUSES.has(ticketStatuses.get(dep) ?? "planned"));
53
102
  if (allDepsResolved)
@@ -55,6 +104,53 @@ export function computeReadySet(plan, ticketStatuses) {
55
104
  }
56
105
  return ready;
57
106
  }
107
+ /**
108
+ * Default ceiling for spec re-review dispatch attempts on a stranded `reviewing`
109
+ * ticket (BAPI-445). Counts the initial review dispatch plus liveness-recovery
110
+ * re-dispatches; once reached, the reviewing recovery branch escalates instead
111
+ * of re-dispatching. Deliberately independent of the BAPI-441 remediation budget.
112
+ */
113
+ export const DEFAULT_MAX_SPEC_REVIEW_ATTEMPTS = 3;
114
+ /**
115
+ * Pure, deterministic remediation decision (no I/O, no clock). Given the current
116
+ * budget counters, the ledger-derived worker liveness, and the configured
117
+ * ceilings, decide whether to NUDGE (worker still alive), RE-DISPATCH (worker
118
+ * gone, budget remaining), or ESCALATE (budget exhausted).
119
+ *
120
+ * Budget exhaustion is checked FIRST so an at-ceiling ticket always escalates
121
+ * regardless of liveness. A counter at-or-above its ceiling exhausts the budget.
122
+ */
123
+ export function decideRemediation(attempts, noProgressAttempts, alive, config) {
124
+ if (attempts >= config.max_remediation_attempts ||
125
+ noProgressAttempts >= config.max_remediation_no_progress_attempts) {
126
+ return "escalate";
127
+ }
128
+ return alive ? "nudge" : "redispatch";
129
+ }
130
+ /**
131
+ * Pure liveness extraction (no I/O, explicit `nowMs`). Finds the most recent
132
+ * `message.delivered`/`message.acked` heartbeat event for `runId` and reports
133
+ * the worker alive when that heartbeat's age is within
134
+ * `windowSeconds`. An empty ledger (no heartbeat for the run) defaults to
135
+ * `{ alive: false, workerId: null }` (fail-closed: never misjudge a worker
136
+ * alive without evidence).
137
+ */
138
+ export function extractWorkerLiveness(events, runId, nowMs, windowSeconds) {
139
+ let latest = null;
140
+ for (const ev of events) {
141
+ if (ev.run_id !== runId)
142
+ continue;
143
+ if (ev.type !== "message.delivered" && ev.type !== "message.acked")
144
+ continue;
145
+ if (!latest || new Date(ev.time).getTime() > new Date(latest.time).getTime()) {
146
+ latest = ev;
147
+ }
148
+ }
149
+ if (!latest)
150
+ return { alive: false, workerId: null };
151
+ const age = nowMs - new Date(latest.time).getTime();
152
+ return { alive: age <= windowSeconds * 1000, workerId: latest.worker_id ?? null };
153
+ }
58
154
  // ---------------------------------------------------------------------------
59
155
  // rebuildObservedState
60
156
  // ---------------------------------------------------------------------------
@@ -65,42 +161,117 @@ export function computeReadySet(plan, ticketStatuses) {
65
161
  */
66
162
  export function rebuildObservedState(postgresState, events, _now) {
67
163
  const { epic_run, ticket_statuses, dispatches } = postgresState;
68
- // Build run_id → ticket_key lookup from dispatch records
164
+ // Build run_id → {ticket_key, isReview} lookup from dispatch records.
165
+ // BAPI-445 (Obstacle 2): a review run's dispatch_key carries a ":review"
166
+ // suffix, so the same run_id map can distinguish a pre-implementation
167
+ // spec-review run from the implementation run. This is correctness-critical:
168
+ // it lets the fold below route a review run's run.stopped to "ready" (review
169
+ // complete → proceed) instead of mis-folding it as implementation completion
170
+ // ("ready_for_review", awaiting merge), which would skip implementation.
69
171
  const runIdToTicketKey = new Map();
70
172
  for (const dispatch of dispatches) {
71
173
  if (dispatch.run_id) {
72
- runIdToTicketKey.set(dispatch.run_id, dispatch.ticket_key);
174
+ runIdToTicketKey.set(dispatch.run_id, {
175
+ ticketKey: dispatch.ticket_key,
176
+ isReview: dispatch.dispatch_key.endsWith(":review"),
177
+ });
73
178
  }
74
179
  }
75
180
  // Populate base maps from Postgres
76
181
  const ticketStatusMap = new Map();
77
182
  const ticketRowVersionMap = new Map();
183
+ const ticketRemediationMap = new Map();
78
184
  for (const ts of ticket_statuses) {
79
185
  ticketStatusMap.set(ts.ticket_key, ts.status);
80
186
  ticketRowVersionMap.set(ts.ticket_key, ts.row_version);
187
+ ticketRemediationMap.set(ts.ticket_key, {
188
+ attempts: ts.remediation_attempts ?? 0,
189
+ no_progress: ts.remediation_no_progress_attempts ?? 0,
190
+ });
81
191
  }
82
192
  const unfoldedSignals = [];
83
193
  const pendingMergeEvents = [];
84
194
  // Track which tickets already have a folded signal (one override per ticket)
85
195
  const foldedTicketKeys = new Set();
196
+ // BAPI-441: per-ticket latest blocking reason (ci.failed / review.changes_requested),
197
+ // tracked across the full ledger so an already-blocked ticket still carries a
198
+ // reason for the remediation pass to frame the nudge.
199
+ const ticketBlockedReasons = new Map();
86
200
  for (const event of events) {
87
201
  if (!TERMINAL_SIGNAL_TYPES.has(event.type))
88
202
  continue;
89
- // Map event → ticket via run_id
203
+ // Map event → ticket via run_id, resolving the dispatch role (review vs.
204
+ // implementation) so the fold can treat review-run terminals distinctly.
90
205
  const runId = typeof event.run_id === "string" ? event.run_id : null;
91
- const ticketKey = runId ? runIdToTicketKey.get(runId) : undefined;
92
- if (!ticketKey)
206
+ const mapped = runId ? runIdToTicketKey.get(runId) : undefined;
207
+ if (!mapped)
93
208
  continue;
94
- if (event.type === "gate.met") {
95
- pendingMergeEvents.push(event);
209
+ const ticketKey = mapped.ticketKey;
210
+ const isReview = mapped.isReview;
211
+ // BAPI-445 FAIL-SAFE: a review run's INCIDENTAL terminals (run.stopped,
212
+ // gate.met, …) must never advance a `reviewing` ticket — ONLY an explicit
213
+ // spec_review.* verdict does. A review session merely ending must not fold
214
+ // reviewing → ready and dispatch implementation regardless of whether the
215
+ // review requested changes (that would be fail-OPEN). When the verdict is
216
+ // not (yet) emitted, the ticket stays in `reviewing` and the reconcile
217
+ // liveness-recovery / escalation path surfaces it for operator attention —
218
+ // implementation never silently proceeds without a pass verdict.
219
+ const isSpecVerdict = event.type === "spec_review.passed" ||
220
+ event.type === "spec_review.changes_requested";
221
+ if (isReview && !isSpecVerdict)
222
+ continue;
223
+ // Record the blocking reason (latest wins; events are seq-ordered) regardless
224
+ // of fold state, so a ticket blocked on a prior tick still resolves a reason.
225
+ if (event.type === "ci.failed" || event.type === "review.changes_requested") {
226
+ ticketBlockedReasons.set(ticketKey, event.type);
227
+ }
228
+ else if (event.type === "spec_review.changes_requested") {
229
+ // BAPI-445: spec-review rejection. Tagged distinctly so reconcile escalates
230
+ // it without spending the BAPI-441 implementation remediation budget.
231
+ ticketBlockedReasons.set(ticketKey, "spec_review.changes_requested");
96
232
  }
97
233
  const postgresStatus = ticketStatusMap.get(ticketKey) ?? "planned";
98
234
  if (!isNonTerminal(postgresStatus))
99
235
  continue;
100
- if (foldedTicketKeys.has(ticketKey))
101
- continue;
236
+ // Only queue for merge actioning if this ticket hasn't already been folded
237
+ // this tick. Without this guard, two gate.met events for the same ticket
238
+ // would both enqueue, and a ci.failed → gate.met sequence would enqueue a
239
+ // merge action for a ticket whose effective status is "blocked".
240
+ if (event.type === "gate.met" && !foldedTicketKeys.has(ticketKey)) {
241
+ pendingMergeEvents.push(event);
242
+ }
102
243
  const signalType = event.type;
103
- const nextStatus = signalToNextStatus(signalType);
244
+ const nextStatus = signalToNextStatus(signalType, isReview);
245
+ if (foldedTicketKeys.has(ticketKey)) {
246
+ // Allow a bounded same-tick upgrade of an already-folded ticket:
247
+ // 1. ready_for_review → done when merge.succeeded arrives after gate.met
248
+ // in the same ledger batch.
249
+ // 2. (BAPI-445) <any review fold> → blocked when a review run's
250
+ // spec_review.changes_requested arrives after its run.stopped in the
251
+ // same batch. A spec-review rejection must dominate plain completion
252
+ // so a changes-requested verdict is never lost to event ordering.
253
+ // First-signal-wins for everything else: if ci.failed arrived first,
254
+ // currentLocalStatus is "blocked" (not "ready_for_review"), so the
255
+ // upgrade guard below is false and merge.succeeded is intentionally
256
+ // dropped — a failed-CI ticket must not be silently advanced to done.
257
+ const currentLocalStatus = ticketStatusMap.get(ticketKey);
258
+ const mergeUpgrade = currentLocalStatus === "ready_for_review" && nextStatus === "done";
259
+ const specRejectUpgrade = signalType === "spec_review.changes_requested" &&
260
+ currentLocalStatus !== "blocked";
261
+ if (mergeUpgrade || specRejectUpgrade) {
262
+ const existingIdx = unfoldedSignals.findIndex((s) => s.ticket_key === ticketKey);
263
+ if (existingIdx >= 0) {
264
+ unfoldedSignals[existingIdx] = {
265
+ ...unfoldedSignals[existingIdx],
266
+ next_status: nextStatus,
267
+ signal_type: signalType,
268
+ event,
269
+ };
270
+ ticketStatusMap.set(ticketKey, nextStatus);
271
+ }
272
+ }
273
+ continue;
274
+ }
104
275
  const rowVersion = ticketRowVersionMap.get(ticketKey) ?? 0;
105
276
  unfoldedSignals.push({
106
277
  ticket_key: ticketKey,
@@ -119,6 +290,8 @@ export function rebuildObservedState(postgresState, events, _now) {
119
290
  plan_version: epic_run.current_plan_version,
120
291
  ticket_statuses: ticketStatusMap,
121
292
  ticket_row_versions: ticketRowVersionMap,
293
+ ticket_remediation_counters: ticketRemediationMap,
294
+ ticket_blocked_reasons: ticketBlockedReasons,
122
295
  unfolded_terminal_signals: unfoldedSignals,
123
296
  pending_merge_events: pendingMergeEvents,
124
297
  };
@@ -64,6 +64,18 @@ function isSqliteBusyError(error) {
64
64
  * the raw error text, stack, and any secret material are discarded.
65
65
  */
66
66
  export function toConductorErrorEnvelope(error) {
67
+ // Optional-native-binding degradation (BAPI-451): the conductor `store.ts`
68
+ // throws ConductorPersistenceUnavailableError when `better-sqlite3` could not be
69
+ // loaded. Detected by name (not instanceof) to avoid an import cycle with
70
+ // store.ts. Surfaced as a retryable-shaped 503 with a fixed, secret-free message
71
+ // so the caller learns persistence is unavailable instead of getting an opaque 500.
72
+ if (error instanceof Error && error.name === "ConductorPersistenceUnavailableError") {
73
+ return {
74
+ error: "PERSISTENCE_UNAVAILABLE",
75
+ status: 503,
76
+ message: "Conductor persistence is unavailable (the optional better-sqlite3 native module is not loaded). Conductor coordination features are disabled; core tools are unaffected.",
77
+ };
78
+ }
67
79
  if (error instanceof ConductorValidationError) {
68
80
  // Validation messages are conductor-authored and name the offending field,
69
81
  // never its value — but redact defensively so an adversarial/secret-bearing
@@ -25,8 +25,24 @@ export const GIT_CI_PRODUCER = "git-pr-ci-producer";
25
25
  export const GIT_HOOK_PRODUCER = "git-hook";
26
26
  /** The single v1 done-gate condition type. */
27
27
  export const REQUIRED_CI_CHECKS_GREEN = "required_ci_checks_green";
28
+ /** The v2 review-state done-gate condition type. */
29
+ export const REVIEW_STATE = "review_state";
28
30
  /** Default gate name surfaced in `gate.met` event data. */
29
31
  export const DEFAULT_GATE_NAME = "done";
32
+ /** Event type emitted when the configured review source is satisfied. */
33
+ export const REVIEW_PASSED = "review.passed";
34
+ /** Event type emitted when a reviewer requests changes. */
35
+ export const REVIEW_CHANGES_REQUESTED = "review.changes_requested";
36
+ /**
37
+ * BAPI-445 pre-implementation SPEC re-review verdict event types. Emitted by the
38
+ * spec re-review run (``/review-ticket --auto``), correlated to that review run,
39
+ * and kept distinct from the PR-review {@link REVIEW_PASSED} /
40
+ * {@link REVIEW_CHANGES_REQUESTED} verdicts so a spec-review outcome is never
41
+ * folded as the implementation PR's review state.
42
+ */
43
+ export const SPEC_REVIEW_PASSED = "spec_review.passed";
44
+ /** Event type emitted when a spec re-review requests changes. */
45
+ export const SPEC_REVIEW_CHANGES_REQUESTED = "spec_review.changes_requested";
30
46
  /** Matches ASCII control characters (C0 range plus DEL). */
31
47
  const CONTROL_CHAR_RE = /[\u0000-\u001F\u007F]/;
32
48
  /** Matches a 40- or 64-character hex string (git SHA-1 / SHA-256 object ids). */
@@ -79,7 +79,7 @@ export function buildWorktreeChangedEventInput(context, updates, phase) {
79
79
  * dedupe by `repo + commit_sha`, and emit best-effort. Returns a
80
80
  * success-compatible result for every failure mode so the commit is never blocked.
81
81
  */
82
- export function runPostCommitHookProducer(deps = {}) {
82
+ export async function runPostCommitHookProducer(deps = {}) {
83
83
  const getContext = deps.getContext ?? getGitWorktreeContext;
84
84
  const readMetadata = deps.readMetadata ?? readHeadCommitMetadata;
85
85
  const emitIfNew = deps.emitIfNew ?? emitConductorEventIfNew;
@@ -90,7 +90,7 @@ export function runPostCommitHookProducer(deps = {}) {
90
90
  return { ok: true, emitted: false, reason: "no-head-commit" };
91
91
  }
92
92
  const input = buildCommitCreatedEventInput(context, metadata);
93
- const decision = emitIfNew(input, {
93
+ const decision = await emitIfNew(input, {
94
94
  event_type: "git.commit_created",
95
95
  repo: context.repo,
96
96
  commit_sha: metadata.sha,
@@ -109,7 +109,7 @@ export function runPostCommitHookProducer(deps = {}) {
109
109
  * success-compatible result for every failure mode so the ref update is never
110
110
  * blocked.
111
111
  */
112
- export function runReferenceTransactionHookProducer(args, deps = {}) {
112
+ export async function runReferenceTransactionHookProducer(args, deps = {}) {
113
113
  const getContext = deps.getContext ?? getGitWorktreeContext;
114
114
  const parseUpdates = deps.parseUpdates ?? parseReferenceTransactionUpdates;
115
115
  const emitIfNew = deps.emitIfNew ?? emitConductorEventIfNew;
@@ -124,7 +124,7 @@ export function runReferenceTransactionHookProducer(args, deps = {}) {
124
124
  const context = getContext({ cwd: deps.cwd, env: deps.env });
125
125
  const input = buildWorktreeChangedEventInput(context, updates, args.phase);
126
126
  const refUpdatesHash = stableJsonHash({ phase: args.phase, updates });
127
- const decision = emitIfNew(input, {
127
+ const decision = await emitIfNew(input, {
128
128
  event_type: "worktree.changed",
129
129
  repo: context.repo,
130
130
  ref_updates_hash: refUpdatesHash,
@@ -103,9 +103,9 @@ export function makeMergeEventId(eventType, actionKey) {
103
103
  const h = createHash("sha256").update(`${eventType}:${actionKey}`).digest("hex");
104
104
  return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20, 32)}`;
105
105
  }
106
- function lookupMergeEventByActionKey(eventType, actionKey, deps) {
106
+ async function lookupMergeEventByActionKey(eventType, actionKey, deps) {
107
107
  const openDb = deps.openDb ?? (() => openReadonlyConductorDatabaseIfExists());
108
- const db = openDb();
108
+ const db = await openDb();
109
109
  if (!db)
110
110
  return false;
111
111
  try {
@@ -126,7 +126,7 @@ function lookupMergeEventByActionKey(eventType, actionKey, deps) {
126
126
  * the action key. Only `merge.succeeded` is terminal — `merge.failed` and
127
127
  * `merge.dry_run` are never terminal.
128
128
  */
129
- export function hasTerminalMergeSucceeded(actionKey, deps = {}) {
129
+ export async function hasTerminalMergeSucceeded(actionKey, deps = {}) {
130
130
  return lookupMergeEventByActionKey("merge.succeeded", actionKey, deps);
131
131
  }
132
132
  /**
@@ -134,7 +134,7 @@ export function hasTerminalMergeSucceeded(actionKey, deps = {}) {
134
134
  * Dry-run is audit/hygiene only and is NEVER treated as terminal — a later real
135
135
  * merge for the same PR/head/gate may still proceed if the repo flag is enabled.
136
136
  */
137
- export function hasMergeDryRun(actionKey, deps = {}) {
137
+ export async function hasMergeDryRun(actionKey, deps = {}) {
138
138
  return lookupMergeEventByActionKey("merge.dry_run", actionKey, deps);
139
139
  }
140
140
  /**
@@ -142,7 +142,7 @@ export function hasMergeDryRun(actionKey, deps = {}) {
142
142
  * action key. Pending approval is NEVER terminal — the supervisor must re-dispatch
143
143
  * until the backend reports approval (returning `merge.attempted` + `merge.succeeded`).
144
144
  */
145
- export function hasMergePendingApproval(actionKey, deps = {}) {
145
+ export async function hasMergePendingApproval(actionKey, deps = {}) {
146
146
  return lookupMergeEventByActionKey("merge.pending_approval", actionKey, deps);
147
147
  }
148
148
  /** Heuristically detect a SQLite duplicate-id / UNIQUE constraint failure. */
@@ -167,7 +167,7 @@ function isDuplicateConstraintError(error) {
167
167
  * outcome fields live under `details`). A duplicate UNIQUE-constraint failure is
168
168
  * classified as `{ emitted: false, reason: "duplicate" }` rather than thrown.
169
169
  */
170
- export function emitMergeLedgerEventIfNew(input, deps = {}) {
170
+ export async function emitMergeLedgerEventIfNew(input, deps = {}) {
171
171
  const emitEvent = deps.emitEvent ?? emitConductorEvent;
172
172
  const eventId = makeMergeEventId(input.type, input.action_key);
173
173
  const event = {
@@ -186,7 +186,7 @@ export function emitMergeLedgerEventIfNew(input, deps = {}) {
186
186
  },
187
187
  };
188
188
  try {
189
- const result = emitEvent(event);
189
+ const result = await emitEvent(event);
190
190
  return { emitted: true, event_id: eventId, event: result.event };
191
191
  }
192
192
  catch (error) {