lemans 0.2.2 → 1.0.0.pre.1

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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +30 -10
  3. data/exe/lemans-remote +161 -46
  4. data/lib/lemans/agent.rb +39 -0
  5. data/lib/lemans/agents/miniswen.rb +28 -25
  6. data/lib/lemans/agents/miniswen_installed.rb +13 -9
  7. data/lib/lemans/agents/nop.rb +3 -3
  8. data/lib/lemans/agents/oracle.rb +8 -8
  9. data/lib/lemans/cli/board_reporter.rb +16 -16
  10. data/lib/lemans/cli/progress_reporter.rb +19 -19
  11. data/lib/lemans/cli/report/aggregate.rb +117 -0
  12. data/lib/lemans/cli/report.rb +164 -0
  13. data/lib/lemans/cli.rb +58 -63
  14. data/lib/lemans/clobber.rb +17 -55
  15. data/lib/lemans/config/agent.rb +51 -0
  16. data/lib/lemans/config/conversion.rb +62 -0
  17. data/lib/lemans/config/environment.rb +39 -0
  18. data/lib/lemans/config/image_spec.rb +42 -0
  19. data/lib/lemans/config/network_policy.rb +55 -0
  20. data/lib/lemans/config/revision.rb +42 -0
  21. data/lib/lemans/config/setup.rb +57 -0
  22. data/lib/lemans/config/tree_digest.rb +26 -0
  23. data/lib/lemans/config/verifier.rb +72 -0
  24. data/lib/lemans/config.rb +107 -0
  25. data/lib/lemans/environment.rb +54 -0
  26. data/lib/lemans/environments/daytona/faraday_transfer.rb +172 -0
  27. data/lib/lemans/environments/daytona/sdk_tweaks.rb +1 -1
  28. data/lib/lemans/environments/daytona/shell.rb +1 -1
  29. data/lib/lemans/environments/daytona/snapshot_store.rb +11 -11
  30. data/lib/lemans/environments/daytona.rb +21 -35
  31. data/lib/lemans/result.rb +270 -0
  32. data/lib/lemans/runner/executor.rb +64 -0
  33. data/lib/lemans/runner/task.rb +62 -0
  34. data/lib/lemans/runner.rb +82 -0
  35. data/lib/lemans/store.rb +44 -0
  36. data/lib/lemans/stores/fs.rb +122 -0
  37. data/lib/lemans/task_definition.rb +194 -0
  38. data/lib/lemans/trial/patch.rb +76 -0
  39. data/lib/lemans/trial/setup.rb +66 -0
  40. data/lib/lemans/trial/snapshot.rb +57 -0
  41. data/lib/lemans/trial/verifier.rb +190 -0
  42. data/lib/lemans/trial.rb +113 -147
  43. data/lib/lemans/version.rb +1 -1
  44. data/lib/lemans.rb +3 -2
  45. data/lib/miniswen/trajectory.rb +2 -0
  46. metadata +58 -25
  47. data/lib/lemans/agents/base.rb +0 -30
  48. data/lib/lemans/bench.rb +0 -280
  49. data/lib/lemans/environments/base.rb +0 -55
  50. data/lib/lemans/network_policy.rb +0 -66
  51. data/lib/lemans/patch.rb +0 -70
  52. data/lib/lemans/restore_paths.rb +0 -21
  53. data/lib/lemans/results/aggregate.rb +0 -114
  54. data/lib/lemans/results/cost_source.rb +0 -13
  55. data/lib/lemans/results/outcome.rb +0 -36
  56. data/lib/lemans/results/report.rb +0 -149
  57. data/lib/lemans/results/sorting.rb +0 -24
  58. data/lib/lemans/results/tally.rb +0 -19
  59. data/lib/lemans/results/usage.rb +0 -24
  60. data/lib/lemans/run.rb +0 -152
  61. data/lib/lemans/setup.rb +0 -59
  62. data/lib/lemans/setup_files.rb +0 -36
  63. data/lib/lemans/snapshot.rb +0 -55
  64. data/lib/lemans/task.rb +0 -207
  65. data/lib/lemans/tree_digest.rb +0 -24
  66. data/lib/lemans/units.rb +0 -44
  67. data/lib/lemans/verifier.rb +0 -199
  68. /data/lib/lemans/{verifier → trial/verifier}/assets/eport-lemans.rb +0 -0
  69. /data/lib/lemans/{verifier → trial/verifier}/assets/lemans_minitest_reporter.rb +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1cf8e2ef8780e6577e9810b782c4fd1fe37fbe5691589c3ef665c09068ef4121
4
- data.tar.gz: c697394a89a3056df20a174e55f83ad2ab81038cff1662a2bb91da784184c9d6
3
+ metadata.gz: 9696bf1ad08f76e0a6faafd5651d67e1a98ca6e36e189ab41f71bdcb5f5f173e
4
+ data.tar.gz: 02e255c8829342789f9a057c1234a3a45bb2ca366db832caedb43d42dd0d7c5b
5
5
  SHA512:
