lemans 0.0.0.pre → 0.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 (56) 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/lib/lemans/agents/base.rb +30 -0
  7. data/lib/lemans/agents/miniswen.rb +119 -0
  8. data/lib/lemans/agents/miniswen_installed.rb +67 -0
  9. data/lib/lemans/agents/nop.rb +15 -0
  10. data/lib/lemans/agents/oracle.rb +53 -0
  11. data/lib/lemans/agents.rb +21 -0
  12. data/lib/lemans/bench.rb +280 -0
  13. data/lib/lemans/cli/board_reporter.rb +135 -0
  14. data/lib/lemans/cli/progress_reporter.rb +67 -0
  15. data/lib/lemans/cli.rb +181 -0
  16. data/lib/lemans/clobber.rb +79 -0
  17. data/lib/lemans/environments/base.rb +55 -0
  18. data/lib/lemans/environments/daytona/retries.rb +49 -0
  19. data/lib/lemans/environments/daytona/sdk_tweaks.rb +50 -0
  20. data/lib/lemans/environments/daytona/shell.rb +142 -0
  21. data/lib/lemans/environments/daytona/snapshot_store.rb +163 -0
  22. data/lib/lemans/environments/daytona.rb +175 -0
  23. data/lib/lemans/environments.rb +16 -0
  24. data/lib/lemans/network_policy.rb +66 -0
  25. data/lib/lemans/patch.rb +70 -0
  26. data/lib/lemans/restore_paths.rb +21 -0
  27. data/lib/lemans/results/aggregate.rb +114 -0
  28. data/lib/lemans/results/cost_source.rb +13 -0
  29. data/lib/lemans/results/outcome.rb +36 -0
  30. data/lib/lemans/results/report.rb +149 -0
  31. data/lib/lemans/results/sorting.rb +24 -0
  32. data/lib/lemans/results/tally.rb +19 -0
  33. data/lib/lemans/results/usage.rb +24 -0
  34. data/lib/lemans/run.rb +152 -0
  35. data/lib/lemans/setup.rb +59 -0
  36. data/lib/lemans/setup_files.rb +36 -0
  37. data/lib/lemans/snapshot.rb +55 -0
  38. data/lib/lemans/task.rb +207 -0
  39. data/lib/lemans/tree_digest.rb +24 -0
  40. data/lib/lemans/trial.rb +187 -0
  41. data/lib/lemans/units.rb +44 -0
  42. data/lib/lemans/verifier/assets/eport-lemans.rb +36 -0
  43. data/lib/lemans/verifier/assets/lemans_minitest_reporter.rb +61 -0
  44. data/lib/lemans/verifier.rb +199 -0
  45. data/lib/lemans/version.rb +5 -0
  46. data/lib/lemans.rb +29 -0
  47. data/lib/miniswen/agent.rb +669 -0
  48. data/lib/miniswen/cli.rb +224 -0
  49. data/lib/miniswen/environment.rb +14 -0
  50. data/lib/miniswen/local.rb +42 -0
  51. data/lib/miniswen/ruby_llm.rb +42 -0
  52. data/lib/miniswen/testing.rb +134 -0
  53. data/lib/miniswen/trajectory.rb +110 -0
  54. data/lib/miniswen/version.rb +5 -0
  55. data/lib/miniswen.rb +48 -0
  56. metadata +160 -7
