@bridge_gpt/mcp-server 0.2.54 → 0.2.55

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/README.md +38 -54
  2. package/build/agent-guidance.generated.js +3 -0
  3. package/build/agent-launchers/claude-executor-adapter.js +3 -0
  4. package/build/agent-notes.js +178 -0
  5. package/build/agent-registry.js +5 -2
  6. package/build/agent-utils.js +58 -0
  7. package/build/agents.generated.js +1 -1
  8. package/build/codex-skill-adapter.js +55 -0
  9. package/build/commands.generated.js +5 -4
  10. package/build/conductor/bridge-api-client.js +199 -6
  11. package/build/conductor/bring-up-facts.js +187 -0
  12. package/build/conductor/claude-hook.js +7 -5
  13. package/build/conductor/cli.js +28 -0
  14. package/build/conductor/doctor.js +80 -9
  15. package/build/conductor/epic-implementer-cli.js +1298 -0
  16. package/build/conductor/epic-runtime.js +1 -1
  17. package/build/conductor/errors.js +2 -2
  18. package/build/conductor/git-ci-types.js +1 -1
  19. package/build/conductor/git-hooks.js +28 -14
  20. package/build/conductor/install-doctor.js +11 -5
  21. package/build/conductor/readiness-cli.js +10 -10
  22. package/build/conductor/readiness-sections.js +58 -9
  23. package/build/conductor/readiness.js +37 -6
  24. package/build/conductor/recovery-cli.js +289 -10
  25. package/build/conductor/recovery-operations.js +125 -2
  26. package/build/conductor/repair-contract.js +58 -0
  27. package/build/conductor/store.js +2 -2
  28. package/build/conductor/supervisor-runtime.js +1 -1
  29. package/build/conductor-bin.js +2 -139
  30. package/build/conductor-claude-hook-bin.js +2 -2
  31. package/build/conductor-claude-hook-removed-stub-bin.js +31 -0
  32. package/build/conductor-removed-stub-bin.js +30 -0
  33. package/build/docs.generated.js +1 -1
  34. package/build/doctor.js +77 -17
  35. package/build/drive-epic.js +541 -115
  36. package/build/epic-implementer-bin.js +145 -0
  37. package/build/epic-implementer-bundle-cli.js +264 -0
  38. package/build/epic-implementer-claude-hook-bin.js +3 -0
  39. package/build/epic-integration-pr.js +5 -3
  40. package/build/executor/env.js +6 -0
  41. package/build/executor/interrupted-worktree.js +60 -0
  42. package/build/executor/job-errors.js +45 -0
  43. package/build/executor/job-runner.js +274 -9
  44. package/build/executor/job-types.js +25 -9
  45. package/build/executor/merge-tree-classifier.js +171 -0
  46. package/build/executor/reconcile-overlap-governance.js +129 -0
  47. package/build/executor/reconcile-overlap-job.js +989 -0
  48. package/build/executor/reconcile-overlap-types.js +14 -0
  49. package/build/executor/spawn-job-driver.js +1 -0
  50. package/build/executor/types.js +2 -0
  51. package/build/executor/worker-finalization.js +25 -2
  52. package/build/executor/worker-guard-hook.js +15 -7
  53. package/build/implement-epic/bridge-client.js +773 -0
  54. package/build/implement-epic/checkpoint-store.js +542 -0
  55. package/build/implement-epic/cli.js +3158 -0
  56. package/build/implement-epic/cut-protocol.js +392 -0
  57. package/build/implement-epic/lock.js +302 -0
  58. package/build/implement-epic/pr-state.js +286 -0
  59. package/build/implement-epic/spawn.js +113 -0
  60. package/build/index.js +586 -138
  61. package/build/init.js +72 -8
  62. package/build/install-bridge-conductor.js +5 -5
  63. package/build/install-bridge.js +403 -70
  64. package/build/mcp-host-config.js +22 -60
  65. package/build/mcp-host-entry-adapter.js +18 -0
  66. package/build/mcp-host-targets.js +1 -21
  67. package/build/merge-pull-request.js +1 -1
  68. package/build/pipelines.generated.js +7 -7
  69. package/build/plan-epic-conductor-eligibility.js +1 -1
  70. package/build/plane/cli.js +36 -5
  71. package/build/plane/preflight.js +128 -12
  72. package/build/plane/shutdown.js +4 -4
  73. package/build/readiness-check.js +3 -3
  74. package/build/readme.generated.js +1 -1
  75. package/build/run-unit-tests-launcher.js +1 -1
  76. package/build/setup-epic.js +69 -31
  77. package/build/start-tickets-conductor.js +8 -7
  78. package/build/version.generated.js +3 -3
  79. package/build/worker-guard-hook-bin.js +1 -1
  80. package/docs/CONDUCTOR.md +8 -6
  81. package/package.json +5 -3
@@ -40,6 +40,8 @@ import { acquireExecutorWorktreeLock, } from "./worktree-lock.js";
40
40
  import { activateJobLog, deactivateJobLog, registerExecutorJobLog, markExecutorJobLogFinished, } from "./job-log-registry.js";
41
41
  import { executorViewerTabsEnabled, openExecutorViewerTab } from "./viewer-tabs.js";
42
42
  import { isImplementationStyleJobType, isRecoveryJobType, isSpawnJobType } from "./job-types.js";
