binpacker 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dfee21fa79b8b157afbe89bba482a2a8c2b95c4ec22dbff36117a269275f47ef
4
- data.tar.gz: 636266dc852e315dffb3e1b815f39b615e4a73be4cfa9dac445a01ef7b7d76fc
3
+ metadata.gz: 98842f3601fe059ff372b62a214ee73e5e96d0b2c01cf5b651063f04e3f00184
4
+ data.tar.gz: 94e73ac26474bf1da1942509f35618e8576d230b685ee69fe09579d4b9387898
5
5
  SHA512:
6
- metadata.gz: 3eb4035f35100358451c5efa9ba5ae178932defb326a988d9d1e6da1521c79b201e68ba075f8bd2575f36a4d0ce33d92b40fd5117afbac21068e3eaabb93daf2
7
- data.tar.gz: 788f484e43e7d9c6d77519cabe924f6080e77ebff5e4fb1125e38721c165c1bf6bbdd6e0f4a9fa1140a3b1d62da770be746cbcd4f68dce15f36d0ab556d949c9
6
+ metadata.gz: 6a983e121b41c7a9e62dad700ee5541813b84c2553e7fc479c3dbedf996358669a9508d2fd7f3eb8df21831c583421ee91382a453ac73a60beead9bf03da0675
7
+ data.tar.gz: 657ac41276da7094a5ea54d53936a9237b1b3139f53b741c954427b57f2d781dca56df91746972e33e93f49edb7a93a2155d5b92561653d672f87ca73373669a
data/exe/binpacker-worker CHANGED
@@ -166,6 +166,16 @@ end.parse!
166
166
 
167
167
  wid = options[:id]
168
168
  exit_code = 0
169
+
170
+ # Test code must never see the orchestrator's control pipe. A test
171
+ # that reads stdin — an LSP server loop, anything calling gets —
172
+ # would block forever on the pipe the orchestrator keeps open
173
+ # between dynamic batches (or, worse, consume control messages).
174
+ # Keep the pipe on a private handle and point process stdin, which
175
+ # spawned test runners inherit, at /dev/null so readers see EOF.
176
+ control = $stdin.dup
177
+ $stdin.reopen(File::NULL)
178
+
169
179
  $stderr.puts "[worker-#{wid}] #{options[:runner]} worker started"
170
180
 
171
181
  loop do
@@ -174,7 +184,7 @@ loop do
174
184
 
175
185
  tests = []
176
186
  done_or_eof = false
177
- $stdin.each_line do |line|
187
+ control.each_line do |line|
178
188
  data = JSON.parse(line.strip)
179
189
  if data["type"] == "done"
180
190
  done_or_eof = true
@@ -186,7 +196,7 @@ loop do
186
196
  end
187
197
 
188
198
  break if done_or_eof && tests.empty?
189
- break if tests.empty? && ($stdin.eof? || $stdin.closed?)
199
+ break if tests.empty? && (control.eof? || control.closed?)
190
200
 
191
201
  files = tests.map { |t| t["file"] }.uniq
192
202
  $stderr.puts "[worker-#{wid}] batch: #{files.size} files, #{tests.size} entries"
@@ -13,10 +13,13 @@ module Binpacker
13
13
  @timing = Timing.new(config.timing_file)
14
14
  end
15
15
 
16
- def run(tests)
16
+ # Runs each Test serially and appends its measured Weight to the Timing file.
17
+ # With incremental: true, Tests that already have a measured Weight are skipped.
18
+ def run(tests, incremental: false)
19
+ targets = incremental ? unmeasured(tests) : tests
17
20
  results = []
18
21
 
19
- tests.each do |test|
22
+ targets.each do |test|
20
23
  elapsed = run_single(test)
21
24
  results << { file: test.file, name: test.name, time: elapsed }
22
25
  end
@@ -27,21 +30,30 @@ module Binpacker
27
30
 
28
31
  private
29
32
 
33
+ def unmeasured(tests)
34
+ measured = @timing.load_raw
35
+ tests.reject { |test| measured.key?([@timing.normalize_path(test.file), test.name]) }
36
+ end
37
+
30
38
  def run_single(test)
