lemans 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +11 -0
  3. data/README.md +15 -3
  4. data/exe/lemans-remote +105 -12
  5. data/lib/lemans/agents/oracle.rb +13 -7
  6. data/lib/lemans/cli/templates/bench/README.md +7 -2
  7. data/lib/lemans/cli/templates/bench/bench.yml +3 -1
  8. data/lib/lemans/cli/templates/bench/tasks/example-task/instruction.md +20 -9
  9. data/lib/lemans/cli/templates/bench/tasks/example-task/solution.1.patch +17 -0
  10. data/lib/lemans/cli/templates/bench/tasks/example-task/solution.2.patch +17 -0
  11. data/lib/lemans/cli/templates/bench/tasks/example-task/verification_test.1.rb +31 -0
  12. data/lib/lemans/cli/templates/bench/tasks/example-task/verification_test.rb +1 -1
  13. data/lib/lemans/config/agent.rb +12 -0
  14. data/lib/lemans/config/environment.rb +28 -2
  15. data/lib/lemans/config/network_policy.rb +2 -0
  16. data/lib/lemans/config/setup.rb +6 -1
  17. data/lib/lemans/config/verifier.rb +18 -7
  18. data/lib/lemans/config.rb +44 -9
  19. data/lib/lemans/environments/docker.rb +1 -1
  20. data/lib/lemans/ext/deep_merge.rb +13 -0
  21. data/lib/lemans/result.rb +41 -1
  22. data/lib/lemans/runner.rb +1 -1
  23. data/lib/lemans/stores/fs.rb +1 -1
  24. data/lib/lemans/task_definition.rb +158 -10
  25. data/lib/lemans/trial/patch.rb +46 -8
  26. data/lib/lemans/trial/verifier/assets/lemans_minitest_reporter.rb +28 -3
  27. data/lib/lemans/trial.rb +83 -33
  28. data/lib/lemans/version.rb +1 -1
  29. data/lib/miniswen/agent.rb +12 -6
  30. data/lib/miniswen/cli/reporter.rb +104 -0
  31. data/lib/miniswen/cli.rb +36 -82
  32. data/lib/miniswen/environment/docker.rb +69 -0
  33. data/lib/miniswen/version.rb +1 -1
  34. metadata +7 -2
  35. data/lib/lemans/cli/templates/bench/tasks/example-task/solution.patch +0 -7
@@ -3,6 +3,21 @@
3
3
  require "json"
4
4
 
5
5
  module LemansReport
6
+ # A check the task wants recorded but not graded
7
+ # Inherit from Skip to let the tests pass.
8
+ class AllowedFailure < Minitest::Skip; end
9
+
10
+ module Assertions
11
+ # Allow failing minitest assertions inside the block (but halt and record them as allowed failures not affected the grade)
12
+ def allow_failure
13
+ yield
14
+ rescue Minitest::Skip
15
+ raise
16
+ rescue Minitest::Assertion => e
17
+ raise AllowedFailure, e.message
18
+ end
19
+ end
20
+
6
21
  # Appends every Minitest result to $LOGS/checks.json. Required by
7
22
  # eport-lemans once Minitest is loaded; never load this file directly.
8
23
  class Reporter < Minitest::AbstractReporter
@@ -22,16 +37,21 @@ module LemansReport
22
37
  return if graded.empty? && prior.empty?
23
38
 
24
39
  checks = prior.merge(graded.to_h { [ name(it), status(it) ] }).sort.to_h
40
+ allowed = existing.fetch("allowed_failures", {}).merge(graded.select { allowed?(it) }.to_h { [ name(it), it.failure.message ] }).sort.to_h
25
41
  File.write(
26
42
  File.join(@dir, "checks.json"),
27
- JSON.pretty_generate(checks: checks, failures: checks.reject { |_, status| status == "pass" }.keys)
43
+ JSON.pretty_generate(
44
+ checks: checks,
45
+ failures: checks.reject { |_, status| status == "pass" || status == ALLOWED }.keys,
46
+ allowed_failures: allowed
47
+ )
28
48
  )
29
49
  end
30
50
 
31
51
  # A skip inside the harness-shipped tests is an unverified requirement and
32
52
  # fails the run. The app's own suite keeps vanilla skip semantics.
