@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
@@ -18,12 +18,16 @@
18
18
  * Epic Run TS client (already available in bridge-api-client.ts as of BAPI-407).
19
19
  */
20
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
+ import { resolveConductorBridgeApiAccess, claimEpicSupervisionLease, fetchEpicRunState, advanceEpicTicketStatus, createEpicTicketStatus, updateEpicRunStatus, recordEpicDispatch, transitionEpicDispatch, fetchParseStatus, triggerRepositoryParse, getEpicPlan, buildEpicDispatchKey, fetchEffectiveSupervisorConfig, fetchEffectiveSupervisorSetup, remediateEpicTicket, deletePullRequestBranch, transitionJiraStatus, safeDiagnosticMessage, } from "./bridge-api-client.js";
22
22
  import { processGateMetMerge } from "./supervisor-merge.js";
23
+ import { observePrCiOnce } from "./pr-ci-producer.js";
24
+ import { runGhCommand } from "./pr-discovery.js";
25
+ import { isPrMergeConflict, parseGhPrMergeabilityFields, } from "./github-mergeability.js";
23
26
  import { makeLocalMergeExecutor, resolveLocalMergeMethod } from "./local-merge.js";
24
27
  import { emitConductorEventIfNew } from "./producer-ledger.js";
25
28
  import { rebuildObservedState, extractWorkerLiveness, } from "./epic-state.js";
26
29
  import { reconcileEpic } from "./epic-reconcile.js";
30
+ import { normalizeDeclaredTouchedFiles } from "./file-scope-guard.js";
27
31
  import { buildSupervisorRemediationWorkerMessage } from "./supervisor-message-relay.js";
28
32
  import { sendWorkerMessage } from "./store.js";
29
33
  import { hashPlan } from "./plan.js";
@@ -49,6 +53,12 @@ const ACTIVE_WORKER_STATUSES = new Set(["dispatched", "running"]);
49
53
  */
50
54
  const PARSE_WAIT_EVENT_SOURCE = "conductor-supervisor";
51
55
  const PARSE_WAIT_EVENT_PRODUCER = "epic-parse-wait";
56
+ /**
57
+ * BAPI-494: source + producer tags for the durable `merge.conflict` marker the
58
+ * done-gate pass emits when a `ready_for_review` ticket's PR is un-mergeable.
59
+ */
60
+ const MERGE_CONFLICT_EVENT_SOURCE = "conductor-supervisor";
61
+ const MERGE_CONFLICT_EVENT_PRODUCER = "epic-mergeability";
52
62
  function defaultLeaseOwner() {
53
63
  return `epic-tick-${process.pid}`;
54
64
  }