31
39
  outfile = Tempfile.new("binpacker-cal")
32
40
  outfile.close
33
41
 
34
42
  start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
35
43
 
44
+ # At file granularity the Test's name is its file path (see Test
45
+ # discovery), so there is no single example/method to filter — run the
46
+ # whole file. A per-test filter would match nothing and measure only
47
+ # load time.
48
+ file_unit = test.name == test.file
49
+
36
50
  case @config.test_runner
37
51
  when "rspec"
38
- cmd = [
39
- "rspec", test.file,
40
- "--example", test.name,
41
- "--format", "json", "--out", outfile.path
42
- ]
52
+ cmd = ["rspec", test.file, "--format", "json", "--out", outfile.path]
53
+ cmd.push("--example", test.name) unless file_unit
43
54
  when "minitest"
44
- cmd = ["ruby", "-Ilib:test", test.file, "--name", "/^#{Regexp.escape(test.name)}$/"]
55
+ cmd = ["ruby", "-Ilib:test", test.file]
56
+ cmd.push("--name", "/^#{Regexp.escape(test.name)}$/") unless file_unit
45
57
  else
46
58
  raise ConfigError, "unsupported runner for calibration: #{@config.test_runner}"
47
59
  end
data/lib/binpacker/cli.rb CHANGED
@@ -15,6 +15,12 @@ module Binpacker
15
15
  @profile = nil
16
16
  @passthrough = []
17
17
  @quiet = false
18
+ @incremental = false
19
+ @report_path = nil
20
+ @skill_list = false
21
+ @skill_path = false
22
+ @skill_describe = false
23
+ @skill_name = nil
18
24
  parse!
19
25
  end
20
26
 
@@ -26,6 +32,10 @@ module Binpacker
26
32
  cmd_run
27
33
  when "init"
28
34
  cmd_init
35
+ when "skill"
36
+ cmd_skill
37
+ when "describe"
38
+ cmd_describe
29
39
  when "--version", "-v"
30
40
  puts "binpacker #{Binpacker::VERSION}"
31
41
  when "--help", "-h", nil
@@ -58,6 +68,26 @@ module Binpacker
58
68
  opts.on("--quiet", "Suppress worker output") do
59
69
  @quiet = true
60
70
  end
71
+
72
+ opts.on("--incremental", "Calibrate only tests without timing data") do
73
+ @incremental = true
74
+ end
75
+
76
+ opts.on("--report PATH", "(run) Write a JSON run report to PATH") do |v|
77
+ @report_path = v
78
+ end
79
+
80
+ opts.on("--list", "(skill) List bundled skills") do
81
+ @skill_list = true
82
+ end
83
+
84
+ opts.on("--path", "(skill) Print the SKILL.md path for <name>") do
85
+ @skill_path = true
86
+ end
87
+
88
+ opts.on("--describe", "(skill) Report project state and next skill") do
89
+ @skill_describe = true
90
+ end
61
91
  end
62
92
 
63
93
  # Extract passthrough arguments after "--"
@@ -67,7 +97,10 @@ module Binpacker
67
97
  end
68
98
 
69
99
  remaining = parser.parse(@args)
70
- @command = remaining.shift
100
+ # A positional command wins; otherwise keep any command an option set
101
+ # (e.g. --version), so `binpacker --version` isn't clobbered to nil.
102
+ @command = remaining.shift || @command
103
+ @skill_name = remaining.shift
71
104
  end
72
105
 
73
106
  def cmd_init
@@ -78,6 +111,11 @@ module Binpacker
78
111
  end
79
112
 
80
113
  framework = detect_framework
114
+ unless ProjectState::SUPPORTED_FRAMEWORKS.include?(framework)
115
+ $stderr.puts "Detected #{framework} — binpacker supports rspec and minitest only."
116
+ $stderr.puts "No binpacker.yml was created."
117
+ exit 1
118
+ end
81
119
  pattern = framework == "minitest" ? "test/**/*_test.rb" : "spec/**/*_spec.rb"
82
120
  runner = framework
83
121
 
@@ -89,7 +127,7 @@ module Binpacker
89
127
  timing_file: binpacker.timings
90
128
  test_pattern: "#{pattern}"
