lemans 1.1.0 → 1.3.0
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 +15 -0
- data/README.md +14 -4
- data/exe/lemans-remote +22 -10
- data/lib/lemans/agents/miniswen.rb +2 -2
- data/lib/lemans/agents/oracle.rb +13 -7
- data/lib/lemans/cli/progress_reporter.rb +6 -1
- data/lib/lemans/cli/regrade.rb +193 -0
- data/lib/lemans/cli/report/aggregate.rb +18 -11
- data/lib/lemans/cli/report.rb +25 -8
- data/lib/lemans/cli/templates/bench/README.md +7 -2
- data/lib/lemans/cli/templates/bench/tasks/example-task/instruction.md +20 -9
- data/lib/lemans/cli/templates/bench/tasks/example-task/solution.1.patch +17 -0
- data/lib/lemans/cli/templates/bench/tasks/example-task/solution.2.patch +17 -0
- data/lib/lemans/cli/templates/bench/tasks/example-task/verification_test.1.rb +31 -0
- data/lib/lemans/cli/templates/bench/tasks/example-task/verification_test.rb +1 -1
- data/lib/lemans/cli.rb +38 -1
- data/lib/lemans/config/agent.rb +12 -0
- data/lib/lemans/config/environment.rb +31 -2
- data/lib/lemans/config/network_policy.rb +2 -0
- data/lib/lemans/config/setup.rb +6 -1
- data/lib/lemans/config/verifier.rb +18 -7
- data/lib/lemans/config.rb +45 -10
- data/lib/lemans/environment.rb +6 -3
- data/lib/lemans/environments/daytona/retries.rb +5 -3
- data/lib/lemans/environments/daytona/shell.rb +11 -1
- data/lib/lemans/environments/daytona.rb +16 -6
- data/lib/lemans/environments/docker.rb +3 -3
- data/lib/lemans/ext/deep_merge.rb +13 -0
- data/lib/lemans/result.rb +57 -7
- data/lib/lemans/runner.rb +1 -1
- data/lib/lemans/store.rb +7 -1
- data/lib/lemans/stores/fs.rb +9 -3
- data/lib/lemans/task_definition.rb +158 -10
- data/lib/lemans/trial/patch.rb +46 -8
- data/lib/lemans/trial/verifier/assets/eport-lemans.rb +6 -0
- data/lib/lemans/trial/verifier/assets/lemans_minitest_reporter.rb +44 -3
- data/lib/lemans/trial/verifier.rb +30 -3
- data/lib/lemans/trial.rb +89 -33
- data/lib/lemans/version.rb +1 -1
- data/lib/miniswen/agent.rb +21 -2
- data/lib/miniswen/version.rb +1 -1
- metadata +6 -2
- data/lib/lemans/cli/templates/bench/tasks/example-task/solution.patch +0 -7
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
diff --git a/fizzbuzz.rb b/fizzbuzz.rb
|
|
2
|
+
index 345b032..98ae4cf 100644
|
|
3
|
+
--- a/fizzbuzz.rb
|
|
4
|
+
+++ b/fizzbuzz.rb
|
|
5
|
+
@@ -1 +1,11 @@
|
|
6
|
+
-1.upto(100) { |i| puts i }
|
|
7
|
+
+1.upto(100) do |i|
|
|
8
|
+
+ if i % 15 == 0
|
|
9
|
+
+ puts "FizzBuzz"
|
|
10
|
+
+ elsif i % 3 == 0
|
|
11
|
+
+ puts "Fizz"
|
|
12
|
+
+ elsif i % 5 == 0
|
|
13
|
+
+ puts "Buzz"
|
|
14
|
+
+ else
|
|
15
|
+
+ puts i
|
|
16
|
+
+ end
|
|
17
|
+
+end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
diff --git a/fizzbuzz.rb b/fizzbuzz.rb
|
|
2
|
+
index 98ae4cf..2c15f7d 100644
|
|
3
|
+
--- a/fizzbuzz.rb
|
|
4
|
+
+++ b/fizzbuzz.rb
|
|
5
|
+
@@ -1,11 +1 @@
|
|
6
|
+
-1.upto(100) do |i|
|
|
7
|
+
- if i % 15 == 0
|
|
8
|
+
- puts "FizzBuzz"
|
|
9
|
+
- elsif i % 3 == 0
|
|
10
|
+
- puts "Fizz"
|
|
11
|
+
- elsif i % 5 == 0
|
|
12
|
+
- puts "Buzz"
|
|
13
|
+
- else
|
|
14
|
+
- puts i
|
|
15
|
+
- end
|
|
16
|
+
-end
|
|
17
|
+
+1.upto(100) { |i| s = "#{"Fizz" if i % 3 == 0}#{"Buzz" if i % 5 == 0}"; puts s.empty? ? i : s }
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "minitest/autorun"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
6
|
+
# Step 1 is gated on correctness alone; the size budget is step 2's problem.
|
|
7
|
+
class FizzbuzzSequenceTest < Minitest::Test
|
|
8
|
+
SOURCE = "/app/fizzbuzz.rb"
|
|
9
|
+
EXPECTED = "/app/expected.txt"
|
|
10
|
+
|
|
11
|
+
def test_the_sequence_matches
|
|
12
|
+
expected = File.read(EXPECTED)
|
|
13
|
+
|
|
14
|
+
# The program must compute the sequence, not read it back: it runs from a
|
|
15
|
+
# scratch directory with expected.txt hidden away.
|
|
16
|
+
output = hiding(EXPECTED) do
|
|
17
|
+
Dir.mktmpdir { |scratch| Dir.chdir(scratch) { IO.popen([ "ruby", SOURCE ], &:read) } }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
assert_equal expected, output
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def hiding(path)
|
|
26
|
+
File.rename(path, "#{path}.hidden")
|
|
27
|
+
yield
|
|
28
|
+
ensure
|
|
29
|
+
File.rename("#{path}.hidden", path) if File.exist?("#{path}.hidden")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -13,7 +13,7 @@ class FizzbuzzTest < Minitest::Test
|
|
|
13
13
|
# The program must compute the sequence, not read it back: it runs from a
|
|
14
14
|
# scratch directory with expected.txt hidden away.
|
|
15
15
|
output = hiding(EXPECTED) do
|
|
16
|
-
Dir.mktmpdir { |scratch| Dir.chdir(scratch) { IO.popen(["ruby", SOURCE], &:read) } }
|
|
16
|
+
Dir.mktmpdir { |scratch| Dir.chdir(scratch) { IO.popen([ "ruby", SOURCE ], &:read) } }
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
assert_equal expected, output
|
data/lib/lemans/cli.rb
CHANGED
|
@@ -127,17 +127,50 @@ module Lemans
|
|
|
127
127
|
raise Thor::Error, "lemans: #{e.message}"
|
|
128
128
|
end
|
|
129
129
|
|
|
130
|
+
desc "regrade", "Re-grade stored results from their checks.json after a verification_test.rb grading change"
|
|
131
|
+
option :bench, default: ".", desc: "Directory holding bench.yml"
|
|
132
|
+
option :task, desc: "Re-grade these tasks' runs", repeatable: true, required: true
|
|
133
|
+
option :runs_dir, default: "./runs", desc: "Directory holding run directories"
|
|
134
|
+
option :mapping, banner: "PATH",
|
|
135
|
+
desc: "Grade by this checks.json-shaped file (every check `fail` or `fail (allowed)`, plus `grading`) " \
|
|
136
|
+
"instead of reading verification_test.rb"
|
|
137
|
+
def regrade
|
|
138
|
+
store = Stores::FS.new(options[:runs_dir])
|
|
139
|
+
tasks = filter_tasks(Config.load_file(options[:bench]).tasks, name: options[:task])
|
|
140
|
+
raise Thor::Error, "lemans: --mapping re-grades one task at a time" if options[:mapping] && tasks.size > 1
|
|
141
|
+
|
|
142
|
+
tasks.each do |task|
|
|
143
|
+
mapping = options[:mapping] ? Regrade.mapping_from_file(options[:mapping]) : Regrade.mapping_for(task)
|
|
144
|
+
regrade = Regrade.new(store, task.name, mapping:)
|
|
145
|
+
regrade.verify_mapping! unless options[:mapping]
|
|
146
|
+
|
|
147
|
+
changes, skipped = regrade.execute!
|
|
148
|
+
changes.each { say_status :regraded, "#{it.result.id} #{grade_change(it)}", :green }
|
|
149
|
+
skipped.each { |result, reason| say_status :skipped, "#{result.id} #{reason}", :yellow }
|
|
150
|
+
say "#{task.name}: #{changes.size} re-graded, #{skipped.size} skipped"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
say ""
|
|
154
|
+
say_status :report, "collecting results from #{options[:runs_dir]}", :cyan
|
|
155
|
+
print_report Report.load(store, names: tasks.map(&:name))
|
|
156
|
+
rescue ConfigError => e
|
|
157
|
+
raise Thor::Error, "lemans: #{e.message}"
|
|
158
|
+
end
|
|
159
|
+
|
|
130
160
|
desc "report", "Summarize run results as a table or CSV"
|
|
131
161
|
option :runs_dir, default: "runs", desc: "Directory holding run directories"
|
|
132
162
|
option :tag, desc: "Only runs whose result carries this tag", repeatable: true
|
|
133
163
|
option :task, desc: "Only these tasks' runs", repeatable: true
|
|
164
|
+
option :metadata, banner: "KEY:VALUE", desc: "Only runs whose task metadata has this value (every pair must match)",
|
|
165
|
+
repeatable: true
|
|
134
166
|
option :format, default: "table", enum: %w[table csv], desc: "Output format"
|
|
135
167
|
option :aggregate, aliases: "-A", banner: "COLUMNS", lazy_default: "task-model",
|
|
136
168
|
desc: "Group results by 1-3 dash-joined columns (task, agent, model)"
|
|
137
169
|
option :sort, aliases: "-S", banner: "COLUMN", desc: "Sort by a column"
|
|
138
170
|
def report
|
|
139
171
|
store = Stores::FS.new(options[:runs_dir])
|
|
140
|
-
results = Report.load(store, tags: options[:tag], names: options[:task]
|
|
172
|
+
results = Report.load(store, tags: options[:tag], names: options[:task],
|
|
173
|
+
metadata: Report.metadata_filter(options[:metadata]))
|
|
141
174
|
raise Thor::Error, "lemans: no matching results found" if results.empty?
|
|
142
175
|
|
|
143
176
|
results = Report::Aggregate.new(results, keys: Report::Aggregate.keys(options[:aggregate])) if options[:aggregate]
|
|
@@ -163,6 +196,10 @@ module Lemans
|
|
|
163
196
|
raise Thor::Error, "lemans: no matching tasks"
|
|
164
197
|
end
|
|
165
198
|
|
|
199
|
+
def grade_change(change)
|
|
200
|
+
%i[reward credit].map { |grade| "#{grade} #{change[grade].map(&:inspect).join(" -> ")}" }.join(" ")
|
|
201
|
+
end
|
|
202
|
+
|
|
166
203
|
def print_report(report)
|
|
167
204
|
print_table report.to_rows
|
|
168
205
|
color = report.summary[:invalid].positive? ? :red : nil
|
data/lib/lemans/config/agent.rb
CHANGED
|
@@ -35,6 +35,18 @@ module Lemans
|
|
|
35
35
|
|
|
36
36
|
def model = models.first
|
|
37
37
|
|
|
38
|
+
def to_h
|
|
39
|
+
{
|
|
40
|
+
"name" => name,
|
|
41
|
+
"model" => models,
|
|
42
|
+
"timeout" => timeout,
|
|
43
|
+
"step_limit" => step_limit,
|
|
44
|
+
"cost_limit" => cost_limit,
|
|
45
|
+
"exec_timeout" => exec_timeout,
|
|
46
|
+
"environment" => { "network" => environment.network.to_h }
|
|
47
|
+
}.compact
|
|
48
|
+
end
|
|
49
|
+
|
|
38
50
|
Environment = Struct.new(:network, keyword_init: true)
|
|
39
51
|
|
|
40
52
|
def initialize(name, model)
|
|
@@ -4,6 +4,7 @@ module Lemans
|
|
|
4
4
|
class Config
|
|
5
5
|
class Environment # :nodoc:
|
|
6
6
|
Resources = Struct.new(:cpus, :memory, :storage, keyword_init: true)
|
|
7
|
+
Profile = Struct.new(:image, :dockerfile, keyword_init: true)
|
|
7
8
|
|
|
8
9
|
class << self
|
|
9
10
|
include Conversion
|
|
@@ -15,10 +16,12 @@ module Lemans
|
|
|
15
16
|
conf.backend = data["backend"] if data["backend"]
|
|
16
17
|
conf.image = data["image"] if data["image"]
|
|
17
18
|
conf.dockerfile = data["dockerfile"] if data["dockerfile"]
|
|
18
|
-
|
|
19
|
+
|
|
20
|
+
data["profiles"]&.each { |name, entry| conf.profiles[name] = profile!(name, entry) }
|
|
19
21
|
|
|
20
22
|
conf.workdir = absolute_path!(data["workdir"]) if data["workdir"]
|
|
21
23
|
conf.build_timeout = seconds!(data["build_timeout"]) if data["build_timeout"]
|
|
24
|
+
conf.sandbox_ttl = seconds!(data["sandbox_ttl"]) if data["sandbox_ttl"]
|
|
22
25
|
conf.network = NetworkPolicy.from_config(data["network"]) if data["network"]
|
|
23
26
|
|
|
24
27
|
conf.resources.cpus = integer!(data.dig("resources", "cpus")) if data.dig("resources", "cpus")
|
|
@@ -27,20 +30,46 @@ module Lemans
|
|
|
27
30
|
|
|
28
31
|
conf
|
|
29
32
|
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def profile!(name, entry)
|
|
37
|
+
image, dockerfile = entry&.values_at("image", "dockerfile")
|
|
38
|
+
raise ConfigError, "environment.profiles.#{name}: image and dockerfile are mutually exclusive" if image && dockerfile
|
|
39
|
+
raise ConfigError, "environment.profiles.#{name} must declare image or dockerfile" unless image || dockerfile
|
|
40
|
+
|
|
41
|
+
Profile.new(image:, dockerfile:)
|
|
42
|
+
end
|
|
30
43
|
end
|
|
31
44
|
|
|
32
45
|
attr_accessor :image, :dockerfile, :workdir, :backend,
|
|
33
|
-
:resources, :build_timeout, :network
|
|
46
|
+
:resources, :build_timeout, :sandbox_ttl, :network, :profiles
|
|
34
47
|
|
|
35
48
|
def initialize
|
|
36
49
|
@image = nil
|
|
37
50
|
@dockerfile = nil
|
|
51
|
+
@profiles = {}
|
|
38
52
|
@backend = "daytona"
|
|
39
53
|
@workdir = "/app"
|
|
40
54
|
@resources = Resources.new(cpus: 2, memory: 2048, storage: 5120)
|
|
41
55
|
@build_timeout = 10 * 60
|
|
56
|
+
@sandbox_ttl = nil
|
|
42
57
|
@network = NetworkPolicy.new
|
|
43
58
|
end
|
|
59
|
+
|
|
60
|
+
def to_h
|
|
61
|
+
{
|
|
62
|
+
"backend" => backend,
|
|
63
|
+
"image" => image,
|
|
64
|
+
"dockerfile" => dockerfile&.to_s,
|
|
65
|
+
"profiles" => profiles.transform_values { { "image" => it.image, "dockerfile" => it.dockerfile&.to_s }.compact },
|
|
66
|
+
"workdir" => workdir,
|
|
67
|
+
"build_timeout" => build_timeout,
|
|
68
|
+
"sandbox_ttl" => sandbox_ttl,
|
|
69
|
+
"network" => network.to_h,
|
|
70
|
+
"resources" => resources.to_h.transform_keys(&:to_s)
|
|
71
|
+
}.compact
|
|
72
|
+
end
|
|
44
73
|
end
|
|
45
74
|
end
|
|
46
75
|
end
|
data/lib/lemans/config/setup.rb
CHANGED
|
@@ -13,7 +13,10 @@ module Lemans
|
|
|
13
13
|
case data
|
|
14
14
|
when Hash
|
|
15
15
|
conf.commands = Array(data["commands"])
|
|
16
|
-
conf.files = Array(data["files"]).map
|
|
16
|
+
conf.files = Array(data["files"]).map do |entry|
|
|
17
|
+
local, remote = entry.is_a?(Array) ? entry : [ entry, entry ]
|
|
18
|
+
[ root.join(local), remote ]
|
|
19
|
+
end
|
|
17
20
|
else
|
|
18
21
|
conf.commands = Array(data)
|
|
19
22
|
end
|
|
@@ -38,6 +41,8 @@ module Lemans
|
|
|
38
41
|
end
|
|
39
42
|
|
|
40
43
|
def empty? = files.empty? && commands.empty?
|
|
44
|
+
|
|
45
|
+
def to_h = { "commands" => commands, "files" => files.map { |local, remote| [ local.to_s, remote ] } }
|
|
41
46
|
end
|
|
42
47
|
end
|
|
43
48
|
end
|
|
@@ -24,6 +24,7 @@ module Lemans
|
|
|
24
24
|
conf.preverify = data["preverify"] if data["preverify"]
|
|
25
25
|
conf.restore_paths = restore_paths!(data["restore"]) if data["restore"]
|
|
26
26
|
conf.logs_dir = absolute_path!(data["logs_dir"]) if data["logs_dir"]
|
|
27
|
+
conf.verification = root.join(data["verification"]) if data["verification"]
|
|
27
28
|
|
|
28
29
|
conf
|
|
29
30
|
end
|
|
@@ -41,31 +42,41 @@ module Lemans
|
|
|
41
42
|
end
|
|
42
43
|
end
|
|
43
44
|
|
|
44
|
-
attr_accessor :
|
|
45
|
+
attr_accessor :timeout, :setup, :command, :preverify, :restore_paths, :logs_dir, :verification
|
|
45
46
|
|
|
46
47
|
def initialize
|
|
47
|
-
@root = Pathname("./")
|
|
48
48
|
@timeout = 10 * 60
|
|
49
49
|
@setup = Setup.new
|
|
50
50
|
@command = DEFAULT_COMMAND
|
|
51
51
|
@preverify = nil
|
|
52
52
|
@restore_paths = []
|
|
53
53
|
@logs_dir = "/logs/verifier"
|
|
54
|
+
@verification = nil
|
|
54
55
|
end
|
|
55
56
|
|
|
56
57
|
def reward_path = "#{logs_dir.chomp("/")}/reward.txt"
|
|
57
58
|
|
|
59
|
+
def to_h
|
|
60
|
+
{
|
|
61
|
+
"timeout" => timeout,
|
|
62
|
+
"setup" => setup.to_h,
|
|
63
|
+
"command" => command,
|
|
64
|
+
"preverify" => preverify,
|
|
65
|
+
"restore" => restore_paths,
|
|
66
|
+
"logs_dir" => logs_dir,
|
|
67
|
+
"verification" => verification&.to_s
|
|
68
|
+
}.compact
|
|
69
|
+
end
|
|
70
|
+
|
|
58
71
|
# [absolute, remote-relative] pairs. Shared verification files grade
|
|
59
72
|
# every trial, so they ship alongside each task's own tests.
|
|
60
73
|
def files
|
|
61
|
-
@files ||=
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
dir.glob("**/*", File::FNM_DOTMATCH).select(&:file?).map { [ it, it.relative_path_from(dir).to_s ] }
|
|
74
|
+
@files ||=
|
|
75
|
+
if verification&.directory?
|
|
76
|
+
verification.glob("**/*", File::FNM_DOTMATCH).select(&:file?).map { [ it, it.relative_path_from(verification).to_s ] }
|
|
65
77
|
else
|
|
66
78
|
[]
|
|
67
79
|
end
|
|
68
|
-
end
|
|
69
80
|
end
|
|
70
81
|
end
|
|
71
82
|
end
|
data/lib/lemans/config.rb
CHANGED
|
@@ -7,18 +7,24 @@ require "pathname"
|
|
|
7
7
|
module Lemans
|
|
8
8
|
# Benchmark configuration and task definitions container.
|
|
9
9
|
class Config
|
|
10
|
-
attr_reader :root, :config_path, :tasks_dir,
|
|
11
|
-
:tasks, :version,
|
|
10
|
+
attr_reader :root, :config_path, :parent, :tasks_dir, :version,
|
|
12
11
|
:backend, :concurrency, :attempts
|
|
13
12
|
|
|
14
13
|
# Nested configs
|
|
15
14
|
attr_reader :environment, :agent, :verifier, :setup
|
|
16
15
|
|
|
16
|
+
using Ext::DeepMerge
|
|
17
|
+
|
|
18
|
+
FILENAMES = %w[bench.yaml bench.yml].freeze
|
|
19
|
+
|
|
17
20
|
class << self
|
|
18
|
-
|
|
21
|
+
# A task directory's bench.yml is loaded with the bench as its `parent`:
|
|
22
|
+
# inherit_from is implied.
|
|
23
|
+
def load_file(path, parent: nil)
|
|
24
|
+
path = File.dirname(path) if File.file?(path)
|
|
19
25
|
raise ConfigError, "bench directory not found: #{path}" unless File.directory?(path)
|
|
20
26
|
|
|
21
|
-
config_name =
|
|
27
|
+
config_name = FILENAMES.find { File.file?(File.join(path, it)) }
|
|
22
28
|
|
|
23
29
|
raise ConfigError, "bench.yml not found" unless config_name
|
|
24
30
|
|
|
@@ -27,7 +33,18 @@ module Lemans
|
|
|
27
33
|
contents = YAML.safe_load_file(config_path.to_s, aliases: true) || {}
|
|
28
34
|
raise ConfigError, "#{path}: #{config_name} must be a mapping of sections" unless contents.is_a?(Hash)
|
|
29
35
|
|
|
30
|
-
root = Pathname(path)
|
|
36
|
+
root = Pathname(path).expand_path
|
|
37
|
+
|
|
38
|
+
parent = load_file(root.join(contents["inherit_from"])) if contents["inherit_from"]
|
|
39
|
+
|
|
40
|
+
if parent
|
|
41
|
+
# Local conventions win over the inherited config (unless declared, even as ~)
|
|
42
|
+
environment = (contents["environment"] ||= {})
|
|
43
|
+
environment["dockerfile"] = root.join("environment/Dockerfile").to_s if root.join("environment/Dockerfile").file? && !environment.key?("dockerfile")
|
|
44
|
+
verifier = (contents["verifier"] ||= {})
|
|
45
|
+
verifier["verification"] = root.join(Verifier::VERIFICATION_DIR).to_s if root.join(Verifier::VERIFICATION_DIR).directory? && !verifier.key?("verification")
|
|
46
|
+
contents = parent.to_h.deep_merge(contents)
|
|
47
|
+
end
|
|
31
48
|
|
|
32
49
|
sections = {}
|
|
33
50
|
sections[:version] = contents["version"]
|
|
@@ -44,17 +61,18 @@ module Lemans
|
|
|
44
61
|
sections[:setup].commands = Array(commands) + sections[:setup].commands
|
|
45
62
|
end
|
|
46
63
|
|
|
47
|
-
new(root, config_path:, **sections)
|
|
64
|
+
new(root, config_path:, parent:, **sections)
|
|
48
65
|
rescue Psych::Exception => e
|
|
49
66
|
raise ConfigError, "#{path}: #{e.message}"
|
|
50
67
|
end
|
|
51
68
|
end
|
|
52
69
|
|
|
53
|
-
def initialize(root = Pathname("./"), config_path: Pathname("./bench.yml"), version: nil, tasks_dir: "tasks",
|
|
70
|
+
def initialize(root = Pathname("./"), config_path: Pathname("./bench.yml"), parent: nil, version: nil, tasks_dir: "tasks",
|
|
54
71
|
setup: nil, agent: Agent.new("miniswen", "openrouter/z-ai/glm-5.2"),
|
|
55
72
|
environment: Environment.new, verifier: Verifier.new)
|
|
56
73
|
@root = root
|
|
57
74
|
@config_path = config_path
|
|
75
|
+
@parent = parent
|
|
58
76
|
@version = version
|
|
59
77
|
@tasks_dir = root.join(tasks_dir)
|
|
60
78
|
@setup = setup || Setup.new
|
|
@@ -62,26 +80,41 @@ module Lemans
|
|
|
62
80
|
@environment = environment
|
|
63
81
|
@environment.dockerfile = root.join(@environment.dockerfile) if @environment.dockerfile
|
|
64
82
|
@environment.dockerfile ||= default_dockerfile unless @environment.image
|
|
83
|
+
@environment.profiles.each_value { it.dockerfile = root.join(it.dockerfile) if it.dockerfile }
|
|
65
84
|
@verifier = verifier
|
|
66
|
-
@verifier.
|
|
85
|
+
@verifier.verification ||= root.join(Verifier::VERIFICATION_DIR)
|
|
67
86
|
@concurrency = 4
|
|
68
87
|
@attempts = 1
|
|
69
88
|
@backend = @environment.backend
|
|
70
|
-
@tasks = parse_tasks
|
|
71
89
|
end
|
|
72
90
|
|
|
91
|
+
def tasks = @tasks ||= parse_tasks
|
|
92
|
+
|
|
73
93
|
def load_options(agent: nil, model: nil, attempts: nil, concurrency: nil, backend: nil, **)
|
|
74
94
|
@agent.name = agent if agent
|
|
75
95
|
@agent.models = Array(model) if model
|
|
76
96
|
@attempts = attempts if attempts
|
|
77
97
|
@concurrency = concurrency if concurrency
|
|
78
98
|
@backend = environment.backend = backend if backend
|
|
99
|
+
tasks.each { it.config.load_options(agent:, model:, attempts:, concurrency:, backend:) unless it.config.equal?(self) }
|
|
79
100
|
end
|
|
80
101
|
|
|
81
102
|
def agent_name = agent.name
|
|
82
103
|
|
|
83
104
|
def models = agent.models
|
|
84
105
|
|
|
106
|
+
# The bench.yml this config reads as, paths resolved: what a bench
|
|
107
|
+
# inheriting from it merges over. Tasks are a bench's own.
|
|
108
|
+
def to_h
|
|
109
|
+
{
|
|
110
|
+
"version" => version,
|
|
111
|
+
"setup" => setup.to_h,
|
|
112
|
+
"agent" => agent.to_h,
|
|
113
|
+
"environment" => environment.to_h,
|
|
114
|
+
"verifier" => verifier.to_h
|
|
115
|
+
}.compact
|
|
116
|
+
end
|
|
117
|
+
|
|
85
118
|
# Resolved once: an hours-long run reports the bench it started from, not
|
|
86
119
|
# later tree drift.
|
|
87
120
|
def revision
|
|
@@ -92,9 +125,11 @@ module Lemans
|
|
|
92
125
|
def digest
|
|
93
126
|
@digest ||= begin
|
|
94
127
|
sha = Digest::SHA256.new
|
|
95
|
-
sha << TreeDigest.call(
|
|
128
|
+
sha << TreeDigest.call(verifier.verification)
|
|
96
129
|
sha << TreeDigest.call(environment.dockerfile.dirname) if environment.dockerfile
|
|
130
|
+
environment.profiles.each_value { sha << TreeDigest.call(it.dockerfile.dirname) if it.dockerfile }
|
|
97
131
|
sha << Digest::SHA256.file(config_path.to_s).hexdigest if config_path.file?
|
|
132
|
+
sha << parent.digest if parent
|
|
98
133
|
sha.hexdigest[0, 16]
|
|
99
134
|
end
|
|
100
135
|
end
|
data/lib/lemans/environment.rb
CHANGED
|
@@ -13,17 +13,20 @@ module Lemans
|
|
|
13
13
|
|
|
14
14
|
DEFAULT_TIMEOUT = 60
|
|
15
15
|
|
|
16
|
-
attr_reader :image, :resources, :network, :env, :labels, :build_timeout
|
|
16
|
+
attr_reader :image, :resources, :network, :env, :labels, :build_timeout, :ttl
|
|
17
17
|
|
|
18
18
|
# `labels` is backend-agnostic trial metadata (task, trial id, phase);
|
|
19
|
-
# every backend receives it even if it has nowhere to put it.
|
|
20
|
-
|
|
19
|
+
# every backend receives it even if it has nowhere to put it. `ttl` is
|
|
20
|
+
# the longest the sandbox is expected to live: a backend that reaps
|
|
21
|
+
# sandboxes on a clock must not reap this one sooner.
|
|
22
|
+
def initialize(image:, resources:, network:, env: {}, labels: {}, build_timeout: nil, ttl: nil)
|
|
21
23
|
@image = image
|
|
22
24
|
@resources = resources
|
|
23
25
|
@network = network
|
|
24
26
|
@env = env
|
|
25
27
|
@labels = labels
|
|
26
28
|
@build_timeout = build_timeout
|
|
29
|
+
@ttl = ttl
|
|
27
30
|
end
|
|
28
31
|
|
|
29
32
|
# Build the image and bring the sandbox up under the network policy it was
|
|
@@ -12,7 +12,7 @@ module Lemans
|
|
|
12
12
|
# instead of wrapping them, so both dialects have to be caught.
|
|
13
13
|
SDK_ERRORS = [ ::Daytona::Sdk::Error, *::Daytona::Sdk::API_ERROR_CLASSES ].freeze
|
|
14
14
|
|
|
15
|
-
READ_ATTEMPTS =
|
|
15
|
+
READ_ATTEMPTS = 5
|
|
16
16
|
RETRY_DELAY_SEC = 2
|
|
17
17
|
|
|
18
18
|
private
|
|
@@ -25,7 +25,7 @@ module Lemans
|
|
|
25
25
|
attempts += 1
|
|
26
26
|
raise if attempts >= READ_ATTEMPTS || !retryable?(e)
|
|
27
27
|
|
|
28
|
-
sleep RETRY_DELAY_SEC
|
|
28
|
+
sleep RETRY_DELAY_SEC * 2**(attempts - 1)
|
|
29
29
|
retry
|
|
30
30
|
end
|
|
31
31
|
end
|
|
@@ -33,9 +33,11 @@ module Lemans
|
|
|
33
33
|
# Transport failures surface as status 0 (libcurl stamps refused/reset/
|
|
34
34
|
# DNS with code 0) or none, and throttling and server errors heal on
|
|
35
35
|
# their own; any other 4xx would fail the same way again.
|
|
36
|
+
# A 408 is the daemon giving up on a read that outran its own exec
|
|
37
|
+
# timeout; a sandbox that wedged and recovered serves it fine next time.
|
|
36
38
|
def retryable?(error)
|
|
37
39
|
status = status_code(error)
|
|
38
|
-
status.nil? || status.zero? || status == 429 || status >= 500
|
|
40
|
+
status.nil? || status.zero? || status == 408 || status == 429 || status >= 500
|
|
39
41
|
end
|
|
40
42
|
|
|
41
43
|
def status_code(error)
|
|
@@ -30,7 +30,7 @@ module Lemans
|
|
|
30
30
|
if timeout && timeout > SHORT_COMMAND_SEC
|
|
31
31
|
exec_in_session(command, timeout: timeout, env: env)
|
|
32
32
|
else
|
|
33
|
-
|
|
33
|
+
exec_short(command, timeout: timeout, env: env)
|
|
34
34
|
end
|
|
35
35
|
|
|
36
36
|
Environment::ExecResult.new(command: command, duration: (now - started).round(3), **response)
|
|
@@ -51,6 +51,16 @@ module Lemans
|
|
|
51
51
|
end
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
+
# A 408 means the daemon already killed the command at its budget, so
|
|
55
|
+
# the model reads a plain timeout, the same as it would on docker.
|
|
56
|
+
def exec_short(command, timeout:, env:)
|
|
57
|
+
exec_directly(command, timeout: timeout, env: env)
|
|
58
|
+
rescue *SDK_ERRORS => e
|
|
59
|
+
raise unless status_code(e) == 408
|
|
60
|
+
|
|
61
|
+
{ exit_code: 124, output: "<command timed out after #{timeout} seconds>" }
|
|
62
|
+
end
|
|
63
|
+
|
|
54
64
|
def exec_directly(command, timeout:, env:)
|
|
55
65
|
response = sandbox.process.exec(
|
|
56
66
|
command: command,
|
|
@@ -9,8 +9,6 @@ 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
|
-
TTL_MINUTES = 120
|
|
13
|
-
|
|
14
12
|
DEFAULT_BUILD_TIMEOUT = 600
|
|
15
13
|
|
|
16
14
|
# Workspace tarballs ride uploads/downloads, so transfers get their own
|
|
@@ -41,14 +39,16 @@ module Lemans
|
|
|
41
39
|
config
|
|
42
40
|
end
|
|
43
41
|
|
|
44
|
-
def initialize(image:, resources:, network:, env: {}, labels: {}, logger: nil, build_timeout: nil)
|
|
45
|
-
super(image:, resources:, network:, env:, labels:,
|
|
42
|
+
def initialize(image:, resources:, network:, env: {}, labels: {}, logger: nil, build_timeout: nil, ttl: 3600)
|
|
43
|
+
super(image:, resources:, network:, env:, labels:, ttl:,
|
|
46
44
|
build_timeout: build_timeout || DEFAULT_BUILD_TIMEOUT)
|
|
47
45
|
@logger = logger
|
|
46
|
+
@started_at = nil
|
|
48
47
|
end
|
|
49
48
|
|
|
50
49
|
def start
|
|
51
50
|
@sandbox = client.create(create_params, on_snapshot_create_logs: @logger)
|
|
51
|
+
@started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
52
52
|
@shell = Shell.new(sandbox)
|
|
53
53
|
self
|
|
54
54
|
rescue *Retries::SDK_ERRORS => e
|
|
@@ -61,7 +61,7 @@ module Lemans
|
|
|
61
61
|
def exec(command, timeout: nil, env: {})
|
|
62
62
|
@shell.exec(command, timeout: timeout || DEFAULT_TIMEOUT, env: env)
|
|
63
63
|
rescue *Retries::SDK_ERRORS => e
|
|
64
|
-
raise InfrastructureError, "daytona: exec failed: #{e.message}"
|
|
64
|
+
raise InfrastructureError, "daytona: exec failed#{ttl_note}: #{e.message}"
|
|
65
65
|
end
|
|
66
66
|
|
|
67
67
|
def upload(local_path, remote_path)
|
|
@@ -127,11 +127,21 @@ module Lemans
|
|
|
127
127
|
auto_delete_interval: 60,
|
|
128
128
|
# A real ceiling: without it a harness that dies mid-run leaves a
|
|
129
129
|
# running sandbox billing forever.
|
|
130
|
-
ttl_minutes:
|
|
130
|
+
ttl_minutes: ttl_minutes,
|
|
131
131
|
**network_kwargs(network)
|
|
132
132
|
)
|
|
133
133
|
end
|
|
134
134
|
|
|
135
|
+
def ttl_minutes = (ttl / 60.0).ceil
|
|
136
|
+
|
|
137
|
+
# Daytona destroys a sandbox at its TTL whatever it is doing; the next
|
|
138
|
+
# exec then fails with a vague "is the Sandbox started?".
|
|
139
|
+
def ttl_note
|
|
140
|
+
return "" unless @started_at && Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at > ttl
|
|
141
|
+
|
|
142
|
+
" after the sandbox's #{ttl_minutes}m TTL expired (raise environment.sandbox_ttl)"
|
|
143
|
+
end
|
|
144
|
+
|
|
135
145
|
def snapshot_store
|
|
136
146
|
SnapshotStore.new(client:, image:, resources:, build_timeout:, logger: @logger)
|
|
137
147
|
end
|
|
@@ -15,8 +15,8 @@ module Lemans
|
|
|
15
15
|
|
|
16
16
|
attr_reader :container
|
|
17
17
|
|
|
18
|
-
def initialize(image:, resources:, network:, env: {}, labels: {}, logger: nil, build_timeout: nil)
|
|
19
|
-
super(image:, resources:, network:, env:, labels:,
|
|
18
|
+
def initialize(image:, resources:, network:, env: {}, labels: {}, logger: nil, build_timeout: nil, ttl: nil)
|
|
19
|
+
super(image:, resources:, network:, env:, labels:, ttl:,
|
|
20
20
|
build_timeout: build_timeout || DEFAULT_BUILD_TIMEOUT)
|
|
21
21
|
@logger = logger
|
|
22
22
|
@name = "lemans-#{SecureRandom.hex(6)}"
|
|
@@ -152,7 +152,7 @@ module Lemans
|
|
|
152
152
|
end
|
|
153
153
|
|
|
154
154
|
status = wait.value
|
|
155
|
-
[ timed_out ? 124 : (status.exitstatus || 1), output.scrub ]
|
|
155
|
+
[ timed_out ? 124 : (status.exitstatus || 1), output.force_encoding(Encoding::UTF_8).scrub ]
|
|
156
156
|
end
|
|
157
157
|
rescue Errno::ENOENT
|
|
158
158
|
raise ConfigError, "docker: CLI not found — install Docker or pick another backend"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lemans
|
|
4
|
+
module Ext
|
|
5
|
+
module DeepMerge
|
|
6
|
+
refine Hash do
|
|
7
|
+
def deep_merge(other)
|
|
8
|
+
merge(other) { |_, base, over| base.is_a?(Hash) && over.is_a?(Hash) ? base.deep_merge(over) : over }
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|