@bridge_gpt/mcp-server 0.2.50 → 0.2.52

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 (76) hide show
  1. package/README.md +24 -8
  2. package/build/agent-capabilities/probe-context.js +15 -7
  3. package/build/agent-capabilities/probes.js +42 -6
  4. package/build/agent-launchers/claude-executor-adapter.js +98 -14
  5. package/build/commands.generated.js +1 -1
  6. package/build/conduct-epic/bridge-client.js +115 -1
  7. package/build/conduct-epic/cli.js +351 -33
  8. package/build/conduct-epic/cut-protocol.js +65 -0
  9. package/build/conductor/bridge-api-client.js +171 -5
  10. package/build/conductor/deny-enforcement-preflight.js +107 -10
  11. package/build/conductor/local-merge.js +170 -11
  12. package/build/conductor-bin.js +2 -2
  13. package/build/connect-bitbucket-api.js +370 -0
  14. package/build/connect-bitbucket.js +437 -0
  15. package/build/docs.generated.js +1 -1
  16. package/build/doctor.js +230 -1
  17. package/build/drive-epic.js +423 -11
  18. package/build/env-file-link.js +164 -0
  19. package/build/epic-integration-pr.js +290 -0
  20. package/build/executor/cli.js +41 -6
  21. package/build/executor/deps.js +5 -1
  22. package/build/executor/env-file-guard.js +113 -0
  23. package/build/executor/env.js +78 -1
  24. package/build/executor/heartbeat.js +9 -0
  25. package/build/executor/http-client.js +90 -22
  26. package/build/executor/job-errors.js +43 -2
  27. package/build/executor/job-runner.js +137 -29
  28. package/build/executor/merge-job.js +102 -6
  29. package/build/executor/permissions.js +106 -0
  30. package/build/executor/preflight.js +38 -13
  31. package/build/executor/resume-pre-spawn.js +2 -1
  32. package/build/executor/runner.js +175 -4
  33. package/build/executor/service-unit.js +15 -0
  34. package/build/executor/terminal-mutation.js +22 -1
  35. package/build/executor/types.js +86 -0
  36. package/build/executor/worker-command.js +21 -5
  37. package/build/executor/worker-guard-hook.js +939 -0
  38. package/build/executor/worker-log.js +56 -0
  39. package/build/executor/worktree.js +11 -0
  40. package/build/git-reachability.js +147 -0
  41. package/build/index.js +535 -95
  42. package/build/install-bridge.js +95 -0
  43. package/build/pipelines.generated.js +10 -2
  44. package/build/plan-epic-conductor-eligibility.js +213 -0
  45. package/build/plane/cli.js +78 -15
  46. package/build/plane/defaults.js +165 -0
  47. package/build/plane/manifest.js +63 -8
  48. package/build/plane/member-logs.js +6 -0
  49. package/build/plane/member-roster.js +195 -11
  50. package/build/plane/preflight.js +43 -0
  51. package/build/plane/shutdown.js +25 -3
  52. package/build/plane/status.js +11 -0
  53. package/build/plane/supervisor.js +343 -14
  54. package/build/plane/test-fakes.js +43 -0
  55. package/build/plane/types.js +82 -11
  56. package/build/pr-base-contract.js +20 -0
  57. package/build/readme.generated.js +1 -1
  58. package/build/review-synthesis-config.js +60 -0
  59. package/build/scripts/executor-protocol-contract-driver.js +311 -0
  60. package/build/setup-epic.js +592 -139
  61. package/build/sfcc/log-query.js +2 -1
  62. package/build/sfcc/reads-custom-object-def.js +10 -13
  63. package/build/sfcc/reads-site-preference.js +5 -5
  64. package/build/sfcc/reads-system-object.js +4 -4
  65. package/build/sfcc/writes-custom-object-def.js +7 -7
  66. package/build/sfcc/writes-site-preference.js +4 -3
  67. package/build/sfcc/writes-system-object.js +7 -6
  68. package/build/start-tickets-conductor.js +11 -2
  69. package/build/start-tickets.js +69 -2
  70. package/build/version.generated.js +3 -3
  71. package/build/worker-containment-diagnostic.js +97 -0
  72. package/build/worker-guard-hook-bin.js +6 -0
  73. package/docs/CONDUCTOR.md +27 -0
  74. package/docs/install/mcp-tool-integrations.md +3 -2
  75. package/package.json +5 -3
  76. package/pipelines/plan-epic.json +5 -0
@@ -14,10 +14,10 @@
14
14
  * `spec_review`, plus per-job worker-log tee, watch registry, and optional
15
15
  * read-only viewer tabs wired around every spawned worker.
16
16
  */
17
- import { rm } from "node:fs/promises";
17
+ import { rm, readdir, lstat, unlink } from "node:fs/promises";
18
18
  import os from "node:os";
19
19
  import { runHeartbeatLoop } from "./heartbeat.js";
