lemans 0.0.0.pre → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +228 -0
  5. data/exe/lemans +17 -0
  6. data/exe/lemans-remote +984 -0
  7. data/lib/lemans/agents/base.rb +30 -0
  8. data/lib/lemans/agents/miniswen.rb +119 -0
  9. data/lib/lemans/agents/miniswen_installed.rb +67 -0
  10. data/lib/lemans/agents/nop.rb +15 -0
  11. data/lib/lemans/agents/oracle.rb +53 -0
  12. data/lib/lemans/agents.rb +21 -0
  13. data/lib/lemans/bench.rb +280 -0
  14. data/lib/lemans/cli/board_reporter.rb +135 -0
  15. data/lib/lemans/cli/progress_reporter.rb +67 -0
  16. data/lib/lemans/cli.rb +181 -0
  17. data/lib/lemans/clobber.rb +79 -0
  18. data/lib/lemans/environments/base.rb +55 -0
  19. data/lib/lemans/environments/daytona/retries.rb +49 -0
  20. data/lib/lemans/environments/daytona/sdk_tweaks.rb +50 -0
  21. data/lib/lemans/environments/daytona/shell.rb +142 -0
  22. data/lib/lemans/environments/daytona/snapshot_store.rb +163 -0
  23. data/lib/lemans/environments/daytona.rb +175 -0
  24. data/lib/lemans/environments.rb +16 -0
  25. data/lib/lemans/network_policy.rb +66 -0
  26. data/lib/lemans/patch.rb +70 -0
  27. data/lib/lemans/restore_paths.rb +21 -0
  28. data/lib/lemans/results/aggregate.rb +114 -0
  29. data/lib/lemans/results/cost_source.rb +13 -0
  30. data/lib/lemans/results/outcome.rb +36 -0
  31. data/lib/lemans/results/report.rb +149 -0
  32. data/lib/lemans/results/sorting.rb +24 -0
  33. data/lib/lemans/results/tally.rb +19 -0
  34. data/lib/lemans/results/usage.rb +24 -0
  35. data/lib/lemans/run.rb +152 -0
  36. data/lib/lemans/setup.rb +59 -0
  37. data/lib/lemans/setup_files.rb +36 -0
  38. data/lib/lemans/snapshot.rb +55 -0
  39. data/lib/lemans/task.rb +207 -0
  40. data/lib/lemans/tree_digest.rb +24 -0
  41. data/lib/lemans/trial.rb +187 -0
  42. data/lib/lemans/units.rb +44 -0
  43. data/lib/lemans/verifier/assets/eport-lemans.rb +36 -0
  44. data/lib/lemans/verifier/assets/lemans_minitest_reporter.rb +61 -0
  45. data/lib/lemans/verifier.rb +199 -0
  46. data/lib/lemans/version.rb +5 -0
  47. data/lib/lemans.rb +29 -0
  48. data/lib/miniswen/agent.rb +678 -0
  49. data/lib/miniswen/cli.rb +224 -0
  50. data/lib/miniswen/environment.rb +14 -0
  51. data/lib/miniswen/local.rb +42 -0
  52. data/lib/miniswen/ruby_llm.rb +42 -0
  53. data/lib/miniswen/testing.rb +134 -0
  54. data/lib/miniswen/trajectory.rb +110 -0
  55. data/lib/miniswen/version.rb +5 -0
  56. data/lib/miniswen.rb +48 -0
  57. metadata +161 -7
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module Lemans
6
+ module Results
7
+ # Rolls trials up the way a leaderboard quotes them: solved out of
8
+ # attempts, median time, mean spend per run. Groups by any 1-3 of
9
+ # task, agent, model — "task-model" reads as two columns.
10
+ class Aggregate
11
+ KEYS = %i[task agent model].freeze
12
+ METRICS = %i[score time cost steps tokens].freeze
13
+ METRIC_SOURCES = { time: :duration_sec, cost: :cost_usd, steps: :steps, tokens: :tokens }.freeze
14
+
15
+ attr_reader :report, :keys
16
+
17
+ def self.keys(spec)
18
+ keys = spec.to_s.split("-").map(&:to_sym)
19
+ return keys if keys.size.between?(1, 3) && keys.uniq == keys && (keys - KEYS).empty?
20
+
21
+ raise ConfigError, "--aggregate: expected 1-3 of #{KEYS.join(", ")} joined by dashes (got #{spec.inspect})"
22
+ end
23
+
24
+ def initialize(report, keys:)
25
+ @report = report
26
+ @keys = keys
27
+ @groups = report.rows
28
+ .group_by { |row| keys.map { row[_1] } }
29
+ .map { |values, group| build(values, group) }
30
+ .sort_by { |group| keys.map { group[_1].to_s } }
31
+ end
32
+
33
+ def order_by!(column)
34
+ column = Sorting.column(column, allowed: keys + METRICS)
35
+ @groups =
36
+ if keys.include?(column)
37
+ Sorting.call(@groups) { _1[column].to_s }
38
+ elsif column == :score
39
+ Sorting.call(@groups, descending: true) { [Rational(_1[:solved], _1[:attempts]), _1[:attempts]] }
40
+ else
41
+ Sorting.call(@groups, descending: true) { _1[METRIC_SOURCES.fetch(column)] }
42
+ end
43
+ self
44
+ end
45
+
46
+ def to_rows
47
+ [keys.map(&:to_s) + METRICS.map(&:to_s)] +
48
+ @groups.map do |group|
49
+ keys.map { |key| display_key(key, group[key]) } + [
50
+ "#{group[:solved]}/#{group[:attempts]}",
51
+ time(group[:duration_sec]),
52
+ cost(group[:cost_usd]),
53
+ mean_display(group[:steps], 1),
54
+ mean_display(group[:tokens], 0)
55
+ ]
56
+ end
57
+ end
58
+
59
+ def to_csv
60
+ columns = keys + %i[solved attempts duration_sec cost_usd steps tokens]
61
+ CSV.generate do |csv|
62
+ csv << columns
63
+ @groups.each { |group| csv << columns.map { group[_1] } }
64
+ end
65
+ end
66
+
67
+ def summary = report.summary
68
+
69
+ def summary_lines = report.summary_lines
70
+
71
+ private
72
+
73
+ # Attempts count every run; means and the median skip runs that never
74
+ # measured the value, so one invalid trial cannot zero out a cell.
75
+ def build(values, group)
76
+ keys.zip(values).to_h.merge(
77
+ solved: Tally.call(group)[:solved],
78
+ attempts: group.size,
79
+ duration_sec: median(group.filter_map { _1[:duration_sec] }),
80
+ cost_usd: mean(group.filter_map { _1[:cost_usd] }),
81
+ steps: mean(group.filter_map { _1[:steps] }),
82
+ tokens: mean(group.filter_map { _1[:tokens] })
83
+ )
84
+ end
85
+
86
+ def mean(values) = values.empty? ? nil : values.sum(0.0) / values.size
87
+
88
+ def median(values)
89
+ return nil if values.empty?
90
+
91
+ sorted = values.sort
92
+ mid = sorted.size / 2
93
+ sorted.size.odd? ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2.0
94
+ end
95
+
96
+ def display_key(key, value)
97
+ return "-" if value.nil?
98
+
99
+ key == :model ? Report.short_model(value) : value.to_s
100
+ end
101
+
102
+ def time(sec)
103
+ return "-" if sec.nil?
104
+
105
+ minutes, seconds = sec.round.divmod(60)
106
+ minutes.positive? ? "#{minutes}m #{seconds}s" : "#{seconds}s"
107
+ end
108
+
109
+ def cost(value) = value.nil? ? "-" : "$#{format("%g", value.round(4))}"
110
+
111
+ def mean_display(value, digits) = value.nil? ? "-" : format("%g", value.round(digits))
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lemans
4
+ module Results
5
+ # Where a trial's dollar figure came from: a published $0.00 is only worth
6
+ # reading if it can be told apart from "nobody could price this model".
7
+ CostSource = Data.define(:name, :model, :priced_as, :registry) do
8
+ def self.none = new(name: :none, model: nil, priced_as: nil, registry: nil)
9
+
10
+ def to_h = { name: name, model: model, priced_as: priced_as, registry: registry }.compact
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lemans
4
+ module Results
5
+ # Why a trial ended, and whether its reward means anything: out-of-budget
6
+ # is a scored failure, a sandbox that never started measured nothing.
7
+ class Outcome
8
+ SCORED = %i[completed agent_timeout step_limit_reached cost_ceiling_reached].freeze
9
+ INVALID = %i[environment_error agent_error accounting_error verifier_error cancelled harness_crash].freeze
10
+
11
+ ALL = (SCORED + INVALID).freeze
12
+
13
+ attr_reader :name, :detail
14
+
15
+ def initialize(name, detail: nil)
16
+ raise ArgumentError, "unknown outcome #{name.inspect}" unless ALL.include?(name)
17
+
18
+ @name = name
19
+ @detail = detail
20
+ freeze
21
+ end
22
+
23
+ ALL.each do |outcome|
24
+ define_method(:"#{outcome}?") { name == outcome }
25
+ end
26
+
27
+ def scored? = SCORED.include?(name)
28
+
29
+ def invalid? = !scored?
30
+
31
+ def to_h = { name: name, scored: scored?, detail: detail }.compact
32
+
33
+ def to_s = detail ? "#{name}: #{detail}" : name.to_s
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+ require "json"
5
+ require "pathname"
6
+
7
+ module Lemans
8
+ module Results
9
+ # Reads a runs directory back as a table or CSV. The result files stay the
10
+ # source of truth; unreadable ones are counted and said out loud.
11
+ class Report
12
+ COLUMNS = %i[task agent model reward outcome scored cost_usd steps tokens duration_sec started_at trial tags
13
+ detail].freeze
14
+ TABLE_COLUMNS = %i[task agent model reward outcome cost_usd steps tokens duration_sec trial].freeze
15
+ NUMERIC_COLUMNS = %i[reward cost_usd steps tokens duration_sec].freeze
16
+
17
+ attr_reader :rows, :unreadable
18
+
19
+ def self.load(runs_dir, tag: nil)
20
+ paths = Pathname(runs_dir).glob("**/result.json").sort
21
+ rows = []
22
+ unreadable = 0
23
+
24
+ paths.each do |path|
25
+ result = JSON.parse(path.read)
26
+ rows << row_from(result)
27
+ rescue JSON::ParserError, SystemCallError, IOError
28
+ unreadable += 1
29
+ end
30
+
31
+ rows = rows.select { _1[:tags].include?(tag) } if tag
32
+ new(rows: rows.sort_by { [_1[:task].to_s, _1[:started_at].to_s] }, unreadable: unreadable)
33
+ end
34
+
35
+ def self.row_from(result)
36
+ {
37
+ task: result["task"],
38
+ agent: result["agent"],
39
+ model: result["model"],
40
+ reward: result["reward"],
41
+ outcome: result.dig("outcome", "name"),
42
+ scored: result.dig("outcome", "scored") == true,
43
+ detail: result.dig("outcome", "detail"),
44
+ cost_usd: result.dig("usage", "cost_usd"),
45
+ steps: result.dig("usage", "steps"),
46
+ tokens: tokens_from(result),
47
+ duration_sec: result["duration_sec"],
48
+ started_at: result["started_at"],
49
+ trial: result["trial"],
50
+ tags: Array(result["tags"]).map(&:to_s)
51
+ }
52
+ end
53
+
54
+ # Tokens the model actually consumed and produced; cache reads stay out,
55
+ # matching how providers meter a run.
56
+ def self.tokens_from(result)
57
+ input = result.dig("usage", "input_tokens")
58
+ output = result.dig("usage", "output_tokens")
59
+ input.nil? && output.nil? ? nil : input.to_i + output.to_i
60
+ end
61
+
62
+ # A bench may name no model at all (nop, oracle); the summary needs a
63
+ # label, not a nil for ljust to crash on.
64
+ def self.short_model(model) = model.to_s.split("/").last || "(default)"
65
+
66
+ def initialize(rows:, unreadable: 0)
67
+ @rows = rows
68
+ @unreadable = unreadable
69
+ end
70
+
71
+ def empty? = rows.empty? && unreadable.zero?
72
+
73
+ # Numbers rank best-first the way a leaderboard reads; names sort A-Z.
74
+ # Trials that never measured the column sink to the bottom either way.
75
+ def order_by!(column)
76
+ column = Sorting.column(column, allowed: TABLE_COLUMNS)
77
+ descending = NUMERIC_COLUMNS.include?(column)
78
+ @rows = Sorting.call(rows, descending: descending) { _1[column] }
79
+ self
80
+ end
81
+
82
+ def summary
83
+ Tally.call(rows).merge(cost_usd: rows.sum { _1[:cost_usd].to_f })
84
+ end
85
+
86
+ def to_rows
87
+ [TABLE_COLUMNS.map(&:to_s)] +
88
+ rows.map do |row|
89
+ TABLE_COLUMNS.map do |column|
90
+ display(column == :model ? short_model(row[:model]) : row[column])
91
+ end
92
+ end
93
+ end
94
+
95
+ def summary_lines
96
+ per_model = rows.group_by { short_model(_1[:model]) }
97
+ lines =
98
+ if per_model.size > 1
99
+ width = per_model.keys.map(&:length).max
100
+ per_model.map { |model, group| "#{model.ljust(width)} #{stats(group)}" } +
101
+ ["#{"total".ljust(width)} #{stats(rows)}"]
102
+ else
103
+ [stats(rows)]
104
+ end
105
+ lines[-1] = "#{lines[-1]} · #{unreadable} unreadable result(s) skipped" if unreadable.positive?
106
+ lines
107
+ end
108
+
109
+ def to_csv
110
+ CSV.generate do |csv|
111
+ csv << COLUMNS
112
+ rows.each do |row|
113
+ csv << COLUMNS.map { |column| column == :tags ? Array(row[:tags]).join(" ") : row[column] }
114
+ end
115
+ end
116
+ end
117
+
118
+ private
119
+
120
+ # The rank divides solved by scored, not total: invalid trials measured nothing.
121
+ def stats(group)
122
+ totals = Tally.call(group).merge(cost_usd: group.sum { _1[:cost_usd].to_f })
123
+ rank = totals[:scored].positive? ? " (#{(100.0 * totals[:solved] / totals[:scored]).round}%)" : ""
124
+ "#{totals[:total]} trials: #{totals[:scored]} scored, #{totals[:invalid]} invalid, " \
125
+ "#{totals[:solved]} solved#{rank} · $#{format("%.4f", totals[:cost_usd])}#{pass_at_k(group)}"
126
+ end
127
+
128
+ def pass_at_k(group)
129
+ cells = group.select { _1[:scored] }.group_by { [_1[:model], _1[:task]] }.values
130
+ sizes = cells.map(&:size).uniq
131
+ return "" unless sizes.any? { _1 > 1 }
132
+
133
+ solved = cells.count { |trials| trials.any? { _1[:reward].to_f >= 1.0 } }
134
+ label = sizes.size == 1 ? "pass@#{sizes.first}" : "pass@k"
135
+ " · #{label} #{solved}/#{cells.size} tasks (#{(100.0 * solved / cells.size).round}%)"
136
+ end
137
+
138
+ def short_model(model) = self.class.short_model(model)
139
+
140
+ def display(value)
141
+ case value
142
+ when nil then "-"
143
+ when Float then format("%g", value.round(4))
144
+ else value.to_s
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lemans
4
+ module Results
5
+ # One sorting rule for every results view: validate the column name and
6
+ # keep rows that never measured the value at the bottom.
7
+ module Sorting
8
+ def self.column(name, allowed:)
9
+ column = name.to_s.to_sym
10
+ return column if allowed.include?(column)
11
+
12
+ raise ConfigError, "--sort: unknown column #{name.inspect} (try #{allowed.join(", ")})"
13
+ end
14
+
15
+ def self.call(rows, descending: false)
16
+ keyed = rows.map { [yield(_1), _1] }
17
+ present, missing = keyed.partition { |value, _| value }
18
+ sorted = present.sort_by { |value, _| value }
19
+ sorted.reverse! if descending
20
+ (sorted + missing).map(&:last)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lemans
4
+ module Results
5
+ # One definition of the numbers everyone quotes — total, scored, invalid, solved — so the
6
+ # streaming summary and the report can never drift apart.
7
+ module Tally
8
+ def self.call(entries)
9
+ scored = entries.count { _1[:scored] }
10
+ {
11
+ total: entries.size,
12
+ scored: scored,
13
+ invalid: entries.size - scored,
14
+ solved: entries.count { _1[:reward].to_f >= 1.0 }
15
+ }
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lemans
4
+ module Results
5
+ # What a trial spent. Cache tokens count separately, and the cost carries
6
+ # its own provenance — $0.00 is the figure most in need of auditing.
7
+ Usage = Data.define(:input_tokens, :output_tokens, :cached_tokens, :cost_usd, :steps, :cost_source) do
8
+ def self.zero
9
+ new(input_tokens: 0, output_tokens: 0, cached_tokens: 0, cost_usd: 0.0, steps: 0, cost_source: CostSource.none)
10
+ end
11
+
12
+ def to_h
13
+ {
14
+ input_tokens: input_tokens,
15
+ output_tokens: output_tokens,
16
+ cached_tokens: cached_tokens,
17
+ cost_usd: cost_usd,
18
+ steps: steps,
19
+ cost_source: cost_source&.to_h
20
+ }
21
+ end
22
+ end
23
+ end
24
+ end
data/lib/lemans/run.rb ADDED
@@ -0,0 +1,152 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+ require "json"
5
+
6
+ module Lemans
7
+ # A whole run: every task, k attempts each, several trials in flight.
8
+ # Concurrency and resume exist because a sequential run is a day of wall clock.
9
+ class Run
10
+ Attempt = Data.define(:task, :model, :index)
11
+
12
+ # What ^C injects into workers instead of Interrupt, so the join loop can
13
+ # swallow its own stop signal while a user's second ^C stays distinguishable
14
+ class Shutdown < Exception; end # rubocop:disable Lint/InheritException
15
+
16
+ def initialize(bench:, tasks:, agent_name:, runs_dir:, attempts: 1, concurrency: 4,
17
+ resume: false, model: nil, backend: "daytona")
18
+ @bench = bench
19
+ @tasks = tasks
20
+ @agent_name = agent_name
21
+ @runs_dir = Pathname(runs_dir)
22
+ @attempts = attempts
23
+ @concurrency = concurrency
24
+ @resume = resume
25
+ @model = Array(model)
26
+ @backend = backend
27
+ end
28
+
29
+ # How many attempts this run will actually schedule — resume already
30
+ # subtracted the retired ones.
31
+ def total = pending.size
32
+
33
+ def call(&report)
34
+ begin
35
+ @runs_dir.mkpath
36
+ rescue SystemCallError => e
37
+ raise ConfigError, "cannot use runs directory #{@runs_dir}: #{e.message}"
38
+ end
39
+ queue = Queue.new
40
+ pending.shuffle.each { queue << _1 }
41
+ queue.close
42
+
43
+ results = Concurrent::Array.new
44
+ workers = Array.new(@concurrency) { Thread.new { drain(queue, results, &report) } }
45
+ workers.each(&:join)
46
+ raise @abort if @abort
47
+
48
+ summarize(results)
49
+ rescue Interrupt
50
+ queue&.clear
51
+ queue&.close
52
+ workers = Array(workers).select(&:alive?)
53
+ report&.call(:interrupted, { in_flight: workers.size })
54
+ workers.each { _1.raise(Shutdown) }
55
+
56
+ workers.each do |worker| # rubocop:disable Style/CombinableLoops
57
+ worker.join
58
+ rescue Shutdown
59
+ # ignore: our own stop signal coming back
60
+ end
61
+ summarize(results || []).merge(interrupted: true)
62
+ end
63
+
64
+ private
65
+
66
+ def pending
67
+ @pending ||= models.flat_map do |model|
68
+ @tasks.flat_map do |task|
69
+ completed = @resume ? completed_attempts(task, model) : 0
70
+ ((completed + 1)..@attempts).map { Attempt.new(task: task, model: model, index: _1) }
71
+ end
72
+ end
73
+ end
74
+
75
+ def models
76
+ return @model if @model.any?
77
+ return [nil] if @bench.agent.models.empty?
78
+
79
+ @bench.agent.models
80
+ end
81
+
82
+ # Counts only attempts that measured the same thing (agent, model, digests, scored) — otherwise
83
+ # editing bench.yml and resuming would let old-profile trials satisfy the new run.
84
+ def completed_attempts(task, model)
85
+ @runs_dir.glob("**/result.json").count { same_run?(_1, task, model) }
86
+ end
87
+
88
+ def same_run?(path, task, model)
89
+ result = JSON.parse(path.read, symbolize_names: true)
90
+
91
+ result[:task] == task.name &&
92
+ result[:agent] == @agent_name &&
93
+ result[:model] == (model || @bench.agent.model) &&
94
+ result[:profile_digest] == @bench.digest &&
95
+ result[:task_digest] == task.digest &&
96
+ result.dig(:outcome, :scored) == true
97
+ rescue JSON::ParserError, SystemCallError, IOError
98
+ false
99
+ end
100
+
101
+ # Any failure that escapes a trial poisons the run: stop scheduling, let
102
+ # in-flight trials finish, surface after the join.
103
+ def drain(queue, results, &)
104
+ # The TUI owns failure output; a dying worker must not spray stderr.
105
+ Thread.current.report_on_exception = false
106
+ while (attempt = queue.pop)
107
+ begin
108
+ results << run_attempt(attempt, &)
109
+ rescue StandardError => e
110
+ @abort ||= e
111
+ queue.clear
112
+ queue.close
113
+ end
114
+ end
115
+ end
116
+
117
+ def run_attempt(attempt)
118
+ trial = Trial.new(
119
+ task: attempt.task,
120
+ bench: @bench,
121
+ agent_name: @agent_name,
122
+ model: attempt.model,
123
+ backend: @backend,
124
+ runs_dir: @runs_dir
125
+ )
126
+
127
+ if block_given?
128
+ yield :started, { task: attempt.task.name, model: attempt.model || @bench.agent.model,
129
+ index: attempt.index, attempts: @attempts, trial: trial.id }
130
+ end
131
+
132
+ result = trial.run
133
+ if block_given?
134
+ yield :finished, {
135
+ task: attempt.task.name,
136
+ model: attempt.model || @bench.agent.model,
137
+ index: attempt.index,
138
+ outcome: result[:outcome][:name],
139
+ scored: result[:outcome][:scored],
140
+ reward: result[:reward],
141
+ duration_sec: result[:duration_sec],
142
+ detail: result[:outcome][:detail]
143
+ }
144
+ end
145
+ result
146
+ end
147
+
148
+ def summarize(results)
149
+ Results::Tally.call(results.map { { scored: _1.dig(:outcome, :scored) == true, reward: _1[:reward] } })
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module Lemans
6
+ # Makes one shared image into this task's sandbox at start-up: uploads the
7
+ # declared files and runs the setup commands. A failure here is never the model's.
8
+ class Setup
9
+ # Uploads land under one harness-owned directory, wiped once the steps have
10
+ # run: an agent whose first `ls /` finds the seed patch is reading the harness.
11
+ ROOT = "/lemans"
12
+ DIR = "#{ROOT}/setup".freeze
13
+
14
+ def initialize(commands:, task:, phase:, timeout_sec:)
15
+ @commands = commands
16
+ @task = task
17
+ @phase = phase
18
+ @timeout_sec = timeout_sec
19
+ end
20
+
21
+ def call(environment)
22
+ return if commands.empty? && files.empty?
23
+
24
+ files.each { |local, remote| environment.upload(local, remote) }
25
+ apply_seed(environment)
26
+ commands.each { environment.exec!(_1, timeout: timeout_sec) }
27
+ environment.exec!("rm -rf #{Shellwords.escape(ROOT)}")
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :commands, :task, :phase, :timeout_sec
33
+
34
+ # Folds a task's flat environment.patch into the workdir, then reseals the
35
+ # tree as a one-commit repo so `git log` does not point at the defect.
36
+ def apply_seed(environment)
37
+ return unless phase == :environment
38
+
39
+ seed = "#{DIR}/#{Task::FLAT_SEED}"
40
+ return unless files.any? { |_, remote| remote == seed }
41
+
42
+ workdir = Shellwords.escape(task.bench.environment.workdir)
43
+ environment.exec!(
44
+ "cd #{workdir} && git apply --binary --whitespace=nowarn #{Shellwords.escape(seed)} && " \
45
+ "rm -rf .git && git init -q && git add -A && " \
46
+ "git -c user.name=lemans -c user.email=lemans@localhost commit -qm 'Initial commit'",
47
+ timeout: timeout_sec
48
+ )
49
+ end
50
+
51
+ def files
52
+ @files ||= from(bench.root, bench.setup_files(phase)) + from(task.dir, task.setup_files(phase))
53
+ end
54
+
55
+ def from(root, paths) = paths.map { [root.join(_1), "#{DIR}/#{_1}"] }
56
+
57
+ def bench = task.bench
58
+ end
59
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Lemans
6
+ # The files a phase's setup steps consume. Paths are confined to the
7
+ # directory that declared them and must exist at load time.
8
+ module SetupFiles
9
+ PHASES = %i[environment verifier].freeze
10
+
11
+ def self.call(declared, root:, label:)
12
+ declared ||= {}
13
+ raise ConfigError, "#{label}: files must name a phase (#{PHASES.join(", ")})" unless declared.is_a?(Hash)
14
+
15
+ unknown = declared.keys.map(&:to_s) - PHASES.map(&:to_s)
16
+ raise ConfigError, "#{label}: files.#{unknown.first} is not a phase (#{PHASES.join(", ")})" if unknown.any?
17
+
18
+ root = Pathname(root)
19
+ PHASES.to_h do |phase|
20
+ [phase, Array(declared[phase.to_s]).map { checked(_1, phase, root: root, label: label) }.freeze]
21
+ end.freeze
22
+ end
23
+
24
+ def self.checked(path, phase, root:, label:)
25
+ raise ConfigError, "#{label}: files.#{phase} entry #{path.inspect} is not a path" unless path.is_a?(String) && !path.empty?
26
+ raise ConfigError, "#{label}: files.#{phase} entry #{path.inspect} must be relative to #{root}" if path.start_with?("/")
27
+
28
+ relative = Pathname(path).cleanpath
29
+ raise ConfigError, "#{label}: files.#{phase} entry #{path.inspect} must name a file inside #{root}" if relative.each_filename.include?("..") || relative.to_s == "."
30
+ raise ConfigError, "#{label}: files.#{phase} names #{path}, which is not a file" unless root.join(relative).file?
31
+
32
+ relative
33
+ end
34
+ private_class_method :checked
35
+ end
36
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module Lemans
6
+ # The sealed baseline of a task's graded surfaces: a git tree written before
7
+ # the agent's first turn, checked out over the live tree at verification.
8
+ class Snapshot
9
+ REMOTE_INDEX = "/tmp/lemans-baseline.idx"
10
+
11
+ TIMEOUT = 300
12
+
13
+ def initialize(environment, bench:, task:, timeout: TIMEOUT)
14
+ @environment = environment
15
+ @workdir = bench.environment.workdir
16
+ @paths = task.restore_paths
17
+ @timeout = timeout
18
+ @baseline = nil
19
+ end
20
+
21
+ # Sealing failure is an environment error: the agent has not run yet.
22
+ def capture!
23
+ return if paths.empty?
24
+
25
+ result = environment.exec(
26
+ "rm -f #{REMOTE_INDEX} && GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} add -A && " \
27
+ "GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} write-tree && rm -f #{REMOTE_INDEX}",
28
+ timeout: timeout
29
+ )
30
+ tree = result.output.to_s.lines.map(&:strip).reject(&:empty?).last
31
+ raise InfrastructureError, "could not seal the graded surfaces: #{result.output.to_s[0, 500]}" unless result.success? && /\A[0-9a-f]{40,64}\z/.match?(tree.to_s)
32
+
33
+ @baseline = tree
34
+ end
35
+
36
+ def restore! # rubocop:disable Naming/PredicateMethod
37
+ return true if paths.empty?
38
+ raise VerifierError, "restore is declared but no baseline was sealed" unless baseline
39
+
40
+ environment.exec(
41
+ "cd #{Shellwords.escape(workdir)} && #{git} cat-file -e #{baseline} && " \
42
+ "rm -rf -- #{escaped_paths} && #{git} checkout #{baseline} -- #{escaped_paths}",
43
+ timeout: timeout
44
+ ).success?
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :environment, :workdir, :paths, :timeout, :baseline
50
+
51
+ def git = "git -c safe.directory='*' -C #{Shellwords.escape(workdir)}"
52
+
53
+ def escaped_paths = Shellwords.join(paths)
54
+ end
55
+ end