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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/README.md +14 -4
  4. data/exe/lemans-remote +22 -10
  5. data/lib/lemans/agents/miniswen.rb +2 -2
  6. data/lib/lemans/agents/oracle.rb +13 -7
  7. data/lib/lemans/cli/progress_reporter.rb +6 -1
  8. data/lib/lemans/cli/regrade.rb +193 -0
  9. data/lib/lemans/cli/report/aggregate.rb +18 -11
  10. data/lib/lemans/cli/report.rb +25 -8
  11. data/lib/lemans/cli/templates/bench/README.md +7 -2
  12. data/lib/lemans/cli/templates/bench/tasks/example-task/instruction.md +20 -9
  13. data/lib/lemans/cli/templates/bench/tasks/example-task/solution.1.patch +17 -0
  14. data/lib/lemans/cli/templates/bench/tasks/example-task/solution.2.patch +17 -0
  15. data/lib/lemans/cli/templates/bench/tasks/example-task/verification_test.1.rb +31 -0
  16. data/lib/lemans/cli/templates/bench/tasks/example-task/verification_test.rb +1 -1
  17. data/lib/lemans/cli.rb +38 -1
  18. data/lib/lemans/config/agent.rb +12 -0
  19. data/lib/lemans/config/environment.rb +31 -2
  20. data/lib/lemans/config/network_policy.rb +2 -0
  21. data/lib/lemans/config/setup.rb +6 -1
  22. data/lib/lemans/config/verifier.rb +18 -7
  23. data/lib/lemans/config.rb +45 -10
  24. data/lib/lemans/environment.rb +6 -3
  25. data/lib/lemans/environments/daytona/retries.rb +5 -3
  26. data/lib/lemans/environments/daytona/shell.rb +11 -1
  27. data/lib/lemans/environments/daytona.rb +16 -6
  28. data/lib/lemans/environments/docker.rb +3 -3
  29. data/lib/lemans/ext/deep_merge.rb +13 -0
  30. data/lib/lemans/result.rb +57 -7
  31. data/lib/lemans/runner.rb +1 -1
  32. data/lib/lemans/store.rb +7 -1
  33. data/lib/lemans/stores/fs.rb +9 -3
  34. data/lib/lemans/task_definition.rb +158 -10
  35. data/lib/lemans/trial/patch.rb +46 -8
  36. data/lib/lemans/trial/verifier/assets/eport-lemans.rb +6 -0
  37. data/lib/lemans/trial/verifier/assets/lemans_minitest_reporter.rb +44 -3
  38. data/lib/lemans/trial/verifier.rb +30 -3
  39. data/lib/lemans/trial.rb +89 -33
  40. data/lib/lemans/version.rb +1 -1
  41. data/lib/miniswen/agent.rb +21 -2
  42. data/lib/miniswen/version.rb +1 -1
  43. metadata +6 -2
  44. 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
@@ -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
- raise ConfigError, "environment.image and environment.dockerfile are mutually exclusive" if conf.image && conf.dockerfile
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
@@ -33,6 +33,8 @@ module Lemans
33
33
  @hosts = hosts
34
34
  end
35
35
 
36
+ def to_h = { "mode" => mode, "hosts" => hosts }.compact
37
+
36
38
  # Backends allowlist domains and IP ranges through separate APIs.
37
39
  def domains = partitioned_hosts.last
38
40
 
@@ -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 { [ root.join(it), it ] }
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 :root, :timeout, :setup, :command, :preverify, :restore_paths, :logs_dir
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 ||= begin
62
- dir = root.join(VERIFICATION_DIR)
63
- if dir.directory?
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
- def load_file(path)
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 = %w[bench.yaml bench.yml].find { File.file?(File.join(path, it)) }
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.root = root
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(root.join(Verifier::VERIFICATION_DIR))
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
@@ -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
- def initialize(image:, resources:, network:, env: {}, labels: {}, build_timeout: nil)
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 = 3
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
- exec_directly(command, timeout: timeout, env: env)
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: 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