@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
@@ -17,16 +17,20 @@
17
17
  * All durable mutations go through the injectable seams that call the sibling
18
18
  * Epic Run TS client (already available in bridge-api-client.ts as of BAPI-407).
19
19
  */
20
- import { resolveConductorBridgeApiAccess, claimEpicSupervisionLease, fetchEpicRunState, advanceEpicTicketStatus, createEpicTicketStatus, recordEpicDispatch, transitionEpicDispatch, fetchParseStatus, triggerRepositoryParse, getEpicPlan, buildEpicDispatchKey, } from "./bridge-api-client.js";
20
+ import { spawnSync } from "child_process";
21
+ import { resolveConductorBridgeApiAccess, claimEpicSupervisionLease, fetchEpicRunState, advanceEpicTicketStatus, createEpicTicketStatus, recordEpicDispatch, transitionEpicDispatch, fetchParseStatus, triggerRepositoryParse, getEpicPlan, buildEpicDispatchKey, fetchEffectiveSupervisorConfig, fetchEffectiveSupervisorSetup, fetchPrReviewStatus, remediateEpicTicket, deletePullRequestBranch, transitionJiraStatus, } from "./bridge-api-client.js";
21
22
  import { processGateMetMerge } from "./supervisor-merge.js";
22
- import { rebuildObservedState, } from "./epic-state.js";
23
+ import { rebuildObservedState, extractWorkerLiveness, } from "./epic-state.js";
23
24
  import { reconcileEpic } from "./epic-reconcile.js";
25
+ import { buildSupervisorRemediationWorkerMessage } from "./supervisor-message-relay.js";
26
+ import { sendWorkerMessage } from "./store.js";
24
27
  import { hashPlan } from "./plan.js";
25
- import { pollConductorEvents } from "./store.js";
28
+ import { pollConductorEvents, POLL_LIMIT_MAX } from "./store.js";
26
29
  import { dispatchSupervisorNotification } from "./supervisor-notification.js";
27
30
  import { makeSupervisorIdempotencyKey } from "./supervisor-ledger.js";
28
31
  import { createDefaultStartTicketsDeps, orchestrateStartTickets } from "../start-tickets.js";
29
32
  import { orchestrateReviewTickets } from "../review-tickets.js";
33
+ import { createStartTicketsConductorContext, provisionConductorHooksForRows, emitStartTicketsRunStarted, } from "../start-tickets-conductor.js";
30
34
  // ---------------------------------------------------------------------------
31
35
  // Constants
32
36
  // ---------------------------------------------------------------------------
