@bridge_gpt/mcp-server 0.2.18 → 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 (81) 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 +1 -1
  7. package/build/backend-warnings.js +44 -0
  8. package/build/claude-settings.js +129 -0
  9. package/build/commands.generated.js +1 -0
  10. package/build/conductor/bridge-api-client.js +7 -7
  11. package/build/conductor/cli.js +65 -12
  12. package/build/conductor/deny-enforcement-preflight.js +96 -0
  13. package/build/conductor/doctor.js +183 -2
  14. package/build/conductor/epic-reconcile.js +9 -1
  15. package/build/conductor/epic-runtime.js +403 -43
  16. package/build/conductor/epic-state.js +7 -0
  17. package/build/conductor/errors.js +115 -3
  18. package/build/conductor/event-accessors.js +28 -10
  19. package/build/conductor/merge-ledger.js +6 -4
  20. package/build/conductor/pr-ci-producer.js +17 -2
  21. package/build/conductor/producer-ledger.js +1 -1
  22. package/build/conductor/store.js +161 -18
  23. package/build/conductor/supervisor-merge.js +32 -5
  24. package/build/conductor/taxonomy.js +8 -0
  25. package/build/conductor/tools.js +28 -6
  26. package/build/conductor/worker-ledger-cli.js +244 -0
  27. package/build/conductor-bin.js +1884 -6917
  28. package/build/doctor.js +8 -0
  29. package/build/executor/cli.js +229 -0
  30. package/build/executor/credentials.js +65 -0
  31. package/build/executor/deps.js +117 -0
  32. package/build/executor/env.js +79 -0
  33. package/build/executor/heartbeat.js +59 -0
  34. package/build/executor/http-client.js +131 -0
  35. package/build/executor/index.js +10 -0
  36. package/build/executor/job-errors.js +55 -0
  37. package/build/executor/job-log-registry.js +110 -0
  38. package/build/executor/job-runner.js +688 -0
  39. package/build/executor/job-types.js +60 -0
  40. package/build/executor/merge-job.js +155 -0
  41. package/build/executor/observation.js +123 -0
  42. package/build/executor/permissions.js +79 -0
  43. package/build/executor/preflight.js +144 -0
  44. package/build/executor/process.js +81 -0
  45. package/build/executor/prompt-spec.js +235 -0
  46. package/build/executor/results.js +134 -0
  47. package/build/executor/resume-pre-spawn.js +179 -0
  48. package/build/executor/runner.js +98 -0
  49. package/build/executor/terminal-mutation.js +34 -0
  50. package/build/executor/test-clock.js +109 -0
  51. package/build/executor/types.js +18 -0
  52. package/build/executor/verdict-artifact.js +53 -0
  53. package/build/executor/viewer-tabs.js +78 -0
  54. package/build/executor/watch-cli.js +113 -0
  55. package/build/executor/worker-command.js +106 -0
  56. package/build/executor/worker-finalization.js +97 -0
  57. package/build/executor/worker-log.js +92 -0
  58. package/build/executor/worktree-gc.js +134 -0
  59. package/build/executor/worktree-inspection.js +86 -0
  60. package/build/executor/worktree.js +103 -0
  61. package/build/index.js +11222 -8544
  62. package/build/mcp-invoke.js +19 -3
  63. package/build/mcp-provisioning.js +31 -25
  64. package/build/mcp-registration-doctor.js +27 -7
  65. package/build/mcp-server-invocation.js +152 -0
  66. package/build/pipelines.generated.js +1 -1
  67. package/build/readme.generated.js +1 -1
  68. package/build/sfcc/reads-site-preference.js +52 -19
  69. package/build/start-tickets-conductor.js +25 -93
  70. package/build/start-tickets-prereqs.js +152 -1
  71. package/build/start-tickets.js +96 -158
  72. package/build/version.generated.js +1 -1
  73. package/build/visual-diff-worker.js +313 -0
  74. package/build/visual-diff.js +632 -0
  75. package/build/worktree-core.js +202 -0
  76. package/package.json +8 -4
  77. package/public/css/main.min.css +39 -0
  78. package/public/css/main.min.css.map +1 -1
  79. package/public/js/main.min.js +7924 -1
  80. package/public/js/main.min.js.map +1 -1
  81. package/smoke-test/SMOKE-TEST.md +2 -1
@@ -24,6 +24,7 @@ import { observePrCiOnce } from "./pr-ci-producer.js";
24
24
  import { runGhCommand } from "./pr-discovery.js";
25
25
  import { isPrMergeConflict, parseGhPrMergeabilityFields, } from "./github-mergeability.js";
26
26
  import { makeLocalMergeExecutor, resolveLocalMergeMethod } from "./local-merge.js";
27
+ import { getHeadSha, getPrNumber } from "./event-accessors.js";
27
28
  import { emitConductorEventIfNew } from "./producer-ledger.js";
28
29
  import { rebuildObservedState, extractWorkerLiveness, } from "./epic-state.js";
29
30
  import { reconcileEpic } from "./epic-reconcile.js";
