lemans 1.0.0 → 1.2.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 (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +11 -0
  3. data/README.md +15 -3
  4. data/exe/lemans-remote +105 -12
  5. data/lib/lemans/agents/oracle.rb +13 -7
  6. data/lib/lemans/cli/templates/bench/README.md +7 -2
  7. data/lib/lemans/cli/templates/bench/bench.yml +3 -1
  8. data/lib/lemans/cli/templates/bench/tasks/example-task/instruction.md +20 -9
  9. data/lib/lemans/cli/templates/bench/tasks/example-task/solution.1.patch +17 -0
  10. data/lib/lemans/cli/templates/bench/tasks/example-task/solution.2.patch +17 -0
  11. data/lib/lemans/cli/templates/bench/tasks/example-task/verification_test.1.rb +31 -0
  12. data/lib/lemans/cli/templates/bench/tasks/example-task/verification_test.rb +1 -1
  13. data/lib/lemans/config/agent.rb +12 -0
  14. data/lib/lemans/config/environment.rb +28 -2
  15. data/lib/lemans/config/network_policy.rb +2 -0
  16. data/lib/lemans/config/setup.rb +6 -1
  17. data/lib/lemans/config/verifier.rb +18 -7
  18. data/lib/lemans/config.rb +44 -9
  19. data/lib/lemans/environments/docker.rb +1 -1
  20. data/lib/lemans/ext/deep_merge.rb +13 -0
  21. data/lib/lemans/result.rb +41 -1
  22. data/lib/lemans/runner.rb +1 -1
  23. data/lib/lemans/stores/fs.rb +1 -1
  24. data/lib/lemans/task_definition.rb +158 -10
  25. data/lib/lemans/trial/patch.rb +46 -8
  26. data/lib/lemans/trial/verifier/assets/lemans_minitest_reporter.rb +28 -3
  27. data/lib/lemans/trial.rb +83 -33
  28. data/lib/lemans/version.rb +1 -1
  29. data/lib/miniswen/agent.rb +12 -6
  30. data/lib/miniswen/cli/reporter.rb +104 -0
  31. data/lib/miniswen/cli.rb +36 -82
  32. data/lib/miniswen/environment/docker.rb +69 -0
  33. data/lib/miniswen/version.rb +1 -1
  34. metadata +7 -2
  35. data/lib/lemans/cli/templates/bench/tasks/example-task/solution.patch +0 -7
@@ -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
 
@@ -29,6 +35,17 @@ module Lemans
29
35
 
30
36
  root = Pathname(path)
31
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
48
+
32
49
  sections = {}
33
50
  sections[:version] = contents["version"]
34
51
  sections[:tasks_dir] = contents["tasks"]
@@ -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
@@ -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
data/lib/lemans/result.rb CHANGED
@@ -59,6 +59,18 @@ module Lemans
59
59
  :cost_usd, :cost_source
60
60
  ) do
61
61
  def as_json(**) = to_h.merge(cost_source: cost_source&.to_h).compact
62
+
63
+ # A multistep trial's totals; an unknown step cost makes the sum unknown.
64
+ def +(other)
65
+ self.class.new(
66
+ input_tokens: input_tokens + other.input_tokens,
67
+ output_tokens: output_tokens + other.output_tokens,
68
+ cached_tokens: cached_tokens + other.cached_tokens,
69
+ steps: steps + other.steps,
70
+ cost_usd: cost_usd && other.cost_usd && cost_usd + other.cost_usd,
71
+ cost_source: other.cost_source || cost_source
72
+ )
73
+ end
62
74
  end
63
75
 
64
76
  def Usage.zero
@@ -95,6 +107,8 @@ module Lemans
95
107
  @finished_at = time || Time.now.utc
96
108
  end
97
109
 
110
+ def duration = finished_at && (finished_at - started_at).round(1)
111
+
98
112
  def as_json(**)
