@bridge_gpt/mcp-server 0.2.10 → 0.2.13

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 (54) hide show
  1. package/README.md +66 -4
  2. package/build/commands.generated.js +6 -6
  3. package/build/conductor/bridge-api-client.js +2 -1
  4. package/build/conductor/cli.js +16 -16
  5. package/build/conductor/doctor.js +79 -2
  6. package/build/conductor/epic-reconcile.js +213 -16
  7. package/build/conductor/epic-runtime.js +215 -60
  8. package/build/conductor/epic-state.js +105 -16
  9. package/build/conductor/errors.js +12 -0
  10. package/build/conductor/git-ci-types.js +10 -0
  11. package/build/conductor/git-producer.js +4 -4
  12. package/build/conductor/local-merge.js +212 -0
  13. package/build/conductor/merge-ledger.js +7 -7
  14. package/build/conductor/pr-ci-producer.js +18 -8
  15. package/build/conductor/pr-review-producer.js +2 -2
  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 +100 -25
  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 +1 -1
  22. package/build/conductor/supervisor-runtime.js +10 -10
  23. package/build/conductor/taxonomy.js +9 -0
  24. package/build/conductor/tools.js +5 -5
  25. package/build/conductor-bin.js +12689 -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 +203 -0
  29. package/build/index.js +25084 -3632
  30. package/build/init.js +57 -0
  31. package/build/install-bridge.js +80 -0
  32. package/build/mcp-profile.js +33 -30
  33. package/build/pipelines.generated.js +70 -48
  34. package/build/readme.generated.js +1 -1
  35. package/build/sfcc/client.js +151 -0
  36. package/build/sfcc/config.js +39 -0
  37. package/build/sfcc/credentials.js +136 -0
  38. package/build/sfcc/ocapi-shape.js +77 -0
  39. package/build/sfcc/output.js +39 -0
  40. package/build/sfcc/permissions.js +136 -0
  41. package/build/sfcc/reads-custom-object-def.js +119 -0
  42. package/build/sfcc/reads-site-preference.js +158 -0
  43. package/build/sfcc/reads-system-object.js +162 -0
  44. package/build/sfcc/register.js +73 -0
  45. package/build/sfcc/setup-status.js +114 -0
  46. package/build/sfcc/tool-wrapper.js +70 -0
  47. package/build/start-tickets-conductor.js +9 -1
  48. package/build/start-tickets.js +47 -4
  49. package/build/version.generated.js +1 -1
  50. package/package.json +7 -4
  51. package/pipelines/check-ci-ticket.json +2 -2
  52. package/pipelines/implement-ticket.json +2 -2
  53. package/pipelines/learn-repository.json +84 -42
  54. package/smoke-test/SMOKE-TEST.md +11 -17
@@ -20,6 +20,8 @@
20
20
  import { spawnSync } from "child_process";
21
21
  import { resolveConductorBridgeApiAccess, claimEpicSupervisionLease, fetchEpicRunState, advanceEpicTicketStatus, createEpicTicketStatus, recordEpicDispatch, transitionEpicDispatch, fetchParseStatus, triggerRepositoryParse, getEpicPlan, buildEpicDispatchKey, fetchEffectiveSupervisorConfig, fetchEffectiveSupervisorSetup, fetchPrReviewStatus, remediateEpicTicket, deletePullRequestBranch, transitionJiraStatus, } from "./bridge-api-client.js";
22
22
  import { processGateMetMerge } from "./supervisor-merge.js";
23
+ import { makeLocalMergeExecutor, resolveLocalMergeMethod } from "./local-merge.js";
24
+ import { emitConductorEventIfNew } from "./producer-ledger.js";
23
25
  import { rebuildObservedState, extractWorkerLiveness, } from "./epic-state.js";
24
26
  import { reconcileEpic } from "./epic-reconcile.js";
25
27
  import { buildSupervisorRemediationWorkerMessage } from "./supervisor-message-relay.js";
@@ -38,12 +40,15 @@ const DEFAULT_LEASE_TTL_SECONDS = 120;
38
40
  const DEFAULT_MAX_DRIFT_MS = 30_000;
39
41
  const DEFAULT_DISPATCH_KEY_TTL_SECONDS = 300; // independent of supervision lease TTL
40
42
  const ACTIVE_WORKER_STATUSES = new Set(["dispatched", "running"]);
