From b36c476932d0b3126e631302b0f8e4ef72e8dfaf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 20:34:53 +0000 Subject: [PATCH] Poll a build at its running or recent-failure rate whichever branch the run is on [patch] BuildSync.Priority read Build.IsOngoing and Build.LastStatus, which describe only the most recently started run across every branch. A long run on main that was still going after a later PR run had finished dropped the build to Low (or skipped it under 15% budget), and main then failing never reached the Medium recent-failure tier. Priority now treats the build as running when any run is ongoing, and as a recent failure when the newest run on any branch failed within the hour. Fixes ktsu-dev/BuildMonitor#392 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW5KENnPmXEat6KQ92F --- BuildMonitor.Test/BuildPriorityTests.cs | 102 ++++++++++++++++++++++++ BuildMonitor/Build.cs | 25 ++++++ BuildMonitor/BuildSync.cs | 9 +-- 3 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 BuildMonitor.Test/BuildPriorityTests.cs 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; }