Commit b953e91
Eric Bower
·
2026-08-18 11:51:33 -0400 EDT
parent a0e7733
fix: order previously ran tasks by start time
4 files changed,
+63,
-8
+11,
-3
| ... | ... | @@ -74,18 +74,26 @@ func newPipelineUIState(repo, branch, commit, jobID string, stats *RepoStats) *P | |
| 74 | 74 | if stats != nil { | |
| 75 | 75 | ui.PredictedTotal = time.Duration(stats.AvgWallDurationMs) * time.Millisecond | |
| 76 | 76 | ||
| 77 | - | // Pre-populate expected tasks in deterministic order | |
| 77 | + | // Pre-populate expected tasks sorted by start time | |
| 78 | 78 | var names []string | |
| 79 | 79 | for name := range stats.Tasks { | |
| 80 | 80 | names = append(names, name) | |
| 81 | 81 | } | |
| 82 | - | sort.Strings(names) | |
| 82 | + | sort.Slice(names, func(i, j int) bool { | |
| 83 | + | ti := stats.Tasks[names[i]] | |
| 84 | + | tj := stats.Tasks[names[j]] | |
| 85 | + | if ti.LastStartedAt != tj.LastStartedAt { | |
| 86 | + | return ti.LastStartedAt < tj.LastStartedAt | |
| 87 | + | } | |
| 88 | + | return names[i] < names[j] | |
| 89 | + | }) | |
| 83 | 90 | ||
| 84 | 91 | for _, name := range names { | |
| 85 | 92 | ts := stats.Tasks[name] | |
| 86 | 93 | ui.Tasks[name] = &TaskUIState{ | |
| 87 | 94 | Name: name, | |
| 88 | 95 | Status: "expected", | |
| 96 | + | Created: ts.LastStartedAt, | |
| 89 | 97 | PredictedDur: time.Duration(ts.AvgDurationMs) * time.Millisecond, | |
| 90 | 98 | PrevStatus: ts.LastStatus, | |
| 91 | 99 | PrevExitCode: ts.LastExitCode, |
| ... | ... | @@ -864,7 +872,7 @@ func waitAndReportUI(cfg *Cfg, log *slog.Logger, repoName, jobID, domainOrEvent, | |
| 864 | 872 | ended, _ := strconv.ParseInt(s.Ended, 10, 64) | |
| 865 | 873 | dur := time.Duration(ended-created) * time.Second | |
| 866 | 874 | code, _ := strconv.Atoi(s.ExitCode) | |
| 867 | - | updateTaskStats(stats, s.Short, dur, code) | |
| 875 | + | updateTaskStats(stats, s.Short, dur, code, created) | |
| 868 | 876 | } | |
| 869 | 877 | updateRepoStats(stats, ctrl.ui.Elapsed, overallStatus) | |
| 870 | 878 | _ = saveRepoStats(stats) |
+36,
-0
| ... | ... | @@ -48,6 +48,42 @@ func TestUI_ForecastStateInitialization(t *testing.T) { | |
| 48 | 48 | } | |
| 49 | 49 | } | |
| 50 | 50 | ||
| 51 | + | func TestUI_ForecastStateSortByStartTime(t *testing.T) { | |
| 52 | + | // Tasks defined out of chronological order: | |
| 53 | + | // "test" started first at t=100 | |
| 54 | + | // "lint" started second at t=200 | |
| 55 | + | // "fmt" started third at t=300 | |
| 56 | + | stats := &RepoStats{ | |
| 57 | + | Repo: "my-repo", | |
| 58 | + | Tasks: map[string]*TaskStats{ | |
| 59 | + | "fmt": { | |
| 60 | + | AvgDurationMs: 850, | |
| 61 | + | LastStartedAt: 300, | |
| 62 | + | }, | |
| 63 | + | "lint": { | |
| 64 | + | AvgDurationMs: 4200, | |
| 65 | + | LastStartedAt: 200, | |
| 66 | + | }, | |
| 67 | + | "test": { | |
| 68 | + | AvgDurationMs: 13500, | |
| 69 | + | LastStartedAt: 100, | |
| 70 | + | }, | |
| 71 | + | }, | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | ui := newPipelineUIState("my-repo", "main", "8f2a1b9", "job123", stats) | |
| 75 | + | ||
| 76 | + | expectedOrder := []string{"test", "lint", "fmt"} | |
| 77 | + | if len(ui.TaskOrder) != len(expectedOrder) { | |
| 78 | + | t.Fatalf("expected %d tasks in order, got %d", len(expectedOrder), len(ui.TaskOrder)) | |
| 79 | + | } | |
| 80 | + | for i, expected := range expectedOrder { | |
| 81 | + | if ui.TaskOrder[i] != expected { | |
| 82 | + | t.Errorf("expected task at position %d to be %q, got %q (order: %v)", i, expected, ui.TaskOrder[i], ui.TaskOrder) | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | ||
| 51 | 87 | func TestUI_RenderProgressBar(t *testing.T) { | |
| 52 | 88 | tests := []struct { | |
| 53 | 89 | elapsed time.Duration |
M
stats.go
+5,
-1
| ... | ... | @@ -26,6 +26,7 @@ type TaskStats struct { | |
| 26 | 26 | LastExitCode int `json:"last_exit_code"` | |
| 27 | 27 | FailureStreak int `json:"failure_streak,omitempty"` | |
| 28 | 28 | SeenCount int `json:"seen_count"` | |
| 29 | + | LastStartedAt int64 `json:"last_started_at,omitempty"` | |
| 29 | 30 | } | |
| 30 | 31 | ||
| 31 | 32 | // getStatsPath returns the path to stats.json for the specified repository. |
| ... | ... | @@ -86,7 +87,7 @@ func saveRepoStats(stats *RepoStats) error { | |
| 86 | 87 | } | |
| 87 | 88 | ||
| 88 | 89 | // updateTaskStats updates single task statistics using EMA duration (alpha = 0.3). | |
| 89 | - | func updateTaskStats(stats *RepoStats, name string, actualDur time.Duration, exitCode int) { | |
| 90 | + | func updateTaskStats(stats *RepoStats, name string, actualDur time.Duration, exitCode int, startedAt ...int64) { | |
| 90 | 91 | if stats.Tasks == nil { | |
| 91 | 92 | stats.Tasks = make(map[string]*TaskStats) | |
| 92 | 93 | } |
| ... | ... | @@ -107,6 +108,9 @@ func updateTaskStats(stats *RepoStats, name string, actualDur time.Duration, exi | |
| 107 | 108 | task.LastDurationMs = actualMs | |
| 108 | 109 | task.LastExitCode = exitCode | |
| 109 | 110 | task.SeenCount++ | |
| 111 | + | if len(startedAt) > 0 && startedAt[0] > 0 { | |
| 112 | + | task.LastStartedAt = startedAt[0] | |
| 113 | + | } | |
| 110 | 114 | ||
| 111 | 115 | if exitCode == 0 { | |
| 112 | 116 | task.LastStatus = "success" |
+11,
-4
| ... | ... | @@ -55,15 +55,18 @@ func TestStats_EMACalculation(t *testing.T) { | |
| 55 | 55 | t.Errorf("expected FailureStreak 1, got %d", lintTask.FailureStreak) | |
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | - | // Fail again -> streak increments | |
| 59 | - | updateTaskStats(stats, "lint", 4100*time.Millisecond, 1) | |
| 58 | + | // Fail again with startedAt -> streak increments and LastStartedAt updated | |
| 59 | + | updateTaskStats(stats, "lint", 4100*time.Millisecond, 1, 1700000500) | |
| 60 | 60 | lintTask = stats.Tasks["lint"] | |
| 61 | 61 | if lintTask.FailureStreak != 2 { | |
| 62 | 62 | t.Errorf("expected FailureStreak 2, got %d", lintTask.FailureStreak) | |
| 63 | 63 | } | |
| 64 | + | if lintTask.LastStartedAt != 1700000500 { | |
| 65 | + | t.Errorf("expected LastStartedAt 1700000500, got %d", lintTask.LastStartedAt) | |
| 66 | + | } | |
| 64 | 67 | ||
| 65 | 68 | // Succeed -> streak resets to 0 | |
| 66 | - | updateTaskStats(stats, "lint", 3900*time.Millisecond, 0) | |
| 69 | + | updateTaskStats(stats, "lint", 3900*time.Millisecond, 0, 1700000600) | |
| 67 | 70 | lintTask = stats.Tasks["lint"] | |
| 68 | 71 | if lintTask.FailureStreak != 0 { | |
| 69 | 72 | t.Errorf("expected FailureStreak reset to 0, got %d", lintTask.FailureStreak) |
| ... | ... | @@ -71,6 +74,9 @@ func TestStats_EMACalculation(t *testing.T) { | |
| 71 | 74 | if lintTask.LastStatus != "success" { | |
| 72 | 75 | t.Errorf("expected LastStatus success, got %s", lintTask.LastStatus) | |
| 73 | 76 | } | |
| 77 | + | if lintTask.LastStartedAt != 1700000600 { | |
| 78 | + | t.Errorf("expected LastStartedAt 1700000600, got %d", lintTask.LastStartedAt) | |
| 79 | + | } | |
| 74 | 80 | } | |
| 75 | 81 | ||
| 76 | 82 | func TestStats_StorageAndPath(t *testing.T) { |
| ... | ... | @@ -98,6 +104,7 @@ func TestStats_StorageAndPath(t *testing.T) { | |
| 98 | 104 | LastStatus: "success", | |
| 99 | 105 | LastExitCode: 0, | |
| 100 | 106 | SeenCount: 5, | |
| 107 | + | LastStartedAt: 1700000000, | |
| 101 | 108 | }, | |
| 102 | 109 | }, | |
| 103 | 110 | } |
| ... | ... | @@ -113,7 +120,7 @@ func TestStats_StorageAndPath(t *testing.T) { | |
| 113 | 120 | if loaded.Repo != repo || loaded.RunsCount != 5 || loaded.AvgWallDurationMs != 12000 { | |
| 114 | 121 | t.Errorf("loaded stats mismatch: %+v", loaded) | |
| 115 | 122 | } | |
| 116 | - | if task, ok := loaded.Tasks["build"]; !ok || task.AvgDurationMs != 6000 { | |
| 123 | + | if task, ok := loaded.Tasks["build"]; !ok || task.AvgDurationMs != 6000 || task.LastStartedAt != 1700000000 { | |
| 117 | 124 | t.Errorf("loaded task stats mismatch: %+v", task) | |
| 118 | 125 | } | |
| 119 | 126 | } |