@bridge_gpt/mcp-server 0.2.16 → 0.2.18

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 (45) hide show
  1. package/build/agents.generated.js +2 -2
  2. package/build/commands.generated.js +6 -6
  3. package/build/conductor/bridge-api-client.js +191 -11
  4. package/build/conductor/claude-hook.js +22 -4
  5. package/build/conductor/cli.js +11 -13
  6. package/build/conductor/done-gate.js +5 -0
  7. package/build/conductor/epic-reconcile.js +62 -13
  8. package/build/conductor/epic-runtime.js +447 -35
  9. package/build/conductor/epic-state.js +517 -63
  10. package/build/conductor/errors.js +41 -0
  11. package/build/conductor/event-accessors.js +234 -0
  12. package/build/conductor/file-scope-guard.js +201 -0
  13. package/build/conductor/github-mergeability.js +85 -0
  14. package/build/conductor/local-merge.js +47 -1
  15. package/build/conductor/merge-identity.js +41 -0
  16. package/build/conductor/merge-ledger.js +13 -68
  17. package/build/conductor/plan.js +12 -2
  18. package/build/conductor/pr-discovery.js +11 -1
  19. package/build/conductor/supervisor-config.js +4 -39
  20. package/build/conductor/supervisor-escalation.js +10 -26
  21. package/build/conductor/supervisor-ledger.js +5 -12
  22. package/build/conductor/supervisor-message-relay.js +2 -5
  23. package/build/conductor/supervisor-notification.js +1 -1
  24. package/build/conductor/supervisor-runtime.js +12 -54
  25. package/build/conductor/supervisor-state.js +4 -18
  26. package/build/conductor/supervisor-types.js +2 -2
  27. package/build/conductor/taxonomy.js +4 -0
  28. package/build/conductor-bin.js +2333 -666
  29. package/build/conductor-claude-hook-bin.js +4 -2
  30. package/build/doctor.js +32 -0
  31. package/build/index.js +10125 -8522
  32. package/build/install-bridge.js +25 -8
  33. package/build/install-doctor.js +387 -0
  34. package/build/pipelines.generated.js +30 -5
  35. package/build/regression-check.js +53 -1
  36. package/build/review-tickets.js +175 -21
  37. package/build/start-tickets-conductor.js +22 -6
  38. package/build/start-tickets-prereqs.js +33 -3
  39. package/build/start-tickets.js +122 -22
  40. package/build/version.generated.js +1 -1
  41. package/package.json +5 -5
  42. package/pipelines/review-ticket.json +24 -2
  43. package/public/css/main.min.css +3272 -1
  44. package/public/css/main.min.css.map +1 -1
  45. package/smoke-test/SMOKE-TEST.md +4 -2
@@ -9,10 +9,20 @@
9
9
  *
10
10
  * Status mapping (BAPI-436 — merge-gated dependent dispatch):
11
11
  * gate.met → ready_for_review (implementation done; awaiting merge)
12
- * run.stopped → ready_for_review (worker session ended; awaiting merge)
12
+ * run.stopped → ready_for_review (PR-BOUND / session-terminal fold see below)
13
13
  * merge.succeeded → done (merged; dependents may now dispatch)
14
14
  * ci.failed → blocked (unchanged)
15
+ *
16
+ * BAPI-507 (N-1): `run.stopped → ready_for_review` is a PR-BOUND fold, not a
17
+ * generic turn-boundary fold. Claude Code's `Stop`/`SubagentStop` hooks fire on
18
+ * every assistant turn / subagent completion — long before a branch is pushed or
19
+ * a PR opened — so an implementation-run `run.stopped` only folds a ticket to
20
+ * `ready_for_review` when that ticket already has a PR binding in the same event
21
+ * batch (`git.pr_opened` / `gate.met` / `merge.succeeded`). Without a binding the
22
+ * ticket keeps its prior non-review status, matching the done-gate's own
23
+ * PR-binding requirement.
15
24
  */
25
+ import { getHeadSha } from "./event-accessors.js";
16
26
  // ---------------------------------------------------------------------------
17
27
  // Status sets (module-private)
18
28
  // ---------------------------------------------------------------------------