91
129
  scheduler:
92
- algorithm: lpt
130
+ algorithm: multifit
93
131
  steal_enabled: true
94
132
  ci:
95
133
  extends: default
@@ -110,21 +148,98 @@ module Binpacker
110
148
  discovery_klass = config.test_runner == "rspec" ? RSpecDiscovery : MinitestDiscovery
111
149
  tests = discovery_klass.new(config).enumerate
112
150
 
113
- puts "Calibrating #{tests.size} tests..."
114
151
  cal = Calibration.new(config)
115
- timings = cal.run(tests)
152
+
153
+ if @incremental
154
+ puts "Calibrating (incremental) #{tests.size} tests..."
155
+ else
156
+ puts "Calibrating #{tests.size} tests..."
157
+ end
158
+
159
+ timings = cal.run(tests, incremental: @incremental)
116
160
 
117
161
  total = timings.sum { |t| t[:time] }
118
- puts "Calibration complete: #{tests.size} tests in #{total.round(2)}s"
162
+ measured = timings.size
163
+ skipped = tests.size - measured
164
+ summary = "Calibration complete: #{measured} #{pluralize(measured, 'test')} in #{total.round(2)}s"
165
+ summary += " (#{skipped} skipped, already measured)" if @incremental && skipped.positive?
166
+ puts summary
119
167
  puts "Timing data written to #{config.timing_file}"
120
168
  end
121
169
 
170
+ def cmd_skill
171
+ return cmd_describe if @skill_describe
172
+
173
+ if @skill_path
174
+ path = Skills.path(@skill_name)
175
+ if path
176
+ puts path
177
+ else
178
+ $stderr.puts "unknown skill: #{@skill_name}"
179
+ exit 1
180
+ end
181
+ elsif @skill_name && !@skill_list
182
+ print_skill(@skill_name)
183
+ else
184
+ list_skills
185
+ end
186
+ end
187
+
188
+ def cmd_describe
189
+ state = ProjectState.new
190
+ puts "binpacker project state:"
191
+ puts " config (binpacker.yml): #{state.config_present? ? 'present' : 'missing'}"
192
+ puts " timing data: #{state.timing_present? ? 'present' : 'missing'}"
193
+ puts " test framework: #{state.framework || 'not detected'}"
194
+ puts " CI wired for binpacker: #{state.ci_wired? ? 'yes' : 'no'}"
195
+ puts ""
196
+
197
+ unless state.supported_framework?
198
+ puts "binpacker supports rspec and minitest; #{state.framework} is not supported yet."
199
+ puts "Setup cannot proceed against a #{state.framework} suite."
200
+ return
201
+ end
202
+
203
+ rec = state.recommendation
204
+ puts "Recommended next skill: #{rec}"
205
+ puts "Run `binpacker skill #{rec}` to load its instructions."
206
+ end
207
+
208
+ def list_skills
209
+ skills = Skills.list
210
+ if skills.empty?
211
+ puts "No bundled skills found."
212
+ return
213
+ end
214
+ puts "Bundled skills:"
215
+ skills.each { |s| puts " #{s[:name]}" }
216
+ puts ""
217
+ puts "Print a skill's instructions with `binpacker skill <name>`."
218
+ end
219
+
220
+ def print_skill(name)
221
+ unless Skills.exist?(name)
222
+ $stderr.puts "unknown skill: #{name}"
223
+ $stderr.puts "Run `binpacker skill` to list bundled skills."
224
+ exit 1
225
+ end
226
+
227
+ puts "# Skill: #{name}"
228
+ puts "# Source: #{Skills.path(name)}"
229
+ puts ""
230
+ puts Skills.body(name)
231
+ end
232
+
122
233
  def cmd_run
123
234
  config = Config.new(profile: @profile)
124
- orchestrator = Orchestrator.new(config, passthrough: @passthrough, quiet: @quiet)
235
+ report_path = @report_path || config.report_file
236
+ orchestrator = Orchestrator.new(
237
+ config, passthrough: @passthrough, quiet: @quiet, report_path: report_path
238
+ )
125
239
 
126
240
  puts "binpacker starting (#{config.worker_count} workers, profile: #{config.profile})"
