lemans 0.2.1 → 0.2.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.
Files changed (4) hide show
  1. checksums.yaml +4 -4
  2. data/exe/lemans-remote +107 -36
  3. data/lib/lemans/version.rb +1 -1
  4. metadata +2 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d451c9e558e461c51e7be30bdc17d9921a0ae8f028d82946dd5c0538620c5c31
4
- data.tar.gz: 3ff346d1263965339b9e51b51eaefade1fdd581048cc6b21c876836503cef3c8
3
+ metadata.gz: 6ce349f204cd0dc573a18932210dd5298941fe097f24576df1e318b29ce7152a
4
+ data.tar.gz: c9113c9bc9dbc4cf1f92d422b3ff929b20420e769828f55585f2a3eead34aa93
5
5
  SHA512:
6
- metadata.gz: e8d4b6027bcb1734cdfb4ca6c4f9797577014dc138189bbafe99fd8476cd07ecec52dbe96c5ad6107f44776b500d92a687d790d202699199ecb90b130522ecf8
7
- data.tar.gz: e0a3ab512fad47cc54b2057d06af7ddd7c2cc27d3b6d7c81ac3284c49ba4109900171f871907f163eb89180d0b82312a4a9039710c13ca24a084a3efc9b6041f
6
+ metadata.gz: 89ecd01b291a2d75475f346e8e780d87e104139e0ca34f48dde192325ec2ed593920e0e63f8b41a26b9e13144052e6bfe841831bc5d40a4432a0cd98d3fc39fc
7
+ data.tar.gz: 84b2bceb8d5b7c60b0e6513bb05b801d38081f8f59c55e96338a2a47d660c0521a31d224c365e0b694581f4e600552e4e9e390f3ccd6130d7fc61c8a9897cce2
data/exe/lemans-remote CHANGED
@@ -13,8 +13,9 @@
13
13
  # exe/lemans-remote provision
14
14
  #
15
15
  # Fire-and-forget a bench run on remote Daytona sandboxes — one sandbox
16
- # per task (--run-in-band for a single sandbox); results are archived to
17
- # the lemans-remote-runs volume. Add --sync to wait for a single sandbox
16
+ # per task (--run-in-band for a single sandbox, --attempts N to repeat
17
+ # each task in N sandboxes); results are archived to the
18
+ # lemans-remote-runs volume. Add --sync to wait for a single sandbox
18
19
  # and download into ./runs directly instead:
19
20
  #
20
21
  # exe/lemans-remote run --bench ../ai-evals --task hello-world
@@ -22,9 +23,11 @@
22
23
  #
23
24
  # Then watch, fetch, and clean up:
24
25
  #
25
- # exe/lemans-remote status [--history]
26
- # exe/lemans-remote pull-runs [RUN_ID ...] [--all]
26
+ # exe/lemans-remote status [--history] [--running | --complete]
27
+ # exe/lemans-remote pull-runs [RUN_ID ...] [--all] [--dry-run]
27
28
  # exe/lemans-remote clobber RUN_ID ... | --all
29
+ # exe/lemans-remote snapshots
30
+ # exe/lemans-remote deprovision [--name SNAPSHOT]
28
31
  #
29
32
  # Credentials come from the host ENV: DAYTONA_API_KEY (or DAYTONA_TOKEN),
30
33
  # OPENROUTER_API_KEY, LEMANS_PROVIDER_ORDER; forward extras with --env KEY.
@@ -47,7 +50,7 @@ require "timeout"
47
50
  require "tmpdir"
48
51
  require "openssl"
49
52
 
50
- module LemansRemote
53
+ module LemansRemote # :nodoc: all
51
54
  LABEL = "lemans-remote"
52
55
  RUN_ID_LABEL = "lemans-remote/run-id"
53
56
  STATUS_LABEL = "lemans-remote/status"
@@ -111,7 +114,7 @@ module LemansRemote
111
114
  name
112
115
  end
113
116
 
114
- def drop(target = name)
117
+ def drop(target = name) # rubocop:disable Naming/PredicateMethod
115
118
  existing = find(target)