99
113
  {
100
114
  name:,
@@ -117,13 +131,23 @@ module Lemans
117
131
  def as_json(**) = to_h
118
132
  end
119
133
 
134
+ Step = Data.define(:outcome, :usage, :duration) do
135
+ def as_json(**) = { outcome: outcome.as_json, usage: usage&.as_json, duration: }.compact
136
+
137
+ def self.from_json(data)
138
+ new(outcome: Outcome.from_json(data[:outcome]),
139
+ usage: data[:usage] && Usage.from_json(data[:usage]),
140
+ duration: data[:duration])
141
+ end
142
+ end
143
+
120
144
  # attributes that must be initialized/specified during construction
121
145
  attr_reader :id, :task, :agent, :model, :index,
122
146
  :profile_digest, :task_digest, :revision
123
147
 
124
148
  attr_accessor :tags, :metadata
125
149
 
126
- attr_reader :phases
150
+ attr_reader :phases, :steps
127
151
 
128
152
  # outcome-related attributes (we use setter-like methods, not accessors)
129
153
  attr_reader :reward, :outcome, :usage
@@ -141,6 +165,7 @@ module Lemans
141
165
  @tags = []
142
166
  @metadata = {}
143
167
  @phases = []
168
+ @steps = nil
144
169
 
145
170
  @id = id || "#{task}__#{SecureRandom.alphanumeric(7)}"
146
171
  @outcome = Outcome.new(:pending)
@@ -184,6 +209,14 @@ module Lemans
184
209
  self
185
210
  end
186
211
 
212
+ def step_completed!(outcome, usage = nil, duration: nil)
213
+ outcome = outcome.is_a?(Outcome) ? outcome : Outcome.new(outcome)
214
+ @steps ||= []
215
+ steps << Step.new(outcome:, usage:, duration:)
216
+ # aggregate right away (so we don't lose data on failure)
217
+ completed!(outcome, aggregate_usage)
218
+ end
219
+
187
220
  def graded!(reward)
188
221
  @reward = reward
189
222
  self
@@ -199,12 +232,15 @@ module Lemans
199
232
  self
200
233
  end
201
234
 
235
+ private def aggregate_usage = steps.filter_map(&:usage).reduce(:+)
236
+
202
237
  def as_json(**)
203
238
  {
204
239
  trial: id, task:, agent:, model:, index:,
205
240
  profile_digest:, task_digest:, revision: revision&.as_json,
206
241
  lemans_version: VERSION,
207
242
  tags:, metadata:, phases: phases.map(&:as_json),
243
+ steps: steps&.map(&:as_json),
208
244
  reward:, outcome: outcome.as_json, usage: usage&.as_json, duration:,
209
245
  started_at: started_at&.iso8601,
210
246
  finished_at: finished_at&.iso8601
@@ -222,6 +258,10 @@ module Lemans
222
258
  result.tags = data[:tags] || []
223
259
  result.metadata = data[:metadata] || {}
224
260
  phases_from(data).each { result.phases << it }
261
+ # Steps first: the stored outcome/usage below override the aggregates.
262
+ data[:steps]&.map { Step.from_json(it) }&.each do |step|
263
+ result.step_completed!(step.outcome, step.usage, duration: step.duration)
264
+ end
225
265
  if data[:outcome]
226
266
  result.completed!(
227
267
  Outcome.from_json(data[:outcome]),
data/lib/lemans/runner.rb CHANGED
@@ -69,7 +69,7 @@ module Lemans
69
69
  run.task == task.name &&
70
70
  run.model == (model || config.agent.model) &&
71
71
  run.agent == config.agent_name &&
72
- run.profile_digest == config.digest &&
72
+ run.profile_digest == task.config.digest &&
73
73
  run.task_digest == task.digest &&
74
74
  run.scored?
75
75
  end
@@ -128,7 +128,7 @@ module Lemans
128
128
 
129
129
  # result.json is stored at <root>/<model-short>/<result-id>
130
130
  def result_dir(result)
131
- root.join((result.model || result.agent).to_s.split("/").last, result.id)
131
+ root.join((result.model || result.agent).to_s.split("/").last.tr("#", "-"), result.id)
132
132
  end
133
133
  end
134
134
  end
@@ -19,17 +19,32 @@ module Lemans
19
19
  FLAT_SEED = "environment.patch"
20
20
 
21
21
  FRONTMATTER = /\A---\n(.*?)\n---\n/m
22
+ STEP_SEPARATOR = /^---[ \t]*\n/
23
+
24
+ STEP_TESTS = { "tests.%d" => :dir, "verification_test.%d.rb" => FLAT_TEST, "verify.%d" => "verify" }.freeze
25
+ # The same step files, kept inside the shared tests/ directory
26
+ SHARED_STEP_TESTS = { "verification_test.%d.rb" => FLAT_TEST, "verify.%d" => "verify" }.freeze
27
+ # What the final step alone runs (see Config::Verifier::DEFAULT_COMMAND): never shipped to an earlier step
28
+ FINAL_STEP_TESTS = [ FLAT_TEST, "verify", "test.sh" ].freeze
29
+ STEP_SOLUTIONS = { "solution.%d" => :dir, "solution.%d.patch" => FLAT_SOLUTION,
30
+ "solve.%d" => "solve", "solve.%d.sh" => "solve.sh" }.freeze
31
+
32
+ STEP_FILE = /\A(?:tests\.(?<test>\d+)|verification_test\.(?<test>\d+)\.rb|verify\.(?<test>\d+)|
33
+ solution\.(?<solution>\d+)(?:\.patch)?|solve\.(?<solution>\d+)(?:\.sh)?)\z/x
22
34
 
23
35
  class << self
24
36
  def load_from_directory(config, dir)
25
37
  dir = Pathname(dir)
26
38
  data = frontmatter(dir)
39
+ config = Config.load_file(dir, parent: config) if Config::FILENAMES.any? { dir.join(it).file? }
27
40
 
28
41
  task = new(config, data["name"] || dir.basename.to_s, dir:)
29
42
  task.description = data["description"].to_s if data["description"]
30
43
  task.difficulty = data["difficulty"].to_sym if data["difficulty"]
31
44
  task.tags = Array(data["tags"]).map(&:to_s) if data["tags"]
32
45
  task.metadata = data["metadata"] if data["metadata"]
46
+ task.environment_profile = data["environment"] if data["environment"]
47
+ task.multistep = data["multistep"] if data.key?("multistep")
33
48
 
34
49
  declared_setup = Config::Setup.from_config(data["setup"], root: dir)
35
50
  refuse_config_collisions!(task, declared_setup, config.setup)
@@ -79,8 +94,21 @@ module Lemans
79
94
  raise ConfigError, "#{task.dir}: a task may only override verifier.setup, not verifier.#{extras.first}"
80
95
  end
81
96
 
82
- raise ConfigError, "#{task.dir}: #{ENVIRONMENT_DIR}/Dockerfile is required when the bench declares no shared image or dockerfile" unless
83
- task.config.environment.image || task.config.environment.dockerfile || task.environment_dockerfile.file?
97
+ if (profile = task.environment_profile)
98
+ unless task.config.environment.profiles.key?(profile)
99
+ declared = task.config.environment.profiles.keys
100
+ listing = declared.any? ? "declares #{declared.join(", ")}" : "declares none"
101
+ raise ConfigError, "#{task.dir}: unknown environment #{profile} — bench.yml #{listing}"
102
+ end
103
+
104
+ raise ConfigError, "#{task.dir}: environment #{profile} and a local #{ENVIRONMENT_DIR}/Dockerfile are mutually exclusive" if
105
+ task.environment_dockerfile.file?
106
+ end
107
+
108
+ raise ConfigError, "#{task.dir}: #{ENVIRONMENT_DIR}/Dockerfile is required when the bench declares no shared image or dockerfile and the task names no environment" unless
109
+ task.environment_profile || task.config.environment.image || task.config.environment.dockerfile || task.environment_dockerfile.file?
110
+
111
+ validate_steps!(task)
84
112
 
85
113
  return unless task.test_files.empty?
86
114
 
@@ -88,6 +116,41 @@ module Lemans
88
116
  "the verifier uploads it at verification time"
89
117
  end
90
118
 
119
+ def validate_steps!(task)
120
+ if task.multistep? && task.steps < 2
121
+ raise ConfigError, "#{task.dir}: multistep: true but #{INSTRUCTION} holds a single section — " \
122
+ "separate step instructions with --- (the first section is the shared preamble)"
123
+ end
124
+
125
+ indexed_solutions = false
126
+ entries = task.dir.children
127
+ # Only step tests may carry an index inside tests/; a solution there is just a file.
128
+ entries += task.tests_dir.children.select { |entry| entry.file? && STEP_FILE.match(entry.basename.to_s)&.[](:test) } if task.tests_dir.directory?
129
+ entries.each do |entry|
130
+ match = STEP_FILE.match(entry.basename.to_s) or next
131
+ name = entry.relative_path_from(task.dir)
132
+
133
+ raise ConfigError, "#{task.dir}: #{name} is an indexed step file, but the task is not multistep: true" unless
134
+ task.multistep?
135
+
136
+ step = (match[:test] || match[:solution]).to_i
137
+ raise ConfigError, "#{task.dir}: #{name} names step #{step}, but the task has #{task.steps} steps" unless
138
+ step.between?(1, task.steps)
139
+
140
+ if match[:solution]
141
+ indexed_solutions = true
142
+ elsif step == task.steps
143
+ raise ConfigError, "#{task.dir}: #{name} indexes the final step — the final verification keeps " \
144
+ "the unindexed name"
145
+ end
146
+ end
147
+
148
+ return unless indexed_solutions && task.solution_files.any?
149
+
150
+ raise ConfigError, "#{task.dir}: #{FLAT_SOLUTION} is the whole task's solution while indexed step solutions " \
151
+ "chain step by step — ship one or the other, not both"
152
+ end
153
+
91
154
  # Collisions would be resolved by upload order, so a task never gets to
92
155
  # shadow the bench-wide copy.
93
156
  def refuse_config_collisions!(task, declared, base)
@@ -103,7 +166,9 @@ module Lemans
103
166
 
104
167
  attr_reader :config, :name, :dir
105
168
 
106
- attr_accessor :difficulty, :tags, :description, :metadata
169
+ attr_accessor :difficulty, :tags, :description, :metadata, :environment_profile, :multistep
170
+
171
+ alias multistep? multistep
107
172
 
108
173
  def initialize(config, name, dir: nil)
109
174
  @config = config
@@ -113,13 +178,26 @@ module Lemans
113
178
  @tags = []
114
179
  @description = ""
115
180
  @metadata = {}
181
+ @environment_profile = nil
182
+ @multistep = false
116
183
 
184
+ @step = nil
117
185
  @dir = dir || config.tasks_dir.join(name)
118
186
  end
119
187
 
120
- # The story alone: frontmatter is for the harness, never for the agent.
188
+ # A copy of the task definition for a particular step
189
+ # (so we can generated correct paths and instructions)
190
+ def for_step(index) = dup.tap { it.step = index }
191
+
192
+ def final_step? = !multistep? || step == steps
193
+
194
+ def steps = multistep? ? sections.size - 1 : 1
195
+
121
196
  def instruction
122
- @instruction ||= dir.join(INSTRUCTION).read.sub(FRONTMATTER, "")
197
+ return body unless multistep?
198
+ raise ArgumentError, "#{name} is multistep — only a step projection (for_step) has an instruction" unless step
199
+
200
+ "#{sections[0].strip}\n\n#{sections.fetch(step).strip}\n"
123
201
  end
124
202
 
125
203
  def digest
@@ -147,14 +225,39 @@ module Lemans
147
225
 
148
226
  # [absolute, remote-relative] pairs. Tests stay on the harness side while
149
227
  # the agent works; uploaded into the sandbox only at verification.
228
+ #
229
+ # A multistep task keeps one tests/ directory: whatever in it carries no
230
+ # step index (helpers, fixtures) ships with every verified step, the
231
+ # step's own verification_test.N.rb ships as verification_test.rb, and
232
+ # the unindexed verification_test.rb stays with the final step.
150
233
  def test_files
151
- tests_dir.directory? ? expand(tests_dir) : flat(FLAT_TEST)
234
+ return final_test_files unless step && !final_step?
235
+
236
+ indexed = step_files(STEP_TESTS) + step_files(SHARED_STEP_TESTS, root: tests_dir)
237
+ return [] if indexed.empty?
238
+
239
+ shared_test_files.reject { |_, remote| FINAL_STEP_TESTS.include?(remote) } + indexed
152
240
  end
153
241
 
242
+ def verifiable? = test_files.any?
243
+
244
+ # A lone whole-task solution is applied before the first step: intermediate
245
+ # gates must see it, and later steps have nothing left to add.
154
246
  def solution_files
247
+ if step
248
+ return step_files(STEP_SOLUTIONS) if indexed_solutions?
249
+ return [] if multistep? && step > 1
250
+ end
251
+
155
252
  solution_dir.directory? ? expand(solution_dir) : flat(FLAT_SOLUTION)
156
253
  end
157
254
 
255
+ # True for the later steps of a multistep task whose lone solution already
256
+ # shipped with step 1
257
+ def solution_applied_earlier?
258
+ !!(step && step > 1 && !indexed_solutions? && (solution_dir.directory? || dir.join(FLAT_SOLUTION).file?))
259
+ end
260
+
158
261
  def solution? = solution_files.any?
159
262
 
160
263
  def tests_dir = dir.join(TESTS_DIR)
@@ -164,17 +267,62 @@ module Lemans
164
267
  def environment_dockerfile = dir.join(ENVIRONMENT_DIR, "Dockerfile")
165
268
 
166
269
  def environment_image
167
- if environment_dockerfile.file?
270
+ if (profile = environment.profiles[environment_profile])
271
+ if profile.image
272
+ Config::ImageSpec.registry(profile.image)
273
+ else
274
+ Config::ImageSpec.dockerfile(profile.dockerfile, slug: environment_profile)
275
+ end
276
+ elsif environment_dockerfile.file?
168
277
  Config::ImageSpec.dockerfile(environment_dockerfile, slug: name)
169
- elsif environment.image
170
- Config::ImageSpec.registry(environment.image)
171
- else
278
+ elsif environment.dockerfile
172
279
  Config::ImageSpec.dockerfile(environment.dockerfile, slug: "shared")
280
+ else
281
+ Config::ImageSpec.registry(environment.image)
173
282
  end
174
283
  end
175
284
 
285
+ protected attr_writer :step
286
+
176
287
  private
177
288
 
289
+ attr_reader :step
290
+
291
+ def body
292
+ @body ||= dir.join(INSTRUCTION).read.sub(FRONTMATTER, "")
293
+ end
294
+
295
+ def sections
296
+ @sections ||= body.split(STEP_SEPARATOR)
297
+ end
298
+
299
+ def indexed_solutions? = (1..steps).any? { step_files(STEP_SOLUTIONS, it).any? }
300
+
301
+ # A task may keep its final test flat at the root and use tests/ for shared files
302
+ def final_test_files
303
+ return flat(FLAT_TEST) unless tests_dir.directory?
304
+
305
+ shared_test_files + flat(FLAT_TEST)
306
+ end
307
+
308
+ # Everything under tests/ that no step claims for itself
309
+ def shared_test_files
310
+ return [] unless tests_dir.directory?
311
+
312
+ expand(tests_dir).reject { |_, remote| STEP_FILE.match?(remote) }
313
+ end
314
+
315
+ def step_files(patterns, index = step, root: dir)
316
+ patterns.flat_map do |pattern, remote|
317
+ path = root.join(format(pattern, index))
318
+ if remote == :dir
319
+ path.directory? ? expand(path) : []
320
+ else
321
+ path.file? ? [ [ path, remote ] ] : []
322
+ end
323
+ end
324
+ end
325
+
178
326
  def own_setup_with_seed
179
327
  declared = @declared_setup || Config::Setup.new
180
328
  return declared unless seed? && declared.files.none? { |_, remote| remote == FLAT_SEED }
@@ -12,7 +12,7 @@ module Lemans
12
12
  REMOTE_PATCH = "/tmp/lemans-agent.patch"
13
13
  REMOTE_INDEX = "/tmp/lemans-patch.idx"
14
14
 
15
- private attr_reader :task, :environment, :path, :workdir, :baseline, :timeout
15
+ private attr_reader :task, :environment, :path, :workdir, :baseline, :savepoint, :timeout
16
16
 
17
17
  def initialize(task, environment, timeout: 300, path: "agent.patch")
18
18
  @task = task
@@ -22,37 +22,75 @@ module Lemans
22
22
 
23
23
  @workdir = task.environment.workdir
24
24
  @baseline = nil
25
+ @savepoint = nil
25
26
  end
26
27
 
27
28
  def seal!
28
29
  @baseline = write_tree
30
+ @savepoint = @baseline
29
31
  end
30
32
 
31
33
  # Must run before the verifier restores the graded surfaces: a patch taken
32
- # after would not show what the agent did to them
33
- def collect!(result, store)
34
+ # after would not show what the agent did to them. Diffs from the last
35
+ # savepoint (the sealed baseline until a multistep trial moves it), so a
36
+ # step's patch shows that step's work alone.
37
+ def collect!(result, store, path: @path)
38
+ return unless savepoint
39
+
40
+ after = write_tree
41
+ return unless after
42
+
43
+ save_diff(result, store, savepoint, after, path)
44
+ end
45
+
46
+ # The compilation of every step: the whole run against the sealed baseline.
47
+ def compile!(result, store)
34
48
  return unless baseline
35
49
 
36
50
  after = write_tree
37
51
  return unless after
38
52
 
39
- diffed = environment.exec("#{git} diff --binary #{baseline} #{after} > #{REMOTE_PATCH}", timeout:)
53
+ save_diff(result, store, baseline, after, @path)
54
+ end
55
+
56
+ # Marks the tree a finished step left: the next collect! diffs from here,
57
+ # and restore! comes back here. The mark is load-bearing, so failing to
58
+ # write it is an environment error, not a lost artifact.
59
+ def savepoint!
60
+ @savepoint = write_tree
61
+ raise InfrastructureError, "could not savepoint the tree the step left behind" unless savepoint
62
+ end
63
+
64
+ # Puts the workdir back to the savepoint exactly: the verifier restored
65
+ # the graded surfaces from the baseline and may have littered the tree,
66
+ # and the next step's agent must find neither.
67
+ def restore!
68
+ return unless savepoint
69
+
70
+ environment.exec!(
71
+ "#{git} read-tree #{savepoint} && #{git} checkout-index -f -a && #{git} clean -fd && #{git} reset -q",
72
+ timeout:
73
+ )
74
+ end
75
+
76
+ private
77
+
78
+ def save_diff(result, store, from, to, destination)
79
+ diffed = environment.exec("#{git} diff --binary #{from} #{to} > #{REMOTE_PATCH}", timeout:)
40
80
  return unless diffed.success?
41
81
 
42
82
  Tempfile.create(%w[agent .patch]) do |file|
43
83
  environment.download(REMOTE_PATCH, file.path)
44
- store.save_artifact(result, Pathname(file.path), path:)
84
+ store.save_artifact(result, Pathname(file.path), path: destination)
45
85
  end
46
86
 
47
87
  environment.exec("rm -f #{REMOTE_PATCH} #{REMOTE_INDEX}", timeout:)
48
- path
88
+ destination
49
89
  rescue InfrastructureError => e
50
90
  warn "lemans: could not collect the agent patch for #{result.id}: #{e.message}"
51
91
  nil
52
92
  end
53
93
 
54
- private
55
-
56
94
  # `safe.directory` because the sandbox may run the tree as a different user
57
95
  # than built it, and git refuses to read a repo it thinks is someone else's.
58
96
  def git = "git -c safe.directory='*' -C #{Shellwords.escape(workdir)}"