127
241
  result = orchestrator.run
242
+ puts "Run report written to #{report_path}" if report_path
128
243
  unit = test_unit_label(config)
129
244
 
130
245
  if result[:passed]
@@ -156,23 +271,30 @@ module Binpacker
156
271
  run Execute tests across worker processes
157
272
  calibrate Run tests serially to generate timing data
158
273
  init Create binpacker.yml with auto-detected settings
274
+ describe Report project state and recommend the next skill
275
+ skill List or print bundled agent skills
159
276
 
160
277
  Options:
161
278
  --profile NAME Select profile from binpacker.yml
279
+ --incremental (calibrate) Measure only tests without timing data
280
+ --report PATH (run) Write a JSON run report to PATH
281
+ --list (skill) List bundled skills
282
+ --path (skill) Print the SKILL.md path for <name>
283
+ --describe (skill) Report project state and next skill
162
284
  --help Show this message
163
285
 
164
286
  Examples:
165
287
  binpacker init
166
288
  binpacker run --profile ci
167
289
  binpacker run -- --tag ~slow
168
- binpacker calibrate
290
+ binpacker calibrate --incremental
291
+ binpacker describe
292
+ binpacker skill binpacker-setup
169
293
  HELP
170
294
  end
171
295
 
172
296
  def detect_framework
173
- return "minitest" if Dir.glob("test*/**/*_test.rb").any?
174
- return "minitest" if Dir.glob("test*/**/test_*.rb").any?
175
- "rspec"
297
+ ProjectState.new.framework || "rspec"
176
298
  end
177
299
  end
178
300
  end
@@ -14,10 +14,11 @@ module Binpacker
14
14
  "timing_file" => "binpacker.timings",
15
15
  "test_pattern" => "spec/**/*_spec.rb",
16
16
  "test_exclude" => [],
17
+ "report_file" => nil,
17
18
  "scheduler" => {
18
19
  "strategy" => "static",
19
20
  "steal_enabled" => false,
20
- "algorithm" => "lpt"
21
+ "algorithm" => "multifit"
21
22
  }
22
23
  }.freeze
23
24
 
@@ -2,33 +2,58 @@
2
2
 
3
3
  module Binpacker
4
4
  class Orchestrator
5
- BATCH_SIZE = 10
6
-
7
- def initialize(config, passthrough: [], quiet: false)
5
+ # Dynamic batches halve the queue's remaining predicted weight,
6
+ # floored at a per-run minimum: early batches are large to amortize
7
+ # the per-batch test-runner boot (each batch is a fresh rspec
8
+ # process), tail batches stay small so workers finish together.
9
+ #
10
+ # The floor comes in two regimes (computed by #min_batch_weight in
11
+ # #run and threaded into #drain_batch):
12
+ # - Calibrated: weights are measured seconds, so the floor is a
13
+ # fixed MIN_BATCH_WEIGHT (~30s), an order of magnitude above the
14
+ # ~3s runner boot — boot overhead stays in the noise.
15
+ # - Cold start: weights are filesize KB, not seconds, so a fixed
16
+ # 30 is meaningless. We instead derive a floor that targets
17
+ # ~COLD_START_BATCHES_PER_WORKER batches per worker (below).
18
+ MIN_BATCH_WEIGHT = 30.0
19
+
20
+ # On a pure cold start predicted weights are filesize KB, not
21
+ # seconds, so the 30s MIN_BATCH_WEIGHT floor has no meaning. Instead
22
+ # we size the floor so the total predicted weight divides into about
23
+ # this many batches per worker — matching the ~5 runner boots per
24
+ # worker that gave the best makespan in the PR #12 simulation.
25
+ COLD_START_BATCHES_PER_WORKER = 5
26
+
27
+ def initialize(config, passthrough: [], quiet: false, report_path: nil)
8
28
  @config = config
9
29
  @passthrough = passthrough
10
30
  @quiet = quiet
31
+ @report_path = report_path
11
32
  end
12
33
 
13
34
  def run
14
35
  tests = discover
15
36
  timing = Timing.new(@config.timing_file)
16
37
  timings = timing.load_with_fallback(tests)