20
- import { isExecutorNamedError, toExecutorFailure, secretFreeErrorMessage, MissingVerdictArtifact, PreSpawnVerification, RequiredMcpRegistration, McpSurfaceMismatch, WorkerStartupFatal, WORKER_STARTUP_FATAL_MESSAGE, formatMcpSurfaceMismatch, ClaudeNotAuthenticated, CLAUDE_NOT_AUTHENTICATED_MESSAGE, ExecutorAdapterUnavailable, ExecutorNamedError, WorktreeBusy, StaleArtifactCleanupFailed, WORKTREE_BUSY_MESSAGE, } from "./job-errors.js";
20
+ import { isExecutorNamedError, toExecutorFailure, secretFreeErrorMessage, MissingVerdictArtifact, PreSpawnVerification, RequiredMcpRegistration, McpSurfaceMismatch, WorkerStartupFatal, WORKER_STARTUP_FATAL_MESSAGE, formatMcpSurfaceMismatch, ClaudeNotAuthenticated, CLAUDE_NOT_AUTHENTICATED_MESSAGE, ExecutorAdapterUnavailable, ExecutorNamedError, WorktreeBusy, StaleArtifactCleanupFailed, WORKTREE_BUSY_MESSAGE, WorkerEnvFilePresent, WORKER_ENV_FILE_PRESENT_MESSAGE, } from "./job-errors.js";
21
21
  import { buildConductorMergeAccessForExecutorJob, buildDefaultMergeLocalDeps, runExecutorMergeJob, MERGE_RETRYABLE, MERGE_FAILED, } from "./merge-job.js";
22
22
  import { createObservationState } from "./observation.js";
23
23
  import { getWorktreeMcpRegistrationTargets, normalizeWorktreePathForRegistration, provisionMcpRegistrationForWorktree, } from "../mcp-provisioning.js";
@@ -27,9 +27,13 @@ import { resolveExecutorAgentId } from "./agent-identity.js";
27
27
  import { createProcessTerminationController, runProcessWithTimeout } from "./process.js";
28
28
  import { prepareResumeSpawn } from "./resume-pre-spawn.js";
29
29
  import { renderPromptSpecPrompt } from "./prompt-spec.js";
30
+ import { defaultResolveBinPath } from "../start-tickets-conductor.js";
31
+ /** The packaged bin basename of the deterministic worker guard (BAPI-1020). */
32
+ const WORKER_GUARD_BIN_FILENAME = "worker-guard-hook-bin.js";
30
33
  import { buildGenericSuccessResult, buildSmokeResult, commitResidue, readCompletionArtifacts, resolveJobTimeoutSeconds, } from "./results.js";
31
34
  import { sendTerminalMutationWithRetry } from "./terminal-mutation.js";
32
35
  import { removeStaleVerdictArtifacts } from "./stale-artifacts.js";
36
+ import { stripWorkerEnvFiles } from "./env-file-guard.js";
33
37
  import { isVerdictJobType, readVerdictArtifact } from "./verdict-artifact.js";
34
38
  import { createWorkerLogTee, closeWorkerLogTee, distinctWorkerLogSessionIds, formatWorkerLogSessionInvariantWarning, teeAsyncIterable, } from "./worker-log.js";
35
39
  import { acquireExecutorWorktreeLock, } from "./worktree-lock.js";
