lemans 1.3.2 → 1.3.3

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: db9e22f2b2098371943cde85a366d70ff205b267719b67974484c5240752cd59
4
- data.tar.gz: 707a10355b3fb212740b2839b51a1b346e8e6165a4aed5403f9c763045ffa6b8
3
+ metadata.gz: '08d62b21b79425a5c1e99867270b4cf2dd41ccb07f8ab7f5e5381a416170907f'
4
+ data.tar.gz: 802e7e3f665913b95a5409882f10b067f4118efe1e2de650a07dd93382f035af
5
5
  SHA512:
6
- metadata.gz: f1472766d86a65544e6b525124fcd0afb828fa36c6a3c5ee1d4bb2d9f24972edb2d844abfdfc03735c6a9cd81b6bbda00b791f782b42cb7d523becdd01f764f1
7
- data.tar.gz: 95d42dbaf57d92ace03b1bffdcf05316fd7244a5abb3fb74a5611ea15a4c8de4bdd86d6e9ab21b2b4bcf4606bce5ab11e5bf44fb372ae74c810d4c38b1c1b465
6
+ metadata.gz: fabad5219961a7514c44f888bd79713db444a1dc5b8b20c6153ae1e55c4252cc4065939bb6671a4e6511a1809f4b89da00ff2a642825348c184969fdf49a7c15
7
+ data.tar.gz: '08a08843cb79dcb1da14e27b1b11d2126fe0d8049dc7cdbcccb573c0c93d81f44613ed6f4e905ec23ab3e9f41e2f6eac44a49e9d520c50ebfca3f82a6d9f4f5f'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.3.3] - 2026-09-11
4
+
5
+ - Daytona: retry sandbox creation when the SDK gives up on a stalled start (the half-made sandbox is adopted or deleted first).
6
+ - Miniswen: run local commands through `sh -c` (a missing command is exit 127, not a harness crash).
7
+ - Miniswen: retry model calls for ~5 min instead of ~1, truncated responses included.
8
+ - `lemans-remote run --launch-interval N` (default 3s) spaces sandbox launches; `--concurrency` is now a no-op.
9
+
3
10
  ## [1.3.2] - 2026-09-08
4
11
 
5
12
  - Miniswen: increase provider error max retry window to ~1 min.
data/exe/lemans-remote CHANGED
@@ -16,7 +16,8 @@
16
16
  # per task × model (--run-in-band for a single sandbox, --attempts N to
17
17
  # repeat each task in N sandboxes); results are archived to the
18
18
  # lemans-remote-runs volume. Add --sync to wait for a single sandbox
19
- # and download into ./runs directly instead:
19
+ # and download into ./runs directly instead. Launches go out one at a
20
+ # time, --launch-interval seconds apart (default 3):
20
21
  #
21
22
  # exe/lemans-remote run --bench ../ai-evals --task hello-world
22
23
  # exe/lemans-remote run --bench ../ai-evals --model openrouter/z-ai/glm-5.2 --model openrouter/qwen/qwen3.5-coder --args="-k 2 -c 8"
@@ -32,7 +33,7 @@
32
33
  #
33
34
  # exe/lemans-remote status [--history] [--running | --complete] [-W [INTERVAL]]
34
35
  # exe/lemans-remote logs RUN_ID [--tail N]
35
- # exe/lemans-remote pull-runs [RUN_ID ...] [--all] [--dry-run]
36
+ # exe/lemans-remote pull-runs [RUN_ID ...] [--match SUBSTR] [--all] [--dry-run]
36
37
  # exe/lemans-remote drop-orphans [--min-age 10m]
37
38
  # exe/lemans-remote clobber RUN_ID ... | --all
38
39
  # exe/lemans-remote snapshots
@@ -440,10 +441,43 @@ module LemansRemote # :nodoc: all
440
441
  end
441
442
  end
442
443
 