43
+ // ---------------------------------------------------------------------------
44
+ // Module-private helpers
45
+ // ---------------------------------------------------------------------------
41
46
  /**
42
- * Module-level transient map keyed by `"${epicKey}:${ticketKey}"`. Cleared on
43
- * parse completion or budget exhaustion. Process-restart safe: re-derived from
44
- * the parse lock status on the next tick.
47
+ * Source + producer tags for the durable `parse.triggered` marker that the
48
+ * parse-after-merge wait emits from the epic-tick process.
45
49
  */
46
- const parseWaitStateMap = new Map();
50
+ const PARSE_WAIT_EVENT_SOURCE = "conductor-supervisor";
51
+ const PARSE_WAIT_EVENT_PRODUCER = "epic-parse-wait";
47
52
  function defaultLeaseOwner() {
48
53
  return `epic-tick-${process.pid}`;
49
54
  }
@@ -75,6 +80,9 @@ export async function runEpicTick(options, deps = {}) {
75
80
  const errorLog = deps.errorLog ?? ((msg) => process.stderr.write(`${msg}\n`));
76
81
  const escalateOnce = deps.escalateOnce ?? defaultEscalateOnce;
77
82
  const dispatchSeam = deps.dispatchSeam ?? defaultDispatchSeam;
83
+ // BAPI-445: optional review dispatch seam. When absent the spec re-review
84
+ // sequencing gate stays off (a ready ticket dispatches implementation directly).
85
+ const dispatchReviewSeamFn = deps.dispatchReviewSeam;
78
86
  const processMergeFn = deps.processMerge ?? processGateMetMerge;
79
87
  const postActionWaitSeam = deps.postActionWaitSeam ?? defaultPostActionWaitSeam;
80
88
  const fetchLocalEvents = deps.fetchLocalEvents ??
@@ -203,7 +211,7 @@ export async function runEpicTick(options, deps = {}) {
203
211
  const dispatchedRunIds = epicRunState.dispatches
204
212
  .map((d) => d.run_id)
205
213
  .filter((rid) => typeof rid === "string" && rid.length > 0);
206
- const localEvents = fetchLocalEvents(epic_key, dispatchedRunIds);
214
+ const localEvents = await fetchLocalEvents(epic_key, dispatchedRunIds);
207
215
  const observed = rebuildObservedState(epicRunState, localEvents, nowFn());
208
216
  workerCount = [...observed.ticket_statuses.values()].filter((s) => ACTIVE_WORKER_STATUSES.has(s)).length;
209
217
  // Step 3.5: Run post-action waits (parse-after-merge)
@@ -217,17 +225,22 @@ export async function runEpicTick(options, deps = {}) {
217
225
  const settleMs = 5000;
218
226
  const fetchParseStatusFn = deps.fetchParseStatus ?? fetchParseStatus;
219
227
  const triggerParseFn = deps.triggerParse ?? triggerRepositoryParse;
228
+ const emitConductorEventFn = deps.emitConductorEvent ?? emitConductorEventIfNew;
220
229
  for (let i = 0; i < observed.unfolded_terminal_signals.length; i++) {
221
230
  const signal = observed.unfolded_terminal_signals[i];
222
231
  if (signal.signal_type !== "merge.succeeded")
223
232
  continue;
224
233
  const ticketKey = signal.ticket_key;
225
- const stateKey = `${epic_key}:${ticketKey}`;
226
- let pState = parseWaitStateMap.get(stateKey);
227
- if (!pState) {
228
- pState = {};
229
- parseWaitStateMap.set(stateKey, pState);
230
- }
234
+ const mergeEvent = signal.event;
235
+ const mergeTimeMs = new Date(mergeEvent.time).getTime();
236
+ // rebuildObservedState only surfaces a merge.succeeded whose run_id maps to
237
+ // a tracked dispatch, so mergeRunId is non-null and in the run-scoped
238
+ // localEvents read — binding the parse.triggered marker to it guarantees a
239
+ // later tick re-reads it. The merged PR head SHA (pre-merge) lives under
240
+ // the merge.* event's data.details; used only as an extra dedupe dimension.
241
+ const mergeRunId = mergeEvent.run_id ?? null;
242
+ const mergeDetails = mergeEvent.data?.details;
243
+ const mergeHeadSha = typeof mergeDetails?.head_sha === "string" ? mergeDetails.head_sha : undefined;
231
244
  const revertSignal = () => {
232
245
  const origStatus = epicRunState.ticket_statuses.find((ts) => ts.ticket_key === ticketKey)?.status ??
233
246
  "running";
@@ -235,27 +248,72 @@ export async function runEpicTick(options, deps = {}) {
235
248
  observed.unfolded_terminal_signals.splice(i, 1);
236
249
  i -= 1;
237
250
  };
238
- const elapsedMs = nowFn() - new Date(signal.event.time).getTime();
239
- // Budget exhaustion: escalate once, then permanently block
251
+ const currentPgStatus = epicRunState.ticket_statuses.find((ts) => ts.ticket_key === ticketKey)?.status ?? null;
252
+ // DURABLE wait state (replaces the former in-memory parseWaitStateMap, which
253
+ // was lost between stateless epic-tick processes → re-triggered the parse on
254
+ // every idle tick and never folded to `done`). The marker is a
255
+ // `parse.triggered` ledger event correlated to this ticket's dispatch run_id
256
+ // and emitted strictly after the merge — so its presence is the skew-free
257
+ // signal that the post-merge parse was already kicked off.
258
+ const parseTriggeredEvent = localEvents.find((e) => e.type === "parse.triggered" &&
259
+ e.subject === ticketKey &&
260
+ e.run_id === mergeRunId &&
261
+ new Date(e.time).getTime() >= mergeTimeMs);
262
+ const elapsedMs = nowFn() - mergeTimeMs;
263
+ // Budget exhaustion (measured from the durable merge.succeeded time):
264
+ // escalate once (gated on the durable Postgres status, not an in-memory
265
+ // flag) then block. A subsequent tick finds it already blocked and drops
266
+ // the signal without re-escalating or re-CASing.
240
267
  if (elapsedMs > maxWaitMs) {
241
- if (!pState.escalated) {
242
- await escalateOnce(epic_key, `parse-after-merge budget exhausted for ${ticketKey}`);
243
- pState.escalated = true;
244
- signal.next_status = "blocked";
268
+ if (currentPgStatus === "blocked") {
245
269
  observed.ticket_statuses.set(ticketKey, "blocked");
246
- // Let the signal remain so the reconcile pass CASes once to blocked
247
- continue;
248
- }
249
- else {
250
- // Already escalated: map to blocked and skip redundant CAS
251
- observed.ticket_statuses.set(ticketKey, "blocked");
252
- parseWaitStateMap.delete(stateKey);
253
270
  observed.unfolded_terminal_signals.splice(i, 1);
254
271
  i -= 1;
255
272
  continue;
256
273
  }
274
+ await escalateOnce(epic_key, `parse-after-merge budget exhausted for ${ticketKey}`);
275
+ signal.next_status = "blocked";
276
+ observed.ticket_statuses.set(ticketKey, "blocked");
277
+ // Let the signal remain so the reconcile pass CASes once to blocked.
278
+ continue;
257
279
  }
258
- // Poll the parse lock
280
+ if (!parseTriggeredEvent) {
281
+ // No durable trigger for this merge yet — fire the parse and record the
282
+ // marker, then hold at ready_for_review for a later tick to fold.
283
+ try {
284
+ await triggerParseFn(access);
285
+ emitConductorEventFn({
286
+ source: PARSE_WAIT_EVENT_SOURCE,
287
+ type: "parse.triggered",
288
+ subject: ticketKey,
289
+ run_id: mergeRunId,
290
+ worker_id: mergeEvent.worker_id ?? null,
291
+ producer: PARSE_WAIT_EVENT_PRODUCER,
292
+ observed_via: "supervisor",
293
+ time: new Date(nowFn()).toISOString(),
294
+ data: {
295
+ summary: `parse-after-merge triggered for ${ticketKey}`,
296
+ details: {
297
+ epic_key,
298
+ ticket_key: ticketKey,
299
+ ...(mergeHeadSha ? { head_sha: mergeHeadSha } : {}),
300
+ },
301
+ },
302
+ }, {
303
+ event_type: "parse.triggered",
304
+ run_id: mergeRunId ?? undefined,
305
+ commit_sha: mergeHeadSha,
306
+ });
307
+ log(`[epic-tick] triggered parse-after-merge for ${ticketKey} in epic=${epic_key}`);
308
+ }
309
+ catch (err) {
310
+ const safeMsg = err instanceof Error ? err.constructor.name : "trigger error";
311
+ errorLog(`[epic-tick] parse trigger failed (${safeMsg}) for ${ticketKey}; will retry next tick`);
312
+ }
313
+ revertSignal();
314
+ continue;
315
+ }
316
+ // A durable parse.triggered exists for this merge — poll the live lock.
259
317
  let parseStatusResult;
260
318
  try {
261
319
  parseStatusResult = await fetchParseStatusFn(access);
@@ -267,38 +325,26 @@ export async function runEpicTick(options, deps = {}) {
267
325
  continue;
268
326
  }
269
327
  if (parseStatusResult.status === "in_progress") {
270
- pState.seenInProgress = true;
328
+ // Parse still running — hold.
271
329
  revertSignal();
272
330
  continue;
273
331
  }
274
- // status === "idle" evaluate race guard and completion
275
- if (pState.seenInProgress) {
276
- // Previously observed in_progress: the parse finished normally
277
- parseWaitStateMap.delete(stateKey);
278
- continue; // let the signal proceed to CAS → done
279
- }
280
- if (pState.triggeredAt !== undefined) {
281
- const msSinceTrigger = nowFn() - pState.triggeredAt;
282
- if (msSinceTrigger < settleMs) {
283
- // Idle observed before the async job acquired its lock (race window)
284
- revertSignal();
285
- continue;
286
- }
287
- // Settle window elapsed without in_progress: treat as instantaneous completion
288
- parseWaitStateMap.delete(stateKey);
289
- continue; // let the signal proceed to CAS → done
290
- }
291
- // No trigger yet — fire it now
292
- try {
293
- await triggerParseFn(access);
294
- pState.triggeredAt = nowFn();
295
- log(`[epic-tick] triggered parse-after-merge for ${ticketKey} in epic=${epic_key}`);
296
- }
297
- catch (err) {
298
- const safeMsg = err instanceof Error ? err.constructor.name : "trigger error";
299
- errorLog(`[epic-tick] parse trigger failed (${safeMsg}) for ${ticketKey}; will retry next tick`);
332
+ // status === "idle". If the marker is younger than the settle window, the
333
+ // async parse may not have acquired its lock yet (idle is a false negative)
334
+ // hold one more tick. Past the settle window, idle means the post-merge
335
+ // parse has completed (or finished instantly), so let the merge.succeeded
336
+ // signal proceed to CAS → done.
337
+ //
338
+ // KNOWN LIMITATION: /jira/parse-status only reports {in_progress, idle} —
339
+ // it cannot distinguish a FAILED parse from a completed one, so an instant
340
+ // parse failure folds to `done` here. Pre-existing; tracked as P2 (surface
341
+ // a parse failure/last_error from parse-status).
342
+ const msSinceTrigger = nowFn() - new Date(parseTriggeredEvent.time).getTime();
343
+ if (msSinceTrigger < settleMs) {
344
+ revertSignal();
345
+ continue;
300
346
  }
301
- revertSignal();
347
+ // Completed: do not revert — the signal proceeds to CAS → done.
302
348
  }
303
349
  }
304
350
  // Step 4: Fetch + assert plan integrity (only if fetchPlan injected)
@@ -386,19 +432,35 @@ export async function runEpicTick(options, deps = {}) {
386
432
  if (ts.dispatch_run_id)
387
433
  ticketRunIdMap.set(ts.ticket_key, ts.dispatch_run_id);
388
434
  }
435
+ // BAPI-445: split dispatch records by role. `:review`-suffixed dispatch keys
436
+ // are the pre-implementation spec re-review runs; they must be EXCLUDED from
437
+ // the implementation liveness map (so BAPI-441 remediation liveness only ever
438
+ // targets the implementation run) and tracked in a separate review map +
439
+ // per-ticket attempt counter consumed by the reviewing recovery branch.
389
440
  const latestDispatchByTicket = new Map();
441
+ const reviewLatestDispatchByTicket = new Map();
442
+ const reviewAttemptCounts = new Map();
390
443
  for (const d of epicRunState.dispatches) {
444
+ const isReview = d.dispatch_key.endsWith(":review");
445
+ if (isReview) {
446
+ reviewAttemptCounts.set(d.ticket_key, (reviewAttemptCounts.get(d.ticket_key) ?? 0) + 1);
447
+ }
391
448
  if (!d.run_id)
392
449
  continue;
393
450
  const updatedAt = new Date(d.updated_at).getTime();
394
- const prev = latestDispatchByTicket.get(d.ticket_key);
451
+ const target = isReview ? reviewLatestDispatchByTicket : latestDispatchByTicket;
452
+ const prev = target.get(d.ticket_key);
395
453
  if (!prev || updatedAt >= prev.updatedAt) {
396
- latestDispatchByTicket.set(d.ticket_key, { runId: d.run_id, updatedAt });
454
+ target.set(d.ticket_key, { runId: d.run_id, updatedAt });
397
455
  }
398
456
  }
399
457
  for (const [tk, info] of latestDispatchByTicket) {
400
458
  ticketRunIdMap.set(tk, info.runId);
401
459
  }
460
+ const reviewTicketRunIdMap = new Map();
461
+ for (const [tk, info] of reviewLatestDispatchByTicket) {
462
+ reviewTicketRunIdMap.set(tk, info.runId);
463
+ }
402
464
  const resolvePrNumber = (ticketKey) => {
403
465
  const raw = prBindings[ticketKey];
404
466
  if (typeof raw === "number" && Number.isInteger(raw) && raw >= 1)
@@ -435,13 +497,30 @@ export async function runEpicTick(options, deps = {}) {
435
497
  planVersion,
436
498
  });
437
499
  },
438
- claimDispatchKey: async (ek, tk, planVersion) => recordEpicDispatch(access, {
500
+ claimDispatchKey: async (ek, tk, planVersion, role, attempt = 0) => recordEpicDispatch(access, {
439
501
  epicKey: ek,
440
502
  ticketKey: tk,
441
503
  planVersion,
442
504
  leaseOwner: lease_owner,
443
505
  ttlSeconds: DEFAULT_DISPATCH_KEY_TTL_SECONDS,
506
+ attempt,
507
+ // BAPI-445: a review-role claim appends ":review" to the dispatch key
508
+ // so the run-id maps above can separate review runs from impl runs.
509
+ reviewRole: role === "review",
444
510
  }),
511
+ // BAPI-445 spec re-review seams. dispatchReviewSeam is wired only when the
512
+ // factory provides it (gate stays off otherwise); the liveness + attempt
513
+ // accessors read the review-scoped maps built above.
514
+ dispatchReviewSeam: dispatchReviewSeamFn
515
+ ? async (ek, tk, attempt = 0) => dispatchReviewSeamFn(ek, tk, attempt)
516
+ : undefined,
517
+ readReviewWorkerLiveness: async (_ek, tk) => {
518
+ const runId = reviewTicketRunIdMap.get(tk);
519
+ if (!runId)
520
+ return { alive: false, workerId: null };
521
+ return extractWorkerLiveness(localEvents, runId, nowFn(), livenessWindowSeconds);
522
+ },
523
+ countReviewAttempts: (tk) => reviewAttemptCounts.get(tk) ?? 0,
445
524
  correlateRunId: async (dispatchKey, runId) => {
446
525
  await transitionEpicDispatch(access, {
447
526
  dispatchKey,
@@ -450,7 +529,27 @@ export async function runEpicTick(options, deps = {}) {
450
529
  });
451
530
  },
452
531
  dispatchSeam: async (ek, tk, attempt = 0) => dispatchSeam(ek, tk, attempt),
453
- processMerge: async (acc, event) => processMergeFn(acc, event),
532
+ processMerge: async (acc, event) => {
533
+ // F4: when policy_json.local_merge.enabled is set (opt-in, default OFF)
534
+ // and the caller did not inject a processMerge stub, run the real merge
535
+ // pipeline with a LOCAL executor swapped in for the backend route — the
536
+ // merge happens here using the agent's own `gh` credentials, so the
537
+ // hosted backend never needs global GitHub write scope. Otherwise the
538
+ // default backend-route path is unchanged for all existing users.
539
+ if (deps.processMerge === undefined) {
540
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
541
+ const localCfg = epicRunState.epic_run.policy_json?.local_merge;
542
+ if (localCfg?.enabled === true) {
543
+ return processGateMetMerge(acc, event, {
544
+ merge: makeLocalMergeExecutor({
545
+ method: resolveLocalMergeMethod(localCfg.method),
546
+ approvalRequired: localCfg.approval_required === true,
547
+ }, { env: process.env }),
548
+ });
549
+ }
550
+ }
551
+ return processMergeFn(acc, event);
552
+ },
454
553
  postActionWaitSeam: async (ek, tk) => postActionWaitSeam(ek, tk),
455
554
  escalateOnce: async (ek, reason) => escalateOnce(ek, reason),
456
555
  log,
@@ -776,12 +875,21 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
776
875
  dryRun: dispatchDryRun,
777
876
  autoApprove: true,
778
877
  maxParallel: 1,
779
- refreshMain: false,
878
+ // F-base: a merge-gated dependent MUST cut from the predecessor's merged
879
+ // code. With refreshMain:false the worktree was cut from a STALE local
880
+ // `main` (never fetched/ff'd after the predecessor merged on origin), so
881
+ // dependents built without the predecessor's code — defeating the whole
882
+ // merge-gated handoff. Refresh (fetch origin + ff local base) before cut.
883
+ refreshMain: true,
780
884
  branchOverrides: {},
781
885
  baseBranch: "main",
782
886
  conductorEnabled: true,
783
887
  // BAPI-441: re-dispatch reuses the existing branch/worktree.
784
888
  resumeMode: isResume,
889
+ // F7: on a FRESH dispatch, refuse a stale leftover `feature/<KEY>` branch
890
+ // (e.g. a prior run's worktree) rather than silently building on it. No
891
+ // effect on resume (which reuses a located worktree via a different path).
892
+ guardStaleWorktree: !isResume,
785
893
  }, {
786
894
  createConductorContext: createStartTicketsConductorContext,
787
895
  provisionConductorHooksForRows,
@@ -802,7 +910,53 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
802
910
  }
803
911
  return runId;
804
912
  };
805
- const fetchLocalEvents = (_ek, runIds) => {
913
+ const dispatchReviewSeam = async (ek, tk, attempt = 0) => {
914
+ // BAPI-445: spawn the pre-implementation spec re-review as the review-tickets
915
+ // automation kind — ALWAYS a review, regardless of the ticket's own plan-node
916
+ // automation kind (which is an implementation kind). fetchPlan must have run
917
+ // first so cachedPlanVersion is populated (same guard as dispatchSeam).
918
+ if (cachedPlanVersion === 0) {
919
+ throw new Error(`dispatchReviewSeam called before fetchPlan for epic ${ek} ticket ${tk}; cachedPlanVersion is 0`);
920
+ }
921
+ const dispatchDryRun = process.env.BAPI_CONDUCTOR_DISPATCH_DRY_RUN === "1";
922
+ // Deliberately omit `dispatch_key` from the identity: the reconcile pass owns
923
+ // the idempotent :review key claim (recordEpicDispatch with reviewRole) and the
924
+ // run_spawned correlation. orchestrateReviewTickets still mints the run_id and
925
+ // injects BAPI_CONDUCTOR_RUN_ID into the review agent's environment, which is
926
+ // exactly the correlation channel the Python review orchestrator reads to stamp
927
+ // the spec_review.* verdict event with this review run's id (Phase 2).
928
+ const identity = {
929
+ epic_key: ek,
930
+ epic_run_id: ek,
931
+ plan_version: cachedPlanVersion,
932
+ };
933
+ const deps = createDefaultStartTicketsDeps();
934
+ const result = await orchestrateReviewTickets(deps, {
935
+ keys: [tk],
936
+ epic: identity,
937
+ agentName: "claude",
938
+ dryRun: dispatchDryRun,
939
+ maxParallel: 1,
940
+ auto: true,
941
+ // Product directive: the spec re-review is `/review-ticket --auto --rounds=2`.
942
+ rounds: 2,
943
+ reviewOverrides: {},
944
+ });
945
+ if (!result.ok) {
946
+ throw new Error(`spec re-review dispatch failed: ${result.error}`);
947
+ }
948
+ let runId = result.rows[0]?.runId;
949
+ if (!runId && dispatchDryRun) {
950
+ // Dry-run produced no real run_id; substitute a synthetic, attempt-scoped id
951
+ // so the reconcile correlate step still records the :review dispatch row.
952
+ runId = `dry-run:review:${ek}:${tk}:${cachedPlanVersion}:r${attempt}`;
953
+ }
954
+ if (!runId) {
955
+ throw new Error(`spec re-review dispatch returned no runId for ticket ${tk}`);
956
+ }
957
+ return runId;
958
+ };
959
+ const fetchLocalEvents = async (_ek, runIds) => {
806
960
  // Workers and the epic-tick process share the same local SQLite ledger
807
961
  // (~/.config/bridge/events.db). pollConductorEvents opens it read-only.
808
962
  //
@@ -831,7 +985,7 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
831
985
  // cap total iterations defensively against a non-advancing cursor.
832
986
  const MAX_PAGES = 10_000;
833
987
  for (let page = 0; page < MAX_PAGES; page += 1) {
834
- const result = pollConductorEvents({
988
+ const result = await pollConductorEvents({
835
989
  data_mode: "full",
836
990
  since_seq: sinceSeq,
837
991
  limit: POLL_LIMIT_MAX,
@@ -897,6 +1051,7 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
897
1051
  return {
898
1052
  fetchPlan,
899
1053
  dispatchSeam,
1054
+ dispatchReviewSeam,
900
1055
  fetchLocalEvents,
901
1056
  escalateOnce,
902
1057
  postActionWaitSeam,
@@ -30,6 +30,12 @@ const NON_TERMINAL_STATUSES = new Set([
30
30
  "running",
31
31
  "blocked",
32
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",
33
39
  ]);
34
40
  const TERMINAL_SIGNAL_TYPES = new Set([
35
41
  "gate.met",
@@ -37,18 +43,42 @@ const TERMINAL_SIGNAL_TYPES = new Set([
37
43
  "ci.failed",
38
44
  "run.stopped",
39
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",
40
49
  ]);
41
50
  function isNonTerminal(status) {
42
51
  return NON_TERMINAL_STATUSES.has(status);
43
52
  }
44
- export 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";
45
75
  if (signalType === "ci.failed")
46
76
  return "blocked";
47
77
  if (signalType === "review.changes_requested")
48
78
  return "blocked";
49
79
  if (signalType === "merge.succeeded")
50
80
  return "done";
51
- return "ready_for_review"; // gate.met and run.stopped: awaiting merge
81
+ return "ready_for_review"; // gate.met and (implementation) run.stopped: awaiting merge
52
82
  }
53
83
  // ---------------------------------------------------------------------------
54
84
  // computeReadySet
@@ -74,6 +104,13 @@ export function computeReadySet(plan, ticketStatuses) {
74
104
  }
75
105
  return ready;
76
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;
77
114
  /**
78
115
  * Pure, deterministic remediation decision (no I/O, no clock). Given the current
79
116
  * budget counters, the ledger-derived worker liveness, and the configured
@@ -124,11 +161,20 @@ export function extractWorkerLiveness(events, runId, nowMs, windowSeconds) {
124
161
  */
125
162
  export function rebuildObservedState(postgresState, events, _now) {
126
163
  const { epic_run, ticket_statuses, dispatches } = postgresState;
127
- // 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.
128
171
  const runIdToTicketKey = new Map();
129
172
  for (const dispatch of dispatches) {
130
173
  if (dispatch.run_id) {
131
- 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
+ });
132
178
  }
133
179
  }
134
180
  // Populate base maps from Postgres
@@ -147,6 +193,14 @@ export function rebuildObservedState(postgresState, events, _now) {
147
193
  const pendingMergeEvents = [];
148
194
  // Track which tickets already have a folded signal (one override per ticket)
149
195
  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();
150
204
  // BAPI-441: per-ticket latest blocking reason (ci.failed / review.changes_requested),
151
205
  // tracked across the full ledger so an already-blocked ticket still carries a
152
206
  // reason for the remediation pass to frame the nudge.
@@ -154,37 +208,72 @@ export function rebuildObservedState(postgresState, events, _now) {
154
208
  for (const event of events) {
155
209
  if (!TERMINAL_SIGNAL_TYPES.has(event.type))
156
210
  continue;
157
- // Map event → ticket via run_id
211
+ // Map event → ticket via run_id, resolving the dispatch role (review vs.
212
+ // implementation) so the fold can treat review-run terminals distinctly.
158
213
  const runId = typeof event.run_id === "string" ? event.run_id : null;
159
- const ticketKey = runId ? runIdToTicketKey.get(runId) : undefined;
160
- if (!ticketKey)
214
+ const mapped = runId ? runIdToTicketKey.get(runId) : undefined;
215
+ if (!mapped)
216
+ continue;
217
+ const ticketKey = mapped.ticketKey;
218
+ const isReview = mapped.isReview;
219
+ // BAPI-445 FAIL-SAFE: a review run's INCIDENTAL terminals (run.stopped,
220
+ // gate.met, …) must never advance a `reviewing` ticket — ONLY an explicit
221
+ // spec_review.* verdict does. A review session merely ending must not fold
222
+ // reviewing → ready and dispatch implementation regardless of whether the
223
+ // review requested changes (that would be fail-OPEN). When the verdict is
224
+ // not (yet) emitted, the ticket stays in `reviewing` and the reconcile
225
+ // liveness-recovery / escalation path surfaces it for operator attention —
226
+ // implementation never silently proceeds without a pass verdict.
227
+ const isSpecVerdict = event.type === "spec_review.passed" ||
228
+ event.type === "spec_review.changes_requested";
229
+ if (isReview && !isSpecVerdict)
161
230
  continue;
162
231
  // Record the blocking reason (latest wins; events are seq-ordered) regardless
163
232
  // of fold state, so a ticket blocked on a prior tick still resolves a reason.
164
233
  if (event.type === "ci.failed" || event.type === "review.changes_requested") {
165
234
  ticketBlockedReasons.set(ticketKey, event.type);
166
235
  }
236
+ else if (event.type === "spec_review.changes_requested") {
237
+ // BAPI-445: spec-review rejection. Tagged distinctly so reconcile escalates
238
+ // it without spending the BAPI-441 implementation remediation budget.
239
+ ticketBlockedReasons.set(ticketKey, "spec_review.changes_requested");
240
+ }
167
241
  const postgresStatus = ticketStatusMap.get(ticketKey) ?? "planned";
168
242
  if (!isNonTerminal(postgresStatus))
169
243
  continue;
170
- // Only queue for merge actioning if this ticket hasn't already been folded
171
- // this tick. Without this guard, two gate.met events for the same ticket
172
- // would both enqueue, and a ci.failed gate.met sequence would enqueue a
173
- // merge action for a ticket whose effective status is "blocked".
174
- if (event.type === "gate.met" && !foldedTicketKeys.has(ticketKey)) {
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)) {
175
255
  pendingMergeEvents.push(event);
256
+ mergeQueuedTicketKeys.add(ticketKey);
176
257
  }
177
258
  const signalType = event.type;
178
- const nextStatus = signalToNextStatus(signalType);
259
+ const nextStatus = signalToNextStatus(signalType, isReview);
179
260
  if (foldedTicketKeys.has(ticketKey)) {
180
- // Allow a same-tick upgrade from ready_for_review done when
181
- // merge.succeeded arrives after gate.met in the same ledger batch.
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.
182
268
  // First-signal-wins for everything else: if ci.failed arrived first,
183
269
  // currentLocalStatus is "blocked" (not "ready_for_review"), so the
184
270
  // upgrade guard below is false and merge.succeeded is intentionally
185
271
  // dropped — a failed-CI ticket must not be silently advanced to done.
186
272
  const currentLocalStatus = ticketStatusMap.get(ticketKey);
187
- if (currentLocalStatus === "ready_for_review" && nextStatus === "done") {
273
+ const mergeUpgrade = currentLocalStatus === "ready_for_review" && nextStatus === "done";
274
+ const specRejectUpgrade = signalType === "spec_review.changes_requested" &&
275
+ currentLocalStatus !== "blocked";
276
+ if (mergeUpgrade || specRejectUpgrade) {
188
277
  const existingIdx = unfoldedSignals.findIndex((s) => s.ticket_key === ticketKey);
189
278
  if (existingIdx >= 0) {
190
279
  unfoldedSignals[existingIdx] = {
@@ -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
@@ -33,6 +33,16 @@ export const DEFAULT_GATE_NAME = "done";
33
33
  export const REVIEW_PASSED = "review.passed";
34
34
  /** Event type emitted when a reviewer requests changes. */
35
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";
36
46
  /** Matches ASCII control characters (C0 range plus DEL). */
37
47
  const CONTROL_CHAR_RE = /[\u0000-\u001F\u007F]/;
38
48
  /** Matches a 40- or 64-character hex string (git SHA-1 / SHA-256 object ids). */