diff --git a/BuildMonitor.Test/BuildPriorityTests.cs b/BuildMonitor.Test/BuildPriorityTests.cs
new file mode 100644
index 0000000..b91a291
--- /dev/null
+++ b/BuildMonitor.Test/BuildPriorityTests.cs
@@ -0,0 +1,102 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.BuildMonitor.Test;
+
+using ktsu.Semantics.Strings;
+
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+///
+/// Covers how a build's polling priority reads its runs. It used to read only the most recently started
+/// run across every branch, so a long run on main still going after a later PR run had finished dropped
+/// the build to Low, and main then failing never reached the recent-failure tier
+/// (ktsu-dev/BuildMonitor#392).
+///
+[TestClass]
+public sealed class BuildPriorityTests
+{
+ private static readonly DateTimeOffset T0 = new(2026, 11, 3, 6, 0, 0, TimeSpan.Zero);
+
+ [TestCleanup]
+ public void Cleanup() => Clock.Reset();
+
+ private static Run AddRun(Build build, string id, string branch, DateTimeOffset started, RunStatus status, DateTimeOffset lastUpdated)
+ {
+ Run run = build.CreateRun(id.As());
+ run.Branch = branch.As();
+ run.Started = started;
+ run.Status = status;
+ run.LastUpdated = lastUpdated;
+ build.Runs[run.Id] = run;
+ build.UpdateFromRun(run);
+ return run;
+ }
+
+ [TestMethod]
+ public void ARunStillGoingOnAnotherBranchKeepsTheBuildHigh()
+ {
+ // Arrange
+ Clock.UtcNowOverride = () => T0.AddMinutes(13);
+ Build build = new();
+ AddRun(build, "1", "main", T0, RunStatus.Running, T0.AddMinutes(12));
+ AddRun(build, "2", "feature", T0.AddMinutes(10), RunStatus.Success, T0.AddMinutes(12));
+
+ // Act
+ RequestPriority priority = new BuildSync { Build = build }.Priority;
+
+ // Assert
+ Assert.AreEqual(RequestPriority.High, priority, "main is still running, so the build should be polled at the running rate");
+ }
+
+ [TestMethod]
+ public void AFailureOnAnotherBranchIsARecentFailure()
+ {
+ // Arrange
+ Clock.UtcNowOverride = () => T0.AddMinutes(21);
+ Build build = new();
+ Run main = AddRun(build, "1", "main", T0, RunStatus.Running, T0.AddMinutes(12));
+ AddRun(build, "2", "feature", T0.AddMinutes(10), RunStatus.Success, T0.AddMinutes(12));
+
+ // Act
+ main.Status = RunStatus.Failure;
+ main.LastUpdated = T0.AddMinutes(20);
+ build.UpdateFromRun(main);
+ RequestPriority priority = new BuildSync { Build = build }.Priority;
+
+ // Assert
+ Assert.AreEqual(RequestPriority.Medium, priority, "main's newest run failed a minute ago");
+ }
+
+ [TestMethod]
+ public void AnOlderFailureSupersededOnItsBranchIsNotRecent()
+ {
+ // Arrange
+ Clock.UtcNowOverride = () => T0.AddMinutes(30);
+ Build build = new();
+ AddRun(build, "1", "main", T0, RunStatus.Failure, T0.AddMinutes(5));
+ AddRun(build, "2", "main", T0.AddMinutes(10), RunStatus.Success, T0.AddMinutes(15));
+ AddRun(build, "3", "feature", T0.AddMinutes(20), RunStatus.Success, T0.AddMinutes(25));
+
+ // Act
+ RequestPriority priority = new BuildSync { Build = build }.Priority;
+
+ // Assert
+ Assert.AreEqual(RequestPriority.Low, priority, "main's failure was fixed by a later run on main");
+ }
+
+ [TestMethod]
+ public void AFailureOverAnHourOldIsLow()
+ {
+ // Arrange
+ Clock.UtcNowOverride = () => T0.AddHours(3);
+ Build build = new();
+ AddRun(build, "1", "main", T0, RunStatus.Failure, T0.AddMinutes(20));
+ AddRun(build, "2", "feature", T0.AddMinutes(10), RunStatus.Success, T0.AddMinutes(12));
+
+ // Act
+ RequestPriority priority = new BuildSync { Build = build }.Priority;
+
+ // Assert
+ Assert.AreEqual(RequestPriority.Low, priority);
+ }
+}
diff --git a/BuildMonitor/Build.cs b/BuildMonitor/Build.cs
index 4908cd0..bbba8c9 100644
--- a/BuildMonitor/Build.cs
+++ b/BuildMonitor/Build.cs
@@ -24,6 +24,31 @@ internal sealed class Build
public RunStatus LastStatus { get; set; }
internal bool IsOngoing => !Runs.IsEmpty && LastStatus is RunStatus.Pending or RunStatus.Running;
+ ///
+ /// Gets a value indicating whether any of the build's runs, on any branch, is still pending or running.
+ /// reads only the most recently started run, so an earlier run on another
+ /// branch that is still going was missed once a later one finished (ktsu-dev/BuildMonitor#392).
+ ///
+ internal bool HasOngoingRun => IsOngoing || Runs.Values.Any(run => run.IsOngoing);
+
+ ///
+ /// Returns whether the newest run on any branch failed and was last updated after .
+ ///
+ /// The earliest update time that counts as recent.
+ /// if some branch's newest run is a recent failure.
+ internal bool HasRecentBranchFailure(DateTimeOffset since)
+ {
+ if (LastStatus == RunStatus.Failure && LastUpdated > since)
+ {
+ return true;
+ }
+
+ return Runs.Values
+ .GroupBy(run => run.Branch)
+ .Select(branch => branch.MaxBy(run => run.Started)!)
+ .Any(newest => newest.Status == RunStatus.Failure && newest.LastUpdated > since);
+ }
+
///
/// Calculates the estimated duration for this build using sophisticated statistical methods.
/// Uses all successful runs across all branches.
diff --git a/BuildMonitor/BuildSync.cs b/BuildMonitor/BuildSync.cs
index ffebdc5..3ff3052 100644
--- a/BuildMonitor/BuildSync.cs
+++ b/BuildMonitor/BuildSync.cs
@@ -54,15 +54,14 @@ internal RequestPriority Priority
{
get
{
- // Running builds are highest priority
- if (Build.IsOngoing)
+ // Running builds are highest priority, whichever branch the running run is on
+ if (Build.HasOngoingRun)
{
return RequestPriority.High;
}
- // Recent failures are medium priority (within last hour)
- if (Build.LastStatus == RunStatus.Failure &&
- Build.LastUpdated > Clock.UtcNow.AddHours(-1))
+ // Recent failures are medium priority (within last hour), on any branch
+ if (Build.HasRecentBranchFailure(Clock.UtcNow.AddHours(-1)))
{
return RequestPriority.Medium;
}