443
- def manifests(sandbox)
444
- code = 'require "json"; puts Dir.glob("/vault/*/manifest.json", File::FNM_DOTMATCH)' \
445
- ".filter_map { |f| JSON.parse(File.read(f)) rescue nil }.to_json"
446
- response = sandbox.process.exec(command: "ruby -e #{Shellwords.escape(code)}", timeout: 120)
444
+ # The vault is an S3-backed FUSE mount: listing /vault is one request,
445
+ # but every /vault/<run>/manifest.json is another round-trip, so a plain
446
+ # glob over hundreds of runs outlives the exec timeout. List once, then
447
+ # read the manifests concurrently (and only the requested ones, if any).
448
+ MANIFEST_READERS = 32
449
+ MANIFESTS_TIMEOUT_SEC = 120
450
+
451
+ MANIFESTS_CODE = <<~'RUBY'
452
+ require "json"
453
+ only = JSON.parse(ARGV[0])
454
+ match = ARGV[1]
455
+ ids = Dir.children("/vault")
456
+ ids &= only if only
457
+ ids.select! { it.include?(match) } if match && !match.empty?
458
+ queue = Queue.new
459
+ ids.each { queue << it }
460
+ queue.close
461
+ found = Queue.new
462
+ readers = Array.new([ids.size, ENV.fetch("LEMANS_MANIFEST_READERS", "32").to_i].min) do
463
+ Thread.new do
464
+ while (id = queue.pop)
465
+ found << JSON.parse(File.read("/vault/#{id}/manifest.json")) rescue nil
466
+ end
467
+ end
468
+ end
469
+ readers.each(&:join)
470
+ list = []
471
+ list << found.pop until found.empty?
472
+ puts list.to_json
473
+ RUBY
474
+
475
+ def manifests(sandbox, only: nil, match: nil)
476
+ args = [ only&.to_a.to_json, match.to_s ].map { Shellwords.escape(it) }.join(" ")
477
+ response = sandbox.process.exec(
478
+ command: "LEMANS_MANIFEST_READERS=#{MANIFEST_READERS} ruby -e #{Shellwords.escape(MANIFESTS_CODE)} #{args}",
479
+ timeout: MANIFESTS_TIMEOUT_SEC
480
+ )
447
481
  raise "could not read the vault manifests: #{response.result}" unless response.exit_code.zero?
448
482
 
449
483
  body = response.result.to_s.strip
@@ -513,7 +547,7 @@ module LemansRemote # :nodoc: all
513
547
  CREATE_ATTEMPTS = 3
514
548
 
515
549
  def initialize(bench:, snapshot:, run_id:, tasks:, models:, extra_args:, extra_env:,
516
- timeout_sec:, runs_dir:, keep:, sync:, shell:)
550
+ timeout_sec:, runs_dir:, keep:, sync:, shell:, launch_interval: 0)
517
551
  @bench = bench
518
552
  @snapshot = snapshot
519
553
  @run_id = run_id
@@ -526,6 +560,7 @@ module LemansRemote # :nodoc: all
526
560
  @keep = keep
527
561
  @sync = sync
528
562
  @shell = shell
563
+ @launch_interval = launch_interval
529
564
  @started_at = Time.now.utc.iso8601
530
565
  end
531
566
 
@@ -564,7 +599,7 @@ module LemansRemote # :nodoc: all
564
599
 
565
600
  # Daytona sometimes never starts a sandbox it accepted; the SDK gives up
566
601
  # after a minute and the half-made sandbox stays behind under this run's
567
- # labels, listed as running until Daytona flags it, then stale forever.
602
+ # labels. A retry waits the launch interval first.
568
603
  def create_sandbox(volume = nil)
569
604
  attempt = 0
570
605
  begin
@@ -575,6 +610,7 @@ module LemansRemote # :nodoc: all
575
610
  raise if attempt >= CREATE_ATTEMPTS
576
611
 
577
612
  say :retry, "sandbox did not start (#{e.message}), attempt #{attempt + 1}/#{CREATE_ATTEMPTS}", :yellow
613
+ sleep @launch_interval if @launch_interval.positive?
578
614
  retry
579
615
  end
580
616
  end
@@ -872,7 +908,9 @@ module LemansRemote # :nodoc: all
872
908
  option :runs_dir, default: "runs", desc: "Sync mode: local directory to sync the results into"
873
909
  option :env, repeatable: true, desc: "Forward an extra host ENV variable by name"
874
910
  option :concurrency, type: :numeric, default: 4, aliases: "-C",
875
- desc: "Async mode: how many sandboxes to launch in parallel (1 to serialize)"
911
+ desc: "Ignored: launches go out one at a time, --launch-interval apart (kept so older invocations still parse)"
912
+ option :launch_interval, type: :numeric, default: 3,
913
+ desc: "Async mode: seconds between consecutive launches (0 to disable)"
876
914
  def run_bench
877
915
  bench = Lemans::Config.load_file(options[:bench])
878
916
 
@@ -899,7 +937,7 @@ module LemansRemote # :nodoc: all
899
937
  attempts = options[:attempts].to_i
900
938
  raise Thor::Error, "lemans-remote: --attempts must be at least 1" if attempts < 1
