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,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