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.
- checksums.yaml +4 -4
- data/README.md +30 -10
- data/exe/lemans-remote +161 -46
- data/lib/lemans/agent.rb +39 -0
- data/lib/lemans/agents/miniswen.rb +28 -25
- data/lib/lemans/agents/miniswen_installed.rb +13 -9
- data/lib/lemans/agents/nop.rb +3 -3
- data/lib/lemans/agents/oracle.rb +8 -8
- data/lib/lemans/cli/board_reporter.rb +16 -16
- data/lib/lemans/cli/progress_reporter.rb +19 -19
- data/lib/lemans/cli/report/aggregate.rb +117 -0
- data/lib/lemans/cli/report.rb +164 -0
- data/lib/lemans/cli.rb +58 -63
- data/lib/lemans/clobber.rb +17 -55
- data/lib/lemans/config/agent.rb +51 -0
- data/lib/lemans/config/conversion.rb +62 -0
- data/lib/lemans/config/environment.rb +39 -0
- data/lib/lemans/config/image_spec.rb +42 -0
- data/lib/lemans/config/network_policy.rb +55 -0
- data/lib/lemans/config/revision.rb +42 -0
- data/lib/lemans/config/setup.rb +57 -0
- data/lib/lemans/config/tree_digest.rb +26 -0
- data/lib/lemans/config/verifier.rb +72 -0
- data/lib/lemans/config.rb +107 -0
- data/lib/lemans/environment.rb +54 -0
- data/lib/lemans/environments/daytona/faraday_transfer.rb +172 -0
- data/lib/lemans/environments/daytona/sdk_tweaks.rb +1 -1
- data/lib/lemans/environments/daytona/shell.rb +1 -1
- data/lib/lemans/environments/daytona/snapshot_store.rb +11 -11
- data/lib/lemans/environments/daytona.rb +21 -35
- data/lib/lemans/result.rb +270 -0
- data/lib/lemans/runner/executor.rb +64 -0
- data/lib/lemans/runner/task.rb +62 -0
- data/lib/lemans/runner.rb +82 -0
- data/lib/lemans/store.rb +44 -0
- data/lib/lemans/stores/fs.rb +122 -0
- data/lib/lemans/task_definition.rb +194 -0
- data/lib/lemans/trial/patch.rb +76 -0
- data/lib/lemans/trial/setup.rb +66 -0
- data/lib/lemans/trial/snapshot.rb +57 -0
- data/lib/lemans/trial/verifier.rb +190 -0
- data/lib/lemans/trial.rb +113 -147
- data/lib/lemans/version.rb +1 -1
- data/lib/lemans.rb +3 -2
- data/lib/miniswen/trajectory.rb +2 -0
- metadata +58 -25
- data/lib/lemans/agents/base.rb +0 -30
- data/lib/lemans/bench.rb +0 -280
- data/lib/lemans/environments/base.rb +0 -55
- data/lib/lemans/network_policy.rb +0 -66
- data/lib/lemans/patch.rb +0 -70
- data/lib/lemans/restore_paths.rb +0 -21
- data/lib/lemans/results/aggregate.rb +0 -114
- data/lib/lemans/results/cost_source.rb +0 -13
- data/lib/lemans/results/outcome.rb +0 -36
- data/lib/lemans/results/report.rb +0 -149
- data/lib/lemans/results/sorting.rb +0 -24
- data/lib/lemans/results/tally.rb +0 -19
- data/lib/lemans/results/usage.rb +0 -24
- data/lib/lemans/run.rb +0 -152
- data/lib/lemans/setup.rb +0 -59
- data/lib/lemans/setup_files.rb +0 -36
- data/lib/lemans/snapshot.rb +0 -55
- data/lib/lemans/task.rb +0 -207
- data/lib/lemans/tree_digest.rb +0 -24
- data/lib/lemans/units.rb +0 -44
- data/lib/lemans/verifier.rb +0 -199
- /data/lib/lemans/{verifier → trial/verifier}/assets/eport-lemans.rb +0 -0
- /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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 9696bf1ad08f76e0a6faafd5651d67e1a98ca6e36e189ab41f71bdcb5f5f173e
|
|
4
|
+
data.tar.gz: 02e255c8829342789f9a057c1234a3a45bb2ca366db832caedb43d42dd0d7c5b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
|
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
|
|
168
|
-
ac-throttle-search miniswen-installed gpt-5.6-luna 1 completed 0.0112 11 118561 137.1
|
|
169
|
-
ac-throttle-search miniswen-installed gpt-5.6-luna 1 completed 0.0161 12 157111 144.1
|
|
170
|
-
ar-announce-once miniswen-installed gpt-5.6-luna 1 completed 0.0157 13 162432 186
|
|
171
|
-
ar-announce-once miniswen-installed gpt-5.6-luna 1 completed 0.0106 11 118691 117.8
|
|
172
|
-
ar-archive-book-access miniswen-installed gpt-5.6-luna 1 completed 0.0128 12 149867 136.2
|
|
173
|
-
ar-archive-book-access miniswen-installed gpt-5.6-luna 1 completed 0.0137 13 163943 149.6
|
|
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
|
-
|
|
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:
|
|
696
|
-
class_option :memory, type: :numeric, default:
|
|
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
|
-
|
|
773
|
-
if options[:history]
|
|
774
|
-
|
|
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
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
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
|
-
|
|
802
|
+
sleep 5
|
|
792
803
|
end
|
|
793
|
-
|
|
794
|
-
|
|
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:
|
|
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 = [
|
|
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
|
|
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
|
|
data/lib/lemans/agent.rb
ADDED
|
@@ -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 <
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
def run(task, environment)
|
|
25
|
+
run_result = obtain_result(task, environment)
|
|
26
|
+
trajectory = trajectory_for(run_result)
|
|
28
27
|
|
|
29
|
-
|
|
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
|
-
|
|
32
|
-
outcome:
|
|
33
|
-
usage: usage_for(
|
|
34
|
-
trajectory
|
|
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(
|
|
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.
|
|
60
|
+
max_time: profile.timeout,
|
|
57
61
|
max_cost: profile.cost_limit,
|
|
58
|
-
exec_timeout: profile.
|
|
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
|
|
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
|
-
|
|
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
|
|
91
|
-
|
|
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.
|
|
113
|
-
exec_timeout_seconds: profile.
|
|
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
|