Fix action tracking with dynamic scheduling.

With dynamic scheduling, the same action may be in scheduling and running
states for different strategies at the same time.  We must account for
this when tracking actions and reporting on them.

To fix this, improve the ActionState structure to be more explicit about
the states it tracks and record the multiple strategies involved at each
stage.

Fixes https://github.com/bazelbuild/bazel/issues/7345.

RELNOTES: None.
PiperOrigin-RevId: 242665140
diff --git a/src/main/java/com/google/devtools/build/lib/runtime/ExperimentalStateTracker.java b/src/main/java/com/google/devtools/build/lib/runtime/ExperimentalStateTracker.java
index 2b2670c..60f63dd 100644
--- a/src/main/java/com/google/devtools/build/lib/runtime/ExperimentalStateTracker.java
+++ b/src/main/java/com/google/devtools/build/lib/runtime/ExperimentalStateTracker.java
@@ -13,12 +13,16 @@
 // limitations under the License.
 package com.google.devtools.build.lib.runtime;
 
+import static com.google.common.base.Preconditions.checkNotNull;
+import static com.google.common.base.Preconditions.checkState;
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 
+import com.google.common.annotations.VisibleForTesting;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Sets;
 import com.google.devtools.build.lib.actions.Action;
 import com.google.devtools.build.lib.actions.ActionCompletionEvent;
+import com.google.devtools.build.lib.actions.ActionExecutionMetadata;
 import com.google.devtools.build.lib.actions.ActionStartedEvent;
 import com.google.devtools.build.lib.actions.AnalyzingActionEvent;
 import com.google.devtools.build.lib.actions.Artifact;
@@ -26,7 +30,6 @@
 import com.google.devtools.build.lib.actions.SchedulingActionEvent;
 import com.google.devtools.build.lib.analysis.AnalysisPhaseCompleteEvent;
 import com.google.devtools.build.lib.analysis.ConfiguredTarget;
-import com.google.devtools.build.lib.bugreport.BugReport;
 import com.google.devtools.build.lib.buildeventstream.AnnounceBuildEventTransportsEvent;
 import com.google.devtools.build.lib.buildeventstream.BuildEventTransport;
 import com.google.devtools.build.lib.buildeventstream.BuildEventTransportClosedEvent;
@@ -49,7 +52,6 @@
 import com.google.devtools.build.lib.view.test.TestStatus.BlazeTestStatus;
 import java.io.IOException;
 import java.util.ArrayDeque;
-import java.util.ArrayList;
 import java.util.Comparator;
 import java.util.Deque;
 import java.util.HashSet;
@@ -59,6 +61,7 @@
 import java.util.TreeSet;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.atomic.AtomicInteger;
