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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/LICENSE.txt +21 -0
- data/README.md +228 -0
- data/exe/lemans +17 -0
- data/exe/lemans-remote +984 -0
- data/lib/lemans/agents/base.rb +30 -0
- data/lib/lemans/agents/miniswen.rb +119 -0
- data/lib/lemans/agents/miniswen_installed.rb +67 -0
- data/lib/lemans/agents/nop.rb +15 -0
- data/lib/lemans/agents/oracle.rb +53 -0
- data/lib/lemans/agents.rb +21 -0
- data/lib/lemans/bench.rb +280 -0
- data/lib/lemans/cli/board_reporter.rb +135 -0
- data/lib/lemans/cli/progress_reporter.rb +67 -0
- data/lib/lemans/cli.rb +181 -0
- data/lib/lemans/clobber.rb +79 -0
- data/lib/lemans/environments/base.rb +55 -0
- data/lib/lemans/environments/daytona/retries.rb +49 -0
- data/lib/lemans/environments/daytona/sdk_tweaks.rb +50 -0
- data/lib/lemans/environments/daytona/shell.rb +142 -0
- data/lib/lemans/environments/daytona/snapshot_store.rb +163 -0
- data/lib/lemans/environments/daytona.rb +175 -0
- data/lib/lemans/environments.rb +16 -0
- data/lib/lemans/network_policy.rb +66 -0
- data/lib/lemans/patch.rb +70 -0
- data/lib/lemans/restore_paths.rb +21 -0
- data/lib/lemans/results/aggregate.rb +114 -0
- data/lib/lemans/results/cost_source.rb +13 -0
- data/lib/lemans/results/outcome.rb +36 -0
- data/lib/lemans/results/report.rb +149 -0
- data/lib/lemans/results/sorting.rb +24 -0
- data/lib/lemans/results/tally.rb +19 -0
- data/lib/lemans/results/usage.rb +24 -0
- data/lib/lemans/run.rb +152 -0
- data/lib/lemans/setup.rb +59 -0
- data/lib/lemans/setup_files.rb +36 -0
- data/lib/lemans/snapshot.rb +55 -0
- data/lib/lemans/task.rb +207 -0
- data/lib/lemans/tree_digest.rb +24 -0
- data/lib/lemans/trial.rb +187 -0
- data/lib/lemans/units.rb +44 -0
- data/lib/lemans/verifier/assets/eport-lemans.rb +36 -0
- data/lib/lemans/verifier/assets/lemans_minitest_reporter.rb +61 -0
- data/lib/lemans/verifier.rb +199 -0
- data/lib/lemans/version.rb +5 -0
- data/lib/lemans.rb +29 -0
- data/lib/miniswen/agent.rb +678 -0
- data/lib/miniswen/cli.rb +224 -0
- data/lib/miniswen/environment.rb +14 -0
- data/lib/miniswen/local.rb +42 -0
- data/lib/miniswen/ruby_llm.rb +42 -0
- data/lib/miniswen/testing.rb +134 -0
- data/lib/miniswen/trajectory.rb +110 -0
- data/lib/miniswen/version.rb +5 -0
- data/lib/miniswen.rb +48 -0
- metadata +161 -7
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lemans
|
|
4
|
+
class CLI < Thor
|
|
5
|
+
# A live table — tasks down, models across — redrawn in place, each cell
|
|
6
|
+
# one glyph per attempt: · queued, spinner running, ✔ solved, ✘ scored short, ! invalid.
|
|
7
|
+
class BoardReporter
|
|
8
|
+
FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
9
|
+
REDRAW_SEC = 0.1
|
|
10
|
+
MAX_DETAIL_CHARS = 200
|
|
11
|
+
|
|
12
|
+
GREEN = "\e[32m"
|
|
13
|
+
RED = "\e[31m"
|
|
14
|
+
YELLOW = "\e[33m"
|
|
15
|
+
DIM = "\e[2m"
|
|
16
|
+
RESET = "\e[0m"
|
|
17
|
+
|
|
18
|
+
def initialize(tasks:, models:, attempts:, total: nil, out: $stderr)
|
|
19
|
+
@tasks = tasks
|
|
20
|
+
@models = models.map { short(_1) }
|
|
21
|
+
@attempts = attempts
|
|
22
|
+
# Injected when known: under --resume the schedule is smaller than
|
|
23
|
+
# tasks × models × attempts.
|
|
24
|
+
@total = total || (tasks.size * models.size * attempts)
|
|
25
|
+
@out = out
|
|
26
|
+
@lock = Mutex.new
|
|
27
|
+
@cells = Hash.new { |cells, key| cells[key] = Array.new(@attempts, :queued) }
|
|
28
|
+
@drawn = 0
|
|
29
|
+
@frame = 0
|
|
30
|
+
@done = 0
|
|
31
|
+
@in_flight = 0
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def start
|
|
35
|
+
@thread = Thread.new do
|
|
36
|
+
loop do
|
|
37
|
+
@lock.synchronize { draw }
|
|
38
|
+
sleep REDRAW_SEC
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def record(event, data)
|
|
45
|
+
@lock.synchronize do
|
|
46
|
+
case event
|
|
47
|
+
when :started
|
|
48
|
+
@in_flight += 1
|
|
49
|
+
cell(data)[data[:index] - 1] = :running
|
|
50
|
+
when :finished
|
|
51
|
+
@in_flight -= 1
|
|
52
|
+
@done += 1
|
|
53
|
+
cell(data)[data[:index] - 1] = data
|
|
54
|
+
announce_error(data)
|
|
55
|
+
when :interrupted
|
|
56
|
+
erase
|
|
57
|
+
@out.puts "#{YELLOW}^C — waiting for #{data[:in_flight]} in-flight trial(s), ^C again to abandon#{RESET}"
|
|
58
|
+
@drawn = 0
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def stop
|
|
64
|
+
return unless @thread
|
|
65
|
+
|
|
66
|
+
@thread.kill
|
|
67
|
+
@thread = nil
|
|
68
|
+
@lock.synchronize { draw } # the final frame stays on screen
|
|
69
|
+
@out.puts
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def announce_error(data)
|
|
75
|
+
return if data[:scored] || data[:detail].nil?
|
|
76
|
+
|
|
77
|
+
erase
|
|
78
|
+
@out.puts "\e[2K#{RED}#{data[:task]}: #{data[:outcome]} — " \
|
|
79
|
+
"#{data[:detail].to_s.lines.first.to_s.strip[0, MAX_DETAIL_CHARS]}#{RESET}"
|
|
80
|
+
@drawn = 0
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def cell(data) = @cells[[data[:task], short(data[:model])]]
|
|
84
|
+
|
|
85
|
+
# A bench may declare no model at all; nil must not reach ljust.
|
|
86
|
+
def short(model) = model.nil? ? "(default)" : model.to_s.split("/").last
|
|
87
|
+
|
|
88
|
+
def draw
|
|
89
|
+
@frame += 1
|
|
90
|
+
task_width = (@tasks.map(&:length) + [4]).max
|
|
91
|
+
cell_width = ([@attempts, 3].max + 2)
|
|
92
|
+
lines = [header(task_width, cell_width)]
|
|
93
|
+
@tasks.each { lines << row(_1, task_width, cell_width) }
|
|
94
|
+
lines << "#{DIM}#{FRAMES[@frame % FRAMES.size]} #{@done}/#{@total} done " \
|
|
95
|
+
"· #{@in_flight} in flight#{RESET}"
|
|
96
|
+
|
|
97
|
+
erase
|
|
98
|
+
@out.print lines.map { "\e[2K#{_1}" }.join("\n")
|
|
99
|
+
@drawn = lines.size
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def erase
|
|
103
|
+
@out.print("\r")
|
|
104
|
+
@out.print("\e[#{@drawn - 1}F") if @drawn > 1
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def header(task_width, cell_width)
|
|
108
|
+
"#{DIM}#{"task".ljust(task_width)} #{@models.map { _1.ljust([_1.length, cell_width].max) }.join(" ")}#{RESET}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# ljust would count the glyphs' invisible ANSI bytes, so cells pad by
|
|
112
|
+
# visible width — one column per attempt — instead.
|
|
113
|
+
def row(task, task_width, cell_width)
|
|
114
|
+
cells = @models.map do |model|
|
|
115
|
+
states = @cells[[task, model]]
|
|
116
|
+
pad = [model.length, cell_width].max - states.size
|
|
117
|
+
states.map { glyph(_1) }.join + (" " * [pad, 0].max)
|
|
118
|
+
end
|
|
119
|
+
"#{task.ljust(task_width)} #{cells.join(" ")}"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def glyph(state)
|
|
123
|
+
case state
|
|
124
|
+
when :queued then "#{DIM}·#{RESET}"
|
|
125
|
+
when :running then FRAMES[@frame % FRAMES.size]
|
|
126
|
+
else
|
|
127
|
+
if !state[:scored] then "#{RED}!#{RESET}"
|
|
128
|
+
elsif state[:reward].to_f >= 1.0 then "#{GREEN}✔#{RESET}"
|
|
129
|
+
else "#{YELLOW}✘#{RESET}"
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lemans
|
|
4
|
+
class CLI < Thor
|
|
5
|
+
# The pipe renderer: one plain line per event
|
|
6
|
+
class ProgressReporter
|
|
7
|
+
# say_status's verb column is 12 wide; the longer outcome names get a
|
|
8
|
+
# short verb here and keep their full name in the table and result.json.
|
|
9
|
+
STATUS_VERBS = {
|
|
10
|
+
completed: :completed,
|
|
11
|
+
agent_timeout: :timeout,
|
|
12
|
+
step_limit_reached: :step_limit,
|
|
13
|
+
cost_ceiling_reached: :cost_limit,
|
|
14
|
+
environment_error: :invalid,
|
|
15
|
+
agent_error: :invalid,
|
|
16
|
+
accounting_error: :invalid,
|
|
17
|
+
verifier_error: :invalid,
|
|
18
|
+
harness_crash: :invalid
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
MAX_DETAIL_CHARS = 200
|
|
22
|
+
|
|
23
|
+
def initialize(shell:, task_width:)
|
|
24
|
+
@shell = shell
|
|
25
|
+
@task_width = task_width
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def start = self
|
|
29
|
+
|
|
30
|
+
def record(event, data)
|
|
31
|
+
case event
|
|
32
|
+
when :started then started(data)
|
|
33
|
+
when :finished then finished(data)
|
|
34
|
+
when :interrupted
|
|
35
|
+
@shell.say_status :interrupt,
|
|
36
|
+
"waiting for #{data[:in_flight]} in-flight trial(s), ^C again to abandon", :yellow
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def stop; end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def started(data)
|
|
45
|
+
attempt = "attempt #{data[:index].to_s.rjust(data[:attempts].to_s.length)}/#{data[:attempts]}"
|
|
46
|
+
@shell.say_status :run, "#{data[:task].to_s.ljust(@task_width)} #{attempt} #{data[:trial]}", :blue
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def finished(data)
|
|
50
|
+
status = data[:scored] ? "reward=#{data[:reward].inspect}" : data[:outcome].to_s
|
|
51
|
+
@shell.say_status STATUS_VERBS.fetch(data[:outcome].to_sym, data[:outcome].to_sym),
|
|
52
|
+
"#{data[:task].to_s.ljust(@task_width)} #{status.ljust(12)} #{data[:duration_sec]}s",
|
|
53
|
+
color(data)
|
|
54
|
+
|
|
55
|
+
@shell.say_status :error, first_line(data[:detail]), :red unless data[:scored] || data[:detail].nil?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def first_line(detail) = detail.to_s.lines.first.to_s.strip[0, MAX_DETAIL_CHARS]
|
|
59
|
+
|
|
60
|
+
def color(data)
|
|
61
|
+
return :red unless data[:scored]
|
|
62
|
+
|
|
63
|
+
data[:reward].to_f >= 1.0 ? :green : :yellow
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/lib/lemans/cli.rb
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "lemans"
|
|
4
|
+
require "thor"
|
|
5
|
+
|
|
6
|
+
module Lemans
|
|
7
|
+
# The commands. Thin on purpose: everything a command does is a call into a
|
|
8
|
+
# class somebody can drive without a terminal.
|
|
9
|
+
class CLI < Thor
|
|
10
|
+
check_unknown_options!
|
|
11
|
+
|
|
12
|
+
def self.exit_on_failure? = true
|
|
13
|
+
|
|
14
|
+
map %w[-v --version] => :version
|
|
15
|
+
desc "version", "Print the lemans version"
|
|
16
|
+
def version
|
|
17
|
+
say VERSION
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
desc "tasks", "List the tasks in a bench"
|
|
21
|
+
option :bench, default: ".", desc: "Directory holding bench.yml"
|
|
22
|
+
option :tag, desc: "Only tasks carrying this tag"
|
|
23
|
+
def tasks
|
|
24
|
+
bench = Bench.load(options[:bench])
|
|
25
|
+
print_table(
|
|
26
|
+
[%w[task difficulty tags description]] +
|
|
27
|
+
select_tasks(bench).map { [_1.name, _1.difficulty, _1.tags.join(","), _1.description] }
|
|
28
|
+
)
|
|
29
|
+
rescue ConfigError => e
|
|
30
|
+
raise Thor::Error, "lemans: #{e.message}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
map "run" => :run_bench
|
|
34
|
+
desc "run", "Run tasks and verify them"
|
|
35
|
+
option :bench, default: ".", desc: "Directory holding bench.yml"
|
|
36
|
+
option :task, desc: "Run task(s) by name", repeatable: true
|
|
37
|
+
option :tag, desc: "Run every task carrying this tag"
|
|
38
|
+
option :agent, desc: "Override the agent from bench.yml (miniswen, oracle, nop)"
|
|
39
|
+
option :model, desc: "Override the model(s) from bench.yml", repeatable: true
|
|
40
|
+
option :attempts, type: :numeric, default: 1, aliases: "-k", desc: "Trials per task"
|
|
41
|
+
option :concurrency, type: :numeric, default: 4, aliases: "-c", desc: "Trials in flight at once"
|
|
42
|
+
option :runs_dir, default: "runs", desc: "Where to write run directories"
|
|
43
|
+
option :backend, default: "daytona", enum: Environments::BACKENDS.keys, desc: "Sandbox backend"
|
|
44
|
+
option :resume, type: :boolean, default: false, desc: "Skip trials that already have a result"
|
|
45
|
+
def run_bench
|
|
46
|
+
# The bundled pricing registry ages faster than the gem: refresh once up
|
|
47
|
+
# front, so every trial prices completions against the same revision.
|
|
48
|
+
Miniswen.refresh_registry!
|
|
49
|
+
|
|
50
|
+
bench = Bench.load(options[:bench])
|
|
51
|
+
tasks = select_tasks(bench)
|
|
52
|
+
|
|
53
|
+
run = Run.new(
|
|
54
|
+
bench: bench,
|
|
55
|
+
tasks: tasks,
|
|
56
|
+
agent_name: options[:agent] || bench.agent.name,
|
|
57
|
+
model: options[:model],
|
|
58
|
+
backend: options[:backend],
|
|
59
|
+
runs_dir: options[:runs_dir],
|
|
60
|
+
attempts: Integer(options[:attempts]),
|
|
61
|
+
concurrency: Integer(options[:concurrency]),
|
|
62
|
+
resume: options[:resume]
|
|
63
|
+
)
|
|
64
|
+
if run.total.zero?
|
|
65
|
+
say_status :resume, "nothing to run — every task × model already has " \
|
|
66
|
+
"#{options[:attempts]} scored attempt(s)", :green
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# A tty gets the live board; a pipe gets plain streamed lines.
|
|
70
|
+
progress =
|
|
71
|
+
if interactive?
|
|
72
|
+
models = options[:model] || (bench.agent.models.empty? ? [bench.agent.model] : bench.agent.models)
|
|
73
|
+
BoardReporter.new(tasks: tasks.map(&:name), models: models,
|
|
74
|
+
attempts: Integer(options[:attempts]), total: run.total)
|
|
75
|
+
else
|
|
76
|
+
ProgressReporter.new(shell: shell, task_width: tasks.map { _1.name.length }.max)
|
|
77
|
+
end
|
|
78
|
+
progress.start
|
|
79
|
+
summary = run.call { |event, data| progress.record(event, data) }
|
|
80
|
+
progress.stop
|
|
81
|
+
|
|
82
|
+
say ""
|
|
83
|
+
say_status :report, "collecting results from #{options[:runs_dir]}", :cyan
|
|
84
|
+
print_report Results::Report.load(options[:runs_dir])
|
|
85
|
+
exit 130 if summary[:interrupted]
|
|
86
|
+
exit 1 if summary[:invalid].positive?
|
|
87
|
+
rescue ConfigError => e
|
|
88
|
+
raise Thor::Error, "lemans: #{e.message}"
|
|
89
|
+
rescue Interrupt
|
|
90
|
+
say ""
|
|
91
|
+
exit 130
|
|
92
|
+
ensure
|
|
93
|
+
progress&.stop
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
desc "clobber", "Delete run results"
|
|
97
|
+
option :runs_dir, default: "runs", desc: "Directory holding run directories"
|
|
98
|
+
option :task, type: :array, desc: "Only these tasks' runs (space-separated)"
|
|
99
|
+
option :ttl, desc: "Only runs older than this (10m, 2h, 1d)"
|
|
100
|
+
option :invalid, type: :boolean, default: false, desc: "Only runs that measured nothing (invalid or unreadable)"
|
|
101
|
+
option :force, type: :boolean, default: false, aliases: "-f", desc: "Delete without asking"
|
|
102
|
+
def clobber
|
|
103
|
+
clobber = Clobber.new(
|
|
104
|
+
runs_dir: options[:runs_dir],
|
|
105
|
+
tasks: options[:task],
|
|
106
|
+
ttl_sec: Units.seconds(options[:ttl], field: "--ttl"),
|
|
107
|
+
invalid: options[:invalid]
|
|
108
|
+
)
|
|
109
|
+
doomed = clobber.matches
|
|
110
|
+
return say "lemans: nothing to clobber under #{options[:runs_dir]}" if doomed.empty?
|
|
111
|
+
|
|
112
|
+
unless options[:force]
|
|
113
|
+
doomed.each { say _1.to_s }
|
|
114
|
+
return say "lemans: nothing deleted" unless yes?("Delete #{doomed.size} run(s) under #{options[:runs_dir]}? [y/N]")
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
removed = clobber.call
|
|
118
|
+
say "deleted #{removed.size} run(s)"
|
|
119
|
+
rescue ConfigError => e
|
|
120
|
+
raise Thor::Error, "lemans: #{e.message}"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
desc "report", "Summarize run results as a table or CSV"
|
|
124
|
+
option :runs_dir, default: "runs", desc: "Directory holding run directories"
|
|
125
|
+
option :tag, desc: "Only runs whose result carries this tag"
|
|
126
|
+
option :format, default: "table", enum: %w[table csv], desc: "Output format"
|
|
127
|
+
option :aggregate, aliases: "-A", banner: "COLUMNS", lazy_default: "task-model",
|
|
128
|
+
desc: "Group results by 1-3 dash-joined columns (task, agent, model)"
|
|
129
|
+
option :sort, aliases: "-S", banner: "COLUMN", desc: "Sort by a column"
|
|
130
|
+
def report
|
|
131
|
+
results = Results::Report.load(options[:runs_dir], tag: options[:tag])
|
|
132
|
+
if results.empty?
|
|
133
|
+
tagged = options[:tag] ? " tagged #{options[:tag].inspect}" : ""
|
|
134
|
+
raise Thor::Error, "lemans: no results#{tagged} under #{options[:runs_dir]}"
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
results = Results::Aggregate.new(results, keys: Results::Aggregate.keys(options[:aggregate])) if options[:aggregate]
|
|
138
|
+
results.order_by!(options[:sort]) if options[:sort]
|
|
139
|
+
options[:format] == "csv" ? say(results.to_csv) : print_report(results)
|
|
140
|
+
rescue ConfigError => e
|
|
141
|
+
raise Thor::Error, "lemans: #{e.message}"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
private
|
|
145
|
+
|
|
146
|
+
# The one task filter for every command that walks a bench: an empty
|
|
147
|
+
# selection is an error, because running or listing nothing is never
|
|
148
|
+
# what a named task or tag meant.
|
|
149
|
+
def select_tasks(bench)
|
|
150
|
+
tasks = bench.tasks
|
|
151
|
+
tasks = tasks.select { options[:task].include?(_1.name) } if options[:task]
|
|
152
|
+
tasks = tasks.select { _1.tags.include?(options[:tag]) } if options[:tag]
|
|
153
|
+
return tasks unless tasks.empty?
|
|
154
|
+
|
|
155
|
+
wanted = [options[:task] && "task named #{options[:task].inspect}",
|
|
156
|
+
options[:tag] && "task tagged #{options[:tag].inspect}"].compact.join(" and no ")
|
|
157
|
+
raise Thor::Error, "lemans: no #{wanted.empty? ? "tasks in #{options[:bench]}" : wanted}"
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def print_report(report)
|
|
161
|
+
print_table report.to_rows
|
|
162
|
+
color = report.summary[:invalid].positive? ? :red : nil
|
|
163
|
+
report.summary_lines.each { say _1, color }
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
def interactive?
|
|
167
|
+
return false unless $stderr.tty?
|
|
168
|
+
|
|
169
|
+
return true if ENV["FORCE_INTERACTIVE"] == "1"
|
|
170
|
+
|
|
171
|
+
# Check various env vars indicating non-interactive mode
|
|
172
|
+
if ENV["NONINTERACTIVE"] == "1" ||
|
|
173
|
+
ENV["CI"] == "true" ||
|
|
174
|
+
ENV["TERM"] == "dumb"
|
|
175
|
+
return false
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
true
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "pathname"
|
|
6
|
+
|
|
7
|
+
module Lemans
|
|
8
|
+
# Deletes run directories based on the provided filters.
|
|
9
|
+
class Clobber
|
|
10
|
+
TRIAL_DIR = /\A(?<task>.+)__[A-Za-z0-9]{7}\z/
|
|
11
|
+
|
|
12
|
+
def initialize(runs_dir:, tasks: [], ttl_sec: nil, invalid: false)
|
|
13
|
+
@runs_dir = Pathname(runs_dir)
|
|
14
|
+
@tasks = Array(tasks)
|
|
15
|
+
@ttl_sec = ttl_sec
|
|
16
|
+
@invalid = invalid
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def matches
|
|
20
|
+
@matches ||= select_matches
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Deletes everything it can, says what it could not, and returns what it
|
|
24
|
+
# actually removed — the caller's "deleted N" must not count survivors.
|
|
25
|
+
def call
|
|
26
|
+
deleted = matches.select do |entry|
|
|
27
|
+
FileUtils.remove_entry(entry.to_s)
|
|
28
|
+
true
|
|
29
|
+
rescue SystemCallError => e
|
|
30
|
+
warn "lemans: could not delete #{entry}: #{e.message}"
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
prune_emptied_parents(deleted)
|
|
34
|
+
deleted
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
attr_reader :runs_dir, :tasks, :ttl_sec
|
|
40
|
+
|
|
41
|
+
def select_matches
|
|
42
|
+
return [] unless runs_dir.directory?
|
|
43
|
+
|
|
44
|
+
runs_dir.glob("**/").map(&:cleanpath).sort.select do |entry|
|
|
45
|
+
task = task_name(entry)
|
|
46
|
+
next false if task.nil?
|
|
47
|
+
|
|
48
|
+
(tasks.empty? || tasks.include?(task)) &&
|
|
49
|
+
(ttl_sec.nil? || age_sec(entry) > ttl_sec) &&
|
|
50
|
+
(!@invalid || invalid?(entry))
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def prune_emptied_parents(deleted)
|
|
55
|
+
root = runs_dir.cleanpath
|
|
56
|
+
deleted.each do |entry|
|
|
57
|
+
dir = entry.parent
|
|
58
|
+
while dir != root && dir.children.empty?
|
|
59
|
+
dir.rmdir
|
|
60
|
+
dir = dir.parent
|
|
61
|
+
end
|
|
62
|
+
rescue SystemCallError
|
|
63
|
+
next
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def task_name(entry) = TRIAL_DIR.match(entry.basename.to_s)&.[](:task)
|
|
68
|
+
|
|
69
|
+
def age_sec(entry) = Time.now - entry.mtime
|
|
70
|
+
|
|
71
|
+
# A trial that measured nothing; an unreadable result counts — it will
|
|
72
|
+
# never be read as anything else.
|
|
73
|
+
def invalid?(entry)
|
|
74
|
+
JSON.parse(entry.join("result.json").read).dig("outcome", "scored") != true
|
|
75
|
+
rescue JSON::ParserError, SystemCallError, IOError
|
|
76
|
+
true
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lemans
|
|
4
|
+
module Environments
|
|
5
|
+
# What every backend must do, and nothing more: a narrow contract is what
|
|
6
|
+
# makes a second backend a day of work instead of a subsystem.
|
|
7
|
+
class Base
|
|
8
|
+
# Backends interleave a command's streams before we ever see them, so one
|
|
9
|
+
# output field is the honest shape.
|
|
10
|
+
ExecResult = Data.define(:command, :exit_code, :output, :duration_sec) do
|
|
11
|
+
def success? = exit_code.zero?
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
attr_reader :image, :resources, :network, :env, :labels, :build_timeout_sec
|
|
15
|
+
|
|
16
|
+
# `labels` is backend-agnostic trial metadata (task, trial id, phase);
|
|
17
|
+
# every backend receives it even if it has nowhere to put it.
|
|
18
|
+
def initialize(image:, resources:, network:, env: {}, labels: {}, build_timeout_sec: nil)
|
|
19
|
+
@image = image
|
|
20
|
+
@resources = resources
|
|
21
|
+
@network = network
|
|
22
|
+
@env = env
|
|
23
|
+
@labels = labels
|
|
24
|
+
@build_timeout_sec = build_timeout_sec
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Build the image and bring the sandbox up under the network policy it was
|
|
28
|
+
# constructed with; a backend that cannot honour the policy must raise.
|
|
29
|
+
def start = raise(NotImplementedError)
|
|
30
|
+
|
|
31
|
+
DEFAULT_TIMEOUT = 60
|
|
32
|
+
|
|
33
|
+
def exec(command, timeout: nil, env: {}) = raise(NotImplementedError)
|
|
34
|
+
|
|
35
|
+
def upload(local_path, remote_path) = raise(NotImplementedError)
|
|
36
|
+
|
|
37
|
+
def download(remote_path, local_path) = raise(NotImplementedError)
|
|
38
|
+
|
|
39
|
+
# Phases change what the sandbox may reach: setup pulls packages, the agent
|
|
40
|
+
# reaches the model API and nothing else, the verifier reaches nothing.
|
|
41
|
+
def network_policy=(policy)
|
|
42
|
+
raise NotImplementedError
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def stop = raise(NotImplementedError)
|
|
46
|
+
|
|
47
|
+
def exec!(command, **)
|
|
48
|
+
result = exec(command, **)
|
|
49
|
+
return result if result.success?
|
|
50
|
+
|
|
51
|
+
raise InfrastructureError, "#{command} exited #{result.exit_code}: #{result.output.to_s[0, 2000]}"
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "daytona"
|
|
4
|
+
|
|
5
|
+
module Lemans
|
|
6
|
+
module Environments
|
|
7
|
+
class Daytona
|
|
8
|
+
# Another try for calls whose repeat is free. Only reads qualify: a
|
|
9
|
+
# mutation may have landed server-side before its failure surfaced.
|
|
10
|
+
module Retries
|
|
11
|
+
# The snapshot service leaks the generated client's own error classes
|
|
12
|
+
# instead of wrapping them, so both dialects have to be caught.
|
|
13
|
+
SDK_ERRORS = [::Daytona::Sdk::Error, *::Daytona::Sdk::API_ERROR_CLASSES].freeze
|
|
14
|
+
|
|
15
|
+
READ_ATTEMPTS = 3
|
|
16
|
+
RETRY_DELAY_SEC = 2
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def with_read_retries
|
|
21
|
+
attempts = 0
|
|
22
|
+
begin
|
|
23
|
+
yield
|
|
24
|
+
rescue *SDK_ERRORS => e
|
|
25
|
+
attempts += 1
|
|
26
|
+
raise if attempts >= READ_ATTEMPTS || !retryable?(e)
|
|
27
|
+
|
|
28
|
+
sleep RETRY_DELAY_SEC
|
|
29
|
+
retry
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Transport failures surface as status 0 (libcurl stamps refused/reset/
|
|
34
|
+
# DNS with code 0) or none, and throttling and server errors heal on
|
|
35
|
+
# their own; any other 4xx would fail the same way again.
|
|
36
|
+
def retryable?(error)
|
|
37
|
+
status = status_code(error)
|
|
38
|
+
status.nil? || status.zero? || status == 429 || status >= 500
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def status_code(error)
|
|
42
|
+
return error.status_code if error.is_a?(::Daytona::Sdk::Error)
|
|
43
|
+
|
|
44
|
+
::Daytona::Sdk.api_error_details(error)[:status_code]
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "daytona"
|
|
4
|
+
require "logger"
|
|
5
|
+
|
|
6
|
+
module Lemans
|
|
7
|
+
module Environments
|
|
8
|
+
class Daytona
|
|
9
|
+
# Repairs the Daytona SDK needs to be usable from a harness. Upstream ask: per-request
|
|
10
|
+
# timeouts like the Python SDK's — tracked in tmp/upstream-daytona-sdk-timeouts.md.
|
|
11
|
+
module SdkTweaks
|
|
12
|
+
GENERATED_CLIENTS = [
|
|
13
|
+
::DaytonaApiClient, ::DaytonaToolboxApiClient, ::DaytonaAnalyticsApiClient
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
# Must clear the longest request: an exec long-polling server-side for
|
|
17
|
+
# SHORT_COMMAND_SEC. File transfers do not ride this cap — they go
|
|
18
|
+
# through the SDK's streaming API with their own TRANSFER_TIMEOUT.
|
|
19
|
+
HTTP_TIMEOUT_SEC = Shell::SHORT_COMMAND_SEC + 30
|
|
20
|
+
|
|
21
|
+
# The clients default to timeout=0, libcurl's "never time out"; a dropped connection then
|
|
22
|
+
# parks a thread no Thread#kill reclaims. Only that 0 is replaced; explicit config wins.
|
|
23
|
+
module Deadline
|
|
24
|
+
def timeout
|
|
25
|
+
value = super
|
|
26
|
+
value&.zero? ? HTTP_TIMEOUT_SEC : value
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# `.configure` is broken — it sets a default config the SDK never reads — and `Sdk.logger`
|
|
31
|
+
# memoizes with no writer, so both are silenced by hand.
|
|
32
|
+
module Quiet
|
|
33
|
+
NULL_LOGGER = Logger.new(IO::NULL)
|
|
34
|
+
|
|
35
|
+
def logger = NULL_LOGGER
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.apply!
|
|
39
|
+
GENERATED_CLIENTS.each { _1::Configuration.prepend(Deadline) }
|
|
40
|
+
return if ENV["DEBUG_DAYTONA"] == "1"
|
|
41
|
+
|
|
42
|
+
GENERATED_CLIENTS.each { _1::Configuration.prepend(Quiet) }
|
|
43
|
+
# The same prepend seam as everything else: poking the @logger ivar
|
|
44
|
+
# would become a silent no-op if the SDK ever renamed it.
|
|
45
|
+
::Daytona::Sdk.singleton_class.prepend(Quiet)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|