901
939
  raise Thor::Error, "lemans-remote: --attempts needs async mode — drop --sync" if options[:sync] && attempts > 1
902
- raise Thor::Error, "lemans-remote: --concurrency must be at least 1" if options[:concurrency].to_i < 1
940
+ raise Thor::Error, "lemans-remote: --launch-interval must not be negative" if options[:launch_interval].to_f.negative?
903
941
 
904
942
  models = options[:model] || []
905
943
  jobs =
@@ -928,7 +966,7 @@ module LemansRemote # :nodoc: all
928
966
  elsif attempts > 1 then "#{jobs.size / attempts} task × model batch(es) × #{attempts} attempts"
929
967
  else "one per task × model"
930
968
  end
931
- say_status :fanout, "#{jobs.size} sandboxes, #{fanout}, #{options[:concurrency].to_i} at a time"
969
+ say_status :fanout, "#{jobs.size} sandboxes, #{fanout}, one every #{options[:launch_interval]}s"
932
970
  failures, retried = launch_batches(bench, jobs, provisioner)
933
971
  finish_retry(planner, retried) if retry_groups
934
972
  say_status :detached, "`lemans-remote status` to watch, `lemans-remote pull-runs` to fetch results"
@@ -995,10 +1033,11 @@ module LemansRemote # :nodoc: all
995
1033
  option :all, type: :boolean, default: false, desc: "Pull every completed run, even ones already pulled"
996
1034
  option :dry_run, type: :boolean, default: false, desc: "Preview what would be downloaded without pulling anything"
997
1035
  option :runs_dir, default: "runs", desc: "Local directory to sync the results into"
1036
+ option :match, desc: "Only consider runs whose id contains this substring (e.g. a task name)"
998
1037
  def pull_runs(*run_ids)
999
1038
  pulled = []
1000
1039
  with_vault do |vault, sandbox|
1001
- manifests = vault.manifests(sandbox)
1040
+ manifests = vault.manifests(sandbox, only: (run_ids if run_ids.any?), match: options[:match])
1002
1041
  known = manifests.map { it["run_id"] }
1003
1042
  targets =
1004
1043
  if run_ids.any?
@@ -1216,39 +1255,31 @@ module LemansRemote # :nodoc: all
1216
1255
  runs_dir: options[:runs_dir],
1217
1256
  keep: options[:keep],
1218
1257
  sync: options[:sync],
1219
- shell: shell
1258
+ shell: shell,
1259
+ launch_interval: options[:launch_interval].to_f
1220
1260
  ).call
1221
1261
  end
1222
1262
 
1263
+ # One at a time, `interval` apart: a burst of creations stalls Daytona's
1264
+ # scheduler.
1223
1265
  def launch_batches(bench, jobs, provisioner)
1224
- queue = Queue.new
1225
- jobs.each { queue << it }
1226
- failures = Queue.new
1227
- launched = Queue.new
1266
+ interval = options[:launch_interval].to_f
1267
+ failures = []
1268
+ launched = []
1228
1269
 
1229
- threads = [ options[:concurrency].to_i, jobs.size ].min.times.map do
1230
- Thread.new do
1231
- loop do
1232
- batch, models, attempt, group =
1233
- begin
1234
- queue.pop(true)
1235
- rescue ThreadError
1236
- break
1237
- end
1238
- begin
1239
- launch_batch(bench, batch, models, provisioner, attempt:)
1240
- launched << group if group
1241
- rescue StandardError => e
1242
- label = batch.empty? ? "all" : batch.join(",")
1243
- label += "/a#{attempt}" if attempt
1244
- failures << "#{label} (#{e.message})"
1245
- end
1246
- end
1270
+ jobs.each_with_index do |(batch, models, attempt, group), index|
1271
+ sleep interval if index.positive? && interval.positive?
1272
+ begin
1273
+ launch_batch(bench, batch, models, provisioner, attempt:)
1274
+ launched << group if group
1275
+ rescue StandardError => e
1276
+ label = batch.empty? ? "all" : batch.join(",")
1277
+ label += "/a#{attempt}" if attempt
1278
+ failures << "#{label} (#{e.message})"
1247
1279
  end
1248
1280
  end
1249
- threads.each(&:join)
1250
1281
 
1251
- [ Array.new(failures.size) { failures.pop }, Array.new(launched.size) { launched.pop } ]
1282
+ [ failures, launched ]
1252
1283
  end
1253
1284
 
1254
1285
  def build_run_id(bench, tasks, models, attempt = nil)