+import javax.annotation.concurrent.ThreadSafe;
 
 /**
  * An experimental state tracker for the new experimental UI.
@@ -86,22 +89,195 @@
   // Non-positive values indicate not to aim for a particular width.
   private final int targetWidth;
 
-  private static class ActionState {
-    public final Action action;
-    public final long nanoStartTime;
-    public final boolean executing;
-    public final String status;
+  /**
+   * Tracker of strategy names to unique IDs and viceversa.
+   *
+   * <p>The IDs generated by this class can be used to index bitmaps.
+   *
+   * <p>TODO(jmmv): Would be nice if the strategies themselves contained their IDs (initialized at
+   * construction time) and we passed around those IDs instead of strings.
+   */
+  @ThreadSafe
+  @VisibleForTesting
+  static class StrategyIds {
+    /** Fallback name in case we exhaust our space for IDs. */
+    public static final String FALLBACK_NAME = "unknown";
 
-    private ActionState(Action action, long nanoStartTime, boolean executing, String status) {
+    /** Counter of unique strategies seen so far. */
+    private AtomicInteger counter = new AtomicInteger(0);
+
+    /** Mapping of strategy names to their unique IDs. */
+    private Map<String, Integer> strategyIds = new ConcurrentHashMap<>();
+
+    /** Mapping of strategy unique IDs to their names. */
+    private Map<Integer, String> strategyNames = new ConcurrentHashMap<>();
+
+    public final Integer fallbackId;
+
+    /** Constructs a new collection of strategy IDs. */
+    public StrategyIds() {
+      fallbackId = getId(FALLBACK_NAME);
+    }
+
+    /** Computes the ID of a strategy given its name. */
+    public Integer getId(String strategy) {
+      Integer id =
+          strategyIds.computeIfAbsent(
+              strategy,
+              (key) -> {
+                int value = counter.getAndIncrement();
+                if (value >= Integer.SIZE) {
+                  return fallbackId;
+                } else {
+                  return 1 << value;
+                }
+              });
+      strategyNames.putIfAbsent(id, strategy);
+      return id;
+    }
+
+    /** Flattens a bitmap of strategy IDs into a human-friendly string. */
+    public String formatNames(int bitmap) {
+      StringBuilder builder = new StringBuilder();
+      int mask = 0x1;
+      while (bitmap != 0) {
+        int id = bitmap & mask;
+        if (id != 0) {
+          String name = checkNotNull(strategyNames.get(id), "Unknown strategy with id " + id);
+          builder.append(name);
+          bitmap &= ~mask;
+          if (bitmap != 0) {
+            builder.append(", ");
+          }
+        }
+        mask <<= 1;
+      }
+      return builder.toString();
+    }
+  }
+
+  private static final StrategyIds strategyIds = new StrategyIds();
+
+  /**
+   * Tracks all details for an action that we have heard about.
+   *
+   * <p>We cannot make assumptions on the order in which action state events come in, so this class
+   * takes care of always "advancing" the state of an action.
+   */
+  private static class ActionState {
+    /**
+     * The action this state belongs to.
+     *
+     * <p>We assume that all events related to the same action refer to the same {@link
+     * ActionExecutionMetadata} object.
+     */
+    final ActionExecutionMetadata action;
+
+    /** Timestamp of the last state change. */
+    long nanoStartTime;
+
+    /**
+     * Whether this action is in the analyzing state or not.
+     *
+     * <p>If true, implies that {@link #schedulingStrategiesBitmap} and {@link
+     * #runningStrategiesBitmap} are both zero. The opposite is not necessarily true: if false, the
+     * bitmaps can be zero as well to represent that the action is still in the preparation stage.
+     */
+    boolean analyzing;
+
+    /**
+     * Bitmap of strategies that are scheduling this action.
+     *
+     * <p>If non-zero, implies that {@link #analyzing} is false.
+     */
+    int schedulingStrategiesBitmap = 0;
+
+    /**
+     * Bitmap of strategies that are running this action.
+     *
+     * <p>If non-zero, implies that {@link #analyzing} is false.
+     */
+    int runningStrategiesBitmap = 0;
+
+    /** Starts tracking the state of an action. */
+    ActionState(ActionExecutionMetadata action, long nanoStartTime) {
       this.action = action;
       this.nanoStartTime = nanoStartTime;
-      this.executing = executing;
-      this.status = status;
+    }
+
+    /** Creates a deep copy of this action state. */
+    synchronized ActionState deepCopy() {
+      ActionState other = new ActionState(action, nanoStartTime);
+      other.analyzing = analyzing;
+      other.schedulingStrategiesBitmap = schedulingStrategiesBitmap;
+      other.runningStrategiesBitmap = runningStrategiesBitmap;
+      return other;
+    }
+
+    /** Computes the weight of this action for the global active actions counter. */
+    synchronized int countActions() {
+      int activeStrategies =
+          Integer.bitCount(schedulingStrategiesBitmap) + Integer.bitCount(runningStrategiesBitmap);
+      return activeStrategies > 0 ? activeStrategies : 1;
+    }
+
+    /**
+     * Marks the action as analyzing.
+     *
+     * <p>Because we may receive events out of order, this does nothing if the action is already
+     * scheduled or running.
+     */
+    void setAnalyzing(long nanoChangeTime) {
+      if (schedulingStrategiesBitmap == 0 && runningStrategiesBitmap == 0) {
+        analyzing = true;
+        nanoStartTime = nanoChangeTime;
+      }
+    }
+
+    /**
+     * Marks the action as scheduling with the given strategy.
+     *
+     * <p>Because we may receive events out of order, this does nothing if the action is already
+     * running with this strategy.
+     */
+    void setScheduling(String strategy, long nanoChangeTime) {
+      int id = strategyIds.getId(strategy);
+      if ((runningStrategiesBitmap & id) == 0) {
+        analyzing = false;
+        schedulingStrategiesBitmap |= id;
+        nanoStartTime = nanoChangeTime;
+      }
+    }
+
+    /**
+     * Marks the action as running with the given strategy.
+     *
+     * <p>Because "running" is a terminal state, this forcibly updates the state to running
+     * regardless of any other events (which may come out of order).
+     */
+    void setRunning(String strategy, long nanoChangeTime) {
+      analyzing = false;
+      int id = strategyIds.getId(strategy);
+      schedulingStrategiesBitmap &= ~id;
+      runningStrategiesBitmap |= id;
+      nanoStartTime = nanoChangeTime;
+    }
+
+    /** Generates a human-readable description of this action's state. */
+    synchronized String describe() {
+      if (runningStrategiesBitmap != 0) {
+        return "Running";
+      } else if (schedulingStrategiesBitmap != 0) {
+        return "Scheduling";
+      } else if (analyzing) {
+        return "Analyzing";
+      } else {
+        return "Preparing";
+      }
     }
   }
 
   private final Map<Artifact, ActionState> activeActions;
-  private final Map<Artifact, String> notStartedActionStatus;
 
   // running downloads are identified by the original URL they were trying to access.
   private final Deque<String> runningDownloads;
@@ -136,7 +312,6 @@
 
   ExperimentalStateTracker(Clock clock, int targetWidth) {
     this.activeActions = new ConcurrentHashMap<>();
-    this.notStartedActionStatus = new ConcurrentHashMap<>();
 
     this.actionsCompleted = new AtomicInteger();
     this.testActions = new ConcurrentHashMap<>();
@@ -262,16 +437,21 @@
     }
   }
 