6
- metadata.gz: 223472ec514232b4ec45a5f735d5786a7932ccd929064cc2c92bc5ebe22a70470836a6054694598a2c761c8eeac21fc7d8dc608a8506d309f2f1bfa7ad0d53eb
7
- data.tar.gz: 3d05d9d1a0c1cd3432cf87c76f2a0afa6ec4b05298be9d6dbc8519268fb7805c6535246550c394a764de56c674ef22dba0a6c103cec807614b43d3548684ca77
6
+ metadata.gz: ccb12333aefc40203516d6e3cd98fdfadb2c96c6c159c4ea6acda851de502e5d9431ec8c69bd330e9b483a09bd6e5d93ef6d30e1dad23ba3f32485f1cf8a3283
7
+ data.tar.gz: 4548e86baeaefe091845ec3d16f91c8b08d54373c2d375f20e78b03e5a95c32f7506137dc02a0a218be02fd8fe8416e000566af91354cd7bf90301d248574c42
data/README.md CHANGED
@@ -12,7 +12,7 @@ Lemans is a harness for benchmarking coding agents, the Ruby way:
12
12
 
13
13
  ## Prerequisites
14
14
 
15
- - Ruby 3.3+ is required to run `lemans`
15
+ - Ruby 3.4+ is required to run `lemans`
16
16
  - Daytona account (API token)
17
17
  - Some LLM provider/proxy credentials (e.g., OpenRouter)
18
18
 
@@ -36,7 +36,8 @@ my-bench/
36
36
  └── tasks/
37
37
  └── hello-world/
38
38
  ├── instruction.md # what the agent is asked to do; YAML frontmatter carries
39
- │ # name, description, difficulty, tags, metadata, restore
39
+ │ # name, description, difficulty, tags, metadata — plus the
40
+ │ # per-task overrides: setup, restore, verifier.setup
40
41
  ├── environment/Dockerfile # [optional] the sandbox image (you can use a shared image in bench.yml)
41
42
  ├── environment.patch # [optional] task setup patch: applied and resealed as a fresh git repo at setup
42
43
  ├── verification_test.rb # grades the result
@@ -48,6 +49,11 @@ A minimal example `bench.yml`:
48
49
  ```yaml
49
50
  version: 1
50
51
 
52
+ # setup: # [optional] sandbox preparation, run before the agent starts
53
+ # files: [fixtures/seed.sql] # uploaded for the commands to consume, then wiped
54
+ # commands: [bin/sandbox-setup]
55
+ # A bare list is a commands shorthand: `setup: [bin/sandbox-setup]`
56
+
51
57
  environment:
52
58
  resources: { cpus: 2, memory: 2GB, storage: 5GB }
53
59
  build_timeout: 10m
@@ -68,6 +74,7 @@ agent:
68
74
 
69
75
  verifier:
70
76
  timeout: 10m
77
+ # setup: [gem install debug] # [optional] grading prep (same files/commands form as setup:)
71
78
  # preverify: ruby -report-lemans bin/rails test # [optional] a command that must pass first
72
79
  # restore: [test, bin] # [optional] folders/files to restore before verification
73
80
  ```
@@ -129,6 +136,19 @@ A minimal task example—checking whether an agent can write "Hello, world" into
129
136
 
130
137
  The task's contents go to `my-bench/tasks/hello-world`.
131
138
 
139
+ A task may extend the bench-wide preparation from its frontmatter — and only the preparation; resources, network, and the agent's budget are the frozen profile a task cannot touch:
140
+
141
+ ```yaml
142
+ setup: # extra files/commands for this task's sandbox
143
+ files: [fixtures/data.csv] # relative to the task directory
144
+ commands: [bin/import]
145
+ restore: [test, config] # overrides verifier.restore for this task
146
+ verifier:
147
+ setup: [gem install debug] # extra grading prep on top of the bench-wide verifier.setup
148
+ ```
149
+
150
+ An `environment.patch` next to `instruction.md` is always applied, declared or not.
151
+
132
152
  ### 3. Set credentials
133
153
 