116
119
  return false unless existing
117
120
 
@@ -348,7 +351,7 @@ module LemansRemote
348
351
  end
349
352
 
350
353
  def manifests(sandbox)
351
- code = 'require "json"; puts Dir.glob("/vault/*/manifest.json")' \
354
+ code = 'require "json"; puts Dir.glob("/vault/*/manifest.json", File::FNM_DOTMATCH)' \
352
355
  ".filter_map { |f| JSON.parse(File.read(f)) rescue nil }.to_json"
353
356
  response = sandbox.process.exec(command: "ruby -e #{Shellwords.escape(code)}", timeout: 120)
354
357
  raise "could not read the vault manifests: #{response.result}" unless response.exit_code.zero?
@@ -357,6 +360,14 @@ module LemansRemote
357
360
  body.empty? ? [] : JSON.parse(body)
358
361
  end
359
362
 
363
+ def archive_size(sandbox, run_id)
364
+ remote = "/vault/#{run_id}/runs.tar.gz"
365
+ response = sandbox.process.exec(command: "du -h #{Shellwords.escape(remote)}", timeout: 60)
366
+ return nil unless response.exit_code.zero?
367
+
368
+ response.result.to_s.split.first
369
+ end
370
+
360
371
  def download_runs(sandbox, run_id, runs_dir)
361
372
  remote = "/vault/#{run_id}/runs.tar.gz"
362
373
  probe = sandbox.process.exec(command: "test -f #{Shellwords.escape(remote)}", timeout: 60)
@@ -384,7 +395,7 @@ module LemansRemote
384
395
  def mark_pulled(sandbox, run_id)
385
396
  code = 'require "json"; require "time"; ' \
386
397
  'path = File.join("/vault", ARGV[0], "manifest.json"); ' \
387
- 'data = JSON.parse(File.read(path)); ' \
398
+ "data = JSON.parse(File.read(path)); " \
388
399
  'data["pulled_at"] = Time.now.utc.iso8601; ' \
389
400
  "File.write(path, JSON.pretty_generate(data))"
390
401
  sandbox.process.exec(command: "ruby -e #{Shellwords.escape(code)} #{Shellwords.escape(run_id)}", timeout: 60)
@@ -537,7 +548,7 @@ module LemansRemote
537
548
  def run_metadata(sandbox)