@@ -43,6 +53,8 @@ const TERMINAL_SIGNAL_TYPES = new Set([
43
53
  "ci.failed",
44
54
  "run.stopped",
45
55
  "review.changes_requested",
56
+ // BAPI-494: an un-mergeable PR folds to `blocked`, head-scoped to the conflict head.
57
+ "merge.conflict",
46
58
  // BAPI-445: pre-implementation spec re-review verdicts, scoped to review runs.
47
59
  "spec_review.passed",
48
60
  "spec_review.changes_requested",
@@ -50,6 +62,14 @@ const TERMINAL_SIGNAL_TYPES = new Set([
50
62
  function isNonTerminal(status) {
51
63
  return NON_TERMINAL_STATUSES.has(status);
52
64
  }
65
+ const HEAD_SCOPED_BLOCKING_SIGNALS = new Set([
66
+ "ci.failed",
67
+ "review.changes_requested",
68
+ "merge.conflict",
69
+ ]);
70
+ function isHeadScopedBlockingSignal(type) {
71
+ return HEAD_SCOPED_BLOCKING_SIGNALS.has(type);
72
+ }
53
73
  /**
54
74
  * Map a terminal ledger signal to the next ticket status.
55
75
  *
@@ -76,6 +96,8 @@ export function signalToNextStatus(signalType, isReviewRun = false) {
76
96
  return "blocked";
77
97
  if (signalType === "review.changes_requested")
78
98
  return "blocked";
99
+ if (signalType === "merge.conflict")
100
+ return "blocked"; // BAPI-494
79
101
  if (signalType === "merge.succeeded")
80
102
  return "done";
81
103
  return "ready_for_review"; // gate.met and (implementation) run.stopped: awaiting merge
@@ -91,6 +113,13 @@ export function signalToNextStatus(signalType, isReviewRun = false) {
91
113
  * 2. Whose full `depends_on` list is satisfied (all deps have "done" status).
92
114
  *
93
115
  * Never calls an LLM or performs I/O. Goal 8 invariant.
116
+ *
117
+ * Planner file-overlap serialization (BAPI-495) is encoded upstream as
118
+ * explicit `depends_on` entries in the stored plan DAG (see
119
+ * `apply_file_overlap_serialization` in `api/library/db/epic_runs.py`) —
120
+ * sibling tickets with overlapping predicted touched files are serialized
121
+ * before this function ever sees the plan. This function deliberately takes
122
+ * no file-prediction input and adds no overlap-guard logic of its own.
94
123
  */
95
124
  export function computeReadySet(plan, ticketStatuses) {
96
125
  const ready = [];
@@ -152,8 +181,226 @@ export function extractWorkerLiveness(events, runId, nowMs, windowSeconds) {
152
181
  return { alive: age <= windowSeconds * 1000, workerId: latest.worker_id ?? null };
153
182
  }
154
183
  // ---------------------------------------------------------------------------
184
+ // Head-scoped blocking (BAPI-487)
185
+ // ---------------------------------------------------------------------------
186
+ // BAPI-493: PR head SHA extraction now lives in the typed accessor layer
187
+ // (`event-accessors.getHeadSha`), which reads ONLY the canonical head path under a
188
+ // ledger event's details and fails closed exactly as the retired local
189
+ // `extractEventHeadSha` did (trimmed 7–40 char hex, lowercased). Every head read in
190
+ // this module goes through `getHeadSha(event)` — there is no hand-rolled details
191
+ // head cast in this file anymore.
192
+ /**
193
+ * Event types that carry an observable implementation-PR head SHA for a ticket
194
+ * (BAPI-487). Used to compute the latest observed head per ticket so a blocking
195
+ * signal emitted for a superseded head can be detected and cleared. Includes the
196
+ * blocking types (`ci.failed`/`review.changes_requested`) as head observations,
197
+ * but staleness is anchored on a `gate.met` for a DIFFERENT head so a delayed
198
+ * old-head rejection cannot masquerade as the current head. `review.passed` /
199
+ * `ci.passed` are head observations only and are deliberately NOT added to
200
+ * {@link TERMINAL_SIGNAL_TYPES} — they never fold a ticket status.
201
+ *
202
+ * BAPI-493: the literal list is statically constrained with
203
+ * `satisfies readonly HeadBearingSemanticEventType[]` so it can never drift from
204
+ * the payload model's head-bearing set — every member here declares `head_sha` in
205
+ * its `EventDetailsByType` payload, so replacing the ad-hoc head reader with the
206
+ * typed `getHeadSha` accessor cannot silently break BAPI-487 stale-block clearing.
207
+ */
208
+ const HEAD_OBSERVATION_TYPE_LIST = [
209
+ "git.pr_opened",
210
+ "ci.passed",
211
+ "ci.failed",
212
+ "review.passed",
213
+ "review.changes_requested",
214
+ "gate.met",
215
+ "merge.succeeded",
216
+ // BAPI-494: merge.conflict carries a details head SHA, so it is a head
217
+ // observation (its head can supersede a stale block and be superseded by a rebase).
218
+ "merge.conflict",
219
+ ];
220
+ const HEAD_OBSERVATION_TYPES = new Set(HEAD_OBSERVATION_TYPE_LIST);
221
+ /**
222
+ * Classify a terminal signal's head scope (BAPI-493). Uses `getHeadSha(event)` as
223
+ * the only head source. Centralizes the stale/current/clear-gate head comparisons
224
+ * the old ad-hoc `staleBlockedTickets`/`staleClearHead` checks performed inline.
225
+ */
226
+ export function classifyTerminalHeadScope(ctx) {
227
+ const { signalType, eventHeadSha, ticketKey, staleBlockedTickets, staleClearHead } = ctx;
228
+ // BAPI-494: merge.conflict joins ci.failed/review.changes_requested as a head-scoped
229
+ // blocking signal — same stale-on-superseded-head classification.
230
+ if (isHeadScopedBlockingSignal(signalType)) {
231
+ if (eventHeadSha !== null && staleBlockedTickets.has(ticketKey)) {
232
+ const clearHead = staleClearHead(ticketKey);
233
+ if (clearHead !== null && clearHead !== eventHeadSha)
234
+ return "stale_blocking_signal";
235
+ }
236
+ return "current_or_unproven";
237
+ }
238
+ if (signalType === "gate.met") {
239
+ if (eventHeadSha !== null && staleBlockedTickets.has(ticketKey)) {
240
+ const clearHead = staleClearHead(ticketKey);
241
+ if (clearHead !== null && clearHead === eventHeadSha)
242
+ return "stale_block_clear_gate";
243
+ }
244
+ return "current_or_unproven";
245
+ }
246
+ return "not_head_scoped";
247
+ }
248
+ /**
249
+ * Single exhaustive terminal-signal precedence table (BAPI-493), replacing the five
250
+ * ad-hoc same-tick fold-upgrade booleans that previously lived inline in
251
+ * `rebuildObservedState` (merge upgrade, spec-review reject, review reject, ci-failed
252
+ * re-block, and stale-head clear). `satisfies Record<TerminalSignalType, ...>` makes
253
+ * adding a new terminal signal a compile error until its precedence is defined here.
254
+ *
255
+ * `foldExists` is true when the ticket already has a same-tick fold. When false the
256
+ * signal is the FIRST fold of the tick and selects normally (the earlier
257
+ * postgres-non-terminal + review-role + stale-skip guards already filtered out the
258
+ * signals that must not fold at all). When true, only a dominating signal may
259
+ * replace the existing fold; everything else is explicitly dropped.
260
+ */
261
+ const TERMINAL_PRECEDENCE = {
262
+ "gate.met": ({ foldExists, headScope, currentLocalStatus }) => {
263
+ if (!foldExists)
264
+ return { action: "select" };
265
+ // A later gate.met on the ticket's newer (passed) head clears a block that a
266
+ // superseded old head created earlier in the same batch (BAPI-487).
267
+ if (headScope === "stale_block_clear_gate" && currentLocalStatus === "blocked") {
268
+ return { action: "replace", reason: "stale-block-clear gate on the current head" };
269
+ }
270
+ return { action: "drop", reason: "gate.met does not override an existing same-tick fold" };
271
+ },
272
+ "merge.succeeded": ({ foldExists, currentLocalStatus }) => {
273
+ if (!foldExists)
274
+ return { action: "select" };
275
+ // ready_for_review → done when merge.succeeded arrives after gate.met/run.stopped
276
+ // in the same batch. Never overrides a same-tick `blocked` fold.
277
+ if (currentLocalStatus === "ready_for_review") {
278
+ return { action: "replace", reason: "merge.succeeded upgrades ready_for_review to done" };
279
+ }
280
+ return { action: "drop", reason: "merge.succeeded does not override a non-ready_for_review fold" };
281
+ },
282
+ "ci.failed": ({ foldExists, headScope, currentLocalStatus }) => {
283
+ if (headScope === "stale_blocking_signal") {
284
+ return { action: "drop", reason: "stale ci.failed on a superseded head" };
285
+ }
286
+ if (!foldExists)
287
+ return { action: "select" };
288
+ // A current/unprovable ci.failed re-blocks a prior gate.met/run.stopped fold so
289
+ // the current failure is not silently dropped (starving remediation).
290
+ if (currentLocalStatus !== "blocked") {
291
+ return { action: "replace", reason: "current-head ci.failed dominates a prior non-blocked fold" };
292
+ }
293
+ return { action: "drop", reason: "ticket already blocked this tick" };
294
+ },
295
+ "review.changes_requested": ({ foldExists, headScope, currentLocalStatus }) => {
296
+ if (headScope === "stale_blocking_signal") {
297
+ return { action: "drop", reason: "stale review.changes_requested on a superseded head" };
298
+ }
299
+ if (!foldExists)
300
+ return { action: "select" };
301
+ if (currentLocalStatus !== "blocked") {
302
+ return { action: "replace", reason: "current-head review.changes_requested dominates a prior non-blocked fold" };
303
+ }
304
+ return { action: "drop", reason: "ticket already blocked this tick" };
305
+ },
306
+ "merge.conflict": ({ foldExists, headScope, currentLocalStatus }) => {
307
+ // BAPI-494: same head-scoped precedence as ci.failed/review.changes_requested. A
308
+ // stale conflict on a superseded head is dropped; a current conflict dominates a
309
+ // prior gate.met/run.stopped fold in the SAME batch (else the conflict is silently
310
+ // dropped and the ticket strands at ready_for_review).
311
+ if (headScope === "stale_blocking_signal") {
312
+ return { action: "drop", reason: "stale merge.conflict on a superseded head" };
313
+ }
314
+ if (!foldExists)
315
+ return { action: "select" };
316
+ if (currentLocalStatus !== "blocked") {
317
+ return { action: "replace", reason: "current-head merge.conflict dominates a prior non-blocked fold" };
318
+ }
319
+ return { action: "drop", reason: "ticket already blocked this tick" };
320
+ },
321
+ "spec_review.changes_requested": ({ foldExists, currentLocalStatus }) => {
322
+ if (!foldExists)
323
+ return { action: "select" };
324
+ // A spec-review rejection dominates plain review completion so a changes-requested
325
+ // verdict is never lost to event ordering.
326
+ if (currentLocalStatus !== "blocked") {
327
+ return { action: "replace", reason: "spec-review rejection dominates a prior non-blocked review fold" };
328
+ }
329
+ return { action: "drop", reason: "ticket already blocked this tick" };
330
+ },
331
+ "spec_review.passed": ({ foldExists }) => {
332
+ if (!foldExists)
333
+ return { action: "select" };
334
+ return { action: "drop", reason: "spec_review.passed does not override an existing same-tick fold" };
335
+ },
336
+ "run.stopped": ({ foldExists }) => {
337
+ if (!foldExists)
338
+ return { action: "select" };
339
+ return { action: "drop", reason: "run.stopped does not override an existing same-tick fold" };
340
+ },
341
+ };
342
+ /**
343
+ * Resolve the fold decision for a terminal signal via {@link TERMINAL_PRECEDENCE}.
344
+ * Every terminal signal is explicitly classified as `select`, `replace`, or `drop`
345
+ * — there is no implicit fall-through or unclassified terminal.
346
+ */
347
+ export function resolveTerminalFoldDecision(table, signalType, headScope, foldExists, currentLocalStatus, nextStatus) {
348
+ const rule = table[signalType];
349
+ return rule({ headScope, foldExists, currentLocalStatus, nextStatus });
350
+ }
351
+ // ---------------------------------------------------------------------------
155
352
  // rebuildObservedState
156
353
  // ---------------------------------------------------------------------------
354
+ /**
355
+ * BAPI-507 (N-1): event types that PROVE an implementation ticket has a bound PR
356
+ * — the precondition for folding a per-turn `run.stopped` to `ready_for_review`.
357
+ */
358
+ const PR_BINDING_EVENT_TYPES = new Set([
359
+ "git.pr_opened",
360
+ "gate.met",
361
+ "merge.succeeded",
362
+ ]);
363
+ /**
364
+ * BAPI-507 (N-1): does this event prove a PR binding exists for its ticket? True
365
+ * only for an implementation-run `git.pr_opened` / `gate.met` / `merge.succeeded`
366
+ * carrying observable PR/head details (a normalized head SHA, or a `pr_number` in
367
+ * the event details). Never true for a bare `run.stopped`, which is what this
368
+ * gate is protecting against.
369
+ */
370
+ export function eventCarriesPrBinding(event) {
371
+ if (!PR_BINDING_EVENT_TYPES.has(event.type))
372
+ return false;
373
+ if (getHeadSha(event) !== null)
374
+ return true;
375
+ // Canonical details location is `event.data.details` (same path getHeadSha
376
+ // reads), NOT a top-level `event.details` — which ConductorEventFull never has.
377
+ const details = event.data?.details;
378
+ if (details && typeof details === "object") {
379
+ const pr = details.pr_number;
380
+ if (typeof pr === "number" && Number.isFinite(pr))
381
+ return true;
382
+ }
383
+ return false;
384
+ }
385
+ /**
386
+ * BAPI-507 (N-1): pre-scan an event batch and return the set of IMPLEMENTATION
387
+ * ticket keys that have a PR binding somewhere in the batch (order-independent).
388
+ * Review-run events are excluded so a review dispatch never binds an
389
+ * implementation ticket. This is what gates the `run.stopped` fold below.
390
+ */
391
+ export function buildPrBoundTickets(events, runIdToTicketKey) {
392
+ const bound = new Set();
393
+ for (const event of events) {
394
+ if (!eventCarriesPrBinding(event))
395
+ continue;
396
+ const runId = typeof event.run_id === "string" ? event.run_id : null;
397
+ const mapped = runId ? runIdToTicketKey.get(runId) : undefined;
398
+ if (!mapped || mapped.isReview)
399
+ continue;
400
+ bound.add(mapped.ticketKey);
401
+ }
402
+ return bound;
403
+ }
157
404
  /**
158
405
  * Rebuild the merged observed state from the durable Postgres desired state
159
406
  * and raw local ledger events. Local sticky terminal signals override
@@ -177,6 +424,10 @@ export function rebuildObservedState(postgresState, events, _now) {
177
424
  });
178
425
  }
179
426
  }
427
+ // BAPI-507 (N-1): implementation tickets that have a PR binding in this event
428
+ // batch. Order-independent pre-scan so a `run.stopped` appearing before its
429
+ // `git.pr_opened` still folds. Gates the run.stopped fold in the loop below.
430
+ const prBoundTickets = buildPrBoundTickets(events, runIdToTicketKey);
180
431
  // Populate base maps from Postgres
181
432
  const ticketStatusMap = new Map();
182
433
  const ticketRowVersionMap = new Map();
@@ -191,20 +442,145 @@ export function rebuildObservedState(postgresState, events, _now) {
191
442
  }
192
443
  const unfoldedSignals = [];
193
444
  const pendingMergeEvents = [];
445
+ // BAPI-500: predicted per-ticket row version AFTER a same-pass fold CAS. Only
446
+ // the first-fold ("select") path records it (tick-start snapshot + 1), keyed by
447
+ // ticket to preserve the one-CAS-per-ticket invariant. reconcileEpic later
448
+ // confirms it from the real CAS response (or clears it on conflict/throw), so
449
+ // the follow-on remediation CAS in the same pass CASes against snapshot+1.
450
+ const ticketPostFoldRowVersionMap = new Map();
194
451
  // Track which tickets already have a folded signal (one override per ticket)
195
452
  const foldedTicketKeys = new Set();
196
- // Track which tickets already have a gate.met queued for merge this tick, so a
197
- // second gate.met for the same ticket never double-enqueues. Distinct from
198
- // foldedTicketKeys: a prior run.stopped fold (→ ready_for_review) must NOT
199
- // suppress a later gate.met's merge enqueue (both map to ready_for_review;
200
- // only gate.met enqueues a merge, and the worker frequently emits run.stopped
201
- // BEFORE a post-hoc gate.met). Only a fold to a non-mergeable status (blocked)
202
- // suppresses the merge handled via the effective-status check below.
203
- const mergeQueuedTicketKeys = new Set();
453
+ // BAPI-493 (A2/F6): merge-enqueue dedupe is keyed by `(ticket, head_sha)`, NOT by
454
+ // ticket alone. A stale-head gate.met can no longer claim the single per-ticket
455
+ // merge slot and suppress the current-head gate.met only the LATEST gated head
456
+ // for a ticket is eligible to enqueue (see the enqueue guard below), and the
457
+ // composite key prevents the same selected head from being enqueued twice.
458
+ // Distinct from foldedTicketKeys: a prior run.stopped fold (→ ready_for_review)
459
+ // must NOT suppress a later gate.met's merge enqueue (both map to
460
+ // ready_for_review; only gate.met enqueues a merge, and the worker frequently
461
+ // emits run.stopped BEFORE a post-hoc gate.met). Only a fold to a non-mergeable
462
+ // status (blocked) suppresses the merge — handled via the effective-status check
463
+ // below.
464
+ const mergeQueuedTicketHeadKeys = new Set();
465
+ /** Deterministic composite merge-queue key: `(ticket, head_sha)`. */
466
+ const mergeQueueKey = (ticketKey, headSha) => `${ticketKey}${headSha}`;
204
467
  // BAPI-441: per-ticket latest blocking reason (ci.failed / review.changes_requested),
205
468
  // tracked across the full ledger so an already-blocked ticket still carries a
206
469
  // reason for the remediation pass to frame the nudge.
207
470
  const ticketBlockedReasons = new Map();
471
+ // BAPI-487: head-scoped block detection. Pre-scan the full ledger once (before
472
+ // the fold loop) to find, per IMPLEMENTATION ticket:
473
+ // - the latest head-bearing observation (HEAD_OBSERVATION_TYPES),
474
+ // - the latest `gate.met` head (the definitive "good, passed" head), and
475
+ // - the latest blocking-signal head (ci.failed / review.changes_requested).
476
+ // A blocking signal on a head that a newer gate.met (or a strictly-later
477
+ // observation of a different head) has superseded must no longer hold the
478
+ // ticket `blocked`. Review-role dispatches (pre-implementation spec re-review)
479
+ // are excluded so their heads never clear an implementation PR block.
480
+ const latestObservedHeadByTicket = new Map();
481
+ // BAPI-493 (E-25): the gated-head prescan retains the FULL event reference (not
482
+ // just { headSha, seq }) so the A2/F6 merge-enqueue below can push the exact
483
+ // event `processGateMetMerge` needs to extract the full merge identity from.
484
+ const latestGatedHeadByTicket = new Map();
485
+ const latestBlockingHeadByTicket = new Map();
486
+ for (const event of events) {
487
+ const runId = typeof event.run_id === "string" ? event.run_id : null;
488
+ const mapped = runId ? runIdToTicketKey.get(runId) : undefined;
489
+ if (!mapped || mapped.isReview)
490
+ continue;
491
+ const headSha = getHeadSha(event);
492
+ if (!headSha)
493
+ continue;
494
+ const ticketKey = mapped.ticketKey;
495
+ if (HEAD_OBSERVATION_TYPES.has(event.type)) {
496
+ const prev = latestObservedHeadByTicket.get(ticketKey);
497
+ if (!prev || event.seq >= prev.seq) {
498
+ latestObservedHeadByTicket.set(ticketKey, { headSha, seq: event.seq });
499
+ }
500
+ }
501
+ if (event.type === "gate.met") {
502
+ const prev = latestGatedHeadByTicket.get(ticketKey);
503
+ if (!prev || event.seq >= prev.seq) {
504
+ latestGatedHeadByTicket.set(ticketKey, { headSha, seq: event.seq, event });
505
+ }
506
+ }
507
+ if (isHeadScopedBlockingSignal(event.type)) {
508
+ const prev = latestBlockingHeadByTicket.get(ticketKey);
509
+ if (!prev || event.seq >= prev.seq) {
510
+ latestBlockingHeadByTicket.set(ticketKey, {
511
+ headSha,
512
+ seq: event.seq,
513
+ reason: event.type,
514
+ });
515
+ }
516
+ }
517
+ }
518
+ // Head_sha of the latest blocking signal per ticket (paired with
519
+ // ticketBlockedReasons). Absent when the blocking event carried no valid head.
520
+ const ticketBlockedHeads = new Map();
521
+ for (const [ticketKey, blocking] of latestBlockingHeadByTicket) {
522
+ ticketBlockedHeads.set(ticketKey, blocking.headSha);
523
+ }
524
+ // A ticket is stale-blocked when its latest blocking head has been superseded
525
+ // by a CAUSALLY-LATER observation of a DIFFERENT head:
526
+ // (a) a gate.met AFTER the blocking event, on a different head, or
527
+ // (b) any head observation AFTER the blocking event, on a different head.
528
+ // Both require `> blocking.seq`: a gate.met/observation that PRECEDES the
529
+ // blocking event proves nothing about staleness — an old gate.met on head A
530
+ // followed by a genuinely newer ci.failed on head B (B never gated) must leave
531
+ // B's failure in force, or the current failure would be silently dropped by the
532
+ // fold-skip below and the ticket starved of remediation. When staleness cannot
533
+ // be proven from the ledger (e.g. a delayed old-head rejection whose head was
534
+ // never observed earlier), the reducer conservatively KEEPS the block; the
535
+ // live done-gate re-evaluation pass (runConductorDoneGatePass), which resolves
536
+ // the real current PR head via gh, is the backstop that clears a truly stale
537
+ // block once the head has provably advanced.
538
+ const staleBlockedTickets = new Set();
539
+ for (const [ticketKey, blocking] of latestBlockingHeadByTicket) {
540
+ const gated = latestGatedHeadByTicket.get(ticketKey);
541
+ const observed = latestObservedHeadByTicket.get(ticketKey);
542
+ const gatedSupersedes = gated !== undefined &&
543
+ gated.seq > blocking.seq &&
544
+ gated.headSha !== blocking.headSha;
545
+ const observedSupersedes = observed !== undefined &&
546
+ observed.seq > blocking.seq &&
547
+ observed.headSha !== blocking.headSha;
548
+ if (gatedSupersedes || observedSupersedes) {
549
+ staleBlockedTickets.add(ticketKey);
550
+ // If the durable base status is `blocked` on this superseded head, clear it
551
+ // locally to ready_for_review BEFORE the fold loop so this tick's
552
+ // remediation/merge logic does not re-act on the stale head.
553
+ if (ticketStatusMap.get(ticketKey) === "blocked") {
554
+ ticketStatusMap.set(ticketKey, "ready_for_review");
555
+ }
556
+ }
557
+ }
558
+ // The head that clears a stale block for a ticket: the passed gate.met head,
559
+ // falling back to the latest observed head.
560
+ const staleClearHead = (ticketKey) => latestGatedHeadByTicket.get(ticketKey)?.headSha ??
561
+ latestObservedHeadByTicket.get(ticketKey)?.headSha ??
562
+ null;
563
+ // Remove EVERY pending merge event previously queued for a ticket once a current
564
+ // (non-stale) blocking signal folds it to `blocked` this tick (BAPI-493). A blocked
565
+ // ticket must have no pending merge — including the case where the block lands on a
566
+ // DIFFERENT, non-stale head than the earlier-selected gate.met (e.g. gate on head A
567
+ // enqueued, then a genuinely newer ci.failed on head B blocks the ticket): the
568
+ // stale-head gate must not still be merged. Provably-stale blocks are dropped
569
+ // earlier and never reach here, so this only fires for real blocks. Also drops the
570
+ // composite dedupe keys so a later current-head gate can re-enqueue.
571
+ const dropPendingMergesForTicket = (ticketKey) => {
572
+ for (let i = pendingMergeEvents.length - 1; i >= 0; i--) {
573
+ const pending = pendingMergeEvents[i];
574
+ const pendingRunId = typeof pending.run_id === "string" ? pending.run_id : null;
575
+ const pendingTicket = pendingRunId ? runIdToTicketKey.get(pendingRunId)?.ticketKey : undefined;
576
+ if (pendingTicket !== ticketKey)
577
+ continue;
578
+ const pendingHead = getHeadSha(pending);
579
+ if (pendingHead)
580
+ mergeQueuedTicketHeadKeys.delete(mergeQueueKey(ticketKey, pendingHead));
581
+ pendingMergeEvents.splice(i, 1);
582
+ }
583
+ };
208
584
  for (const event of events) {
209
585
  if (!TERMINAL_SIGNAL_TYPES.has(event.type))
210
586
  continue;
@@ -228,12 +604,44 @@ export function rebuildObservedState(postgresState, events, _now) {
228
604
  event.type === "spec_review.changes_requested";
229
605
  if (isReview && !isSpecVerdict)
230
606
  continue;
607
+ const signalType = event.type;
608
+ // BAPI-507 (N-1): an implementation-run `run.stopped` only folds a ticket to
609
+ // `ready_for_review` when a PR binding for that ticket exists in this batch.
610
+ // Claude Code's Stop/SubagentStop hooks fire on every turn/subagent — long
611
+ // before a branch is pushed — so an unbound run.stopped must NOT persist the
612
+ // ticket as ready_for_review. Review-run terminals are already filtered above.
613
+ if (signalType === "run.stopped" &&
614
+ !isReview &&
615
+ !prBoundTickets.has(ticketKey)) {
616
+ continue;
617
+ }
618
+ const nextStatus = signalToNextStatus(signalType, isReview);
619
+ // BAPI-493: head reads and stale/current/clear-gate classification flow through
620
+ // the typed accessor + single head-scope classifier — no ad-hoc head casts.
621
+ const eventHeadSha = getHeadSha(event);
622
+ const headScope = classifyTerminalHeadScope({
623
+ ticketKey,
624
+ signalType,
625
+ nextStatus,
626
+ currentLocalStatus: ticketStatusMap.get(ticketKey),
627
+ eventHeadSha,
628
+ staleBlockedTickets,
629
+ staleClearHead,
630
+ });
631
+ // BAPI-487: a PROVABLY-stale blocking signal (ci.failed / review.changes_requested
632
+ // on a head a newer gate/observation superseded) is dropped entirely — it neither
633
+ // records a blocked reason nor folds. A missing/invalid head or a not-provably-stale
634
+ // ticket falls through and folds normally (fail-closed / backward compatible); the
635
+ // live done-gate re-evaluation is the backstop for staleness the ledger can't prove.
636
+ if (headScope === "stale_blocking_signal")
637
+ continue;
231
638
  // Record the blocking reason (latest wins; events are seq-ordered) regardless
232
639
  // of fold state, so a ticket blocked on a prior tick still resolves a reason.
233
- if (event.type === "ci.failed" || event.type === "review.changes_requested") {
234
- ticketBlockedReasons.set(ticketKey, event.type);
640
+ if (isHeadScopedBlockingSignal(signalType)) {
641
+ // BAPI-494: ci.failed / review.changes_requested / merge.conflict.
642
+ ticketBlockedReasons.set(ticketKey, signalType);
235
643
  }
236
- else if (event.type === "spec_review.changes_requested") {
644
+ else if (signalType === "spec_review.changes_requested") {
237
645
  // BAPI-445: spec-review rejection. Tagged distinctly so reconcile escalates
238
646
  // it without spending the BAPI-441 implementation remediation budget.
239
647
  ticketBlockedReasons.set(ticketKey, "spec_review.changes_requested");
@@ -241,63 +649,107 @@ export function rebuildObservedState(postgresState, events, _now) {
241
649
  const postgresStatus = ticketStatusMap.get(ticketKey) ?? "planned";
242
650
  if (!isNonTerminal(postgresStatus))
243
651
  continue;
244
- // Queue gate.met for merge actioning unless (a) this ticket's effective
245
- // status this tick is non-mergeable ("blocked" e.g. a ci.failed that
246
- // folded earlier in the same batch, or a Postgres-blocked ticket whose
247
- // ci.failed re-folds every tick), or (b) a gate.met for it is already
248
- // queued. `postgresStatus` already reflects same-tick folds (the loop
249
- // mutates `ticketStatusMap`), so a prior run.stopped fold leaves it
250
- // "ready_for_review" and the merge proceeds fixing the deadlock where a
251
- // worker's run.stopped (lower seq) suppressed a later operator/CI gate.met.
252
- if (event.type === "gate.met" &&
253
- postgresStatus !== "blocked" &&
254
- !mergeQueuedTicketKeys.has(ticketKey)) {
255
- pendingMergeEvents.push(event);
256
- mergeQueuedTicketKeys.add(ticketKey);
257
- }
258
- const signalType = event.type;
259
- const nextStatus = signalToNextStatus(signalType, isReview);
260
- if (foldedTicketKeys.has(ticketKey)) {
261
- // Allow a bounded same-tick upgrade of an already-folded ticket:
262
- // 1. ready_for_review → done when merge.succeeded arrives after gate.met
263
- // in the same ledger batch.
264
- // 2. (BAPI-445) <any review fold> → blocked when a review run's
265
- // spec_review.changes_requested arrives after its run.stopped in the
266
- // same batch. A spec-review rejection must dominate plain completion
267
- // so a changes-requested verdict is never lost to event ordering.
268
- // First-signal-wins for everything else: if ci.failed arrived first,
269
- // currentLocalStatus is "blocked" (not "ready_for_review"), so the
270
- // upgrade guard below is false and merge.succeeded is intentionally
271
- // dropped — a failed-CI ticket must not be silently advanced to done.
272
- const currentLocalStatus = ticketStatusMap.get(ticketKey);
273
- const mergeUpgrade = currentLocalStatus === "ready_for_review" && nextStatus === "done";
274
- const specRejectUpgrade = signalType === "spec_review.changes_requested" &&
275
- currentLocalStatus !== "blocked";
276
- if (mergeUpgrade || specRejectUpgrade) {
277
- const existingIdx = unfoldedSignals.findIndex((s) => s.ticket_key === ticketKey);
278
- if (existingIdx >= 0) {
279
- unfoldedSignals[existingIdx] = {
280
- ...unfoldedSignals[existingIdx],
281
- next_status: nextStatus,
282
- signal_type: signalType,
283
- event,
284
- };
285
- ticketStatusMap.set(ticketKey, nextStatus);
652
+ // BAPI-493 (A2/F6): enqueue the LATEST-head gate.met for merge, deduped by
653
+ // `(ticket, head_sha)`. The event must BE the latest gated head for its ticket
654
+ // (reference-equal to the prescan's retained event) so a stale-head gate.met can
655
+ // no longer claim the per-ticket slot and suppress the current-head one. Fail
656
+ // closed on a missing head. `postgresStatus` already reflects same-tick folds, so
657
+ // a prior run.stopped fold leaves it "ready_for_review" and the merge proceeds;
658
+ // BAPI-487: a stale-block-clearing gate on the newer head may enqueue even when
659
+ // the base status was `blocked`.
660
+ if (signalType === "gate.met" && eventHeadSha !== null) {
661
+ const latestGate = latestGatedHeadByTicket.get(ticketKey);
662
+ const isLatestGatedEvent = latestGate !== undefined && latestGate.event === event;
663
+ const gateMetClearsStale = headScope === "stale_block_clear_gate";
664
+ if (isLatestGatedEvent &&
665
+ (postgresStatus !== "blocked" || gateMetClearsStale)) {
666
+ const key = mergeQueueKey(ticketKey, eventHeadSha);
667
+ if (!mergeQueuedTicketHeadKeys.has(key)) {
668
+ pendingMergeEvents.push(event);
669
+ mergeQueuedTicketHeadKeys.add(key);
286
670
  }
287
671
  }
672
+ }
673
+ // BAPI-493 (D-2, subsumes A3/F7): one exhaustive head-scoped precedence model
674
+ // replaces the five ad-hoc upgrade booleans. Every terminal signal resolves to
675
+ // exactly one of select / replace / drop.
676
+ const foldExists = foldedTicketKeys.has(ticketKey);
677
+ const currentLocalStatus = ticketStatusMap.get(ticketKey);
678
+ const decision = resolveTerminalFoldDecision(TERMINAL_PRECEDENCE, signalType, headScope, foldExists, currentLocalStatus, nextStatus);
679
+ if (decision.action === "drop")
680
+ continue;
681
+ if (decision.action === "select") {
682
+ // BAPI-500: skip a redundant blocked→blocked fold. When the ticket's
683
+ // effective status this tick is already `blocked` and this signal would
684
+ // only re-fold it to `blocked`, a status CAS persists no new status and
685
+ // only churns `row_version` (and re-logs the fold every tick). Skip ONLY
686
+ // the unfolded-signal push (the thing that drives the CAS) and the
687
+ // post-fold prediction — but still mark the ticket folded this tick so
688
+ // same-tick fold precedence is preserved: a later `gate.met`/`merge.succeeded`
689
+ // in the SAME batch is still dropped (upholding the documented
690
+ // ci.failed-wins invariant), exactly as when the redundant fold was pushed.
691
+ // The blocking reason/head side maps above are already populated, so the
692
+ // remediation pass still frames the blocked ticket correctly; and because
693
+ // no post-fold version is recorded, remediation CASes against the tick-start
694
+ // snapshot, which for an un-refolded durable block IS the current version.
695
+ // (Stale old-head blocks were cleared to `ready_for_review` before the fold
696
+ // loop, so a genuinely stale block never reaches this guard.) `foldedTicketKeys`
697
+ // is rebuilt per tick, so this does not affect the cross-tick path where a
698
+ // later `merge.succeeded` advances an already-blocked ticket to `done`.
699
+ if (nextStatus === "blocked" && currentLocalStatus === "blocked") {
700
+ foldedTicketKeys.add(ticketKey);
701
+ continue;
702
+ }
703
+ const rowVersion = ticketRowVersionMap.get(ticketKey) ?? 0;
704
+ // BAPI-500: predict the post-fold row version (snapshot + 1) so a follow-on
705
+ // remediation CAS this same pass uses the version the block CAS will write.
706
+ ticketPostFoldRowVersionMap.set(ticketKey, rowVersion + 1);
707
+ unfoldedSignals.push({
708
+ ticket_key: ticketKey,
709
+ postgres_row_version: rowVersion,
710
+ next_status: nextStatus,
711
+ signal_type: signalType,
712
+ event,
713
+ });
714
+ ticketStatusMap.set(ticketKey, nextStatus);
715
+ foldedTicketKeys.add(ticketKey);
288
716
  continue;
289
717
  }
290
- const rowVersion = ticketRowVersionMap.get(ticketKey) ?? 0;
291
- unfoldedSignals.push({
292
- ticket_key: ticketKey,
293
- postgres_row_version: rowVersion,
294
- next_status: nextStatus,
295
- signal_type: signalType,
296
- event,
297
- });
298
- // Apply override locally so the effective status map reflects the change
718
+ // decision.action === "replace": a dominating signal overrides a same-tick fold.
719
+ // Preserve the one-CAS-per-ticket invariant (at most one unfolded signal each).
720
+ const existingIdx = unfoldedSignals.findIndex((s) => s.ticket_key === ticketKey);
721
+ if (existingIdx >= 0) {
722
+ unfoldedSignals[existingIdx] = {
723
+ ...unfoldedSignals[existingIdx],
724
+ next_status: nextStatus,
725
+ signal_type: signalType,
726
+ event,
727
+ };
728
+ }
729
+ else {
730
+ // A3/F7: a ticket already PERSISTED at ready_for_review with a later
731
+ // merge.succeeded (and no unfolded signal created earlier this tick) must
732
+ // still produce a fresh unfolded signal to `done` rather than silently
733
+ // dropping the transition. Use the current row version for the CAS guard.
734
+ const rowVersion = ticketRowVersionMap.get(ticketKey) ?? 0;
735
+ unfoldedSignals.push({
736
+ ticket_key: ticketKey,
737
+ postgres_row_version: rowVersion,
738
+ next_status: nextStatus,
739
+ signal_type: signalType,
740
+ event,
741
+ });
742
+ }
299
743
  ticketStatusMap.set(ticketKey, nextStatus);
300
744
  foldedTicketKeys.add(ticketKey);
745
+ // BAPI-493/BAPI-494: a current (non-stale) blocking signal — ci.failed,
746
+ // review.changes_requested, or merge.conflict — that just folded the ticket to
747
+ // `blocked` must evict ANY pending merge queued for that ticket this tick —
748
+ // regardless of whether the block's head matches the queued gate's head — or a
749
+ // now-superseded gate could still be merged despite the block winning the fold.
750
+ if (isHeadScopedBlockingSignal(signalType)) {
751
+ dropPendingMergesForTicket(ticketKey);
752
+ }
301
753
  }
302
754
  return {
303
755
  epic_key: epic_run.epic_key,
@@ -305,8 +757,10 @@ export function rebuildObservedState(postgresState, events, _now) {
305
757
  plan_version: epic_run.current_plan_version,
306
758
  ticket_statuses: ticketStatusMap,
307
759
  ticket_row_versions: ticketRowVersionMap,
760
+ ticket_post_fold_row_versions: ticketPostFoldRowVersionMap,
308
761
  ticket_remediation_counters: ticketRemediationMap,
309
762
  ticket_blocked_reasons: ticketBlockedReasons,
763
+ ticket_blocked_heads: ticketBlockedHeads,
310
764
  unfolded_terminal_signals: unfoldedSignals,
311
765
  pending_merge_events: pendingMergeEvents,
312
766
  };