lemans 1.3.1 → 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: 94b9194a7931b789ba1cb675cfc8d81e0367f8b73754ce1fa11cbff33fff7174
4
- data.tar.gz: 4b61d46c323ddd60cb016402d757cb53db56c0adb372709d73ebeec369789e41
3
+ metadata.gz: '08d62b21b79425a5c1e99867270b4cf2dd41ccb07f8ab7f5e5381a416170907f'
4
+ data.tar.gz: 802e7e3f665913b95a5409882f10b067f4118efe1e2de650a07dd93382f035af
5
5
  SHA512:
6
- metadata.gz: db72821e26aa89d9bb9922134042e38c3041a2a8df32669c7e3d69d9ad5f06ebd2fddbfcee89b0f38bc4a4a0f263190858fc24ee86aac5e51984ee1b62a7a6b1
7
- data.tar.gz: 1b144ff16c02f963031bf078f03f465508edad3f0ed061e0879905ca189143ad14e6a008a0f9fbaf2d0bea28e2124f82f84eea3f5596bb771251c868e675cd50
6
+ metadata.gz: fabad5219961a7514c44f888bd79713db444a1dc5b8b20c6153ae1e55c4252cc4065939bb6671a4e6511a1809f4b89da00ff2a642825348c184969fdf49a7c15
7
+ data.tar.gz: '08a08843cb79dcb1da14e27b1b11d2126fe0d8049dc7cdbcccb573c0c93d81f44613ed6f4e905ec23ab3e9f41e2f6eac44a49e9d520c50ebfca3f82a6d9f4f5f'
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
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
+
10
+ ## [1.3.2] - 2026-09-08
11
+
12
+ - Miniswen: increase provider error max retry window to ~1 min.
13
+ - Collect patches on agent errors.
14
+
3
15
  ## [1.3.1] - 2026-09-04
4
16
 
5
17
  - `agent.max_output_tokens` and `lemans run --max-output-tokens`
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"
@@ -31,7 +32,8 @@
31
32
  # Then watch, fetch, and clean up:
32
33
  #
33
34
  # exe/lemans-remote status [--history] [--running | --complete] [-W [INTERVAL]]
34
- # exe/lemans-remote pull-runs [RUN_ID ...] [--all] [--dry-run]
35
+ # exe/lemans-remote logs RUN_ID [--tail N]
36
+ # exe/lemans-remote pull-runs [RUN_ID ...] [--match SUBSTR] [--all] [--dry-run]
35
37
  # exe/lemans-remote drop-orphans [--min-age 10m]
36
38
  # exe/lemans-remote clobber RUN_ID ... | --all
37
39
  # exe/lemans-remote snapshots
@@ -213,11 +215,7 @@ module LemansRemote # :nodoc: all
213
215
 
214
216
  def pack(to:)
215
217
  Dir.mktmpdir("lemans-remote-stage") do |stage|
216
- @bench.root.children.each do |child|
217
- next if EXCLUDES.include?(child.basename.to_s)
218
-
219
- FileUtils.cp_r(child, File.join(stage, child.basename.to_s))
220
- end
218
+ stage_files(stage)
221
219
  prune_tasks(stage) if @selected_tasks
222
220
  _, err, status = Open3.capture3("tar", "-czf", to, "-C", stage, ".")
223
221
  raise "could not pack the bench: #{err}" unless status.success?
@@ -227,6 +225,44 @@ module LemansRemote # :nodoc: all
227
225
 
228
226
  private
229
227
 
228
+ # Every sandbox gets a copy, so a git bench ships what git would: tracked
229
+ # files plus untracked ones .gitignore lets through.
230
+ def stage_files(stage)
231
+ files = git_files
232
+ return copy_children(stage) unless files
233
+
234
+ files.each do |relative|
235
+ next if EXCLUDES.include?(relative.split("/", 2).first)
236
+
237
+ source = @bench.root.join(relative)
238
+ # The index still lists a file deleted from disk
239
+ next unless File.exist?(source) || File.symlink?(source)
240
+
241
+ destination = File.join(stage, relative)
242
+ FileUtils.mkdir_p(File.dirname(destination))
243
+ FileUtils.copy_entry(source, destination)
244
+ end
245
+ end
246
+
247
+ def copy_children(stage)
248
+ @bench.root.children.each do |child|
249
+ next if EXCLUDES.include?(child.basename.to_s)
250
+
251
+ FileUtils.cp_r(child, File.join(stage, child.basename.to_s))
252
+ end
253
+ end
254
+
255
+ # nil when the bench is not a git checkout; a nested repository lists as a
256
+ # bare directory, and is left out
257
+ def git_files
258
+ out, _err, status = Open3.capture3(
259
+ "git", "-C", @bench.root.to_s, "ls-files", "-z", "--cached", "--others", "--exclude-standard"
260
+ )
261
+ return nil unless status.success?
262
+
263
+ out.split("\0").reject { it.end_with?("/") }
264
+ end
265
+
230
266
  def prune_tasks(stage)