@@ -392,7 +396,7 @@ async function checkAdapterMcpAdvisoryForPreparedWorktree(worktreePath, deps, ad
392
396
  * `controls.signal` (overall-timeout abort) is threaded into local merge deps
393
397
  * without changing the local `gh` credential model.
394
398
  */
395
- export async function defaultRunMergeForClaimed(job, deps, _options, controls) {
399
+ export async function defaultRunMergeForClaimed(job, deps, options, controls) {
396
400
  const accessResult = await buildConductorMergeAccessForExecutorJob({
397
401
  env: deps.env,
398
402
  cwd: deps.cwd,
@@ -419,6 +423,12 @@ export async function defaultRunMergeForClaimed(job, deps, _options, controls) {
419
423
  // Thread the overall-timeout abort signal into local merge deps WITHOUT
420
424
  // changing the local `gh` credential model (the signal is not a credential).
421
425
  localMergeDeps: buildDefaultMergeLocalDeps(deps, controls?.signal),
426
+ // BAPI-951: the executor's own configured base — resolveExecutorJobBaseBranch
427
+ // (called inside runExecutorMergeJob) resolves the CHILD PR's base from the
428
+ // job's persisted run base, falling back to this value for a legacy job.
429
+ repositoryBaseBranch: options.baseBranch,
430
+ epicIntegrationAdvisoryLog: deps.errorLog,
431
+ cwd: deps.cwd,
422
432
  });
423
433
  }
424
434
  /** An in-memory owned process for the no-op `smoke` acceptance job. */
@@ -493,6 +503,18 @@ async function superviseProcess(params) {
493
503
  isDoneRef.value = true;
494
504
  const loopOutcome = await hb;
495
505
  params.observation.setExitCode(result.exitCode);
506
+ if (loopOutcome.kind === "auth_fatal") {
507
+ // BAPI-1021 (AC-5): notify the runner immediately with ONLY the bounded
508
+ // identifiers — never carried onto `result` itself, so the process's own
509
+ // classification/completion handling is unaffected and the job continues
510
+ // to drain normally.
511
+ params.control?.reportAuthFatal?.({
512
+ jobId: params.job.id,
513
+ repoName: params.job.repo_name,
514
+ category: "heartbeat",
515
+ httpStatus: loopOutcome.httpStatus,
516
+ });
517
+ }
496
518
  return loopOutcome.kind === "server_stop"
497
519
  ? { ...result, serverStopRequested: true }
498
520
  : result;
@@ -509,7 +531,33 @@ function terminalToRunResult(terminal, successStatus) {
509
531
  return { status: "abandoned", reason: "deadman" };
510
532
  case "skipped":
511
533
  return { status: "abandoned", reason: "already_abandoned" };
534
+ case "auth_fatal":
535
+ // BAPI-1021 (AC-5): a halted authentication/access condition, not an
536
+ // ordinary job failure or a lost/stale claim — `reportAuthFatal` (called
537
+ // by the wrapper below, before this mapping runs) is what actually
538
+ // notifies the runner.
539
+ return { status: "abandoned", reason: "auth_fatal" };
540
+ }
541
+ }
542
+ /**
543
+ * BAPI-1021 (AC-5): send a terminal mutation via
544
+ * {@link sendTerminalMutationWithRetry}, and — on an `auth_fatal` outcome —
545
+ * notify the runner exactly once via `control.reportAuthFatal`, carrying ONLY
546
+ * the job ID, repository, operation category, and HTTP status. Every other
547
+ * outcome passes through completely unchanged.
548
+ */
549
+ async function sendTerminalMutationWithAuthReport(params, job, control) {
550
+ const terminal = await sendTerminalMutationWithRetry(params);
551
+ if (terminal.outcome === "auth_fatal") {
552
+ const notice = {
553
+ jobId: job.id,
554
+ repoName: job.repo_name,
555
+ category: params.kind,
556
+ httpStatus: terminal.httpStatus,
557
+ };
558
+ control?.reportAuthFatal?.(notice);
512
559
  }
560
+ return terminal;
513
561
  }
514
562
  /** Dispatch and run a single claimed job. */
515
563
  export async function runClaimedJob(job, httpClient, options, deps, _report, seams = {},
@@ -543,10 +591,10 @@ control) {
543
591
  // ensure/recreate or worker-spawn logic — it ensures no worktree and spawns no
544
592
  // worker (TDD §5/§9).
545
593
  if (job.job_type === "merge") {
546
- return runMergeJob(job, httpClient, options, deps, ownership, observation, seams);
594
+ return runMergeJob(job, httpClient, options, deps, ownership, observation, seams, control);
547
595
  }
548
596
  if (job.job_type === "smoke") {
549
- return runSmokeJob(job, httpClient, options, deps, ownership, observation);
597
+ return runSmokeJob(job, httpClient, options, deps, ownership, observation, control);
550
598
  }
551
599
  // `implement`/`resume`/`spec_review` and the recovery jobs (`remediate`/
552
600
  // `ci_fix`/`rebase`) all run through the real headless-spawn path — dispatched
@@ -634,7 +682,7 @@ function createMergeFlowProcess(startMerge) {
634
682
  * ignored. The local merge executor's outcome otherwise drives `/complete`
635
683
  * (success) or `/fail`; stale/retry/invalid terminal outcomes reuse T3a handling.
636
684
  */
637
- async function runMergeJob(job, httpClient, options, deps, ownership, observation, seams) {
685
+ async function runMergeJob(job, httpClient, options, deps, ownership, observation, seams, control) {
638
686
  // Resolve + enforce payload.timeout_seconds BEFORE starting any merge work.
639
687
  const timeout = resolveJobTimeoutSeconds(job, options.defaultJobTimeoutSeconds);
640
688
  if (!timeout.ok) {
@@ -658,6 +706,7 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
658
706
  proc: flow.proc,
659
707
  timeoutSeconds,
660
708
  collectTelemetry: async () => ({}),
709
+ control,
661
710
  });
662
711
  // Ownership abandonment (stale-claim / dead-man) wins: never send a terminal
663
712
  // mutation for a claim we no longer own, and never process a late outcome.
@@ -667,7 +716,7 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
667
716
  // Overall merge flow timed out: fail the active claim as RETRYABLE and ignore
668
717
  // any later merge promise resolution (the abort has already been signalled).
669
718
  if (procResult.classification === "timeout") {
670
- const terminal = await sendTerminalMutationWithRetry({
719
+ const terminal = await sendTerminalMutationWithAuthReport({
671
720
  kind: "fail",
672
721
  send: () => httpClient.fail(job, {
673
722
  error_kind: MERGE_RETRYABLE,
@@ -679,13 +728,13 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
679
728
  options,
680
729
  ownership,
681
730
  log: deps.log,
682
- });
731
+ }, job, control);
683
732
  return terminalToRunResult(terminal, "failed");
684
733
  }
685
734
  // A thrown error inside the merge flow maps to a bounded MergeFailed.
686
735
  const flowError = flow.getError();
687
736
  if (flowError !== undefined) {
688
- const terminal = await sendTerminalMutationWithRetry({
737
+ const terminal = await sendTerminalMutationWithAuthReport({
689
738
  kind: "fail",
690
739
  send: () => httpClient.fail(job, {
691
740
  error_kind: MERGE_FAILED,
@@ -697,14 +746,14 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
697
746
  options,
698
747
  ownership,
699
748
  log: deps.log,
700
- });
749
+ }, job, control);
701
750
  return terminalToRunResult(terminal, "failed");
702
751
  }
703
752
  const outcome = flow.getOutcome();
704
753
  if (outcome === undefined) {
705
754
  // Clean process result with no stored outcome — an internal invariant break;
706
755
  // fail loud rather than silently reporting success.
707
- const terminal = await sendTerminalMutationWithRetry({
756
+ const terminal = await sendTerminalMutationWithAuthReport({
708
757
  kind: "fail",
709
758
  send: () => httpClient.fail(job, {
710
759
  error_kind: MERGE_FAILED,
@@ -716,7 +765,7 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
716
765
  options,
717
766
  ownership,
718
767
  log: deps.log,
719
- });
768
+ }, job, control);
720
769
  return terminalToRunResult(terminal, "failed");
721
770
  }
722
771
  if (outcome.ok) {
@@ -727,24 +776,24 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
727
776
  result: outcome.result,
728
777
  telemetry: observation.snapshot(),
729
778
  };
730
- const terminal = await sendTerminalMutationWithRetry({
779
+ const terminal = await sendTerminalMutationWithAuthReport({
731
780
  kind: "complete",
732
781
  send: () => httpClient.complete(job, completion),
733
782
  deps,
734
783
  options,
735
784
  ownership,
736
785
  log: deps.log,
737
- });
786
+ }, job, control);
738
787
  return terminalToRunResult(terminal, "completed");
739
788
  }
740
- const terminal = await sendTerminalMutationWithRetry({
789
+ const terminal = await sendTerminalMutationWithAuthReport({
741
790
  kind: "fail",
742
791
  send: () => httpClient.fail(job, { ...outcome.failure, telemetry: observation.snapshot() }),
743
792
  deps,
744
793
  options,
745
794
  ownership,
746
795
  log: deps.log,
747
- });
796
+ }, job, control);
748
797
  return terminalToRunResult(terminal, "failed");
749
798
  }
750
799
  /**
@@ -761,7 +810,7 @@ async function runMergeJob(job, httpClient, options, deps, ownership, observatio
761
810
  * job to give it something to register MCP into would be a materially larger,
762
811
  * unratified change to that architectural boundary, not a wiring task.
763
812
  */
764
- async function runSmokeJob(job, httpClient, options, deps, ownership, observation) {
813
+ async function runSmokeJob(job, httpClient, options, deps, ownership, observation, control) {
765
814
  const timeout = resolveJobTimeoutSeconds(job, options.defaultJobTimeoutSeconds);
766
815
  if (!timeout.ok) {
767
816
  // Consistent with runSpawnJob: a timeout-contract failure fails the job
@@ -790,6 +839,7 @@ async function runSmokeJob(job, httpClient, options, deps, ownership, observatio
790
839
  proc,
791
840
  timeoutSeconds,
792
841
  collectTelemetry: async () => ({}),
842
+ control,
793
843
  });
794
844
  if (ownership.abandoned) {
795
845
  return { status: "abandoned", reason: ownership.abandonReason };
@@ -807,14 +857,14 @@ async function runSmokeJob(job, httpClient, options, deps, ownership, observatio
807
857
  result,
808
858
  telemetry: observation.snapshot(),
809
859
  };
810
- const terminal = await sendTerminalMutationWithRetry({
860
+ const terminal = await sendTerminalMutationWithAuthReport({
811
861
  kind: "complete",
812
862
  send: () => httpClient.complete(job, completion),
813
863
  deps,
814
864
  options,
815
865
  ownership,
816
866
  log: deps.log,
817
- });
867
+ }, job, control);
818
868
  return terminalToRunResult(terminal, "completed");
819
869
  }
820
870
  /**
@@ -1278,6 +1328,14 @@ async function runPreparedSpawn(params) {
1278
1328
  worktreePath,
1279
1329
  baseBranch: effectiveBaseBranch,
1280
1330
  homedir: deps.homedir(),
1331
+ // BAPI-1020 — the prepared worktree's own branch and the packaged guard bin.
1332
+ // `branch` comes from `prepareSpawn`'s `wt.branch`, i.e. the worktree the
1333
+ // executor actually created, never from job payload text: the guard decides
1334
+ // "is this push destination mine?" by comparing against it, so a value the
1335
+ // payload could influence would let a job nominate its own safe branch.
1336
+ workerBranch: branch,
1337
+ workerGuardHookBinPath: (seams.resolveWorkerGuardBinPath ??
1338
+ (() => defaultResolveBinPath(WORKER_GUARD_BIN_FILENAME)))(),
1281
1339
  });
1282
1340
  // Log on WARNING PRESENCE, not on `!ok` (BAPI-815/R2). The git-exclude
1283
1341
  // hygiene step can degrade without failing the deny layer — present-but-
@@ -1348,6 +1406,13 @@ async function runPreparedSpawn(params) {
1348
1406
  writeFile: deps.writeFile,
1349
1407
  appendFile: deps.appendFile,
1350
1408
  platform: deps.platform,
1409
+ // BAPI-1021 (AC-6): the archive seams. All optional on `ExecutorDeps`
1410
+ // already, so this is source-compatible with every existing deps
1411
+ // builder — archival simply does not run when either is absent.
1412
+ rename: deps.rename,
1413
+ stat: deps.stat,
1414
+ now: deps.now,
1415
+ errorLog: deps.errorLog,
1351
1416
  });
1352
1417
  }
1353
1418
  catch {
@@ -1470,6 +1535,10 @@ async function runPreparedSpawn(params) {
1470
1535
  mcpConfigPath: mcpContract.configPath,
1471
1536
  effectiveBaseBranch,
1472
1537
  indexScope,
1538
+ // BAPI-1020 — becomes `BAPI_WORKER_BRANCH` in the worker environment, which
1539
+ // is the deterministic guard's only source of branch identity. Same
1540
+ // provenance as the deny-provisioning value above: the prepared worktree.
1541
+ workerBranch: branch,
1473
1542
  parentEnv: deps.env,
1474
1543
  });
1475
1544
  assertUsableSpawnShape(spawnShape, adapter);
@@ -1508,6 +1577,44 @@ async function runPreparedSpawn(params) {
1508
1577
  return { status: "failed", reason: "executor_adapter_unavailable" };
1509
1578
  }
1510
1579
  const mcpSurface = createMcpSurfaceObserver(mcpContract.expectedServerNames, (line) => initParsing.value.parseInitEvent(line));
1580
+ // --- Worker env-file strip (FAIL-CLOSED, BAPI-1019/A.4) ---------------
1581
+ // THE LAST thing that touches the worktree before a worker exists. Placed
1582
+ // after the deny layer, after MCP provisioning and its verification, and after
1583
+ // spawn-shape/observer construction precisely so nothing that runs later can
1584
+ // re-introduce a file the guard just confirmed gone. Everything between this
1585
+ // call and `deps.spawnProcess` below is pure in-memory work.
1586
+ //
1587
+ // Fail-CLOSED, following `StaleArtifactCleanupFailed` rather than the
1588
+ // fail-OPEN deny-layer posture two hundred lines up. The deny layer shrinks a
1589
+ // safety margin when it degrades; this gate is the margin. Architecture Miss 28
1590
+ // was a worker that reached the operator's real database through a copied
1591
+ // `.env` and truncated it — so "could not confirm the worktree is clean" must
1592
+ // refuse, never proceed and hope.
1593
+ const stripEnvFiles = seams.stripWorkerEnvFiles ?? stripWorkerEnvFiles;
1594
+ const stripped = await stripEnvFiles(worktreePath, {
1595
+ readdir: (dirPath) => readdir(dirPath),
1596
+ // `lstat`/`unlink` only — the guard must never resolve a symlink, because an
1597
+ // entry pointing at the main checkout would resolve to the operator's real
1598
+ // `.env` and unlinking THAT would delete it. The narrow bag is the
1599
+ // enforcement: no `stat`, `readlink`, or `readFile` is reachable from here.
1600
+ lstat: (target) => lstat(target),
1601
+ unlink: (target) => unlink(target),
1602
+ platform: deps.platform,
1603
+ });
1604
+ if (!stripped.ok) {
1605
+ await closeWorkerLog();
1606
+ await finalizeRegistry();
1607
+ // Exactly ONE bounded stderr line: the job id and the closed guard category.
1608
+ // No worktree path (it carries a username), no removed filename, no errno, no
1609
+ // exception text.
1610
+ deps.errorLog(`[executor] job ${job.id}: refusing to spawn, worker env files unconfirmed: ${stripped.category}`);
1611
+ await httpClient.fail(job, {
1612
+ error_kind: WorkerEnvFilePresent,
1613
+ error_message: WORKER_ENV_FILE_PRESENT_MESSAGE,
1614
+ classification: "crashed",
1615
+ });
1616
+ return { status: "failed", reason: "worker_env_file_present" };
1617
+ }
1511
1618
  let proc;
1512
1619
  try {
1513
1620
  proc = deps.spawnProcess(spawnShape.executable, spawnShape.argv, {
@@ -1577,6 +1684,7 @@ async function runPreparedSpawn(params) {
1577
1684
  // and an MCP containment refusal all converge on one `SIGTERM` and one grace
1578
1685
  // timer for this child (BAPI-828).
1579
1686
  terminationController,
1687
+ control,
1580
1688
  });
1581
1689
  // --- Adapter lifecycle (EXPLICIT, BAPI-781) ---------------------------
1582
1690
  // Consumed rather than assumed. Claude declares `{ kind: "none" }` and so
@@ -1736,14 +1844,14 @@ async function runPreparedSpawn(params) {
1736
1844
  classification: "crashed",
1737
1845
  };
1738
1846
  await finalizeRegistry();
1739
- const terminal = await sendTerminalMutationWithRetry({
1847
+ const terminal = await sendTerminalMutationWithAuthReport({
1740
1848
  kind: "fail",
1741
1849
  send: () => httpClient.fail(job, { ...failure, ...commitResidue(git), telemetry: observation.snapshot() }),
1742
1850
  deps,
1743
1851
  options,
1744
1852
  ownership,
1745
1853
  log: deps.log,
1746
- });
1854
+ }, job, control);
1747
1855
  return terminalToRunResult(terminal, "failed");
1748
1856
  }
1749
1857
  // BAPI-528 Wave 3 M2: verdict jobs (`spec_review`) also produce a
@@ -1763,14 +1871,14 @@ async function runPreparedSpawn(params) {
1763
1871
  ...commitResidue(git),
1764
1872
  telemetry: observation.snapshot(),
1765
1873
  };
1766
- const terminal = await sendTerminalMutationWithRetry({
1874
+ const terminal = await sendTerminalMutationWithAuthReport({
1767
1875
  kind: "complete",
1768
1876
  send: () => httpClient.complete(job, completion),
1769
1877
  deps,
1770
1878
  options,
1771
1879
  ownership,
1772
1880
  log: deps.log,
1773
- });
1881
+ }, job, control);
1774
1882
  await finalizeRegistry();
1775
1883
  return terminalToRunResult(terminal, "completed");
1776
1884
  }
@@ -1800,7 +1908,7 @@ async function runPreparedSpawn(params) {
1800
1908
  });
1801
1909
  if (!finalization.ok) {
1802
1910
  await finalizeRegistry();
1803
- const terminal = await sendTerminalMutationWithRetry({
1911
+ const terminal = await sendTerminalMutationWithAuthReport({
1804
1912
  kind: "fail",
1805
1913
  send: () => httpClient.fail(job, {
1806
1914
  ...finalization.failure,
@@ -1811,7 +1919,7 @@ async function runPreparedSpawn(params) {
1811
1919
  options,
1812
1920
  ownership,
1813
1921
  log: deps.log,
1814
- });
1922
+ }, job, control);
1815
1923
  return terminalToRunResult(terminal, "failed");
1816
1924
  }
1817
1925
  // BAPI-862: attach the pull request the EXECUTOR verified. `pr_url` was never
@@ -1834,14 +1942,14 @@ async function runPreparedSpawn(params) {
1834
1942
  ...commitResidue(git),
1835
1943
  telemetry: observation.snapshot(),
1836
1944
  };
1837
- const terminal = await sendTerminalMutationWithRetry({
1945
+ const terminal = await sendTerminalMutationWithAuthReport({
1838
1946
  kind: "complete",
1839
1947
  send: () => httpClient.complete(job, completion),
1840
1948
  deps,
1841
1949
  options,
1842
1950
  ownership,
1843
1951
  log: deps.log,
1844
- });
1952
+ }, job, control);
1845
1953
  await finalizeRegistry();
1846
1954
  return terminalToRunResult(terminal, "completed");
1847
1955
  }
@@ -1863,7 +1971,7 @@ async function runPreparedSpawn(params) {
1863
1971
  const authDetection = adapter.authFailureDetection;
1864
1972
  const authFailure = authDetection?.supported === true &&
1865
1973
  authDetection.value.classify(procResult.stdoutExcerpt).notAuthenticated;
1866
- const terminal = await sendTerminalMutationWithRetry({
1974
+ const terminal = await sendTerminalMutationWithAuthReport({
1867
1975
  kind: "fail",
1868
1976
  send: () => httpClient.fail(job, {
1869
1977
  error_kind: authFailure ? ClaudeNotAuthenticated : classificationToErrorKind(procResult.classification),
@@ -1878,6 +1986,6 @@ async function runPreparedSpawn(params) {
1878
1986
  options,
1879
1987
  ownership,
1880
1988
  log: deps.log,
1881
- });
1989
+ }, job, control);
1882
1990
  return terminalToRunResult(terminal, "failed");
1883
1991
  }
@@ -23,7 +23,9 @@
23
23
  */
24
24
  import { makeLocalMergeExecutor, resolveLocalMergeMethod, } from "../conductor/local-merge.js";
25
25
  import { resolveConductorBridgeApiAccess, } from "../conductor/bridge-api-client.js";
26
+ import { createProductionEpicIntegrationGhRunner, ensureEpicIntegrationPullRequest, formatEpicIntegrationPullRequestOutcome, EPIC_BRANCH_PREFIX, } from "../epic-integration-pr.js";
26
27
  import { secretFreeErrorMessage } from "./job-errors.js";
28
+ import { resolveExecutorJobBaseBranch } from "./base-branch.js";
27
29
  function positiveIntOrNull(value) {
28
30
  return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : null;
29
31
  }
@@ -38,8 +40,21 @@ function asString(value) {
38
40
  * Extract and validate merge fields. Prefers the claimed-job `expected_head_sha`
39
41
  * over any payload copy, validates `pr_number` as a positive integer, and fails
40
42
  * (before any subprocess) when the PR number or expected head SHA is absent.
43
+ *
44
+ * BAPI-1021 (AC-11): also resolves the two branch-ref expectations the local
45
+ * merge executor's ref-verification guard needs, and fails closed — before any
46
+ * `makeLocalMergeExecutor` construction or `runCommand` seam is reached — when
47
+ * either cannot be resolved:
48
+ *
49
+ * - `expectedBaseBranch` via {@link resolveExecutorJobBaseBranch}, the SAME
50
+ * resolver every other PR-producing job type uses, with `repositoryBaseBranch`
51
+ * as its documented legacy fallback for a payload omitting `base_branch`.
52
+ * - `expectedHeadBranch` EXCLUSIVELY from a trimmed, nonblank
53
+ * `job.expected_branch` — never from payload data, which is untrusted and
54
+ * job-type-generic. There is no fallback: a merge job with no known expected
55
+ * head branch cannot be ref-verified.
41
56
  */
42
- export function readMergeJobPayloadFields(job) {
57
+ export function readMergeJobPayloadFields(job, repositoryBaseBranch) {
43
58
  const payload = job.payload && typeof job.payload === "object" ? job.payload : {};
44
59
  const prNumber = positiveIntOrNull(payload.pr_number);
45
60
  if (prNumber === null) {
@@ -49,6 +64,15 @@ export function readMergeJobPayloadFields(job) {
49
64
  if (!expectedHeadSha) {
50
65
  return { ok: false, error: "merge job requires an expected_head_sha (top-level or payload)" };
51
66
  }
67
+ const baseResolution = resolveExecutorJobBaseBranch(job, repositoryBaseBranch);
68
+ if (!baseResolution.ok) {
69
+ return { ok: false, error: baseResolution.error };
70
+ }
71
+ const expectedBaseBranch = baseResolution.baseBranch;
72
+ const expectedHeadBranch = asString(job.expected_branch ?? undefined);
73
+ if (!expectedHeadBranch) {
74
+ return { ok: false, error: "merge job requires a nonblank expected_branch" };
75
+ }
52
76
  const method = resolveLocalMergeMethod(payload.method);
53
77
  const requiredChecks = Array.isArray(payload.required_checks)
54
78
  ? payload.required_checks.filter((c) => typeof c === "string")
@@ -58,7 +82,17 @@ export function readMergeJobPayloadFields(job) {
58
82
  const ciWaitPollIntervalMs = nonNegativeIntOrUndefined(payload.ci_wait_poll_interval_ms);
59
83
  return {
60
84
  ok: true,
61
- fields: { prNumber, expectedHeadSha, method, requiredChecks, actionKey, ciWaitTimeoutMs, ciWaitPollIntervalMs },
85
+ fields: {
86
+ prNumber,
87
+ expectedHeadSha,
88
+ method,
89
+ requiredChecks,
90
+ actionKey,
91
+ ciWaitTimeoutMs,
92
+ ciWaitPollIntervalMs,
93
+ expectedBaseBranch,
94
+ expectedHeadBranch,
95
+ },
62
96
  };
63
97
  }
64
98
  /** Construct the {@link ConductorMergeRequest} for the local merge executor. */
@@ -120,10 +154,18 @@ export function buildMergeJobResult(response, fields) {
120
154
  * safe to re-fire the same merge later.
121
155
  * - {@link MERGE_CONFLICT}: needs a rebase before it can merge.
122
156
  * - {@link MERGE_FAILED}: deterministic other failure (bad state, missing input).
157
+ * - {@link MERGE_REF_MISMATCH}: BAPI-1021 — a DELIBERATE, terminal safety
158
+ * refusal: the PR's current base or head branch did not match the caller's
159
+ * expectation, so no provider merge was attempted. This is neither
160
+ * retryable (the mismatch will not resolve itself) nor a rebase target (it
161
+ * is not a conflict) — see `_merge_entry_failure_is_retryable` and
162
+ * `_merge_entry_failure_requires_rebase` on the backend gate-retry path,
163
+ * which both explicitly exclude it.
123
164
  */
124
165
  export const MERGE_RETRYABLE = "MergeRetryable";
125
166
  export const MERGE_CONFLICT = "MergeConflict";
126
167
  export const MERGE_FAILED = "MergeFailed";
168
+ export const MERGE_REF_MISMATCH = "MergeRefMismatch";
127
169
  /** Reasons whose failure is transient/infrastructure and safe to retry. */
128
170
  const RETRYABLE_MERGE_REASONS = new Set([
129
171
  "gh_pr_view_timeout",
@@ -147,10 +189,18 @@ const RETRYABLE_MERGE_REASONS = new Set([
147
189
  * `ci_poll_`-prefixed reason is retryable — a prefix match keeps unbounded HTTP-status
148
190
  * variants covered so a distinct reason never accidentally downgrades to
149
191
  * {@link MERGE_FAILED}. Conflict classification is checked first so it is never masked.
192
+ *
193
+ * BAPI-1021: `ref_mismatch` is classified BEFORE conflict, retryable-prefix, and
194
+ * generic-failure handling, and is deliberately NEVER added to
195
+ * {@link RETRYABLE_MERGE_REASONS} — it is a terminal safety refusal, not a
196
+ * transient or conflict-shaped failure, and must not be re-firable by any
197
+ * future classification change.
150
198
  */
151
199
  export function classifyMergeFailureErrorKind(response) {
152
200
  const hasConflictEvent = response.ledger_events.some((ev) => ev.type === "merge.conflict");
153
201
  const reason = asString(response.reason ?? undefined);
202
+ if (reason === "ref_mismatch")
203
+ return MERGE_REF_MISMATCH;
154
204
  if (hasConflictEvent || reason === "gh_merge_conflict")
155
205
  return MERGE_CONFLICT;
156
206
  if (reason && (reason.startsWith("ci_poll_") || RETRYABLE_MERGE_REASONS.has(reason))) {
@@ -164,22 +214,59 @@ export function classifyMergeFailureErrorKind(response) {
164
214
  * `error_message` includes only the sanitized merge status and reason (never raw
165
215
  * `gh` output). `classification` stays within the executor's vocabulary; the
166
216
  * `error_kind` is the routing signal.
217
+ *
218
+ * BAPI-1021: a {@link MERGE_REF_MISMATCH} classifies as `classification:
219
+ * "clean_exit"` rather than `"crashed"` — it is a deliberate refusal the
220
+ * executor made correctly, not a crash. Every other merge outcome keeps its
221
+ * existing `"crashed"` classification.
167
222
  */
168
223
  export function buildMergeJobFailure(response) {
169
224
  const reason = asString(response.reason ?? undefined) ?? response.status;
225
+ const errorKind = classifyMergeFailureErrorKind(response);
170
226
  return {
171
- error_kind: classifyMergeFailureErrorKind(response),
227
+ error_kind: errorKind,
172
228
  error_message: secretFreeErrorMessage(new Error(`local merge ${response.status}: ${reason}`)),
173
- classification: "crashed",
229
+ classification: errorKind === MERGE_REF_MISMATCH ? "clean_exit" : "crashed",
174
230
  };
175
231
  }
232
+ /**
233
+ * Post-merge draft epic-integration-PR retry (BAPI-951). Invoked ONLY after a
234
+ * successful ticket-bound merge whose child PR based on `epic/<EPIC>` — never
235
+ * for a `main`/other-based merge, and never for any failed merge. Wrapped in
236
+ * its own try/catch (the shared helper is already non-throwing, but this
237
+ * guards against a future/injected implementation replacing an already-decided
238
+ * merge outcome). NEVER changes the returned result, the `error_kind`, or the
239
+ * `/complete` payload — only emits a sanitized advisory line.
240
+ */
241
+ async function tryEnsurePostMergeEpicIntegrationPr(seams, childBaseBranch) {
242
+ try {
243
+ if (!childBaseBranch.startsWith(EPIC_BRANCH_PREFIX))
244
+ return;
245
+ const epicKey = childBaseBranch.slice(EPIC_BRANCH_PREFIX.length);
246
+ if (epicKey.length === 0)
247
+ return;
248
+ const gh = seams.epicIntegrationGh ?? createProductionEpicIntegrationGhRunner();
249
+ const outcome = await ensureEpicIntegrationPullRequest({
250
+ epicKey,
251
+ epicBranch: childBaseBranch,
252
+ baseBranch: seams.repositoryBaseBranch,
253
+ command: "executor merge",
254
+ gh,
255
+ cwd: seams.cwd,
256
+ });
257
+ seams.epicIntegrationAdvisoryLog?.(`epic integration pr: ${JSON.stringify(formatEpicIntegrationPullRequestOutcome(outcome))}`);
258
+ }
259
+ catch {
260
+ // Advisory-only: never allowed to affect the already-decided merge outcome.
261
+ }
262
+ }
176
263
  /**
177
264
  * Run the deterministic merge. NEVER ensures/recreates a worktree and NEVER
178
265
  * spawns a worker. Returns a completion result on a succeeded merge, else a
179
266
  * bounded, secret-free executor failure.
180
267
  */
181
268
  export async function runExecutorMergeJob(job, seams) {
182
- const resolution = readMergeJobPayloadFields(job);
269
+ const resolution = readMergeJobPayloadFields(job, seams.repositoryBaseBranch);
183
270
  if (!resolution.ok) {
184
271
  return {
185
272
  ok: false,
@@ -197,6 +284,11 @@ export async function runExecutorMergeJob(job, seams) {
197
284
  method: fields.method,
198
285
  ciWaitTimeoutMs: fields.ciWaitTimeoutMs,
199
286
  ciWaitPollIntervalMs: fields.ciWaitPollIntervalMs,
287
+ // BAPI-1021 (AC-11): pass the already-resolved expectations through
288
+ // unchanged so verification and the post-merge enrichment below use one
289
+ // authoritative value — neither re-resolves it independently.
290
+ expectedBaseBranch: fields.expectedBaseBranch,
291
+ expectedHeadBranch: fields.expectedHeadBranch,
200
292
  }, seams.localMergeDeps);
201
293
  let response;
202
294
  try {
@@ -213,7 +305,11 @@ export async function runExecutorMergeJob(job, seams) {
213
305
  };
214
306
  }
215
307
  if (response.status === "succeeded") {
216
- return { ok: true, result: buildMergeJobResult(response, fields) };
308
+ // Build the successful outcome COMPLETELY first — enrichment can only ever
309
+ // add an advisory log line, never alter what is returned.
310
+ const result = buildMergeJobResult(response, fields);
311
+ await tryEnsurePostMergeEpicIntegrationPr(seams, fields.expectedBaseBranch);
312
+ return { ok: true, result };
217
313
  }
218
314
  return { ok: false, failure: buildMergeJobFailure(response) };
219
315
  }