538
549
  {
539
550
  "run_id" => @run_id,
540
- "bench" => @bench.root.basename.to_s,
551
+ "bench" => @bench.root.expand_path.basename.to_s,
541
552
  "tasks" => @tasks,
542
553
  "models" => @models,
543
554
  "args" => @extra_args,
@@ -722,6 +733,7 @@ module LemansRemote
722
733
  option :sync, type: :boolean, default: false, desc: "Wait for the run and download the results directly"
723
734
  option :run_in_band, type: :boolean, default: false, aliases: %w[--runInBand],
724
735
  desc: "Async mode: run all tasks in one sandbox instead of one sandbox per task"
736
+ option :attempts, type: :numeric, default: 1, desc: "Async mode: repeat each task batch N times, one sandbox per attempt"
725
737
  option :keep, type: :boolean, default: false, desc: "Sync mode: leave the sandbox around for debugging"
726
738
  option :runs_dir, default: "runs", desc: "Sync mode: local directory to sync the results into"
727
739
  option :env, repeatable: true, desc: "Forward an extra host ENV variable by name"
@@ -732,23 +744,28 @@ module LemansRemote
732
744
  raise Thor::Error, "lemans-remote: no such task(s): #{unknown.join(", ")}" if unknown.any?
733
745
 
734
746
  provisioner = build_provisioner
735
- unless provisioner.provisioned?
736
- raise Thor::Error, "lemans-remote: snapshot #{provisioner.name} not found — run `lemans-remote provision` first"
737
- end
747
+ raise Thor::Error, "lemans-remote: snapshot #{provisioner.name} not found — run `lemans-remote provision` first" unless provisioner.provisioned?
748
+
749
+ attempts = options[:attempts].to_i
750
+ raise Thor::Error, "lemans-remote: --attempts must be at least 1" if attempts < 1
751
+ raise Thor::Error, "lemans-remote: --attempts needs async mode — drop --sync" if options[:sync] && attempts > 1
738
752
 
739
753
  models = options[:model] || []
740
754
  batches = plan_batches(bench, tasks)
755
+ jobs = batches.flat_map { |batch| (1..attempts).map { [batch, attempts > 1 ? _1 : nil] } }
741
756
  Vault.ensure unless options[:sync]
742
757
 
743
- if batches.size == 1
744
- exit_code = launch_batch(bench, batches.first, models, provisioner)
758
+ if jobs.size == 1
759
+ batch, attempt = jobs.first
760
+ exit_code = launch_batch(bench, batch, models, provisioner, attempt:)
745
761
  say_status :detached, "`lemans-remote status` to watch, `lemans-remote pull-runs` to fetch results" unless options[:sync]
746
762
  exit exit_code unless exit_code.zero?
747
763
  return
748
764
  end
749
765
 
750
- say_status :fanout, "#{batches.size} sandboxes, one per task"
751
- failures = launch_batches(bench, batches, models, provisioner)
766
+ fanout = attempts > 1 ? "#{batches.size} task batch(es) × #{attempts} attempts" : "one per task"
767
+ say_status :fanout, "#{jobs.size} sandboxes, #{fanout}"
768
+ failures = launch_batches(bench, jobs, models, provisioner)
752
769
  say_status :detached, "`lemans-remote status` to watch, `lemans-remote pull-runs` to fetch results"
753
770
  raise Thor::Error, "lemans-remote: failed to launch: #{failures.join("; ")}" if failures.any?
754
771
  rescue Lemans::ConfigError, RuntimeError => e
@@ -757,6 +774,8 @@ module LemansRemote
757
774
 
758
775
  desc "status", "List lemans-remote runs (--history adds completed runs whose sandboxes are gone)"
759
776
  option :history, type: :boolean, default: false, desc: "Also read the vault manifests (spins a short-lived helper sandbox)"
777
+ option :running, type: :boolean, default: false, desc: "Only show runs still in flight"
778
+ option :complete, type: :boolean, default: false, desc: "Only show finished runs (success, failed, or stale)"
760
779
  def status
761
780
  rows = Fleet.rows.map { [_1.run_id, _1.status, _1.started_at || "?", _1.state, _1.sandbox.id] }
762
781
  if options[:history]
@@ -768,8 +787,15 @@ module LemansRemote
768
787
  end
769
788
  end
770
789
 
790
+ filtered = options[:running] ^ options[:complete]
791
+ rows.select! { (_1[1] == "running") == options[:running] } if filtered
792
+
771
793
  if rows.empty?
772
- say "no lemans-remote runs#{options[:history] ? "" : " — try --history for completed ones"}"
794
+ if filtered
795
+ say "no #{options[:running] ? "running" : "complete"} lemans-remote runs"
796
+ else
797
+ say "no lemans-remote runs#{" — try --history for completed ones" unless options[:history]}"
798
+ end
773
799
  return
774
800
  end
775
801
 
@@ -783,6 +809,7 @@ module LemansRemote
783
809
  map "pull-runs" => :pull_runs
784
810
  desc "pull-runs [RUN_IDS...]", "Download archived runs from the vault into the local runs directory"
785
811
  option :all, type: :boolean, default: false, desc: "Pull every completed run, even ones already pulled"
812
+ option :dry_run, type: :boolean, default: false, desc: "Preview what would be downloaded without pulling anything"
786
813
  option :runs_dir, default: "runs", desc: "Local directory to sync the results into"
787
814
  def pull_runs(*run_ids)
788
815
  pulled = []
@@ -803,6 +830,16 @@ module LemansRemote
803
830
  say_status :pull, "nothing new to pull (use --all to re-pull)", :yellow if targets.empty?
804
831
 
805
832
  targets.each do |run_id|
833
+ if options[:dry_run]
834
+ size = vault.archive_size(sandbox, run_id)
835
+ if size
836
+ say_status :would_pull, "#{run_id} (#{size})", :cyan
837
+ else
838
+ say_status :missing, "#{run_id} has no runs.tar.gz (still running?)", :yellow
839
+ end
840
+ next
841
+ end
842
+
806
843
  results = vault.download_runs(sandbox, run_id, options[:runs_dir])
807
844
  if results
808
845
  vault.mark_pulled(sandbox, run_id)
@@ -828,9 +865,7 @@ module LemansRemote
828
865
 
829
866
  live = Fleet.live
830
867
  if options[:all]
831
- unless live.empty?
832
- raise Thor::Error, "lemans-remote: #{live.size} run(s) still live (#{live.map(&:run_id).join(", ")}) — wait for them to finish"
833
- end
868
+ raise Thor::Error, "lemans-remote: #{live.size} run(s) still live (#{live.map(&:run_id).join(", ")}) — wait for them to finish" unless live.empty?
834
869
  return unless options[:force] || yes?("Delete the whole #{Vault::NAME} volume and every stopped run sandbox? [y/N]")
835
870
 
836
871
  Fleet.rows.each { safe_delete_sandbox(_1.sandbox) }
@@ -858,6 +893,28 @@ module LemansRemote
858
893
  raise Thor::Error, "lemans-remote: #{e.message}"
859
894
  end
860
895
 
896
+ desc "snapshots", "List lemans-remote orchestrator snapshots"
897
+ def snapshots
898
+ current = build_provisioner.name
899
+ rows = all_snapshots.select { _1.name.start_with?("#{LABEL}-") }.map do |snap|
900
+ shape = "#{snap.cpu.to_i}cpu/#{snap.mem.to_i}gb/#{snap.disk.to_i}gb"
901
+ [snap.name, snap.state, shape, snap.created_at.to_s[0, 19], snap.last_used_at.to_s[0, 19],
902
+ snap.name == current ? "current" : "-"]
903
+ end
904
+
905
+ if rows.empty?
906
+ say "no lemans-remote snapshots — run `lemans-remote provision` first"
907
+ return
908
+ end
909
+
910
+ rows.sort_by! { _1[3].to_s }
911
+ rows.reverse!
912
+ print_table([%w[snapshot state shape created last-used note]] + rows)
913
+ say "delete old ones with `lemans-remote deprovision --name SNAPSHOT`"
914
+ rescue Lemans::ConfigError, RuntimeError => e
915
+ raise Thor::Error, "lemans-remote: #{e.message}"
916
+ end
917
+
861
918
  desc "deprovision", "Delete the orchestrator snapshot"
862
919
  option :name, desc: "Delete a specific snapshot by name (default: the one for this shape and version)"
863
920
  def deprovision
@@ -877,14 +934,14 @@ module LemansRemote
877
934
  say Lemans::VERSION
878
935
  end
879
936
 
937
+ FANOUT_THREADS = 4
938
+
880
939
  private
881
940
 
882
941
  def build_provisioner
883
942
  Provisioner.new(cpus: options[:cpus].to_i, memory_gb: options[:memory].to_i, disk_gb: options[:disk].to_i)
884
943
  end
885
944
 
886
- FANOUT_THREADS = 4
887
-
888
945
  def plan_batches(bench, tasks)
889
946
  return [tasks] if options[:sync] || options[:run_in_band]
890
947
 
@@ -892,11 +949,11 @@ module LemansRemote
892
949
  expanded.map { [_1] }
893
950
  end
894
951
 
895
- def launch_batch(bench, tasks, models, provisioner)
952
+ def launch_batch(bench, tasks, models, provisioner, attempt: nil)
896
953
  Runner.new(
897
954
  bench: bench,
898
955
  snapshot: provisioner.name,
899
- run_id: build_run_id(bench, tasks, models),
956
+ run_id: build_run_id(bench, tasks, models, attempt),
900
957
  tasks: tasks,
901
958
  models: models,
902
959
  extra_args: options[:args].strip,
@@ -909,24 +966,26 @@ module LemansRemote
909
966
  ).call
910
967
  end
911
968
 
912
- def launch_batches(bench, batches, models, provisioner)
969
+ def launch_batches(bench, jobs, models, provisioner)
913
970
  queue = Queue.new
914
- batches.each { queue << _1 }
971
+ jobs.each { queue << _1 }
915
972
  failures = Queue.new
916
973
 
917
- threads = [FANOUT_THREADS, batches.size].min.times.map do
974
+ threads = [FANOUT_THREADS, jobs.size].min.times.map do
918
975
  Thread.new do
919
976
  loop do
920
- batch =
977
+ batch, attempt =
921
978
  begin
922
979
  queue.pop(true)
923
980
  rescue ThreadError
924
981
  break
925
982
  end
926
983
  begin
927
- launch_batch(bench, batch, models, provisioner)
984
+ launch_batch(bench, batch, models, provisioner, attempt:)
928
985
  rescue StandardError => e
929
- failures << "#{batch.join(",")} (#{e.message})"
986
+ label = batch.empty? ? "all" : batch.join(",")
987
+ label += "/a#{attempt}" if attempt
988
+ failures << "#{label} (#{e.message})"
930
989
  end
931
990
  end
932
991
  end
@@ -936,27 +995,39 @@ module LemansRemote
936
995
  Array.new(failures.size) { failures.pop }
937
996
  end
938
997
 
939
- def build_run_id(bench, tasks, models)
940
- parts = [bench.root.basename.to_s]
998
+ def build_run_id(bench, tasks, models, attempt = nil)
999
+ parts = [bench.root.expand_path.basename.to_s]
941
1000
  parts << (tasks.size == 1 ? tasks.first : "#{tasks.size}tasks") if tasks.any?
942
1001
  if models.any?
943
1002
  short = models.first.split("/").last
944
1003
  short += "+#{models.size - 1}" if models.size > 1
945
1004
  parts << short
946
1005
  end
1006
+ parts << "a#{attempt}" if attempt
947
1007
  parts << (ENV["LEMANS_REMOTE_USER"] || ENV["USER"] || "anon")
948
1008
  parts << Time.now.utc.strftime("%Y%m%dT%H%M%S")
949
1009
  parts << SecureRandom.alphanumeric(4).downcase
950
1010
  parts.map { name_part(_1) }.reject(&:empty?).join("-")
951
1011
  end
952
1012
 
953
- def name_part(value) = value.to_s.downcase.gsub(/[^a-z0-9._+]+/, "-").gsub(/\A-+|-+\z/, "")
1013
+ def name_part(value) = value.to_s.downcase.gsub(/[^a-z0-9._+]+/, "-").gsub(/\A[-.]+|[-.]+\z/, "")
1014
+
1015
+ def all_snapshots
1016
+ items = []
1017
+ page = 1
1018
+ loop do
1019
+ result = LemansRemote.client.snapshot.list(page: page, limit: 200)
1020
+ items.concat(result.items)
1021
+ break if page >= result.total_pages.to_i
1022
+
1023
+ page += 1
1024
+ end
1025
+ items
1026
+ end
954
1027
 
955
1028
  def with_vault(&block)
956
1029
  provisioner = build_provisioner
957
- unless provisioner.provisioned?
958
- raise Thor::Error, "lemans-remote: snapshot #{provisioner.name} not found — run `lemans-remote provision` first"
959
- end
1030
+ raise Thor::Error, "lemans-remote: snapshot #{provisioner.name} not found — run `lemans-remote provision` first" unless provisioner.provisioned?
960
1031
 
961
1032
  vault = VaultClient.new(snapshot: provisioner.name)
962
1033
  vault.with_helper { |sandbox| block.call(vault, sandbox) }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lemans
4
- VERSION = "0.2.1"
4
+ VERSION = "0.2.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: 0.2.1
4
+ version: 0.2.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Svyatoslav Kryukov
@@ -114,6 +114,7 @@ email:
114
114
  - ardecvz@gmail.com
115
115
  executables:
116
116
  - lemans
117
+ - lemans-remote
117
118
  extensions: []
118
119
  extra_rdoc_files: []
119
120
  files: