Teach ActionExecutionException and downstream structures about DetailedExitCode

I intend this change to be a pure refactoring. It should entail no
externally observable change in behavior.

This replaces ActionExecutionException's handling of ExitCode values. It
now handles DetailedExitCode values.

The upstream effects include:
* SpawnExecException's translation to ActionExecutionException gives it a
  DetailedExitCode instead of an ExitCode.

SpawnExecException could relay its SpawnResult's FailureDetail, but to
keep this change pure, it does not, yet.

SpawnExecException and ActionExecutionException continue to encode the
idea of a "user error" failure with a null DetailedExitCode value, as
they encoded that idea before with a null ExitCode value. For detailed
user errors this must change, but it isn't changed yet.

The downstream effects include:
* BuildResult handles DetailedExitCode instead.
* BlazeCommandResult now preferably handles DetailedExitCode. It retains
  its ExitCode-only factory methods, for now.
* BuildFailedException handles DetailedExitCode instead.

RELNOTES: None.
PiperOrigin-RevId: 298643766
diff --git a/src/main/java/com/google/devtools/build/lib/BUILD b/src/main/java/com/google/devtools/build/lib/BUILD
index 938890f..f077ea8 100644
--- a/src/main/java/com/google/devtools/build/lib/BUILD
+++ b/src/main/java/com/google/devtools/build/lib/BUILD
@@ -308,6 +308,7 @@
         ":exitcode-external",
         ":failure_detail_util",
         "//src/main/protobuf:failure_details_java_proto",
+        "//third_party:guava",
         "//third_party:jsr305",
     ],
 )
@@ -587,6 +588,7 @@
         ":bug-report",
         ":build-request-options",
         ":command-utils",
+        ":detailed_exit_code",
         ":events",
         ":exitcode-external",
         ":keep-going-option",
diff --git a/src/main/java/com/google/devtools/build/lib/actions/ActionExecutionException.java b/src/main/java/com/google/devtools/build/lib/actions/ActionExecutionException.java
index f16d441..c9becf1 100644
--- a/src/main/java/com/google/devtools/build/lib/actions/ActionExecutionException.java
+++ b/src/main/java/com/google/devtools/build/lib/actions/ActionExecutionException.java
@@ -20,6 +20,8 @@
 import com.google.devtools.build.lib.collect.nestedset.Order;
 import com.google.devtools.build.lib.concurrent.ThreadSafety.ThreadSafe;
 import com.google.devtools.build.lib.events.Location;
+import com.google.devtools.build.lib.server.FailureDetails.FailureDetail;
+import com.google.devtools.build.lib.util.DetailedExitCode;
 import com.google.devtools.build.lib.util.ExitCode;
 import javax.annotation.Nullable;
 
@@ -33,14 +35,14 @@
   private final Action action;
   private final NestedSet<Cause> rootCauses;
   private final boolean catastrophe;
-  @Nullable private final ExitCode exitCode;
+  @Nullable private final DetailedExitCode detailedExitCode;
 
   public ActionExecutionException(Throwable cause, Action action, boolean catastrophe) {
     super(cause.getMessage(), cause);
     this.action = action;
     this.rootCauses = rootCausesFromAction(action);
     this.catastrophe = catastrophe;
-    this.exitCode = null;
+    this.detailedExitCode = null;
   }
 
   public ActionExecutionException(String message,
@@ -49,17 +51,20 @@
     this.action = action;
     this.rootCauses = rootCausesFromAction(action);
     this.catastrophe = catastrophe;
-    this.exitCode = null;
+    this.detailedExitCode = null;
   }
 