134
154
  ```bash
@@ -164,19 +184,19 @@ ar-announce-once ✔✔
164
184
  ar-archive-book-access ✔✔
165
185
  ⠧ 6/6 done · 0 in flight
166
186
 
167
- task agent model reward outcome cost_usd steps tokens duration_sec trial
168
- ac-throttle-search miniswen-installed gpt-5.6-luna 1 completed 0.0112 11 118561 137.1 ac-throttle-search__j57Jfpt
169
- ac-throttle-search miniswen-installed gpt-5.6-luna 1 completed 0.0161 12 157111 144.1 ac-throttle-search__ph2nfjN
170
- ar-announce-once miniswen-installed gpt-5.6-luna 1 completed 0.0157 13 162432 186 ar-announce-once__Xi30ZmP
171
- ar-announce-once miniswen-installed gpt-5.6-luna 1 completed 0.0106 11 118691 117.8 ar-announce-once__mo6a6zQ
172
- ar-archive-book-access miniswen-installed gpt-5.6-luna 1 completed 0.0128 12 149867 136.2 ar-archive-book-access__oSRKzQc
173
- ar-archive-book-access miniswen-installed gpt-5.6-luna 1 completed 0.0137 13 163943 149.6 ar-archive-book-access__XB5PRL3
187
+ task agent model reward outcome cost_usd steps tokens duration trial
188
+ ac-throttle-search miniswen-installed gpt-5.6-luna 1 completed 0.0112 11 118561 137.1 ac-throttle-search__j57Jfpt
189
+ ac-throttle-search miniswen-installed gpt-5.6-luna 1 completed 0.0161 12 157111 144.1 ac-throttle-search__ph2nfjN
190
+ ar-announce-once miniswen-installed gpt-5.6-luna 1 completed 0.0157 13 162432 186 ar-announce-once__Xi30ZmP
191
+ ar-announce-once miniswen-installed gpt-5.6-luna 1 completed 0.0106 11 118691 117.8 ar-announce-once__mo6a6zQ
192
+ ar-archive-book-access miniswen-installed gpt-5.6-luna 1 completed 0.0128 12 149867 136.2 ar-archive-book-access__oSRKzQc
193
+ ar-archive-book-access miniswen-installed gpt-5.6-luna 1 completed 0.0137 13 163943 149.6 ar-archive-book-access__XB5PRL3
174
194
  6 trials: 6 scored, 0 invalid, 6 solved (100%) · $0.0801 · pass@2 3/3 tasks (100%)
175
195
  ```
176
196
 
177
197
  `lemans run` runs all the tasks for the model defined in `bench.yml` and prints the report at the end. You can override the model(s) to use (`--model`), the number of attempts (`--attempts`), or select specific tasks by name (`--task=ac-throttle-search`, may be repeated).
178
198
 
