Skip to content

Commit 6e52fa6

Browse files
committed
simulate: --repeats runs every scenario k times for pass@k / pass^k
One attempt per scenario cannot tell a flaky scenario from a broken one. --repeats K asks the server for K attempts of every scenario in one run; the CLI groups attempts under their scenario in the job list and report, labels each attempt, and adds a pass@k (passed at least once) / pass^k (passed every time) line to the counts header and summaries, computed over the scenarios whose attempts have all finished. The exit code is unchanged: any failed attempt fails the run.
1 parent 692b64e commit 6e52fa6

6 files changed

Lines changed: 175 additions & 7 deletions

File tree

autocomplete/fish_autocomplete

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ complete -x -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subco
222222
complete -x -c lk -n '__fish_seen_subcommand_from agent a; and not __fish_seen_subcommand_from init create dockerfile config deploy promote status update restart rollback logs tail delete destroy versions list secrets update-secrets private-link start dev console daemon simulate help h' -a 'simulate' -d 'Run agent simulations against LiveKit Cloud'
223223
complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate' -f -l num-simulations -s n -r -d 'Number of scenarios to generate'
224224
complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate' -f -l concurrency -r -d 'Max simulations running in parallel (default: server-side limit)'
225+
complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate' -f -l repeats -s k -r -d 'Run every scenario `K` times and report pass@k (passed at least once) and pass^k (passed every time). Requires --scenarios'
225226
complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate' -f -l scenarios -r -d 'Path to a scenarios `FILE` (yaml). If omitted, scenarios are generated from the agent\'s source'
226227
complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate' -f -l yes -s y -d 'Skip the source-upload confirmation prompt (required for non-interactive runs that generate from source)'
227228
complete -c lk -n '__fish_seen_subcommand_from agent a; and __fish_seen_subcommand_from simulate' -f -l agent-name -r -d '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.'

cmd/lk/simulate.go