@@ -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
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "daytona"
4
+ require "securerandom"
5
+ require "shellwords"
6
+
7
+ module Lemans
8
+ module Environments
9
+ class Daytona
10
+ # Runs one sandbox's commands. Long commands run detached and are polled — a direct call
11
+ # would die at the HTTP deadline — and no async exit code exists, so the command writes its own.
12
+ class Shell
13
+ include Retries
14
+
15
+ SHORT_COMMAND_SEC = 120
16
+ POLL_INTERVAL_SEC = 2
17
+ MAX_OUTPUT_BYTES = 200_000
18
+ HOUSEKEEPING_TIMEOUT = 60
19
+
20
+ def initialize(sandbox)
21
+ @sandbox = sandbox
22
+ @session_id = fresh_session_id
23
+ sandbox.process.create_session(@session_id)
24
+ end
25
+
26
+ def exec(command, timeout: nil, env: {})
27
+ validate_env!(env)
28
+ started = now
29
+ response =
30
+ if timeout && timeout > SHORT_COMMAND_SEC
31
+ exec_in_session(command, timeout: timeout, env: env)
32
+ else
33
+ exec_directly(command, timeout: timeout, env: env)
34
+ end
35
+
36
+ Base::ExecResult.new(command: command, duration_sec: (now - started).round(3), **response)
37
+ end
38
+
39
+ private
40
+
41
+ attr_reader :sandbox
42
+
43
+ # Both transports must accept the same env: the session path serializes
44
+ # keys into `export` lines, where a key that is not a shell identifier
45
+ # would fail silently and run the command without its variable.
46
+ def validate_env!(env)
47
+ env.each_key do |key|
48
+ next if key.to_s.match?(/\A[A-Za-z_]\w*\z/)
49
+
50
+ raise ConfigError, "environment variable #{key.inspect} is not a shell identifier"
51
+ end
52
+ end
53
+
54
+ def exec_directly(command, timeout:, env:)
55
+ response = sandbox.process.exec(
56
+ command: command,
57
+ env: env.empty? ? nil : env,
58
+ timeout: timeout&.to_i
59
+ )
60
+ { exit_code: response.exit_code, output: response.result.to_s }
61
+ end
62
+
63
+ def exec_in_session(command, timeout:, env:)
64
+ run_id = SecureRandom.hex(6)
65
+ status_file = "/tmp/lemans-#{run_id}.status"
66
+ log_file = "/tmp/lemans-#{run_id}.log"
67
+ exports = env.map { |key, value| "export #{key}=#{Shellwords.escape(value.to_s)}" }.join("\n")
68
+
69
+ sandbox.process.execute_session_command(
70
+ session_id: @session_id,
71
+ req: ::Daytona::SessionExecuteRequest.new(
72
+ # A subshell, not a brace group: `set -e`/`exit` would take the
73
+ # session's own shell down and leave the status line unwritten.
74
+ command: "(\n#{exports}\n#{command}\n) > #{Shellwords.escape(log_file)} 2>&1\n" \
75
+ "echo $? > #{Shellwords.escape(status_file)}",
76
+ run_async: true
77
+ )
78
+ )
79
+
80
+ exit_code = nil
81
+ begin
82
+ exit_code = await_status(status_file, timeout)
83
+ { exit_code: exit_code || 124, output: tail(log_file) }
84
+ ensure
85
+ # On every exit path, including a poll that died: a command that
86
+ # outran its budget must not keep running, and the scratch files
87
+ # must not stay for the model to find. Session torn down first,
88
+ # for the best odds the command is dead before the rm runs.
89
+ terminate_session! if exit_code.nil?
90
+ clear_scratch(log_file, status_file)
91
+ end
92
+ end
93
+
94
+ def clear_scratch(*paths)
95
+ command = "rm -f #{paths.map { Shellwords.escape(_1) }.join(" ")}"
96
+ exec_directly(command, timeout: HOUSEKEEPING_TIMEOUT, env: {})
97
+ rescue *SDK_ERRORS => e
98
+ warn "lemans: could not clear #{paths.join(", ")}: #{e.message}"
99
+ end
100
+
101
+ def terminate_session!
102
+ sandbox.process.delete_session(@session_id)
103
+ rescue StandardError => e
104
+ warn "lemans: could not delete session #{@session_id}: #{e.message}"
105
+ ensure
106
+ @session_id = fresh_session_id
107
+ begin
108
+ sandbox.process.create_session(@session_id)
109
+ rescue StandardError => e
110
+ warn "lemans: could not open a replacement session: #{e.message}"
111
+ end
112
+ end
113
+
114
+ def await_status(status_file, timeout)
115
+ deadline = now + timeout
116
+ poll = "cat #{Shellwords.escape(status_file)} 2>/dev/null"
117
+ loop do
118
+ status = with_read_retries { exec_directly(poll, timeout: HOUSEKEEPING_TIMEOUT, env: {}) }
119
+ value = status[:output].to_s.strip
120
+ return Integer(value) if value.match?(/\A\d+\z/)
121
+ return nil if now > deadline
122
+
123
+ sleep POLL_INTERVAL_SEC
124
+ end
125
+ end
126
+
127
+ def tail(log_file, bytes: MAX_OUTPUT_BYTES)
128
+ with_read_retries do
129
+ exec_directly("tail -c #{bytes} #{Shellwords.escape(log_file)} 2>/dev/null",
130
+ timeout: HOUSEKEEPING_TIMEOUT, env: {})
131
+ # Scrubbed where bytes become a string: tail -c cuts on a byte
132
+ # boundary and can split a multibyte character.
133
+ end[:output].to_s.scrub
134
+ end
135
+
136
+ def fresh_session_id = "lemans-#{SecureRandom.hex(6)}"
137
+
138
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+ require "daytona"
5
+ require "digest"
6
+ require "timeout"
7
+
8
+ module Lemans
9
+ module Environments
10
+ class Daytona
11
+ # Content-named snapshots: one build per image digest × resource shape,
12
+ # reused forever after.
13
+ class SnapshotStore
14
+ include Retries
15
+
16
+ POLL_INTERVAL_SEC = 2
17
+
18
+ SNAPSHOT_FAILED = [
19
+ ::DaytonaApiClient::SnapshotState::ERROR,
20
+ ::DaytonaApiClient::SnapshotState::BUILD_FAILED
21
+ ].freeze
22
+
23
+ # Two trials that want the same missing snapshot must not both build
24
+ # it; across processes the name collision settles it and the loser waits.
25
+ LOCKS = Concurrent::Map.new
26
+
27
+ def self.lock(name)
28
+ LOCKS.compute_if_absent(name) { Mutex.new }
29
+ end
30
+
31
+ def initialize(client:, image:, resources:, build_timeout_sec:, logger: nil)
32
+ @client = client
33
+ @image = image
34
+ @resources = resources
35
+ @build_timeout_sec = build_timeout_sec
36
+ @logger = logger
37
+ end
38
+
39
+ def call
40
+ self.class.lock(name).synchronize do
41
+ existing = find(name)
42
+ existing = discard(existing) if existing && SNAPSHOT_FAILED.include?(existing.state)
43
+ existing ? await_ready(existing) : build(name)
44
+ end
45
+ name
46
+ end
47
+
48
+ # The lookup name and the whole reuse policy: image digest plus resource
49
+ # shape, because a sandbox inherits resources from its snapshot.
50
+ def name
51
+ @name ||= begin
52
+ shape = [image.digest, resources.cpus, resources.memory_mb, resources.storage_mb].join(":")
53
+ "lemans-#{Digest::SHA256.hexdigest(shape)[0, 32]}"
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ attr_reader :client, :image, :resources, :build_timeout_sec, :logger
60
+
61
+ # A failed build keeps the name, so it would wedge every future trial
62
+ # sharing that image. The state is terminal; rebuilding is all that is left.
63
+ def discard(snapshot)
64
+ client.snapshot.delete(snapshot)
65
+
66
+ deadline = now + build_timeout_sec
67
+ while find(name)
68
+ raise InfrastructureError, "daytona: failed snapshot #{name} would not go away" if now > deadline
69
+
70
+ sleep POLL_INTERVAL_SEC
71
+ end
72
+ nil
73
+ rescue *SDK_ERRORS => e
74
+ # Another process discarding the same failed snapshot got there
75
+ # first: gone is the goal state, same as find's 404 policy.
76
+ return nil if status_code(e) == 404
77
+
78
+ raise InfrastructureError, "daytona: could not remove failed snapshot #{name}: #{e.message}"
79
+ end
80
+
81
+ def find(name)
82
+ with_read_retries { client.snapshot.get(name) }
83
+ rescue *SDK_ERRORS => e
84
+ return nil if status_code(e) == 404
85
+
86
+ raise InfrastructureError, "daytona: could not look up snapshot #{name}: #{e.message}"
87
+ end
88
+
89
+ # The SDK's blocking create has no budget of its own, so
90
+ # environment.build_timeout is enforced here or nowhere.
91
+ def build(name)
92
+ params = ::Daytona::CreateSnapshotParams.new(
93
+ name: name,
94
+ # A published reference goes in as a one-line `FROM`, not a bare name:
95
+ # Daytona mangles a bare name's `@sha256:` pin into an invalid reference.
96
+ image: image.built? ? ::Daytona::Image.from_dockerfile(image.dockerfile_path.to_s) : base_image,
97
+ resources: daytona_resources
98
+ )
99
+ Timeout.timeout(build_timeout_sec, InfrastructureError,
100
+ "daytona: snapshot #{name} did not build within #{build_timeout_sec}s") do
101
+ client.snapshot.create(params, on_logs: logger)
102
+ end
103
+ rescue *SDK_ERRORS => e
104
+ unless status_code(e) == 409
105
+ raise InfrastructureError,
106
+ "daytona: could not build snapshot #{name}: #{e.message}"
107
+ end
108
+
109
+ # Another process reached the same missing snapshot first; this trial
110
+ # waits for its build rather than failing.
111
+ taken = find(name)
112
+ raise InfrastructureError, "daytona: snapshot #{name} was taken and then vanished" if taken.nil?
113
+
114
+ await_ready(taken)
115
+ end
116
+
117
+ # A snapshot that exists may still be building for whoever won the race,
118
+ # or deactivated from disuse — a weekly run will hit that.
119
+ def await_ready(snapshot)
120
+ deadline = now + build_timeout_sec
121
+ activated = false
122
+
123
+ loop do
124
+ state = snapshot.state
125
+ return if state == ::DaytonaApiClient::SnapshotState::ACTIVE
126
+
127
+ raise InfrastructureError, "daytona: snapshot #{name} is #{state}: #{snapshot.error_reason}" if SNAPSHOT_FAILED.include?(state)
128
+
129
+ if state == ::DaytonaApiClient::SnapshotState::INACTIVE && !activated
130
+ client.snapshot.activate(snapshot)
131
+ activated = true
132
+ end
133
+
134
+ raise InfrastructureError, "daytona: snapshot #{name} was still #{state} after #{build_timeout_sec}s" if now > deadline
135
+
136
+ sleep POLL_INTERVAL_SEC
137
+ snapshot = find(name)
138
+ if snapshot.nil?
139
+ raise InfrastructureError,
140
+ "daytona: snapshot #{name} vanished while it was being waited on"
141
+ end
142
+ end
143
+ rescue *SDK_ERRORS => e
144
+ raise InfrastructureError, "daytona: could not wait for snapshot #{name}: #{e.message}"
145
+ end
146
+
147
+ def base_image = ::Daytona::Image.base(image.reference)
148
+
149
+ def daytona_resources
150
+ ::Daytona::Resources.new(
151
+ cpu: resources.cpus,
152
+ memory: to_gib(resources.memory_mb),
153
+ disk: to_gib(resources.storage_mb)
154
+ )
155
+ end
156
+
157
+ def to_gib(megabytes) = [(megabytes / 1024.0).ceil, 1].max
158
+
159
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
160
+ end
161
+ end
162
+ end
163
+ end