231
267
  tasks_rel = @bench.tasks_dir.relative_path_from(@bench.root).to_s
232
268
  staged_tasks = File.join(stage, tasks_rel)
@@ -405,16 +441,57 @@ module LemansRemote # :nodoc: all
405
441
  end
406
442
  end
407
443
 
408
- def manifests(sandbox)
409
- code = 'require "json"; puts Dir.glob("/vault/*/manifest.json", File::FNM_DOTMATCH)' \
410
- ".filter_map { |f| JSON.parse(File.read(f)) rescue nil }.to_json"
411
- 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
+ )
412
481
  raise "could not read the vault manifests: #{response.result}" unless response.exit_code.zero?
413
482
 
414
483
  body = response.result.to_s.strip
415
484
  body.empty? ? [] : JSON.parse(body)
416
485
  end
417
486
 
487
+ def run_log(sandbox, run_id)
488
+ remote = "/vault/#{run_id}/run.log"
489
+ response = sandbox.process.exec(command: "cat #{Shellwords.escape(remote)}", timeout: 120)
490
+ raise "no log in the vault for #{run_id} (never launched, or still running on a sandbox that is gone)" unless response.exit_code.zero?
491
+
492
+ response.result.to_s
493
+ end
494
+
418
495
  def archive_size(sandbox, run_id)
419
496
  remote = "/vault/#{run_id}/runs.tar.gz"
420
497
  response = sandbox.process.exec(command: "du -h #{Shellwords.escape(remote)}", timeout: 60)
@@ -467,9 +544,10 @@ module LemansRemote # :nodoc: all
467
544
  SETUP_TIMEOUT_SEC = 300
468
545
  TRANSFER_TIMEOUT_SEC = 900
469
546
  TIMED_OUT = 124
547
+ CREATE_ATTEMPTS = 3
470
548
 
471
549
  def initialize(bench:, snapshot:, run_id:, tasks:, models:, extra_args:, extra_env:,
472
- timeout_sec:, runs_dir:, keep:, sync:, shell:)
550
+ timeout_sec:, runs_dir:, keep:, sync:, shell:, launch_interval: 0)
473
551
  @bench = bench
474
552
  @snapshot = snapshot
475
553
  @run_id = run_id
@@ -482,6 +560,7 @@ module LemansRemote # :nodoc: all
482
560
  @keep = keep
483
561
  @sync = sync
484
562
  @shell = shell
563
+ @launch_interval = launch_interval
485
564
  @started_at = Time.now.utc.iso8601
486
565
  end
487
566
 
@@ -518,8 +597,39 @@ module LemansRemote # :nodoc: all
518
597
  0
519
598
  end
520
599
 
600
+ # Daytona sometimes never starts a sandbox it accepted; the SDK gives up
601
+ # after a minute and the half-made sandbox stays behind under this run's
602
+ # labels. A retry waits the launch interval first.
521
603
  def create_sandbox(volume = nil)