38
+ @timings = timings
17
39
 
18
- scheduler = Scheduler.for(@config.scheduler["algorithm"])
40
+ scheduler = Scheduler.for(@config.scheduler['algorithm'])
19
41
  queues = scheduler.partition(
20
42
  tests: tests,
21
43
  worker_count: @config.worker_count,
22
44
  timings: timings
23
45
  )
24
46
 
47
+ # Capture predicted per-Worker loads before execution drains the queues.
48
+ @predicted_loads = queues.map { |q| q.total_weight(timings) }
49
+
25
50
  runner_class = TestRunner.for(@config.test_runner)
26
- workers = queues.map.with_index do |queue, idx|
51
+ workers = queues.map.with_index do |_queue, idx|
27
52
  Worker.new(idx, runner_class, passthrough: @passthrough, quiet: @quiet).tap(&:start)
28
53
  end
29
54
 
30
- if @config.scheduler["steal_enabled"]
31
- run_dynamic(workers, queues, timing, tests)
55
+ if @config.scheduler['steal_enabled']
56
+ run_dynamic(workers, queues, timing, tests, min_batch_weight(timing, timings))
32
57
  else
33
58
  run_static(workers, queues, timing, tests)
34
59
  end
@@ -38,9 +63,9 @@ module Binpacker
38
63
 
39
64
  def discover
40
65
  case @config.test_runner
41
- when "rspec"
66
+ when 'rspec'
42
67
  RSpecDiscovery.new(@config).enumerate
43
- when "minitest"
68
+ when 'minitest'
44
69
  MinitestDiscovery.new(@config).enumerate
45
70
  else
46
71
  raise ConfigError, "unsupported runner: #{@config.test_runner}"
@@ -53,7 +78,7 @@ module Binpacker
53
78
 
54
79
  workers.zip(queues).each do |worker, queue|
55
80
  worker.send_tests(queue.remaining)
56
- progress.update(worker.id, done: 0, total: queue_totals[worker.id], file: queue.remaining.first&.file || "")
81
+ progress.update(worker.id, done: 0, total: queue_totals[worker.id], file: queue.remaining.first&.file || '')
57
82
  end
58
83
  progress.refresh
59
84
 
@@ -76,10 +101,10 @@ module Binpacker
76
101
  worker_examples[worker.id] = worker.example_count
77
102
  worker_passed[worker.id] = worker.passed_count
78
103
  worker_time[worker.id] = worker.timings.sum { |t| t[:time] }
79
- progress.update(worker.id, done: queue_totals[worker.id], total: queue_totals[worker.id], file: "done")
104
+ progress.update(worker.id, done: queue_totals[worker.id], total: queue_totals[worker.id], file: 'done')
80
105
  progress.refresh
81
106
  rescue WorkerError => e
82
- $stderr.puts "worker #{worker.id} error: #{e.message}"
107
+ warn "worker #{worker.id} error: #{e.message}"
83
108
  all_passed = false
84
109
  ensure
85
110
  worker.cleanup
@@ -87,7 +112,7 @@ module Binpacker
87
112
 
88
113
  progress.finish
89
114
 
90
- worker_stats = workers.map.with_index do |w, i|
115
+ worker_stats = workers.map.with_index do |_w, i|
91
116
  {
92
117
  files: queue_totals[i],
93
118
  total_time: worker_time[i],
@@ -96,15 +121,14 @@ module Binpacker
96
121
  }
97
122
  end
98
123
  progress.summary(worker_stats)
124
+ write_report(worker_stats, all_timings)
99
125
 
100
126
  finalize(timing, all_timings, all_passed, total_examples, passed_examples, tests)
101
127
  end
102
128
 
103
- def run_dynamic(workers, queues, timing, tests)
129
+ def run_dynamic(workers, queues, timing, tests, floor)
104
130
  all_timings = []
105
131
  all_passed = true
106
- total_examples = 0
107
- passed_examples = 0
108
132
  active = []
109
133
 
110
134
  queue_totals = queues.map(&:size)
@@ -117,25 +141,23 @@ module Binpacker
117
141
  progress = ProgressDisplay.new(workers.size)
118
142
 