-  public ActionExecutionException(String message,
-                                  Throwable cause, Action action, boolean catastrophe,
-                                  ExitCode exitCode) {
+  public ActionExecutionException(
+      String message,
+      Throwable cause,
+      Action action,
+      boolean catastrophe,
+      DetailedExitCode detailedExitCode) {
     super(message, cause);
     this.action = action;
     this.rootCauses = rootCausesFromAction(action);
     this.catastrophe = catastrophe;
-    this.exitCode = exitCode;
+    this.detailedExitCode = detailedExitCode;
   }
 
   public ActionExecutionException(String message, Action action, boolean catastrophe) {
@@ -67,7 +72,7 @@
     this.action = action;
     this.rootCauses = rootCausesFromAction(action);
     this.catastrophe = catastrophe;
-    this.exitCode = null;
+    this.detailedExitCode = null;
   }
 
   public ActionExecutionException(String message, Action action, boolean catastrophe,
@@ -76,7 +81,7 @@
     this.action = action;
     this.rootCauses = rootCausesFromAction(action);
     this.catastrophe = catastrophe;
-    this.exitCode = exitCode;
+    this.detailedExitCode = DetailedExitCode.justExitCode(exitCode);
   }
 
   public ActionExecutionException(
@@ -85,7 +90,7 @@
     this.action = action;
     this.rootCauses = rootCauses;
     this.catastrophe = catastrophe;
-    this.exitCode = null;
+    this.detailedExitCode = null;
   }
 
   public ActionExecutionException(
@@ -98,7 +103,7 @@
     this.action = action;
     this.rootCauses = rootCauses;
     this.catastrophe = catastrophe;
-    this.exitCode = null;
+    this.detailedExitCode = null;
   }
 
   public ActionExecutionException(
@@ -107,15 +112,15 @@
       Action action,
       NestedSet<Cause> rootCauses,
       boolean catastrophe,
-      ExitCode exitCode) {
+      DetailedExitCode detailedExitCode) {
     super(message, cause);
     this.action = action;
     this.rootCauses = rootCauses;
     this.catastrophe = catastrophe;
-    this.exitCode = exitCode;
+    this.detailedExitCode = detailedExitCode;
   }
 
-  static NestedSet<Cause> rootCausesFromAction(Action action) {
+  private static NestedSet<Cause> rootCausesFromAction(Action action) {
     return action == null || action.getOwner() == null || action.getOwner().getLabel() == null
         ? NestedSetBuilder.<Cause>emptySet(Order.STABLE_ORDER)
         : NestedSetBuilder.<Cause>create(
@@ -155,8 +160,33 @@
     return catastrophe;
   }
 
-  @Nullable public ExitCode getExitCode() {
-    return exitCode;
+  /**
+   * Returns the exit code to return from this Bazel invocation because of this action execution
+   * failure.
+   *
+   * <p>Returns {@code null} if the exception is not intended to cause the invocation to fail, or if
+   * the failure is attributable to the user. (In the latter case, ExitCode.BUILD_FAILURE with
+   * numeric value 1 will be returned.)
+   */
+  @Nullable
+  public ExitCode getExitCode() {
+    return detailedExitCode == null ? null : detailedExitCode.getExitCode();
+  }
+
+  /**
+   * Returns the pair of {@link ExitCode} and optional {@link FailureDetail} to return from this
+   * Bazel invocation because of this action execution failure.
+   *
+   * <p>Returns {@code null} if the exception is not intended to cause the invocation to fail, or if
+   * the failure is attributable to the user. (In the latter case, ExitCode.BUILD_FAILURE with
+   * numeric value 1 will be returned for an exit code, and no FailureDetail will be returned.)
+   */
+  // TODO(b/138456686): for detailed user failures, this must be able to return non-null for
+  //  user-attributable failures. The meaning of "null" must be changed in code paths handling this
+  //  returned value.
+  @Nullable
+  public DetailedExitCode getDetailedExitCode() {
+    return detailedExitCode;
   }
 
   /**
diff --git a/src/main/java/com/google/devtools/build/lib/actions/AlreadyReportedActionExecutionException.java b/src/main/java/com/google/devtools/build/lib/actions/AlreadyReportedActionExecutionException.java
index 3efc919..fa9cd46 100644
--- a/src/main/java/com/google/devtools/build/lib/actions/AlreadyReportedActionExecutionException.java
+++ b/src/main/java/com/google/devtools/build/lib/actions/AlreadyReportedActionExecutionException.java
@@ -29,8 +29,13 @@
 public class AlreadyReportedActionExecutionException extends ActionExecutionException {
 
   public AlreadyReportedActionExecutionException(ActionExecutionException cause) {
-    super(cause.getMessage(), cause.getCause(), cause.getAction(), cause.getRootCauses(),
-        cause.isCatastrophe(), cause.getExitCode());
+    super(
+        cause.getMessage(),
+        cause.getCause(),
+        cause.getAction(),
+        cause.getRootCauses(),
+        cause.isCatastrophe(),
+        cause.getDetailedExitCode());
   }
 
   @Override
diff --git a/src/main/java/com/google/devtools/build/lib/actions/BUILD b/src/main/java/com/google/devtools/build/lib/actions/BUILD
index 741afaa..f922d67 100644
--- a/src/main/java/com/google/devtools/build/lib/actions/BUILD
+++ b/src/main/java/com/google/devtools/build/lib/actions/BUILD
@@ -34,6 +34,7 @@
         ":localhost_capacity",
         "//src/main/java/com/google/devtools/build/lib:bug-report",
         "//src/main/java/com/google/devtools/build/lib:command-utils",
+        "//src/main/java/com/google/devtools/build/lib:detailed_exit_code",
         "//src/main/java/com/google/devtools/build/lib:events",
         "//src/main/java/com/google/devtools/build/lib:packages-internal",
         "//src/main/java/com/google/devtools/build/lib:unix",
diff --git a/src/main/java/com/google/devtools/build/lib/actions/BuildFailedException.java b/src/main/java/com/google/devtools/build/lib/actions/BuildFailedException.java
index 622f249..d0aa35b 100644
--- a/src/main/java/com/google/devtools/build/lib/actions/BuildFailedException.java
+++ b/src/main/java/com/google/devtools/build/lib/actions/BuildFailedException.java
@@ -19,6 +19,8 @@
 import com.google.devtools.build.lib.collect.nestedset.NestedSetBuilder;
 import com.google.devtools.build.lib.collect.nestedset.Order;
 import com.google.devtools.build.lib.concurrent.ThreadSafety.ThreadSafe;
+import com.google.devtools.build.lib.server.FailureDetails.FailureDetail;
+import com.google.devtools.build.lib.util.DetailedExitCode;
 import com.google.devtools.build.lib.util.ExitCode;
 import javax.annotation.Nullable;
 
@@ -33,7 +35,7 @@
  *
  * <p>This exception typically leads to Bazel termination with exit code {@link
  * ExitCode#BUILD_FAILURE}. However, if a more specific exit code is appropriate, it can be
- * propagated by specifying the exit code to the constructor.
+ * propagated by specifying the exit code to the constructor using a {@link DetailedExitCode}.
  */
 @ThreadSafe
 public class BuildFailedException extends Exception {
@@ -41,7 +43,7 @@
   private final Action action;
   private final NestedSet<Cause> rootCauses;
   private final boolean errorAlreadyShown;
-  @Nullable private final ExitCode exitCode;
+  @Nullable private final DetailedExitCode detailedExitCode;
 
   public BuildFailedException() {
     this(null);
@@ -51,8 +53,14 @@
     this(message, false, null, NestedSetBuilder.emptySet(Order.STABLE_ORDER), false, null);
   }
 
-  public BuildFailedException(String message, ExitCode exitCode) {
-    this(message, false, null, NestedSetBuilder.emptySet(Order.STABLE_ORDER), false, exitCode);
+  public BuildFailedException(String message, DetailedExitCode detailedExitCode) {
+    this(
+        message,
+        false,
+        null,
+        NestedSetBuilder.emptySet(Order.STABLE_ORDER),
+        false,
+        detailedExitCode);
   }
 
   public BuildFailedException(String message, boolean catastrophic) {
@@ -65,13 +73,13 @@
       Action action,
       NestedSet<Cause> rootCauses,
       boolean errorAlreadyShown,
-      ExitCode exitCode) {
+      @Nullable DetailedExitCode detailedExitCode) {
     super(message);
     this.catastrophic = catastrophic;
     this.rootCauses = rootCauses;
     this.action = action;
     this.errorAlreadyShown = errorAlreadyShown;
-    this.exitCode = exitCode;
+    this.detailedExitCode = detailedExitCode;
   }
 
   public boolean isCatastrophic() {
@@ -90,7 +98,19 @@
     return errorAlreadyShown || getMessage() == null;
   }
 
-  @Nullable public ExitCode getExitCode() {
-    return exitCode;
+  /**
+   * Returns the pair of {@link ExitCode} and optional {@link FailureDetail} to return from this
+   * Bazel invocation.
+   *
+   * <p>Returns {@code null} if the failure is attributable to the user. (In this case,
+   * ExitCode.BUILD_FAILURE with numeric value 1 will be returned for an exit code, and no
+   * FailureDetail will be returned.)
+   */
+  // TODO(b/138456686): for detailed user failures, this must be able to return non-null for
+  //  user-attributable failures. The meaning of "null" must be changed in code paths handling this
+  //  returned value.
+  @Nullable
+  public DetailedExitCode getDetailedExitCode() {
+    return detailedExitCode;
   }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/buildtool/BuildResult.java b/src/main/java/com/google/devtools/build/lib/buildtool/BuildResult.java
index c4e7686..0b50ebf 100644
--- a/src/main/java/com/google/devtools/build/lib/buildtool/BuildResult.java
+++ b/src/main/java/com/google/devtools/build/lib/buildtool/BuildResult.java
@@ -26,6 +26,7 @@
 import com.google.devtools.build.lib.buildeventstream.BuildToolLogs;
 import com.google.devtools.build.lib.buildeventstream.BuildToolLogs.LogFileEntry;
 import com.google.devtools.build.lib.skyframe.AspectValue;
+import com.google.devtools.build.lib.util.DetailedExitCode;
 import com.google.devtools.build.lib.util.ExitCode;
 import com.google.devtools.build.lib.util.Pair;
 import com.google.devtools.build.lib.vfs.Path;
@@ -49,7 +50,8 @@
   private Throwable crash = null;
   private boolean catastrophe = false;
   private boolean stopOnFirstFailure;
-  private ExitCode exitCondition = ExitCode.BLAZE_INTERNAL_ERROR;
+  private DetailedExitCode detailedExitCode =
+      DetailedExitCode.justExitCode(ExitCode.BLAZE_INTERNAL_ERROR);
 
   private BuildConfigurationCollection configurations;
   private Collection<ConfiguredTarget> actualTargets;
@@ -101,22 +103,21 @@
     return wasSuspended;
   }
 
-  public void setExitCondition(ExitCode exitCondition) {
-    this.exitCondition = exitCondition;
+  public void setDetailedExitCode(DetailedExitCode detailedExitCode) {
+    this.detailedExitCode = detailedExitCode;
   }
 
-  /**
-   * True iff the build request has been successfully completed.
-   */
+  /** True iff the build request has been successfully completed. */
   public boolean getSuccess() {
-    return exitCondition.equals(ExitCode.SUCCESS);
+    return detailedExitCode.isSuccess();
   }
 
   /**
-   * Gets the Blaze exit condition.
+   * Gets the {@link DetailedExitCode} containing the {@link ExitCode} and optional failure detail
+   * to complete the command with.
    */
-  public ExitCode getExitCondition() {
-    return exitCondition;
+  public DetailedExitCode getDetailedExitCode() {
+    return detailedExitCode;
   }
 
   /**
@@ -283,7 +284,7 @@
         .add("stopTimeMillis", stopTimeMillis)
         .add("crash", crash)
         .add("catastrophe", catastrophe)
-        .add("exitCondition", exitCondition)
+        .add("detailedExitCode", detailedExitCode)
         .add("actualTargets", actualTargets)
         .add("testTargets", testTargets)
         .add("successfulTargets", successfulTargets)
diff --git a/src/main/java/com/google/devtools/build/lib/buildtool/BuildTool.java b/src/main/java/com/google/devtools/build/lib/buildtool/BuildTool.java
index d625154..45f7607 100644
--- a/src/main/java/com/google/devtools/build/lib/buildtool/BuildTool.java
+++ b/src/main/java/com/google/devtools/build/lib/buildtool/BuildTool.java
@@ -47,6 +47,7 @@
 import com.google.devtools.build.lib.runtime.BlazeRuntime;
 import com.google.devtools.build.lib.runtime.CommandEnvironment;
 import com.google.devtools.build.lib.util.AbruptExitException;
+import com.google.devtools.build.lib.util.DetailedExitCode;
 import com.google.devtools.build.lib.util.ExitCode;
 import com.google.devtools.build.lib.vfs.Path;
 import com.google.devtools.common.options.OptionsProvider;
@@ -283,10 +284,11 @@
     maybeSetStopOnFirstFailure(request, result);
     int startSuspendCount = suspendCount();
     Throwable catastrophe = null;
-    ExitCode exitCode = ExitCode.BLAZE_INTERNAL_ERROR;
+    DetailedExitCode detailedExitCode =
+        DetailedExitCode.justExitCode(ExitCode.BLAZE_INTERNAL_ERROR);
     try {
       buildTargets(request, result, validator);
-      exitCode = ExitCode.SUCCESS;
+      detailedExitCode = DetailedExitCode.justExitCode(ExitCode.SUCCESS);
     } catch (BuildFailedException e) {
       if (e.isErrorAlreadyShown()) {
         // The actual error has already been reported by the Builder.
@@ -296,34 +298,38 @@
       if (e.isCatastrophic()) {
         result.setCatastrophe();
       }
-      exitCode = e.getExitCode() != null ? e.getExitCode() : ExitCode.BUILD_FAILURE;
+      detailedExitCode =
+          e.getDetailedExitCode() != null
+              ? e.getDetailedExitCode()
+              : DetailedExitCode.justExitCode(ExitCode.BUILD_FAILURE);
     } catch (InterruptedException e) {
       // We may have been interrupted by an error, or the user's interruption may have raced with
       // an error, so check to see if we should report that error code instead.
-      exitCode = env.getPendingExitCode();
-      if (exitCode == null) {
-        exitCode = ExitCode.INTERRUPTED;
+      ExitCode environmentPendingExitCode = env.getPendingExitCode();
+      if (environmentPendingExitCode == null) {
+        detailedExitCode = DetailedExitCode.justExitCode(ExitCode.INTERRUPTED);
         env.getReporter().handle(Event.error("build interrupted"));
         env.getEventBus().post(new BuildInterruptedEvent());
       } else {
         // Report the exception from the environment - the exception we're handling here is just an
         // interruption.
+        detailedExitCode = DetailedExitCode.justExitCode(environmentPendingExitCode);
         reportExceptionError(env.getPendingException());
         result.setCatastrophe();
       }
     } catch (TargetParsingException | LoadingFailedException | ViewCreationFailedException e) {
-      exitCode = ExitCode.PARSING_FAILURE;
+      detailedExitCode = DetailedExitCode.justExitCode(ExitCode.PARSING_FAILURE);
       reportExceptionError(e);
     } catch (PostAnalysisQueryCommandLineException e) {
-      exitCode = ExitCode.COMMAND_LINE_ERROR;
+      detailedExitCode = DetailedExitCode.justExitCode(ExitCode.COMMAND_LINE_ERROR);
       reportExceptionError(e);
     } catch (TestExecException e) {
       // ExitCode.SUCCESS means that build was successful. Real return code of program
       // is going to be calculated in TestCommand.doTest().
-      exitCode = ExitCode.SUCCESS;
+      detailedExitCode = DetailedExitCode.justExitCode(ExitCode.SUCCESS);
       reportExceptionError(e);
     } catch (InvalidConfigurationException e) {
-      exitCode = ExitCode.COMMAND_LINE_ERROR;
+      detailedExitCode = DetailedExitCode.justExitCode(ExitCode.COMMAND_LINE_ERROR);
       reportExceptionError(e);
       // TODO(gregce): With "global configurations" we cannot tie a configuration creation failure
       // to a single target and have to halt the entire build. Once configurations are genuinely
@@ -331,14 +337,14 @@
       // target(s) that triggered them.
       result.setCatastrophe();
     } catch (AbruptExitException e) {
-      exitCode = e.getExitCode();
+      detailedExitCode = DetailedExitCode.justExitCode(e.getExitCode());
       reportExceptionError(e);
       result.setCatastrophe();
     } catch (Throwable throwable) {
       catastrophe = throwable;
       Throwables.propagate(throwable);
     } finally {
-      stopRequest(result, catastrophe, exitCode, startSuspendCount);
+      stopRequest(result, catastrophe, detailedExitCode, startSuspendCount);
     }
 
     return result;
@@ -377,17 +383,20 @@
    *
    * @param result result to update
    * @param crash any unexpected {@link RuntimeException} or {@link Error}. May be null
-   * @param exitCondition a suggested exit condition from either the build logic or a thrown
-   *     exception somewhere along the way
+   * @param detailedExitCode describes the exit code and an optional detailed failure value to add
+   *     to {@code result}
    * @param startSuspendCount number of suspensions before the build started
    */
   public void stopRequest(
-      BuildResult result, Throwable crash, ExitCode exitCondition, int startSuspendCount) {
-    Preconditions.checkState((crash == null) || !exitCondition.equals(ExitCode.SUCCESS));
+      BuildResult result,
+      Throwable crash,
+      DetailedExitCode detailedExitCode,
+      int startSuspendCount) {
+    Preconditions.checkState((crash == null) || !detailedExitCode.isSuccess());
     int stopSuspendCount = suspendCount();
     Preconditions.checkState(startSuspendCount <= stopSuspendCount);
     result.setUnhandledThrowable(crash);
-    result.setExitCondition(exitCondition);
+    result.setDetailedExitCode(detailedExitCode);
     InterruptedException ie = null;
     try {
       env.getSkyframeExecutor().notifyCommandComplete(env.getReporter());
@@ -410,14 +419,13 @@
     // modules add their data to the collection.
     env.getEventBus().post(result.getBuildToolLogCollection().freeze().toEvent());
     if (ie != null) {
-      if (exitCondition.equals(ExitCode.SUCCESS)) {
-        result.setExitCondition(ExitCode.INTERRUPTED);
-      } else if (!exitCondition.equals(ExitCode.INTERRUPTED)) {
+      if (detailedExitCode.isSuccess()) {
+        result.setDetailedExitCode(DetailedExitCode.justExitCode(ExitCode.INTERRUPTED));
+      } else if (!detailedExitCode.getExitCode().equals(ExitCode.INTERRUPTED)) {
         logger.log(
             Level.WARNING,
-            "Suppressed interrupted exception during stop request because already failing with exit"
-                + " code "
-                + exitCondition,
+            "Suppressed interrupted exception during stop request because already failing with: "
+                + detailedExitCode,
             ie);
       }
     }
diff --git a/src/main/java/com/google/devtools/build/lib/buildtool/SkyframeBuilder.java b/src/main/java/com/google/devtools/build/lib/buildtool/SkyframeBuilder.java
index 529e983..0cb1821 100644
--- a/src/main/java/com/google/devtools/build/lib/buildtool/SkyframeBuilder.java
+++ b/src/main/java/com/google/devtools/build/lib/buildtool/SkyframeBuilder.java
@@ -47,7 +47,7 @@
 import com.google.devtools.build.lib.skyframe.SkyframeExecutor;
 import com.google.devtools.build.lib.skyframe.TopDownActionCache;
 import com.google.devtools.build.lib.util.AbruptExitException;
-import com.google.devtools.build.lib.util.ExitCode;
+import com.google.devtools.build.lib.util.DetailedExitCode;
 import com.google.devtools.build.lib.util.LoggingUtil;
 import com.google.devtools.build.lib.vfs.ModifiedFileSet;
 import com.google.devtools.build.skyframe.CycleInfo;
@@ -55,10 +55,10 @@
 import com.google.devtools.build.skyframe.EvaluationResult;
 import com.google.devtools.build.skyframe.SkyKey;
 import com.google.devtools.common.options.OptionsProvider;
+import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.Comparator;
-import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -129,7 +129,7 @@
         .getEventBus()
         .post(new ExecutionProgressReceiverAvailableEvent(executionProgressReceiver));
 
-    List<ExitCode> exitCodes = new LinkedList<>();
+    List<DetailedExitCode> detailedExitCodes = new ArrayList<>();
     EvaluationResult<?> result;
 
     ActionExecutionStatusReporter statusReporter = ActionExecutionStatusReporter.create(
@@ -168,15 +168,15 @@
               executionProgressReceiver,
               topLevelArtifactContext);
       // progressReceiver is finished, so unsynchronized access to builtTargets is now safe.
-      Optional<ExitCode> exitCode =
+      Optional<DetailedExitCode> detailedExitCode =
           processResult(
               reporter,
               result,
               options.getOptions(KeepGoingOption.class).keepGoing,
               skyframeExecutor);
 
-      if (exitCode != null) {
-        exitCodes.add(exitCode.orNull());
+      if (detailedExitCode != null) {
+        detailedExitCodes.add(detailedExitCode.orNull());
       }
 
       // Run exclusive tests: either tagged as "exclusive" or is run in an invocation with
@@ -196,20 +196,20 @@
                 topDownActionCache,
                 null,
                 topLevelArtifactContext);
-        exitCode =
+        detailedExitCode =
             processResult(
                 reporter,
                 result,
                 options.getOptions(KeepGoingOption.class).keepGoing,
                 skyframeExecutor);
         Preconditions.checkState(
-            exitCode != null || !result.keyNames().isEmpty(),
+            detailedExitCode != null || !result.keyNames().isEmpty(),
             "Build reported as successful but test %s not executed: %s",
             exclusiveTest,
             result);
 
-        if (exitCode != null) {
-          exitCodes.add(exitCode.orNull());
+        if (detailedExitCode != null) {
+          detailedExitCodes.add(detailedExitCode.orNull());
         }
       }
     } finally {
@@ -218,11 +218,11 @@
       statusReporter.unregisterFromEventBus();
     }
 
-    if (!exitCodes.isEmpty()) {
+    if (!detailedExitCodes.isEmpty()) {
       if (options.getOptions(KeepGoingOption.class).keepGoing) {
         // Use the exit code with the highest priority.
         throw new BuildFailedException(
-            null, Collections.max(exitCodes, ExitCodeComparator.INSTANCE));
+            null, Collections.max(detailedExitCodes, DetailedExitCodeComparator.INSTANCE));
       } else {
         throw new BuildFailedException();
       }
@@ -230,15 +230,22 @@
   }
 
   /**
-   * Process the Skyframe update, taking into account the keepGoing setting.
+   * Process an {@link EvaluationResult}, taking into account the keepGoing setting.
    *
-   * <p>Returns optional {@link ExitCode} based on following conditions: 1. null, if result had no
-   * errors. 2. Optional.absent(), if result had errors but none of the errors specified an exit
-   * code. 3. Optional.of(e), if result had errors and one of them specified exit code 'e'. Throws
-   * on fail-fast failures.
+   * <p>Returns a nullable optional {@link DetailedExitCode} value, as follows:
+   *
+   * <ol>
+   *   <li>{@code null}, if {@code result} had no errors
+   *   <li>{@code Optional.absent()}, if result had errors but none of the errors specified a {@link
+   *       DetailedExitCode}
+   *   <li>{@code Optional.of(e)} if result had errors and one of them specified a {@link
+   *       DetailedExitCode} value {@code e}.
+   * </ol>
+   *
+   * <p>Throws on catastrophic failures.
    */
   @Nullable
-  private static Optional<ExitCode> processResult(
+  private static Optional<DetailedExitCode> processResult(
       ExtendedEventHandler eventHandler,
       EvaluationResult<?> result,
       boolean keepGoing,
@@ -259,21 +266,21 @@
         //   1. First infrastructure error with non-null exit code
         //   2. First non-infrastructure error with non-null exit code
         //   3. Null (later default to 1)
-        ExitCode exitCode = null;
+        DetailedExitCode detailedExitCode = null;
         for (Map.Entry<SkyKey, ErrorInfo> error : result.errorMap().entrySet()) {
           Throwable cause = error.getValue().getException();
           if (cause instanceof ActionExecutionException) {
             ActionExecutionException actionExecutionCause = (ActionExecutionException) cause;
-            ExitCode code = actionExecutionCause.getExitCode();
+            DetailedExitCode thisCode = actionExecutionCause.getDetailedExitCode();
             // Update global exit code when current exit code is not null and global exit code has
             // a lower 'reporting' priority.
-            if (ExitCodeComparator.INSTANCE.compare(code, exitCode) > 0) {
-              exitCode = code;
+            if (DetailedExitCodeComparator.INSTANCE.compare(thisCode, detailedExitCode) > 0) {
+              detailedExitCode = thisCode;
             }
           }
         }
 
-        return Optional.fromNullable(exitCode);
+        return Optional.fromNullable(detailedExitCode);
       }
       ErrorInfo errorInfo = Preconditions.checkNotNull(result.getError(), result);
       Exception exception = errorInfo.getException();
@@ -312,7 +319,7 @@
           actionExecutionCause.getAction(),
           actionExecutionCause.getRootCauses(),
           /*errorAlreadyShown=*/ !actionExecutionCause.showError(),
-          actionExecutionCause.getExitCode());
+          actionExecutionCause.getDetailedExitCode());
     } else if (cause instanceof MissingInputFileException) {
       throw new BuildFailedException(cause.getMessage());
     } else if (cause instanceof BuildFileNotFoundException) {
@@ -342,24 +349,24 @@
   }
 
   /**
-   * A comparator to determine the reporting priority of {@link ExitCode}.
+   * A comparator to determine the reporting priority of {@link DetailedExitCode}.
    *
-   * <p> Priority: infrastructure exit codes > non-infrastructure exit codes > null exit codes.
+   * <p>Priority: infrastructure exit codes > non-infrastructure exit codes > null exit codes.
    */
-  private static class ExitCodeComparator implements Comparator<ExitCode> {
-    private static final ExitCodeComparator INSTANCE = new ExitCodeComparator();
+  private static class DetailedExitCodeComparator implements Comparator<DetailedExitCode> {
+    private static final DetailedExitCodeComparator INSTANCE = new DetailedExitCodeComparator();
 
     @Override
-    public int compare(ExitCode c1, ExitCode c2) {
+    public int compare(DetailedExitCode c1, DetailedExitCode c2) {
       // returns POSITIVE result when the priority of c1 is HIGHER than the priority of c2
       return getPriority(c1) - getPriority(c2);
     }
 
-    private int getPriority(ExitCode code) {
+    private static int getPriority(DetailedExitCode code) {
       if (code == null) {
         return 0;
       } else {
-        return code.isInfrastructureFailure() ? 2 : 1;
+        return code.getExitCode().isInfrastructureFailure() ? 2 : 1;
       }
     }
   }
diff --git a/src/main/java/com/google/devtools/build/lib/buildtool/buildevent/BuildCompleteEvent.java b/src/main/java/com/google/devtools/build/lib/buildtool/buildevent/BuildCompleteEvent.java
index 86516aa..c4b6744 100644
--- a/src/main/java/com/google/devtools/build/lib/buildtool/buildevent/BuildCompleteEvent.java
+++ b/src/main/java/com/google/devtools/build/lib/buildtool/buildevent/BuildCompleteEvent.java
@@ -25,19 +25,23 @@
 /**
  * This event is fired from BuildTool#stopRequest().
  *
- * <p>This class also implements the {@link BuildFinished} event of the build event protocol (BEP).
+ * <p>This class also implements the {@link BuildCompletingEvent} of the build event protocol (BEP).
  */
 public final class BuildCompleteEvent extends BuildCompletingEvent {
   private final BuildResult result;
 
   /** Construct the BuildCompleteEvent. */
   public BuildCompleteEvent(BuildResult result, Collection<BuildEventId> children) {
-    super(result.getExitCondition(), result.getStopTime(), children, result.getWasSuspended());
+    super(
+        result.getDetailedExitCode().getExitCode(),
+        result.getStopTime(),
+        children,
+        result.getWasSuspended());
     this.result = checkNotNull(result);
   }
 
   public BuildCompleteEvent(BuildResult result) {
-    this(result, ImmutableList.<BuildEventId>of());
+    this(result, ImmutableList.of());
   }
 
   /**
diff --git a/src/main/java/com/google/devtools/build/lib/exec/SpawnExecException.java b/src/main/java/com/google/devtools/build/lib/exec/SpawnExecException.java
index 6beec44..091e0df 100644
--- a/src/main/java/com/google/devtools/build/lib/exec/SpawnExecException.java
+++ b/src/main/java/com/google/devtools/build/lib/exec/SpawnExecException.java
@@ -23,6 +23,7 @@
 import com.google.devtools.build.lib.actions.Spawn;
 import com.google.devtools.build.lib.actions.SpawnResult;
 import com.google.devtools.build.lib.actions.SpawnResult.Status;
+import com.google.devtools.build.lib.util.DetailedExitCode;
 import com.google.devtools.build.lib.util.ExitCode;
 
 /**
@@ -68,14 +69,15 @@
     String message =
         result.getDetailMessage(
             messagePrefix, getMessage(), verboseFailures, isCatastrophic(), forciblyRunRemotely);
-    return new ActionExecutionException(message, this, action, isCatastrophic(), getExitCode());
+    return new ActionExecutionException(
+        message, this, action, isCatastrophic(), getDetailedExitCode());
   }
 
   /** Return exit code depending on the spawn result. */
-  protected ExitCode getExitCode() {
+  private DetailedExitCode getDetailedExitCode() {
     if (result.status().isConsideredUserError()) {
       return null;
     }
-    return ExitCode.REMOTE_ERROR;
+    return DetailedExitCode.justExitCode(ExitCode.REMOTE_ERROR);
   }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/runtime/BlazeCommandResult.java b/src/main/java/com/google/devtools/build/lib/runtime/BlazeCommandResult.java
index 4292557..69646e4 100644
--- a/src/main/java/com/google/devtools/build/lib/runtime/BlazeCommandResult.java
+++ b/src/main/java/com/google/devtools/build/lib/runtime/BlazeCommandResult.java
@@ -82,6 +82,10 @@
     return new BlazeCommandResult(DetailedExitCode.of(failureDetail), null, false);
   }
 
+  public static BlazeCommandResult detailedExitCode(DetailedExitCode detailedExitCode) {
+    return new BlazeCommandResult(detailedExitCode, null, false);
+  }
+
   public static BlazeCommandResult execute(ExecRequest execDescription) {
     return new BlazeCommandResult(
         DetailedExitCode.justExitCode(ExitCode.SUCCESS),
diff --git a/src/main/java/com/google/devtools/build/lib/runtime/commands/AqueryCommand.java b/src/main/java/com/google/devtools/build/lib/runtime/commands/AqueryCommand.java
index 8984e2d..dd5c0c9 100644
--- a/src/main/java/com/google/devtools/build/lib/runtime/commands/AqueryCommand.java
+++ b/src/main/java/com/google/devtools/build/lib/runtime/commands/AqueryCommand.java
@@ -31,6 +31,7 @@
 import com.google.devtools.build.lib.runtime.BlazeRuntime;
 import com.google.devtools.build.lib.runtime.Command;
 import com.google.devtools.build.lib.runtime.CommandEnvironment;
+import com.google.devtools.build.lib.util.DetailedExitCode;
 import com.google.devtools.build.lib.util.ExitCode;
 import com.google.devtools.common.options.OptionPriority.PriorityCategory;
 import com.google.devtools.common.options.OptionsParser;
@@ -136,8 +137,9 @@
         return BlazeCommandResult.exitCode(ExitCode.COMMAND_LINE_ERROR);
       }
     }
-    ExitCode exitCode = aqueryBuildTool.processRequest(request, null).getExitCondition();
-    return BlazeCommandResult.exitCode(exitCode);
+    DetailedExitCode detailedExitCode =
+        aqueryBuildTool.processRequest(request, null).getDetailedExitCode();
+    return BlazeCommandResult.detailedExitCode(detailedExitCode);
   }
 
   private ImmutableMap<String, QueryFunction> getFunctionsMap(CommandEnvironment env) {
diff --git a/src/main/java/com/google/devtools/build/lib/runtime/commands/BuildCommand.java b/src/main/java/com/google/devtools/build/lib/runtime/commands/BuildCommand.java
index b2405e0..3ccc1ba 100644
--- a/src/main/java/com/google/devtools/build/lib/runtime/commands/BuildCommand.java
+++ b/src/main/java/com/google/devtools/build/lib/runtime/commands/BuildCommand.java
@@ -32,7 +32,7 @@
 import com.google.devtools.build.lib.runtime.CommandEnvironment;
 import com.google.devtools.build.lib.runtime.KeepGoingOption;
 import com.google.devtools.build.lib.runtime.LoadingPhaseThreadsOption;
-import com.google.devtools.build.lib.util.ExitCode;
+import com.google.devtools.build.lib.util.DetailedExitCode;
 import com.google.devtools.common.options.OptionsParsingResult;
 import java.util.List;
 
@@ -91,7 +91,8 @@
           targets,
           env.getReporter().getOutErr(), env.getCommandId(), env.getCommandStartTime());
     }
-    ExitCode exitCode = new BuildTool(env).processRequest(request, null).getExitCondition();
-    return BlazeCommandResult.exitCode(exitCode);
+    DetailedExitCode detailedExitCode =
+        new BuildTool(env).processRequest(request, null).getDetailedExitCode();
+    return BlazeCommandResult.detailedExitCode(detailedExitCode);
   }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/runtime/commands/CqueryCommand.java b/src/main/java/com/google/devtools/build/lib/runtime/commands/CqueryCommand.java
index 92b7277..918989b 100644
--- a/src/main/java/com/google/devtools/build/lib/runtime/commands/CqueryCommand.java
+++ b/src/main/java/com/google/devtools/build/lib/runtime/commands/CqueryCommand.java
@@ -30,6 +30,7 @@
 import com.google.devtools.build.lib.runtime.BlazeRuntime;
 import com.google.devtools.build.lib.runtime.Command;
 import com.google.devtools.build.lib.runtime.CommandEnvironment;
+import com.google.devtools.build.lib.util.DetailedExitCode;
 import com.google.devtools.build.lib.util.ExitCode;
 import com.google.devtools.common.options.OptionPriority.PriorityCategory;
 import com.google.devtools.common.options.OptionsParser;
@@ -128,8 +129,8 @@
             env.getReporter().getOutErr(),
             env.getCommandId(),
             env.getCommandStartTime());
-    ExitCode exitCode =
-        new CqueryBuildTool(env, expr).processRequest(request, null).getExitCondition();
-    return BlazeCommandResult.exitCode(exitCode);
+    DetailedExitCode detailedExitCode =
+        new CqueryBuildTool(env, expr).processRequest(request, null).getDetailedExitCode();
+    return BlazeCommandResult.detailedExitCode(detailedExitCode);
   }
 }
diff --git a/src/main/java/com/google/devtools/build/lib/runtime/commands/PrintActionCommand.java b/src/main/java/com/google/devtools/build/lib/runtime/commands/PrintActionCommand.java
index 3d0c2cd..4c4e2ae 100644
--- a/src/main/java/com/google/devtools/build/lib/runtime/commands/PrintActionCommand.java
+++ b/src/main/java/com/google/devtools/build/lib/runtime/commands/PrintActionCommand.java
@@ -50,6 +50,7 @@
 import com.google.devtools.build.lib.runtime.Command;
 import com.google.devtools.build.lib.runtime.CommandEnvironment;
 import com.google.devtools.build.lib.runtime.KeepGoingOption;
+import com.google.devtools.build.lib.util.DetailedExitCode;
 import com.google.devtools.build.lib.util.ExitCode;
 import com.google.devtools.build.lib.util.io.OutErr;
 import com.google.devtools.common.options.Option;
@@ -104,7 +105,7 @@
     PrintActionRunner runner = new PrintActionRunner(loadingOptions.compileOneDependency, options,
         env.getReporter().getOutErr(),
         options.getResidue(), Sets.newHashSet(printActionOptions.printActionMnemonics));
-    return BlazeCommandResult.exitCode(runner.printActionsForTargets(env));
+    return BlazeCommandResult.detailedExitCode(runner.printActionsForTargets(env));
   }
 
   /**
@@ -141,22 +142,22 @@
       };
     }
 
-    private ExitCode printActionsForTargets(CommandEnvironment env) {
+    private DetailedExitCode printActionsForTargets(CommandEnvironment env) {
       BuildResult result = gatherActionsForTargets(env, requestedTargets);
       if (result == null) {
-        return ExitCode.PARSING_FAILURE;
+        return DetailedExitCode.justExitCode(ExitCode.PARSING_FAILURE);
       }
       if (hasFatalBuildFailure(result)) {
         env.getReporter().handle(Event.error("Build failed when printing actions"));
-        return result.getExitCondition();
+        return result.getDetailedExitCode();
       }
       String action = TextFormat.printToString(summaryBuilder);
       if (!action.isEmpty()) {
         outErr.printOut(action);
-        return result.getExitCondition();
+        return result.getDetailedExitCode();
       } else {
         env.getReporter().handle(Event.error("no actions to print were found"));
-        return ExitCode.PARSING_FAILURE;
+        return DetailedExitCode.justExitCode(ExitCode.PARSING_FAILURE);
       }
     }
 
diff --git a/src/main/java/com/google/devtools/build/lib/runtime/commands/RunCommand.java b/src/main/java/com/google/devtools/build/lib/runtime/commands/RunCommand.java
index c5e8b50..f76eb13 100644
--- a/src/main/java/com/google/devtools/build/lib/runtime/commands/RunCommand.java
+++ b/src/main/java/com/google/devtools/build/lib/runtime/commands/RunCommand.java
@@ -289,7 +289,7 @@
 
     if (!result.getSuccess()) {
       env.getReporter().handle(Event.error("Build failed. Not running target"));
-      return BlazeCommandResult.exitCode(result.getExitCondition());
+      return BlazeCommandResult.detailedExitCode(result.getDetailedExitCode());
     }
 
     // Make sure that we have exactly 1 built target (excluding --run_under),
diff --git a/src/main/java/com/google/devtools/build/lib/runtime/commands/TestCommand.java b/src/main/java/com/google/devtools/build/lib/runtime/commands/TestCommand.java
index 20b998a..ecc37a9 100644
--- a/src/main/java/com/google/devtools/build/lib/runtime/commands/TestCommand.java
+++ b/src/main/java/com/google/devtools/build/lib/runtime/commands/TestCommand.java
@@ -40,6 +40,7 @@
 import com.google.devtools.build.lib.runtime.TestResultNotifier;
 import com.google.devtools.build.lib.runtime.TestSummaryPrinter.TestLogPathFormatter;
 import com.google.devtools.build.lib.runtime.UiOptions;
+import com.google.devtools.build.lib.util.DetailedExitCode;
 import com.google.devtools.build.lib.util.ExitCode;
 import com.google.devtools.build.lib.util.io.AnsiTerminalPrinter;
 import com.google.devtools.build.lib.vfs.Path;
@@ -136,13 +137,17 @@
       // (original exitcode=BUILD_FAILURE) or if there weren't but --noanalyze was given
       // (original exitcode=SUCCESS).
       env.getReporter().handle(Event.error("Couldn't start the build. Unable to run tests"));
-      ExitCode exitCode =
-          buildResult.getSuccess() ? ExitCode.PARSING_FAILURE : buildResult.getExitCondition();
+      DetailedExitCode detailedExitCode =
+          buildResult.getSuccess()
+              ? DetailedExitCode.justExitCode(ExitCode.PARSING_FAILURE)
+              : buildResult.getDetailedExitCode();
       env.getEventBus()
           .post(
               new TestingCompleteEvent(
-                  exitCode, buildResult.getStopTime(), buildResult.getWasSuspended()));
-      return BlazeCommandResult.exitCode(exitCode);
+                  detailedExitCode.getExitCode(),
+                  buildResult.getStopTime(),
+                  buildResult.getWasSuspended()));
+      return BlazeCommandResult.detailedExitCode(detailedExitCode);
     }
     // TODO(bazel-team): the check above shadows NO_TESTS_FOUND, but switching the conditions breaks
     // more tests
@@ -150,12 +155,17 @@
       env.getReporter().handle(Event.error(
           null, "No test targets were found, yet testing was requested"));
 
-      ExitCode exitCode =
-          buildResult.getSuccess() ? ExitCode.NO_TESTS_FOUND : buildResult.getExitCondition();
+      DetailedExitCode detailedExitCode =
+          buildResult.getSuccess()
+              ? DetailedExitCode.justExitCode(ExitCode.NO_TESTS_FOUND)
+              : buildResult.getDetailedExitCode();
       env.getEventBus()
           .post(
-              new NoTestsFound(exitCode, buildResult.getStopTime(), buildResult.getWasSuspended()));
-      return BlazeCommandResult.exitCode(exitCode);
+              new NoTestsFound(
+                  detailedExitCode.getExitCode(),
+                  buildResult.getStopTime(),
+                  buildResult.getWasSuspended()));
+      return BlazeCommandResult.detailedExitCode(detailedExitCode);
     }
 
     boolean buildSuccess = buildResult.getSuccess();
@@ -171,14 +181,19 @@
           + AnsiTerminalPrinter.Mode.DEFAULT);
     }
 
-    ExitCode exitCode = buildSuccess
-        ? (testSuccess ? ExitCode.SUCCESS : ExitCode.TESTS_FAILED)
-        : buildResult.getExitCondition();
+    DetailedExitCode detailedExitCode =
+        buildSuccess
+            ? (testSuccess
+                ? DetailedExitCode.justExitCode(ExitCode.SUCCESS)
+                : DetailedExitCode.justExitCode(ExitCode.TESTS_FAILED))
+            : buildResult.getDetailedExitCode();
     env.getEventBus()
         .post(
             new TestingCompleteEvent(
-                exitCode, buildResult.getStopTime(), buildResult.getWasSuspended()));
-    return BlazeCommandResult.exitCode(exitCode);
+                detailedExitCode.getExitCode(),
+                buildResult.getStopTime(),
+                buildResult.getWasSuspended()));
+    return BlazeCommandResult.detailedExitCode(detailedExitCode);
   }
 
   /**
diff --git a/src/main/java/com/google/devtools/build/lib/runtime/mobileinstall/MobileInstallCommand.java b/src/main/java/com/google/devtools/build/lib/runtime/mobileinstall/MobileInstallCommand.java
index 5493872..93fe3cd 100644
--- a/src/main/java/com/google/devtools/build/lib/runtime/mobileinstall/MobileInstallCommand.java
+++ b/src/main/java/com/google/devtools/build/lib/runtime/mobileinstall/MobileInstallCommand.java
@@ -41,6 +41,7 @@
 import com.google.devtools.build.lib.shell.BadExitStatusException;
 import com.google.devtools.build.lib.shell.CommandException;
 import com.google.devtools.build.lib.util.CommandBuilder;
+import com.google.devtools.build.lib.util.DetailedExitCode;
 import com.google.devtools.build.lib.util.ExitCode;
 import com.google.devtools.build.lib.util.io.OutErr;
 import com.google.devtools.build.lib.vfs.Path;
@@ -181,8 +182,9 @@
               env.getReporter().getOutErr(),
               env.getCommandId(),
               env.getCommandStartTime());
-      ExitCode exitCode = new BuildTool(env).processRequest(request, null).getExitCondition();
-      return BlazeCommandResult.exitCode(exitCode);
+      DetailedExitCode detailedExitCode =
+          new BuildTool(env).processRequest(request, null).getDetailedExitCode();
+      return BlazeCommandResult.detailedExitCode(detailedExitCode);
     }
 
     // This list should look like: ["//executable:target", "arg1", "arg2"]
@@ -211,7 +213,7 @@
 
     if (!result.getSuccess()) {
       env.getReporter().handle(Event.error("Build failed. Not running target"));
-      return BlazeCommandResult.exitCode(result.getExitCondition());
+      return BlazeCommandResult.detailedExitCode(result.getDetailedExitCode());
     }
 
     Collection<ConfiguredTarget> targetsBuilt = result.getSuccessfulTargets();
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/ActionExecutionFunction.java b/src/main/java/com/google/devtools/build/lib/skyframe/ActionExecutionFunction.java
index ed36d95..91b1f31 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/ActionExecutionFunction.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/ActionExecutionFunction.java
@@ -1266,7 +1266,7 @@
           action,
           rootCauses.build(),
           firstActionExecutionException.isCatastrophe(),
-          firstActionExecutionException.getExitCode());
+          firstActionExecutionException.getDetailedExitCode());
     }
 
     if (missingCount > 0) {
diff --git a/src/main/java/com/google/devtools/build/lib/skyframe/SkyframeExecutor.java b/src/main/java/com/google/devtools/build/lib/skyframe/SkyframeExecutor.java
index d6ae9c6..ef4cb7b 100644
--- a/src/main/java/com/google/devtools/build/lib/skyframe/SkyframeExecutor.java
+++ b/src/main/java/com/google/devtools/build/lib/skyframe/SkyframeExecutor.java
@@ -1886,7 +1886,6 @@
         getConfigurations(eventHandler, ImmutableList.of(options), options, keepGoing));
   }
 
-  @VisibleForTesting
   public BuildConfiguration getConfiguration(
       ExtendedEventHandler eventHandler, BuildConfigurationValue.Key configurationKey) {
     if (configurationKey == null) {
diff --git a/src/main/java/com/google/devtools/build/lib/util/DetailedExitCode.java b/src/main/java/com/google/devtools/build/lib/util/DetailedExitCode.java
index 520a0cf..de37710 100644
--- a/src/main/java/com/google/devtools/build/lib/util/DetailedExitCode.java
+++ b/src/main/java/com/google/devtools/build/lib/util/DetailedExitCode.java
@@ -14,6 +14,8 @@
 
 package com.google.devtools.build.lib.util;
 
+import static com.google.common.base.Preconditions.checkNotNull;
+
 import com.google.devtools.build.lib.server.FailureDetails.FailureDetail;
 import javax.annotation.Nullable;
 
@@ -36,8 +38,42 @@
     return failureDetail;
   }
 
+  public boolean isSuccess() {
+    return exitCode.equals(ExitCode.SUCCESS);
+  }
+
+  /**
+   * Returns a {@link DetailedExitCode} specifying {@link ExitCode} but no {@link FailureDetail}.
+   *
+   * <p>This method exists in order to allow for code which has not yet been wired for {@link
+   * FailureDetail) support to interact with {@link FailureDetail}-handling code infrastructure.
+   *
+   * <p>Callsites should migrate to using either:
+   *
+   * <ul>
+   *   <li>{@link #of(ExitCode, FailureDetail)}, when they're wired for {@link FailureDetail}
+   *   support but not yet ready to have {@link FailureDetail} metadata determine exit code behavior
+   *   <li>{@link #of(FailureDetail)}, when changing exit code behavior is desired.
+   * </ul>
+   *
+   */
   public static DetailedExitCode justExitCode(ExitCode exitCode) {
-    return new DetailedExitCode(exitCode, null);
+    return new DetailedExitCode(checkNotNull(exitCode), null);
+  }
+
+  /**
+   * Returns a {@link DetailedExitCode} combining the provided {@link FailureDetail} and {@link
+   * ExitCode}.
+   *
+   * <p>This method exists in order to allow for the introduction of new {@link
+   * FailureDetail)-handling code infrastructure without requiring any simultaneous change in exit
+   * code behavior.
+   *
+   * <p>Callsites should migrate to using {@link #of(FailureDetail)} instead.
+   */
+  // TODO(b/138456686): consider controlling this behavior by flag if migration appears risky.
+  public static DetailedExitCode of(ExitCode exitCode, FailureDetail failureDetail) {
+    return new DetailedExitCode(checkNotNull(exitCode), checkNotNull(failureDetail));
   }
 
   /**
diff --git a/src/test/java/com/google/devtools/build/lib/BUILD b/src/test/java/com/google/devtools/build/lib/BUILD
index 4fba77d..9e59e72 100644
--- a/src/test/java/com/google/devtools/build/lib/BUILD
+++ b/src/test/java/com/google/devtools/build/lib/BUILD
@@ -1261,6 +1261,7 @@
         "//src/main/java/com/google/devtools/build/lib:bazel-modules",
         "//src/main/java/com/google/devtools/build/lib:bazel-rules",
         "//src/main/java/com/google/devtools/build/lib:build-base",
+        "//src/main/java/com/google/devtools/build/lib:detailed_exit_code",
         "//src/main/java/com/google/devtools/build/lib:loading-phase-threads-option",
         "//src/main/java/com/google/devtools/build/lib:packages",
         "//src/main/java/com/google/devtools/build/lib:runtime",
diff --git a/src/test/java/com/google/devtools/build/lib/buildtool/BUILD b/src/test/java/com/google/devtools/build/lib/buildtool/BUILD
index b6337e2..0dfef12 100644
--- a/src/test/java/com/google/devtools/build/lib/buildtool/BUILD
+++ b/src/test/java/com/google/devtools/build/lib/buildtool/BUILD
@@ -21,6 +21,7 @@
         "//src/main/java/com/google/devtools/build/lib:build-base",
         "//src/main/java/com/google/devtools/build/lib:build-request-options",
         "//src/main/java/com/google/devtools/build/lib:command-utils",
+        "//src/main/java/com/google/devtools/build/lib:detailed_exit_code",
         "//src/main/java/com/google/devtools/build/lib:events",
         "//src/main/java/com/google/devtools/build/lib:exitcode-external",
         "//src/main/java/com/google/devtools/build/lib:keep-going-option",
diff --git a/src/test/java/com/google/devtools/build/lib/buildtool/util/BlazeRuntimeWrapper.java b/src/test/java/com/google/devtools/build/lib/buildtool/util/BlazeRuntimeWrapper.java
index b75366a..8cb02f5 100644
--- a/src/test/java/com/google/devtools/build/lib/buildtool/util/BlazeRuntimeWrapper.java
+++ b/src/test/java/com/google/devtools/build/lib/buildtool/util/BlazeRuntimeWrapper.java
@@ -57,6 +57,7 @@
 import com.google.devtools.build.lib.runtime.proto.InvocationPolicyOuterClass.InvocationPolicy;
 import com.google.devtools.build.lib.sandbox.SandboxOptions;
 import com.google.devtools.build.lib.skyframe.SkyframeExecutor;
+import com.google.devtools.build.lib.util.DetailedExitCode;
 import com.google.devtools.build.lib.util.ExitCode;
 import com.google.devtools.build.lib.util.io.OutErr;
 import com.google.devtools.build.lib.vfs.OutputService;
@@ -370,7 +371,9 @@
         buildTool.stopRequest(
             lastResult,
             null,
-            success ? ExitCode.SUCCESS : ExitCode.BUILD_FAILURE,
+            success
+                ? DetailedExitCode.justExitCode(ExitCode.SUCCESS)
+                : DetailedExitCode.justExitCode(ExitCode.BUILD_FAILURE),
             /*startSuspendCount=*/ 0);
         getSkyframeExecutor().notifyCommandComplete(env.getReporter());
       }
diff --git a/src/test/java/com/google/devtools/build/lib/runtime/BuildEventStreamerTest.java b/src/test/java/com/google/devtools/build/lib/runtime/BuildEventStreamerTest.java
index a87d2021..d78c92a 100644
--- a/src/test/java/com/google/devtools/build/lib/runtime/BuildEventStreamerTest.java
+++ b/src/test/java/com/google/devtools/build/lib/runtime/BuildEventStreamerTest.java
@@ -67,6 +67,7 @@
 import com.google.devtools.build.lib.collect.nestedset.NestedSetBuilder;
 import com.google.devtools.build.lib.collect.nestedset.NestedSetView;
 import com.google.devtools.build.lib.testutil.FoundationTestCase;
+import com.google.devtools.build.lib.util.DetailedExitCode;
 import com.google.devtools.build.lib.util.ExitCode;
 import com.google.devtools.build.lib.util.Pair;
 import com.google.devtools.build.lib.vfs.Path;
@@ -1326,7 +1327,7 @@
   private BuildCompleteEvent buildCompleteEvent(
       ExitCode exitCode, boolean stopOnFailure, Throwable crash, boolean catastrophe) {
     BuildResult result = new BuildResult(0);
-    result.setExitCondition(exitCode);
+    result.setDetailedExitCode(DetailedExitCode.justExitCode(exitCode));
     result.setStopOnFirstFailure(stopOnFailure);
     if (catastrophe) {
       result.setCatastrophe();
diff --git a/src/test/java/com/google/devtools/build/lib/runtime/UiStateTrackerTest.java b/src/test/java/com/google/devtools/build/lib/runtime/UiStateTrackerTest.java
index fb052c6..4382cf4 100644
--- a/src/test/java/com/google/devtools/build/lib/runtime/UiStateTrackerTest.java
+++ b/src/test/java/com/google/devtools/build/lib/runtime/UiStateTrackerTest.java
@@ -52,6 +52,7 @@
 import com.google.devtools.build.lib.skyframe.PackageProgressReceiver;
 import com.google.devtools.build.lib.testutil.FoundationTestCase;
 import com.google.devtools.build.lib.testutil.ManualClock;
+import com.google.devtools.build.lib.util.DetailedExitCode;
 import com.google.devtools.build.lib.util.ExitCode;
 import com.google.devtools.build.lib.util.Pair;
 import com.google.devtools.build.lib.util.io.LoggingTerminalWriter;
@@ -1128,7 +1129,7 @@
     BuildEventTransport transport2 = newBepTransport("BuildEventTransport2");
     BuildEventTransport transport3 = newBepTransport("BuildEventTransport3");
     BuildResult buildResult = new BuildResult(clock.currentTimeMillis());
-    buildResult.setExitCondition(ExitCode.SUCCESS);
+    buildResult.setDetailedExitCode(DetailedExitCode.justExitCode(ExitCode.SUCCESS));
     clock.advanceMillis(TimeUnit.SECONDS.toMillis(1));
     buildResult.setStopTime(clock.currentTimeMillis());
 
@@ -1198,7 +1199,7 @@
     BuildEventTransport transport1 = newBepTransport(Strings.repeat("A", 61));
     BuildEventTransport transport2 = newBepTransport("BuildEventTransport");
     BuildResult buildResult = new BuildResult(clock.currentTimeMillis());
-    buildResult.setExitCondition(ExitCode.SUCCESS);
+    buildResult.setDetailedExitCode(DetailedExitCode.justExitCode(ExitCode.SUCCESS));
     LoggingTerminalWriter terminalWriter = new LoggingTerminalWriter(true);
     UiStateTracker stateTracker = new UiStateTracker(clock, 60);
     stateTracker.buildStarted(null);
diff --git a/src/test/java/com/google/devtools/build/lib/skyframe/BUILD b/src/test/java/com/google/devtools/build/lib/skyframe/BUILD
index 3ecb0d9..00b62f1 100644
--- a/src/test/java/com/google/devtools/build/lib/skyframe/BUILD
+++ b/src/test/java/com/google/devtools/build/lib/skyframe/BUILD
@@ -82,6 +82,7 @@
         "//src/main/java/com/google/devtools/build/lib:bazel-rules",
         "//src/main/java/com/google/devtools/build/lib:build-base",
         "//src/main/java/com/google/devtools/build/lib:build-request-options",
+        "//src/main/java/com/google/devtools/build/lib:detailed_exit_code",
         "//src/main/java/com/google/devtools/build/lib:events",
         "//src/main/java/com/google/devtools/build/lib:keep-going-option",
         "//src/main/java/com/google/devtools/build/lib:packages",
diff --git a/src/test/java/com/google/devtools/build/lib/skyframe/SequencedSkyframeExecutorTest.java b/src/test/java/com/google/devtools/build/lib/skyframe/SequencedSkyframeExecutorTest.java
index 249764e..901a0c1 100644
--- a/src/test/java/com/google/devtools/build/lib/skyframe/SequencedSkyframeExecutorTest.java
+++ b/src/test/java/com/google/devtools/build/lib/skyframe/SequencedSkyframeExecutorTest.java
@@ -116,6 +116,7 @@
 import com.google.devtools.build.lib.syntax.StarlarkSemantics;
 import com.google.devtools.build.lib.testutil.MoreAsserts;
 import com.google.devtools.build.lib.testutil.TestUtils;
+import com.google.devtools.build.lib.util.DetailedExitCode;
 import com.google.devtools.build.lib.util.ExitCode;
 import com.google.devtools.build.lib.util.Fingerprint;
 import com.google.devtools.build.lib.util.io.TimestampGranularityMonitor;
@@ -1480,8 +1481,12 @@
     @Override
     public ActionResult execute(ActionExecutionContext actionExecutionContext)
         throws ActionExecutionException {
-      throw new ActionExecutionException("message", new Exception("just cause"), this,
-          /*catastrophe=*/true, expectedExitCode);
+      throw new ActionExecutionException(
+          "message",
+          new Exception("just cause"),
+          this,
+          /*catastrophe=*/ true,
+          DetailedExitCode.justExitCode(expectedExitCode));
     }
   }
 
@@ -1582,7 +1587,8 @@
                     null));
     // The catastrophic exception should be propagated into the BuildFailedException whether or not
     // --keep_going is set.
-    assertThat(e.getExitCode()).isEqualTo(CatastrophicAction.expectedExitCode);
+    assertThat(e.getDetailedExitCode().getExitCode())
+        .isEqualTo(CatastrophicAction.expectedExitCode);
     assertThat(builtTargets).isEmpty();
     assertThat(markerRan.get()).isFalse();
   }
@@ -1715,7 +1721,8 @@
                     null));
     // The catastrophic exception should be propagated into the BuildFailedException whether or not
     // --keep_going is set.
