Skip to content

Commit 30b901b

Browse files
committed
feat(simulate): retry still-failing scenarios in CI with --retries (default 3)
After a run finishes with failures, CI mode re-runs only the failing scenarios against the same already-registered agent, up to --retries times; a scenario passes when any attempt does. The scenarios come from the finished run itself, so generated-from-source runs retry without re-uploading or regenerating. Systemic conditions (broken agent, quota exhaustion) are never retried. The verdict (counts, --baseline comparison, exit error) reads each scenario's outcome from the last attempt that ran it to a terminal state, so a cancelled retry cannot launder an earlier failure. Every attempt's transcript still prints; a failure that later passed keeps its transcript but loses its ::error:: annotation, and the final counts name the scenarios that passed on retry so flakes stay visible.
1 parent 5998abf commit 30b901b

5 files changed

Lines changed: 335 additions & 14 deletions

File tree

cmd/lk/simulate.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,11 @@ var simulateCommand = &cli.Command{
105105
Name: "baseline",
106106
Usage: "Compare failures against the finished run with run `ID`: only scenarios that pass there and fail here fail the exit code. Non-interactive (CI) runs only",
107107
},
108+
&cli.IntFlag{
109+
Name: "retries",
110+
Value: 3,
111+
Usage: "Times to re-run scenarios that still fail before giving up (0 disables). A scenario passes when any attempt passes. Non-interactive (CI) runs only",
112+
},
108113
&cli.StringFlag{
109114
Name: "agent-name",
110115
Usage: "Run against an already-running agent instead of spawning one locally. Pass the registered `NAME`, or \"\" to target the project's default agent (the one that auto-joins every room). Requires --scenarios.",
@@ -219,6 +224,7 @@ type simulateConfig struct {
219224
scenariosPath string // path to the --scenarios file (empty when generating from source)
220225
viewModeRunID string // non-empty when --view opens a pre-existing run
221226
baselineRunID string // --baseline: failures this run also has don't fail CI
227+
retries int // --retries: times to re-run still-failing scenarios (CI only)
222228
liveAgent bool // --agent-name: run against an already-running agent, don't spawn one
223229
warnings []string // config-level warnings surfaced at setup (e.g. ignored flags)
224230

@@ -422,6 +428,7 @@ func runSimulate(ctx context.Context, cmd *cli.Command, simulationMode livekit.S
422428
scenariosPath: scenariosPath,
423429
viewModeRunID: runID,
424430
baselineRunID: cmd.String("baseline"),
431+
retries: int(cmd.Int("retries")),
425432
liveAgent: liveAgent,
426433
warnings: simulateConfigWarnings(mode, numSimulations),
427434
}

cmd/lk/simulate_ci.go

Lines changed: 50 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -145,33 +145,76 @@ func runSimulateCI(ctx context.Context, config *simulateConfig) error {
145145
return err
146146
}
147147
runFinished = true
148+
firstRunID := runID
149+
150+
// --- Retry still-failing scenarios ---
151+
//
152+
// Each retry re-runs only the scenarios still failing, against the same
153+
// already-registered agent. Systemic conditions (broken agent, quota
154+
// exhaustion) fail the same way again, so they are not retried.
155+
attempts := []*livekit.SimulationRun{run}
156+
for len(attempts)-1 < config.retries && !poller.brokenAgent && !poller.quotaWarned {
157+
failed := failedScenarioKeys(mergedFinalRun(attempts))
158+
if len(failed) == 0 {
159+
break
160+
}
161+
group := retryScenarioGroup(run, config.scenarioGroup, failed)
162+
if group == nil {
163+
break
164+
}
165+
166+
report.RetryingFailed(len(attempts), config.retries, failed)
167+
168+
retryCfg := *config
169+
retryCfg.mode = modeScenarios
170+
retryCfg.scenarioGroup = group
171+
retryID, _, err := createSimulationRun(ctx, &retryCfg)
172+
if err != nil {
173+
out.Warnf("Warning: could not create the retry run: %v", err)
174+
break
175+
}
176+
runID, runFinished = retryID, false
177+
report.RunCreated(runID, simulationDashboardURL(config.pc.ProjectId, runID))
178+
179+
retryRun, err := poller.poll(ctx, runID)
180+
if err != nil {
181+
return err
182+
}
183+
runFinished = true
184+
attempts = append(attempts, retryRun)
185+
}
148186
brokenAgent := poller.brokenAgent
187+
finalRun := mergedFinalRun(attempts)
149188

150189
// --- Results ---
151190

152191
if !out.Interactive() {
153-
report.Results(run, agent)
192+
report.ResultsAll(attempts, agent)
154193
} else {
155194
// A terminal is watching; we just couldn't open the TUI (e.g. stdin
156195
// isn't a TTY). Keep it to counts and pointers, the per-scenario
157196
// transcripts go to a report file like the TUI's.
158-
dashboardURL := simulationDashboardURL(config.pc.ProjectId, runID)
159-
if path := newRunReporter().Finish(run, agent, brokenAgent, dashboardURL); path != "" {
197+
dashboardURL := simulationDashboardURL(config.pc.ProjectId, firstRunID)
198+
if path := newRunReporter().FinishAll(attempts, agent, brokenAgent, dashboardURL); path != "" {
160199
out.Statusf("Run report: %s", path)
161200
}
162-
total, _, passed, failedN := simulationJobCounts(run)
163-
fmt.Fprintf(out.ResultWriter(), "%d total, %d passed, %d failed\n", total, passed, failedN)
201+
total, _, passed, failedN := simulationJobCounts(finalRun)
202+
line := fmt.Sprintf("%d total, %d passed, %d failed", total, passed, failedN)
203+
if flaky := passedOnRetry(attempts); len(flaky) > 0 {
204+
line += fmt.Sprintf(" (%d passed on retry)", len(flaky))
205+
}
206+
fmt.Fprintln(out.ResultWriter(), line)
164207
}
165208

166209
if brokenAgent && agent != nil {
167210
writeBrokenAgentNote(out.WarnWriter(), agent)
168211
}
169212

170-
if url := simulationDashboardURL(config.pc.ProjectId, runID); url != "" {
213+
if url := simulationDashboardURL(config.pc.ProjectId, firstRunID); url != "" {
171214
out.Statusf("Dashboard: %s", url)
172215
}
173216

174-
return baselineFailureError(ctx, config, run)
217+
return baselineFailureError(ctx, config, finalRun)
175218
}
176219

177220
// ciRunPoller polls a run until it reaches a terminal state. Detection state

cmd/lk/simulate_report.go

Lines changed: 55 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -127,8 +127,48 @@ func (l *simLog) BrokenAgent() {
127127
fmt.Fprintln(l.info, "The agent is failing to run jobs; cancelling the run.")
128128
}
129129

130+
// RetryingFailed announces the next retry run and resets per-run progress
131+
// tracking so the retry's counts print from zero.
132+
func (l *simLog) RetryingFailed(retryNum, maxRetries int, keys []string) {
133+
l.prevStatus = livekit.SimulationRun_Status(-1)
134+
l.prevDone = 0
135+
fmt.Fprintln(l.out)
136+
fmt.Fprintf(l.out, "%d scenario(s) still failing, retrying (%d of %d): %s\n",
137+
len(keys), retryNum, maxRetries, strings.Join(keys, ", "))
138+
}
139+
130140
func (l *simLog) Results(run *livekit.SimulationRun, ap *AgentProcess) {
131-
writeRunResults(l.out, run, ap)
141+
l.ResultsAll([]*livekit.SimulationRun{run}, ap)
142+
}
143+
144+
// ResultsAll writes each attempt's results in order. A job that failed but
145+
// passed on a later attempt keeps its transcript and loses only the ::error::
146+
// annotation; the merged counts at the end are the run's verdict.
147+
func (l *simLog) ResultsAll(attempts []*livekit.SimulationRun, ap *AgentProcess) {
148+
if len(attempts) == 0 {
149+
return
150+
}
151+
flaky := passedOnRetry(attempts)
152+
resolved := make(map[string]bool, len(flaky))
153+
for _, key := range flaky {
154+
resolved[key] = true
155+
}
156+
for i, run := range attempts {
157+
if i > 0 {
158+
fmt.Fprintln(l.out)
159+
fmt.Fprintf(l.out, "--- Retry %d ---\n", i)
160+
}
161+
writeRunResults(l.out, run, ap, resolved)
162+
}
163+
if len(attempts) > 1 {
164+
total, _, passed, failed := simulationJobCounts(mergedFinalRun(attempts))
165+
fmt.Fprintln(l.out)
166+
fmt.Fprintf(l.out, "After retries: %d total, %d passed, %d failed", total, passed, failed)
167+
if len(flaky) > 0 {
168+
fmt.Fprintf(l.out, " (passed on retry: %s)", strings.Join(flaky, ", "))
169+
}
170+
fmt.Fprintln(l.out)
171+
}
132172
if l.quotaNote != "" {
133173
fmt.Fprintf(l.out, "\n⚠ %s\n", l.quotaNote)
134174
}
@@ -161,8 +201,10 @@ func (a asciiWriter) Write(p []byte) (int, error) {
161201
}
162202

163203
// writeRunResults writes the per-job results and the run summary, with GitHub
164-
// group markers (a useful delimiter outside GitHub too).
165-
func writeRunResults(w io.Writer, run *livekit.SimulationRun, ap *AgentProcess) {
204+
// group markers (a useful delimiter outside GitHub too). Failed jobs whose
205+
// scenario is in passedOnRetry get no ::error:: annotation — a later attempt
206+
// passed them.
207+
func writeRunResults(w io.Writer, run *livekit.SimulationRun, ap *AgentProcess, passedOnRetry map[string]bool) {
166208
if run == nil {
167209
return
168210
}
@@ -227,7 +269,7 @@ func writeRunResults(w io.Writer, run *livekit.SimulationRun, ap *AgentProcess)
227269

228270
fmt.Fprintln(w, "::endgroup::")
229271

230-
if job.Status == livekit.SimulationRun_Job_STATUS_FAILED {
272+
if job.Status == livekit.SimulationRun_Job_STATUS_FAILED && !passedOnRetry[scenarioKey(job)] {
231273
firstLine, _, _ := strings.Cut(job.Error, "\n")
232274
fmt.Fprintf(w, "::error::Job %d failed: %s\n", i+1, firstLine)
233275
}
@@ -340,12 +382,18 @@ func newRunReporter() *runReporter {
340382
}
341383

342384
func (r *runReporter) Finish(run *livekit.SimulationRun, ap *AgentProcess, brokenAgent bool, dashboardURL string) string {
385+
var attempts []*livekit.SimulationRun
386+
if run != nil {
387+
attempts = []*livekit.SimulationRun{run}
388+
}
389+
return r.FinishAll(attempts, ap, brokenAgent, dashboardURL)
390+
}
391+
392+
func (r *runReporter) FinishAll(attempts []*livekit.SimulationRun, ap *AgentProcess, brokenAgent bool, dashboardURL string) string {
343393
if r.f == nil {
344394
return ""
345395
}
346-
if run != nil {
347-
r.Results(run, ap)
348-
}
396+
r.ResultsAll(attempts, ap)
349397
if brokenAgent && ap != nil {
350398
writeBrokenAgentNote(r.info, ap)
351399
}

cmd/lk/simulate_retry.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
// Copyright 2026 LiveKit, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package main
16+
17+
import (
18+
"github.com/livekit/protocol/livekit"
19+
"google.golang.org/protobuf/proto"
20+
)
21+
22+
// scenarioKey identifies a scenario across runs: the label, or the
23+
// instructions for generated jobs that carry no label.
24+
func scenarioKey(job *livekit.SimulationRun_Job) string {
25+
if l := job.GetLabel(); l != "" {
26+
return l
27+
}
28+
return job.GetInstructions()
29+
}
30+
31+
// retryScenarioGroup returns the scenarios matching keys, taken from the
32+
// finished run (which carries its scenarios in both --scenarios and generated
33+
// modes) or from fallback when the run carries none. Nil when nothing matches:
34+
// there is nothing to re-run.
35+
func retryScenarioGroup(run *livekit.SimulationRun, fallback *livekit.ScenarioGroup, keys []string) *livekit.ScenarioGroup {
36+
group := run.GetScenarioGroup()
37+
if len(group.GetScenarios()) == 0 {
38+
group = fallback
39+
}
40+
want := make(map[string]bool, len(keys))
41+
for _, k := range keys {
42+
want[k] = true
43+
}
44+
out := &livekit.ScenarioGroup{Name: group.GetName()}
45+
for _, s := range group.GetScenarios() {
46+
key := s.GetLabel()
47+
if key == "" {
48+
key = s.GetInstructions()
49+
}
50+
if want[key] {
51+
out.Scenarios = append(out.Scenarios, s)
52+
}
53+
}
54+
if len(out.Scenarios) == 0 {
55+
return nil
56+
}
57+
return out
58+
}
59+
60+
// mergedFinalRun folds retry attempts into the first run: each job's outcome
61+
// comes from the last attempt that ran its scenario to a terminal state, so a
62+
// cancelled retry cannot launder an earlier failure. The CI verdict (counts,
63+
// baseline comparison, exit error) reads final outcomes; the printed
64+
// per-attempt results stay verbatim.
65+
func mergedFinalRun(attempts []*livekit.SimulationRun) *livekit.SimulationRun {
66+
if len(attempts) == 1 {
67+
return attempts[0]
68+
}
69+
final := make(map[string]*livekit.SimulationRun_Job)
70+
for _, run := range attempts[1:] {
71+
for _, job := range run.GetJobs() {
72+
if isTerminalJobStatus(job.GetStatus()) {
73+
final[scenarioKey(job)] = job
74+
}
75+
}
76+
}
77+
merged := proto.Clone(attempts[0]).(*livekit.SimulationRun)
78+
for i, job := range merged.Jobs {
79+
if f, ok := final[scenarioKey(job)]; ok {
80+
merged.Jobs[i] = f
81+
}
82+
}
83+
return merged
84+
}
85+
86+
// passedOnRetry returns the scenarios that failed on some attempt but passed
87+
// on a later one, in first-run job order.
88+
func passedOnRetry(attempts []*livekit.SimulationRun) []string {
89+
failedEver := make(map[string]bool)
90+
for _, run := range attempts {
91+
for _, key := range failedScenarioKeys(run) {
92+
failedEver[key] = true
93+
}
94+
}
95+
seen := make(map[string]bool)
96+
var flaky []string
97+
for _, job := range mergedFinalRun(attempts).GetJobs() {
98+
key := scenarioKey(job)
99+
if failedEver[key] && !seen[key] && job.GetStatus() == livekit.SimulationRun_Job_STATUS_COMPLETED {
100+
seen[key] = true
101+
flaky = append(flaky, key)
102+
}
103+
}
104+
return flaky
105+
}

0 commit comments

Comments
 (0)