119
143
  workers.zip(queues).each do |worker, queue|
120
- batch = drain_batch(queue)
144
+ batch = drain_batch(queue, floor)
121
145
  if batch.empty?
122
146
  worker.signal_done
123
147
  worker.collect_results
124
148
  all_timings.concat(worker.timings)
125
149
  all_passed &&= worker.success?
126
- total_examples += worker.example_count
127
- passed_examples += worker.passed_count
128
150
  worker_examples[worker.id] = worker.example_count
129
151
  worker_passed[worker.id] = worker.passed_count
130
152
  worker.cleanup
131
153
  worker_done[worker.id] = queue_totals[worker.id]
132
- progress.update(worker.id, done: worker_done[worker.id], total: queue_totals[worker.id], file: "done")
154
+ progress.update(worker.id, done: worker_done[worker.id], total: queue_totals[worker.id], file: 'done')
133
155
  else
134
156
  worker.send_tests(batch)
135
157
  worker.batch_done
136
158
  active << worker
137
159
  batch_sizes[worker.id] = batch.size
138
- current_file = batch.first&.file || ""
160
+ current_file = batch.first&.file || ''
139
161
  progress.update(worker.id, done: 0, total: queue_totals[worker.id], file: current_file)
140
162
  end
141
163
  end
@@ -143,33 +165,38 @@ module Binpacker
143
165
  until active.empty?
144
166
  ready = active.find { |w| w.wait_for_batch }
145
167
  unless ready
146
- active.reject! { |w| w.status == :crashed || w.status == :error }
168
+ active.reject! { |w| %i[crashed error].include?(w.status) }
147
169
  sleep 0.1
148
170
  next
149
171
  end
150
172
 
151
173
  begin
152
174
  all_passed &&= ready.success?
153
- total_examples += ready.example_count
154
- passed_examples += ready.passed_count
155
175
 
156
176
  worker_done[ready.id] += batch_sizes[ready.id]
177
+ # Worker#example_count accumulates across batches, so these
178
+ # are running totals per worker; the grand totals are summed
179
+ # once after the loop, never added per batch.
157
180
  worker_examples[ready.id] = ready.example_count
158
181
  worker_passed[ready.id] = ready.passed_count
159
182
 
160
183
  own_queue = queues[ready.id]
161
- next_batch = drain_batch(own_queue)
184
+ next_batch = drain_batch(own_queue, floor)
162
185
 
163
186
  if next_batch.empty?
164
- donor = queues.reject(&:empty?).max_by(&:size)
165
- next_batch = drain_batch(donor) if donor
187
+ donor = queues.reject(&:empty?).max_by { |q| q.total_weight(@timings) }
188
+ if donor
189
+ next_batch = drain_batch(donor, floor)
190
+ queue_totals[ready.id] += next_batch.size
191
+ queue_totals[donor.worker_id] -= next_batch.size
192
+ end
166
193
  end
167
194
 
168
195
  if next_batch.any?
169
196
  ready.send_tests(next_batch)
170
197
  ready.batch_done
171
198
  batch_sizes[ready.id] = next_batch.size
172
- current_file = next_batch.first&.file || ""
199
+ current_file = next_batch.first&.file || ''
173
200
  progress.update(ready.id, done: worker_done[ready.id], total: queue_totals[ready.id], file: current_file)
174
201
  progress.refresh
175
202
  else
@@ -177,11 +204,11 @@ module Binpacker
177
204
  all_timings.concat(ready.timings)
178
205
  active.delete(ready)
179
206
  worker_done[ready.id] = queue_totals[ready.id]
180
- progress.update(ready.id, done: queue_totals[ready.id], total: queue_totals[ready.id], file: "done")
207
+ progress.update(ready.id, done: queue_totals[ready.id], total: queue_totals[ready.id], file: 'done')
181
208
  progress.refresh
182
209
  end
183
210
  rescue WorkerError => e
184
- $stderr.puts "worker #{ready.id} error: #{e.message}"
211
+ warn "worker #{ready.id} error: #{e.message}"
185
212
  all_passed = false
186
213
  active.delete(ready)
187
214
  end
@@ -189,6 +216,9 @@ module Binpacker
189
216
 