@@ -7,6 +7,8 @@ module Lemans
7
7
  class Daytona
8
8
  # Another try for calls whose repeat is free. Only reads qualify: a
9
9
  # mutation may have landed server-side before its failure surfaced.
10
+ # Sandbox creation is the exception (Daytona#create_sandbox): what it
11
+ # half-made is found by label and dealt with before the repeat.
10
12
  module Retries
11
13
  # The snapshot service leaks the generated client's own error classes
12
14
  # instead of wrapping them, so both dialects have to be caught.
@@ -9,12 +9,18 @@ module Lemans
9
9
  # Daytona sandboxes. Daytona builds images server-side into reusable content-named
10
10
  # snapshots and enforces the network policy itself.
11
11
  class Daytona < Environment
12
+ include Retries
13
+
12
14
  DEFAULT_BUILD_TIMEOUT = 600
13
15
 
14
16
  # Workspace tarballs ride uploads/downloads, so transfers get their own
15
17
  # budget through the SDK's streaming API instead of the global HTTP cap.
16
18
  TRANSFER_TIMEOUT = 900
17
19
 
20
+ CREATE_ATTEMPTS = 3
21
+ CREATE_RETRY_DELAY = 5
22
+ ADOPT_TIMEOUT = 180
23
+
18
24
  SDKTweaks.apply!
19
25
  # The SDK's typhoeus/libcurl transfers segfault the VM under concurrent
20
26
  # easy_perform calls; Faraday/Net::HTTP is pure Ruby, so transfers need
@@ -47,7 +53,7 @@ module Lemans
47
53
  end
48
54
 
49
55
  def start
50
- @sandbox = client.create(create_params, on_snapshot_create_logs: @logger)
56
+ @sandbox = create_sandbox
51
57
  @started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
52
58
  @shell = Shell.new(sandbox)
53
59
  self
@@ -116,6 +122,64 @@ module Lemans
116
122
 
117
123
  def client = self.class.client
118
124
 
125
+ # Under load Daytona accepts a sandbox, then stalls past the SDK's fixed
126
+ # minute. The one it half-made carries this trial's labels: adopt it if
127
+ # it comes up, delete it otherwise, and only then try again. Without
128
+ # labels there is nothing to find it by, so the failure stands.
129
+ def create_sandbox
130
+ attempt = 0
131
+ begin
132
+ attempt += 1
133
+ client.create(create_params, on_snapshot_create_logs: @logger)
134
+ rescue *Retries::SDK_ERRORS => e
135
+ raise if labels.empty? || !retryable?(e)
136
+
137
+ if (leftover = half_created(e))
138
+ return leftover if await_start(leftover)
139
+
140
+ reap!(leftover, e)
141
+ end
142
+ raise if attempt >= CREATE_ATTEMPTS
143
+
144
+ log "sandbox did not start (#{e.message}), attempt #{attempt + 1}/#{CREATE_ATTEMPTS}"
145
+ sleep CREATE_RETRY_DELAY
146
+ retry
147
+ end
148
+ end
149
+
150
+ # One create makes one sandbox; two under the same labels is not ours to sort out.
151
+ def half_created(error)
152
+ found = client.list(::Daytona::ListSandboxesQuery.new(labels: labels)).to_a
153
+ if found.size > 1
154
+ raise InfrastructureError, "#{could_not_start(error)}; #{found.map(&:id).join(", ")} all carry #{labels.inspect}"
155
+ end
156
+
157
+ found.first
158
+ rescue *Retries::SDK_ERRORS => e
159
+ raise InfrastructureError, "#{could_not_start(error)}; a sandbox may still be running under #{labels.inspect}, " \
160
+ "and listing them failed: #{e.message}"
161
+ end
162
+
163
+ # The SDK's wait fails at once on a dead sandbox.
164
+ def await_start(sandbox)
165
+ sandbox.wait_for_sandbox_start(ADOPT_TIMEOUT)
166
+ log "adopted sandbox #{sandbox.id}, which came up after the SDK gave up on it"
167
+ true
168
+ rescue *Retries::SDK_ERRORS => e
169
+ log "sandbox #{sandbox.id} did not come up on its own: #{e.message}"
170
+ false
171
+ end
172
+
173
+ def reap!(sandbox, error)
174
+ sandbox.delete
175
+ rescue *Retries::SDK_ERRORS => e
176
+ raise InfrastructureError, "#{could_not_start(error)}; #{sandbox.id} may still be running and could not be deleted: #{e.message}"
177
+ end
178
+
179
+ def could_not_start(error) = "daytona: could not start sandbox: #{error.message}"
180
+
181
+ def log(message) = @logger&.call("lemans: #{message}\n")
182
+
119
183
  # A sandbox inherits the snapshot's resources, so the profile's are