33
53
  def passed?
34
- @results.none? { |result| graded?(result) && result.skipped? }
54
+ @results.none? { |result| graded?(result) && result.skipped? && !allowed?(result) }
35
55
  end
36
56
 
37
57
  private
@@ -50,8 +70,13 @@ module LemansReport
50
70
 
51
71
  def name(result) = "#{result.klass}##{result.name}"
52
72
 
73
+ ALLOWED = "fail (allowed)"
74
+
75
+ def allowed?(result) = result.skipped? && result.failure.is_a?(AllowedFailure)
76
+
53
77
  def status(result)
54
- if result.skipped? then "skip"
78
+ if allowed?(result) then ALLOWED
79
+ elsif result.skipped? then "skip"
55
80
  elsif result.error? then "error"
56
81
  elsif result.passed? then "pass"
57
82
  else "fail"
data/lib/lemans/trial.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "json"
4
4
  require "pathname"
5
+ require "shellwords"
5
6
  require "time"
6
7
 
7
8
  module Lemans
@@ -11,7 +12,7 @@ module Lemans
11
12
  class Trial
12
13
  attr_reader :task, :config, :model, :agent_name, :environment, :result
13
14
 
14
- private attr_reader :agent, :store, :snapshot, :patch
15
+ private attr_reader :agent, :store, :snapshot, :patch, :current_step_index
15
16
 
16
17
  def initialize(task, model = nil, result: nil, store: nil, agent: nil, environment: nil)
17
18
  @task = task
@@ -44,6 +45,7 @@ module Lemans
44
45
 
45
46
  @snapshot = nil
46
47
  @patch = nil
48
+ @current_step_index = nil
47
49
  end
48
50
 
49
51
  def run
@@ -67,42 +69,60 @@ module Lemans
67
69
  environment.switch_network_policy!(config.agent.environment.network)
68
70
  end
69
71
 
70
- response =
71
- phase(:agent) do
72
- agent.run(task, environment)
73
- rescue InfrastructureError, ::Miniswen::InfrastructureError => e
74
- # Mark the failure here, where the agent phase is still known
75
- result.failed!(:agent_error, e.message)
76
- raise
77
- end
72
+ each_step do |step_task|
73
+ response =
74
+ phase(:agent) do
75
+ agent.run(step_task, environment)
76
+ rescue InfrastructureError, ::Miniswen::InfrastructureError => e
77
+ # Mark the failure here, where the agent phase is still known
78
+ result.failed!(:agent_error, e.message)
79
+ raise
80
+ end
78
81
 
79
- # Whatever the agent brought back is evidence, a failed run's included
80
- save_trajectory(response.trajectory)
81
- store&.save_artifact(result, response.raw_result, path: "agent.result.json") if response.raw_result
82
+ # Whatever the agent brought back is evidence, a failed run's included
83
+ save_trajectory!(response.trajectory)
84
+ store&.save_artifact(result, response.raw_result, path: with_step_index("agent.result.json")) if response.raw_result
82
85
 
83
- if response.error?
84
- result.failed!(:agent_error, response.error)
85
- return result
86
- end
86
+ if response.error?
87
+ result.failed!(:agent_error, response.error)
88
+ return result
89
+ end
87
90
 
88
- result.completed!(response.outcome, response.usage)
91
+ if task.multistep?
92
+ result.step_completed!(response.outcome, response.usage, duration: result.phases.last.duration)
93
+ else
94
+ result.completed!(response.outcome, response.usage)
95
+ end
89
96
 
90
- check_cost_limit!
97
+ check_cost_limit!
91
98
 
92
- patch.collect!(result, store) if store
99
+ patch.collect!(result, store, path: with_step_index("agent.patch")) if store
100
+ if step_task.final_step?
101
+ patch.compile!(result, store) if task.multistep? && store
102
+ # Don't index the final verification
103
+ @current_step_index = nil
104
+ else
105
+ patch.savepoint!
106
+ end
93
107
 
94
- if result.scored?
95
- phase(:verifier) do
96
- # The sandbox is sealed before the tests arrive
97
- environment.switch_network_policy!(Config::NetworkPolicy.new("none"))
108
+ if result.scored? && step_task.verifiable?
109
+ phase(:verifier) do
110
+ # The sandbox is sealed before the tests arrive
111
+ environment.switch_network_policy!(Config::NetworkPolicy.new("none"))
98
112
 