190
217
  progress.finish
191
218
 
219
+ total_examples = worker_examples.sum
220
+ passed_examples = worker_passed.sum
221
+
192
222
  worker_stats = workers.map.with_index do |w, i|
193
223
  tw = w.timings.sum { |t| t[:time] }
194
224
  {
@@ -199,24 +229,62 @@ module Binpacker
199
229
  }
200
230
  end
201
231
  progress.summary(worker_stats)
232
+ write_report(worker_stats, all_timings)
202
233
 
203
234
  workers.each(&:cleanup)
204
235
  finalize(timing, all_timings, all_passed, total_examples, passed_examples, tests)
205
236
  end
206
237
 
207
- def drain_batch(queue)
238
+ def write_report(worker_stats, all_timings)
239
+ return unless @report_path
240
+
241
+ Report.new(
242
+ profile: @config.profile,
243
+ algorithm: @config.scheduler['algorithm'],
244
+ predicted_loads: @predicted_loads,
245
+ worker_stats: worker_stats,
246
+ all_timings: all_timings,
247
+ timings: @timings
248
+ ).write(@report_path)
249
+ end
250
+
251
+ # Per-run batch-weight floor. Calibrated runs use the fixed
252
+ # seconds-scale MIN_BATCH_WEIGHT. On a cold start weights are KB, so
253
+ # that floor is meaningless; derive one that splits the total
254
+ # predicted weight into ~COLD_START_BATCHES_PER_WORKER batches per
255
+ # worker, falling back to MIN_BATCH_WEIGHT when there is nothing to
256
+ # divide (zero total weight or zero workers).
257
+ def min_batch_weight(timing, timings)
258
+ return MIN_BATCH_WEIGHT if timing.calibrated?
259
+
260
+ total = timings.values.sum(0.0)
261
+ workers = @config.worker_count
262
+ return MIN_BATCH_WEIGHT if total.zero? || workers.zero?
263
+
264
+ total / (workers * COLD_START_BATCHES_PER_WORKER)
265
+ end
266
+
267
+ # Drains the next batch off +queue+: enough tests to halve its
268
+ # remaining predicted weight, but never less than +floor+ (see
269
+ # #min_batch_weight) and never fewer than one test.
270
+ def drain_batch(queue, floor)
208
271
  return [] if queue.nil? || queue.empty?
272
+
273
+ target = [queue.total_weight(@timings) / 2.0, floor].max
209
274
  batch = []
210
- BATCH_SIZE.times do
211
- test = queue.pop
212
- break unless test
275
+ weight = 0.0
276
+ while (batch.empty? || weight < target) && (test = queue.pop)
213
277
  batch << test
278
+ weight += @timings.fetch(test.key, Timing::DEFAULT_WEIGHT)
214
279
  end
215
280
  batch
216
281
  end
217
282
 
218
283
  def finalize(timing, all_timings, all_passed, total_examples, passed_examples, tests)
219
- timing.append_all(all_timings) unless all_timings.empty?
284
+ unless all_timings.empty?
285
+ timing.append_all(all_timings)
286
+ timing.compact!
287
+ end
220
288
  empty_filter = minitest_empty_filter?(tests, total_examples)
221
289
  all_passed = false if empty_filter
222
290
 
@@ -230,7 +298,7 @@ module Binpacker
230
298
  end
231
299
 
232
300
  def minitest_empty_filter?(tests, total_examples)
233
- return false unless @config.test_runner == "minitest"
301
+ return false unless @config.test_runner == 'minitest'
234
302
  return false unless tests.any?
235
303
  return false unless total_examples.zero?
236
304
 
@@ -240,8 +308,8 @@ module Binpacker
240
308
  def minitest_include_filter?
241
309
  @passthrough.any? do |arg|
242
310
  %w[--name --include -n -i].include?(arg) ||
243
- arg.start_with?("--name=", "--include=") ||
244
- (arg.start_with?("-n", "-i") && arg.length > 2)
311
+ arg.start_with?('--name=', '--include=') ||
312
+ (arg.start_with?('-n', '-i') && arg.length > 2)
245
313
  end
246
314
  end
247
315
  end