522
- params = ::Daytona::CreateSandboxFromSnapshotParams.new(
604
+ attempt = 0
605
+ begin
606
+ attempt += 1
607
+ LemansRemote.client.create(sandbox_params(volume))
608
+ rescue *Backend::Retries::SDK_ERRORS => e
609
+ drop_half_created
610
+ raise if attempt >= CREATE_ATTEMPTS
611
+
612
+ say :retry, "sandbox did not start (#{e.message}), attempt #{attempt + 1}/#{CREATE_ATTEMPTS}", :yellow
613
+ sleep @launch_interval if @launch_interval.positive?
614
+ retry
615
+ end
616
+ end
617
+
618
+ # Runs from the rescue of a failed creation, so it must not raise: an
619
+ # exception here would replace the creation error and end the retries.
620
+ def drop_half_created
621
+ query = ::Daytona::ListSandboxesQuery.new(labels: { RUN_ID_LABEL => @run_id })
622
+ LemansRemote.client.list(query).each do |sandbox|
623
+ sandbox.delete
624
+ rescue StandardError => e
625
+ warn "lemans-remote: could not delete half-created sandbox #{sandbox.id}: #{e.message}"
626
+ end
627
+ rescue StandardError => e
628
+ warn "lemans-remote: could not list the sandboxes of #{@run_id}: #{e.message}"
629
+ end
630
+
631
+ def sandbox_params(volume)
632
+ ::Daytona::CreateSandboxFromSnapshotParams.new(
523
633
  snapshot: @snapshot,
524
634
  env_vars: env_vars,
525
635
  labels: {
@@ -533,7 +643,6 @@ module LemansRemote # :nodoc: all
533
643
  ttl_minutes: (@timeout_sec / 60.0).ceil + 60,
534
644
  volumes: volume ? [ Vault.mount_param(volume) ] : nil
535
645
  )
536
- LemansRemote.client.create(params)
537
646
  end
538
647
 
539
648
  def env_vars
@@ -799,7 +908,9 @@ module LemansRemote # :nodoc: all
799
908
  option :runs_dir, default: "runs", desc: "Sync mode: local directory to sync the results into"
800
909
  option :env, repeatable: true, desc: "Forward an extra host ENV variable by name"
801
910
  option :concurrency, type: :numeric, default: 4, aliases: "-C",
802
- 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)"
803
914
  def run_bench
804
915
  bench = Lemans::Config.load_file(options[:bench])
805
916
 
@@ -826,7 +937,7 @@ module LemansRemote # :nodoc: all
826
937
  attempts = options[:attempts].to_i
827
938
  raise Thor::Error, "lemans-remote: --attempts must be at least 1" if attempts < 1
828
939
  raise Thor::Error, "lemans-remote: --attempts needs async mode — drop --sync" if options[:sync] && attempts > 1
829
- 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?
830
941
 
831
942
  models = options[:model] || []
832
943
  jobs =
@@ -855,7 +966,7 @@ module LemansRemote # :nodoc: all
855
966
  elsif attempts > 1 then "#{jobs.size / attempts} task × model batch(es) × #{attempts} attempts"
856
967
  else "one per task × model"
857
968
  end
858
- 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"
859
970
  failures, retried = launch_batches(bench, jobs, provisioner)
860
971
  finish_retry(planner, retried) if retry_groups
861
972
  say_status :detached, "`lemans-remote status` to watch, `lemans-remote pull-runs` to fetch results"
@@ -897,15 +1008,36 @@ module LemansRemote # :nodoc: all
897
1008
  raise Thor::Error, "lemans-remote: #{e.message}"
898
1009
  end
899
1010
 
1011
+ desc "logs RUN_ID", "Print a run's lemans output (live from its sandbox, or from the vault once it finished)"
1012
+ option :tail, type: :numeric, desc: "Only the last N lines"
1013
+ def logs(run_id)
1014
+ row = Fleet.rows.find { it.run_id == run_id }
1015
+ text =
1016
+ if row&.state == DaytonaApiClient::SandboxState::STARTED
1017
+ row.sandbox.process.exec(command: "cat #{REMOTE_LOG} 2>/dev/null", timeout: 60).result.to_s
1018
+ elsif row && Fleet::LIVE_STATES.include?(row.state)
1019
+ # The vault gets the log when the run exits, and a sandbox still
1020
+ # coming up has nothing to read yet
1021
+ raise "#{run_id} is #{row.state} — it has not started logging yet"
1022
+ else
1023
+ with_vault { |vault, sandbox| vault.run_log(sandbox, run_id) }
1024
+ end
1025
+ text = text.lines.last(options[:tail].to_i).join if options[:tail]
1026
+ say text
1027
+ rescue RuntimeError => e
1028
+ raise Thor::Error, "lemans-remote: #{e.message}"
1029
+ end
1030
+
900
1031
  map "pull-runs" => :pull_runs
901
1032
  desc "pull-runs [RUN_IDS...]", "Download archived runs from the vault into the local runs directory"
902
1033
  option :all, type: :boolean, default: false, desc: "Pull every completed run, even ones already pulled"
903
1034
  option :dry_run, type: :boolean, default: false, desc: "Preview what would be downloaded without pulling anything"
904
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)"
905
1037
  def pull_runs(*run_ids)
906
1038
  pulled = []
907
1039
  with_vault do |vault, sandbox|
908
- manifests = vault.manifests(sandbox)
1040
+ manifests = vault.manifests(sandbox, only: (run_ids if run_ids.any?), match: options[:match])
909
1041
  known = manifests.map { it["run_id"] }
910
1042
  targets =
911
1043
  if run_ids.any?
@@ -1123,39 +1255,31 @@ module LemansRemote # :nodoc: all
1123
1255
  runs_dir: options[:runs_dir],
1124
1256
  keep: options[:keep],
1125
1257
  sync: options[:sync],
1126
- shell: shell
1258
+ shell: shell,
1259
+ launch_interval: options[:launch_interval].to_f
1127
1260
  ).call
1128
1261
  end
1129
1262
 
1263
+ # One at a time, `interval` apart: a burst of creations stalls Daytona's
1264
+ # scheduler.
1130
1265
  def launch_batches(bench, jobs, provisioner)