120
184
  # stamped into the snapshot at build time.
121
185
  def create_params
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lemans
4
- VERSION = "1.3.2"
4
+ VERSION = "1.3.3"
5
5
  end
@@ -513,7 +513,7 @@ module Miniswen
513
513
  body = e.response&.body.to_s
514
514
  detail = body.empty? ? e.message : "#{e.message}: #{body[0, 1000]}"
515
515
  raise InfrastructureError, "miniswen: the model call failed: #{detail}"
516
- rescue Faraday::SSLError, Faraday::ConnectionFailed, Faraday::TimeoutError => e
516
+ rescue Faraday::SSLError, Faraday::ConnectionFailed, Faraday::TimeoutError, Faraday::ParsingError => e
517
517
  raise InfrastructureError, "miniswen: the model call failed: #{e.class}: #{e.message}"
518
518
  end
519
519
 
@@ -9,8 +9,10 @@ module Miniswen
9
9
  class Local < Environment
10
10
  TIMEOUT_EXIT_CODE = 124
11
11
 
12
+ # Always through a shell: Ruby execs a metacharacter-free string directly,
13
+ # and a missing binary would then raise ENOENT here instead of exiting 127.
12
14
  def exec(command, timeout: nil, env: nil)
13
- Open3.popen2e(env || {}, command, pgroup: true) do |stdin, io, wait_thr|
15
+ Open3.popen2e(env || {}, "sh", "-c", command, pgroup: true) do |stdin, io, wait_thr|
14
16
  stdin.close
15
17
  reader = Thread.new { io.read }
16
18
 
@@ -8,9 +8,10 @@ RubyLLM.configure do |config|
8
8
  config.logger = Logger.new(IO::NULL) unless ENV["MINISWEN_DEBUG"] == "1"
9
9
  end
10
10
 
11
- # About a minute of retries for egress blips (1, 2, 4, 8, 16, 32s plus jitter)
11
+ # About five minutes of retries (1, 2, 4, ... 128s plus jitter): provider
12
+ # outages and rate-limit windows outlast the minute this used to allow.
12
13
  RubyLLM.configure do |config|
13
- config.max_retries = 6
14
+ config.max_retries = 8
14
15
  config.retry_interval = 1
15
16
  end
16
17
 
@@ -36,13 +37,13 @@ module Miniswen
36
37
  end
37
38
  end
38
39
 
39
- # A handshake reset never sent the request, so retrying is as safe as the
40
- # ConnectionFailed retries ruby_llm already does; it only lists SSL errors
41
- # as fatal.
42
- module RetryTransientSSL
43
- def retry_exceptions = super + [ Faraday::SSLError ]
40
+ # ruby_llm lists SSL errors as fatal, but a handshake reset never sent the
41
+ # request; a 200 with a truncated JSON body is the same dropped connection
42
+ # one step later.
43
+ module RetryTransientFailures
44
+ def retry_exceptions = super + [ Faraday::SSLError, Faraday::ParsingError ]
44
45
  end
45
46
  end
46
47
 
47
48
  RubyLLM::Providers::OpenRouter.prepend(Miniswen::VerbatimReasoningDetails)
48
- RubyLLM::Connection.prepend(Miniswen::RetryTransientSSL)
49
+ RubyLLM::Connection.prepend(Miniswen::RetryTransientFailures)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Miniswen
4
- VERSION = "1.3.2"
4
+ VERSION = "1.3.3"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lemans
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.2
4
+ version: 1.3.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Svyatoslav Kryukov
@@ -53,6 +53,20 @@ dependencies:
53
53
  - - "~>"
54
54
  - !ruby/object:Gem::Version
55
55
  version: '0.203'
56
+ - !ruby/object:Gem::Dependency
57
+ name: json
58
+ requirement: !ruby/object:Gem::Requirement
59
+ requirements:
60
+ - - "<"
61
+ - !ruby/object:Gem::Version
62
+ version: '3'
63
+ type: :runtime
64
+ prerelease: false
65
+ version_requirements: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - "<"
68
+ - !ruby/object:Gem::Version
69
+ version: '3'
56
70
  - !ruby/object:Gem::Dependency
57
71
  name: faraday
58
72
  requirement: !ruby/object:Gem::Requirement