-    assertThat(e.getExitCode()).isEqualTo(CatastrophicAction.expectedExitCode);
+    assertThat(e.getDetailedExitCode().getExitCode())
+        .isEqualTo(CatastrophicAction.expectedExitCode);
     assertThat(builtTargets).isEmpty();
   }
 
@@ -1845,7 +1852,8 @@
                         OutputGroupInfo.determineOutputGroups(ImmutableList.of(), true))));
     // The catastrophic exception should be propagated into the BuildFailedException whether or not
     // --keep_going is set.
-    assertThat(e.getExitCode()).isEqualTo(CatastrophicAction.expectedExitCode);
+    assertThat(e.getDetailedExitCode().getExitCode())
+        .isEqualTo(CatastrophicAction.expectedExitCode);
     assertThat(builtTargets).isEmpty();
   }
 
@@ -1952,7 +1960,8 @@
                     null));
     // The catastrophic exception should be propagated into the BuildFailedException whether or not
     // --keep_going is set.
-    assertThat(e.getExitCode()).isEqualTo(CatastrophicAction.expectedExitCode);
+    assertThat(e.getDetailedExitCode().getExitCode())
+        .isEqualTo(CatastrophicAction.expectedExitCode);
     assertThat(builtTargets).isEmpty();
   }
 