@@ -130,6 +131,200 @@ export function resolveTicketPrBindingFromGh(ticketKey, options = {}) {
130
131
  }
131
132
  return null;
132
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
+ }
133
328
  /**
134
329
  * For each `ready_for_review` ticket — and (BAPI-487) each `blocked` ticket whose
135
330
  * live PR head has advanced past the head that blocked it — ask the conductor to
@@ -151,14 +346,39 @@ export function resolveTicketPrBindingFromGh(ticketKey, options = {}) {
151
346
  * PR binding, are skipped.
152
347
  */
153
348
  export async function runConductorDoneGatePass(ticketStatuses, deps) {
349
+ const backstopEnabled = deps.dispatchedBackstopPolicy?.enabled === true;
154
350
  for (const [ticketKey, status] of ticketStatuses) {
155
- if (status !== "ready_for_review" && status !== "blocked")
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))
156
357
  continue;
157
- const prBinding = deps.resolvePrBinding(ticketKey);
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);
158
364
  if (prBinding === null) {
159
365
  deps.log(`[epic-tick] done-gate poll for ${ticketKey}: skipped (no PR binding)`);
160
366
  continue;
161
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
+ }
162
382
  // BAPI-487: gate a `blocked` ticket's re-evaluation on its head advancing.
163
383
  if (status === "blocked") {
164
384
  const blockedHead = deps.resolveBlockedHeadSha?.(ticketKey) ?? null;
@@ -175,12 +395,13 @@ export async function runConductorDoneGatePass(ticketStatuses, deps) {
175
395
  `superseded by current head ${prBinding.headSha}; re-evaluating`);
176
396
  }
177
397
  }
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.
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.
184
405
  const runId = deps.resolveRunId?.(ticketKey) ?? null;
185
406
  const workerId = deps.resolveWorkerId?.(ticketKey) ?? null;
186
407
  // BAPI-494: a `ready_for_review` PR that is already un-mergeable
@@ -439,7 +660,6 @@ export async function runEpicTick(options, deps = {}) {
439
660
  const isParseWaitEnabled = pamConfig?.enabled === true;
440
661
  if (isParseWaitEnabled) {
441
662
  const maxWaitMs = typeof pamConfig?.max_wait_ms === "number" ? pamConfig.max_wait_ms : 10 * 60 * 1000;
442
- const settleMs = 5000;
443
663
  const fetchParseStatusFn = deps.fetchParseStatus ?? fetchParseStatus;
444
664
  const triggerParseFn = deps.triggerParse ?? triggerRepositoryParse;
445
665
  for (let i = 0; i < observed.unfolded_terminal_signals.length; i++) {
@@ -476,26 +696,52 @@ export async function runEpicTick(options, deps = {}) {
476
696
  e.run_id === mergeRunId &&
477
697
  new Date(e.time).getTime() >= mergeTimeMs);
478
698
  const elapsedMs = nowFn() - mergeTimeMs;
479
- // Budget exhaustion (measured from the durable merge.succeeded time):
480
- // escalate once (gated on the durable Postgres status, not an in-memory
481
- // flag) then block. A subsequent tick finds it already blocked and drops
482
- // the signal without re-escalating or re-CASing.
483
- 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) => {
484
723
  if (currentPgStatus === "blocked") {
485
724
  observed.ticket_statuses.set(ticketKey, "blocked");
486
725
  observed.unfolded_terminal_signals.splice(i, 1);
487
726
  i -= 1;
488
- continue;
727
+ return;
489
728
  }
490
- await escalateOnce(epic_key, `parse-after-merge budget exhausted for ${ticketKey}`);
729
+ await escalateOnce(epic_key, reason);
491
730
  signal.next_status = "blocked";
492
731
  observed.ticket_statuses.set(ticketKey, "blocked");
493
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}`);
494
738
  continue;
495
739
  }
496
740
  if (!parseTriggeredEvent) {
497
- // No durable trigger for this merge yet — fire the parse and record the
498
- // 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.
499
745
  try {
500
746
  await triggerParseFn(access);
501
747
  emitConductorEventFn({
@@ -520,16 +766,20 @@ export async function runEpicTick(options, deps = {}) {
520
766
  run_id: mergeRunId ?? undefined,
521
767
  commit_sha: mergeHeadSha,
522
768
  });
523
- 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();
524
771
  }
525
772
  catch (err) {
526
773
  const safeMsg = err instanceof Error ? err.constructor.name : "trigger error";
527
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();
528
778
  }
529
- revertSignal();
530
779
  continue;
531
780
  }
532
- // 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).
533
783
  let parseStatusResult;
534
784
  try {
535
785
  parseStatusResult = await fetchParseStatusFn(access);
@@ -537,30 +787,84 @@ export async function runEpicTick(options, deps = {}) {
537
787
  catch (err) {
538
788
  const safeMsg = err instanceof Error ? err.constructor.name : "fetch error";
539
789
  errorLog(`[epic-tick] parse-status check failed (${safeMsg}) for ${ticketKey}; will retry next tick`);
540
- revertSignal();
790
+ // Transient status-fetch failure: hold in parse_pending for the next tick.
791
+ holdOrEnterParsePending();
541
792
  continue;
542
793
  }
543
- if (parseStatusResult.status === "in_progress") {
544
- // Parse still runninghold.
545
- 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}`);
546
829
  continue;
547
830
  }