43
+ import { cleanupInterruptedWorktree } from "./interrupted-worktree.js";
44
+ import { lazyProviderCheckPoller, runReconcileOverlapJob, } from "./reconcile-overlap-job.js";
43
45
  import { resolveExecutorJobBaseBranch } from "./base-branch.js";
44
46
  import { resolveExecutorJobIndexScope } from "./index-scope.js";
45
47
  import { validateWorkerFinalization } from "./worker-finalization.js";
@@ -613,6 +615,21 @@ control) {
613
615
  if (job.job_type === "smoke") {
614
616
  return runSmokeJob(job, httpClient, options, deps, ownership, observation, control);
615
617
  }
618
+ // BAPI-1152: `reconcile_overlap` takes its OWN branch, after `merge`/`smoke`
619
+ // and before the generic spawn path. It needs a prepared, locked worktree like
620
+ // a spawn job, but its body is the reconcile module's state machine: the
621
+ // payload is validated there BEFORE any subprocess, and the worker — when one
622
+ // is needed at all — is launched through the SAME locked `runSpawnJob` path
623
+ // via a driver, so there is still exactly one worker spawn boundary.
624
+ if (job.job_type === "reconcile_overlap") {
625
+ return await runReconcileOverlapJob(job, {
626
+ fail: async (failure) => {
627
+ await httpClient.fail(job, failure);
628
+ return { status: "failed", reason: "reconcile_overlap_payload" };
629
+ },
630
+ runLockedSpawn: (driver) => runSpawnJob(job, httpClient, options, deps, ownership, observation, seams, control, driver),
631
+ }, buildReconcileOverlapSeams(deps));
632
+ }
616
633
  // `implement`/`resume`/`spec_review` and the recovery jobs (`remediate`/
617
634
  // `ci_fix`/`rebase`) all run through the real headless-spawn path — dispatched
618
635
  // via the shared `isSpawnJobType` policy (`job-types.ts`) AFTER the dedicated
@@ -646,6 +663,18 @@ control) {
646
663
  * from the supervised `ProcessRunResult`/`ownership`, not from this adapter).
647
664
  */