99
- verification = Verifier.new(task, environment, snapshot).verify! do |evidence, path|
100
- store&.save_artifact(result, evidence, path:)
101
- end
113
+ verification = Verifier.new(step_task, environment, snapshot).verify! do |evidence, path|
114
+ store&.save_artifact(result, evidence, path: with_step_index(path))
115
+ end
102
116
 
103
- store&.save_artifact(result, verification.logs, path: "verifier.log")
117
+ store&.save_artifact(result, verification.logs, path: with_step_index("verifier.log"))
104
118
 
105
- result.graded!(verification.reward)
119
+ if step_task.final_step?
120
+ result.graded!(verification.reward)
121
+ elsif verification.reward.zero?
122
+ result.graded!(0.0)
123
+ throw :halt
124
+ end
125
+ end
106
126
  end
107
127
  end
108
128
 
@@ -125,11 +145,41 @@ module Lemans
125
145
 
126
146
  private
127
147
 
128
- def save_trajectory(trajectory)
148
+ def save_trajectory!(trajectory)
129
149
  return unless trajectory && store
130
150
 
131
- trajectory.session_id = result.id
132
- store.save_artifact(result, JSON.pretty_generate(trajectory.to_atif), path: "trajectory.json")
151
+ path = with_step_index("trajectory.json")
152
+ session_id = with_step_index(result.id)
153
+
154
+ trajectory.session_id = session_id
155
+ store.save_artifact(result, JSON.pretty_generate(trajectory.to_atif), path:)
156
+ end
157
+
158
+ def each_step
159
+ return yield task unless task.multistep?
160
+
161
+ catch(:halt) do
162
+ 1.upto(task.steps) do |index|
163
+ resume_agent! if index > 1
164
+ @current_step_index = index
165
+ yield task.for_step(index)
166
+ end
167
+ end
168
+ end
169
+
170
+ def resume_agent!
171
+ patch.restore!
172
+ environment.exec!("rm -rf #{Verifier::TESTS_DIR} #{Shellwords.escape(task.verifier.logs_dir)}")
173
+ environment.switch_network_policy!(config.agent.environment.network)
174
+ end
175
+
176
+ def with_step_index(path)
177
+ return path unless current_step_index
178
+
179
+ *pre, last = path.to_s.split(".")
180
+ return "#{last}.#{current_step_index}" if pre.empty?
181
+
182
+ [ *pre, current_step_index, last ].join(".")
133
183
  end
134
184
 
135
185
  def check_cost_limit!
@@ -144,10 +194,10 @@ module Lemans
144
194
  end
145
195
 
146
196
  def phase(name)
147
- result.phase_started(name)
197
+ result.phase_started(with_step_index(name).to_sym)
148
198
  yield
149
199
  ensure
150
- result.phase_finished(name)
200
+ result.phase_finished(with_step_index(name).to_sym)
151
201
  end
152
202
  end
153
203
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lemans
4
- VERSION = "1.0.0"
4
+ VERSION = "1.2.0"
5
5
  end
@@ -232,11 +232,14 @@ module Miniswen
232
232
  private attr_reader :max_steps, :max_time, :max_cost, :exec_timeout,
233
233
  :clock, :reporter
234
234
 
235
- # `model` is a litellm-style name ("openrouter/z-ai/glm-5.2"). Limits of 0 or nil are disabled.
235
+ # `model` is a litellm-style name ("openrouter/z-ai/glm-5.2"), optionally
236
+ # suffixed with a reasoning effort ("openrouter/openai/gpt-5.6-luna#xhigh").
237
+ # Limits of 0 or nil are disabled.
236
238
  def initialize(model:, environment:, max_steps: 0, max_time: 0, max_cost: nil,
237
239
  exec_timeout: 30, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) },
238
240
  reporter: nil)
239
- @provider, @id = model.split("/", 2)
241
+ name, @effort = model.split("#", 2)
242
+ @provider, @id = name.split("/", 2)
240
243
  unless @id
241
244
  @id = @provider
242
245
  @provider = nil
@@ -315,8 +318,8 @@ module Miniswen
315
318
  _, provider = resolved