@@ -61,6 +71,191 @@ async function defaultDispatchSeam(_epicKey, ticketKey, _attempt = 0) {
61
71
  async function defaultPostActionWaitSeam(_epicKey, _ticketKey) {
62
72
  // no-op: parse-after-merge wait is a sibling ticket's concern
63
73
  }
74
+ export function parsePrBindingFromGhJson(stdout) {
75
+ let pr;
76
+ try {
77
+ pr = JSON.parse(stdout);
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ const num = pr.number;
83
+ const sha = pr.headRefOid;
84
+ const state = typeof pr.state === "string" ? pr.state : "";
85
+ if (typeof num === "number" &&
86
+ Number.isInteger(num) &&
87
+ num >= 1 &&
88
+ typeof sha === "string" &&
89
+ /^[0-9a-f]{7,40}$/i.test(sha) &&
90
+ state.toUpperCase() === "OPEN") {
91
+ // BAPI-494: parse mergeability defensively from the same JSON object — unknown
92
+ // values become null and never reject an otherwise valid open PR binding.
93
+ const mergeability = parseGhPrMergeabilityFields(pr);
94
+ return {
95
+ prNumber: num,
96
+ headSha: sha,
97
+ mergeable: mergeability.mergeable,
98
+ mergeStateStatus: mergeability.mergeStateStatus,
99
+ };
100
+ }
101
+ return null;
102
+ }
103
+ /**
104
+ * Shared gh-discovery PR-binding resolver: resolve BOTH the PR number and the
105
+ * CURRENT head SHA for a ticket from its `feature/<KEY>` branch via
106
+ * `gh pr view ... --json number,headRefOid,state`. This is the single
107
+ * gh-discovery path used by BOTH the Conductor done-gate pass and the
108
+ * remediation CAS dispatch (BAPI-487 Requirement 1). It intentionally does NOT
109
+ * consult `epic_supervisor_setup.pr_bindings` — nothing auto-populates that map,
110
+ * so the legacy `pr_bindings` seam always returned null and stranded remediation.
111
+ *
112
+ * Fail-closed: returns null for any failed `gh` invocation, empty stdout, or a
113
+ * closed/merged PR / malformed number / non-hex head SHA (all enforced by
114
+ * {@link parsePrBindingFromGhJson}). `options.runGh` is injectable for pure unit
115
+ * tests; `options.cwd` defaults to the Conductor's current working directory.
116
+ */
117
+ export function resolveTicketPrBindingFromGh(ticketKey, options = {}) {
118
+ const runGh = options.runGh ?? runGhCommand;
119
+ const ghRes = runGh(
120
+ // BAPI-494: mergeability fields added to the SAME per-ticket binding call — the
121
+ // done-gate reads mergeability inside this existing call, spawning no new gh process.
122
+ ["pr", "view", `feature/${ticketKey}`, "--json", "number,headRefOid,state,mergeable,mergeStateStatus"], { cwd: options.cwd ?? process.cwd() });
123
+ if (ghRes.ok && ghRes.stdout.trim()) {
124
+ const parsed = parsePrBindingFromGhJson(ghRes.stdout);
125
+ // Normalize the head SHA to lowercase so downstream head-scoped comparisons
126
+ // (blocked-head vs. current-head, gate.met dedupe) are case-stable.
127
+ return parsed
128
+ ? { ...parsed, headSha: parsed.headSha.toLowerCase() }
129
+ : null;
130
+ }
131
+ return null;
132
+ }
133
+ /**
134
+ * For each `ready_for_review` ticket — and (BAPI-487) each `blocked` ticket whose
135
+ * live PR head has advanced past the head that blocked it — ask the conductor to
136
+ * evaluate the composite done-gate itself via {@link observePrCiOnce}, the
137
+ * correctness backstop for FINDING 6 where a worker never self-emits `gate.met`.
138
+ * `observePrCiOnce` binds to the CURRENT head SHA, fails closed on any
139
+ * unavailable/ambiguous/stale-head signal, and idempotently emits `gate.met`
140
+ * (deduped by ticket+PR+head_sha) so the fold + merge-enqueue happens exactly once
141
+ * regardless of whether the worker also emitted it.
142
+ *
143
+ * A `blocked` ticket is re-evaluated ONLY when its PR binding resolves AND either
144
+ * the recorded blocked head is unknown (fail-closed recovery) or the current head
145
+ * differs from it — a ticket still blocked on its current head is left blocked so
146
+ * a same-head failure is never bypassed.
147
+ *
148
+ * Fully fail-closed and non-blocking: a per-ticket observation error is caught and
149
+ * logged so it can never crash or stall the reconcile loop; tickets that are
150
+ * neither `ready_for_review` nor advanced-head `blocked`, and tickets without a
151
+ * PR binding, are skipped.
152
+ */
153
+ export async function runConductorDoneGatePass(ticketStatuses, deps) {
154
+ for (const [ticketKey, status] of ticketStatuses) {
155
+ if (status !== "ready_for_review" && status !== "blocked")
156
+ continue;
157
+ const prBinding = deps.resolvePrBinding(ticketKey);
158
+ if (prBinding === null) {
159
+ deps.log(`[epic-tick] done-gate poll for ${ticketKey}: skipped (no PR binding)`);
160
+ continue;
161
+ }
162
+ // BAPI-487: gate a `blocked` ticket's re-evaluation on its head advancing.
163
+ if (status === "blocked") {
164
+ const blockedHead = deps.resolveBlockedHeadSha?.(ticketKey) ?? null;
165
+ if (blockedHead === null) {
166
+ deps.log(`[epic-tick] done-gate re-eval for ${ticketKey}: blocked with no recorded head; ` +
167
+ `polling current head ${prBinding.headSha} fail-closed as recovery`);
168
+ }
169
+ else if (blockedHead.toLowerCase() === prBinding.headSha.toLowerCase()) {
170
+ deps.log(`[epic-tick] done-gate poll for ${ticketKey}: skipped (still blocked on current head ${prBinding.headSha})`);
171
+ continue;
172
+ }
173
+ else {
174
+ deps.log(`[epic-tick] done-gate re-eval for ${ticketKey}: blocked head ${blockedHead} ` +
175
+ `superseded by current head ${prBinding.headSha}; re-evaluating`);
176
+ }
177
+ }
178
+ // Stamp the ticket's run_id AND worker_id onto the emitted gate.met via a
179
+ // per-ticket env override (observePrCiOnce reads BAPI_CONDUCTOR_RUN_ID/
180
+ // _WORKER_ID from env, env taking precedence). run_id correlates the fold to
181
+ // the ticket; a non-empty worker_id is required for the merge to enqueue
182
+ // (merge-ledger extractMergeActionIdentityFromGateEvent). Both must be present
183
+ // or the conductor-emitted gate.met folds/merges to nothing.
184
+ const runId = deps.resolveRunId?.(ticketKey) ?? null;
185
+ const workerId = deps.resolveWorkerId?.(ticketKey) ?? null;
186
+ // BAPI-494: a `ready_for_review` PR that is already un-mergeable
187
+ // (CONFLICTING/DIRTY) can never be re-reviewed (claude-review won't verdict an
188
+ // unmergeable branch) or merged — so it would sit at ready_for_review forever.
189
+ // Route it into the head-scoped `merge.conflict` blocking path (folded next
190
+ // tick, remediated as a resume-mode redispatch) BEFORE re-polling CI, and skip
191
+ // CI observation for this ticket. Only `ready_for_review` conflicts are emitted
192
+ // here; a `blocked` ticket already has routing + stale-head re-evaluation.
193
+ if (status === "ready_for_review" && isPrMergeConflict(prBinding)) {
194
+ try {
195
+ await deps.emitConflictSignal?.({
196
+ ticketKey,
197
+ repoName: deps.access.repoName,
198
+ prNumber: prBinding.prNumber,
199
+ headSha: prBinding.headSha,
200
+ mergeable: prBinding.mergeable,
201
+ mergeStateStatus: prBinding.mergeStateStatus,
202
+ runId,
203
+ workerId,
204
+ });
205
+ deps.log(`[epic-tick] done-gate conflict for ${ticketKey}: PR #${prBinding.prNumber} ` +
206
+ `not mergeable at head ${prBinding.headSha}; emitted merge.conflict`);
207
+ }
208
+ catch (err) {
209
+ const safeMsg = err instanceof Error ? err.constructor.name : "conflict emit error";
210
+ deps.errorLog(`[epic-tick] done-gate conflict-signal failed (${safeMsg}) for ${ticketKey}; continuing`);
211
+ }
212
+ continue;
213
+ }
214
+ const perTicketEnv = {
215
+ ...deps.env,
216
+ ...(runId ? { BAPI_CONDUCTOR_RUN_ID: runId } : {}),
217
+ ...(workerId ? { BAPI_CONDUCTOR_WORKER_ID: workerId } : {}),
218
+ };
219
+ try {
220
+ const observeResult = await deps.observePrCi({
221
+ repoName: deps.access.repoName,
222
+ prNumber: prBinding.prNumber,
223
+ headSha: prBinding.headSha,
224
+ }, {
225
+ env: perTicketEnv,
226
+ resolveAccess: async () => ({ ok: true, access: deps.access }),
227
+ });
228
+ deps.log(`[epic-tick] done-gate poll for ${ticketKey}: ${observeResult.reason}`);
229
+ }
230
+ catch (err) {
231
+ const safeMsg = err instanceof Error ? err.constructor.name : "observe error";
232
+ deps.errorLog(`[epic-tick] done-gate poll failed (${safeMsg}) for ${ticketKey}; continuing`);
233
+ }
234
+ }
235
+ }
236
+ // ---------------------------------------------------------------------------
237
+ // N-3 run self-completion (BAPI-507)
238
+ // ---------------------------------------------------------------------------
239
+ /**
240
+ * BAPI-507 (N-3): should the epic run self-complete this tick? True ONLY when the
241
+ * approved plan has at least one ticket AND every plan ticket's effective status
242
+ * is exactly `"done"` in `observed.ticket_statuses`. Any node that is
243
+ * `abandoned` / `blocked` / `ready_for_review` / `running` / `reviewing` /
244
+ * `ready` / `dispatched` / `planned`, missing from the status map, or any other
245
+ * non-`done` value makes this return `false`. Conservative done-only policy: a
246
+ * plan ending in a `done` + `abandoned` mix does NOT auto-complete — an operator
247
+ * decides how to close a run with abandoned nodes. Pure: no I/O, no clock.
248
+ */
249
+ function shouldSelfCompleteEpicRun(plan, observed) {
250
+ if (plan.tickets.length === 0)
251
+ return false;
252
+ for (const ticket of plan.tickets) {
253
+ if (observed.ticket_statuses.get(ticket.ticket_key) !== "done") {
254
+ return false;
255
+ }
256
+ }
257
+ return true;
258
+ }
64
259
  // ---------------------------------------------------------------------------
65
260
  // runEpicTick
66
261
  // ---------------------------------------------------------------------------
@@ -91,6 +286,28 @@ export async function runEpicTick(options, deps = {}) {
91
286
  const claimLeaseFn = deps.claimLease ?? claimEpicSupervisionLease;
92
287
  const fetchEpicStateFn = deps.fetchEpicState ?? fetchEpicRunState;
93
288
  const releaseLease = deps.releaseLease;
289
+ const env = deps.env ?? process.env;
290
+ const observePrCiSeamFn = deps.observePrCiSeam ?? observePrCiOnce;
291
+ // BAPI-507 (N-3): default self-completion seam CASes the run active → done via
292
+ // the existing PATCH /runs/{id} path, addressing the run by its concrete UUID.
293
+ const completeEpicRunFn = deps.completeEpicRun ??
294
+ (async (acc, epicRunId) => {
295
+ await updateEpicRunStatus(acc, {
296
+ epicKey: epicRunId,
297
+ status: "done",
298
+ expectedStatus: "active",
299
+ });
300
+ });
301
+ // BAPI-494: a single durable event emitter reused by BOTH the parse-after-merge
302
+ // marker block and the done-gate `merge.conflict` wiring, so there is one
303
+ // injectable ledger path (not a second hardcoded emitter).
304
+ const emitConductorEventFn = deps.emitConductorEvent ?? emitConductorEventIfNew;
305
+ // FINDING 1 (BAPI-463): a dry-run tick must exercise the full dispatch-resolution
306
+ // path (ready-set → resolved command + model routing) yet persist NO authoritative
307
+ // dispatch state. Without this guard, `correlateRunId` writes a `run_spawned` row
308
+ // under the REAL dispatch_key, and the next real tick's `claimDispatchKey` returns
309
+ // `already-spawned`, silently skipping the real worker spawn.
310
+ const isDryRun = env.BAPI_CONDUCTOR_DISPATCH_DRY_RUN === "1";
94
311
  const startMs = nowFn();
95
312
  // ---------------------------------------------------------------------------
96
313
  // Step 0: Resolve Bridge API access (offline / fail-closed guard)
@@ -225,7 +442,6 @@ export async function runEpicTick(options, deps = {}) {
225
442
  const settleMs = 5000;
226
443
  const fetchParseStatusFn = deps.fetchParseStatus ?? fetchParseStatus;
227
444
  const triggerParseFn = deps.triggerParse ?? triggerRepositoryParse;
228
- const emitConductorEventFn = deps.emitConductorEvent ?? emitConductorEventIfNew;
229
445
  for (let i = 0; i < observed.unfolded_terminal_signals.length; i++) {
230
446
  const signal = observed.unfolded_terminal_signals[i];
231
447
  if (signal.signal_type !== "merge.succeeded")
@@ -393,6 +609,12 @@ export async function runEpicTick(options, deps = {}) {
393
609
  // liveness window) and setup (pr_bindings) once. Fail-open: if the config
394
610
  // read fails, remediationConfig stays undefined and reconcile skips the
395
611
  // remediation pass entirely (dispatch/merge steps unaffected).
612
+ //
613
+ // BAPI-487: `prBindings` is retained ONLY for the legacy teardown seam
614
+ // (which needs just the PR number). Both the done-gate observation and the
615
+ // remediation CAS resolve their PR binding via gh-discovery
616
+ // (resolveTicketPrBindingFromGh), never from this map — nothing
617
+ // auto-populates `pr_bindings`, so relying on it stranded remediation.
396
618
  let remediationConfig;
397
619
  let livenessWindowSeconds = 120;
398
620
  let prBindings = {};
@@ -461,6 +683,14 @@ export async function runEpicTick(options, deps = {}) {
461
683
  for (const [tk, info] of reviewLatestDispatchByTicket) {
462
684
  reviewTicketRunIdMap.set(tk, info.runId);
463
685
  }
686
+ // Legacy PR-number-only resolver from `pr_bindings`. BAPI-487: this is now
687
+ // consumed ONLY by the teardownSeam, which needs just the number and is
688
+ // outside this ticket's remediation critical path. Both the done-gate
689
+ // observation and the remediation CAS resolve their PR binding via
690
+ // gh-discovery (resolveTicketPrBindingFromGh / resolvePrBinding) instead,
691
+ // because nothing auto-populates `pr_bindings` (confirmed NULL for repo
692
+ // `bapi`), so this legacy path always returns null. Teardown is left on it
693
+ // deliberately rather than silently migrated.
464
694
  const resolvePrNumber = (ticketKey) => {
465
695
  const raw = prBindings[ticketKey];
466
696
  if (typeof raw === "number" && Number.isInteger(raw) && raw >= 1)
@@ -473,6 +703,92 @@ export async function runEpicTick(options, deps = {}) {
473
703
  }
474
704
  return null;
475
705
  };
706
+ // gh-discovery PR binding used by BOTH the done-gate pass and the
707
+ // remediation CAS (BAPI-487 Requirement 1). Delegates to the shared
708
+ // `resolveTicketPrBindingFromGh` helper so the two seams cannot diverge; it
709
+ // resolves the number AND the current head SHA from `feature/<KEY>` and
710
+ // fail-closes unless the PR is OPEN with a well-formed number + head SHA.
711
+ const resolvePrBinding = (ticketKey) => resolveTicketPrBindingFromGh(ticketKey, { cwd: process.cwd() });
712
+ // ---------------------------------------------------------------------
713
+ // Step 4.5: Conductor-driven done-gate for ready_for_review tickets
714
+ // (FINDING 6, BAPI-463) — the correctness backstop for the hands-off run.
715
+ //
716
+ // Workers do not reliably self-emit `gate.met` (a ticket can reach
717
+ // ready_for_review via `run.stopped` alone), so the merge enqueue — gated on
718
+ // `gate.met` only — would stall forever awaiting a worker signal. Here the
719
+ // CONDUCTOR evaluates the composite done-gate itself via the existing
720
+ // `observePrCiOnce`, which binds to the resolved CURRENT head SHA, evaluates
721
+ // the pure fail-closed done-gate (required-CI green + current-head review
722
+ // verdict), and idempotently emits `gate.met` (deduped by ticket+PR+head_sha)
723
+ // so this tick or the next one folds it and enqueues the merge exactly once.
724
+ // Worker-emitted `gate.met` stays a valid fast-path; this pass never removes it.
725
+ // The pass is best-effort and fully fail-closed: an observation error is
726
+ // caught per-ticket so it can never crash or block the reconcile loop, and
727
+ // `observePrCiOnce` emits nothing on an unavailable/ambiguous/stale-head signal.
728
+ // run_id → worker_id from the ledger (first worker_id seen per run) so the
729
+ // conductor-emitted gate.met carries the real worker's id; fall back to a
730
+ // synthetic conductor id so the merge-identity presence check always passes.
731
+ const runIdToWorkerId = new Map();
732
+ for (const ev of localEvents) {
733
+ if (ev.run_id && ev.worker_id && !runIdToWorkerId.has(ev.run_id)) {
734
+ runIdToWorkerId.set(ev.run_id, ev.worker_id);
735
+ }
736
+ }
737
+ const resolveTicketRunId = (tk) => ticketRunIdMap.get(tk) ?? latestDispatchByTicket.get(tk)?.runId ?? null;
738
+ const resolveDoneGateWorkerId = (tk) => {
739
+ const rid = resolveTicketRunId(tk);
740
+ if (rid && runIdToWorkerId.has(rid))
741
+ return runIdToWorkerId.get(rid);
742
+ return `conductor:${tk}`;
743
+ };
744
+ await runConductorDoneGatePass(observed.ticket_statuses, {
745
+ observePrCi: observePrCiSeamFn,
746
+ resolvePrBinding,
747
+ // BAPI-487: re-evaluate a blocked ticket only when its PR head advanced
748
+ // past the head recorded on its latest blocking signal.
749
+ resolveBlockedHeadSha: (tk) => observed.ticket_blocked_heads?.get(tk) ?? null,
750
+ resolveRunId: resolveTicketRunId,
751
+ resolveWorkerId: resolveDoneGateWorkerId,
752
+ // BAPI-494: convert a detected conflict into a durable, head-scoped
753
+ // `merge.conflict` ledger event stamped with the ticket's dispatch run/worker
754
+ // so the fold correlates it and the remediation pass redispatches. Emitted via
755
+ // the shared injectable emitter, idempotent per conflict head. Folded next tick
756
+ // (this pass runs after rebuildObservedState), matching the gate.met latency.
757
+ emitConflictSignal: (input) => {
758
+ emitConductorEventFn({
759
+ source: MERGE_CONFLICT_EVENT_SOURCE,
760
+ type: "merge.conflict",
761
+ subject: input.ticketKey,
762
+ run_id: input.runId,
763
+ worker_id: input.workerId,
764
+ producer: MERGE_CONFLICT_EVENT_PRODUCER,
765
+ observed_via: "supervisor",
766
+ time: new Date(nowFn()).toISOString(),
767
+ data: {
768
+ summary: `PR #${input.prNumber} for ${input.ticketKey} is not mergeable`,
769
+ status: "blocked",
770
+ reason: "merge.conflict",
771
+ details: {
772
+ epic_key,
773
+ ticket_key: input.ticketKey,
774
+ repo: input.repoName,
775
+ pr_number: input.prNumber,
776
+ head_sha: input.headSha,
777
+ mergeable: input.mergeable,
778
+ mergeStateStatus: input.mergeStateStatus,
779
+ },
780
+ },
781
+ }, {
782
+ event_type: "merge.conflict",
783
+ run_id: input.runId ?? undefined,
784
+ commit_sha: input.headSha,
785
+ });
786
+ },
787
+ access,
788
+ env,
789
+ log,
790
+ errorLog,
791
+ });
476
792
  const maxSeqForRun = (runId) => {
477
793
  let maxSeq = 0;
478
794
  for (const ev of localEvents) {
@@ -489,25 +805,57 @@ export async function runEpicTick(options, deps = {}) {
489
805
  nextStatus,
490
806
  planVersion,
491
807
  }),
492
- seedTicketStatus: async (ek, tk, planVersion) => {
808
+ seedTicketStatus: async (_ek, tk, planVersion) => {
809
+ // A6/BAPI-507: address the run by its concrete epic_run_id — the
810
+ // endpoint path is /runs/{epic_run_id}/tickets. Passing the UUID (not
811
+ // the Jira epic key `_ek`) skips the epic-key→active-run resolution and
812
+ // its multi-active-run CONFLICT edge. The backend read-back makes a
813
+ // re-seed of an existing row a clean 2xx no-op.
493
814
  await createEpicTicketStatus(access, {
494
- epicKey: ek,
815
+ epicKey: epicRunState.epic_run.epic_run_id,
495
816
  ticketKey: tk,
496
817
  status: "planned",
497
818
  planVersion,
498
819
  });
499
820
  },
500
- claimDispatchKey: async (ek, tk, planVersion, role, attempt = 0) => recordEpicDispatch(access, {
501
- epicKey: ek,
502
- ticketKey: tk,
503
- planVersion,
504
- leaseOwner: lease_owner,
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",
510
- }),
821
+ claimDispatchKey: async (ek, tk, planVersion, role, attempt = 0) => {
822
+ // FINDING 1 (BAPI-463): in dry-run mode, DO NOT persist a real dispatch
823
+ // claim. Return a synthetic "claimed" result (run_id null) so the reconcile
824
+ // pass proceeds to resolve the command + model routing (still exercised) but
825
+ // no authoritative `run_spawned` row is written under the real dispatch_key.
826
+ if (isDryRun) {
827
+ const suffix = `${role === "review" ? ":review" : ""}${attempt > 0 ? `:r${attempt}` : ""}`;
828
+ const nowIso = new Date(nowFn()).toISOString();
829
+ log(`[DRY RUN] claiming dispatch key skipped for ${tk} (epic=${ek}, role=${role ?? "implementation"}, attempt=${attempt}); no run_spawned row persisted`);
830
+ return {
831
+ ok: true,
832
+ kind: "claimed",
833
+ dispatch: {
834
+ dispatch_key: `dry-run:${ek}:${tk}:v${planVersion}${suffix}`,
835
+ epic_run_id: ek,
836
+ ticket_key: tk,
837
+ plan_version: planVersion,
838
+ status: "pending",
839
+ run_id: null,
840
+ lease_owner: lease_owner,
841
+ lease_expires_at: null,
842
+ created_at: nowIso,
843
+ updated_at: nowIso,
844
+ },
845
+ };
846
+ }
847
+ return recordEpicDispatch(access, {
848
+ epicKey: ek,
849
+ ticketKey: tk,
850
+ planVersion,
851
+ leaseOwner: lease_owner,
852
+ ttlSeconds: DEFAULT_DISPATCH_KEY_TTL_SECONDS,
853
+ attempt,
854
+ // BAPI-445: a review-role claim appends ":review" to the dispatch key
855
+ // so the run-id maps above can separate review runs from impl runs.
856
+ reviewRole: role === "review",
857
+ });
858
+ },
511
859
  // BAPI-445 spec re-review seams. dispatchReviewSeam is wired only when the
512
860
  // factory provides it (gate stays off otherwise); the liveness + attempt
513
861
  // accessors read the review-scoped maps built above.
@@ -522,6 +870,13 @@ export async function runEpicTick(options, deps = {}) {
522
870
  },
523
871
  countReviewAttempts: (tk) => reviewAttemptCounts.get(tk) ?? 0,
524
872
  correlateRunId: async (dispatchKey, runId) => {
873
+ // FINDING 1 (BAPI-463): dry-run must not transition the dispatch row to
874
+ // `run_spawned`. Skipping this keeps the real dispatch_key un-claimed so a
875
+ // subsequent real tick spawns the worker instead of hitting `already-spawned`.
876
+ if (isDryRun) {
877
+ log(`[DRY RUN] correlate run_id skipped for dispatch_key=${dispatchKey} (run_id=${runId}); no run_spawned transition persisted`);
878
+ return;
879
+ }
525
880
  await transitionEpicDispatch(access, {
526
881
  dispatchKey,
527
882
  nextStatus: "run_spawned",
@@ -615,19 +970,30 @@ export async function runEpicTick(options, deps = {}) {
615
970
  return extractWorkerLiveness(localEvents, runId, nowFn(), livenessWindowSeconds);
616
971
  },
617
972
  remediateCas: async (ek, tk, attemptKind, reason) => {
618
- const prNumber = resolvePrNumber(tk);
619
- if (prNumber === null) {
973
+ // BAPI-487 Requirement 1: resolve the PR number + head SHA via the same
974
+ // gh-discovery the done-gate pass uses, NOT the never-populated
975
+ // `pr_bindings` map (which stranded remediation on `remediate: no PR
976
+ // binding`). resolvePrBinding returns both the number and the current
977
+ // head SHA, so the separate legacy review-status head-SHA fetch is gone.
978
+ const prBinding = resolvePrBinding(tk);
979
+ if (prBinding === null) {
620
980
  throw new Error(`remediate: no PR binding for ${tk}`);
621
981
  }
622
- const reviewStatus = (await fetchPrReviewStatus(access, prNumber));
623
- const headSha = reviewStatus?.detail?.head_sha ?? null;
624
- if (!headSha) {
625
- throw new Error(`remediate: no head_sha for PR ${prNumber}`);
626
- }
627
- const rowVersion = observed.ticket_row_versions.get(tk) ?? 0;
982
+ const prNumber = prBinding.prNumber;
983
+ const headSha = prBinding.headSha;
984
+ // BAPI-500: remediation runs AFTER the fold step in the SAME reconcile
985
+ // pass. When this ticket folded to `blocked` this tick, the block CAS
986
+ // already bumped its row_version to snapshot+1, so prefer the confirmed
987
+ // same-pass post-fold version; fall back to the tick-start snapshot when
988
+ // the ticket was not folded this tick, then to 0. Sending the stale
989
+ // tick-start snapshot is exactly the deterministic 400 loop this fixes.
990
+ const rowVersion = observed.ticket_post_fold_row_versions?.get(tk) ??
991
+ observed.ticket_row_versions.get(tk) ??
992
+ 0;
628
993
  // Deterministic block-state idempotency key: stable for a given durable
629
994
  // row_version so a same-tick retry replays (409, swallowed); advances
630
- // with the next attempt.
995
+ // with the next attempt. Derived from the SAME local `rowVersion` sent
996
+ // as `expected_row_version` so the key and the CAS token stay in lockstep.
631
997
  const idempotencyKey = `remediate:${ek}:${tk}:${rowVersion}`;
632
998
  const result = await remediateEpicTicket(access, {
633
999
  pr_number: prNumber,
@@ -695,6 +1061,24 @@ export async function runEpicTick(options, deps = {}) {
695
1061
  for (const w of reconcileResult.warnings) {
696
1062
  errorLog(`[epic-tick] warning: ${w}`);
697
1063
  }
1064
+ // Step 5.5 (N-3 / BAPI-507): self-complete the run once every approved-plan
1065
+ // ticket is durably `done`. Deferred while any terminal signal folded THIS
1066
+ // tick is still unpersisted (`unfolded_terminal_signals`) so the run closes
1067
+ // on a SUBSEQUENT tick — after Postgres durably reflects all-done — rather
1068
+ // than from a same-tick local projection. Fail-open: a stale CAS (a
1069
+ // concurrent terminal transition already closed the run, or the top-of-tick
1070
+ // terminal guard will) is bounded operational noise, never a tick failure.
1071
+ if (shouldSelfCompleteEpicRun(plan, observed) &&
1072
+ observed.unfolded_terminal_signals.length === 0) {
1073
+ try {
1074
+ await completeEpicRunFn(access, epicRunState.epic_run.epic_run_id);
1075
+ log(`[epic-tick] epic=${epic_key} self-completed: all plan tickets done`);
1076
+ }
1077
+ catch (err) {
1078
+ errorLog(`[epic-tick] self-completion CAS failed for epic=${epic_key}: ` +
1079
+ `${safeDiagnosticMessage(err, "self-complete error")}`);
1080
+ }
1081
+ }
698
1082
  }
699
1083
  else {
700
1084
  log(`[epic-tick] no plan available for epic=${epic_key}; skipping dispatch and merge steps`);
@@ -780,6 +1164,10 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
780
1164
  // Shared closure state populated by fetchPlan and consumed by dispatchSeam.
781
1165
  let cachedPlanVersion = 0;
782
1166
  const automationMap = new Map();
1167
+ // BAPI-507 (N-2): per-ticket normalized declared touched-file set, sourced from
1168
+ // the plan DAG node's `touched_files`. Consumed by dispatchSeam to inject the
1169
+ // declared file scope into the implementation worker's environment.
1170
+ const touchedFilesMap = new Map();
783
1171
  const fetchPlan = async (ek, acc) => {
784
1172
  let response;
785
1173
  try {
@@ -797,18 +1185,32 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
797
1185
  return null;
798
1186
  const dag = response.plan_blob;
799
1187
  cachedPlanVersion = response.plan_version;
800
- // Populate the automation map for the dispatch seam.
1188
+ // Populate the automation + declared-touched-files maps for the dispatch seam.
801
1189
  for (const node of dag.nodes) {
1190
+ const nodeKey = node.ticket_key.trim();
802
1191
  const kind = node.automations?.[0]?.kind;
803
1192
  if (kind) {
804
- automationMap.set(node.ticket_key.trim(), kind);
1193
+ automationMap.set(nodeKey, kind);
1194
+ }
1195
+ // BAPI-507 (N-2): normalize defensively (trim, drop blanks, dedupe, sort;
1196
+ // no filesystem access). Missing/non-array touched_files → unspecified, so
1197
+ // the ticket simply gets no declared scope rather than failing dispatch.
1198
+ const declared = normalizeDeclaredTouchedFiles(node.touched_files);
1199
+ if (declared.length > 0) {
1200
+ touchedFilesMap.set(nodeKey, declared);
805
1201
  }
806
1202
  }
807
- // Map DAG nodes to EpicTicketNode (drop automations/status/edges).
808
- const tickets = dag.nodes.map((n) => ({
809
- ticket_key: n.ticket_key.trim(),
810
- depends_on: (n.depends_on ?? []).map((k) => k.trim()),
811
- }));
1203
+ // Map DAG nodes to EpicTicketNode, preserving each node's normalized declared
1204
+ // touched-file set (drop automations/status/edges).
1205
+ const tickets = dag.nodes.map((n) => {
1206
+ const nodeKey = n.ticket_key.trim();
1207
+ const declared = touchedFilesMap.get(nodeKey);
1208
+ return {
1209
+ ticket_key: nodeKey,
1210
+ depends_on: (n.depends_on ?? []).map((k) => k.trim()),
1211
+ ...(declared && declared.length > 0 ? { touched_files: declared } : {}),
1212
+ };
1213
+ });
812
1214
  // Recompute the hash locally — do NOT trust the server-returned plan_hash.
813
1215
  // This makes the integrity gate fail-closed: a tampered or drifted blob
814
1216
  // will hash differently than approved_plan_hash and halt the tick.
@@ -836,11 +1238,18 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
836
1238
  // path (ready-set → claim key → dispatch → correlate run_id) still exercises;
837
1239
  // dry-run rows carry no runId, so a synthetic one is substituted for correlation.
838
1240
  const dispatchDryRun = process.env.BAPI_CONDUCTOR_DISPATCH_DRY_RUN === "1";
1241
+ // BAPI-507 (N-2): thread the ticket's declared file scope into the
1242
+ // IMPLEMENTATION dispatch identity only. A `review-tickets` node does not open
1243
+ // an implementation PR, so it gets no file-scope env (Step 8.5).
1244
+ const declaredTouchedFiles = kind === "review-tickets" ? undefined : touchedFilesMap.get(tk);
839
1245
  const identity = {
840
1246
  epic_key: ek,
841
1247
  epic_run_id: ek,
842
1248
  plan_version: cachedPlanVersion,
843
1249
  dispatch_key: buildEpicDispatchKey(ek, tk, cachedPlanVersion, attempt),
1250
+ ...(declaredTouchedFiles && declaredTouchedFiles.length > 0
1251
+ ? { declared_touched_files: declaredTouchedFiles }
1252
+ : {}),
844
1253
  };
845
1254
  const deps = createDefaultStartTicketsDeps();
846
1255
  let runId;
@@ -853,6 +1262,10 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
853
1262
  maxParallel: 1,
854
1263
  auto: true,
855
1264
  reviewOverrides: {},
1265
+ // BAPI-474: the Conductor epic-dispatch path stays git-fetch-free — it
1266
+ // already dispatches into the correct worktree/branch context, so the
1267
+ // fresh-base materialization (an interactive-CLI concern) is unneeded here.
1268
+ noRefreshBase: true,
856
1269
  });
857
1270
  if (!result.ok) {
858
1271
  throw new Error(`review-tickets dispatch failed: ${result.error}`);
@@ -941,6 +1354,9 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
941
1354
  // Product directive: the spec re-review is `/review-ticket --auto --rounds=2`.
942
1355
  rounds: 2,
943
1356
  reviewOverrides: {},
1357
+ // BAPI-474: see the sibling dispatchSeam comment — Conductor dispatch stays
1358
+ // git-fetch-free.
1359
+ noRefreshBase: true,
944
1360
  });
945
1361
  if (!result.ok) {
946
1362
  throw new Error(`spec re-review dispatch failed: ${result.error}`);
@@ -1009,7 +1425,6 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
1009
1425
  state: null,
1010
1426
  liveness: null,
1011
1427
  elapsed_ms: 0,
1012
- ambiguous: false,
1013
1428
  context: {},
1014
1429
  };
1015
1430
  // Attempt to extract a ticket key from structured reason strings like
@@ -1022,10 +1437,7 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
1022
1437
  const assessment = {
1023
1438
  classification: "stuck",
1024
1439
  confidence: 1,
1025
- should_escalate: true,
1026
1440
  reason,
1027
- draft_escalation_text: null,
1028
- source: "degraded",
1029
1441
  };
1030
1442
  const idempotencyKey = makeSupervisorIdempotencyKey({
1031
1443
  run_id: ek,