@bridge_gpt/mcp-server 0.2.16 → 0.2.19

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 (103) hide show
  1. package/CONDUCTOR.md +75 -0
  2. package/README.md +2 -2
  3. package/build/agent-capabilities/probe-context.js +13 -3
  4. package/build/agent-capabilities/probes.js +262 -11
  5. package/build/agent-capabilities/reporter.js +1 -0
  6. package/build/agents.generated.js +3 -3
  7. package/build/backend-warnings.js +44 -0
  8. package/build/claude-settings.js +129 -0
  9. package/build/commands.generated.js +7 -6
  10. package/build/conductor/bridge-api-client.js +198 -18
  11. package/build/conductor/claude-hook.js +22 -4
  12. package/build/conductor/cli.js +76 -25
  13. package/build/conductor/deny-enforcement-preflight.js +96 -0
  14. package/build/conductor/doctor.js +183 -2
  15. package/build/conductor/done-gate.js +5 -0
  16. package/build/conductor/epic-reconcile.js +71 -14
  17. package/build/conductor/epic-runtime.js +839 -67
  18. package/build/conductor/epic-state.js +524 -63
  19. package/build/conductor/errors.js +156 -3
  20. package/build/conductor/event-accessors.js +252 -0
  21. package/build/conductor/file-scope-guard.js +201 -0
  22. package/build/conductor/github-mergeability.js +85 -0
  23. package/build/conductor/local-merge.js +47 -1
  24. package/build/conductor/merge-identity.js +41 -0
  25. package/build/conductor/merge-ledger.js +19 -72
  26. package/build/conductor/plan.js +12 -2
  27. package/build/conductor/pr-ci-producer.js +17 -2
  28. package/build/conductor/pr-discovery.js +11 -1
  29. package/build/conductor/producer-ledger.js +1 -1
  30. package/build/conductor/store.js +161 -18
  31. package/build/conductor/supervisor-config.js +4 -39
  32. package/build/conductor/supervisor-escalation.js +10 -26
  33. package/build/conductor/supervisor-ledger.js +5 -12
  34. package/build/conductor/supervisor-merge.js +32 -5
  35. package/build/conductor/supervisor-message-relay.js +2 -5
  36. package/build/conductor/supervisor-notification.js +1 -1
  37. package/build/conductor/supervisor-runtime.js +12 -54
  38. package/build/conductor/supervisor-state.js +4 -18
  39. package/build/conductor/supervisor-types.js +2 -2
  40. package/build/conductor/taxonomy.js +12 -0
  41. package/build/conductor/tools.js +28 -6
  42. package/build/conductor/worker-ledger-cli.js +244 -0
  43. package/build/conductor-bin.js +1800 -5166
  44. package/build/conductor-claude-hook-bin.js +4 -2
  45. package/build/doctor.js +40 -0
  46. package/build/executor/cli.js +229 -0
  47. package/build/executor/credentials.js +65 -0
  48. package/build/executor/deps.js +117 -0
  49. package/build/executor/env.js +79 -0
  50. package/build/executor/heartbeat.js +59 -0
  51. package/build/executor/http-client.js +131 -0
  52. package/build/executor/index.js +10 -0
  53. package/build/executor/job-errors.js +55 -0
  54. package/build/executor/job-log-registry.js +110 -0
  55. package/build/executor/job-runner.js +688 -0
  56. package/build/executor/job-types.js +60 -0
  57. package/build/executor/merge-job.js +155 -0
  58. package/build/executor/observation.js +123 -0
  59. package/build/executor/permissions.js +79 -0
  60. package/build/executor/preflight.js +144 -0
  61. package/build/executor/process.js +81 -0
  62. package/build/executor/prompt-spec.js +235 -0
  63. package/build/executor/results.js +134 -0
  64. package/build/executor/resume-pre-spawn.js +179 -0
  65. package/build/executor/runner.js +98 -0
  66. package/build/executor/terminal-mutation.js +34 -0
  67. package/build/executor/test-clock.js +109 -0
  68. package/build/executor/types.js +18 -0
  69. package/build/executor/verdict-artifact.js +53 -0
  70. package/build/executor/viewer-tabs.js +78 -0
  71. package/build/executor/watch-cli.js +113 -0
  72. package/build/executor/worker-command.js +106 -0
  73. package/build/executor/worker-finalization.js +97 -0
  74. package/build/executor/worker-log.js +92 -0
  75. package/build/executor/worktree-gc.js +134 -0
  76. package/build/executor/worktree-inspection.js +86 -0
  77. package/build/executor/worktree.js +103 -0
  78. package/build/index.js +13950 -9669
  79. package/build/install-bridge.js +25 -8
  80. package/build/install-doctor.js +387 -0
  81. package/build/mcp-invoke.js +19 -3
  82. package/build/mcp-provisioning.js +31 -25
  83. package/build/mcp-registration-doctor.js +27 -7
  84. package/build/mcp-server-invocation.js +152 -0
  85. package/build/pipelines.generated.js +31 -6
  86. package/build/readme.generated.js +1 -1
  87. package/build/regression-check.js +53 -1
  88. package/build/review-tickets.js +175 -21
  89. package/build/sfcc/reads-site-preference.js +52 -19
  90. package/build/start-tickets-conductor.js +47 -99
  91. package/build/start-tickets-prereqs.js +185 -4
  92. package/build/start-tickets.js +218 -180
  93. package/build/version.generated.js +1 -1
  94. package/build/visual-diff-worker.js +313 -0
  95. package/build/visual-diff.js +632 -0
  96. package/build/worktree-core.js +202 -0
  97. package/package.json +10 -6
  98. package/pipelines/review-ticket.json +24 -2
  99. package/public/css/main.min.css +3311 -1
  100. package/public/css/main.min.css.map +1 -1
  101. package/public/js/main.min.js +7924 -1
  102. package/public/js/main.min.js.map +1 -1
  103. package/smoke-test/SMOKE-TEST.md +5 -2
@@ -18,12 +18,17 @@
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";
27
+ import { getHeadSha, getPrNumber } from "./event-accessors.js";
24
28
  import { emitConductorEventIfNew } from "./producer-ledger.js";
25
29
  import { rebuildObservedState, extractWorkerLiveness, } from "./epic-state.js";
26
30
  import { reconcileEpic } from "./epic-reconcile.js";
31
+ import { normalizeDeclaredTouchedFiles } from "./file-scope-guard.js";
27
32
  import { buildSupervisorRemediationWorkerMessage } from "./supervisor-message-relay.js";
28
33
  import { sendWorkerMessage } from "./store.js";
29
34
  import { hashPlan } from "./plan.js";
@@ -49,6 +54,12 @@ const ACTIVE_WORKER_STATUSES = new Set(["dispatched", "running"]);
49
54
  */
50
55
  const PARSE_WAIT_EVENT_SOURCE = "conductor-supervisor";
51
56
  const PARSE_WAIT_EVENT_PRODUCER = "epic-parse-wait";
57
+ /**
58
+ * BAPI-494: source + producer tags for the durable `merge.conflict` marker the
59
+ * done-gate pass emits when a `ready_for_review` ticket's PR is un-mergeable.
60
+ */
61
+ const MERGE_CONFLICT_EVENT_SOURCE = "conductor-supervisor";
62
+ const MERGE_CONFLICT_EVENT_PRODUCER = "epic-mergeability";
52
63
  function defaultLeaseOwner() {
53
64
  return `epic-tick-${process.pid}`;
54
65
  }