Lines changed: 92 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,11 @@ var simulateCommand = &cli.Command{
8888
Name: "concurrency",
8989
Usage: "Max simulations running in parallel (default: server-side limit)",
9090
},
91+
&cli.IntFlag{
92+
Name: "repeats",
93+
Aliases: []string{"k"},
94+
Usage: "Run every scenario `K` times and report pass@k (passed at least once) and pass^k (passed every time). Requires --scenarios",
95+
},
9196
&cli.StringFlag{
9297
Name: "scenarios",
9398
Usage: "Path to a scenarios `FILE` (yaml). If omitted, scenarios are generated from the agent's source",
@@ -266,6 +271,7 @@ type simulateConfig struct {
266271
pc *config.ProjectConfig
267272
numSimulations int32
268273
concurrency int32
274+
repeats int32
269275
mode simulateMode
270276
simulationMode livekit.SimulationMode
271277
agentName string
@@ -372,11 +378,15 @@ func runSimulate(ctx context.Context, cmd *cli.Command, simulationMode livekit.S
372378

373379
numSimulations := int32(cmd.Int("num-simulations"))
374380
concurrency := int32(cmd.Int("concurrency"))
381+
repeats := int32(cmd.Int("repeats"))
375382
liveAgentName := cmd.String("agent-name")
376383

377384
// never auto-discovered: an explicit --scenarios file is the source of
378385
// truth, otherwise scenarios are generated from the agent's source
379386
scenariosPath := cmd.String("scenarios")
387+
if repeats > 1 && scenariosPath == "" {
388+
return fmt.Errorf("--repeats requires --scenarios (generated scenarios are not repeated)")
389+
}
380390

381391
var (
382392
agentName string
@@ -449,6 +459,7 @@ func runSimulate(ctx context.Context, cmd *cli.Command, simulationMode livekit.S
449459
pc: pc,
450460
numSimulations: numSimulations,
451461
concurrency: concurrency,
462+
repeats: repeats,
452463
mode: mode,
453464
simulationMode: simulationMode,
454465
agentName: agentName,
@@ -645,6 +656,7 @@ func createSimulationRun(ctx context.Context, c *simulateConfig) (string, *livek
645656
}
646657
if c.mode == modeScenarios {
647658
req.ScenarioGroup = c.scenarioGroup
659+
req.Repeats = c.repeats
648660
}
649661

650662
resp, err := c.client.CreateSimulationRun(ctx, req)
@@ -798,15 +810,92 @@ func cancelSimulationRun(client *lksdk.AgentSimulationClient, runID string) {
798810
}
799811
}
800812

801-
// sortedJobs orders a run's jobs by job ID: the backend's ordering shuffles
802-
// rows as statuses change.
813+
// sortedJobs orders a run's jobs so every attempt of a scenario sits together,
814+
// scenarios in scenarios.yaml order, then by job ID: the backend's ordering
815+
// shuffles rows as statuses change.
803816
func sortedJobs(run *livekit.SimulationRun) []*livekit.SimulationRun_Job {
817+
order := make(map[string]int, len(run.GetScenarioGroup().GetScenarios()))
818+
for i, sc := range run.GetScenarioGroup().GetScenarios() {
819+
order[sc.GetId()] = i
820+
}
804821
jobs := make([]*livekit.SimulationRun_Job, len(run.Jobs))
805822
copy(jobs, run.Jobs)
806-
sort.Slice(jobs, func(i, j int) bool { return jobs[i].GetId() < jobs[j].GetId() })
823+
sort.Slice(jobs, func(i, j int) bool {
824+
a, b := jobs[i], jobs[j]
825+
if a.GetScenarioId() != b.GetScenarioId() {
826+
return order[a.GetScenarioId()] < order[b.GetScenarioId()]
827+
}
828+
if a.GetAttempt() != b.GetAttempt() {
829+
return a.GetAttempt() < b.GetAttempt()
830+
}
831+
return a.GetId() < b.GetId()
832+
})
807833
return jobs
808834
}
809835

836+
// scenarioPassCounts folds a repeated run's jobs into scenarios: how many
837+
// have every attempt finished, and of those how many passed at least once
838+
// (pass@k) and every time (pass^k). Zero scenarios when the run did not
839+
// repeat, so callers can skip the line.
840+
func scenarioPassCounts(run *livekit.SimulationRun) (scenarios, passAny, passAll int) {
841+
k := int(run.GetRepeats())
842+
if k < 2 {
843+
return
844+
}
845+
type tally struct{ terminal, passed int }
846+
tallies := map[string]*tally{}
847+
for _, j := range run.Jobs {
848+
if j.GetScenarioId() == "" {
849+
continue
850+
}
851+
t := tallies[j.GetScenarioId()]
852+
if t == nil {
853+
t = &tally{}
854+
tallies[j.GetScenarioId()] = t
855+
}
856+
if isTerminalJobStatus(j.Status) {
857+
t.terminal++
858+
}
859+
if j.Status == livekit.SimulationRun_Job_STATUS_COMPLETED {
860+
t.passed++
861+
}
862+
}
863+
for _, t := range tallies {
864+
if t.terminal < k {
865+
continue
866+
}
867+
scenarios++
868+
if t.passed > 0 {
869+
passAny++
870+
}
871+
if t.passed == k {
872+
passAll++
873+
}
874+
}
875+
return
876+
}
877+
878+
// passRateLine is the pass@k / pass^k summary, or "" when the run did not
879+
// repeat or no scenario has finished every attempt.
880+
func passRateLine(run *livekit.SimulationRun) string {
881+
scenarios, passAny, passAll := scenarioPassCounts(run)
882+
if scenarios == 0 {
883+
return ""
884+
}
885+
k := run.GetRepeats()
886+
return fmt.Sprintf("pass@%d %d/%d (%.2f), pass^%d %d/%d (%.2f)",
887+
k, passAny, scenarios, float64(passAny)/float64(scenarios),
888+
k, passAll, scenarios, float64(passAll)/float64(scenarios))
889+
}
890+
891+
// attemptSuffix marks a job's attempt when the run repeated scenarios.
892+
func attemptSuffix(run *livekit.SimulationRun, job *livekit.SimulationRun_Job) string {
893+
if run.GetRepeats() < 2 || job.GetAttempt() == 0 {
894+
return ""
895+
}
896+
return fmt.Sprintf(" (attempt %d/%d)", job.GetAttempt(), run.GetRepeats())
897+
}
898+
810899
func simulationJobCounts(run *livekit.SimulationRun) (total, done, passed, failed int) {
811900
if run == nil {
812901
return

cmd/lk/simulate_ci.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,9 @@ func runSimulateCI(ctx context.Context, config *simulateConfig) error {
207207
}
208208
total, _, passed, failedN := simulationJobCounts(run)
209209
fmt.Fprintf(out.ResultWriter(), "%d total, %d passed, %d failed\n", total, passed, failedN)
210+
if line := passRateLine(run); line != "" {
211+
fmt.Fprintln(out.ResultWriter(), line)
212+
}
210213
}
211214

212215
if brokenAgent && agent != nil {

cmd/lk/simulate_repeats_test.go

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
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+
"testing"
19+
20+
"github.com/livekit/protocol/livekit"
21+
"github.com/stretchr/testify/require"
22+
)
23+
24+
func attemptJob(id, scenario string, attempt int32, status livekit.SimulationRun_Job_Status) *livekit.SimulationRun_Job {
25+
return &livekit.SimulationRun_Job{Id: id, ScenarioId: scenario, Attempt: attempt, Status: status}
26+
}
27+
28+
func TestScenarioPassCounts(t *testing.T) {
29+
const (
30+
done = livekit.SimulationRun_Job_STATUS_COMPLETED
31+
failed = livekit.SimulationRun_Job_STATUS_FAILED
32+
running = livekit.SimulationRun_Job_STATUS_RUNNING
33+
)
34+
run := &livekit.SimulationRun{Repeats: 2, Jobs: []*livekit.SimulationRun_Job{
35+
attemptJob("SRJ_1", "SCN_a", 1, done), attemptJob("SRJ_2", "SCN_a", 2, done), // pass^k
36+
attemptJob("SRJ_3", "SCN_b", 1, done), attemptJob("SRJ_4", "SCN_b", 2, failed), // pass@k only
37+
attemptJob("SRJ_5", "SCN_c", 1, failed), attemptJob("SRJ_6", "SCN_c", 2, failed), // neither
38+
attemptJob("SRJ_7", "SCN_d", 1, done), attemptJob("SRJ_8", "SCN_d", 2, running), // not finished
39+
}}
40+
41+
scenarios, passAny, passAll := scenarioPassCounts(run)
42+
require.Equal(t, 3, scenarios)
43+
require.Equal(t, 2, passAny)
44+
require.Equal(t, 1, passAll)
45+
require.Equal(t, "pass@2 2/3 (0.67), pass^2 1/3 (0.33)", passRateLine(run))
46+
47+
run.Repeats = 1
48+
require.Equal(t, "", passRateLine(run))
49+
}
50+
51+
func TestSortedJobs_GroupsAttemptsInScenarioOrder(t *testing.T) {
52+
run := &livekit.SimulationRun{
53+
Repeats: 2,
54+
ScenarioGroup: &livekit.ScenarioGroup{Scenarios: []*livekit.Scenario{{Id: "SCN_b"}, {Id: "SCN_a"}}},
55+
Jobs: []*livekit.SimulationRun_Job{
56+
attemptJob("SRJ_1", "SCN_a", 2, 0),
57+
attemptJob("SRJ_2", "SCN_b", 2, 0),
58+
attemptJob("SRJ_3", "SCN_a", 1, 0),
59+
attemptJob("SRJ_4", "SCN_b", 1, 0),
60+
},
61+
}
62+
var ids []string
63+
for _, j := range sortedJobs(run) {
64+
ids = append(ids, j.Id)
65+
}
66+
require.Equal(t, []string{"SRJ_4", "SRJ_2", "SRJ_3", "SRJ_1"}, ids)
67+
require.Equal(t, " (attempt 1/2)", attemptSuffix(run, run.Jobs[3]))
68+
}

cmd/lk/simulate_report.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ func writeRunResults(w io.Writer, run *livekit.SimulationRun, ap *AgentProcess)
173173
return
174174
}
175175

176-
for i, job := range run.Jobs {
176+
for i, job := range sortedJobs(run) {
177177
icon := "⏺"
178178
switch job.Status {
179179
case livekit.SimulationRun_Job_STATUS_COMPLETED:
@@ -186,6 +186,7 @@ func writeRunResults(w io.Writer, run *livekit.SimulationRun, ap *AgentProcess)
186186
if label == "" {
187187
label = fmt.Sprintf("Job %d", i+1)
188188
}
189+
label += attemptSuffix(run, job)
189190

190191
fmt.Fprintf(w, "::group::%s %s (%s)\n", icon, label, job.Id)
191192

@@ -251,6 +252,9 @@ func writeRunSummary(w io.Writer, run *livekit.SimulationRun, summary *livekit.S
251252
fmt.Fprintln(w)
252253
fmt.Fprintln(w, "::group::Summary")
253254
fmt.Fprintf(w, "%d total, %d passed, %d failed\n", total, passed, failed)
255+
if line := passRateLine(run); line != "" {
256+
fmt.Fprintln(w, line)
257+
}
254258

255259
if summary.GoingWell != "" {
256260
fmt.Fprintln(w)

cmd/lk/simulate_tui.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1409,6 +1409,9 @@ func (m *simulateModel) renderCounts() string {
14091409
if running > 0 {
14101410
parts = append(parts, yellowStyle().Render(fmt.Sprintf("%d running", running)))
14111411
}
1412+
if line := passRateLine(m.run); line != "" {
1413+
parts = append(parts, boldStyle.Render(line))
1414+
}
14121415

14131416
elapsed := ""
14141417
if !m.startTime.IsZero() {
@@ -1474,7 +1477,7 @@ func (m *simulateModel) renderJobList() string {
14741477
maxWidth := 0
14751478
for i := winStart; i < winEnd; i++ {
14761479
ij := jobs[i]
1477-
row := rowData{ij: ij, label: jobLabel(ij.job)}
1480+
row := rowData{ij: ij, label: jobLabel(ij.job) + attemptSuffix(m.run, ij.job)}
14781481
rows = append(rows, row)
14791482
w := lipgloss.Width(fmt.Sprintf(" ⏺ %3d. %s %s", ij.origIdx, ij.job.Id, row.label))
14801483
if w > maxWidth {
@@ -1556,7 +1559,7 @@ func (m *simulateModel) buildMatrixRows() []matrixRow {
15561559
}
15571560
for i := winStart; i < winEnd; i++ {
15581561
ij := jobs[i]
1559-
label := jobLabel(ij.job)
1562+
label := jobLabel(ij.job) + attemptSuffix(m.run, ij.job)
15601563
iconCh, iconStyle := jobStatusIcon(ij.job)
15611564
line := fmt.Sprintf(" %c %3d. %s %s", iconCh, ij.origIdx, ij.job.Id, label)
15621565
rows = append(rows, matrixRow{
@@ -1598,7 +1601,7 @@ func (m *simulateModel) renderDetail() string {
15981601
b.WriteString("\n")
15991602
fmt.Fprintf(&b, " %s %s %s\n",
16001603
jobIcon(job),
1601-
boldStyle.Render(fmt.Sprintf("Job %d", origIdx)),
1604+
boldStyle.Render(fmt.Sprintf("Job %d", origIdx)+attemptSuffix(m.run, job)),
16021605
dimStyle.Render(job.Id),
16031606
)
16041607
if url := simulationJobDashboardURL(m.projectID(), m.runID, job.Id); url != "" {

0 commit comments

Comments
 (0)