179
- Each trial writes a flat `runs/<model>/<task>__<id>/` directory: `result.json` (reward, outcome, usage, per-phase timings, tags, digests), `trajectory.json` (ATIF), `agent.patch` (the agent's work as one diff against the sealed baseline), `verifier.log`, and whatever the verifier left under `$LOGS` (`checks.json` included). `lemans run --resume` skips trials that already have a scored result for the same agent and model.
199
+ Each trial writes a flat `runs/<model>/<task>__<id>/` directory: `result.json` (reward, outcome, usage, per-phase timings, tags, digests), `trajectory.json` (ATIF), `agent.patch` (the agent's work as one diff against the sealed baseline), `verifier.log`, and whatever the verifier left under `$LOGS` (`checks.json` included). Every `result.json` is stamped with the `lemans_version` that wrote it, and newer lemans keeps reading runs produced by older releases. `lemans run --resume` skips trials that already have a scored result for the same agent and model.
180
200
 
181
201
  You can also run `lemans report` with various flags to see aggregated results, e.g.:
182
202
 
data/exe/lemans-remote CHANGED
@@ -24,8 +24,11 @@
24
24
  # Then watch, fetch, and clean up:
25
25
  #
26
26
  # exe/lemans-remote status [--history] [--running | --complete]
27
- # exe/lemans-remote pull-runs [RUN_ID ...] [--all]
27
+ # exe/lemans-remote pull-runs [RUN_ID ...] [--all] [--dry-run]
28
+ # exe/lemans-remote drop-orphans [--min-age 10m]
28
29
  # exe/lemans-remote clobber RUN_ID ... | --all
30
+ # exe/lemans-remote snapshots
31
+ # exe/lemans-remote deprovision [--name SNAPSHOT]
29
32
  #
30
33
  # Credentials come from the host ENV: DAYTONA_API_KEY (or DAYTONA_TOKEN),
31
34
  # OPENROUTER_API_KEY, LEMANS_PROVIDER_ORDER; forward extras with --env KEY.
@@ -48,7 +51,7 @@ require "timeout"
48
51
  require "tmpdir"
49
52
  require "openssl"
50
53
 
51
- module LemansRemote
54
+ module LemansRemote # :nodoc: all
52
55
  LABEL = "lemans-remote"
53
56
  RUN_ID_LABEL = "lemans-remote/run-id"
54
57
  STATUS_LABEL = "lemans-remote/status"
@@ -112,7 +115,7 @@ module LemansRemote
112
115
  name
113
116
  end
114
117
 
115
- def drop(target = name)
118
+ def drop(target = name) # rubocop:disable Naming/PredicateMethod
116
119
  existing = find(target)
117
120
  return false unless existing
118
121
 
@@ -349,7 +352,7 @@ module LemansRemote
349
352
  end
350
353
 
351
354
  def manifests(sandbox)
352
- code = 'require "json"; puts Dir.glob("/vault/*/manifest.json")' \
355
+ code = 'require "json"; puts Dir.glob("/vault/*/manifest.json", File::FNM_DOTMATCH)' \
353
356
  ".filter_map { |f| JSON.parse(File.read(f)) rescue nil }.to_json"
354
357
  response = sandbox.process.exec(command: "ruby -e #{Shellwords.escape(code)}", timeout: 120)
355
358
  raise "could not read the vault manifests: #{response.result}" unless response.exit_code.zero?
@@ -358,6 +361,14 @@ module LemansRemote
358
361
  body.empty? ? [] : JSON.parse(body)
359
362
  end
360
363
 
364
+ def archive_size(sandbox, run_id)
365
+ remote = "/vault/#{run_id}/runs.tar.gz"
366
+ response = sandbox.process.exec(command: "du -h #{Shellwords.escape(remote)}", timeout: 60)
367
+ return nil unless response.exit_code.zero?
368
+
369
+ response.result.to_s.split.first
370
+ end
371
+
361
372
  def download_runs(sandbox, run_id, runs_dir)
362
373
  remote = "/vault/#{run_id}/runs.tar.gz"
363
374
  probe = sandbox.process.exec(command: "test -f #{Shellwords.escape(remote)}", timeout: 60)
@@ -385,7 +396,7 @@ module LemansRemote
385
396
  def mark_pulled(sandbox, run_id)
386
397
  code = 'require "json"; require "time"; ' \
387
398
  'path = File.join("/vault", ARGV[0], "manifest.json"); ' \
388
- 'data = JSON.parse(File.read(path)); ' \
399
+ "data = JSON.parse(File.read(path)); " \
389
400
  'data["pulled_at"] = Time.now.utc.iso8601; ' \
390
401
  "File.write(path, JSON.pretty_generate(data))"
391
402
  sandbox.process.exec(command: "ruby -e #{Shellwords.escape(code)} #{Shellwords.escape(run_id)}", timeout: 60)
@@ -538,7 +549,7 @@ module LemansRemote
538
549
  def run_metadata(sandbox)
539
550
  {
540
551
  "run_id" => @run_id,
541
- "bench" => @bench.root.basename.to_s,
552
+ "bench" => @bench.root.expand_path.basename.to_s,
542
553
  "tasks" => @tasks,
543
554
  "models" => @models,
544
555
  "args" => @extra_args,
@@ -688,12 +699,14 @@ module LemansRemote
688
699
  end
689
700
 
690
701
  class CLI < Thor
702
+ include Lemans::Config::Conversion
703
+
691
704
  check_unknown_options!
692
705
 
693
706
  def self.exit_on_failure? = true
694
707
 
695
- class_option :cpus, type: :numeric, default: 4, desc: "Orchestrator vCPUs (part of the snapshot identity)"
696
- class_option :memory, type: :numeric, default: 8, desc: "Orchestrator memory, GiB"
708
+ class_option :cpus, type: :numeric, default: 2, desc: "Orchestrator vCPUs (part of the snapshot identity)"
709
+ class_option :memory, type: :numeric, default: 4, desc: "Orchestrator memory, GiB"
697
710
  class_option :disk, type: :numeric, default: 10, desc: "Orchestrator disk, GiB"
698
711
 
699
712
  desc "provision", "Build the orchestrator snapshot (lemans #{Lemans::VERSION} preinstalled)"
@@ -727,6 +740,8 @@ module LemansRemote
727
740
  option :keep, type: :boolean, default: false, desc: "Sync mode: leave the sandbox around for debugging"
728
741
  option :runs_dir, default: "runs", desc: "Sync mode: local directory to sync the results into"
729
742
  option :env, repeatable: true, desc: "Forward an extra host ENV variable by name"
743
+ option :concurrency, type: :numeric, default: 4, aliases: "-C",
744
+ desc: "Async mode: how many sandboxes to launch in parallel (1 to serialize)"
730
745
  def run_bench
731
746
  bench = Lemans::Bench.load(options[:bench])
732
747
  tasks = options[:task] || []
@@ -734,13 +749,12 @@ module LemansRemote
734
749
  raise Thor::Error, "lemans-remote: no such task(s): #{unknown.join(", ")}" if unknown.any?
735
750
 
736
751
  provisioner = build_provisioner
737
- unless provisioner.provisioned?
738
- raise Thor::Error, "lemans-remote: snapshot #{provisioner.name} not found — run `lemans-remote provision` first"
739
- end
752
+ raise Thor::Error, "lemans-remote: snapshot #{provisioner.name} not found — run `lemans-remote provision` first" unless provisioner.provisioned?
740
753
 
741
754
  attempts = options[:attempts].to_i
742
755
  raise Thor::Error, "lemans-remote: --attempts must be at least 1" if attempts < 1
743
756
  raise Thor::Error, "lemans-remote: --attempts needs async mode — drop --sync" if options[:sync] && attempts > 1
757
+ raise Thor::Error, "lemans-remote: --concurrency must be at least 1" if options[:concurrency].to_i < 1
744
758
 
745
759
  models = options[:model] || []
746
760
  batches = plan_batches(bench, tasks)
@@ -756,7 +770,7 @@ module LemansRemote
756
770
  end
757
771
 
758
772
  fanout = attempts > 1 ? "#{batches.size} task batch(es) × #{attempts} attempts" : "one per task"
759
- say_status :fanout, "#{jobs.size} sandboxes, #{fanout}"
773
+ say_status :fanout, "#{jobs.size} sandboxes, #{fanout}, #{options[:concurrency].to_i} at a time"
760
774
  failures = launch_batches(bench, jobs, models, provisioner)
761
775
  say_status :detached, "`lemans-remote status` to watch, `lemans-remote pull-runs` to fetch results"
762
776
  raise Thor::Error, "lemans-remote: failed to launch: #{failures.join("; ")}" if failures.any?
@@ -768,32 +782,27 @@ module LemansRemote
768
782
  option :history, type: :boolean, default: false, desc: "Also read the vault manifests (spins a short-lived helper sandbox)"
769
783
  option :running, type: :boolean, default: false, desc: "Only show runs still in flight"
770
784
  option :complete, type: :boolean, default: false, desc: "Only show finished runs (success, failed, or stale)"
785
+ option :watch, type: :boolean, default: false, aliases: "-W", desc: "Redraw the table every 5 seconds until interrupted"
771
786
  def status
772
- rows = Fleet.rows.map { [_1.run_id, _1.status, _1.started_at || "?", _1.state, _1.sandbox.id] }
773
- if options[:history]
774
- seen = rows.map(&:first)
775
- manifests = with_vault { |vault, sandbox| vault.manifests(sandbox) }
776
- manifests.reject { seen.include?(_1["run_id"]) }.each do |manifest|
777
- note = manifest["pulled_at"] ? "pulled" : "-"
778
- rows << [manifest["run_id"], manifest["status"], manifest["started_at"] || "?", "gone", note]
779
- end
780
- end
781
-
782
- filtered = options[:running] ^ options[:complete]
783
- rows.select! { (_1[1] == "running") == options[:running] } if filtered
787
+ no_watch_history = "lemans-remote: --watch cannot be combined with --history (each refresh would spin a helper sandbox)"
788
+ raise Thor::Error, no_watch_history if options[:watch] && options[:history]
789
+ return print_status unless options[:watch]
784
790
 
785
- if rows.empty?
786
- if filtered
787
- say "no #{options[:running] ? "running" : "complete"} lemans-remote runs"
788
- else
789
- say "no lemans-remote runs#{options[:history] ? "" : " — try --history for completed ones"}"
791
+ loop do
792
+ print "\e[H\e[2J"
793
+ say "lemans-remote status — #{Time.now.strftime("%H:%M:%S")}, refreshing every 5s (^C to stop)"
794
+ begin
795
+ unless print_status
796
+ say_status :done, "no running sandboxes — stopping watch", :green
797
+ break
798
+ end
799
+ rescue StandardError => e
800
+ say_status :error, e.message, :red
790
801
  end
791
- return
802
+ sleep 5
792
803
  end
793
-
794
- rows.sort_by! { _1[2].to_s }
795
- rows.reverse!
796
- print_table([%w[run status started sandbox id]] + rows)
804
+ rescue Interrupt
805
+ say ""
797
806
  rescue Lemans::ConfigError, RuntimeError => e
798
807
  raise Thor::Error, "lemans-remote: #{e.message}"
799
808
  end
@@ -801,6 +810,7 @@ module LemansRemote
801
810
  map "pull-runs" => :pull_runs
802
811
  desc "pull-runs [RUN_IDS...]", "Download archived runs from the vault into the local runs directory"
803
812
  option :all, type: :boolean, default: false, desc: "Pull every completed run, even ones already pulled"
813
+ option :dry_run, type: :boolean, default: false, desc: "Preview what would be downloaded without pulling anything"
804
814
  option :runs_dir, default: "runs", desc: "Local directory to sync the results into"
805
815
  def pull_runs(*run_ids)
806
816
  pulled = []
@@ -821,6 +831,16 @@ module LemansRemote
821
831
  say_status :pull, "nothing new to pull (use --all to re-pull)", :yellow if targets.empty?
822
832
 
823
833
  targets.each do |run_id|
834
+ if options[:dry_run]
835
+ size = vault.archive_size(sandbox, run_id)
836
+ if size
837
+ say_status :would_pull, "#{run_id} (#{size})", :cyan
838
+ else
839
+ say_status :missing, "#{run_id} has no runs.tar.gz (still running?)", :yellow
840
+ end
841
+ next
842
+ end
843
+
824
844
  results = vault.download_runs(sandbox, run_id, options[:runs_dir])
825
845
  if results
826
846
  vault.mark_pulled(sandbox, run_id)
@@ -846,9 +866,7 @@ module LemansRemote
846
866
 
847
867
  live = Fleet.live
848
868
  if options[:all]
849
- unless live.empty?
850
- raise Thor::Error, "lemans-remote: #{live.size} run(s) still live (#{live.map(&:run_id).join(", ")}) — wait for them to finish"
851
- end
869
+ raise Thor::Error, "lemans-remote: #{live.size} run(s) still live (#{live.map(&:run_id).join(", ")}) — wait for them to finish" unless live.empty?
852
870
  return unless options[:force] || yes?("Delete the whole #{Vault::NAME} volume and every stopped run sandbox? [y/N]")
853
871
 
854
872
  Fleet.rows.each { safe_delete_sandbox(_1.sandbox) }
@@ -876,6 +894,54 @@ module LemansRemote
876
894
  raise Thor::Error, "lemans-remote: #{e.message}"
877
895
  end
878
896
 
897
+ map "drop-orphans" => :drop_orphans
898
+ desc "drop-orphans", "Delete run sandboxes whose launch died mid-flight (forever running, nothing uploaded)"
899
+ option :min_age, default: "10m", desc: "Leave younger runs alone — they may still be uploading"
900
+ option :force, type: :boolean, default: false, aliases: "-f", desc: "Skip the confirmation"
901
+ def drop_orphans
902
+ cutoff = Time.now.utc - seconds!(options[:min_age])
903
+ candidates = Fleet.live.select do |row|
904
+ row.status == "running" && row.started_at && Time.parse(row.started_at) <= cutoff
905
+ end
906
+ orphans = candidates.select { orphan?(_1.sandbox) }
907
+ if orphans.empty?
908
+ say_status :clean, "no orphaned run sandboxes", :green
909
+ return
910
+ end
911
+
912
+ orphans.each { say_status :orphan, "#{_1.run_id} (#{_1.sandbox.id}, started #{_1.started_at})", :yellow }
913
+ return unless options[:force] || yes?("Delete #{orphans.size} orphaned sandbox(es)? [y/N]")
914
+
915
+ orphans.each do |row|
916
+ safe_delete_sandbox(row.sandbox)
917
+ say_status :dropped, row.run_id, :green
918
+ end
919
+ rescue Lemans::ConfigError, RuntimeError => e
920
+ raise Thor::Error, "lemans-remote: #{e.message}"
921
+ end
922
+
923
+ desc "snapshots", "List lemans-remote orchestrator snapshots"
924
+ def snapshots
925
+ current = build_provisioner.name
926
+ rows = all_snapshots.select { _1.name.start_with?("#{LABEL}-") }.map do |snap|
927
+ shape = "#{snap.cpu.to_i}cpu/#{snap.mem.to_i}gb/#{snap.disk.to_i}gb"
928
+ [snap.name, snap.state, shape, snap.created_at.to_s[0, 19], snap.last_used_at.to_s[0, 19],
929
+ snap.name == current ? "current" : "-"]
930
+ end
931
+
932
+ if rows.empty?
933
+ say "no lemans-remote snapshots — run `lemans-remote provision` first"
934
+ return
935
+ end
936
+
937
+ rows.sort_by! { _1[3].to_s }
938
+ rows.reverse!
939
+ print_table([%w[snapshot state shape created last-used note]] + rows)
940
+ say "delete old ones with `lemans-remote deprovision --name SNAPSHOT`"
941
+ rescue Lemans::ConfigError, RuntimeError => e
942
+ raise Thor::Error, "lemans-remote: #{e.message}"
943
+ end
944
+
879
945
  desc "deprovision", "Delete the orchestrator snapshot"
880
946
  option :name, desc: "Delete a specific snapshot by name (default: the one for this shape and version)"
881
947
  def deprovision
@@ -897,12 +963,41 @@ module LemansRemote
897
963
 
898
964
  private
899
965
 
966
+ def print_status
967
+ rows = Fleet.rows.map { [_1.run_id, _1.status, _1.started_at || "?", _1.state, _1.sandbox.id] }
968
+ if options[:history]
969
+ seen = rows.map(&:first)
970
+ manifests = with_vault { |vault, sandbox| vault.manifests(sandbox) }
971
+ manifests.reject { seen.include?(_1["run_id"]) }.each do |manifest|
972
+ note = manifest["pulled_at"] ? "pulled" : "-"
973
+ rows << [manifest["run_id"], manifest["status"], manifest["started_at"] || "?", "gone", note]
974
+ end
975
+ end
976
+
977
+ any_running = rows.any? { _1[1] == "running" }
978
+
979
+ filtered = options[:running] ^ options[:complete]
980
+ rows.select! { (_1[1] == "running") == options[:running] } if filtered
981
+
982
+ if rows.empty?
983
+ if filtered
984
+ say "no #{options[:running] ? "running" : "complete"} lemans-remote runs"
985
+ else
986
+ say "no lemans-remote runs#{" — try --history for completed ones" unless options[:history]}"
987
+ end
988
+ return any_running
989
+ end
990
+
991
+ rows.sort_by! { _1[2].to_s }
992
+ rows.reverse!
993
+ print_table([%w[run status started sandbox id]] + rows)
994
+ any_running
995
+ end
996
+
900
997
  def build_provisioner
901
998
  Provisioner.new(cpus: options[:cpus].to_i, memory_gb: options[:memory].to_i, disk_gb: options[:disk].to_i)
902
999
  end
903
1000
 
904
- FANOUT_THREADS = 4
905
-
906
1001
  def plan_batches(bench, tasks)
907
1002
  return [tasks] if options[:sync] || options[:run_in_band]
908
1003
 
@@ -919,7 +1014,7 @@ module LemansRemote
919
1014
  models: models,
920
1015
  extra_args: options[:args].strip,
921
1016
  extra_env: options[:env] || [],
922
- timeout_sec: Lemans::Units.seconds(options[:timeout], field: "--timeout").to_i,
1017
+ timeout_sec: seconds!(options[:timeout]).to_i,
923
1018
  runs_dir: options[:runs_dir],
924
1019
  keep: options[:keep],
925
1020
  sync: options[:sync],
@@ -932,7 +1027,7 @@ module LemansRemote
932
1027
  jobs.each { queue << _1 }
933
1028
  failures = Queue.new
934
1029
 
935
- threads = [FANOUT_THREADS, jobs.size].min.times.map do
1030
+ threads = [options[:concurrency].to_i, jobs.size].min.times.map do
936
1031
  Thread.new do
937
1032
  loop do
938
1033
  batch, attempt =
@@ -957,7 +1052,7 @@ module LemansRemote
957
1052
  end
958
1053
 
959
1054
  def build_run_id(bench, tasks, models, attempt = nil)
960
- parts = [bench.root.basename.to_s]
1055
+ parts = [bench.root.expand_path.basename.to_s]
961
1056
  parts << (tasks.size == 1 ? tasks.first : "#{tasks.size}tasks") if tasks.any?
962
1057
  if models.any?
963
1058
  short = models.first.split("/").last
@@ -971,13 +1066,24 @@ module LemansRemote
971
1066
  parts.map { name_part(_1) }.reject(&:empty?).join("-")
972
1067
  end
973
1068
 
974
- def name_part(value) = value.to_s.downcase.gsub(/[^a-z0-9._+]+/, "-").gsub(/\A-+|-+\z/, "")
1069
+ def name_part(value) = value.to_s.downcase.gsub(/[^a-z0-9._+]+/, "-").gsub(/\A[-.]+|[-.]+\z/, "")
1070
+
1071
+ def all_snapshots
1072
+ items = []
1073
+ page = 1
1074
+ loop do
1075
+ result = LemansRemote.client.snapshot.list(page: page, limit: 200)
1076
+ items.concat(result.items)
1077
+ break if page >= result.total_pages.to_i
1078
+
1079
+ page += 1
1080
+ end
1081
+ items
1082
+ end
975
1083
 
976
1084
  def with_vault(&block)
977
1085
  provisioner = build_provisioner
978
- unless provisioner.provisioned?
979
- raise Thor::Error, "lemans-remote: snapshot #{provisioner.name} not found — run `lemans-remote provision` first"
980
- end
1086
+ raise Thor::Error, "lemans-remote: snapshot #{provisioner.name} not found — run `lemans-remote provision` first" unless provisioner.provisioned?
981
1087
 
982
1088
  vault = VaultClient.new(snapshot: provisioner.name)
983
1089
  vault.with_helper { |sandbox| block.call(vault, sandbox) }
@@ -988,6 +1094,15 @@ module LemansRemote
988
1094
  rescue StandardError => e
989
1095
  warn "lemans-remote: could not delete sandbox #{sandbox.id}: #{e.message}"
990
1096
  end
1097
+
1098
+ # Every launched run extracts the bench into /task within minutes of
1099
+ # creation (async ones also upload the wrapper); a sandbox whose launcher
1100
+ # process died mid-flight has neither, forever.
1101
+ def orphan?(sandbox)
1102
+ !sandbox.process.exec(command: "test -d #{REMOTE_BENCH_DIR} -o -f #{REMOTE_WRAPPER}", timeout: 60).exit_code.zero?
1103
+ rescue StandardError
1104
+ false
1105
+ end
991
1106
  end
992
1107
  end
993
1108
 
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lemans
4
+ # What the harness asks of an agent: install yourself, then work on the
5
+ # task. The name-to-class registry lives on the Agents module.
6
+ class Agent
7
+ # What one run of an agent produced. Outcome and usage are the core
8
+ # Result's parts; trajectory, the raw self-reported result, and the error
9
+ # are optional — the trial persists them, agents only carry them.
10
+ Response = Data.define(:outcome, :usage, :trajectory, :error, :raw_result) do
11
+ def initialize(outcome: nil, usage: nil, trajectory: nil, error: nil, raw_result: nil) = super
12
+
13
+ def error? = !error.nil?
14
+ end
15
+
16
+ attr_reader :profile, :model
17
+
18
+ def initialize(profile:, model: nil)
19
+ @profile = profile
20
+
21
+ @model = model || profile.model
22
+ end
23
+
24
+ def name = self.class::NAME
25
+
26
+ # Run before the agent phase's network policy narrows, so an agent that
27
+ # pulls its own runtime can still reach a package index.
28
+ def install(_task, _environment) = nil
29
+
30
+ # Run the task.
31
+ def run(task, environment)
32
+ raise NotImplementedError
33
+ end
34
+
35
+ private
36
+
37
+ def timeout = profile.timeout
38
+ end
39
+ end
@@ -7,9 +7,8 @@ module Lemans
7
7
  module Agents
8
8
  # The harness adapter for Miniswen::Agent. The loop runs harness-side, so
9
9
  # there is nothing to install and no model API in the sandbox allowlist.
10
- class Miniswen < Base
10
+ class Miniswen < Agent
11
11
  NAME = "miniswen"
12
- TRAJECTORY_FILENAME = "trajectory.json"
13
12
 
14
13
  OUTCOME_FOR_STATUS = {
15
14
  submitted: :completed,
@@ -22,22 +21,25 @@ module Lemans
22
21
  cost_limit: :cost_ceiling_reached
23
22
  }.freeze
24
23
 
25
- def call(environment, task:, logs_dir:)
26
- result = obtain_result(environment, task: task, logs_dir: logs_dir)
27
- trajectory_path = write_trajectory(logs_dir, result)
24
+ def run(task, environment)
25
+ run_result = obtain_result(task, environment)
26
+ trajectory = trajectory_for(run_result)
28
27
 
29
- raise ::Miniswen::InfrastructureError, result.error if result.status == :error
28
+ # A failed model call is still an answer: the trial saves the
29
+ # trajectory as evidence before failing.
30
+ return Response.new(trajectory:, raw_result:, error: run_result.error) if run_result.status == :error
30
31
 
31
- Result.new(
32
- outcome: Results::Outcome.new(OUTCOME_FOR_STATUS.fetch(result.status), detail: detail_for(result)),
33
- usage: usage_for(result),
34
- trajectory: trajectory_path
32
+ Response.new(
33
+ outcome: Result::Outcome.new(OUTCOME_FOR_STATUS.fetch(run_result.status), detail_for(run_result)),
34
+ usage: usage_for(run_result),
35
+ trajectory:,
36
+ raw_result:
35
37
  )
36
38
  end
37
39
 
38
40
  private
39
41
 
40
- def obtain_result(environment, task:, logs_dir:) # rubocop:disable Lint/UnusedMethodArgument
42
+ def obtain_result(task, environment)
41
43
  agent = agent_for(environment)
42
44
  begin
43
45
  agent.run(task.instruction)
@@ -46,6 +48,8 @@ module Lemans
46
48
  end
47
49
  end
48
50
 
51
+ def raw_result = nil
52
+
49
53
  def agent_for(environment)
50
54
  raise ConfigError, "miniswen needs a model to drive" if model.to_s.empty?
51
55
 
@@ -53,9 +57,9 @@ module Lemans
53
57
  model: model.to_s,
54
58
  environment: environment,
55
59
  max_steps: profile.step_limit,
56
- max_time: profile.timeout_sec,
60
+ max_time: profile.timeout,
57
61
  max_cost: profile.cost_limit,
58
- exec_timeout: profile.exec_timeout_sec
62
+ exec_timeout: profile.exec_timeout
59
63
  )
60
64
  end
61
65
 
@@ -76,7 +80,7 @@ module Lemans
76
80
  }
77
81
  # Only a run that never called the model spent nothing; a zero count
78
82
  # on a run that did is missing data, not a free run.
79
- return Results::Usage.zero if result.steps.zero?
83
+ return Result::Usage.zero if result.steps.zero?
80
84
 
81
85
  if result.cost_usd.nil?
82
86
  raise ::Miniswen::AccountingError,
@@ -84,23 +88,22 @@ module Lemans
84
88
  "#{result.output_tokens} output tokens cannot be reported as $0.00"
85
89
  end
86
90
 
87
- Results::Usage.new(**totals, cost_usd: result.cost_usd, cost_source: result.cost_source)
91
+ Result::Usage.new(
92
+ **totals,
93
+ cost_usd: result.cost_usd,
94
+ # FIXME: need a better way to map Miniswen's cost source to Lemans'
95
+ cost_source: Result::CostSource.new(**result.cost_source.to_h)
96
+ )
88
97
  end
89
98
 
90
- def write_trajectory(logs_dir, result)
91
- trajectory = ::Miniswen::Trajectory.from(
99
+ def trajectory_for(result)
100
+ ::Miniswen::Trajectory.from(
92
101
  result,
93
102
  model: model,
94
- session_id: session_id_for(logs_dir),
95
103
  agent: { name: name, version: VERSION, extra: agent_extra }
96
104
  )
97
- path = logs_dir.join(TRAJECTORY_FILENAME)
98
- path.write(JSON.pretty_generate(trajectory.to_atif))
99
- path
100
105
  end
101
106
 
102
- def session_id_for(logs_dir) = Pathname(logs_dir).basename.to_s
103
-
104
107
  # What the trajectory cannot be read without: the prompts the model saw
105
108
  # and the budget it worked under
106
109
  def agent_extra
@@ -109,8 +112,8 @@ module Lemans
109
112
  instance_template: ::Miniswen::Agent::INSTANCE_TEMPLATE,
110
113
  step_limit: profile.step_limit,
111
114
  cost_limit: profile.cost_limit,
112
- wall_time_limit_seconds: profile.timeout_sec,
113
- exec_timeout_seconds: profile.exec_timeout_sec,
115
+ wall_time_limit_seconds: profile.timeout,
116
+ exec_timeout_seconds: profile.exec_timeout,
114
117
  max_consecutive_format_errors: ::Miniswen::Agent::MAX_CONSECUTIVE_FORMAT_ERRORS
115
118
  }.compact }
116
119
  end