316
319
  env = provider.configuration_requirements.to_h { [ it.to_s.upcase, RubyLLM.config.public_send(it) ] }.compact
317
320
 
318
- order = ENV["LEMANS_PROVIDER_ORDER"]
319
- env["LEMANS_PROVIDER_ORDER"] = order if order
321
+ order = provider_order
322
+ env["OPENROUTER_PROVIDER_ORDER"] = order if order
320
323
  env
321
324
  end
322
325
 
@@ -500,7 +503,8 @@ module Miniswen
500
503
  tools: { bash: @bash_tool },
501
504
  temperature: nil,
502
505
  model: model_info,
503
- params: routing_params
506
+ params: routing_params,
507
+ thinking: (RubyLLM::Thinking::Config.new(effort: @effort) if @effort)
504
508
  )
505
509
  payload(response)
506
510
  rescue RubyLLM::Error => e
@@ -531,12 +535,14 @@ module Miniswen
531
535
  end
532
536
 
533
537
  def routing_params
534
- order = ENV["LEMANS_PROVIDER_ORDER"]
538
+ order = provider_order
535
539
  return {} unless order && @provider == "openrouter"
536
540
 
537
541
  { provider: { order: order.split(",").map(&:strip), allow_fallbacks: false } }
538
542
  end
539
543
 
544
+ def provider_order = ENV["LEMANS_PROVIDER_ORDER"] || ENV["OPENROUTER_PROVIDER_ORDER"]
545
+
540
546
  def cost_source
541
547
  if local?