@@ -1969,7 +1978,11 @@
     public ActionResult execute(ActionExecutionContext actionExecutionContext)
         throws ActionExecutionException {
       throw new ActionExecutionException(
-          "foo", new Exception("bar"), this, /*catastrophe=*/ false, exitCode);
+          "foo",
+          new Exception("bar"),
+          this,
+          /*catastrophe=*/ false,
+          DetailedExitCode.justExitCode(exitCode));
     }
   }
 
@@ -2060,7 +2073,7 @@
                     null));
     // The exit code should be propagated into the BuildFailedException whether or not --keep_going
     // is set.
-    assertThat(e.getExitCode()).isEqualTo(USER_EXIT_CODE);
+    assertThat(e.getDetailedExitCode().getExitCode()).isEqualTo(USER_EXIT_CODE);
   }
 
   /**
@@ -2157,7 +2170,7 @@
                     null));
     // The exit code should be propagated into the BuildFailedException whether or not --keep_going
     // is set.
-    assertThat(e.getExitCode()).isEqualTo(INFRA_EXIT_CODE);
+    assertThat(e.getDetailedExitCode().getExitCode()).isEqualTo(INFRA_EXIT_CODE);
   }
 
   /**
@@ -2628,7 +2641,6 @@
       return null;
     }
 
-    @SuppressWarnings("unchecked")
     @Override
     public <T extends Info> T get(NativeProvider<T> provider) {
       return provider.getValueClass().cast(get(provider.getKey()));