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 +4 -4
- data/CHANGELOG.md +12 -0
- data/exe/lemans-remote +167 -43
- data/lib/lemans/agents/miniswen_installed.rb +6 -4
- data/lib/lemans/environments/daytona/retries.rb +2 -0
- data/lib/lemans/environments/daytona.rb +65 -1
- data/lib/lemans/trial.rb +11 -1
- data/lib/lemans/version.rb +1 -1
- data/lib/miniswen/agent.rb +1 -1
- data/lib/miniswen/local.rb +3 -1
- data/lib/miniswen/ruby_llm.rb +9 -8
- data/lib/miniswen/version.rb +1 -1
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '08d62b21b79425a5c1e99867270b4cf2dd41ccb07f8ab7f5e5381a416170907f'
|
|
4
|
+
data.tar.gz: 802e7e3f665913b95a5409882f10b067f4118efe1e2de650a07dd93382f035af
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
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
|
-
|
|
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: "
|
|
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: --
|
|
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[:
|
|
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
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
launched = Queue.new
|
|
1266
|
+
interval = options[:launch_interval].to_f
|
|
1267
|
+
failures = []
|
|
1268
|
+
launched = []
|
|
1135
1269
|
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
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
|
-
[
|
|
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
|
|
18
|
-
#
|
|
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:
|
|
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 =
|
|
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
|
-
|
|
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
|
|
data/lib/lemans/version.rb
CHANGED
data/lib/miniswen/agent.rb
CHANGED
|
@@ -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
|
|
data/lib/miniswen/local.rb
CHANGED
|
@@ -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
|
|
data/lib/miniswen/ruby_llm.rb
CHANGED
|
@@ -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
|
-
#
|
|
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 =
|
|
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
|
-
#
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
module
|
|
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::
|
|
49
|
+
RubyLLM::Connection.prepend(Miniswen::RetryTransientFailures)
|
data/lib/miniswen/version.rb
CHANGED
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.
|
|
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
|