+  private ActionState getActionState(
+      ActionExecutionMetadata action, Artifact actionId, long nanoTimeNow) {
+    ActionState state =
+        activeActions.computeIfAbsent(actionId, (key) -> new ActionState(action, nanoTimeNow));
+    checkState(
+        state.action == action,
+        "Inconsistent ActionExecutionMetadata objects across events for the same action");
+    return state;
+  }
+
   void actionStarted(ActionStartedEvent event) {
     Action action = event.getAction();
     Artifact actionId = action.getPrimaryOutput();
-    long nanoStartTime = event.getNanoTimeStart();
 
-    String status = notStartedActionStatus.remove(actionId);
-    boolean nowExecuting = status != null;
-    activeActions.put(
-        actionId,
-        new ActionState(action, nanoStartTime, nowExecuting, nowExecuting ? status : null));
+    getActionState(action, actionId, event.getNanoTimeStart());
 
     if (action.getOwner() != null) {
       Label owner = action.getOwner().getLabel();
@@ -285,53 +465,33 @@
   }
 
   void analyzingAction(AnalyzingActionEvent event) {
+    ActionExecutionMetadata action = event.getActionMetadata();
     Artifact actionId = event.getActionMetadata().getPrimaryOutput();
-    ActionState state = activeActions.remove(actionId);
-    if (state != null) {
-      activeActions.put(
-          actionId,
-          new ActionState(state.action, clock.nanoTime(), /*executing=*/ false, "Analyzing"));
-    }
-    notStartedActionStatus.remove(actionId);
+    long now = clock.nanoTime();
+    getActionState(action, actionId, now).setAnalyzing(now);
   }
 
   void schedulingAction(SchedulingActionEvent event) {
+    ActionExecutionMetadata action = event.getActionMetadata();
     Artifact actionId = event.getActionMetadata().getPrimaryOutput();
-    ActionState state = activeActions.remove(actionId);
-    if (state != null) {
-      activeActions.put(
-          actionId,
-          new ActionState(state.action, clock.nanoTime(), /*executing=*/ false, "Scheduling"));
-    }
-    notStartedActionStatus.remove(actionId);
+    long now = clock.nanoTime();
+    getActionState(action, actionId, now).setScheduling(event.getStrategy(), now);
   }
 
   void runningAction(RunningActionEvent event) {
+    ActionExecutionMetadata action = event.getActionMetadata();
     Artifact actionId = event.getActionMetadata().getPrimaryOutput();
-    String strategy = event.getStrategy();
-
-    ActionState state = activeActions.remove(actionId);
-    if (state != null) {
-      activeActions.put(
-          actionId, new ActionState(state.action, clock.nanoTime(), /*executing=*/ true, strategy));
-    } else {
-      notStartedActionStatus.put(actionId, strategy);
-    }
+    long now = clock.nanoTime();
+    getActionState(action, actionId, now).setRunning(event.getStrategy(), now);
   }
 
   void actionCompletion(ActionCompletionEvent event) {
     actionsCompleted.incrementAndGet();
     Action action = event.getAction();
     Artifact actionId = action.getPrimaryOutput();
+
+    checkState(activeActions.containsKey(actionId));
     activeActions.remove(actionId);
-    boolean removed = notStartedActionStatus.containsKey(actionId);
-    if (removed && !action.discoversInputs()) {
-      BugReport.sendBugReport(
-          new IllegalStateException(
-              "Should not complete an action before starting it, and action did not discover "
-                  + "inputs, so should not have published a status before execution: "
-                  + action));
-    }
 
     if (action.getOwner() != null) {
       Label owner = action.getOwner().getLabel();
@@ -431,7 +591,9 @@
       long nanoRuntime = nanoTime - actionState.nanoStartTime;
       long runtimeSeconds = nanoRuntime / NANOS_PER_SECOND;
       String text =
-          actionState.executing ? sep + runtimeSeconds + "s" : sep + "[" + runtimeSeconds + "s]";
+          actionState.runningStrategiesBitmap == 0
+              ? sep + "[" + runtimeSeconds + "s]"
+              : sep + runtimeSeconds + "s";
       if (remainingWidth < text.length()) {
         allReported = false;
         break;
@@ -448,7 +610,7 @@
   // actions.
   private String describeAction(
       ActionState actionState, long nanoTime, int desiredWidth, Set<Artifact> toSkip) {
-    Action action = actionState.action;
+    ActionExecutionMetadata action = actionState.action;
     if (action.getOwner() != null) {
       Label owner = action.getOwner().getLabel();
       if (owner != null) {
@@ -467,10 +629,10 @@
     long nanoRuntime = nanoTime - actionState.nanoStartTime;
     long runtimeSeconds = nanoRuntime / NANOS_PER_SECOND;
     String strategy = null;
-    if (actionState.executing) {
-      strategy = actionState.status;
+    if (actionState.runningStrategiesBitmap != 0) {
+      strategy = strategyIds.formatNames(actionState.runningStrategiesBitmap);
     } else {
-      String status = actionState.status;
+      String status = actionState.describe();
       if (status == null) {
         status = NO_STATUS;
       }
@@ -562,10 +724,8 @@
     int actionsCount = 0;
     int executingActionsCount = 0;
     for (ActionState actionState : activeActions.values()) {
-      actionsCount++;
-      if (actionState.executing) {
-        executingActionsCount++;
-      }
+      actionsCount += actionState.countActions();
+      executingActionsCount += Integer.bitCount(actionState.runningStrategiesBitmap);
     }
 
     if (actionsCount == 1) {
@@ -583,14 +743,19 @@
     long nanoTime = clock.nanoTime();
     int actionCount = activeActions.size();
     Set<Artifact> toSkip = new TreeSet<>();
-    ArrayList<Map.Entry<Artifact, ActionState>> copy = new ArrayList<>(activeActions.entrySet());
-    copy.sort(
-        Comparator.comparing(
-                (Map.Entry<Artifact, ActionState> entry) -> !entry.getValue().executing)
-            .thenComparing(entry -> entry.getValue().nanoStartTime));
-    for (Map.Entry<Artifact, ActionState> entry : copy) {
+
+    Map<ActionState, Artifact> copy =
+        new TreeMap<>(
+            Comparator.comparing((ActionState entry) -> entry.runningStrategiesBitmap == 0)
+                .thenComparing(entry -> entry.nanoStartTime)
+                .thenComparing(ActionState::hashCode));
+    for (Map.Entry<Artifact, ActionState> action : activeActions.entrySet()) {
+      copy.put(action.getValue().deepCopy(), action.getKey());
+    }
+
+    for (Map.Entry<ActionState, Artifact> entry : copy.entrySet()) {
       totalCount++;
-      if (toSkip.contains(entry.getKey())) {
+      if (toSkip.contains(entry.getValue())) {
         continue;
       }
       count++;
@@ -602,7 +767,7 @@
           targetWidth - 4 - ((count >= sampleSize && count < actionCount) ? AND_MORE.length() : 0);
       terminalWriter
           .newline()
-          .append("    " + describeAction(entry.getValue(), nanoTime, width, toSkip));
+          .append("    " + describeAction(entry.getKey(), nanoTime, width, toSkip));
     }
     if (totalCount < actionCount) {
       terminalWriter.append(AND_MORE);
diff --git a/src/main/java/com/google/devtools/build/lib/util/io/LoggingTerminalWriter.java b/src/main/java/com/google/devtools/build/lib/util/io/LoggingTerminalWriter.java
index 290ba37..1264a02 100644
--- a/src/main/java/com/google/devtools/build/lib/util/io/LoggingTerminalWriter.java
+++ b/src/main/java/com/google/devtools/build/lib/util/io/LoggingTerminalWriter.java
@@ -16,8 +16,7 @@
 import java.io.IOException;
 
 /**
- * An {@link AnsiTerminalWriter} that keeps just generates a transcript
- * of the events it was exposed of.
+ * An {@link AnsiTerminalWriter} that just generates a transcript of the events it was exposed of.
  */
 public class LoggingTerminalWriter implements AnsiTerminalWriter {
   // Strings for recording the non-append calls
@@ -38,6 +37,11 @@
     this(false);
   }
 
+  /** Clears the stored transcript; mostly useful for testing purposes. */
+  public void reset() {
+    transcript = "";
+  }
+
   @Override
   public AnsiTerminalWriter append(String text) throws IOException {
     transcript += text;
diff --git a/src/test/java/com/google/devtools/build/lib/runtime/ExperimentalStateTrackerTest.java b/src/test/java/com/google/devtools/build/lib/runtime/ExperimentalStateTrackerTest.java
index 3366488..b768dd5 100644
--- a/src/test/java/com/google/devtools/build/lib/runtime/ExperimentalStateTrackerTest.java
+++ b/src/test/java/com/google/devtools/build/lib/runtime/ExperimentalStateTrackerTest.java
@@ -26,7 +26,6 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.devtools.build.lib.actions.Action;
 import com.google.devtools.build.lib.actions.ActionCompletionEvent;
-import com.google.devtools.build.lib.actions.ActionExecutionMetadata;
 import com.google.devtools.build.lib.actions.ActionLookupData;
 import com.google.devtools.build.lib.actions.ActionOwner;
 import com.google.devtools.build.lib.actions.ActionStartedEvent;
@@ -46,6 +45,7 @@
 import com.google.devtools.build.lib.cmdline.Label;
 import com.google.devtools.build.lib.events.ExtendedEventHandler.FetchProgress;
 import com.google.devtools.build.lib.packages.AspectDescriptor;
+import com.google.devtools.build.lib.runtime.ExperimentalStateTracker.StrategyIds;
 import com.google.devtools.build.lib.skyframe.LoadingPhaseStartedEvent;
 import com.google.devtools.build.lib.skyframe.PackageProgressReceiver;
 import com.google.devtools.build.lib.testutil.FoundationTestCase;
@@ -59,6 +59,8 @@
 import com.google.devtools.build.lib.view.test.TestStatus.BlazeTestStatus;
 import java.io.IOException;
 import java.net.URL;
+import java.util.HashSet;
+import java.util.Set;
 import java.util.concurrent.TimeUnit;
 import org.junit.Test;
 import org.junit.runner.RunWith;
@@ -71,6 +73,80 @@
 @RunWith(JUnit4.class)
 public class ExperimentalStateTrackerTest extends FoundationTestCase {
 
+  @Test
+  public void testStrategyIds_getId_idsAreBitmasks() {
+    StrategyIds strategyIds = new StrategyIds();
+    Integer id1 = strategyIds.getId("foo");
+    Integer id2 = strategyIds.getId("bar");
+    Integer id3 = strategyIds.getId("baz");
+
+    assertThat(id1).isGreaterThan(0);
+    assertThat(id2).isGreaterThan(0);
+    assertThat(id3).isGreaterThan(0);
+
+    assertThat(id1 & id2).isEqualTo(0);
+    assertThat(id1 & id3).isEqualTo(0);
+    assertThat(id2 & id3).isEqualTo(0);
+  }
+
+  @Test
+  public void testStrategyIds_getId_idsAreReusedIfAlreadyExist() {
+    StrategyIds strategyIds = new StrategyIds();
+    Integer id1 = strategyIds.getId("foo");
+    Integer id2 = strategyIds.getId("bar");
+    Integer id3 = strategyIds.getId("foo");
+
+    assertThat(id1).isNotEqualTo(id2);
+    assertThat(id1).isEqualTo(id3);
+  }
+
+  @Test
+  public void testStrategyIds_getId_exhaustIds() {
+    StrategyIds strategyIds = new StrategyIds();
+    Set<Integer> ids = new HashSet<>();
+    StringBuilder name = new StringBuilder();
+    for (; ; ) {
+      name.append('a');
+      Integer id = strategyIds.getId(name.toString());
+      if (id.equals(strategyIds.fallbackId)) {
+        break;
+      }
+      ids.add(id);
+    }
+    assertThat(ids).hasSize(Integer.SIZE - 1); // Minus 1 for FALLBACK_NAME.
+
+    assertThat(strategyIds.getId("some")).isEqualTo(strategyIds.fallbackId);
+    assertThat(strategyIds.getId("more")).isEqualTo(strategyIds.fallbackId);
+  }
+
+  @Test
+  public void testStrategyIds_formatNames_fallbackExistsByDefault() {
+    StrategyIds strategyIds = new StrategyIds();
+    assertThat(strategyIds.formatNames(strategyIds.fallbackId))
+        .isEqualTo(StrategyIds.FALLBACK_NAME);
+  }
+
+  @Test
+  public void testStrategyIds_formatNames_oneHasNoComma() {
+    StrategyIds strategyIds = new StrategyIds();
+    Integer id1 = strategyIds.getId("abc");
+    assertThat(strategyIds.formatNames(id1)).isEqualTo("abc");
+  }
+
+  @Test
+  public void testStrategyIds_formatNames() {
+    StrategyIds strategyIds = new StrategyIds();
+    Integer id1 = strategyIds.getId("abc");
+    Integer id2 = strategyIds.getId("xyz");
+    Integer id3 = strategyIds.getId("def");
+
+    // Names are not sorted alphabetically but their order is stable based on prior getId calls.
+    assertThat(strategyIds.formatNames(id1 | id2)).isEqualTo("abc, xyz");
+    assertThat(strategyIds.formatNames(id1 | id3)).isEqualTo("abc, def");
+    assertThat(strategyIds.formatNames(id2 | id3)).isEqualTo("xyz, def");
+    assertThat(strategyIds.formatNames(id1 | id2 | id3)).isEqualTo("abc, xyz, def");
+  }
+
   private Action mockAction(String progressMessage, String primaryOutput) {
     Path path = outputBase.getRelative(PathFragment.create(primaryOutput));
     Artifact artifact = new Artifact(path, ArtifactRoot.asSourceRoot(Root.fromPath(outputBase)));
@@ -480,14 +556,13 @@
     ManualClock clock = new ManualClock();
     Path path = outputBase.getRelative(PathFragment.create(primaryOutput));
     Artifact artifact = new Artifact(path, ArtifactRoot.asSourceRoot(Root.fromPath(outputBase)));
-    ActionExecutionMetadata actionMetadata = Mockito.mock(ActionExecutionMetadata.class);
-    when(actionMetadata.getOwner()).thenReturn(Mockito.mock(ActionOwner.class));
-    when(actionMetadata.getPrimaryOutput()).thenReturn(artifact);
+    Action action = mockAction("Some random action", primaryOutput);
+    when(action.getOwner()).thenReturn(Mockito.mock(ActionOwner.class));
+    when(action.getPrimaryOutput()).thenReturn(artifact);
 
     ExperimentalStateTracker stateTracker = new ExperimentalStateTracker(clock);
-    stateTracker.actionStarted(
-        new ActionStartedEvent(mockAction("Some random action", primaryOutput), clock.nanoTime()));
-    stateTracker.runningAction(new RunningActionEvent(actionMetadata, strategy));
+    stateTracker.actionStarted(new ActionStartedEvent(action, clock.nanoTime()));
+    stateTracker.runningAction(new RunningActionEvent(action, strategy));
 
     LoggingTerminalWriter terminalWriter = new LoggingTerminalWriter(/*discardHighlight=*/ true);
     stateTracker.writeProgressBar(terminalWriter);
@@ -498,6 +573,84 @@
         .isTrue();
   }
 
+  @Test
+  public void testMultipleActionStrategiesVisibleForDynamicScheduling() throws Exception {
+    String strategy1 = "strategy1";
+    String strategy2 = "stratagy2";
+    String primaryOutput = "some/path/to/a/file";
+
+    ManualClock clock = new ManualClock();
+    Path path = outputBase.getRelative(PathFragment.create(primaryOutput));
+    Artifact artifact = new Artifact(path, ArtifactRoot.asSourceRoot(Root.fromPath(outputBase)));
+    Action action = mockAction("Some random action", primaryOutput);
+    when(action.getOwner()).thenReturn(Mockito.mock(ActionOwner.class));
+    when(action.getPrimaryOutput()).thenReturn(artifact);
+
+    ExperimentalStateTracker stateTracker = new ExperimentalStateTracker(clock);
+    stateTracker.actionStarted(new ActionStartedEvent(action, clock.nanoTime()));
+    stateTracker.runningAction(new RunningActionEvent(action, strategy1));
+    stateTracker.runningAction(new RunningActionEvent(action, strategy2));
+
+    LoggingTerminalWriter terminalWriter = new LoggingTerminalWriter(/*discardHighlight=*/ true);
+    stateTracker.writeProgressBar(terminalWriter);
+    String output = terminalWriter.getTranscript();
+
+    assertWithMessage(
+            "Output should mention strategies '"
+                + strategy1
+                + "' and '"
+                + strategy2
+                + "', but was: "
+                + output)
+        .that(output.contains(strategy1 + ", " + strategy2))
+        .isTrue();
+  }
+
+  @Test
+  public void testActionCountsWithDynamicScheduling() throws Exception {
+    String primaryOutput1 = "some/path/to/a/file";
+    String primaryOutput2 = "some/path/to/b/file";
+
+    ManualClock clock = new ManualClock();
+    ExperimentalStateTracker stateTracker = new ExperimentalStateTracker(clock);
+    LoggingTerminalWriter terminalWriter = new LoggingTerminalWriter(/*discardHighlight=*/ true);
+
+    Path path1 = outputBase.getRelative(PathFragment.create(primaryOutput1));
+    Artifact artifact1 = new Artifact(path1, ArtifactRoot.asSourceRoot(Root.fromPath(outputBase)));
+    Action action1 = mockAction("First random action", primaryOutput1);
+    when(action1.getOwner()).thenReturn(Mockito.mock(ActionOwner.class));
+    when(action1.getPrimaryOutput()).thenReturn(artifact1);
+    stateTracker.actionStarted(new ActionStartedEvent(action1, clock.nanoTime()));
+
+    Path path2 = outputBase.getRelative(PathFragment.create(primaryOutput2));
+    Artifact artifact2 = new Artifact(path2, ArtifactRoot.asSourceRoot(Root.fromPath(outputBase)));
+    Action action2 = mockAction("First random action", primaryOutput1);
+    when(action2.getOwner()).thenReturn(Mockito.mock(ActionOwner.class));
+    when(action2.getPrimaryOutput()).thenReturn(artifact2);
+    stateTracker.actionStarted(new ActionStartedEvent(action2, clock.nanoTime()));
+
+    stateTracker.runningAction(new RunningActionEvent(action1, "strategy1"));
+    stateTracker.schedulingAction(new SchedulingActionEvent(action2, "strategy1"));
+    terminalWriter.reset();
+    stateTracker.writeProgressBar(terminalWriter);
+    assertThat(terminalWriter.getTranscript()).contains("2 actions, 1 running");
+
+    stateTracker.runningAction(new RunningActionEvent(action1, "strategy2"));
+    terminalWriter.reset();
+    stateTracker.writeProgressBar(terminalWriter);
+    assertThat(terminalWriter.getTranscript()).contains("3 actions, 2 running");
+
+    stateTracker.runningAction(new RunningActionEvent(action2, "strategy1"));
+    terminalWriter.reset();
+    stateTracker.writeProgressBar(terminalWriter);
+    assertThat(terminalWriter.getTranscript()).contains("3 actions running");
+
+    stateTracker.runningAction(new RunningActionEvent(action2, "strategy2"));
+    terminalWriter.reset();
+    stateTracker.writeProgressBar(terminalWriter);
+    assertThat(terminalWriter.getTranscript()).contains("4 actions running");
+  }
+
   private void doTestOutputLength(boolean withTest, int actions) throws Exception {
     // If we target 70 characters, then there should be enough space to both,
     // keep the line limit, and show the local part of the running actions and