lemans 1.4.0 → 1.5.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 +16 -0
- data/README.md +17 -2
- data/exe/lemans-remote +141 -3
- data/exe/lemans-viewer +998 -0
- data/lib/lemans/agent.rb +4 -1
- data/lib/lemans/agents/miniswen.rb +10 -5
- data/lib/lemans/agents/miniswen_installed.rb +16 -5
- data/lib/lemans/agents/nop.rb +1 -1
- data/lib/lemans/agents/oracle.rb +1 -1
- data/lib/lemans/agents.rb +4 -2
- data/lib/lemans/cli/regrade.rb +29 -77
- data/lib/lemans/cli/report/aggregate.rb +54 -30
- data/lib/lemans/cli/report.rb +168 -28
- data/lib/lemans/cli.rb +110 -34
- data/lib/lemans/environments/docker/proxy/Dockerfile +4 -0
- data/lib/lemans/environments/docker/proxy/README.md +49 -0
- data/lib/lemans/environments/docker/proxy/server.rb +79 -0
- data/lib/lemans/environments/docker.rb +79 -16
- data/lib/lemans/result.rb +65 -6
- data/lib/lemans/runner/task.rb +9 -5
- data/lib/lemans/runner.rb +45 -2
- data/lib/lemans/store.rb +6 -0
- data/lib/lemans/stores/fs.rb +16 -4
- data/lib/lemans/trial/patch.rb +21 -0
- data/lib/lemans/trial/verifier/test_scanner.rb +136 -0
- data/lib/lemans/trial/verifier.rb +19 -10
- data/lib/lemans/trial.rb +108 -40
- data/lib/lemans/version.rb +1 -1
- data/lib/lemans.rb +2 -0
- data/lib/miniswen/agent.rb +52 -16
- data/lib/miniswen/cli.rb +8 -3
- data/lib/miniswen/environment/docker.rb +1 -1
- data/lib/miniswen/jail.rb +1 -1
- data/lib/miniswen/local.rb +1 -1
- data/lib/miniswen/version.rb +1 -1
- metadata +49 -1
|
@@ -12,6 +12,7 @@ module Lemans
|
|
|
12
12
|
HOUSEKEEPING_TIMEOUT = 60
|
|
13
13
|
MAX_OUTPUT_BYTES = 200_000
|
|
14
14
|
EXEC_SLACK = 30
|
|
15
|
+
PROXY_PORT = 3128
|
|
15
16
|
|
|
16
17
|
attr_reader :container
|
|
17
18
|
|
|
@@ -24,7 +25,8 @@ module Lemans
|
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
def start
|
|
27
|
-
build_image! if image.built?
|
|
28
|
+
build_image!(image) if image.built?
|
|
29
|
+
start_proxy!(network.hosts) if network.allowlist?
|
|
28
30
|
docker!("run", *run_args, timeout: build_timeout)
|
|
29
31
|
@container = @name
|
|
30
32
|
self
|
|
@@ -37,9 +39,10 @@ module Lemans
|
|
|
37
39
|
timeout ||= DEFAULT_TIMEOUT
|
|
38
40
|
started = now
|
|
39
41
|
argv = [ "exec" ]
|
|
42
|
+
env = proxy_env.merge(env) if network.allowlist?
|
|
40
43
|
env.each { |key, value| argv += [ "--env", "#{key}=#{value}" ] }
|
|
41
44
|
# The in-container timeout is what actually kills the process
|
|
42
|
-
argv += [ container, "timeout", timeout.to_i.to_s, "
|
|
45
|
+
argv += [ container, "timeout", timeout.to_i.to_s, "bash", "-c", command ]
|
|
43
46
|
|
|
44
47
|
exit_code, output = capture("docker", *argv, timeout: timeout + EXEC_SLACK)
|
|
45
48
|
ExecResult.new(command:, exit_code:, output:, duration: (now - started).round(3))
|
|
@@ -59,13 +62,13 @@ module Lemans
|
|
|
59
62
|
def switch_network_policy!(policy)
|
|
60
63
|
assert_policy_supported!(policy)
|
|
61
64
|
|
|
62
|
-
if policy.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
65
|
+
wanted = ("bridge" if policy.public?) || (internal_network if policy.allowlist?)
|
|
66
|
+
networks = connected_networks
|
|
67
|
+
(networks - [ wanted ]).each { docker!("network", "disconnect", it, container) }
|
|
68
|
+
|
|
69
|
+
remove_proxy
|
|
70
|
+
start_proxy!(policy.hosts) if policy.allowlist?
|
|
71
|
+
docker!("network", "connect", wanted, container) if wanted && !networks.include?(wanted)
|
|
69
72
|
|
|
70
73
|
@network = policy
|
|
71
74
|
end
|
|
@@ -81,41 +84,101 @@ module Lemans
|
|
|
81
84
|
end
|
|
82
85
|
rescue StandardError => e
|
|
83
86
|
warn "lemans: container #{container} may still be running — remove failed: #{e.class}: #{e.message}"
|
|
87
|
+
ensure
|
|
88
|
+
remove_proxy
|
|
89
|
+
remove_internal_network
|
|
84
90
|
end
|
|
85
91
|
|
|
86
92
|
private
|
|
87
93
|
|
|
88
94
|
def assert_policy_supported!(policy)
|
|
89
|
-
return
|
|
95
|
+
return unless policy.allowlist? && policy.ip_targets.any?
|
|
90
96
|
|
|
91
|
-
raise ConfigError, "docker:
|
|
97
|
+
raise ConfigError, "docker: an allowlist takes host names only (#{policy.ip_targets.join(", ")})"
|
|
92
98
|
end
|
|
93
99
|
|
|
94
100
|
# The tag is the content digest, so an existing image is the identical thing
|
|
95
|
-
def build_image!
|
|
96
|
-
exists, = capture("docker", "image", "inspect",
|
|
101
|
+
def build_image!(spec)
|
|
102
|
+
exists, = capture("docker", "image", "inspect", spec.name, timeout: HOUSEKEEPING_TIMEOUT)
|
|
97
103
|
return if exists.zero?
|
|
98
104
|
|
|
99
|
-
docker!("build", "--tag",
|
|
105
|
+
docker!("build", "--tag", spec.name, spec.context_dir.to_s, timeout: build_timeout, on_output: @logger)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# The task container sits on an internal network with no route out; the
|
|
109
|
+
# proxy is on it and on bridge, so the hosts it allows are the only way out.
|
|
110
|
+
def start_proxy!(hosts)
|
|
111
|
+
image = Config::ImageSpec.dockerfile(Pathname(__dir__).join("docker/proxy/Dockerfile"), slug: "proxy")
|
|
112
|
+
build_image!(image)
|
|
113
|
+
create_internal_network!
|
|
114
|
+
docker!("run", "--detach", "--name", proxy_name, "--network", internal_network,
|
|
115
|
+
*label_args, image.name, *hosts)
|
|
116
|
+
docker!("network", "connect", "bridge", proxy_name)
|
|
117
|
+
wait_for_proxy!
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def wait_for_proxy!(attempts: 50)
|
|
121
|
+
attempts.times do
|
|
122
|
+
_, output = capture("docker", "logs", proxy_name, timeout: HOUSEKEEPING_TIMEOUT)
|
|
123
|
+
return if output.include?("listening on")
|
|
124
|
+
|
|
125
|
+
sleep 0.2
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
raise InfrastructureError, "docker: the allowlist proxy did not start"
|
|
100
129
|
end
|
|
101
130
|
|
|
131
|
+
def proxy_env
|
|
132
|
+
url = "http://#{proxy_name}:#{PROXY_PORT}"
|
|
133
|
+
no_proxy = "localhost,127.0.0.1"
|
|
134
|
+
{ "http_proxy" => url, "https_proxy" => url, "HTTP_PROXY" => url, "HTTPS_PROXY" => url,
|
|
135
|
+
"no_proxy" => no_proxy, "NO_PROXY" => no_proxy }
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def create_internal_network!
|
|
139
|
+
exists, = capture("docker", "network", "inspect", internal_network, timeout: HOUSEKEEPING_TIMEOUT)
|
|
140
|
+
return if exists.zero?
|
|
141
|
+
|
|
142
|
+
docker!("network", "create", "--internal", *label_args, internal_network)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def remove_proxy
|
|
146
|
+
capture("docker", "rm", "--force", proxy_name, timeout: HOUSEKEEPING_TIMEOUT)
|
|
147
|
+
rescue StandardError
|
|
148
|
+
nil
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def remove_internal_network
|
|
152
|
+
capture("docker", "network", "rm", internal_network, timeout: HOUSEKEEPING_TIMEOUT)
|
|
153
|
+
rescue StandardError
|
|
154
|
+
nil
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def proxy_name = "#{@name}-proxy"
|
|
158
|
+
|
|
159
|
+
def internal_network = "#{@name}-net"
|
|
160
|
+
|
|
102
161
|
def run_args
|
|
103
162
|
args = [ "--detach", "--init", "--name", @name,
|
|
104
163
|
"--cpus", resources.cpus.to_s, "--memory", "#{resources.memory}m",
|
|
105
164
|
"--cap-add", "SYS_ADMIN", "--cap-add", "NET_ADMIN", "--security-opt", "apparmor=unconfined",
|
|
106
165
|
"--entrypoint", "sh" ]
|
|
107
166
|
args += [ "--network", "none" ] if network.none?
|
|
167
|
+
args += [ "--network", internal_network ] if network.allowlist?
|
|
108
168
|
env.each { |key, value| args += [ "--env", "#{key}=#{value}" ] }
|
|
109
|
-
|
|
110
|
-
args + [ image.name, "-c", "tail -f /dev/null" ]
|
|
169
|
+
args + label_args + [ image.name, "-c", "tail -f /dev/null" ]
|
|
111
170
|
end
|
|
112
171
|
|
|
172
|
+
def label_args = labels.flat_map { |key, value| [ "--label", "#{key}=#{value}" ] }
|
|
173
|
+
|
|
113
174
|
def connected_networks
|
|
114
175
|
docker!("inspect", "--format", "{{range $name, $_ := .NetworkSettings.Networks}}{{$name}} {{end}}", container).split
|
|
115
176
|
end
|
|
116
177
|
|
|
117
178
|
def remove
|
|
118
179
|
capture("docker", "rm", "--force", "--volumes", @name, timeout: HOUSEKEEPING_TIMEOUT)
|
|
180
|
+
remove_proxy
|
|
181
|
+
remove_internal_network
|
|
119
182
|
rescue StandardError
|
|
120
183
|
nil
|
|
121
184
|
end
|
data/lib/lemans/result.rb
CHANGED
|
@@ -136,6 +136,12 @@ module Lemans
|
|
|
136
136
|
def as_json(**) = to_h
|
|
137
137
|
end
|
|
138
138
|
|
|
139
|
+
Restart = Data.define(:trial, :step, :mode) do
|
|
140
|
+
def initialize(trial:, step:, mode: nil) = super
|
|
141
|
+
|
|
142
|
+
def as_json(**) = to_h.compact
|
|
143
|
+
end
|
|
144
|
+
|
|
139
145
|
Step = Data.define(:outcome, :usage, :duration) do
|
|
140
146
|
def as_json(**) = { outcome: outcome.as_json, usage: usage&.as_json, duration: }.compact
|
|
141
147
|
|
|
@@ -150,12 +156,12 @@ module Lemans
|
|
|
150
156
|
attr_reader :id, :task, :agent, :model, :index,
|
|
151
157
|
:profile_digest, :task_digest, :revision
|
|
152
158
|
|
|
153
|
-
attr_accessor :tags, :metadata
|
|
159
|
+
attr_accessor :tags, :metadata, :restarted_from, :total_steps
|
|
154
160
|
|
|
155
161
|
attr_reader :phases, :steps
|
|
156
162
|
|
|
157
163
|
# outcome-related attributes (we use setter-like methods, not accessors)
|
|
158
|
-
attr_reader :reward, :credit, :outcome, :usage
|
|
164
|
+
attr_reader :reward, :credit, :features, :outcome, :usage
|
|
159
165
|
|
|
160
166
|
def initialize(task:, agent:, model:, id: nil, index: nil,
|
|
161
167
|
profile_digest: nil, task_digest: nil, revision: nil)
|
|
@@ -171,6 +177,8 @@ module Lemans
|
|
|
171
177
|
@metadata = {}
|
|
172
178
|
@phases = []
|
|
173
179
|
@steps = nil
|
|
180
|
+
@total_steps = nil
|
|
181
|
+
@restarted_from = nil
|
|
174
182
|
|
|
175
183
|
@id = id || "#{task}__#{SecureRandom.alphanumeric(7)}"
|
|
176
184
|
@outcome = Outcome.new(:pending)
|
|
@@ -222,9 +230,10 @@ module Lemans
|
|
|
222
230
|
completed!(outcome, aggregate_usage)
|
|
223
231
|
end
|
|
224
232
|
|
|
225
|
-
def graded!(reward, credit: reward)
|
|
233
|
+
def graded!(reward, credit: reward, features: nil)
|
|
226
234
|
@reward = reward
|
|
227
235
|
@credit = credit
|
|
236
|
+
@features = features
|
|
228
237
|
self
|
|
229
238
|
end
|
|
230
239
|
|
|
@@ -236,6 +245,49 @@ module Lemans
|
|
|
236
245
|
@outcome = Outcome.new(reason, detail)
|
|
237
246
|
@reward = nil
|
|
238
247
|
@credit = nil
|
|
248
|
+
@features = nil
|
|
249
|
+
self
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# A step is settled once the next one started: it passed its gate and left a savepoint.
|
|
253
|
+
def settled_steps
|
|
254
|
+
names = phases.map(&:name)
|
|
255
|
+
(1..steps.to_a.size).count { names.include?(:"agent.#{it + 1}") }
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# The step whose verification ran last, nil when none did
|
|
259
|
+
def verified_step
|
|
260
|
+
name = phases.reverse.find { it.name.to_s.match?(/\Averifier(\.\d+)?\z/) }&.name or return nil
|
|
261
|
+
name.to_s[/\.(\d+)\z/, 1]&.to_i || steps&.size || 1
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Carries over the steps before `step`; a reverification carries `step` itself too.
|
|
265
|
+
def restart!(source, step:, mode: nil)
|
|
266
|
+
@restarted_from = Restart.new(trial: source.id, step:, mode: mode&.to_s)
|
|
267
|
+
carried = mode == :reverify ? step : step - 1
|
|
268
|
+
|
|
269
|
+
if source.steps
|
|
270
|
+
source.steps.first(carried).each { step_completed!(it.outcome, it.usage, duration: it.duration) }
|
|
271
|
+
elsif carried.positive?
|
|
272
|
+
# A single-step run graded before keeps its agent outcome; a failed grading lost it.
|
|
273
|
+
completed!(source.scored? ? source.outcome : Outcome.new(:completed), source.usage)
|
|
274
|
+
end
|
|
275
|
+
self
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Splices in the named phases of the source run, shifted so that its
|
|
279
|
+
# `through` phase ends now, and moves this run's environment setup back in
|
|
280
|
+
# front of them: the timeline reads as one run.
|
|
281
|
+
def adopt_phases!(source, names, through: names.last, now: Time.now.utc)
|
|
282
|
+
delta = now - source.phases.find { it.name == through }.finished_at
|
|
283
|
+
|
|
284
|
+
setup = phases.first
|
|
285
|
+
shift = source.phases.find { it.name == setup.name }.finished_at + delta - setup.finished_at
|
|
286
|
+
phases[0] = Phase.new(setup.name, started_at: setup.started_at + shift, finished_at: setup.finished_at + shift)
|
|
287
|
+
|
|
288
|
+
source.phases.select { names.include?(it.name) }.each do |phase|
|
|
289
|
+
phases << Phase.new(phase.name, started_at: phase.started_at + delta, finished_at: phase.finished_at + delta)
|
|
290
|
+
end
|
|
239
291
|
self
|
|
240
292
|
end
|
|
241
293
|
|
|
@@ -245,10 +297,11 @@ module Lemans
|
|
|
245
297
|
{
|
|
246
298
|
trial: id, task:, agent:, model:, index:,
|
|
247
299
|
profile_digest:, task_digest:, revision: revision&.as_json,
|
|
300
|
+
restarted_from: restarted_from&.as_json,
|
|
248
301
|
lemans_version: VERSION,
|
|
249
302
|
tags:, metadata:, phases: phases.map(&:as_json),
|
|
250
|
-
steps: steps&.map(&:as_json),
|
|
251
|
-
reward:, credit:, outcome: outcome.as_json, usage: usage&.as_json, duration:,
|
|
303
|
+
steps: steps&.map(&:as_json), total_steps:,
|
|
304
|
+
reward:, credit:, features:, outcome: outcome.as_json, usage: usage&.as_json, duration:,
|
|
252
305
|
started_at: started_at&.iso8601,
|
|
253
306
|
finished_at: finished_at&.iso8601
|
|
254
307
|
}.compact
|
|
@@ -264,6 +317,8 @@ module Lemans
|
|
|
264
317
|
)
|
|
265
318
|
result.tags = data[:tags] || []
|
|
266
319
|
result.metadata = data[:metadata] || {}
|
|
320
|
+
result.restarted_from = Restart.new(**data[:restarted_from]) if data[:restarted_from]
|
|
321
|
+
result.total_steps = data[:total_steps]
|
|
267
322
|
phases_from(data).each { result.phases << it }
|
|
268
323
|
|
|
269
324
|
# Steps first: the stored outcome/usage below override the aggregates.
|
|
@@ -281,7 +336,10 @@ module Lemans
|
|
|
281
336
|
)
|
|
282
337
|
end
|
|
283
338
|
|
|
284
|
-
|
|
339
|
+
unless data[:reward].nil?
|
|
340
|
+
result.graded!(data[:reward], credit: data[:credit] || data[:reward],
|
|
341
|
+
features: data[:features]&.transform_keys(&:to_s))
|
|
342
|
+
end
|
|
285
343
|
result
|
|
286
344
|
end
|
|
287
345
|
|
|
@@ -298,6 +356,7 @@ module Lemans
|
|
|
298
356
|
)
|
|
299
357
|
result.tags = definition.tags
|
|
300
358
|
result.metadata = definition.metadata
|
|
359
|
+
result.total_steps = definition.steps if definition.multistep?
|
|
301
360
|
result
|
|
302
361
|
end
|
|
303
362
|
|
data/lib/lemans/runner/task.rb
CHANGED
|
@@ -20,18 +20,22 @@ module Lemans
|
|
|
20
20
|
def_delegators :definition, :name, :config
|
|
21
21
|
def_delegators :result, :id
|
|
22
22
|
|
|
23
|
-
private attr_reader :definition, :store, :reporter
|
|
23
|
+
private attr_reader :definition, :store, :reporter, :restart_from, :restart_mode
|
|
24
24
|
|
|
25
|
-
def initialize(model, task_definition, index: 0, store: nil, reporter: nil)
|
|
25
|
+
def initialize(model, task_definition, index: 0, store: nil, reporter: nil, restart_from: nil, restart_mode: nil)
|
|
26
26
|
@model = model
|
|
27
27
|
@definition = task_definition
|
|
28
28
|
@index = index
|
|
29
29
|
@store = store
|
|
30
30
|
@reporter = reporter
|
|
31
|
+
@restart_from = restart_from
|
|
32
|
+
@restart_mode = restart_mode
|
|
31
33
|
@status = :pending
|
|
32
34
|
|
|
33
|
-
# prepare the result object: it's used by the actual execution down the stack
|
|
34
|
-
|
|
35
|
+
# prepare the result object: it's used by the actual execution down the stack;
|
|
36
|
+
# a restart keeps the agent of the run it restarts
|
|
37
|
+
@result = Result.from_task(definition, index:, model: model || config.models.first,
|
|
38
|
+
**({ agent: restart_from.agent } if restart_from))
|
|
35
39
|
end
|
|
36
40
|
|
|
37
41
|
def with_reporter(reporter)
|
|
@@ -55,7 +59,7 @@ module Lemans
|
|
|
55
59
|
private
|
|
56
60
|
|
|
57
61
|
def execute!
|
|
58
|
-
Trial.new(definition, model, store:, result:).run
|
|
62
|
+
Trial.new(definition, model, store:, result:, agent: restart_from&.agent, restart_from:, restart_mode:).run
|
|
59
63
|
end
|
|
60
64
|
end
|
|
61
65
|
end
|
data/lib/lemans/runner.rb
CHANGED
|
@@ -20,20 +20,26 @@ module Lemans
|
|
|
20
20
|
|
|
21
21
|
attr_reader :config, :tasks, :store, :reporter
|
|
22
22
|
|
|
23
|
-
private attr_reader :resuming, :executor
|
|
23
|
+
private attr_reader :resuming, :restarts, :restart_mode, :allow_scored, :executor
|
|
24
24
|
|
|
25
|
-
def initialize(config, tasks, store: nil, reporter: nil, executor: nil, resume: false
|
|
25
|
+
def initialize(config, tasks, store: nil, reporter: nil, executor: nil, resume: false,
|
|
26
|
+
restarts: [], restart_mode: nil, allow_scored: false)
|
|
26
27
|
@config = config
|
|
27
28
|
@tasks = tasks
|
|
28
29
|
@store = store
|
|
29
30
|
@reporter = reporter
|
|
30
31
|
@executor = executor || Executor.new(config.concurrency)
|
|
31
32
|
@resuming = resume
|
|
33
|
+
@restarts = restarts
|
|
34
|
+
@restart_mode = restart_mode
|
|
35
|
+
@allow_scored = allow_scored
|
|
32
36
|
end
|
|
33
37
|
|
|
34
38
|
def resuming? = @resuming
|
|
35
39
|
|
|
36
40
|
def attempts
|
|
41
|
+
return @attempts ||= restart_attempts if restarts.any?
|
|
42
|
+
|
|
37
43
|
@attempts ||= config.agent.models.flat_map do |model|
|
|
38
44
|
@tasks.flat_map do |task|
|
|
39
45
|
completed = resuming? ? completed_attempts(task, model) : 0
|
|
@@ -64,6 +70,43 @@ module Lemans
|
|
|
64
70
|
|
|
65
71
|
private
|
|
66
72
|
|
|
73
|
+
# Every run is checked before any starts: one refused run stops the batch.
|
|
74
|
+
def restart_attempts
|
|
75
|
+
refusals = []
|
|
76
|
+
attempts = restarts.filter_map do |restart|
|
|
77
|
+
restart_attempt(restart)
|
|
78
|
+
rescue ConfigError => e
|
|
79
|
+
refusals << e.message
|
|
80
|
+
nil
|
|
81
|
+
end
|
|
82
|
+
raise ConfigError, refusals.join("\n") if refusals.any?
|
|
83
|
+
|
|
84
|
+
attempts
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# A run named for a restart is restarted on purpose: a changed task or
|
|
88
|
+
# bench is the operator's call, and a reverification may grade a scored run.
|
|
89
|
+
def restart_attempt(restart)
|
|
90
|
+
refuse = ->(reason) { raise ConfigError, "cannot restart #{restart.id}: #{reason}" }
|
|
91
|
+
task = tasks.find { it.name == restart.task }
|
|
92
|
+
refuse.("task #{restart.task} is not in the bench") unless task
|
|
93
|
+
refuse.("it is already scored (--allow-scored to restart it anyway)") if restart.scored? && restart_mode != :reverify && !allow_scored
|
|
94
|
+
|
|
95
|
+
case restart_mode
|
|
96
|
+
when :reverify
|
|
97
|
+
refuse.("it never reached a verification") unless restart.verified_step
|
|
98
|
+
when :recover
|
|
99
|
+
refuse.("#{restart.agent} cannot recover a session") unless Agents.lookup(restart.agent).recoverable?
|
|
100
|
+
|
|
101
|
+
history = task.multistep? ? "agent.result.#{restart.settled_steps + 1}.json" : "agent.result.json"
|
|
102
|
+
refuse.("it has no #{history} to recover from") unless store&.read_artifact(restart, history)
|
|
103
|
+
else
|
|
104
|
+
refuse.("no step was settled before it failed") if restart.settled_steps.zero?
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
Task.new(restart.model, task, store:, index: restart.index || 1, restart_from: restart, restart_mode:)
|
|
108
|
+
end
|
|
109
|
+
|
|
67
110
|
def completed_attempts(task, model)
|
|
68
111
|
completed_runs.count do |run|
|
|
69
112
|
run.task == task.name &&
|
data/lib/lemans/store.rb
CHANGED
|
@@ -46,5 +46,11 @@ module Lemans
|
|
|
46
46
|
def read_artifact(result, path)
|
|
47
47
|
raise NotImplementedError
|
|
48
48
|
end
|
|
49
|
+
|
|
50
|
+
# Returns the sorted paths of the artifacts the result stored (the result
|
|
51
|
+
# record is not one of them)
|
|
52
|
+
def artifacts(result)
|
|
53
|
+
raise NotImplementedError
|
|
54
|
+
end
|
|
49
55
|
end
|
|
50
56
|
end
|
data/lib/lemans/stores/fs.rb
CHANGED
|
@@ -52,8 +52,8 @@ module Lemans
|
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
def delete(result)
|
|
55
|
-
dir =
|
|
56
|
-
return unless dir
|
|
55
|
+
dir = result_dir(result)
|
|
56
|
+
return unless dir.directory?
|
|
57
57
|
|
|
58
58
|
FileUtils.remove_entry(dir.to_s)
|
|
59
59
|
prune_empty_parents(dir.parent)
|
|
@@ -90,6 +90,13 @@ module Lemans
|
|
|
90
90
|
file.read if file.file?
|
|
91
91
|
end
|
|
92
92
|
|
|
93
|
+
def artifacts(result)
|
|
94
|
+
dir = result_dir(result)
|
|
95
|
+
return [] unless dir.directory?
|
|
96
|
+
|
|
97
|
+
(dir.glob("**/*").select(&:file?).map { it.relative_path_from(dir).to_s } - [ FILENAME ]).sort
|
|
98
|
+
end
|
|
99
|
+
|
|
93
100
|
private
|
|
94
101
|
|
|
95
102
|
def filtered(text) = filterer ? filterer.filter(text) : text
|
|
@@ -132,9 +139,14 @@ module Lemans
|
|
|
132
139
|
tmp&.delete if tmp&.exist?
|
|
133
140
|
end
|
|
134
141
|
|
|
135
|
-
# result.json is stored at <root>/<model-short>/<result-id
|
|
142
|
+
# result.json is stored at <root>/<model-short>/<result-id>; a tree
|
|
143
|
+
# arranged by hand (runs grouped under batch directories) is searched
|
|
144
|
+
# for the id instead.
|
|
136
145
|
def result_dir(result)
|
|
137
|
-
root.join((result.model || result.agent).to_s.split("/").last.tr("#", "-"), result.id)
|
|
146
|
+
canonical = root.join((result.model || result.agent).to_s.split("/").last.to_s.tr("#", "-"), result.id)
|
|
147
|
+
return canonical if canonical.directory?
|
|
148
|
+
|
|
149
|
+
root.glob("**/#{result.id}").find(&:directory?) || canonical
|
|
138
150
|
end
|
|
139
151
|
end
|
|
140
152
|
end
|
data/lib/lemans/trial/patch.rb
CHANGED
|
@@ -61,6 +61,15 @@ module Lemans
|
|
|
61
61
|
raise InfrastructureError, "could not savepoint the tree the step left behind" unless savepoint
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
+
# Brings a fresh tree to where an earlier run left it: the settled steps'
|
|
65
|
+
# patches applied in order and marked as the savepoint, then the work of
|
|
66
|
+
# the step that run stopped in, on top of it, so collect! sees it as this step's.
|
|
67
|
+
def replay!(settled, unsettled = nil)
|
|
68
|
+
settled.each { apply!(it) }
|
|
69
|
+
savepoint!
|
|
70
|
+
apply!(unsettled) if unsettled
|
|
71
|
+
end
|
|
72
|
+
|
|
64
73
|
# Puts the workdir back to the savepoint exactly: the verifier restored
|
|
65
74
|
# the graded surfaces from the baseline and may have littered the tree,
|
|
66
75
|
# and the next step's agent must find neither.
|
|
@@ -75,6 +84,18 @@ module Lemans
|
|
|
75
84
|
|
|
76
85
|
private
|
|
77
86
|
|
|
87
|
+
def apply!(contents)
|
|
88
|
+
return if contents.empty?
|
|
89
|
+
|
|
90
|
+
Tempfile.create(%w[agent .patch]) do |file|
|
|
91
|
+
file.write(contents)
|
|
92
|
+
file.flush
|
|
93
|
+
environment.upload(file.path, REMOTE_PATCH)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
environment.exec!("#{git} apply --binary --whitespace=nowarn #{REMOTE_PATCH} && rm -f #{REMOTE_PATCH}", timeout:)
|
|
97
|
+
end
|
|
98
|
+
|
|
78
99
|
def save_diff(result, store, from, to, destination)
|
|
79
100
|
diffed = environment.exec("#{git} diff --binary #{from} #{to} > #{REMOTE_PATCH}", timeout:)
|
|
80
101
|
return unless diffed.success?
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "prism"
|
|
5
|
+
|
|
6
|
+
module Lemans
|
|
7
|
+
class Trial
|
|
8
|
+
class Verifier
|
|
9
|
+
# Reads the grading schema off the test files without running them: every
|
|
10
|
+
# `def test_*` and ActiveSupport `test "..."` is a check, an
|
|
11
|
+
# `allow_failure` call inside makes it an extra worth its `points:`, and
|
|
12
|
+
# a `# @feature <name>` comment right above a check or inside it tracks
|
|
13
|
+
# the check as that feature. Files required from the test file's
|
|
14
|
+
# directory are read too: suites often live apart.
|
|
15
|
+
class TestScanner < Prism::Visitor
|
|
16
|
+
ENTRY = "verification_test.rb"
|
|
17
|
+
FEATURE = /\A#\s*@feature\s+(\S+)/
|
|
18
|
+
|
|
19
|
+
Span = Data.define(:check, :file, :lines)
|
|
20
|
+
|
|
21
|
+
attr_reader :tests, :points, :features, :base_credit, :stray_features
|
|
22
|
+
|
|
23
|
+
# The task's verification_test.rb scanned, nil when it has none
|
|
24
|
+
def self.for(task)
|
|
25
|
+
local, = task.test_files.find { |_, remote| remote == ENTRY }
|
|
26
|
+
return unless local
|
|
27
|
+
|
|
28
|
+
new(Pathname(local).dirname).tap { it.scan(Pathname(local)) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(root)
|
|
32
|
+
super()
|
|
33
|
+
@root = root
|
|
34
|
+
@scope = []
|
|
35
|
+
@tests = []
|
|
36
|
+
@points = {}
|
|
37
|
+
@features = {}
|
|
38
|
+
@stray_features = []
|
|
39
|
+
@spans = []
|
|
40
|
+
@current = nil
|
|
41
|
+
@scanned = []
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def scan(path)
|
|
45
|
+
return if @scanned.include?(path)
|
|
46
|
+
|
|
47
|
+
@scanned << path
|
|
48
|
+
file = @file
|
|
49
|
+
begin
|
|
50
|
+
@file = path
|
|
51
|
+
parsed = Prism.parse_file(path.to_s)
|
|
52
|
+
parsed.value.accept(self)
|
|
53
|
+
attach_features(path, parsed.comments)
|
|
54
|
+
ensure
|
|
55
|
+
@file = file
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def visit_module_node(node) = scoped(node) { super }
|
|
60
|
+
|
|
61
|
+
def visit_class_node(node) = scoped(node) { super }
|
|
62
|
+
|
|
63
|
+
def visit_def_node(node)
|
|
64
|
+
return super unless node.name.start_with?("test_")
|
|
65
|
+
|
|
66
|
+
within("#{@scope.join("::")}##{node.name}", node) { super }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def visit_call_node(node)
|
|
70
|
+
case node.name
|
|
71
|
+
when :test
|
|
72
|
+
title = node.arguments&.arguments&.first
|
|
73
|
+
if node.receiver.nil? && node.block && title.is_a?(Prism::StringNode)
|
|
74
|
+
return within("#{@scope.join("::")}#test_#{title.unescaped.gsub(/\s+/, "_")}", node) { super }
|
|
75
|
+
end
|
|
76
|
+
when :allow_failure
|
|
77
|
+
@points[@current] ||= points_of(node) if @current
|
|
78
|
+
when :require, :require_relative
|
|
79
|
+
required = node.arguments&.arguments&.first
|
|
80
|
+
scan_required(node.name, required.unescaped) if node.receiver.nil? && required.is_a?(Prism::StringNode)
|
|
81
|
+
return
|
|
82
|
+
when :base_credit=
|
|
83
|
+
@base_credit = node.arguments.arguments.first.value if node.receiver.is_a?(Prism::ConstantReadNode) && node.receiver.name == :LemansReport
|
|
84
|
+
end
|
|
85
|
+
super
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def scan_required(how, name)
|
|
91
|
+
path = (how == :require_relative ? @file.dirname : @root).join("#{name.delete_suffix(".rb")}.rb")
|
|
92
|
+
scan(path) if path.file?
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def scoped(node)
|
|
96
|
+
@scope.push(node.constant_path.full_name)
|
|
97
|
+
yield
|
|
98
|
+
ensure
|
|
99
|
+
@scope.pop
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def within(check, node)
|
|
103
|
+
@tests << check
|
|
104
|
+
@spans << Span.new(check:, file: @file, lines: node.location.start_line..node.location.end_line)
|
|
105
|
+
@current = check
|
|
106
|
+
yield
|
|
107
|
+
ensure
|
|
108
|
+
@current = nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def points_of(node)
|
|
112
|
+
keywords = node.arguments&.arguments&.grep(Prism::KeywordHashNode)&.first
|
|
113
|
+
pair = keywords&.elements&.find { it.is_a?(Prism::AssocNode) && it.key.is_a?(Prism::SymbolNode) && it.key.unescaped == "points" }
|
|
114
|
+
pair ? pair.value.value : 1
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# A comment belongs to the check it sits in, or to the one its
|
|
118
|
+
# comment block opens onto.
|
|
119
|
+
def attach_features(path, comments)
|
|
120
|
+
own_line = path.readlines.each_with_index.filter_map { |text, index| index + 1 if text.lstrip.start_with?("#") }
|
|
121
|
+
spans = @spans.select { it.file == path }
|
|
122
|
+
|
|
123
|
+
comments.each do |comment|
|
|
124
|
+
name = comment.slice[FEATURE, 1] or next
|
|
125
|
+
line = comment.location.start_line
|
|
126
|
+
span = spans.find { it.lines.cover?(line) } ||
|
|
127
|
+
spans.find { |s| s.lines.begin > line && (line...s.lines.begin).all? { own_line.include?(it) } }
|
|
128
|
+
next @stray_features << "#{path}:#{line}" unless span
|
|
129
|
+
|
|
130
|
+
(@features[name] ||= []) << span.check unless @features[name]&.include?(span.check)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|