@@ -61,6 +72,411 @@ async function defaultDispatchSeam(_epicKey, ticketKey, _attempt = 0) {
61
72
  async function defaultPostActionWaitSeam(_epicKey, _ticketKey) {
62
73
  // no-op: parse-after-merge wait is a sibling ticket's concern
63
74
  }
75
+ export function parsePrBindingFromGhJson(stdout) {
76
+ let pr;
77
+ try {
78
+ pr = JSON.parse(stdout);
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ const num = pr.number;
84
+ const sha = pr.headRefOid;
85
+ const state = typeof pr.state === "string" ? pr.state : "";
86
+ if (typeof num === "number" &&
87
+ Number.isInteger(num) &&
88
+ num >= 1 &&
89
+ typeof sha === "string" &&
90
+ /^[0-9a-f]{7,40}$/i.test(sha) &&
91
+ state.toUpperCase() === "OPEN") {
92
+ // BAPI-494: parse mergeability defensively from the same JSON object — unknown
93
+ // values become null and never reject an otherwise valid open PR binding.
94
+ const mergeability = parseGhPrMergeabilityFields(pr);
95
+ return {
96
+ prNumber: num,
97
+ headSha: sha,
98
+ mergeable: mergeability.mergeable,
99
+ mergeStateStatus: mergeability.mergeStateStatus,
100
+ };
101
+ }
102
+ return null;
103
+ }
104
+ /**
105
+ * Shared gh-discovery PR-binding resolver: resolve BOTH the PR number and the
106
+ * CURRENT head SHA for a ticket from its `feature/<KEY>` branch via
107
+ * `gh pr view ... --json number,headRefOid,state`. This is the single
108
+ * gh-discovery path used by BOTH the Conductor done-gate pass and the
109
+ * remediation CAS dispatch (BAPI-487 Requirement 1). It intentionally does NOT
110
+ * consult `epic_supervisor_setup.pr_bindings` — nothing auto-populates that map,
111
+ * so the legacy `pr_bindings` seam always returned null and stranded remediation.
112
+ *
113
+ * Fail-closed: returns null for any failed `gh` invocation, empty stdout, or a
114
+ * closed/merged PR / malformed number / non-hex head SHA (all enforced by
115
+ * {@link parsePrBindingFromGhJson}). `options.runGh` is injectable for pure unit
116
+ * tests; `options.cwd` defaults to the Conductor's current working directory.
117
+ */
118
+ export function resolveTicketPrBindingFromGh(ticketKey, options = {}) {
119
+ const runGh = options.runGh ?? runGhCommand;
120
+ const ghRes = runGh(
121
+ // BAPI-494: mergeability fields added to the SAME per-ticket binding call — the
122
+ // done-gate reads mergeability inside this existing call, spawning no new gh process.
123
+ ["pr", "view", `feature/${ticketKey}`, "--json", "number,headRefOid,state,mergeable,mergeStateStatus"], { cwd: options.cwd ?? process.cwd() });
124
+ if (ghRes.ok && ghRes.stdout.trim()) {
125
+ const parsed = parsePrBindingFromGhJson(ghRes.stdout);
126
+ // Normalize the head SHA to lowercase so downstream head-scoped comparisons
127
+ // (blocked-head vs. current-head, gate.met dedupe) are case-stable.
128
+ return parsed
129
+ ? { ...parsed, headSha: parsed.headSha.toLowerCase() }
130
+ : null;
131
+ }
132
+ return null;
133
+ }
134
+ // ---------------------------------------------------------------------------
135
+ // Dispatched/running done-gate backstop (BAPI-525 Change B)
136
+ // ---------------------------------------------------------------------------
137
+ /** Conservative default quiescence window (ticks) for the dispatched backstop. */
138
+ const DEFAULT_DISPATCHED_BACKSTOP_QUIESCENCE_TICKS = 3;
139
+ /** The conservative default (disabled) dispatched-backstop policy. */
140
+ export const DEFAULT_DISPATCHED_BACKSTOP_POLICY = {
141
+ enabled: false,
142
+ quiescence_ticks: DEFAULT_DISPATCHED_BACKSTOP_QUIESCENCE_TICKS,
143
+ quiescence_ms: null,
144
+ };
145
+ /** Accept only a positive integer; every other shape (0, negative, float, string, NaN) → null. */
146
+ function parsePositiveInt(value) {
147
+ return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
148
+ }
149
+ /**
150
+ * Resolve the dispatched-backstop policy from an epic run's `policy_json`.
151
+ * Fail-closed to {@link DEFAULT_DISPATCHED_BACKSTOP_POLICY} on any missing/malformed
152
+ * shape. Only a literal boolean `true` enables the path; `quiescence_ticks` /
153
+ * `quiescence_ms` accept only positive integers and otherwise fall back to the
154
+ * conservative default (ticks) / `null` (ms). Mirrors the defensive style used for
155
+ * `policy_json.post_action_waits.parse_after_merge`.
156
+ */
157
+ export function resolveDispatchedBackstopPolicy(policyJson) {
158
+ const doneGate = policyJson && typeof policyJson === "object" && !Array.isArray(policyJson)
159
+ ? policyJson.done_gate
160
+ : undefined;
161
+ const cfg = doneGate && typeof doneGate === "object" && !Array.isArray(doneGate)
162
+ ? doneGate.dispatched_backstop
163
+ : undefined;
164
+ if (!cfg || typeof cfg !== "object" || Array.isArray(cfg)) {
165
+ return { ...DEFAULT_DISPATCHED_BACKSTOP_POLICY };
166
+ }
167
+ const record = cfg;
168
+ return {
169
+ enabled: record.enabled === true,
170
+ quiescence_ticks: parsePositiveInt(record.quiescence_ticks) ?? DEFAULT_DISPATCHED_BACKSTOP_QUIESCENCE_TICKS,
171
+ quiescence_ms: parsePositiveInt(record.quiescence_ms),
172
+ };
173
+ }
174
+ /** Reuse the single active-worker status set as the backstop admission set. */
175
+ export function isActiveWorkerStatus(status) {
176
+ return ACTIVE_WORKER_STATUSES.has(status);
177
+ }
178
+ /**
179
+ * The base done-gate statuses (`ready_for_review`/`blocked`) whose behavior is
180
+ * unchanged by BAPI-525 — no quiescence gate is applied to them.
181
+ */
182
+ export function isBaseDoneGateStatus(status) {
183
+ return status === "ready_for_review" || status === "blocked";
184
+ }
185
+ /**
186
+ * Head-changing event families used to compute stable-head quiescence. `getHeadSha`
187
+ * reads the canonical `data.details.head_sha` regardless of the typed details
188
+ * family, so `git.commit_created` (details-less in the accessor map) still yields a
189
+ * head when the raw envelope carries one.
190
+ */
191
+ const HEAD_CHANGING_EVENT_TYPES = new Set([
192
+ "git.pr_opened",
193
+ "git.commit_created",
194
+ "ci.passed",
195
+ "review.passed",
196
+ "gate.met",
197
+ ]);
198
+ /**
199
+ * Build a local-first PR binding resolver for the dispatched backstop (BAPI-525
200
+ * Change B, Requirement 5). Resolution order per ticket:
201
+ * 1. dispatch/run-state PR binding — SKIPPED: `EpicDispatch`/`EpicTicketStatus`
202
+ * carry no PR number/head SHA and `pr_bindings` is a legacy always-null seam,
203
+ * so there is no durable PR binding on run state to consult (documented here so
204
+ * a future state-model addition knows to wire it in first).
205
+ * 2. the newest local ledger event (by `seq`) carrying BOTH a valid `pr_number`
206
+ * and `head_sha` (e.g. a `gate.met`), read through the typed `getPrNumber` /
207
+ * `getHeadSha` accessors and normalized fail-closed.
208
+ * 3. a `gh pr view feature/<KEY>` lookup, with NEGATIVE results cached per tick so
209
+ * a ticket with no open PR is never polled twice in the same tick.
210
+ * A local binding carries `mergeable`/`mergeStateStatus: null` (unknown without the
211
+ * gh call); a conflict check treats `null` as non-conflicting.
212
+ */
213
+ export function buildTicketPrBindingResolver(deps) {
214
+ // Precompute ticket_key -> newest local (pr_number, head_sha) binding.
215
+ const localByTicket = new Map();
216
+ for (const ev of deps.events) {
217
+ const prNumber = getPrNumber(ev);
218
+ const headSha = getHeadSha(ev);
219
+ if (prNumber === null || headSha === null)
220
+ continue;
221
+ let ticketKey = null;
222
+ if (typeof ev.run_id === "string" && deps.resolveTicketForRunId) {
223
+ ticketKey = deps.resolveTicketForRunId(ev.run_id);
224
+ }
225
+ if (!ticketKey && typeof ev.subject === "string" && ev.subject.trim().length > 0) {
226
+ ticketKey = ev.subject;
227
+ }
228
+ if (!ticketKey)
229
+ continue;
230
+ const prev = localByTicket.get(ticketKey);
231
+ if (!prev || ev.seq >= prev.seq) {
232
+ localByTicket.set(ticketKey, {
233
+ seq: ev.seq,
234
+ binding: {
235
+ prNumber,
236
+ headSha: headSha.toLowerCase(),
237
+ mergeable: null,
238
+ mergeStateStatus: null,
239
+ },
240
+ });
241
+ }
242
+ }
243
+ // Per-tick gh cache: memoize BOTH hits and misses so a missing PR is looked up at
244
+ // most once per ticket per tick (Requirement 5 — no gh/CI thrash on dispatched).
245
+ const ghCache = new Map();
246
+ return (ticketKey) => {
247
+ const local = localByTicket.get(ticketKey);
248
+ if (local)
249
+ return local.binding;
250
+ if (ghCache.has(ticketKey))
251
+ return ghCache.get(ticketKey) ?? null;
252
+ const gh = deps.resolveFromGh(ticketKey);
253
+ ghCache.set(ticketKey, gh);
254
+ return gh;
255
+ };
256
+ }
257
+ /**
258
+ * Build the active-ticket quiescence resolver (BAPI-525 Change B, Requirement 6).
259
+ * A `dispatched`/`running` ticket is folded toward `ready_for_review` ONLY when its
260
+ * PR-bound completion evidence (resolved by the caller) is accompanied by a
261
+ * quiescence signal — NEVER on green-PR evidence alone, because workers are
262
+ * interactive and may still be pushing follow-up commits:
263
+ *
264
+ * - an explicit implementation `run.stopped` for the ticket's impl run, OR
265
+ * - a head SHA that MATCHES the candidate PR head AND has been stable for at least
266
+ * `policy.quiescence_ms` since the latest head-changing ledger event.
267
+ *
268
+ * `quiescence_ticks` is parsed and carried on the policy but is NOT used here: the
269
+ * conductor has no durable per-tick marker to count "N consecutive ticks" against,
270
+ * and tick stability MUST NOT be faked from process memory (it would not survive a
271
+ * restart). When only `quiescence_ticks` is configured (no `quiescence_ms`), the
272
+ * stable-head path cannot prove quiescence and only `run.stopped` folds the ticket.
273
+ * Worker MCP heartbeats (`message.delivered`/`message.acked`) are deliberately NOT
274
+ * used as the reliable primitive — that channel is exactly what fails in Issue 1.
275
+ */
276
+ export function buildActiveTicketQuiescenceResolver(deps) {
277
+ return (ticketKey, prBinding) => {
278
+ if (!deps.policy.enabled)
279
+ return { quiescent: false, reason: "policy_disabled" };
280
+ const implRunId = deps.resolveImplRunId(ticketKey);
281
+ // Select the ticket's implementation-run events. Prefer the impl run_id (which
282
+ // excludes any `:review` run); fall back to subject match when no run is known.
283
+ const matchesTicket = (ev) => implRunId !== null
284
+ ? ev.run_id === implRunId
285
+ : ev.subject === ticketKey;
286
+ // Explicit completion evidence: an implementation `run.stopped`.
287
+ for (const ev of deps.events) {
288
+ if (ev.type === "run.stopped" && matchesTicket(ev)) {
289
+ return { quiescent: true, reason: "run.stopped" };
290
+ }
291
+ }
292
+ // Stable-head evidence: latest head-changing event for the impl run.
293
+ let latest = null;
294
+ for (const ev of deps.events) {
295
+ if (!HEAD_CHANGING_EVENT_TYPES.has(ev.type) || !matchesTicket(ev))
296
+ continue;
297
+ const headSha = getHeadSha(ev);
298
+ if (headSha === null)
299
+ continue;
300
+ const timeMs = typeof ev.time === "string" ? Date.parse(ev.time) : NaN;
301
+ if (Number.isNaN(timeMs))
302
+ continue;
303
+ if (!latest || ev.seq >= latest.seq) {
304
+ latest = { headSha: headSha.toLowerCase(), timeMs, seq: ev.seq };
305
+ }
306
+ }
307
+ if (!latest)
308
+ return { quiescent: false, reason: "no_signal" };
309
+ if (latest.headSha !== prBinding.headSha.toLowerCase()) {
310
+ return { quiescent: false, reason: "unstable_head", last_head_event_time: latest.timeMs };
311
+ }
312
+ // Head matches the candidate PR head. Prove stability via the ms window only.
313
+ if (deps.policy.quiescence_ms === null) {
314
+ return { quiescent: false, reason: "no_signal", last_head_event_time: latest.timeMs };
315
+ }
316
+ const stableMs = deps.nowMs - latest.timeMs;
317
+ if (stableMs >= deps.policy.quiescence_ms) {
318
+ return {
319
+ quiescent: true,
320
+ reason: "stable_head",
321
+ stable_ms: stableMs,
322
+ last_head_event_time: latest.timeMs,
323
+ };
324
+ }
325
+ return { quiescent: false, reason: "unstable_head", last_head_event_time: latest.timeMs };
326
+ };
327
+ }
328
+ /**
329
+ * For each `ready_for_review` ticket — and (BAPI-487) each `blocked` ticket whose
330
+ * live PR head has advanced past the head that blocked it — ask the conductor to
331
+ * evaluate the composite done-gate itself via {@link observePrCiOnce}, the
332
+ * correctness backstop for FINDING 6 where a worker never self-emits `gate.met`.
333
+ * `observePrCiOnce` binds to the CURRENT head SHA, fails closed on any
334
+ * unavailable/ambiguous/stale-head signal, and idempotently emits `gate.met`
335
+ * (deduped by ticket+PR+head_sha) so the fold + merge-enqueue happens exactly once
336
+ * regardless of whether the worker also emitted it.
337
+ *
338
+ * A `blocked` ticket is re-evaluated ONLY when its PR binding resolves AND either
339
+ * the recorded blocked head is unknown (fail-closed recovery) or the current head
340
+ * differs from it — a ticket still blocked on its current head is left blocked so
341
+ * a same-head failure is never bypassed.
342
+ *
343
+ * Fully fail-closed and non-blocking: a per-ticket observation error is caught and
344
+ * logged so it can never crash or stall the reconcile loop; tickets that are
345
+ * neither `ready_for_review` nor advanced-head `blocked`, and tickets without a
346
+ * PR binding, are skipped.
347
+ */
348
+ export async function runConductorDoneGatePass(ticketStatuses, deps) {
349
+ const backstopEnabled = deps.dispatchedBackstopPolicy?.enabled === true;
350
+ for (const [ticketKey, status] of ticketStatuses) {
351
+ // BAPI-525 Change B: admit the base statuses (ready_for_review/blocked) always;
352
+ // admit the active statuses (dispatched/running) ONLY when the dispatched
353
+ // backstop is enabled. Every other status (terminal, planned, reviewing, …) is
354
+ // skipped before any PR binding / CI observation, exactly as before.
355
+ const isActive = isActiveWorkerStatus(status);
356
+ if (!isBaseDoneGateStatus(status) && !(isActive && backstopEnabled))
357
+ continue;
358
+ // Base statuses always resolve the CURRENT head via gh-discovery (unchanged).
359
+ // Active statuses use the local-first resolver (BAPI-525 Change B) so a
360
+ // dispatched ticket with no local PR binding is not re-polled every tick.
361
+ const prBinding = isActive
362
+ ? (deps.resolveActivePrBinding ?? deps.resolvePrBinding)(ticketKey)
363
+ : deps.resolvePrBinding(ticketKey);
364
+ if (prBinding === null) {
365
+ deps.log(`[epic-tick] done-gate poll for ${ticketKey}: skipped (no PR binding)`);
366
+ continue;
367
+ }
368
+ // BAPI-525 Change B: an admitted active ticket is folded ONLY when quiescent
369
+ // (explicit run.stopped or a stable head) — never on green-PR evidence alone, so
370
+ // an interactive worker still pushing commits is not yanked out from under.
371
+ if (isActive) {
372
+ const quiescence = deps.resolveActiveTicketQuiescence?.(ticketKey, prBinding) ?? {
373
+ quiescent: false,
374
+ reason: "no_signal",
375
+ };
376
+ if (!quiescence.quiescent) {
377
+ deps.log(`[epic-tick] done-gate poll for ${ticketKey}: skipped (${status} not quiescent: ${quiescence.reason})`);
378
+ continue;
379
+ }
380
+ deps.log(`[epic-tick] done-gate backstop for ${ticketKey}: ${status} quiescent (${quiescence.reason}); evaluating done-gate at head ${prBinding.headSha}`);
381
+ }
382
+ // BAPI-487: gate a `blocked` ticket's re-evaluation on its head advancing.
383
+ if (status === "blocked") {
384
+ const blockedHead = deps.resolveBlockedHeadSha?.(ticketKey) ?? null;
385
+ if (blockedHead === null) {
386
+ deps.log(`[epic-tick] done-gate re-eval for ${ticketKey}: blocked with no recorded head; ` +
387
+ `polling current head ${prBinding.headSha} fail-closed as recovery`);
388
+ }
389
+ else if (blockedHead.toLowerCase() === prBinding.headSha.toLowerCase()) {
390
+ deps.log(`[epic-tick] done-gate poll for ${ticketKey}: skipped (still blocked on current head ${prBinding.headSha})`);
391
+ continue;
392
+ }
393
+ else {
394
+ deps.log(`[epic-tick] done-gate re-eval for ${ticketKey}: blocked head ${blockedHead} ` +
395
+ `superseded by current head ${prBinding.headSha}; re-evaluating`);
396
+ }
397
+ }
398
+ // Stamp the ticket's run_id and (when available) worker_id onto the emitted
399
+ // gate.met via a per-ticket env override (observePrCiOnce reads
400
+ // BAPI_CONDUCTOR_RUN_ID/_WORKER_ID from env, env taking precedence). run_id
401
+ // correlates the fold to the ticket. worker_id is attribution ONLY: BAPI-525
402
+ // Change A removed the worker_id requirement from merge-identity extraction, so
403
+ // a missing worker_id no longer blocks the merge — the backstop stamps real
404
+ // attribution when it can resolve it and merges regardless when it cannot.
405
+ const runId = deps.resolveRunId?.(ticketKey) ?? null;
406
+ const workerId = deps.resolveWorkerId?.(ticketKey) ?? null;
407
+ // BAPI-494: a `ready_for_review` PR that is already un-mergeable
408
+ // (CONFLICTING/DIRTY) can never be re-reviewed (claude-review won't verdict an
409
+ // unmergeable branch) or merged — so it would sit at ready_for_review forever.
410
+ // Route it into the head-scoped `merge.conflict` blocking path (folded next
411
+ // tick, remediated as a resume-mode redispatch) BEFORE re-polling CI, and skip
412
+ // CI observation for this ticket. Only `ready_for_review` conflicts are emitted
413
+ // here; a `blocked` ticket already has routing + stale-head re-evaluation.
414
+ if (status === "ready_for_review" && isPrMergeConflict(prBinding)) {
415
+ try {
416
+ await deps.emitConflictSignal?.({
417
+ ticketKey,
418
+ repoName: deps.access.repoName,
419
+ prNumber: prBinding.prNumber,
420
+ headSha: prBinding.headSha,
421
+ mergeable: prBinding.mergeable,
422
+ mergeStateStatus: prBinding.mergeStateStatus,
423
+ runId,
424
+ workerId,
425
+ });
426
+ deps.log(`[epic-tick] done-gate conflict for ${ticketKey}: PR #${prBinding.prNumber} ` +
427
+ `not mergeable at head ${prBinding.headSha}; emitted merge.conflict`);
428
+ }
429
+ catch (err) {
430
+ const safeMsg = err instanceof Error ? err.constructor.name : "conflict emit error";
431
+ deps.errorLog(`[epic-tick] done-gate conflict-signal failed (${safeMsg}) for ${ticketKey}; continuing`);
432
+ }
433
+ continue;
434
+ }
435
+ const perTicketEnv = {
436
+ ...deps.env,
437
+ ...(runId ? { BAPI_CONDUCTOR_RUN_ID: runId } : {}),
438
+ ...(workerId ? { BAPI_CONDUCTOR_WORKER_ID: workerId } : {}),
439
+ };
440
+ try {
441
+ const observeResult = await deps.observePrCi({
442
+ repoName: deps.access.repoName,
443
+ prNumber: prBinding.prNumber,
444
+ headSha: prBinding.headSha,
445
+ }, {
446
+ env: perTicketEnv,
447
+ resolveAccess: async () => ({ ok: true, access: deps.access }),
448
+ });
449
+ deps.log(`[epic-tick] done-gate poll for ${ticketKey}: ${observeResult.reason}`);
450
+ }
451
+ catch (err) {
452
+ const safeMsg = err instanceof Error ? err.constructor.name : "observe error";
453
+ deps.errorLog(`[epic-tick] done-gate poll failed (${safeMsg}) for ${ticketKey}; continuing`);
454
+ }
455
+ }
456
+ }
457
+ // ---------------------------------------------------------------------------
458
+ // N-3 run self-completion (BAPI-507)
459
+ // ---------------------------------------------------------------------------
460
+ /**
461
+ * BAPI-507 (N-3): should the epic run self-complete this tick? True ONLY when the
462
+ * approved plan has at least one ticket AND every plan ticket's effective status
463
+ * is exactly `"done"` in `observed.ticket_statuses`. Any node that is
464
+ * `abandoned` / `blocked` / `ready_for_review` / `running` / `reviewing` /
465
+ * `ready` / `dispatched` / `planned`, missing from the status map, or any other
466
+ * non-`done` value makes this return `false`. Conservative done-only policy: a
467
+ * plan ending in a `done` + `abandoned` mix does NOT auto-complete — an operator
468
+ * decides how to close a run with abandoned nodes. Pure: no I/O, no clock.
469
+ */
470
+ function shouldSelfCompleteEpicRun(plan, observed) {
471
+ if (plan.tickets.length === 0)
472
+ return false;
473
+ for (const ticket of plan.tickets) {
474
+ if (observed.ticket_statuses.get(ticket.ticket_key) !== "done") {
475
+ return false;
476
+ }
477
+ }
478
+ return true;
479
+ }
64
480
  // ---------------------------------------------------------------------------
65
481
  // runEpicTick
66
482
  // ---------------------------------------------------------------------------
@@ -91,6 +507,28 @@ export async function runEpicTick(options, deps = {}) {
91
507
  const claimLeaseFn = deps.claimLease ?? claimEpicSupervisionLease;
92
508
  const fetchEpicStateFn = deps.fetchEpicState ?? fetchEpicRunState;
93
509
  const releaseLease = deps.releaseLease;
510
+ const env = deps.env ?? process.env;
511
+ const observePrCiSeamFn = deps.observePrCiSeam ?? observePrCiOnce;
512
+ // BAPI-507 (N-3): default self-completion seam CASes the run active → done via
513
+ // the existing PATCH /runs/{id} path, addressing the run by its concrete UUID.
514
+ const completeEpicRunFn = deps.completeEpicRun ??
515
+ (async (acc, epicRunId) => {
516
+ await updateEpicRunStatus(acc, {
517
+ epicKey: epicRunId,
518
+ status: "done",
519
+ expectedStatus: "active",
520
+ });
521
+ });
522
+ // BAPI-494: a single durable event emitter reused by BOTH the parse-after-merge
523
+ // marker block and the done-gate `merge.conflict` wiring, so there is one
524
+ // injectable ledger path (not a second hardcoded emitter).
525
+ const emitConductorEventFn = deps.emitConductorEvent ?? emitConductorEventIfNew;
526
+ // FINDING 1 (BAPI-463): a dry-run tick must exercise the full dispatch-resolution
527
+ // path (ready-set → resolved command + model routing) yet persist NO authoritative
528
+ // dispatch state. Without this guard, `correlateRunId` writes a `run_spawned` row
529
+ // under the REAL dispatch_key, and the next real tick's `claimDispatchKey` returns
530
+ // `already-spawned`, silently skipping the real worker spawn.
531
+ const isDryRun = env.BAPI_CONDUCTOR_DISPATCH_DRY_RUN === "1";
94
532
  const startMs = nowFn();
95
533
  // ---------------------------------------------------------------------------
96
534
  // Step 0: Resolve Bridge API access (offline / fail-closed guard)
@@ -222,10 +660,8 @@ export async function runEpicTick(options, deps = {}) {
222
660
  const isParseWaitEnabled = pamConfig?.enabled === true;
223
661
  if (isParseWaitEnabled) {
224
662
  const maxWaitMs = typeof pamConfig?.max_wait_ms === "number" ? pamConfig.max_wait_ms : 10 * 60 * 1000;
225
- const settleMs = 5000;
226
663
  const fetchParseStatusFn = deps.fetchParseStatus ?? fetchParseStatus;
227
664
  const triggerParseFn = deps.triggerParse ?? triggerRepositoryParse;
228
- const emitConductorEventFn = deps.emitConductorEvent ?? emitConductorEventIfNew;
229
665
  for (let i = 0; i < observed.unfolded_terminal_signals.length; i++) {
230
666
  const signal = observed.unfolded_terminal_signals[i];
231
667
  if (signal.signal_type !== "merge.succeeded")
@@ -260,26 +696,52 @@ export async function runEpicTick(options, deps = {}) {
260
696
  e.run_id === mergeRunId &&
261
697
  new Date(e.time).getTime() >= mergeTimeMs);
262
698
  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.
267
- if (elapsedMs > maxWaitMs) {
699
+ // BAPI-527: enter (or hold at) the explicit parse_pending state. This
700
+ // replaces the old "revert to ready_for_review and hold" behavior a
701
+ // merged ticket whose post-merge parse is queued/running is now DURABLY
702
+ // parse_pending, so the state is observable between stateless epic-tick
703
+ // processes. When the ticket is ALREADY durably parse_pending we hold
704
+ // WITHOUT a churny re-CAS (revert drops the signal, leaving the Postgres row
705
+ // untouched); otherwise we retag the merge.succeeded fold to parse_pending so
706
+ // reconcile CASes it there.
707
+ const holdOrEnterParsePending = () => {
708
+ if (currentPgStatus === "parse_pending") {
709
+ revertSignal();
710
+ return;
711
+ }
712
+ signal.next_status = "parse_pending";
713
+ observed.ticket_statuses.set(ticketKey, "parse_pending");
714
+ };
715
+ // BAPI-527: fold to `blocked` for a terminal parse failure or an exhausted
716
+ // wait budget. Escalates exactly once, gated on the durable Postgres status
717
+ // (not an in-memory flag): a later tick that finds the ticket already
718
+ // `blocked` drops the signal without re-escalating or re-CASing. EVERY
719
+ // terminal failure/timeout path routes through here, so a permanent parse
720
+ // failure can never silently wedge the epic at parse_pending. `reason` is a
721
+ // bounded, secret-free summary — never subprocess stderr/traceback.
722
+ const foldToBlocked = async (reason) => {
268
723
  if (currentPgStatus === "blocked") {
269
724
  observed.ticket_statuses.set(ticketKey, "blocked");
270
725
  observed.unfolded_terminal_signals.splice(i, 1);
271
726
  i -= 1;
272
- continue;
727
+ return;
273
728
  }
274
- await escalateOnce(epic_key, `parse-after-merge budget exhausted for ${ticketKey}`);
729
+ await escalateOnce(epic_key, reason);
275
730
  signal.next_status = "blocked";
276
731
  observed.ticket_statuses.set(ticketKey, "blocked");
277
732
  // Let the signal remain so the reconcile pass CASes once to blocked.
733
+ };
734
+ // Budget exhaustion (measured from the durable merge.succeeded time):
735
+ // escalate once then block with a parse-timeout reason.
736
+ if (elapsedMs > maxWaitMs) {
737
+ await foldToBlocked(`parse-after-merge timed out (budget exhausted) for ${ticketKey}`);
278
738
  continue;
279
739
  }
280
740
  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.
741
+ // No durable trigger for this merge yet — fire the parse, record the
742
+ // marker, and set the ticket to parse_pending. DO NOT poll parse status
743
+ // in the SAME tick as the trigger, so parse_pending is always observable
744
+ // after merge before any success/failure fold.
283
745
  try {
284
746
  await triggerParseFn(access);
285
747
  emitConductorEventFn({
@@ -304,16 +766,20 @@ export async function runEpicTick(options, deps = {}) {
304
766
  run_id: mergeRunId ?? undefined,
305
767
  commit_sha: mergeHeadSha,
306
768
  });
307
- log(`[epic-tick] triggered parse-after-merge for ${ticketKey} in epic=${epic_key}`);
769
+ log(`[epic-tick] triggered parse-after-merge for ${ticketKey} in epic=${epic_key}; holding at parse_pending`);
770
+ holdOrEnterParsePending();
308
771
  }
309
772
  catch (err) {
310
773
  const safeMsg = err instanceof Error ? err.constructor.name : "trigger error";
311
774
  errorLog(`[epic-tick] parse trigger failed (${safeMsg}) for ${ticketKey}; will retry next tick`);
775
+ // Transient trigger failure: hold the merge.succeeded signal for the
776
+ // next tick rather than folding to done/blocked.
777
+ revertSignal();
312
778
  }
313
- revertSignal();
314
779
  continue;
315
780
  }
316
- // A durable parse.triggered exists for this merge — poll the live lock.
781
+ // A durable parse.triggered exists for this merge — poll the durable parse
782
+ // status surface (BAPI-527: idle | queued | in_progress | succeeded | failed).
317
783
  let parseStatusResult;
318
784
  try {
319
785
  parseStatusResult = await fetchParseStatusFn(access);
@@ -321,30 +787,84 @@ export async function runEpicTick(options, deps = {}) {
321
787
  catch (err) {
322
788
  const safeMsg = err instanceof Error ? err.constructor.name : "fetch error";
323
789
  errorLog(`[epic-tick] parse-status check failed (${safeMsg}) for ${ticketKey}; will retry next tick`);
324
- revertSignal();
790
+ // Transient status-fetch failure: hold in parse_pending for the next tick.
791
+ holdOrEnterParsePending();
325
792
  continue;
326
793
  }
327
- if (parseStatusResult.status === "in_progress") {
328
- // Parse still runninghold.
329
- revertSignal();
794
+ if (parseStatusResult.status === "failed") {
795
+ // Terminal parse failureemit a compact parse.failed marker, then fold
796
+ // to blocked (escalate-once). `last_error` is already a bounded, sanitized
797
+ // backend summary; the marker carries no stdout/stderr/stack/command.
798
+ emitConductorEventFn({
799
+ source: PARSE_WAIT_EVENT_SOURCE,
800
+ type: "parse.failed",
801
+ subject: ticketKey,
802
+ run_id: mergeRunId,
803
+ worker_id: mergeEvent.worker_id ?? null,
804
+ producer: PARSE_WAIT_EVENT_PRODUCER,
805
+ observed_via: "supervisor",
806
+ time: new Date(nowFn()).toISOString(),
807
+ data: {
808
+ summary: `parse-after-merge failed for ${ticketKey}`,
809
+ status: "failed",
810
+ reason: "parse_failed",
811
+ details: {
812
+ epic_key,
813
+ ticket_key: ticketKey,
814
+ ...(mergeHeadSha ? { head_sha: mergeHeadSha } : {}),
815
+ ...(typeof parseStatusResult.last_error === "string"
816
+ ? { last_error: parseStatusResult.last_error }
817
+ : {}),
818
+ ...(typeof parseStatusResult.attempt_count === "number"
819
+ ? { attempt_count: parseStatusResult.attempt_count }
820
+ : {}),
821
+ },
822
+ },
823
+ }, {
824
+ event_type: "parse.failed",
825
+ run_id: mergeRunId ?? undefined,
826
+ commit_sha: mergeHeadSha,
827
+ });
828
+ await foldToBlocked(`parse-after-merge failed for ${ticketKey}`);
330
829
  continue;
331
830
  }
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();
831
+ if (parseStatusResult.status === "succeeded") {
832
+ // Terminal parse success emit a compact parse.succeeded marker and let
833
+ // the original merge.succeeded signal proceed with next_status="done".
834
+ emitConductorEventFn({
835
+ source: PARSE_WAIT_EVENT_SOURCE,
836
+ type: "parse.succeeded",
837
+ subject: ticketKey,
838
+ run_id: mergeRunId,
839
+ worker_id: mergeEvent.worker_id ?? null,
840
+ producer: PARSE_WAIT_EVENT_PRODUCER,
841
+ observed_via: "supervisor",
842
+ time: new Date(nowFn()).toISOString(),
843
+ data: {
844
+ summary: `parse-after-merge succeeded for ${ticketKey}`,
845
+ status: "succeeded",
846
+ reason: "parse_succeeded",
847
+ details: {
848
+ epic_key,
849
+ ticket_key: ticketKey,
850
+ ...(mergeHeadSha ? { head_sha: mergeHeadSha } : {}),
851
+ },
852
+ },
853
+ }, {
854
+ event_type: "parse.succeeded",
855
+ run_id: mergeRunId ?? undefined,
856
+ commit_sha: mergeHeadSha,
857
+ });
858
+ log(`[epic-tick] parse-after-merge succeeded for ${ticketKey}; folding merge.succeeded → done`);
859
+ // Do NOT revert: signal.next_status stays "done" (from the merge.succeeded
860
+ // fold) so reconcile CASes the ticket to done.
345
861
  continue;
346
862
  }
347
- // Completed: do not revert the signal proceeds to CAS done.
863
+ // status is queued | in_progress | idle. All are NON-terminal for a
864
+ // triggered parse: queued/in_progress are self-evidently still running, and
865
+ // BAPI-527 treats `idle` as unknown/not-yet-terminal — NEVER fold to done
866
+ // from idle (only an explicit `succeeded` does that). Hold at parse_pending.
867
+ holdOrEnterParsePending();
348
868
  }
349
869
  }
350
870
  // Step 4: Fetch + assert plan integrity (only if fetchPlan injected)
@@ -393,6 +913,12 @@ export async function runEpicTick(options, deps = {}) {
393
913
  // liveness window) and setup (pr_bindings) once. Fail-open: if the config
394
914
  // read fails, remediationConfig stays undefined and reconcile skips the
395
915
  // remediation pass entirely (dispatch/merge steps unaffected).
916
+ //
917
+ // BAPI-487: `prBindings` is retained ONLY for the legacy teardown seam
918
+ // (which needs just the PR number). Both the done-gate observation and the
919
+ // remediation CAS resolve their PR binding via gh-discovery
920
+ // (resolveTicketPrBindingFromGh), never from this map — nothing
921
+ // auto-populates `pr_bindings`, so relying on it stranded remediation.
396
922
  let remediationConfig;
397
923
  let livenessWindowSeconds = 120;
398
924
  let prBindings = {};
@@ -461,6 +987,14 @@ export async function runEpicTick(options, deps = {}) {
461
987
  for (const [tk, info] of reviewLatestDispatchByTicket) {
462
988
  reviewTicketRunIdMap.set(tk, info.runId);
463
989
  }
990
+ // Legacy PR-number-only resolver from `pr_bindings`. BAPI-487: this is now
991
+ // consumed ONLY by the teardownSeam, which needs just the number and is
992
+ // outside this ticket's remediation critical path. Both the done-gate
993
+ // observation and the remediation CAS resolve their PR binding via
994
+ // gh-discovery (resolveTicketPrBindingFromGh / resolvePrBinding) instead,
995
+ // because nothing auto-populates `pr_bindings` (confirmed NULL for repo
996
+ // `bapi`), so this legacy path always returns null. Teardown is left on it
997
+ // deliberately rather than silently migrated.
464
998
  const resolvePrNumber = (ticketKey) => {
465
999
  const raw = prBindings[ticketKey];
466
1000
  if (typeof raw === "number" && Number.isInteger(raw) && raw >= 1)
@@ -473,6 +1007,138 @@ export async function runEpicTick(options, deps = {}) {
473
1007
  }
474
1008
  return null;
475
1009
  };
1010
+ // gh-discovery PR binding used by BOTH the done-gate pass and the
1011
+ // remediation CAS (BAPI-487 Requirement 1). Delegates to the shared
1012
+ // `resolveTicketPrBindingFromGh` helper so the two seams cannot diverge; it
1013
+ // resolves the number AND the current head SHA from `feature/<KEY>` and
1014
+ // fail-closes unless the PR is OPEN with a well-formed number + head SHA.
1015
+ const resolvePrBinding = (ticketKey) => resolveTicketPrBindingFromGh(ticketKey, { cwd: process.cwd() });
1016
+ // ---------------------------------------------------------------------
1017
+ // Step 4.5: Conductor-driven done-gate for ready_for_review tickets
1018
+ // (FINDING 6, BAPI-463) — the correctness backstop for the hands-off run.
1019
+ //
1020
+ // Workers do not reliably self-emit `gate.met` (a ticket can reach
1021
+ // ready_for_review via `run.stopped` alone), so the merge enqueue — gated on
1022
+ // `gate.met` only — would stall forever awaiting a worker signal. Here the
1023
+ // CONDUCTOR evaluates the composite done-gate itself via the existing
1024
+ // `observePrCiOnce`, which binds to the resolved CURRENT head SHA, evaluates
1025
+ // the pure fail-closed done-gate (required-CI green + current-head review
1026
+ // verdict), and idempotently emits `gate.met` (deduped by ticket+PR+head_sha)
1027
+ // so this tick or the next one folds it and enqueues the merge exactly once.
1028
+ // Worker-emitted `gate.met` stays a valid fast-path; this pass never removes it.
1029
+ // The pass is best-effort and fully fail-closed: an observation error is
1030
+ // caught per-ticket so it can never crash or block the reconcile loop, and
1031
+ // `observePrCiOnce` emits nothing on an unavailable/ambiguous/stale-head signal.
1032
+ // run_id → worker_id from the ledger (first worker_id seen per run) so the
1033
+ // conductor-emitted gate.met carries the real worker's id for ATTRIBUTION.
1034
+ // BAPI-525 Change A: merge eligibility no longer depends on worker_id, so an
1035
+ // unresolved worker maps to `null` (real attribution or nothing) — the old
1036
+ // synthetic `conductor:${tk}` fallback existed only to satisfy the now-removed
1037
+ // merge-identity presence check and is no longer needed.
1038
+ const runIdToWorkerId = new Map();
1039
+ for (const ev of localEvents) {
1040
+ if (ev.run_id && ev.worker_id && !runIdToWorkerId.has(ev.run_id)) {
1041
+ runIdToWorkerId.set(ev.run_id, ev.worker_id);
1042
+ }
1043
+ }
1044
+ const resolveTicketRunId = (tk) => ticketRunIdMap.get(tk) ?? latestDispatchByTicket.get(tk)?.runId ?? null;
1045
+ const resolveDoneGateWorkerId = (tk) => {
1046
+ const rid = resolveTicketRunId(tk);
1047
+ if (rid && runIdToWorkerId.has(rid))
1048
+ return runIdToWorkerId.get(rid);
1049
+ return null;
1050
+ };
1051
+ // BAPI-525 Change A: durable worker attribution for a `gate.met` that carries
1052
+ // no worker_id (e.g. the autonomous git-hook chain). Used ONLY to stamp the
1053
+ // emitted `merge.*` ledger events for audit — NEVER a merge gate. Resolves via
1054
+ // the event's run_id, else the event subject's implementation dispatch run,
1055
+ // else null (a null attribution is tolerated end-to-end).
1056
+ const resolveWorkerIdForGateEvent = (event, _identity) => {
1057
+ const directRid = typeof event.run_id === "string" && event.run_id.trim().length > 0
1058
+ ? event.run_id
1059
+ : null;
1060
+ const rid = directRid ??
1061
+ (typeof event.subject === "string" && event.subject.trim().length > 0
1062
+ ? resolveTicketRunId(event.subject)
1063
+ : null);
1064
+ if (rid && runIdToWorkerId.has(rid))
1065
+ return runIdToWorkerId.get(rid);
1066
+ return null;
1067
+ };
1068
+ // BAPI-525 Change B: resolve the per-run dispatched-backstop policy (default
1069
+ // OFF). When enabled, the done-gate pass ALSO admits dispatched/running
1070
+ // tickets, gated by quiescence. Invert the impl-dispatch maps so ledger
1071
+ // events can be attributed back to a ticket for local-first PR binding.
1072
+ const dispatchedBackstopPolicy = resolveDispatchedBackstopPolicy(epicRunState.epic_run.policy_json);
1073
+ const ticketForRunId = new Map();
1074
+ for (const [tk, rid] of ticketRunIdMap)
1075
+ ticketForRunId.set(rid, tk);
1076
+ for (const [tk, info] of latestDispatchByTicket)
1077
+ ticketForRunId.set(info.runId, tk);
1078
+ const resolveActivePrBinding = buildTicketPrBindingResolver({
1079
+ events: localEvents,
1080
+ resolveTicketForRunId: (rid) => ticketForRunId.get(rid) ?? null,
1081
+ resolveFromGh: resolvePrBinding,
1082
+ });
1083
+ const resolveActiveTicketQuiescence = buildActiveTicketQuiescenceResolver({
1084
+ events: localEvents,
1085
+ nowMs: nowFn(),
1086
+ policy: dispatchedBackstopPolicy,
1087
+ resolveImplRunId: resolveTicketRunId,
1088
+ });
1089
+ await runConductorDoneGatePass(observed.ticket_statuses, {
1090
+ observePrCi: observePrCiSeamFn,
1091
+ resolvePrBinding,
1092
+ // BAPI-525 Change B: local-first binding + quiescence gate for the new
1093
+ // dispatched/running admission (policy default OFF ⇒ no behavior change).
1094
+ resolveActivePrBinding,
1095
+ dispatchedBackstopPolicy,
1096
+ resolveActiveTicketQuiescence,
1097
+ // BAPI-487: re-evaluate a blocked ticket only when its PR head advanced
1098
+ // past the head recorded on its latest blocking signal.
1099
+ resolveBlockedHeadSha: (tk) => observed.ticket_blocked_heads?.get(tk) ?? null,
1100
+ resolveRunId: resolveTicketRunId,
1101
+ resolveWorkerId: resolveDoneGateWorkerId,
1102
+ // BAPI-494: convert a detected conflict into a durable, head-scoped
1103
+ // `merge.conflict` ledger event stamped with the ticket's dispatch run/worker
1104
+ // so the fold correlates it and the remediation pass redispatches. Emitted via
1105
+ // the shared injectable emitter, idempotent per conflict head. Folded next tick
1106
+ // (this pass runs after rebuildObservedState), matching the gate.met latency.
1107
+ emitConflictSignal: (input) => {
1108
+ emitConductorEventFn({
1109
+ source: MERGE_CONFLICT_EVENT_SOURCE,
1110
+ type: "merge.conflict",
1111
+ subject: input.ticketKey,
1112
+ run_id: input.runId,
1113
+ worker_id: input.workerId,
1114
+ producer: MERGE_CONFLICT_EVENT_PRODUCER,
1115
+ observed_via: "supervisor",
1116
+ time: new Date(nowFn()).toISOString(),
1117
+ data: {
1118
+ summary: `PR #${input.prNumber} for ${input.ticketKey} is not mergeable`,
1119
+ status: "blocked",
1120
+ reason: "merge.conflict",
1121
+ details: {
1122
+ epic_key,
1123
+ ticket_key: input.ticketKey,
1124
+ repo: input.repoName,
1125
+ pr_number: input.prNumber,
1126
+ head_sha: input.headSha,
1127
+ mergeable: input.mergeable,
1128
+ mergeStateStatus: input.mergeStateStatus,
1129
+ },
1130
+ },
1131
+ }, {
1132
+ event_type: "merge.conflict",
1133
+ run_id: input.runId ?? undefined,
1134
+ commit_sha: input.headSha,
1135
+ });
1136
+ },
1137
+ access,
1138
+ env,
1139
+ log,
1140
+ errorLog,
1141
+ });
476
1142
  const maxSeqForRun = (runId) => {
477
1143
  let maxSeq = 0;
478
1144
  for (const ev of localEvents) {
@@ -489,25 +1155,57 @@ export async function runEpicTick(options, deps = {}) {
489
1155
  nextStatus,
490
1156
  planVersion,
491
1157
  }),
492
- seedTicketStatus: async (ek, tk, planVersion) => {
1158
+ seedTicketStatus: async (_ek, tk, planVersion) => {
1159
+ // A6/BAPI-507: address the run by its concrete epic_run_id — the
1160
+ // endpoint path is /runs/{epic_run_id}/tickets. Passing the UUID (not
1161
+ // the Jira epic key `_ek`) skips the epic-key→active-run resolution and
1162
+ // its multi-active-run CONFLICT edge. The backend read-back makes a
1163
+ // re-seed of an existing row a clean 2xx no-op.
493
1164
  await createEpicTicketStatus(access, {
494
- epicKey: ek,
1165
+ epicKey: epicRunState.epic_run.epic_run_id,
495
1166
  ticketKey: tk,
496
1167
  status: "planned",
497
1168
  planVersion,
498
1169
  });
499
1170
  },
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
- }),
1171
+ claimDispatchKey: async (ek, tk, planVersion, role, attempt = 0) => {
1172
+ // FINDING 1 (BAPI-463): in dry-run mode, DO NOT persist a real dispatch
1173
+ // claim. Return a synthetic "claimed" result (run_id null) so the reconcile
1174
+ // pass proceeds to resolve the command + model routing (still exercised) but
1175
+ // no authoritative `run_spawned` row is written under the real dispatch_key.
1176
+ if (isDryRun) {
1177
+ const suffix = `${role === "review" ? ":review" : ""}${attempt > 0 ? `:r${attempt}` : ""}`;
1178
+ const nowIso = new Date(nowFn()).toISOString();
1179
+ log(`[DRY RUN] claiming dispatch key skipped for ${tk} (epic=${ek}, role=${role ?? "implementation"}, attempt=${attempt}); no run_spawned row persisted`);
1180
+ return {
1181
+ ok: true,
1182
+ kind: "claimed",
1183
+ dispatch: {
1184
+ dispatch_key: `dry-run:${ek}:${tk}:v${planVersion}${suffix}`,
1185
+ epic_run_id: ek,
1186
+ ticket_key: tk,
1187
+ plan_version: planVersion,
1188
+ status: "pending",
1189
+ run_id: null,
1190
+ lease_owner: lease_owner,
1191
+ lease_expires_at: null,
1192
+ created_at: nowIso,
1193
+ updated_at: nowIso,
1194
+ },
1195
+ };
1196
+ }
1197
+ return recordEpicDispatch(access, {
1198
+ epicKey: ek,
1199
+ ticketKey: tk,
1200
+ planVersion,
1201
+ leaseOwner: lease_owner,
1202
+ ttlSeconds: DEFAULT_DISPATCH_KEY_TTL_SECONDS,
1203
+ attempt,
1204
+ // BAPI-445: a review-role claim appends ":review" to the dispatch key
1205
+ // so the run-id maps above can separate review runs from impl runs.
1206
+ reviewRole: role === "review",
1207
+ });
1208
+ },
511
1209
  // BAPI-445 spec re-review seams. dispatchReviewSeam is wired only when the
512
1210
  // factory provides it (gate stays off otherwise); the liveness + attempt
513
1211
  // accessors read the review-scoped maps built above.
@@ -522,6 +1220,13 @@ export async function runEpicTick(options, deps = {}) {
522
1220
  },
523
1221
  countReviewAttempts: (tk) => reviewAttemptCounts.get(tk) ?? 0,
524
1222
  correlateRunId: async (dispatchKey, runId) => {
1223
+ // FINDING 1 (BAPI-463): dry-run must not transition the dispatch row to
1224
+ // `run_spawned`. Skipping this keeps the real dispatch_key un-claimed so a
1225
+ // subsequent real tick spawns the worker instead of hitting `already-spawned`.
1226
+ if (isDryRun) {
1227
+ log(`[DRY RUN] correlate run_id skipped for dispatch_key=${dispatchKey} (run_id=${runId}); no run_spawned transition persisted`);
1228
+ return;
1229
+ }
525
1230
  await transitionEpicDispatch(access, {
526
1231
  dispatchKey,
527
1232
  nextStatus: "run_spawned",
@@ -545,8 +1250,12 @@ export async function runEpicTick(options, deps = {}) {
545
1250
  method: resolveLocalMergeMethod(localCfg.method),
546
1251
  approvalRequired: localCfg.approval_required === true,
547
1252
  }, { env: process.env }),
1253
+ // BAPI-525 Change A: durable attribution for a worker_id-less gate.met.
1254
+ resolveWorkerIdForGateEvent,
548
1255
  });
549
1256
  }
1257
+ // Default (backend-route) path, with durable merge attribution wired in.
1258
+ return processGateMetMerge(acc, event, { resolveWorkerIdForGateEvent });
550
1259
  }
551
1260
  return processMergeFn(acc, event);
552
1261
  },
@@ -615,19 +1324,30 @@ export async function runEpicTick(options, deps = {}) {
615
1324
  return extractWorkerLiveness(localEvents, runId, nowFn(), livenessWindowSeconds);
616
1325
  },
617
1326
  remediateCas: async (ek, tk, attemptKind, reason) => {
618
- const prNumber = resolvePrNumber(tk);
619
- if (prNumber === null) {
1327
+ // BAPI-487 Requirement 1: resolve the PR number + head SHA via the same
1328
+ // gh-discovery the done-gate pass uses, NOT the never-populated
1329
+ // `pr_bindings` map (which stranded remediation on `remediate: no PR
1330
+ // binding`). resolvePrBinding returns both the number and the current
1331
+ // head SHA, so the separate legacy review-status head-SHA fetch is gone.
1332
+ const prBinding = resolvePrBinding(tk);
1333
+ if (prBinding === null) {
620
1334
  throw new Error(`remediate: no PR binding for ${tk}`);
621
1335
  }
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;
1336
+ const prNumber = prBinding.prNumber;
1337
+ const headSha = prBinding.headSha;
1338
+ // BAPI-500: remediation runs AFTER the fold step in the SAME reconcile
1339
+ // pass. When this ticket folded to `blocked` this tick, the block CAS
1340
+ // already bumped its row_version to snapshot+1, so prefer the confirmed
1341
+ // same-pass post-fold version; fall back to the tick-start snapshot when
1342
+ // the ticket was not folded this tick, then to 0. Sending the stale
1343
+ // tick-start snapshot is exactly the deterministic 400 loop this fixes.
1344
+ const rowVersion = observed.ticket_post_fold_row_versions?.get(tk) ??
1345
+ observed.ticket_row_versions.get(tk) ??
1346
+ 0;
628
1347
  // Deterministic block-state idempotency key: stable for a given durable
629
1348
  // row_version so a same-tick retry replays (409, swallowed); advances
630
- // with the next attempt.
1349
+ // with the next attempt. Derived from the SAME local `rowVersion` sent
1350
+ // as `expected_row_version` so the key and the CAS token stay in lockstep.
631
1351
  const idempotencyKey = `remediate:${ek}:${tk}:${rowVersion}`;
632
1352
  const result = await remediateEpicTicket(access, {
633
1353
  pr_number: prNumber,
@@ -695,6 +1415,24 @@ export async function runEpicTick(options, deps = {}) {
695
1415
  for (const w of reconcileResult.warnings) {
696
1416
  errorLog(`[epic-tick] warning: ${w}`);
697
1417
  }
1418
+ // Step 5.5 (N-3 / BAPI-507): self-complete the run once every approved-plan
1419
+ // ticket is durably `done`. Deferred while any terminal signal folded THIS
1420
+ // tick is still unpersisted (`unfolded_terminal_signals`) so the run closes
1421
+ // on a SUBSEQUENT tick — after Postgres durably reflects all-done — rather
1422
+ // than from a same-tick local projection. Fail-open: a stale CAS (a
1423
+ // concurrent terminal transition already closed the run, or the top-of-tick
1424
+ // terminal guard will) is bounded operational noise, never a tick failure.
1425
+ if (shouldSelfCompleteEpicRun(plan, observed) &&
1426
+ observed.unfolded_terminal_signals.length === 0) {
1427
+ try {
1428
+ await completeEpicRunFn(access, epicRunState.epic_run.epic_run_id);
1429
+ log(`[epic-tick] epic=${epic_key} self-completed: all plan tickets done`);
1430
+ }
1431
+ catch (err) {
1432
+ errorLog(`[epic-tick] self-completion CAS failed for epic=${epic_key}: ` +
1433
+ `${safeDiagnosticMessage(err, "self-complete error")}`);
1434
+ }
1435
+ }
698
1436
  }
699
1437
  else {
700
1438
  log(`[epic-tick] no plan available for epic=${epic_key}; skipping dispatch and merge steps`);
@@ -780,6 +1518,10 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
780
1518
  // Shared closure state populated by fetchPlan and consumed by dispatchSeam.
781
1519
  let cachedPlanVersion = 0;
782
1520
  const automationMap = new Map();
1521
+ // BAPI-507 (N-2): per-ticket normalized declared touched-file set, sourced from
1522
+ // the plan DAG node's `touched_files`. Consumed by dispatchSeam to inject the
1523
+ // declared file scope into the implementation worker's environment.
1524
+ const touchedFilesMap = new Map();
783
1525
  const fetchPlan = async (ek, acc) => {
784
1526
  let response;
785
1527
  try {
@@ -797,18 +1539,32 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
797
1539
  return null;
798
1540
  const dag = response.plan_blob;
799
1541
  cachedPlanVersion = response.plan_version;
800
- // Populate the automation map for the dispatch seam.
1542
+ // Populate the automation + declared-touched-files maps for the dispatch seam.
801
1543
  for (const node of dag.nodes) {
1544
+ const nodeKey = node.ticket_key.trim();
802
1545
  const kind = node.automations?.[0]?.kind;
803
1546
  if (kind) {
804
- automationMap.set(node.ticket_key.trim(), kind);
1547
+ automationMap.set(nodeKey, kind);
1548
+ }
1549
+ // BAPI-507 (N-2): normalize defensively (trim, drop blanks, dedupe, sort;
1550
+ // no filesystem access). Missing/non-array touched_files → unspecified, so
1551
+ // the ticket simply gets no declared scope rather than failing dispatch.
1552
+ const declared = normalizeDeclaredTouchedFiles(node.touched_files);
1553
+ if (declared.length > 0) {
1554
+ touchedFilesMap.set(nodeKey, declared);
805
1555
  }
806
1556
  }
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
- }));
1557
+ // Map DAG nodes to EpicTicketNode, preserving each node's normalized declared
1558
+ // touched-file set (drop automations/status/edges).
1559
+ const tickets = dag.nodes.map((n) => {
1560
+ const nodeKey = n.ticket_key.trim();
1561
+ const declared = touchedFilesMap.get(nodeKey);
1562
+ return {
1563
+ ticket_key: nodeKey,
1564
+ depends_on: (n.depends_on ?? []).map((k) => k.trim()),
1565
+ ...(declared && declared.length > 0 ? { touched_files: declared } : {}),
1566
+ };
1567
+ });
812
1568
  // Recompute the hash locally — do NOT trust the server-returned plan_hash.
813
1569
  // This makes the integrity gate fail-closed: a tampered or drifted blob
814
1570
  // will hash differently than approved_plan_hash and halt the tick.
@@ -836,11 +1592,18 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
836
1592
  // path (ready-set → claim key → dispatch → correlate run_id) still exercises;
837
1593
  // dry-run rows carry no runId, so a synthetic one is substituted for correlation.
838
1594
  const dispatchDryRun = process.env.BAPI_CONDUCTOR_DISPATCH_DRY_RUN === "1";
1595
+ // BAPI-507 (N-2): thread the ticket's declared file scope into the
1596
+ // IMPLEMENTATION dispatch identity only. A `review-tickets` node does not open
1597
+ // an implementation PR, so it gets no file-scope env (Step 8.5).
1598
+ const declaredTouchedFiles = kind === "review-tickets" ? undefined : touchedFilesMap.get(tk);
839
1599
  const identity = {
840
1600
  epic_key: ek,
841
1601
  epic_run_id: ek,
842
1602
  plan_version: cachedPlanVersion,
843
1603
  dispatch_key: buildEpicDispatchKey(ek, tk, cachedPlanVersion, attempt),
1604
+ ...(declaredTouchedFiles && declaredTouchedFiles.length > 0
1605
+ ? { declared_touched_files: declaredTouchedFiles }
1606
+ : {}),
844
1607
  };
845
1608
  const deps = createDefaultStartTicketsDeps();
846
1609
  let runId;
@@ -853,6 +1616,10 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
853
1616
  maxParallel: 1,
854
1617
  auto: true,
855
1618
  reviewOverrides: {},
1619
+ // BAPI-474: the Conductor epic-dispatch path stays git-fetch-free — it
1620
+ // already dispatches into the correct worktree/branch context, so the
1621
+ // fresh-base materialization (an interactive-CLI concern) is unneeded here.
1622
+ noRefreshBase: true,
856
1623
  });
857
1624
  if (!result.ok) {
858
1625
  throw new Error(`review-tickets dispatch failed: ${result.error}`);
@@ -879,8 +1646,14 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
879
1646
  // code. With refreshMain:false the worktree was cut from a STALE local
880
1647
  // `main` (never fetched/ff'd after the predecessor merged on origin), so
881
1648
  // dependents built without the predecessor's code — defeating the whole
882
- // merge-gated handoff. Refresh (fetch origin + ff local base) before cut.
1649
+ // merge-gated handoff. Refresh (fetch origin) before cut.
883
1650
  refreshMain: true,
1651
+ // BAPI-527 (Step 10): unattended conductor dispatch must NOT mutate the
1652
+ // operator's local checkout. `nonMutatingBase: true` resolves origin/<base>
1653
+ // via a fetch-only immutable SHA and cuts new worktrees from it — never a
1654
+ // `git merge --ff-only` / `git branch --force` / branch switch on the host
1655
+ // checkout. Interactive start-tickets keeps the old refreshBaseBranch path.
1656
+ nonMutatingBase: true,
884
1657
  branchOverrides: {},
885
1658
  baseBranch: "main",
886
1659
  conductorEnabled: true,
@@ -941,6 +1714,9 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
941
1714
  // Product directive: the spec re-review is `/review-ticket --auto --rounds=2`.
942
1715
  rounds: 2,
943
1716
  reviewOverrides: {},
1717
+ // BAPI-474: see the sibling dispatchSeam comment — Conductor dispatch stays
1718
+ // git-fetch-free.
1719
+ noRefreshBase: true,
944
1720
  });
945
1721
  if (!result.ok) {
946
1722
  throw new Error(`spec re-review dispatch failed: ${result.error}`);
@@ -1009,7 +1785,6 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
1009
1785
  state: null,
1010
1786
  liveness: null,
1011
1787
  elapsed_ms: 0,
1012
- ambiguous: false,
1013
1788
  context: {},
1014
1789
  };
1015
1790
  // Attempt to extract a ticket key from structured reason strings like
@@ -1022,10 +1797,7 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
1022
1797
  const assessment = {
1023
1798
  classification: "stuck",
1024
1799
  confidence: 1,
1025
- should_escalate: true,
1026
1800
  reason,
1027
- draft_escalation_text: null,
1028
- source: "degraded",
1029
1801
  };
1030
1802
  const idempotencyKey = makeSupervisorIdempotencyKey({
1031
1803
  run_id: ek,