1131
- queue = Queue.new
1132
- jobs.each { queue << it }
1133
- failures = Queue.new
1134
- launched = Queue.new
1266
+ interval = options[:launch_interval].to_f
1267
+ failures = []
1268
+ launched = []
1135
1269
 
1136
- threads = [ options[:concurrency].to_i, jobs.size ].min.times.map do
1137
- Thread.new do
1138
- loop do
1139
- batch, models, attempt, group =
1140
- begin
1141
- queue.pop(true)
1142
- rescue ThreadError
1143
- break
1144
- end
1145
- begin
1146
- launch_batch(bench, batch, models, provisioner, attempt:)
1147
- launched << group if group
1148
- rescue StandardError => e
1149
- label = batch.empty? ? "all" : batch.join(",")
1150
- label += "/a#{attempt}" if attempt
1151
- failures << "#{label} (#{e.message})"
1152
- end
1153
- 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})"
1154
1279
  end
1155
1280
  end
1156
- threads.each(&:join)
1157
1281
 
1158
- [ Array.new(failures.size) { failures.pop }, Array.new(launched.size) { launched.pop } ]
1282
+ [ failures, launched ]
1159
1283
  end
1160
1284
 
1161
1285
  def build_run_id(bench, tasks, models, attempt = nil)
@@ -14,8 +14,9 @@ module Lemans
14
14
  NAME = "miniswen-installed"
15
15
  RESULTS_PATH = "/tmp/lemans-miniswen.result.json"
16
16
  INSTALL_TIMEOUT_SEC = 300
17
- # The CLI enforces max-time itself; the slack only covers process
18
- # startup, so the results file exists before the outer exec expires.
17
+ # The CLI enforces max-time itself, but between steps only: a command
18
+ # started just before the deadline runs to its own exec timeout first,
19
+ # and the outer exec must outlast that too. The slack covers startup.
19
20
  EXEC_SLACK_SEC = 60
20
21
 
21
22
  def install(_task, environment)
@@ -31,8 +32,7 @@ module Lemans
31
32
  # An in-sandbox run self-reports: everything but the verifier's reward
32
33
  # comes from a file the sandbox wrote.
33
34
  def obtain_result(task, environment)
34
- run = environment.exec(command_for(task), timeout: profile.timeout + EXEC_SLACK_SEC,
35
- env: provider_env(environment))
35
+ run = environment.exec(command_for(task), timeout: outer_timeout, env: provider_env(environment))
36
36
 
37
37
  begin
38
38
  Tempfile.create(%w[miniswen .result.json]) do |file|
@@ -49,6 +49,8 @@ module Lemans
49
49
 
50
50
  attr_reader :raw_result
51
51
 
52
+ def outer_timeout = profile.timeout + profile.exec_timeout + EXEC_SLACK_SEC
53
+
52
54
  # A missing credential fails the run before the sandbox executes
53
55
  # anything: it is the operator's configuration to fix, not a trial result.
54
56
  def provider_env(environment)
@@ -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
data/lib/lemans/trial.rb CHANGED
@@ -77,6 +77,11 @@ module Lemans
77
77
  rescue InfrastructureError, ::Miniswen::InfrastructureError => e
78
78
  # Mark the failure here, where the agent phase is still known
79
79
  result.failed!(:agent_error, e.message)
80
+ collect_patch!
81
+ raise
82
+ rescue ::Miniswen::AccountingError
83
+ # Classified by the outer rescue; the work is still on disk
84
+ collect_patch!
80
85
  raise
81
86
  end
82
87
 
@@ -86,6 +91,7 @@ module Lemans
86
91
 
87
92
  if response.error?
88
93
  result.failed!(:agent_error, response.error)
94
+ collect_patch!
89
95
  return result
90
96
  end
91
97
 
@@ -97,7 +103,7 @@ module Lemans
97
103
 
98
104
  check_cost_limit!
99
105
 
100
- patch.collect!(result, store, path: with_step_index("agent.patch")) if store
106
+ collect_patch!
101
107
  if step_task.final_step?
102
108
  patch.compile!(result, store) if task.multistep? && store
103
109
  # Don't index the final verification
@@ -146,6 +152,10 @@ module Lemans
146
152
 
147
153
  private
148
154
 
155
+ def collect_patch!
156
+ patch.collect!(result, store, path: with_step_index("agent.patch")) if store
157
+ end
158
+
149
159
  def save_trajectory!(trajectory)
150
160
  return unless trajectory && store
151
161
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lemans
4
- VERSION = "1.3.1"
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
- # Increase retry window to handle egress network issues
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 = 3
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.1"
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.1
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