542
548
  return CostSource.new(name: :local_provider, model: @model,
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Miniswen
4
+ class CLI
5
+ # Prints messages and tool calls in real-time. The renderer deliberately
6
+ # keeps the captured (non-TTY) version plain, which makes it useful in CI
7
+ # and when piping a run to a log file too.
8
+ class Reporter
9
+ private attr_reader :io
10
+
11
+ # Tool output can be extremely noisy (for example, a recursive grep or
12
+ # a test runner dumping a log). Keep the normal report useful while
13
+ # allowing -vv to retain the complete output for debugging.
14
+ MAX_TOOL_OUTPUT_CHARS = 1_000
15
+
16
+ def initialize(io = $stdout, verbose: false, tool_output: verbose, reasoning: true)
17
+ @io = io
18
+ @verbose = verbose
19
+ @tool_output = tool_output
20
+ @reasoning = reasoning
21
+ end
22
+
23
+ def on_message(message)
24
+ case message[:role].to_s
25
+ when "assistant"
26
+ write_assistant(message)
27
+ when "tool"
28
+ write_tool(message)
29
+ when "user"
30
+ write_block("!", message[:content], :warning)
31
+ else
32
+ write_block("·", message[:content], :muted)
33
+ end
34
+ end
35
+
36
+ # Tool calls are reported separately so the command is visible before
37
+ # its output arrives. It is not added to the trajectory sent to the LLM.
38
+ def on_tool_call(call)
39
+ command = call.dig(:arguments, "command") || call.dig(:arguments, :command)
40
+ return if command.to_s.empty?
41
+
42
+ line = style("$ #{command}", :command)
43
+ io.puts(" #{line}")
44
+ end
45
+
46
+ def print_summary(result)
47
+ write_assistant(result.messages.last)
48
+ write_block("↳", "steps=#{result.steps} · cost=$#{result.cost_usd}", :muted)
49
+ end
50
+
51
+ def print_failure(result)
52
+ write_block("!", "Miniswen failed: #{result.status}", :warning)
53
+ end
54
+
55
+ private
56
+
57
+ def write_tool(message)
58
+ return write_block("↳", message[:content], :tool) if @tool_output
59
+
60
+ exit_code = message.dig(:observation, :exit_code)
61
+ return if exit_code.nil?
62
+
63
+ if exit_code.zero?
64
+ write_block("↳", "ok", :muted)
65
+ else
66
+ output = truncate(message.dig(:observation, :output).to_s.strip)
67
+ write_block("!", "not ok: exit #{exit_code}\n#{output}", :warning)
68
+ end
69
+ end
70
+
71
+ def write_assistant(message)
72
+ content = message[:content].to_s.strip
73
+ write_block("∴", message[:thinking], :thinking) if @reasoning && (@verbose || content.empty?)
74
+ write_block("●", content, :assistant)
75
+ end
76
+
77
+ def write_block(marker, content, tone)
78
+ text = content.to_s.strip
79
+ return if text.empty?
80
+
81
+ text = truncate(text) if %i[tool thinking].include?(tone)
82
+ lines = text.lines(chomp: true)
83
+ io.puts("#{style(marker, tone)} #{style(lines.shift, tone)}")
84
+ lines.each { |line| io.puts(" #{style(line, tone)}") }
85
+ end
86
+
87
+ def truncate(text)
88
+ return text if @verbose || text.length <= MAX_TOOL_OUTPUT_CHARS
89
+
90
+ head = MAX_TOOL_OUTPUT_CHARS / 2
91
+ tail = MAX_TOOL_OUTPUT_CHARS - head
92
+ omitted = text.length - head - tail
93
+ "#{text[0, head]}\n... [#{omitted} characters omitted] ...\n#{text[-tail, tail]}"
94
+ end
95
+
96
+ def style(text, tone)
97
+ return text unless io.respond_to?(:tty?) && io.tty?
98
+
99
+ colors = { assistant: 36, tool: 32, warning: 33, command: 35, muted: 90, thinking: 90 }
100
+ "\e[#{colors.fetch(tone)}m#{text}\e[0m"
101
+ end
102
+ end
103
+ end
104
+ end
data/lib/miniswen/cli.rb CHANGED
@@ -1,88 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "optparse"
4
+ require "fileutils"
4
5
 
5
6
  require "miniswen/version"
7
+ require "miniswen/cli/reporter"
6
8
 
7
9
  module Miniswen
8
10
  class CLI # :nodoc:
9
- # Prints messages and tool calls in real-time. The renderer deliberately
10
- # keeps the captured (non-TTY) version plain, which makes it useful in CI
11
- # and when piping a run to a log file too.
12
- class Reporter
13
- private attr_reader :io
14
-
15
- # Tool output can be extremely noisy (for example, a recursive grep or
16
- # a test runner dumping a log). Keep the normal report useful while
17
- # allowing -vv to retain the complete output for debugging.
18
- MAX_TOOL_OUTPUT_CHARS = 1_000
19
-
20
- def initialize(io = $stdout, verbose: false)
21
- @io = io
22
- @verbose = verbose
23
- end
24
-
25
- def on_message(message)
26
- case message[:role].to_s
27
- when "assistant"
28
- write_block("●", message[:content], :assistant)
29
- when "tool"
30
- write_block("↳", message[:content], :tool)
31
- when "user"
32
- write_block("!", message[:content], :warning)
33
- else
34
- write_block("·", message[:content], :muted)
35
- end
36
- end
37
-
38
- # Tool calls are reported separately so the command is visible before
39
- # its output arrives. It is not added to the trajectory sent to the LLM.
40
- def on_tool_call(call)
41
- command = call.dig(:arguments, "command") || call.dig(:arguments, :command)
42
- return if command.to_s.empty?
43
-
44
- line = style("$ #{command}", :command)
45
- io.puts(" #{line}")
46
- end
47
-
48
- def print_summary(result)
49
- write_block("●", result.messages.last[:content], :assistant)
50
- write_block("↳", "steps=#{result.steps} · cost=$#{result.cost_usd}", :muted)
51
- end
52
-
53
- def print_failure(result)
54
- write_block("!", "Miniswen failed: #{result.status}", :warning)
55
- end
56
-
57
- private
58
-
59
- def write_block(marker, content, tone)
60
- text = content.to_s.strip
61
- return if text.empty?
62
-
63
- text = truncate_tool_output(text) if tone == :tool
64
- lines = text.lines(chomp: true)
65
- io.puts("#{style(marker, tone)} #{style(lines.shift, tone)}")
66
- lines.each { |line| io.puts(" #{style(line, tone)}") }
67
- end
68
-
69
- def truncate_tool_output(text)
70
- return text if @verbose || text.length <= MAX_TOOL_OUTPUT_CHARS
71
-
72
- head = MAX_TOOL_OUTPUT_CHARS / 2
73
- tail = MAX_TOOL_OUTPUT_CHARS - head
74
- omitted = text.length - head - tail
75
- "#{text[0, head]}\n... [#{omitted} characters omitted] ...\n#{text[-tail, tail]}"
76
- end
77
-
78
- def style(text, tone)
79
- return text unless io.respond_to?(:tty?) && io.tty?
80
-
81
- colors = { assistant: 36, tool: 32, warning: 33, command: 35, muted: 90 }
82
- "\e[#{colors.fetch(tone)}m#{text}\e[0m"
83
- end
84
- end
85
-
86
11
  attr_reader :instruction, :model, :options
87
12
 
88
13
  def initialize
@@ -90,6 +15,8 @@ module Miniswen
90
15
  @model = ENV.fetch("MINISWEN_MODEL", nil)
91
16
  @options = {}
92
17
  @verbose = false
18
+ @show_output = false
19
+ @reasoning = true
93
20
  @quiet = false
94
21
  @results_path = nil
95
22
  @atif_path = nil
@@ -110,8 +37,16 @@ module Miniswen
110
37
 
111
38
  require "miniswen/local"
112
39
 
113
- reporter = @quiet ? nil : Reporter.new(verbose: @verbose)
114
- agent = Agent.new(model:, reporter:, environment: Local.new, **options)
40
+ reporter = @quiet ? nil : Reporter.new(verbose: @verbose, tool_output: @verbose || @show_output, reasoning: @reasoning)
41
+ environment =
42
+ if @docker_id
43
+ require "miniswen/environment/docker"
44
+ Environment::Docker.new(@docker_id)
45
+ else
46
+ Local.new
47
+ end
48
+
49
+ agent = Agent.new(model:, reporter:, environment:, **options)
115
50
 
116
51
  begin
117
52
  result = agent.run(instruction)
@@ -133,8 +68,15 @@ module Miniswen
133
68
  private
134
69
 
135
70
  def write_results(result)
136
- File.write(@results_path, JSON.generate(result.to_h)) if @results_path
137
- write_atif(result) if @atif_path
71
+ if @results_path
72
+ FileUtils.mkdir_p(File.dirname(@results_path))
73
+ File.write(@results_path, JSON.generate(result.to_h))
74
+ end
75
+
76
+ if @atif_path
77
+ FileUtils.mkdir_p(File.dirname(@atif_path))
78
+ write_atif(result)
79
+ end
138
80
  end
139
81
 
140
82
  def error_message(error)
@@ -162,7 +104,7 @@ module Miniswen
162
104
  end
163
105
 
164
106
  opts.on("-p INSTRUCTION", "--prompt=INSTRUCTION", String, "Instruction prompt") do |v|
165
- @instruction = v
107
+ @instruction = File.file?(v) ? File.read(v) : v
166
108
  end
167
109
 
168
110
  opts.on("--max-steps=STEPS", Integer, "Max steps count") do |v|
@@ -185,6 +127,14 @@ module Miniswen
185
127
  @quiet = true
186
128
  end
187
129
 
130
+ opts.on("--show-output", "Print tool output instead of just the exit status") do
131
+ @show_output = true
132
+ end
133
+
134
+ opts.on("--no-reasoning", "Hide the model's reasoning") do
135
+ @reasoning = false
136
+ end
137
+
188
138
  opts.on("--results-path=PATH", String, "Write the run result as JSON to PATH") do |v|
189
139
  @results_path = v
190
140
  end
@@ -193,6 +143,10 @@ module Miniswen
193
143
  @atif_path = v
194
144
  end
195
145
 
146
+ opts.on("--docker=ID", String, "Docker container ID to exec commands on") do |v|
147
+ @docker_id = v
148
+ end
149
+
196
150
  opts.on("--refresh-registry", "Refresh the model registry, persist it, and exit") do
197
151
  @refresh_registry = true
198
152
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ require "miniswen/environment"
6
+
7
+ module Miniswen
8
+ class Environment
9
+ class Docker < self
10
+ TIMEOUT_MARKED_EXIT_CODES = [ 124, 143 ].freeze
11
+
12
+ private attr_reader :id
13
+
14
+ def initialize(id)
15
+ @id = id
16
+ end
17
+
18
+ def exec(command, timeout: nil, env: nil)
19
+ argv = [ "docker", "exec" ]
20
+ env&.each { |key, value| argv += [ "--env", "#{key}=#{value}" ] }
21
+ argv << id
22
+ argv += [ "timeout", timeout.ceil.to_s ] if timeout&.positive?
23
+ argv += [ "sh", "-c", command ]
24
+
25
+ Open3.popen2e(*argv) do |stdin, pipe, wait|
26
+ stdin.close
27
+ deadline = (now + timeout + 10 if timeout&.positive?) # add some slack
28
+ output = +""
29
+ timed_out = false
30
+
31
+ loop do
32
+ remaining = deadline && deadline - now
33
+ if remaining && remaining <= 0
34
+ timed_out = true
35
+ kill(wait.pid)
36
+ break
37
+ end
38
+ next unless pipe.wait_readable(remaining)
39
+
40
+ chunk = pipe.read_nonblock(65_536, exception: false)
41
+ break if chunk.nil?
42
+ next if chunk == :wait_readable
43
+
44
+ output << chunk
45
+ end
46
+
47
+ status = wait.value
48
+ exit_code = timed_out ? 124 : (status.exitstatus || 1)
49
+ output = output.force_encoding(Encoding::UTF_8).scrub
50
+ if timeout&.positive? && (timed_out || TIMEOUT_MARKED_EXIT_CODES.include?(exit_code))
51
+ output = "#{output}\n<command timed out after #{timeout} seconds>"
52
+ end
53
+
54
+ ExecResult.new(exit_code:, output:)
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def kill(pid)
61
+ Process.kill("KILL", pid)
62
+ rescue Errno::ESRCH
63
+ nil
64
+ end
65
+
66
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
67
+ end
68
+ end
69
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Miniswen
4
- VERSION = "1.0.0"
4
+ VERSION = "1.2.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lemans
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.0
4
+ version: 1.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Svyatoslav Kryukov
@@ -170,7 +170,9 @@ files:
170
170
  - lib/lemans/cli/templates/bench/environment/Dockerfile
171
171
  - lib/lemans/cli/templates/bench/tasks/example-task/environment.patch
172
172
  - lib/lemans/cli/templates/bench/tasks/example-task/instruction.md
173
- - lib/lemans/cli/templates/bench/tasks/example-task/solution.patch
173
+ - lib/lemans/cli/templates/bench/tasks/example-task/solution.1.patch
174
+ - lib/lemans/cli/templates/bench/tasks/example-task/solution.2.patch
175
+ - lib/lemans/cli/templates/bench/tasks/example-task/verification_test.1.rb
174
176
  - lib/lemans/cli/templates/bench/tasks/example-task/verification_test.rb
175
177
  - lib/lemans/cli/templates/bench/tasks/hello-world/environment.patch
176
178
  - lib/lemans/cli/templates/bench/tasks/hello-world/instruction.md
@@ -196,6 +198,7 @@ files:
196
198
  - lib/lemans/environments/daytona/shell.rb
197
199
  - lib/lemans/environments/daytona/snapshot_store.rb
198
200
  - lib/lemans/environments/docker.rb
201
+ - lib/lemans/ext/deep_merge.rb
199
202
  - lib/lemans/result.rb
200
203
  - lib/lemans/runner.rb
201
204
  - lib/lemans/runner/executor.rb
@@ -215,7 +218,9 @@ files:
215
218
  - lib/miniswen.rb
216
219
  - lib/miniswen/agent.rb
217
220
  - lib/miniswen/cli.rb
221
+ - lib/miniswen/cli/reporter.rb
218
222
  - lib/miniswen/environment.rb
223
+ - lib/miniswen/environment/docker.rb
219
224
  - lib/miniswen/local.rb
220
225
  - lib/miniswen/ruby_llm.rb
221
226
  - lib/miniswen/testing.rb
@@ -1,7 +0,0 @@
1
- diff --git a/fizzbuzz.rb b/fizzbuzz.rb
2
- index 345b032..2c15f7d 100644
3
- --- a/fizzbuzz.rb
4
- +++ b/fizzbuzz.rb
5
- @@ -1 +1 @@
6
- -1.upto(100) { |i| puts i }
7
- +1.upto(100) { |i| s = "#{"Fizz" if i % 3 == 0}#{"Buzz" if i % 5 == 0}"; puts s.empty? ? i : s }