648
665
  function createMergeFlowProcess(startMerge) {
666
+ const flow = createFlowProcess(startMerge, (result) => result.ok);
667
+ return { proc: flow.proc, getOutcome: flow.getOutcome, getError: flow.getError };
668
+ }
669
+ /**
670
+ * The generic form of {@link createMergeFlowProcess} (BAPI-1152): adapt any
671
+ * in-process flow into an {@link OwnedChildProcess} so it is supervised by the
672
+ * SAME heartbeat/deadman/timeout path as a worker. `settled` resolves once the
673
+ * flow's own promise has settled — after a timeout or kill the supervised
674
+ * process resolves immediately, but the flow may still be finishing a bounded
675
+ * subprocess, and a caller that is about to reset the worktree must wait for it.
676
+ */
677
+ function createFlowProcess(start, isOk) {
649
678
  const controller = new AbortController();
650
679
  let outcome;
651
680
  let error;
@@ -654,12 +683,12 @@ function createMergeFlowProcess(startMerge) {
654
683
  const waitPromise = new Promise((resolve) => {
655
684
  settleWait = resolve;
656
685
  });
657
- // Start the merge flow immediately; capture its resolution/rejection.
658
- startMerge(controller.signal).then((result) => {
686
+ // Start the flow immediately; capture its resolution/rejection.
687
+ const settled = start(controller.signal).then((result) => {
659
688
  outcome = result;
660
689
  if (!done) {
661
690
  done = true;
662
- settleWait({ exitCode: result.ok ? 0 : 1, signal: null });
691
+ settleWait({ exitCode: isOk(result) ? 0 : 1, signal: null });
663
692
  }
664
693
  }, (err) => {
665
694
  error = err;
@@ -687,6 +716,7 @@ function createMergeFlowProcess(startMerge) {
687
716
  proc,
688
717
  getOutcome: () => outcome,
689
718
  getError: () => error,
719
+ settled,
690
720
  };
691
721
  }
692
722
  /**
@@ -884,6 +914,131 @@ async function runSmokeJob(job, httpClient, options, deps, ownership, observatio
884
914
  }, job, control);
885
915
  return terminalToRunResult(terminal, "completed");
886
916
  }
917
+ /** Build the reconcile module's seams from executor deps (BAPI-1152). */
918
+ function buildReconcileOverlapSeams(deps) {
919
+ return {
920
+ runCommand: deps.runCommand,
921
+ readFile: deps.readFile,
922
+ writeFile: deps.writeFile,
923
+ mkdir: deps.mkdir,
924
+ now: deps.now,
925
+ sleep: deps.sleep,
926
+ env: deps.env,
927
+ pollProviderChecks: lazyProviderCheckPoller({
928
+ env: deps.env,
929
+ cwd: deps.cwd,
930
+ homedir: deps.homedir,
931
+ platform: deps.platform,
932
+ readFile: deps.readFile,
933
+ stat: deps.stat,
934
+ }),
935
+ errorLog: deps.errorLog,
936
+ };
937
+ }
938
+ /** Bound on waiting for an aborted driver phase to finish its in-flight command. */
939
+ const DRIVER_PHASE_SETTLE_MAX_MS = 240_000;
940
+ /**
941
+ * Run one driver phase under the SAME heartbeat/deadman/timeout supervision a
942
+ * worker gets (BAPI-1152). A deterministic phase can run for many minutes — local
943
+ * verification commands, a CI wait — and without heartbeats the janitor would
944
+ * re-queue a healthy job. On a timeout or a lost claim the phase is aborted and
945
+ * then AWAITED (bounded) before returning, so the caller never resets the
946
+ * worktree underneath a git command the phase is still running.
947
+ */
948
+ async function superviseDriverPhase(params) {
949
+ const flow = createFlowProcess(params.run, () => true);
950
+ const procResult = await superviseProcess({
951
+ job: params.job,
952
+ httpClient: params.httpClient,
953
+ options: params.options,
954
+ deps: params.deps,
955
+ ownership: params.ownership,
956
+ observation: params.observation,
957
+ proc: flow.proc,
958
+ timeoutSeconds: Math.max(1, params.timeoutSeconds),
959
+ collectTelemetry: params.collectTelemetry,
960
+ control: params.control,
961
+ });
962
+ await Promise.race([flow.settled, params.deps.sleep(DRIVER_PHASE_SETTLE_MAX_MS)]);
963
+ if (procResult.serverStopRequested)
964
+ return { kind: "server_stop" };
965
+ if (params.ownership.abandoned)
966
+ return { kind: "abandoned" };
967
+ if (procResult.classification === "timeout")
968
+ return { kind: "timeout" };
969
+ const error = flow.getError();
970
+ if (error !== undefined)
971
+ return { kind: "error", error };
972
+ const value = flow.getOutcome();
973
+ if (value === undefined)
974
+ return { kind: "error", error: new Error("driver phase produced no outcome") };
975
+ return { kind: "result", value };
976
+ }
977
+ /** Report a driver's terminal decision through the shared terminal-mutation path. */
978
+ async function reportDriverOutcome(outcome, ctx) {
979
+ if (outcome.kind === "complete") {
980
+ const completion = {
981
+ job_type: ctx.job.job_type,
982
+ exit_code: 0,
983
+ classification: "clean_exit",
984
+ result: outcome.result,
985
+ // ONLY a pushed head is reported: the server binds `last_commit_sha` as the
986
+ // job's produced head, and a job that pushed nothing produced none.
987
+ ...(outcome.lastCommitSha ? { last_commit_sha: outcome.lastCommitSha } : {}),
988
+ telemetry: ctx.observation.snapshot(),
989
+ };
990
+ const terminal = await sendTerminalMutationWithAuthReport({
991
+ kind: "complete",
992
+ send: () => ctx.httpClient.complete(ctx.job, completion),
993
+ deps: ctx.deps,
994
+ options: ctx.options,
995
+ ownership: ctx.ownership,
996
+ log: ctx.deps.log,
997
+ }, ctx.job, ctx.control);
998
+ return terminalToRunResult(terminal, "completed");
999
+ }
1000
+ const terminal = await sendTerminalMutationWithAuthReport({
1001
+ kind: "fail",
1002
+ send: () => ctx.httpClient.fail(ctx.job, { ...outcome.failure, telemetry: ctx.observation.snapshot() }),
1003
+ deps: ctx.deps,
1004
+ options: ctx.options,
1005
+ ownership: ctx.ownership,
1006
+ log: ctx.deps.log,
1007
+ }, ctx.job, ctx.control);
1008
+ return terminalToRunResult(terminal, "failed");
1009
+ }
1010
+ /** Restore the worktree and report a driver phase that did not finish. */
1011
+ async function reportDriverPhaseInterruption(phase, ctx) {
1012
+ await ctx.driver.abandon(ctx.driverBaseContext);
1013
+ if (phase.kind === "abandoned") {
1014
+ return { status: "abandoned", reason: ctx.ownership.abandonReason };
1015
+ }
1016
+ if (phase.kind === "server_stop") {
1017
+ await ctx.httpClient.fail(ctx.job, {
1018
+ error_kind: "ServerStopRequested",
1019
+ error_message: "the server requested this run stop; the reconciliation was stopped",
1020
+ classification: "killed",
1021
+ telemetry: ctx.observation.snapshot(),
1022
+ });
1023
+ ctx.ownership.abandoned = true;
1024
+ ctx.ownership.abandonReason = "server_stop";
1025
+ return { status: "abandoned", reason: "server_stop" };
1026
+ }
1027
+ return await reportDriverOutcome({
1028
+ kind: "fail",
1029
+ failure: phase.kind === "timeout"
1030
+ ? {
1031
+ error_kind: "Timeout",
1032
+ error_message: `${ctx.job.job_type} phase exceeded the job timeout`,
1033
+ classification: "timeout",
1034
+ }
1035
+ : {
1036
+ error_kind: "WorkerCrashed",
1037
+ error_message: `${ctx.job.job_type} phase failed: ${secretFreeErrorMessage(phase.error)}`,
1038
+ classification: "crashed",
1039
+ },
1040
+ }, ctx);
1041
+ }
887
1042
  /**
888
1043
  * BAPI-862: the payload key the reconciler stamps with the implement gate's last
889
1044
  * observation reason code. Server-minted and opaque to the executor beyond the
@@ -1048,7 +1203,13 @@ async function prepareSpawn(job, httpClient, options, deps, seams) {
1048
1203
  * returns BEFORE either the deny layer or MCP provisioning runs — neither ever
1049
1204
  * touches a worktree that will not become a runnable worker.
1050
1205
  */
1051
- async function runSpawnJob(job, httpClient, options, deps, ownership, observation, seams, control) {
1206
+ async function runSpawnJob(job, httpClient, options, deps, ownership, observation, seams, control,
1207
+ /**
1208
+ * BAPI-1152: an optional spawn-job driver (`reconcile_overlap`). It runs inside
1209
+ * the SAME lock and the SAME prepared-spawn path as every other spawn job, so it
1210
+ * adds no second worktree entry and no second worker launcher.
1211
+ */
1212
+ driver) {
1052
1213
  // --- Per-job base branch (BAPI-586) ----------------------------------
1053
1214
  // Resolve the effective logical base BEFORE any side effect: the persisted
1054
1215
  // run base (`payload.base_branch`) is authoritative, falling back to the
@@ -1239,6 +1400,7 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
1239
1400
  adapter,
1240
1401
  control,
1241
1402
  lifecycle: spawnLifecycle,
1403
+ driver,
1242
1404
  });
1243
1405
  }
1244
1406
  finally {
@@ -1273,12 +1435,12 @@ async function runSpawnJob(job, httpClient, options, deps, ownership, observatio
1273
1435
  * rather than a 500-line indentation nobody can verify by eye.
1274
1436
  */
1275
1437
  async function runPreparedSpawn(params) {
1276
- const { job, httpClient, options, jobOptions, deps, ownership, observation, seams, worktreePath, branch, prep, effectiveBaseBranch, indexScope, permissionPosture, adapter, control, lifecycle, } = params;
1438
+ const { job, httpClient, options, jobOptions, deps, ownership, observation, seams, worktreePath, branch, prep, effectiveBaseBranch, indexScope, permissionPosture, adapter, control, lifecycle, driver, } = params;
1277
1439
  // BAPI-586: PR-producing spawn jobs (implement/resume/remediate/ci_fix/rebase)
1278
1440
  // are told to open the PR against the injected run base. Verdict-only
1279
1441
  // `spec_review` produces no PR, so it is excluded from the instruction while
1280
1442
  // still using the pinned base for its fresh worktree.
1281
- const prompt = isImplementationStyleJobType(job.job_type)
1443
+ let prompt = isImplementationStyleJobType(job.job_type)
1282
1444
  ? `${prep.prompt} ${buildPrBaseContractLaunchInstruction()}`
1283
1445
  : prep.prompt;
1284
1446
  // --- Timeout contract ------------------------------------------------
@@ -1291,6 +1453,49 @@ async function runPreparedSpawn(params) {
1291
1453
  });
1292
1454
  return { status: "failed", reason: "timeout_contract" };
1293
1455
  }
1456
+ // --- Spawn-job driver: the pre-worker phase (BAPI-1152) --------------
1457
+ // Runs INSIDE the lock and before every worker-owned side effect. A driver may
1458
+ // finish the whole job here (a clean, green reconciliation pushes with no
1459
+ // worker at all), or hand back the ONE worker it needs and the pre-spawn state
1460
+ // it has already proven. The job's overall timeout budget spans every phase.
1461
+ const jobDeadlineMs = deps.now() + timeout.timeoutSeconds * 1000;
1462
+ const remainingMs = () => Math.max(0, jobDeadlineMs - deps.now());
1463
+ const driverBaseContext = {
1464
+ worktreePath,
1465
+ branch,
1466
+ preparedHeadSha: prep.expectedHeadSha,
1467
+ effectiveBaseBranch,
1468
+ };
1469
+ const driverTelemetry = () => collectGitTelemetry({ runCommand: deps.runCommand, now: deps.now }, worktreePath, effectiveBaseBranch);
1470
+ let driverStep;
1471
+ if (driver) {
1472
+ const phase = await superviseDriverPhase({
1473
+ job,
1474
+ httpClient,
1475
+ options: jobOptions,
1476
+ deps,
1477
+ ownership,
1478
+ observation,
1479
+ control,
1480
+ timeoutSeconds: Math.ceil(remainingMs() / 1000),
1481
+ collectTelemetry: driverTelemetry,
1482
+ run: (signal) => driver.beforeWorker({ ...driverBaseContext, signal, remainingMs }),
1483
+ });
1484
+ if (phase.kind !== "result") {
1485
+ return await reportDriverPhaseInterruption(phase, {
1486
+ job, httpClient, options, deps, ownership, observation, control, driver, driverBaseContext,
1487
+ });
1488
+ }
1489
+ if (phase.value.kind === "finish") {
1490
+ return await reportDriverOutcome(phase.value.outcome, {
1491
+ job, httpClient, options, deps, ownership, observation, control,
1492
+ });
1493
+ }
1494
+ driverStep = phase.value;
1495
+ // The driver's prompt is used VERBATIM: it is server-rendered, and the PR-base
1496
+ // launch instruction would tell a worker with no push authority to open a PR.
1497
+ prompt = driverStep.prompt;
1498
+ }
1294
1499
  // --- Pre-spawn git verification (FAIL-CLOSED, BAPI-731 WS-E) ---------
1295
1500
  // FIRST of the pre-spawn contracts, and deliberately BEFORE every
1296
1501
  // executor-owned write to the worktree (deny layer, MCP provisioning, worker
@@ -1304,8 +1509,15 @@ async function runPreparedSpawn(params) {
1304
1509
  // untracked-clean / head-verify behavior in `worktree-core.ts`; it is an
1305
1510
  // additional independent check on top of them.
1306
1511
  const preSpawnTelemetry = await collectGitTelemetry({ runCommand: deps.runCommand, now: deps.now }, worktreePath, effectiveBaseBranch);
1307
- const verification = evaluatePreSpawnGitVerification(preSpawnTelemetry, prep.expectedHeadSha);
1512
+ // BAPI-1152: a driver proves its own pre-spawn state (a reconciliation worker
1513
+ // may start inside an in-progress merge, which the generic "clean at the
1514
+ // prepared commit" check would refuse by design).
1515
+ const verification = driverStep
1516
+ ? driverStep.preSpawnVerification
1517
+ : evaluatePreSpawnGitVerification(preSpawnTelemetry, prep.expectedHeadSha);
1308
1518
  if (!verification.ok) {
1519
+ if (driver)
1520
+ await driver.abandon(driverBaseContext);
1309
1521
  // Record the attempt boundary even for a refusal: an attempt that never
1310
1522
  // spawned still started and ended at the observed commit, so the reconciler
1311
1523
  // sees a machine-readable "went nowhere" rather than a silent gap.
@@ -1555,7 +1767,12 @@ async function runPreparedSpawn(params) {
1555
1767
  // BAPI-1020 — becomes `BAPI_WORKER_BRANCH` in the worker environment, which
1556
1768
  // is the deterministic guard's only source of branch identity. Same
1557
1769
  // provenance as the deny-provisioning value above: the prepared worktree.
1558
- workerBranch: branch,
1770
+ //
1771
+ // BAPI-1152: a driver that holds the push itself OMITS it, so the guard
1772
+ // denies every `git push` the worker attempts — the worker has no remote
1773
+ // write authority at all.
1774
+ ...(driver?.omitWorkerBranch ? {} : { workerBranch: branch }),
1775
+ ...(driverStep ? { declaredTouchedFiles: driverStep.declaredTouchedFiles } : {}),
1559
1776
  parentEnv: deps.env,
1560
1777
  });
1561
1778
  assertUsableSpawnShape(spawnShape, adapter);
@@ -1736,7 +1953,9 @@ async function runPreparedSpawn(params) {
1736
1953
  ownership,
1737
1954
  observation,
1738
1955
  proc: supervisedProc,
1739
- timeoutSeconds: timeout.timeoutSeconds,
1956
+ // BAPI-1152: a driver's worker gets what is LEFT of the job budget, since the
1957
+ // pre-worker phase has already spent some of it.
1958
+ timeoutSeconds: driver ? Math.max(1, Math.ceil(remainingMs() / 1000)) : timeout.timeoutSeconds,
1740
1959
  collectTelemetry,
1741
1960
  collectRemoteMarker,
1742
1961
  mcpSurface,
@@ -1786,6 +2005,8 @@ async function runPreparedSpawn(params) {
1786
2005
  if (procResult.serverStopRequested) {
1787
2006
  await closeWorkerLog();
1788
2007
  await finalizeRegistry();
2008
+ if (driver)
2009
+ await driver.abandon(driverBaseContext);
1789
2010
  await httpClient.fail(job, {
1790
2011
  error_kind: "ServerStopRequested",
1791
2012
  error_message: "the server requested this run stop; the owned worker was terminated",
@@ -1813,6 +2034,8 @@ async function runPreparedSpawn(params) {
1813
2034
  await reportWorkerLogSessionInvariant(tee, deps);
1814
2035
  if (ownership.abandoned) {
1815
2036
  await finalizeRegistry();
2037
+ if (driver)
2038
+ await driver.abandon(driverBaseContext);
1816
2039
  return { status: "abandoned", reason: ownership.abandonReason };
1817
2040
  }
1818
2041
  // --- MCP surface containment verdict (FAIL-CLOSED, BAPI-790) ----------
@@ -1833,6 +2056,8 @@ async function runPreparedSpawn(params) {
1833
2056
  const surfaceVerdict = mcpSurface.verdict();
1834
2057
  if (surfaceVerdict.kind === "mismatch") {
1835
2058
  await finalizeRegistry();
2059
+ if (driver)
2060
+ await driver.abandon(driverBaseContext);
1836
2061
  await httpClient.fail(job, {
1837
2062
  error_kind: McpSurfaceMismatch,
1838
2063
  error_message: formatMcpSurfaceMismatch(surfaceVerdict.expected, surfaceVerdict.observed),
@@ -1860,6 +2085,8 @@ async function runPreparedSpawn(params) {
1860
2085
  procResult.classification === "crashed" &&
1861
2086
  procResult.stdoutExcerpt.trim().length === 0) {
1862
2087
  await finalizeRegistry();
2088
+ if (driver)
2089
+ await driver.abandon(driverBaseContext);
1863
2090
  await httpClient.fail(job, {
1864
2091
  error_kind: WorkerStartupFatal,
1865
2092
  error_message: WORKER_STARTUP_FATAL_MESSAGE,
@@ -1886,6 +2113,31 @@ async function runPreparedSpawn(params) {
1886
2113
  "for this spawn");
1887
2114
  }
1888
2115
  const git = observation.git();
2116
+ // BAPI-1152: a driver owns everything after a clean worker exit — staging, the
2117
+ // governance scan, the commits, the guarded push, and finalization.
2118
+ if (driver && procResult.classification === "clean_exit") {
2119
+ await finalizeRegistry();
2120
+ const phase = await superviseDriverPhase({
2121
+ job,
2122
+ httpClient,
2123
+ options: jobOptions,
2124
+ deps,
2125
+ ownership,
2126
+ observation,
2127
+ control,
2128
+ timeoutSeconds: Math.max(1, Math.ceil(remainingMs() / 1000)),
2129
+ collectTelemetry: driverTelemetry,
2130
+ run: (signal) => driver.afterWorker({ ...driverBaseContext, signal, remainingMs }, { classification: procResult.classification, exitCode: procResult.exitCode }),
2131
+ });
2132
+ if (phase.kind !== "result") {
2133
+ return await reportDriverPhaseInterruption(phase, {
2134
+ job, httpClient, options, deps, ownership, observation, control, driver, driverBaseContext,
2135
+ });
2136
+ }
2137
+ return await reportDriverOutcome(phase.value, {
2138
+ job, httpClient, options, deps, ownership, observation, control,
2139
+ });
2140
+ }
1889
2141
  if (procResult.classification === "clean_exit") {
1890
2142
  // Verdict jobs (`spec_review`) forward the fixed-path artifact verbatim; a
1891
2143
  // missing/invalid artifact is a fail-loud `MissingVerdictArtifact`.
@@ -2028,6 +2280,19 @@ async function runPreparedSpawn(params) {
2028
2280
  // a verdict and a reason code only — never the matched line — so nothing from
2029
2281
  // the worker's stream reaches `/fail` through this path.
2030
2282
  await finalizeRegistry();
2283
+ // BAPI-1152: an interrupted worker (timeout or kill) can leave a stale
2284
+ // `index.lock` or an in-progress merge behind — the BAPI-1111 residue that made
2285
+ // the next job's reset fail. Removed for EVERY spawn job, never with a hard
2286
+ // reset; only a driver (`reconcile_overlap`) additionally resets to its anchor.
2287
+ if (procResult.classification === "timeout" || procResult.classification === "killed") {
2288
+ await cleanupInterruptedWorktree(worktreePath, {
2289
+ runCommand: deps.runCommand,
2290
+ removeFile: deps.removeFile ?? ((filePath) => rm(filePath)),
2291
+ errorLog: deps.errorLog,
2292
+ });
2293
+ }
2294
+ if (driver)
2295
+ await driver.abandon(driverBaseContext);
2031
2296
  const authDetection = adapter.authFailureDetection;
2032
2297
  const authFailure = authDetection?.supported === true &&
2033
2298
  authDetection.value.classify(procResult.stdoutExcerpt).notAuthenticated;
@@ -2,9 +2,10 @@
2
2
  * Shared executor job-type policy (BAPI-542, TDD §9).
3
3
  *
4
4
  * Single source of truth for which claimed job types the executor spawns and
5
- * which of those are RECOVERY jobs. Recovery jobs (`remediate`/`ci_fix`/`rebase`)
6
- * resume the ticket's existing branch/PR through the `/implement-ticket <KEY>
7
- * --auto` correction loop rather than cutting a fresh branch off base. Dispatch
5
+ * which of those are RECOVERY jobs. Recovery jobs (`remediate`/`ci_fix`/`rebase`/
6
+ * `reconcile_overlap`) resume the ticket's existing branch/PR rather than cutting
7
+ * a fresh branch off base the first three through the `/implement-ticket <KEY>
8
+ * --auto` correction loop, `reconcile_overlap` through its own job body. Dispatch
8
9
  * (`job-runner.ts` `runClaimedJob`), prompt synthesis (`worker-command.ts`
9
10
  * `resolveExecutorPrompt`), and worktree reuse (`job-runner.ts` `prepareSpawn` →
10
11
  * `worktree.ts` `ensureExecutorWorktree`) all read from here so the recovery /
@@ -17,21 +18,33 @@
17
18
  /**
18
19
  * Recovery job types: enqueued by the server-side reconciler when a gate is
19
20
  * ACTIONABLE (review changes requested → `remediate`, CI failed → `ci_fix`,
20
- * merge conflict → `rebase`). They re-enter the correction loop on the ticket's
21
- * EXISTING branch/PR, so the executor reuses that branch instead of cutting a
22
- * fresh one off base.
21
+ * merge conflict → `rebase`, overlapping sibling at `code_review`
22
+ * `reconcile_overlap`). They work on the ticket's EXISTING branch/PR, so the
23
+ * executor reuses that branch instead of cutting a fresh one off base.
24
+ *
25
+ * BAPI-1152: `reconcile_overlap` is a recovery job for worktree reuse and prompt
26
+ * resolution, but it does NOT re-enter `/implement-ticket`: it always carries an
27
+ * explicit server-rendered `payload.prompt`, which wins over the synthesis in
28
+ * `worker-command.ts`, and `job-runner.ts` dispatches it to its own body.
23
29
  */
24
30
  export const RECOVERY_JOB_TYPES = new Set([
25
31
  "remediate",
26
32
  "ci_fix",
27
33
  "rebase",
34
+ "reconcile_overlap",
28
35
  ]);
29
36
  /**
30
37
  * Real headless-spawn job types (worktree + worker LLM): the first-pass
31
38
  * `implement`, the `resume` continuation (own pre-spawn protocol), the
32
- * `spec_review` verdict job, and the three recovery jobs. `merge` (deterministic,
39
+ * `spec_review` verdict job, and the recovery jobs. `merge` (deterministic,
33
40
  * no worktree/worker) and `smoke` (no-op acceptance) are intentionally EXCLUDED —
34
41
  * they have dedicated dispatch branches BEFORE the generic spawn path.
42
+ *
43
+ * BAPI-1152: `reconcile_overlap` is here because it needs a prepared, locked
44
+ * worktree and MAY spawn a worker — but only CONDITIONALLY. A clean merge whose
45
+ * local checks pass is pushed with no worker at all. It also has a dedicated
46
+ * dispatch branch ahead of the generic spawn path; membership here is what makes
47
+ * worktree preparation and `isImplementationStyleJobType` treat it as a spawn job.
35
48
  */
36
49
  export const SPAWN_JOB_TYPES = new Set([
37
50
  "implement",
@@ -40,6 +53,7 @@ export const SPAWN_JOB_TYPES = new Set([
40
53
  "remediate",
41
54
  "ci_fix",
42
55
  "rebase",
56
+ "reconcile_overlap",
43
57
  ]);
44
58
  /** True only for the exact lowercase recovery job types. */
45
59
  export function isRecoveryJobType(jobType) {
@@ -52,8 +66,10 @@ export function isSpawnJobType(jobType) {
52
66
  /**
53
67
  * Implementation-style spawn jobs (BAPI-551): every spawn job type expected to
54
68
  * end with a pushed origin branch or an opened PR — `implement`, `resume`, and
55
- * the recovery jobs. Excludes `spec_review`, the one spawn type that is a
56
- * verdict job (it produces `.conductor/result.json` instead of a branch/PR).
69
+ * the recovery jobs (`reconcile_overlap` included: when it pushes, finalization
70
+ * verifies the push against origin). Excludes `spec_review`, the one spawn type
71
+ * that is a verdict job (it produces `.conductor/result.json` instead of a
72
+ * branch/PR).
57
73
  */
58
74
  export function isImplementationStyleJobType(jobType) {
59
75
  return isSpawnJobType(jobType) && jobType !== "spec_review";
@@ -0,0 +1,171 @@
1
+ /**
2
+ * Deterministic merge classification for `reconcile_overlap` (BAPI-1152).
3
+ *
4
+ * Classifies a (child head, required epic SHA) pair with
5
+ * `git merge-tree --write-tree` BEFORE anything touches the held worktree.
6
+ * `merge-tree` computes the merge entirely in the object database: it writes a
7
+ * tree object and reports conflicts, but never touches a working tree, an index,
8
+ * `HEAD`, or a ref. That is what lets the job decide "clean or conflicted" first
9
+ * and only then materialize a real merge.
10
+ *
11
+ * Output is read in the machine-readable `-z --name-only --messages` form, never
12
+ * scraped from human text. Verified against git 2.50.1:
13
+ *
14
+ * <tree-oid> NUL <conflicted path> NUL ... NUL NUL
15
+ * ( <N> NUL <path 1> NUL ... <path N> NUL <type> NUL <message> NUL )*
16
+ *
17
+ * with exit 0 for a clean merge and 1 for a conflicted one. Exit 1 is ALSO what
18
+ * git returns for an unknown commit — with no tree OID on stdout — so the exit
19
+ * code alone never decides "conflicted": the parsed tree OID does. Anything that
20
+ * is not a proven clean or conflicted result is `operational_error`, and the job
21
+ * fails rather than guessing.
22
+ *
23
+ * Every git call is an argv list through the injected `RunCommand` (`execFile`,
24
+ * `shell: false`); no shell string is ever built.
25
+ */
26
+ import { normalizeRepoRelativePath } from "../conductor/file-scope-guard.js";
27
+ /** Bound on the conflicted paths recorded. More than this is still `conflicted`. */
28
+ export const MERGE_TREE_MAX_CONFLICTS = 200;
29
+ /** Bound on the informational lines kept per conflicted path. */
30
+ export const MERGE_TREE_MAX_MESSAGES_PER_PATH = 5;
31
+ /** Bound on one message line. */
32
+ export const MERGE_TREE_MESSAGE_MAX_CHARS = 300;
33
+ /** Wall-clock bound on each git call the classifier makes. */
34
+ export const MERGE_TREE_COMMAND_TIMEOUT_MS = 120_000;
35
+ const OID_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
36
+ function descriptorFor(type, message) {
37
+ // The MESSAGE carries git's user-facing spelling ("CONFLICT (content)"); the
38
+ // type token is an internal name ("CONFLICT (contents)"). Prefer the message's
39
+ // own prefix so the descriptor matches what an operator sees in `git merge`.
40
+ const fromMessage = /^(CONFLICT \([^)]*\))/.exec(message.trim());
41
+ if (fromMessage)
42
+ return fromMessage[1];
43
+ return type.trim().length > 0 ? type.trim() : "CONFLICT (unknown)";
44
+ }
45
+ /**
46
+ * Parse `git merge-tree --write-tree -z --name-only --messages` stdout.
47
+ *
48
+ * Exported for the unit tests; `classifyMergeTree` is the runtime entry point.
49
+ * Returns `null` when stdout does not start with a tree OID — the caller treats
50
+ * that as an operational error.
51
+ */
52
+ export function parseMergeTreeOutput(stdout) {
53
+ const tokens = stdout.split("\0");
54
+ const treeOid = (tokens[0] ?? "").trim().toLowerCase();
55
+ if (!OID_RE.test(treeOid))
56
+ return null;
57
+ let i = 1;
58
+ const conflictedPaths = [];
59
+ while (i < tokens.length && tokens[i] !== "") {
60
+ conflictedPaths.push(tokens[i]);
61
+ i += 1;
62
+ }
63
+ i += 1; // the empty token separating conflicted paths from messages
64
+ const byPath = new Map();
65
+ const record = (rawPath, descriptor, message) => {
66
+ const path = normalizeRepoRelativePath(rawPath) ?? rawPath;
67
+ let entry = byPath.get(path);
68
+ if (!entry) {
69
+ entry = { path, descriptor: descriptor ?? "CONFLICT (unknown)", messages: [] };
70
+ byPath.set(path, entry);
71
+ }
72
+ else if (descriptor && entry.descriptor === "CONFLICT (unknown)") {
73
+ entry.descriptor = descriptor;
74
+ }
75
+ if (message && entry.messages.length < MERGE_TREE_MAX_MESSAGES_PER_PATH) {
76
+ entry.messages.push(message.trim().slice(0, MERGE_TREE_MESSAGE_MAX_CHARS));
77
+ }
78
+ };
79
+ while (i < tokens.length) {
80
+ const countToken = tokens[i];
81
+ if (countToken === "" || countToken === undefined)
82
+ break;
83
+ const count = Number.parseInt(countToken, 10);
84
+ // A group is `count` paths plus a type and a message; a truncated group ends
85
+ // the parse rather than reading past stdout.
86
+ if (!Number.isInteger(count) || count < 0 || i + count + 2 >= tokens.length)
87
+ break;
88
+ const paths = tokens.slice(i + 1, i + 1 + count);
89
+ const type = tokens[i + 1 + count] ?? "";
90
+ const message = tokens[i + 2 + count] ?? "";
91
+ i += count + 3;
92
+ if (!type.startsWith("CONFLICT"))
93
+ continue;
94
+ const descriptor = descriptorFor(type, message);
95
+ // The FIRST path of a multi-path message (e.g. rename/delete names the new
96
+ // and the old path) is the one git reports as conflicted; the rest are
97
+ // context. Recording every named path would report a path that no longer
98
+ // exists in the merge result as conflicted.
99
+ const conflicted = paths.find((p) => conflictedPaths.includes(p)) ?? paths[0];
100
+ if (conflicted !== undefined)
101
+ record(conflicted, descriptor, message);
102
+ }
103
+ // A path git listed as conflicted with no CONFLICT message is still conflicted.
104
+ for (const path of conflictedPaths) {
105
+ const normalized = normalizeRepoRelativePath(path) ?? path;
106
+ if (!byPath.has(normalized))
107
+ record(path, null, null);
108
+ }
109
+ const conflicts = Array.from(byPath.values())
110
+ .sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0))
111
+ .slice(0, MERGE_TREE_MAX_CONFLICTS);
112
+ return { treeOid, conflicts };
113
+ }
114
+ /** Verify a commit object is present locally. */
115
+ async function commitPresent(runCommand, repoDir, sha) {
116
+ try {
117
+ const result = await runCommand("git", ["cat-file", "-e", `${sha}^{commit}`], {
118
+ cwd: repoDir,
119
+ timeoutMs: MERGE_TREE_COMMAND_TIMEOUT_MS,
120
+ });
121
+ return result.exitCode === 0;
122
+ }
123
+ catch {
124
+ return false;
125
+ }
126
+ }
127
+ /**
128
+ * Classify merging `requiredEpicSha` into `childHead`. Never mutates a worktree,
129
+ * an index, `HEAD`, or a ref. Both SHAs must already be present in `repoDir`'s
130
+ * object database (the caller fetches them).
131
+ */
132
+ export async function classifyMergeTree(runCommand, repoDir, childHead, requiredEpicSha) {
133
+ for (const sha of [childHead, requiredEpicSha]) {
134
+ if (!OID_RE.test(sha)) {
135
+ return { kind: "operational_error", reason: "a merge input is not a full commit SHA" };
136
+ }
137
+ if (!(await commitPresent(runCommand, repoDir, sha))) {
138
+ return {
139
+ kind: "operational_error",
140
+ reason: `commit ${sha.slice(0, 12)} is not present in the local object database`,
141
+ };
142
+ }
143
+ }
144
+ let result;
145
+ try {
146
+ result = await runCommand("git", ["merge-tree", "--write-tree", "-z", "--name-only", "--messages", childHead, requiredEpicSha], { cwd: repoDir, timeoutMs: MERGE_TREE_COMMAND_TIMEOUT_MS });
147
+ }
148
+ catch {
149
+ return { kind: "operational_error", reason: "git merge-tree could not be run" };
150
+ }
151
+ if (result.exitCode !== 0 && result.exitCode !== 1) {
152
+ return { kind: "operational_error", reason: `git merge-tree exited ${result.exitCode}` };
153
+ }
154
+ const parsed = parseMergeTreeOutput(result.stdout);
155
+ if (parsed === null) {
156
+ return { kind: "operational_error", reason: "git merge-tree produced no tree object" };
157
+ }
158
+ if (result.exitCode === 0) {
159
+ return { kind: "clean", treeOid: parsed.treeOid };
160
+ }
161
+ if (parsed.conflicts.length === 0) {
162
+ // Exit 1 WITH a tree but no conflict record: git said "not clean" and did not
163
+ // say why. Unknown is conflicted, never clean.
164
+ return {
165
+ kind: "conflicted",
166
+ treeOid: parsed.treeOid,
167
+ conflicts: [{ path: "(unreported)", descriptor: "CONFLICT (unknown)", messages: [] }],
168
+ };
169
+ }
170
+ return { kind: "conflicted", treeOid: parsed.treeOid, conflicts: parsed.conflicts };
171
+ }