548
- // status === "idle". If the marker is younger than the settle window, the
549
- // async parse may not have acquired its lock yet (idle is a false negative)
550
- // hold one more tick. Past the settle window, idle means the post-merge
551
- // parse has completed (or finished instantly), so let the merge.succeeded
552
- // signal proceed to CAS → done.
553
- //
554
- // KNOWN LIMITATION: /jira/parse-status only reports {in_progress, idle} —
555
- // it cannot distinguish a FAILED parse from a completed one, so an instant
556
- // parse failure folds to `done` here. Pre-existing; tracked as P2 (surface
557
- // a parse failure/last_error from parse-status).
558
- const msSinceTrigger = nowFn() - new Date(parseTriggeredEvent.time).getTime();
559
- if (msSinceTrigger < settleMs) {
560
- 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.
561
861
  continue;
562
862
  }
563
- // 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();
564
868
  }
565
869
  }
566
870
  // Step 4: Fetch + assert plan integrity (only if fetchPlan injected)
@@ -726,8 +1030,11 @@ export async function runEpicTick(options, deps = {}) {
726
1030
  // caught per-ticket so it can never crash or block the reconcile loop, and
727
1031
  // `observePrCiOnce` emits nothing on an unavailable/ambiguous/stale-head signal.
728
1032
  // 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.
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.
731
1038
  const runIdToWorkerId = new Map();
732
1039
  for (const ev of localEvents) {
733
1040
  if (ev.run_id && ev.worker_id && !runIdToWorkerId.has(ev.run_id)) {
@@ -739,11 +1046,54 @@ export async function runEpicTick(options, deps = {}) {
739
1046
  const rid = resolveTicketRunId(tk);
740
1047
  if (rid && runIdToWorkerId.has(rid))
741
1048
  return runIdToWorkerId.get(rid);
742
- return `conductor:${tk}`;
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;
743
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
+ });
744
1089
  await runConductorDoneGatePass(observed.ticket_statuses, {
745
1090
  observePrCi: observePrCiSeamFn,
746
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,
747
1097
  // BAPI-487: re-evaluate a blocked ticket only when its PR head advanced
748
1098
  // past the head recorded on its latest blocking signal.
749
1099
  resolveBlockedHeadSha: (tk) => observed.ticket_blocked_heads?.get(tk) ?? null,
@@ -900,8 +1250,12 @@ export async function runEpicTick(options, deps = {}) {
900
1250
  method: resolveLocalMergeMethod(localCfg.method),
901
1251
  approvalRequired: localCfg.approval_required === true,
902
1252
  }, { env: process.env }),
1253
+ // BAPI-525 Change A: durable attribution for a worker_id-less gate.met.
1254
+ resolveWorkerIdForGateEvent,
903
1255
  });
904
1256
  }
1257
+ // Default (backend-route) path, with durable merge attribution wired in.
1258
+ return processGateMetMerge(acc, event, { resolveWorkerIdForGateEvent });
905
1259
  }
906
1260
  return processMergeFn(acc, event);
907
1261
  },
@@ -1292,8 +1646,14 @@ export async function buildProductionEpicRuntimeDeps(epicKey) {
1292
1646
  // code. With refreshMain:false the worktree was cut from a STALE local
1293
1647
  // `main` (never fetched/ff'd after the predecessor merged on origin), so
1294
1648
  // dependents built without the predecessor's code — defeating the whole
1295
- // merge-gated handoff. Refresh (fetch origin + ff local base) before cut.
1649
+ // merge-gated handoff. Refresh (fetch origin) before cut.
1296
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,
1297
1657
  branchOverrides: {},
1298
1658
  baseBranch: "main",
1299
1659
  conductorEnabled: true,
@@ -46,6 +46,13 @@ const NON_TERMINAL_STATUSES = new Set([
46
46
  // reviewing ticket is mid-flight, not ready. Stranded reviewing tickets are
47
47
  // re-driven by reconcile's dedicated reviewing re-entry branch.
48
48
  "reviewing",
49
+ // BAPI-527: asynchronous post-merge parse. Non-terminal so the durable
50
+ // merge.succeeded signal keeps re-folding across ticks until parse-after-merge
51
+ // resolves it to `done` (terminal parse success) or `blocked` (permanent parse
52
+ // failure/timeout). computeReadySet only ever returns `planned`/`ready`, so a
53
+ // parse_pending ticket is never (re)dispatched, and only `done` satisfies a
54
+ // dependency — a merged-but-not-yet-parsed predecessor cannot unblock dependents.
55
+ "parse_pending",
49
56
  ]);
50
57
  const TERMINAL_SIGNAL_TYPES = new Set([
51
58
  "gate.met",