@@ -46,7 +50,7 @@ function defaultLeaseOwner() {
46
50
  async function defaultEscalateOnce(epicKey, reason) {
47
51
  process.stderr.write(`[epic-tick] ESCALATION epic=${epicKey} reason=${reason}\n`);
48
52
  }
49
- async function defaultDispatchSeam(_epicKey, ticketKey) {
53
+ async function defaultDispatchSeam(_epicKey, ticketKey, _attempt = 0) {
50
54
  throw new Error(`dispatch seam not wired for ticket ${ticketKey}`);
51
55
  }
52
56
  async function defaultPostActionWaitSeam(_epicKey, _ticketKey) {
@@ -71,9 +75,13 @@ export async function runEpicTick(options, deps = {}) {
71
75
  const errorLog = deps.errorLog ?? ((msg) => process.stderr.write(`${msg}\n`));
72
76
  const escalateOnce = deps.escalateOnce ?? defaultEscalateOnce;
73
77
  const dispatchSeam = deps.dispatchSeam ?? defaultDispatchSeam;
78
+ // BAPI-445: optional review dispatch seam. When absent the spec re-review
79
+ // sequencing gate stays off (a ready ticket dispatches implementation directly).
80
+ const dispatchReviewSeamFn = deps.dispatchReviewSeam;
74
81
  const processMergeFn = deps.processMerge ?? processGateMetMerge;
75
82
  const postActionWaitSeam = deps.postActionWaitSeam ?? defaultPostActionWaitSeam;
76
- const fetchLocalEvents = deps.fetchLocalEvents ?? ((_key) => []);
83
+ const fetchLocalEvents = deps.fetchLocalEvents ??
84
+ ((_key, _runIds) => []);
77
85
  const resolveBridgeAccess = deps.resolveBridgeAccess ?? resolveConductorBridgeApiAccess;
78
86
  const claimLeaseFn = deps.claimLease ?? claimEpicSupervisionLease;
79
87
  const fetchEpicStateFn = deps.fetchEpicState ?? fetchEpicRunState;
@@ -190,7 +198,15 @@ export async function runEpicTick(options, deps = {}) {
190
198
  worker_count: 0,
191
199
  };
192
200
  }
193
- const localEvents = fetchLocalEvents(epic_key);
201
+ // Scope the local-ledger read to this epic's dispatched run_ids. The shared
202
+ // ~/.config/bridge/events.db ledger accumulates events for every epic/worker
203
+ // on the machine; rebuildObservedState only folds signals whose run_id maps
204
+ // to one of these dispatches, so scoping the read here avoids loading the
205
+ // entire (up to 50K-row) ledger on every tick.
206
+ const dispatchedRunIds = epicRunState.dispatches
207
+ .map((d) => d.run_id)
208
+ .filter((rid) => typeof rid === "string" && rid.length > 0);
209
+ const localEvents = await fetchLocalEvents(epic_key, dispatchedRunIds);
194
210
  const observed = rebuildObservedState(epicRunState, localEvents, nowFn());
195
211
  workerCount = [...observed.ticket_statuses.values()].filter((s) => ACTIVE_WORKER_STATUSES.has(s)).length;
196
212
  // Step 3.5: Run post-action waits (parse-after-merge)
@@ -330,6 +346,98 @@ export async function runEpicTick(options, deps = {}) {
330
346
  }
331
347
  // Step 5: Reconcile observed→desired
332
348
  if (plan !== null) {
349
+ // BAPI-441: fetch the effective supervisor config (budget ceilings +
350
+ // liveness window) and setup (pr_bindings) once. Fail-open: if the config
351
+ // read fails, remediationConfig stays undefined and reconcile skips the
352
+ // remediation pass entirely (dispatch/merge steps unaffected).
353
+ let remediationConfig;
354
+ let livenessWindowSeconds = 120;
355
+ let prBindings = {};
356
+ try {
357
+ const cfg = await fetchEffectiveSupervisorConfig(access, epic_key);
358
+ remediationConfig = {
359
+ max_remediation_attempts: cfg.max_remediation_attempts,
360
+ max_remediation_no_progress_attempts: cfg.max_remediation_no_progress_attempts,
361
+ auto_rereview_enabled: cfg.auto_rereview_enabled ?? false,
362
+ teardown_enabled: cfg.teardown_enabled ?? false,
363
+ };
364
+ livenessWindowSeconds = cfg.worker_liveness_window_seconds;
365
+ }
366
+ catch (err) {
367
+ const safeMsg = err instanceof Error ? err.constructor.name : "config error";
368
+ errorLog(`[epic-tick] supervisor-config fetch failed (${safeMsg}); skipping remediation for epic=${epic_key}`);
369
+ }
370
+ if (remediationConfig) {
371
+ try {
372
+ const setup = await fetchEffectiveSupervisorSetup(access, epic_key);
373
+ if (setup.pr_bindings && typeof setup.pr_bindings === "object") {
374
+ prBindings = setup.pr_bindings;
375
+ }
376
+ }
377
+ catch (err) {
378
+ const safeMsg = err instanceof Error ? err.constructor.name : "setup error";
379
+ errorLog(`[epic-tick] supervisor-setup fetch failed (${safeMsg}); remediation PR resolution degraded for epic=${epic_key}`);
380
+ }
381
+ }
382
+ // ticket_key → dispatched run_id (the run whose heartbeat liveness reads).
383
+ // Seed from ticket_status.dispatch_run_id, then prefer the most-recent
384
+ // dispatch-ledger run_id per ticket so that after a remediation re-dispatch
385
+ // (a new attempt-scoped epic_dispatch row correlated with the fresh run_id)
386
+ // liveness tracks the NEW worker rather than the stale original.
387
+ const ticketRunIdMap = new Map();
388
+ for (const ts of epicRunState.ticket_statuses) {
389
+ if (ts.dispatch_run_id)
390
+ ticketRunIdMap.set(ts.ticket_key, ts.dispatch_run_id);
391
+ }
392
+ // BAPI-445: split dispatch records by role. `:review`-suffixed dispatch keys
393
+ // are the pre-implementation spec re-review runs; they must be EXCLUDED from
394
+ // the implementation liveness map (so BAPI-441 remediation liveness only ever
395
+ // targets the implementation run) and tracked in a separate review map +
396
+ // per-ticket attempt counter consumed by the reviewing recovery branch.
397
+ const latestDispatchByTicket = new Map();
398
+ const reviewLatestDispatchByTicket = new Map();
399
+ const reviewAttemptCounts = new Map();
400
+ for (const d of epicRunState.dispatches) {
401
+ const isReview = d.dispatch_key.endsWith(":review");
402
+ if (isReview) {
403
+ reviewAttemptCounts.set(d.ticket_key, (reviewAttemptCounts.get(d.ticket_key) ?? 0) + 1);
404
+ }
405
+ if (!d.run_id)
406
+ continue;
407
+ const updatedAt = new Date(d.updated_at).getTime();
408
+ const target = isReview ? reviewLatestDispatchByTicket : latestDispatchByTicket;
409
+ const prev = target.get(d.ticket_key);
410
+ if (!prev || updatedAt >= prev.updatedAt) {
411
+ target.set(d.ticket_key, { runId: d.run_id, updatedAt });
412
+ }
413
+ }
414
+ for (const [tk, info] of latestDispatchByTicket) {
415
+ ticketRunIdMap.set(tk, info.runId);
416
+ }
417
+ const reviewTicketRunIdMap = new Map();
418
+ for (const [tk, info] of reviewLatestDispatchByTicket) {
419
+ reviewTicketRunIdMap.set(tk, info.runId);
420
+ }
421
+ const resolvePrNumber = (ticketKey) => {
422
+ const raw = prBindings[ticketKey];
423
+ if (typeof raw === "number" && Number.isInteger(raw) && raw >= 1)
424
+ return raw;
425
+ if (raw && typeof raw === "object") {
426
+ const obj = raw;
427
+ const pr = obj.pr_number ?? obj.pr;
428
+ if (typeof pr === "number" && Number.isInteger(pr) && pr >= 1)
429
+ return pr;
430
+ }
431
+ return null;
432
+ };
433
+ const maxSeqForRun = (runId) => {
434
+ let maxSeq = 0;
435
+ for (const ev of localEvents) {
436
+ if (ev.run_id === runId && ev.seq > maxSeq)
437
+ maxSeq = ev.seq;
438
+ }
439
+ return maxSeq;
440
+ };
333
441
  const reconcileDeps = {
334
442
  casTicketStatus: async (ek, tk, rowVersion, nextStatus, planVersion) => advanceEpicTicketStatus(access, {
335
443
  epicKey: ek,
@@ -346,13 +454,30 @@ export async function runEpicTick(options, deps = {}) {
346
454
  planVersion,
347
455
  });
348
456
  },
349
- claimDispatchKey: async (ek, tk, planVersion) => recordEpicDispatch(access, {
457
+ claimDispatchKey: async (ek, tk, planVersion, role, attempt = 0) => recordEpicDispatch(access, {
350
458
  epicKey: ek,
351
459
  ticketKey: tk,
352
460
  planVersion,
353
461
  leaseOwner: lease_owner,
354
462
  ttlSeconds: DEFAULT_DISPATCH_KEY_TTL_SECONDS,
463
+ attempt,
464
+ // BAPI-445: a review-role claim appends ":review" to the dispatch key
465
+ // so the run-id maps above can separate review runs from impl runs.
466
+ reviewRole: role === "review",
355
467
  }),
468
+ // BAPI-445 spec re-review seams. dispatchReviewSeam is wired only when the
469
+ // factory provides it (gate stays off otherwise); the liveness + attempt
470
+ // accessors read the review-scoped maps built above.
471
+ dispatchReviewSeam: dispatchReviewSeamFn
472
+ ? async (ek, tk, attempt = 0) => dispatchReviewSeamFn(ek, tk, attempt)
473
+ : undefined,
474
+ readReviewWorkerLiveness: async (_ek, tk) => {
475
+ const runId = reviewTicketRunIdMap.get(tk);
476
+ if (!runId)
477
+ return { alive: false, workerId: null };
478
+ return extractWorkerLiveness(localEvents, runId, nowFn(), livenessWindowSeconds);
479
+ },
480
+ countReviewAttempts: (tk) => reviewAttemptCounts.get(tk) ?? 0,
356
481
  correlateRunId: async (dispatchKey, runId) => {
357
482
  await transitionEpicDispatch(access, {
358
483
  dispatchKey,
@@ -360,13 +485,146 @@ export async function runEpicTick(options, deps = {}) {
360
485
  runId,
361
486
  });
362
487
  },
363
- dispatchSeam: async (ek, tk) => dispatchSeam(ek, tk),
488
+ dispatchSeam: async (ek, tk, attempt = 0) => dispatchSeam(ek, tk, attempt),
364
489
  processMerge: async (acc, event) => processMergeFn(acc, event),
365
490
  postActionWaitSeam: async (ek, tk) => postActionWaitSeam(ek, tk),
366
491
  escalateOnce: async (ek, reason) => escalateOnce(ek, reason),
367
492
  log,
493
+ // BAPI-442: teardown and Jira-transition seams (fail-open, optional).
494
+ teardownSeam: async (_ek, tk) => {
495
+ // Resolve the PR number for the ticket.
496
+ const prNumber = resolvePrNumber(tk);
497
+ if (prNumber === null) {
498
+ errorLog(`[epic-tick] teardown: no PR binding for ${tk}; skipping`);
499
+ return;
500
+ }
501
+ // Fetch setup to get the expected head SHA if available; fall back to empty.
502
+ let expectedSha = "";
503
+ try {
504
+ const setup = await fetchEffectiveSupervisorSetup(access, epic_key);
505
+ const binding = (setup.pr_bindings ?? {})[tk];
506
+ if (binding && typeof binding === "object") {
507
+ const b = binding;
508
+ if (typeof b.head_sha === "string")
509
+ expectedSha = b.head_sha;
510
+ }
511
+ }
512
+ catch {
513
+ // Best-effort; proceed with empty SHA (endpoint still deletes by PR number)
514
+ }
515
+ try {
516
+ await deletePullRequestBranch(access, prNumber, expectedSha || "");
517
+ log(`[epic-tick] teardown: branch deleted for PR #${prNumber} (ticket=${tk})`);
518
+ }
519
+ catch (err) {
520
+ const safeMsg = err instanceof Error ? err.constructor.name : "error";
521
+ errorLog(`[epic-tick] teardown: branch-delete failed (${safeMsg}) for ${tk}`);
522
+ }
523
+ // Remove local worktree idempotently; errors are benign.
524
+ try {
525
+ spawnSync("git", ["worktree", "remove", "--force", tk], { stdio: "ignore" });
526
+ log(`[epic-tick] teardown: worktree removed for ${tk}`);
527
+ }
528
+ catch {
529
+ // Already removed or never created — idempotent skip.
530
+ }
531
+ },
532
+ jiraTransitionSeam: async (_ek, tk) => {
533
+ try {
534
+ const result = await transitionJiraStatus(access, tk, "auto");
535
+ if (result.status === "skipped") {
536
+ log(`[epic-tick] jira-transition: no matching transition for ${tk} (skipped)`);
537
+ }
538
+ else {
539
+ log(`[epic-tick] jira-transition: transitioned ${tk}`);
540
+ }
541
+ }
542
+ catch (err) {
543
+ const safeMsg = err instanceof Error ? err.constructor.name : "error";
544
+ errorLog(`[epic-tick] jira-transition failed (${safeMsg}) for ${tk}`);
545
+ }
546
+ },
547
+ // BAPI-441 remediation seams.
548
+ readWorkerLiveness: async (_ek, tk) => {
549
+ const runId = ticketRunIdMap.get(tk);
550
+ if (!runId)
551
+ return { alive: false, workerId: null };
552
+ return extractWorkerLiveness(localEvents, runId, nowFn(), livenessWindowSeconds);
553
+ },
554
+ remediateCas: async (ek, tk, attemptKind, reason) => {
555
+ const prNumber = resolvePrNumber(tk);
556
+ if (prNumber === null) {
557
+ throw new Error(`remediate: no PR binding for ${tk}`);
558
+ }
559
+ const reviewStatus = (await fetchPrReviewStatus(access, prNumber));
560
+ const headSha = reviewStatus?.detail?.head_sha ?? null;
561
+ if (!headSha) {
562
+ throw new Error(`remediate: no head_sha for PR ${prNumber}`);
563
+ }
564
+ const rowVersion = observed.ticket_row_versions.get(tk) ?? 0;
565
+ // Deterministic block-state idempotency key: stable for a given durable
566
+ // row_version so a same-tick retry replays (409, swallowed); advances
567
+ // with the next attempt.
568
+ const idempotencyKey = `remediate:${ek}:${tk}:${rowVersion}`;
569
+ const result = await remediateEpicTicket(access, {
570
+ pr_number: prNumber,
571
+ epic_run_id: ek,
572
+ ticket_key: tk,
573
+ expected_row_version: rowVersion,
574
+ head_sha: headSha,
575
+ idempotency_key: idempotencyKey,
576
+ attempt_kind: attemptKind,
577
+ reason,
578
+ });
579
+ if (result.conflict) {
580
+ return { conflict: true, reviewDigest: null, truncated: false };
581
+ }
582
+ return {
583
+ conflict: false,
584
+ reviewDigest: result.response.review_digest,
585
+ truncated: result.response.truncated,
586
+ };
587
+ },
588
+ sendNudge: async (_ek, tk, attempt, reviewDigest, truncated, reason, workerId) => {
589
+ const runId = ticketRunIdMap.get(tk);
590
+ if (!runId)
591
+ throw new Error(`nudge: no run_id for ${tk}`);
592
+ // workerId is resolved by readWorkerLiveness from the same heartbeat
593
+ // scan and null-checked by the reconcile pass before remediateCas, so
594
+ // the two seams stay consistent and no budget is burned on a missing id.
595
+ const input = buildSupervisorRemediationWorkerMessage({
596
+ runId,
597
+ workerId,
598
+ ticketKey: tk,
599
+ reason,
600
+ attempt,
601
+ reviewDigest: reviewDigest ?? "",
602
+ truncated,
603
+ causeSeq: maxSeqForRun(runId),
604
+ });
605
+ sendWorkerMessage(input);
606
+ },
607
+ resumeDispatch: async (ek, tk, attempt) => {
608
+ // Claim an attempt-scoped pending dispatch row FIRST so the spawn's
609
+ // run_spawned correlation (inside orchestrateStartTickets) has a row to
610
+ // transition and the re-dispatched run_id is durably recorded against
611
+ // the ticket. The claim is idempotent (lease-held/already-spawned are
612
+ // returned, not thrown).
613
+ await recordEpicDispatch(access, {
614
+ epicKey: ek,
615
+ ticketKey: tk,
616
+ planVersion: plan.plan_version,
617
+ leaseOwner: lease_owner,
618
+ ttlSeconds: DEFAULT_DISPATCH_KEY_TTL_SECONDS,
619
+ attempt,
620
+ });
621
+ // dispatchSeam returns the new run_id; orchestrate correlates it into
622
+ // the attempt-scoped epic_dispatch row, so the next tick's liveness map
623
+ // (built from the dispatch ledger) tracks the fresh worker.
624
+ await dispatchSeam(ek, tk, attempt);
625
+ },
368
626
  };
369
- const reconcileResult = await reconcileEpic(access, observed, plan, reconcileDeps);
627
+ const reconcileResult = await reconcileEpic(access, observed, plan, reconcileDeps, remediationConfig);
370
628
  log(`[epic-tick] reconcile done: epic=${epic_key} ` +
371
629
  `signals=${reconcileResult.signals_folded} ` +
372
630
  `dispatched=${reconcileResult.dispatched} ` +
@@ -494,7 +752,7 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
494
752
  const planHash = hashPlan(dag);
495
753
  return { plan_hash: planHash, plan_version: response.plan_version, tickets };
496
754
  };
497
- const dispatchSeam = async (ek, tk) => {
755
+ const dispatchSeam = async (ek, tk, attempt = 0) => {
498
756
  // Guard: fetchPlan must run before dispatchSeam so cachedPlanVersion and
499
757
  // automationMap are populated. A zero version means the factory seam was
500
758
  // wired but fetchPlan was never called — fail explicitly rather than silently
@@ -502,6 +760,12 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
502
760
  if (cachedPlanVersion === 0) {
503
761
  throw new Error(`dispatchSeam called before fetchPlan for epic ${ek} ticket ${tk}; cachedPlanVersion is 0`);
504
762
  }
763
+ // BAPI-441: a remediation re-dispatch (attempt > 0) reuses the existing
764
+ // branch/worktree (resume mode) and claims an attempt-scoped dispatch key so
765
+ // it is not deduped against the original epic dispatch.
766
+ const isResume = attempt > 0;
767
+ // The dispatch kind comes from the plan node's automation (start-tickets or
768
+ // review-tickets); default to start-tickets when unspecified.
505
769
  const kind = automationMap.get(tk) ?? "start-tickets";
506
770
  // Operator dry-run: when BAPI_CONDUCTOR_DISPATCH_DRY_RUN=1, dispatch resolves
507
771
  // the spawn command + model routing but opens NO terminal, creates NO worktree,
@@ -513,7 +777,7 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
513
777
  epic_key: ek,
514
778
  epic_run_id: ek,
515
779
  plan_version: cachedPlanVersion,
516
- dispatch_key: buildEpicDispatchKey(ek, tk, cachedPlanVersion),
780
+ dispatch_key: buildEpicDispatchKey(ek, tk, cachedPlanVersion, attempt),
517
781
  };
518
782
  const deps = createDefaultStartTicketsDeps();
519
783
  let runId;
@@ -533,6 +797,14 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
533
797
  runId = result.rows[0]?.runId;
534
798
  }
535
799
  else {
800
+ // BAPI-409 / IH-1: epic dispatch (dispatch_key set) requires the conductor
801
+ // stage to mint a run_id and provision per-worker env/supervisor context.
802
+ // `conductorEnabled: true` alone is necessary but not sufficient — the
803
+ // BAPI-409 guard in orchestrateStartTickets fails closed unless the
804
+ // createConductorContext seam (and its siblings) is injected via the third
805
+ // `overrides` argument, exactly as the packaged start-tickets CLI does. The
806
+ // orchestrator short-circuits on dryRun before using them, so passing them
807
+ // unconditionally is safe; dispatchDryRun preserves the operator dry-run seam.
536
808
  const result = await orchestrateStartTickets(deps, {
537
809
  keys: [tk],
538
810
  epic: identity,
@@ -543,11 +815,13 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
543
815
  refreshMain: false,
544
816
  branchOverrides: {},
545
817
  baseBranch: "main",
546
- // Epic dispatch always uses the Conductor system (epic-tick coordinates
547
- // workers through it), so the message-relay instruction must be present
548
- // on epic-dispatched worker prompts regardless of the user-facing
549
- // `--conductor` default.
550
818
  conductorEnabled: true,
819
+ // BAPI-441: re-dispatch reuses the existing branch/worktree.
820
+ resumeMode: isResume,
821
+ }, {
822
+ createConductorContext: createStartTicketsConductorContext,
823
+ provisionConductorHooksForRows,
824
+ emitStartTicketsRunStarted,
551
825
  });
552
826
  if (!result.ok) {
553
827
  throw new Error(`start-tickets dispatch failed: ${result.error}`);
@@ -564,11 +838,96 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
564
838
  }
565
839
  return runId;
566
840
  };
567
- const fetchLocalEvents = (_ek) => {
841
+ const dispatchReviewSeam = async (ek, tk, attempt = 0) => {
842
+ // BAPI-445: spawn the pre-implementation spec re-review as the review-tickets
843
+ // automation kind — ALWAYS a review, regardless of the ticket's own plan-node
844
+ // automation kind (which is an implementation kind). fetchPlan must have run
845
+ // first so cachedPlanVersion is populated (same guard as dispatchSeam).
846
+ if (cachedPlanVersion === 0) {
847
+ throw new Error(`dispatchReviewSeam called before fetchPlan for epic ${ek} ticket ${tk}; cachedPlanVersion is 0`);
848
+ }
849
+ const dispatchDryRun = process.env.BAPI_CONDUCTOR_DISPATCH_DRY_RUN === "1";
850
+ // Deliberately omit `dispatch_key` from the identity: the reconcile pass owns
851
+ // the idempotent :review key claim (recordEpicDispatch with reviewRole) and the
852
+ // run_spawned correlation. orchestrateReviewTickets still mints the run_id and
853
+ // injects BAPI_CONDUCTOR_RUN_ID into the review agent's environment, which is
854
+ // exactly the correlation channel the Python review orchestrator reads to stamp
855
+ // the spec_review.* verdict event with this review run's id (Phase 2).
856
+ const identity = {
857
+ epic_key: ek,
858
+ epic_run_id: ek,
859
+ plan_version: cachedPlanVersion,
860
+ };
861
+ const deps = createDefaultStartTicketsDeps();
862
+ const result = await orchestrateReviewTickets(deps, {
863
+ keys: [tk],
864
+ epic: identity,
865
+ agentName: "claude",
866
+ dryRun: dispatchDryRun,
867
+ maxParallel: 1,
868
+ auto: true,
869
+ // Product directive: the spec re-review is `/review-ticket --auto --rounds=2`.
870
+ rounds: 2,
871
+ reviewOverrides: {},
872
+ });
873
+ if (!result.ok) {
874
+ throw new Error(`spec re-review dispatch failed: ${result.error}`);
875
+ }
876
+ let runId = result.rows[0]?.runId;
877
+ if (!runId && dispatchDryRun) {
878
+ // Dry-run produced no real run_id; substitute a synthetic, attempt-scoped id
879
+ // so the reconcile correlate step still records the :review dispatch row.
880
+ runId = `dry-run:review:${ek}:${tk}:${cachedPlanVersion}:r${attempt}`;
881
+ }
882
+ if (!runId) {
883
+ throw new Error(`spec re-review dispatch returned no runId for ticket ${tk}`);
884
+ }
885
+ return runId;
886
+ };
887
+ const fetchLocalEvents = async (_ek, runIds) => {
568
888
  // Workers and the epic-tick process share the same local SQLite ledger
569
889
  // (~/.config/bridge/events.db). pollConductorEvents opens it read-only.
570
- const result = pollConductorEvents({ data_mode: "full" });
571
- return result.events;
890
+ //
891
+ // Scope the read to this epic's dispatched run_ids. The shared ledger holds
892
+ // events for every epic/worker on the machine (up to RETENTION_MAX_ROWS),
893
+ // but rebuildObservedState only folds signals whose run_id maps to one of
894
+ // these dispatches — so the run_ids filter pushes that scoping into SQL and
895
+ // avoids loading sibling-epic events on every tick. With no known run_ids
896
+ // (first tick before any dispatch) there is nothing to fold, so skip the
897
+ // read entirely.
898
+ //
899
+ // pollConductorEvents returns at most POLL_LIMIT_MAX events per call
900
+ // (default 100, capped at 1000) starting at `since_seq`. rebuildObservedState
901
+ // folds terminal signals (gate.met/run.stopped/merge.succeeded/ci.failed)
902
+ // ONLY from the events it is handed, so a single capped page silently hides
903
+ // recent terminal signals once the (scoped) result grows past one page —
904
+ // done-detection then breaks. Drain the COMPLETE history by paginating on the
905
+ // `next_seq` cursor until a short (or empty) page signals the tail.
906
+ if (runIds !== undefined && runIds.length === 0) {
907
+ return [];
908
+ }
909
+ const runIdsFilter = runIds && runIds.length > 0 ? { run_ids: [...runIds] } : undefined;
910
+ const events = [];
911
+ let sinceSeq = 1;
912
+ // Retention caps (retention_days/retention_max_rows) bound the ledger, but
913
+ // cap total iterations defensively against a non-advancing cursor.
914
+ const MAX_PAGES = 10_000;
915
+ for (let page = 0; page < MAX_PAGES; page += 1) {
916
+ const result = await pollConductorEvents({
917
+ data_mode: "full",
918
+ since_seq: sinceSeq,
919
+ limit: POLL_LIMIT_MAX,
920
+ filter: runIdsFilter,
921
+ });
922
+ events.push(...result.events);
923
+ // Stop on a short/empty page (no more rows) or a cursor that fails to
924
+ // advance (guards against an infinite loop).
925
+ if (result.count < POLL_LIMIT_MAX || result.next_seq <= sinceSeq) {
926
+ break;
927
+ }
928
+ sinceSeq = result.next_seq;
929
+ }
930
+ return events;
572
931
  };
573
932
  const escalateOnce = async (ek, reason) => {
574
933
  const candidate = {
@@ -620,8 +979,13 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
620
979
  return {
621
980
  fetchPlan,
622
981
  dispatchSeam,
982
+ dispatchReviewSeam,
623
983
  fetchLocalEvents,
624
984
  escalateOnce,
625
985
  postActionWaitSeam,
986
+ // BAPI-442 seams are wired at the reconcileDeps level inside runEpicTick
987
+ // (they need the per-tick `access` and `prBindings` closure). The factory
988
+ // returns the dispatchSeam with isReReview support; the other two seams are
989
+ // defined inline in the reconcileDeps object in runEpicTick.
626
990
  };
627
991
  }