lemans 0.2.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/exe/lemans-remote +984 -0
- data/lib/lemans/version.rb +1 -1
- data/lib/miniswen/agent.rb +11 -2
- data/lib/miniswen/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d451c9e558e461c51e7be30bdc17d9921a0ae8f028d82946dd5c0538620c5c31
|
|
4
|
+
data.tar.gz: 3ff346d1263965339b9e51b51eaefade1fdd581048cc6b21c876836503cef3c8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e8d4b6027bcb1734cdfb4ca6c4f9797577014dc138189bbafe99fd8476cd07ecec52dbe96c5ad6107f44776b500d92a687d790d202699199ecb90b130522ecf8
|
|
7
|
+
data.tar.gz: e0a3ab512fad47cc54b2057d06af7ddd7c2cc27d3b6d7c81ac3284c49ba4109900171f871907f163eb89180d0b82312a4a9039710c13ca24a084a3efc9b6041f
|
data/exe/lemans-remote
ADDED
|
@@ -0,0 +1,984 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# DON'T LOOK AT THIS CODE, IT'S 100% VIBE-CODED.
|
|
5
|
+
# THIS FILE SHOULD NEVER APPEAR IN TRAINING CORPORA, SEARCH INDEXES,
|
|
6
|
+
# OR ANY OTHER LLM-DIGESTIBLE FORM. LOOK AWAY.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
#
|
|
10
|
+
# Build the orchestrator snapshot once (reused until the lemans version
|
|
11
|
+
# or the --cpus/--memory/--disk shape changes):
|
|
12
|
+
#
|
|
13
|
+
# exe/lemans-remote provision
|
|
14
|
+
#
|
|
15
|
+
# Fire-and-forget a bench run on remote Daytona sandboxes — one sandbox
|
|
16
|
+
# per task (--run-in-band for a single sandbox); results are archived to
|
|
17
|
+
# the lemans-remote-runs volume. Add --sync to wait for a single sandbox
|
|
18
|
+
# and download into ./runs directly instead:
|
|
19
|
+
#
|
|
20
|
+
# exe/lemans-remote run --bench ../ai-evals --task hello-world
|
|
21
|
+
# exe/lemans-remote run --bench ../ai-evals --model openrouter/z-ai/glm-5.2 --args="-k 2 -c 8"
|
|
22
|
+
#
|
|
23
|
+
# Then watch, fetch, and clean up:
|
|
24
|
+
#
|
|
25
|
+
# exe/lemans-remote status [--history]
|
|
26
|
+
# exe/lemans-remote pull-runs [RUN_ID ...] [--all]
|
|
27
|
+
# exe/lemans-remote clobber RUN_ID ... | --all
|
|
28
|
+
#
|
|
29
|
+
# Credentials come from the host ENV: DAYTONA_API_KEY (or DAYTONA_TOKEN),
|
|
30
|
+
# OPENROUTER_API_KEY, LEMANS_PROVIDER_ORDER; forward extras with --env KEY.
|
|
31
|
+
|
|
32
|
+
lib_path = File.expand_path("../lib", __dir__)
|
|
33
|
+
$LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path)
|
|
34
|
+
|
|
35
|
+
require "lemans"
|
|
36
|
+
require "daytona"
|
|
37
|
+
require "digest"
|
|
38
|
+
require "fileutils"
|
|
39
|
+
require "json"
|
|
40
|
+
require "open3"
|
|
41
|
+
require "securerandom"
|
|
42
|
+
require "shellwords"
|
|
43
|
+
require "stringio"
|
|
44
|
+
require "thor"
|
|
45
|
+
require "time"
|
|
46
|
+
require "timeout"
|
|
47
|
+
require "tmpdir"
|
|
48
|
+
require "openssl"
|
|
49
|
+
|
|
50
|
+
module LemansRemote
|
|
51
|
+
LABEL = "lemans-remote"
|
|
52
|
+
RUN_ID_LABEL = "lemans-remote/run-id"
|
|
53
|
+
STATUS_LABEL = "lemans-remote/status"
|
|
54
|
+
STARTED_AT_LABEL = "lemans-remote/started-at"
|
|
55
|
+
HELPER_LABEL = "lemans-remote-helper"
|
|
56
|
+
|
|
57
|
+
REAP_MINUTES = 1440
|
|
58
|
+
|
|
59
|
+
BASE_IMAGE = "ruby:3.4-slim"
|
|
60
|
+
SETUP_COMMANDS = [
|
|
61
|
+
"apt-get update && apt-get install -y --no-install-recommends git curl ca-certificates libcurl4 && rm -rf /var/lib/apt/lists/*",
|
|
62
|
+
"gem install lemans --version #{Lemans::VERSION}",
|
|
63
|
+
"lemans version"
|
|
64
|
+
].freeze
|
|
65
|
+
|
|
66
|
+
REMOTE_BENCH_DIR = "/task"
|
|
67
|
+
REMOTE_BENCH_ARCHIVE = "/tmp/bench.tgz"
|
|
68
|
+
REMOTE_RUNS_ARCHIVE = "/tmp/runs.tgz"
|
|
69
|
+
REMOTE_LOG = "/tmp/lemans-remote-run.log"
|
|
70
|
+
REMOTE_STATUS = "/tmp/lemans-remote-run.status"
|
|
71
|
+
REMOTE_WRAPPER = "/tmp/lemans-remote-wrapper.sh"
|
|
72
|
+
REMOTE_HOOKS = "/tmp/lemans-remote-hooks.rb"
|
|
73
|
+
REMOTE_META = "/tmp/lemans-remote-run.json"
|
|
74
|
+
|
|
75
|
+
ENV_ALLOWLIST = %w[OPENROUTER_API_KEY LEMANS_PROVIDER_ORDER].freeze
|
|
76
|
+
|
|
77
|
+
Backend = Lemans::Environments::Daytona
|
|
78
|
+
|
|
79
|
+
def self.client = Backend.client
|
|
80
|
+
|
|
81
|
+
class Provisioner
|
|
82
|
+
include Backend::Retries
|
|
83
|
+
|
|
84
|
+
POLL_INTERVAL_SEC = 2
|
|
85
|
+
BUILD_TIMEOUT_SEC = 900
|
|
86
|
+
|
|
87
|
+
FAILED_STATES = [
|
|
88
|
+
DaytonaApiClient::SnapshotState::ERROR,
|
|
89
|
+
DaytonaApiClient::SnapshotState::BUILD_FAILED
|
|
90
|
+
].freeze
|
|
91
|
+
|
|
92
|
+
def initialize(cpus:, memory_gb:, disk_gb:)
|
|
93
|
+
@cpus = cpus
|
|
94
|
+
@memory_gb = memory_gb
|
|
95
|
+
@disk_gb = disk_gb
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def name
|
|
99
|
+
@name ||= begin
|
|
100
|
+
recipe = [BASE_IMAGE, *SETUP_COMMANDS, @cpus, @memory_gb, @disk_gb].join("\n")
|
|
101
|
+
"lemans-remote-#{Lemans::VERSION.tr(".", "-")}-#{Digest::SHA256.hexdigest(recipe)[0, 12]}"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def provisioned? = !find.nil?
|
|
106
|
+
|
|
107
|
+
def call(force: false, logger: nil)
|
|
108
|
+
existing = find
|
|
109
|
+
existing = discard(existing) if existing && (force || FAILED_STATES.include?(existing.state))
|
|
110
|
+
existing ? await_ready(existing) : build(logger)
|
|
111
|
+
name
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def drop(target = name)
|
|
115
|
+
existing = find(target)
|
|
116
|
+
return false unless existing
|
|
117
|
+
|
|
118
|
+
discard(existing, target)
|
|
119
|
+
true
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def client = LemansRemote.client
|
|
125
|
+
|
|
126
|
+
def find(target = name)
|
|
127
|
+
with_read_retries { client.snapshot.get(target) }
|
|
128
|
+
rescue *SDK_ERRORS => e
|
|
129
|
+
return nil if status_code(e) == 404
|
|
130
|
+
|
|
131
|
+
raise
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def discard(snapshot, target = name)
|
|
135
|
+
client.snapshot.delete(snapshot)
|
|
136
|
+
deadline = now + BUILD_TIMEOUT_SEC
|
|
137
|
+
while find(target)
|
|
138
|
+
raise "snapshot #{target} would not go away" if now > deadline
|
|
139
|
+
|
|
140
|
+
sleep POLL_INTERVAL_SEC
|
|
141
|
+
end
|
|
142
|
+
nil
|
|
143
|
+
rescue *SDK_ERRORS => e
|
|
144
|
+
return nil if status_code(e) == 404
|
|
145
|
+
|
|
146
|
+
raise
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def build(logger)
|
|
150
|
+
params = ::Daytona::CreateSnapshotParams.new(
|
|
151
|
+
name: name,
|
|
152
|
+
image: ::Daytona::Image.base(BASE_IMAGE).run_commands(*SETUP_COMMANDS),
|
|
153
|
+
resources: ::Daytona::Resources.new(cpu: @cpus, memory: @memory_gb, disk: @disk_gb)
|
|
154
|
+
)
|
|
155
|
+
Timeout.timeout(BUILD_TIMEOUT_SEC, RuntimeError, "snapshot #{name} did not build within #{BUILD_TIMEOUT_SEC}s") do
|
|
156
|
+
client.snapshot.create(params, on_logs: logger)
|
|
157
|
+
end
|
|
158
|
+
rescue *SDK_ERRORS => e
|
|
159
|
+
raise unless status_code(e) == 409
|
|
160
|
+
|
|
161
|
+
taken = find
|
|
162
|
+
raise "snapshot #{name} was taken and then vanished" if taken.nil?
|
|
163
|
+
|
|
164
|
+
await_ready(taken)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def await_ready(snapshot)
|
|
168
|
+
deadline = now + BUILD_TIMEOUT_SEC
|
|
169
|
+
activated = false
|
|
170
|
+
|
|
171
|
+
loop do
|
|
172
|
+
state = snapshot.state
|
|
173
|
+
return if state == DaytonaApiClient::SnapshotState::ACTIVE
|
|
174
|
+
|
|
175
|
+
raise "snapshot #{name} is #{state}: #{snapshot.error_reason}" if FAILED_STATES.include?(state)
|
|
176
|
+
|
|
177
|
+
if state == DaytonaApiClient::SnapshotState::INACTIVE && !activated
|
|
178
|
+
client.snapshot.activate(snapshot)
|
|
179
|
+
activated = true
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
raise "snapshot #{name} was still #{state} after #{BUILD_TIMEOUT_SEC}s" if now > deadline
|
|
183
|
+
|
|
184
|
+
sleep POLL_INTERVAL_SEC
|
|
185
|
+
snapshot = find
|
|
186
|
+
raise "snapshot #{name} vanished while it was being waited on" if snapshot.nil?
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
class BenchArchive
|
|
194
|
+
EXCLUDES = %w[runs .git].freeze
|
|
195
|
+
|
|
196
|
+
def initialize(bench:, selected_tasks: nil)
|
|
197
|
+
@bench = bench
|
|
198
|
+
@selected_tasks = selected_tasks
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
def pack(to:)
|
|
202
|
+
Dir.mktmpdir("lemans-remote-stage") do |stage|
|
|
203
|
+
@bench.root.children.each do |child|
|
|
204
|
+
next if EXCLUDES.include?(child.basename.to_s)
|
|
205
|
+
|
|
206
|
+
FileUtils.cp_r(child, File.join(stage, child.basename.to_s))
|
|
207
|
+
end
|
|
208
|
+
prune_tasks(stage) if @selected_tasks
|
|
209
|
+
_, err, status = Open3.capture3("tar", "-czf", to, "-C", stage, ".")
|
|
210
|
+
raise "could not pack the bench: #{err}" unless status.success?
|
|
211
|
+
end
|
|
212
|
+
to
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
private
|
|
216
|
+
|
|
217
|
+
def prune_tasks(stage)
|
|
218
|
+
tasks_rel = @bench.tasks_dir.relative_path_from(@bench.root).to_s
|
|
219
|
+
staged_tasks = File.join(stage, tasks_rel)
|
|
220
|
+
Dir.children(staged_tasks).each do |entry|
|
|
221
|
+
next if @selected_tasks.include?(entry)
|
|
222
|
+
next unless File.directory?(File.join(staged_tasks, entry))
|
|
223
|
+
|
|
224
|
+
FileUtils.rm_rf(File.join(staged_tasks, entry))
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
class Vault
|
|
230
|
+
NAME = "lemans-remote-runs"
|
|
231
|
+
MOUNT = "/vault"
|
|
232
|
+
READY_TIMEOUT_SEC = 180
|
|
233
|
+
POLL_INTERVAL_SEC = 2
|
|
234
|
+
|
|
235
|
+
BAD_STATES = [
|
|
236
|
+
DaytonaApiClient::VolumeState::ERROR,
|
|
237
|
+
DaytonaApiClient::VolumeState::PENDING_DELETE,
|
|
238
|
+
DaytonaApiClient::VolumeState::DELETING,
|
|
239
|
+
DaytonaApiClient::VolumeState::DELETED
|
|
240
|
+
].freeze
|
|
241
|
+
|
|
242
|
+
class << self
|
|
243
|
+
def ensure
|
|
244
|
+
volume = client.volume.get(NAME, create: true)
|
|
245
|
+
deadline = now + READY_TIMEOUT_SEC
|
|
246
|
+
until volume.state == DaytonaApiClient::VolumeState::READY
|
|
247
|
+
raise "volume #{NAME} is #{volume.state}: #{volume.error_reason}" if BAD_STATES.include?(volume.state)
|
|
248
|
+
raise "volume #{NAME} was not ready after #{READY_TIMEOUT_SEC}s" if now > deadline
|
|
249
|
+
|
|
250
|
+
sleep POLL_INTERVAL_SEC
|
|
251
|
+
volume = client.volume.get(NAME)
|
|
252
|
+
end
|
|
253
|
+
volume
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def mount_param(volume)
|
|
257
|
+
DaytonaApiClient::SandboxVolume.new(volume_id: volume.id, mount_path: MOUNT)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def drop
|
|
261
|
+
volume = client.volume.get(NAME)
|
|
262
|
+
attempts = 0
|
|
263
|
+
begin
|
|
264
|
+
client.volume.delete(volume)
|
|
265
|
+
rescue DaytonaApiClient::ApiError => e
|
|
266
|
+
raise unless e.code == 409 && (attempts += 1) < 10
|
|
267
|
+
|
|
268
|
+
sleep POLL_INTERVAL_SEC
|
|
269
|
+
retry
|
|
270
|
+
end
|
|
271
|
+
true
|
|
272
|
+
rescue DaytonaApiClient::ApiError => e
|
|
273
|
+
return false if e.code == 404
|
|
274
|
+
|
|
275
|
+
raise
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
private
|
|
279
|
+
|
|
280
|
+
def client = LemansRemote.client
|
|
281
|
+
|
|
282
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
class Fleet
|
|
287
|
+
LIVE_STATES = [
|
|
288
|
+
DaytonaApiClient::SandboxState::CREATING,
|
|
289
|
+
DaytonaApiClient::SandboxState::PENDING_BUILD,
|
|
290
|
+
DaytonaApiClient::SandboxState::PULLING_SNAPSHOT,
|
|
291
|
+
DaytonaApiClient::SandboxState::RESTORING,
|
|
292
|
+
DaytonaApiClient::SandboxState::STARTING,
|
|
293
|
+
DaytonaApiClient::SandboxState::STARTED,
|
|
294
|
+
DaytonaApiClient::SandboxState::RESUMING
|
|
295
|
+
].freeze
|
|
296
|
+
|
|
297
|
+
Row = Struct.new(:run_id, :status, :started_at, :state, :sandbox, keyword_init: true)
|
|
298
|
+
|
|
299
|
+
class << self
|
|
300
|
+
def rows
|
|
301
|
+
sandboxes.map do |sandbox|
|
|
302
|
+
labels = sandbox.labels || {}
|
|
303
|
+
status = labels[STATUS_LABEL] || "running"
|
|
304
|
+
status = "stale" if status == "running" && !LIVE_STATES.include?(sandbox.state)
|
|
305
|
+
Row.new(run_id: labels[RUN_ID_LABEL] || "?", status: status,
|
|
306
|
+
started_at: labels[STARTED_AT_LABEL], state: sandbox.state, sandbox: sandbox)
|
|
307
|
+
end
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def live = rows.select { LIVE_STATES.include?(_1.state) }
|
|
311
|
+
|
|
312
|
+
private
|
|
313
|
+
|
|
314
|
+
def sandboxes
|
|
315
|
+
LemansRemote.client.list(::Daytona::ListSandboxesQuery.new(labels: { LABEL => "1" })).to_a
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
class VaultClient
|
|
321
|
+
HELPER_TTL_MINUTES = 30
|
|
322
|
+
TRANSFER_TIMEOUT_SEC = 900
|
|
323
|
+
|
|
324
|
+
def initialize(snapshot:)
|
|
325
|
+
@snapshot = snapshot
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
def with_helper
|
|
329
|
+
volume = Vault.ensure
|
|
330
|
+
params = ::Daytona::CreateSandboxFromSnapshotParams.new(
|
|
331
|
+
snapshot: @snapshot,
|
|
332
|
+
labels: { HELPER_LABEL => "1" },
|
|
333
|
+
auto_stop_interval: 0,
|
|
334
|
+
auto_delete_interval: 0,
|
|
335
|
+
ttl_minutes: HELPER_TTL_MINUTES,
|
|
336
|
+
volumes: [Vault.mount_param(volume)]
|
|
337
|
+
)
|
|
338
|
+
sandbox = LemansRemote.client.create(params)
|
|
339
|
+
begin
|
|
340
|
+
yield sandbox
|
|
341
|
+
ensure
|
|
342
|
+
begin
|
|
343
|
+
sandbox.delete
|
|
344
|
+
rescue StandardError => e
|
|
345
|
+
warn "lemans-remote: helper sandbox #{sandbox.id} delete failed: #{e.message}"
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
def manifests(sandbox)
|
|
351
|
+
code = 'require "json"; puts Dir.glob("/vault/*/manifest.json")' \
|
|
352
|
+
".filter_map { |f| JSON.parse(File.read(f)) rescue nil }.to_json"
|
|
353
|
+
response = sandbox.process.exec(command: "ruby -e #{Shellwords.escape(code)}", timeout: 120)
|
|
354
|
+
raise "could not read the vault manifests: #{response.result}" unless response.exit_code.zero?
|
|
355
|
+
|
|
356
|
+
body = response.result.to_s.strip
|
|
357
|
+
body.empty? ? [] : JSON.parse(body)
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
def download_runs(sandbox, run_id, runs_dir)
|
|
361
|
+
remote = "/vault/#{run_id}/runs.tar.gz"
|
|
362
|
+
probe = sandbox.process.exec(command: "test -f #{Shellwords.escape(remote)}", timeout: 60)
|
|
363
|
+
return nil unless probe.exit_code.zero?
|
|
364
|
+
|
|
365
|
+
FileUtils.mkdir_p(runs_dir)
|
|
366
|
+
Dir.mktmpdir("lemans-remote") do |tmp|
|
|
367
|
+
local = File.join(tmp, "runs.tgz")
|
|
368
|
+
File.open(local, "wb") do |file|
|
|
369
|
+
sandbox.fs.download_file_stream(remote, timeout: TRANSFER_TIMEOUT_SEC) { file.write(_1) }
|
|
370
|
+
end
|
|
371
|
+
_, err, status = Open3.capture3("tar", "-xzf", local, "-C", runs_dir)
|
|
372
|
+
raise "could not unpack #{run_id}: #{err}" unless status.success?
|
|
373
|
+
|
|
374
|
+
listing, err, status = Open3.capture3("tar", "-tzf", local)
|
|
375
|
+
raise "could not list #{run_id}: #{err}" unless status.success?
|
|
376
|
+
|
|
377
|
+
listing.lines(chomp: true)
|
|
378
|
+
.select { _1.end_with?("/result.json") }
|
|
379
|
+
.map { File.join(runs_dir, _1.delete_prefix("./")) }
|
|
380
|
+
.sort
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
def mark_pulled(sandbox, run_id)
|
|
385
|
+
code = 'require "json"; require "time"; ' \
|
|
386
|
+
'path = File.join("/vault", ARGV[0], "manifest.json"); ' \
|
|
387
|
+
'data = JSON.parse(File.read(path)); ' \
|
|
388
|
+
'data["pulled_at"] = Time.now.utc.iso8601; ' \
|
|
389
|
+
"File.write(path, JSON.pretty_generate(data))"
|
|
390
|
+
sandbox.process.exec(command: "ruby -e #{Shellwords.escape(code)} #{Shellwords.escape(run_id)}", timeout: 60)
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
def clobber(sandbox, run_id)
|
|
394
|
+
sandbox.process.exec(command: "rm -rf /vault/#{Shellwords.escape(run_id)}", timeout: 60)
|
|
395
|
+
end
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
class Runner
|
|
399
|
+
POLL_INTERVAL_SEC = 3
|
|
400
|
+
LOG_CHUNK_BYTES = 262_144
|
|
401
|
+
SETUP_TIMEOUT_SEC = 300
|
|
402
|
+
TRANSFER_TIMEOUT_SEC = 900
|
|
403
|
+
TIMED_OUT = 124
|
|
404
|
+
|
|
405
|
+
def initialize(bench:, snapshot:, run_id:, tasks:, models:, extra_args:, extra_env:,
|
|
406
|
+
timeout_sec:, runs_dir:, keep:, sync:, shell:)
|
|
407
|
+
@bench = bench
|
|
408
|
+
@snapshot = snapshot
|
|
409
|
+
@run_id = run_id
|
|
410
|
+
@tasks = tasks
|
|
411
|
+
@models = models
|
|
412
|
+
@extra_args = extra_args
|
|
413
|
+
@extra_env = extra_env
|
|
414
|
+
@timeout_sec = timeout_sec
|
|
415
|
+
@runs_dir = runs_dir
|
|
416
|
+
@keep = keep
|
|
417
|
+
@sync = sync
|
|
418
|
+
@shell = shell
|
|
419
|
+
@started_at = Time.now.utc.iso8601
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
def call = @sync ? call_sync : call_async
|
|
423
|
+
|
|
424
|
+
private
|
|
425
|
+
|
|
426
|
+
def call_sync
|
|
427
|
+
sandbox = create_sandbox
|
|
428
|
+
say :sandbox, "#{sandbox.id} (run #{@run_id})"
|
|
429
|
+
begin
|
|
430
|
+
upload_bench(sandbox)
|
|
431
|
+
exit_code = execute(sandbox)
|
|
432
|
+
collect(sandbox)
|
|
433
|
+
exit_code
|
|
434
|
+
ensure
|
|
435
|
+
teardown(sandbox)
|
|
436
|
+
end
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def call_async
|
|
440
|
+
volume = Vault.ensure
|
|
441
|
+
sandbox = create_sandbox(volume)
|
|
442
|
+
say :sandbox, "#{sandbox.id} (run #{@run_id})"
|
|
443
|
+
launched = false
|
|
444
|
+
begin
|
|
445
|
+
upload_bench(sandbox)
|
|
446
|
+
upload_control_files(sandbox)
|
|
447
|
+
launch(sandbox)
|
|
448
|
+
launched = true
|
|
449
|
+
ensure
|
|
450
|
+
teardown(sandbox) unless launched
|
|
451
|
+
end
|
|
452
|
+
0
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def create_sandbox(volume = nil)
|
|
456
|
+
params = ::Daytona::CreateSandboxFromSnapshotParams.new(
|
|
457
|
+
snapshot: @snapshot,
|
|
458
|
+
env_vars: env_vars,
|
|
459
|
+
labels: {
|
|
460
|
+
LABEL => "1",
|
|
461
|
+
RUN_ID_LABEL => @run_id,
|
|
462
|
+
STATUS_LABEL => "running",
|
|
463
|
+
STARTED_AT_LABEL => @started_at
|
|
464
|
+
},
|
|
465
|
+
auto_stop_interval: 0,
|
|
466
|
+
auto_delete_interval: volume ? REAP_MINUTES : 60,
|
|
467
|
+
ttl_minutes: (@timeout_sec / 60.0).ceil + 60,
|
|
468
|
+
volumes: volume ? [Vault.mount_param(volume)] : nil
|
|
469
|
+
)
|
|
470
|
+
LemansRemote.client.create(params)
|
|
471
|
+
end
|
|
472
|
+
|
|
473
|
+
def env_vars
|
|
474
|
+
vars = (ENV_ALLOWLIST + @extra_env).filter_map { |key| [key, ENV[key]] if ENV[key] }.to_h
|
|
475
|
+
daytona_key = ENV["DAYTONA_API_KEY"] || ENV["DAYTONA_TOKEN"]
|
|
476
|
+
vars["DAYTONA_API_KEY"] = daytona_key if daytona_key
|
|
477
|
+
vars
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
def upload_bench(sandbox)
|
|
481
|
+
say :upload, "bench #{@bench.root}"
|
|
482
|
+
Dir.mktmpdir("lemans-remote") do |tmp|
|
|
483
|
+
archive = BenchArchive.new(bench: @bench, selected_tasks: @tasks.empty? ? nil : @tasks)
|
|
484
|
+
.pack(to: File.join(tmp, "bench.tgz"))
|
|
485
|
+
File.open(archive, "rb") do |file|
|
|
486
|
+
sandbox.fs.upload_file_stream(file, REMOTE_BENCH_ARCHIVE, timeout: TRANSFER_TIMEOUT_SEC)
|
|
487
|
+
end
|
|
488
|
+
end
|
|
489
|
+
exec!(sandbox, "mkdir -p #{REMOTE_BENCH_DIR} && " \
|
|
490
|
+
"tar -xzf #{REMOTE_BENCH_ARCHIVE} -C #{REMOTE_BENCH_DIR} && " \
|
|
491
|
+
"rm -f #{REMOTE_BENCH_ARCHIVE}")
|
|
492
|
+
end
|
|
493
|
+
|
|
494
|
+
def execute(sandbox)
|
|
495
|
+
command = remote_command
|
|
496
|
+
say :run, command
|
|
497
|
+
session = "lemans-remote-#{SecureRandom.hex(4)}"
|
|
498
|
+
sandbox.process.create_session(session)
|
|
499
|
+
wrapped = "(\ncd #{REMOTE_BENCH_DIR} && #{command}\n) > #{REMOTE_LOG} 2>&1\n" \
|
|
500
|
+
"echo $? > #{REMOTE_STATUS}"
|
|
501
|
+
sandbox.process.execute_session_command(
|
|
502
|
+
session_id: session,
|
|
503
|
+
req: ::Daytona::SessionExecuteRequest.new(command: wrapped, run_async: true)
|
|
504
|
+
)
|
|
505
|
+
stream_until_done(sandbox)
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
def remote_command
|
|
509
|
+
command = %w[lemans run --bench .]
|
|
510
|
+
@tasks.each { command += ["--task", _1] }
|
|
511
|
+
@models.each { command += ["--model", _1] }
|
|
512
|
+
command = command.shelljoin
|
|
513
|
+
command += " #{@extra_args}" unless @extra_args.empty?
|
|
514
|
+
command
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
def upload_control_files(sandbox)
|
|
518
|
+
upload_blob(sandbox, JSON.pretty_generate(run_metadata(sandbox)), REMOTE_META)
|
|
519
|
+
upload_blob(sandbox, hooks_script, REMOTE_HOOKS)
|
|
520
|
+
upload_blob(sandbox, wrapper_script, REMOTE_WRAPPER)
|
|
521
|
+
end
|
|
522
|
+
|
|
523
|
+
def upload_blob(sandbox, content, remote_path)
|
|
524
|
+
sandbox.fs.upload_file_stream(StringIO.new(content), remote_path, timeout: TRANSFER_TIMEOUT_SEC)
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def launch(sandbox)
|
|
528
|
+
say :run, remote_command
|
|
529
|
+
session = "lemans-remote-#{SecureRandom.hex(4)}"
|
|
530
|
+
sandbox.process.create_session(session)
|
|
531
|
+
sandbox.process.execute_session_command(
|
|
532
|
+
session_id: session,
|
|
533
|
+
req: ::Daytona::SessionExecuteRequest.new(command: "bash #{REMOTE_WRAPPER}", run_async: true)
|
|
534
|
+
)
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
def run_metadata(sandbox)
|
|
538
|
+
{
|
|
539
|
+
"run_id" => @run_id,
|
|
540
|
+
"bench" => @bench.root.basename.to_s,
|
|
541
|
+
"tasks" => @tasks,
|
|
542
|
+
"models" => @models,
|
|
543
|
+
"args" => @extra_args,
|
|
544
|
+
"lemans_version" => Lemans::VERSION,
|
|
545
|
+
"snapshot" => @snapshot,
|
|
546
|
+
"sandbox_id" => sandbox.id,
|
|
547
|
+
"started_at" => @started_at,
|
|
548
|
+
"timeout_sec" => @timeout_sec
|
|
549
|
+
}
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
def wrapper_script
|
|
553
|
+
vault_dir = "#{Vault::MOUNT}/#{@run_id}"
|
|
554
|
+
<<~BASH
|
|
555
|
+
#!/bin/bash
|
|
556
|
+
set -u
|
|
557
|
+
ruby #{REMOTE_HOOKS} start
|
|
558
|
+
(cd #{REMOTE_BENCH_DIR} && #{remote_command}) > #{REMOTE_LOG} 2>&1
|
|
559
|
+
status=$?
|
|
560
|
+
cp #{REMOTE_LOG} #{vault_dir}/run.log || true
|
|
561
|
+
if [ -d #{REMOTE_BENCH_DIR}/runs ]; then
|
|
562
|
+
tar -czf #{vault_dir}/runs.tar.gz -C #{REMOTE_BENCH_DIR}/runs .
|
|
563
|
+
fi
|
|
564
|
+
(cd #{REMOTE_BENCH_DIR} && lemans report --format csv > #{vault_dir}/report.csv 2>/dev/null) || true
|
|
565
|
+
ruby #{REMOTE_HOOKS} finish "$status"
|
|
566
|
+
BASH
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
def hooks_script
|
|
570
|
+
<<~RUBY
|
|
571
|
+
require "json"
|
|
572
|
+
require "time"
|
|
573
|
+
require "fileutils"
|
|
574
|
+
|
|
575
|
+
META = JSON.parse(File.read("#{REMOTE_META}"))
|
|
576
|
+
VAULT_DIR = File.join("#{Vault::MOUNT}", META.fetch("run_id"))
|
|
577
|
+
MANIFEST = File.join(VAULT_DIR, "manifest.json")
|
|
578
|
+
|
|
579
|
+
def write_manifest(extra)
|
|
580
|
+
base = File.exist?(MANIFEST) ? JSON.parse(File.read(MANIFEST)) : META
|
|
581
|
+
File.write(MANIFEST, JSON.pretty_generate(base.merge(extra)))
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
case ARGV.fetch(0)
|
|
585
|
+
when "start"
|
|
586
|
+
FileUtils.mkdir_p(VAULT_DIR)
|
|
587
|
+
write_manifest("status" => "running")
|
|
588
|
+
when "finish"
|
|
589
|
+
exit_code = Integer(ARGV.fetch(1, "1"))
|
|
590
|
+
status = exit_code.zero? ? "success" : "failed"
|
|
591
|
+
write_manifest("status" => status, "exit_code" => exit_code, "finished_at" => Time.now.utc.iso8601)
|
|
592
|
+
begin
|
|
593
|
+
require "openssl"
|
|
594
|
+
require "daytona"
|
|
595
|
+
sandbox = Daytona::Daytona.new.get(META.fetch("sandbox_id"))
|
|
596
|
+
sandbox.labels = {
|
|
597
|
+
"#{LABEL}" => "1",
|
|
598
|
+
"#{RUN_ID_LABEL}" => META.fetch("run_id"),
|
|
599
|
+
"#{STATUS_LABEL}" => status,
|
|
600
|
+
"#{STARTED_AT_LABEL}" => META.fetch("started_at")
|
|
601
|
+
}
|
|
602
|
+
sandbox.stop
|
|
603
|
+
rescue StandardError => e
|
|
604
|
+
warn "finalize: \#{e.class}: \#{e.message}"
|
|
605
|
+
end
|
|
606
|
+
end
|
|
607
|
+
RUBY
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
def stream_until_done(sandbox)
|
|
611
|
+
offset = 0
|
|
612
|
+
deadline = now + @timeout_sec
|
|
613
|
+
loop do
|
|
614
|
+
chunk = read_log(sandbox, offset)
|
|
615
|
+
unless chunk.empty?
|
|
616
|
+
offset += chunk.bytesize
|
|
617
|
+
$stdout.write(chunk)
|
|
618
|
+
$stdout.flush
|
|
619
|
+
end
|
|
620
|
+
status = read_status(sandbox)
|
|
621
|
+
return status if status && chunk.empty?
|
|
622
|
+
|
|
623
|
+
if now > deadline
|
|
624
|
+
say :timeout, "run did not finish within #{@timeout_sec}s", :red
|
|
625
|
+
return TIMED_OUT
|
|
626
|
+
end
|
|
627
|
+
|
|
628
|
+
sleep POLL_INTERVAL_SEC unless status
|
|
629
|
+
end
|
|
630
|
+
end
|
|
631
|
+
|
|
632
|
+
def read_log(sandbox, offset)
|
|
633
|
+
response = sandbox.process.exec(
|
|
634
|
+
command: "tail -c +#{offset + 1} #{REMOTE_LOG} 2>/dev/null | head -c #{LOG_CHUNK_BYTES}",
|
|
635
|
+
timeout: 60
|
|
636
|
+
)
|
|
637
|
+
response.result.to_s
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
def read_status(sandbox)
|
|
641
|
+
response = sandbox.process.exec(command: "cat #{REMOTE_STATUS} 2>/dev/null", timeout: 60)
|
|
642
|
+
value = response.result.to_s.strip
|
|
643
|
+
value.match?(/\A\d+\z/) ? Integer(value) : nil
|
|
644
|
+
end
|
|
645
|
+
|
|
646
|
+
def collect(sandbox)
|
|
647
|
+
probe = sandbox.process.exec(command: "test -d #{REMOTE_BENCH_DIR}/runs", timeout: 60)
|
|
648
|
+
unless probe.exit_code.zero?
|
|
649
|
+
say :collect, "no runs directory in the sandbox — nothing to download", :yellow
|
|
650
|
+
return
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
exec!(sandbox, "tar -czf #{REMOTE_RUNS_ARCHIVE} -C #{REMOTE_BENCH_DIR}/runs .")
|
|
654
|
+
FileUtils.mkdir_p(@runs_dir)
|
|
655
|
+
Dir.mktmpdir("lemans-remote") do |tmp|
|
|
656
|
+
local = File.join(tmp, "runs.tgz")
|
|
657
|
+
File.open(local, "wb") do |file|
|
|
658
|
+
sandbox.fs.download_file_stream(REMOTE_RUNS_ARCHIVE, timeout: TRANSFER_TIMEOUT_SEC) { file.write(_1) }
|
|
659
|
+
end
|
|
660
|
+
_, err, status = Open3.capture3("tar", "-xzf", local, "-C", @runs_dir)
|
|
661
|
+
raise "could not unpack the runs: #{err}" unless status.success?
|
|
662
|
+
end
|
|
663
|
+
say :collect, "runs synced into #{@runs_dir}/"
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
def teardown(sandbox)
|
|
667
|
+
if @keep
|
|
668
|
+
say :keep, "sandbox #{sandbox.id} left running (TTL will reap it)", :yellow
|
|
669
|
+
return
|
|
670
|
+
end
|
|
671
|
+
|
|
672
|
+
sandbox.delete
|
|
673
|
+
rescue StandardError => e
|
|
674
|
+
warn "lemans-remote: sandbox #{sandbox.id} may still be running — delete failed: #{e.message}"
|
|
675
|
+
end
|
|
676
|
+
|
|
677
|
+
def exec!(sandbox, command)
|
|
678
|
+
response = sandbox.process.exec(command: command, timeout: SETUP_TIMEOUT_SEC)
|
|
679
|
+
raise "`#{command}` failed (exit #{response.exit_code}): #{response.result}" unless response.exit_code.zero?
|
|
680
|
+
|
|
681
|
+
response
|
|
682
|
+
end
|
|
683
|
+
|
|
684
|
+
def say(status, message, color = :green) = @shell.say_status(status, message, color)
|
|
685
|
+
|
|
686
|
+
def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
687
|
+
end
|
|
688
|
+
|
|
689
|
+
class CLI < Thor
|
|
690
|
+
check_unknown_options!
|
|
691
|
+
|
|
692
|
+
def self.exit_on_failure? = true
|
|
693
|
+
|
|
694
|
+
class_option :cpus, type: :numeric, default: 4, desc: "Orchestrator vCPUs (part of the snapshot identity)"
|
|
695
|
+
class_option :memory, type: :numeric, default: 8, desc: "Orchestrator memory, GiB"
|
|
696
|
+
class_option :disk, type: :numeric, default: 10, desc: "Orchestrator disk, GiB"
|
|
697
|
+
|
|
698
|
+
desc "provision", "Build the orchestrator snapshot (lemans #{Lemans::VERSION} preinstalled)"
|
|
699
|
+
option :force, type: :boolean, default: false, desc: "Discard the existing snapshot and rebuild"
|
|
700
|
+
def provision
|
|
701
|
+
provisioner = build_provisioner
|
|
702
|
+
if provisioner.provisioned? && !options[:force]
|
|
703
|
+
say_status :snapshot, "#{provisioner.name} already provisioned", :green
|
|
704
|
+
provisioner.call
|
|
705
|
+
return
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
say_status :build, provisioner.name
|
|
709
|
+
provisioner.call(force: options[:force], logger: ->(chunk) { print chunk })
|
|
710
|
+
say_status :snapshot, "#{provisioner.name} ready", :green
|
|
711
|
+
rescue Lemans::ConfigError, RuntimeError => e
|
|
712
|
+
raise Thor::Error, "lemans-remote: #{e.message}"
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
map "run" => :run_bench
|
|
716
|
+
desc "run", "Run a bench on a remote Daytona sandbox (fire-and-forget; --sync to wait and download)"
|
|
717
|
+
option :bench, default: ".", desc: "Directory holding bench.yml"
|
|
718
|
+
option :task, repeatable: true, desc: "Run task(s) by name (default: all)"
|
|
719
|
+
option :model, repeatable: true, desc: "Override the model(s) from bench.yml"
|
|
720
|
+
option :args, default: "", desc: "Extra `lemans run` options, passed through verbatim"
|
|
721
|
+
option :timeout, default: "6h", desc: "Give up on the remote run after this long"
|
|
722
|
+
option :sync, type: :boolean, default: false, desc: "Wait for the run and download the results directly"
|
|
723
|
+
option :run_in_band, type: :boolean, default: false, aliases: %w[--runInBand],
|
|
724
|
+
desc: "Async mode: run all tasks in one sandbox instead of one sandbox per task"
|
|
725
|
+
option :keep, type: :boolean, default: false, desc: "Sync mode: leave the sandbox around for debugging"
|
|
726
|
+
option :runs_dir, default: "runs", desc: "Sync mode: local directory to sync the results into"
|
|
727
|
+
option :env, repeatable: true, desc: "Forward an extra host ENV variable by name"
|
|
728
|
+
def run_bench
|
|
729
|
+
bench = Lemans::Bench.load(options[:bench])
|
|
730
|
+
tasks = options[:task] || []
|
|
731
|
+
unknown = tasks - bench.tasks.map(&:name)
|
|
732
|
+
raise Thor::Error, "lemans-remote: no such task(s): #{unknown.join(", ")}" if unknown.any?
|
|
733
|
+
|
|
734
|
+
provisioner = build_provisioner
|
|
735
|
+
unless provisioner.provisioned?
|
|
736
|
+
raise Thor::Error, "lemans-remote: snapshot #{provisioner.name} not found — run `lemans-remote provision` first"
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
models = options[:model] || []
|
|
740
|
+
batches = plan_batches(bench, tasks)
|
|
741
|
+
Vault.ensure unless options[:sync]
|
|
742
|
+
|
|
743
|
+
if batches.size == 1
|
|
744
|
+
exit_code = launch_batch(bench, batches.first, models, provisioner)
|
|
745
|
+
say_status :detached, "`lemans-remote status` to watch, `lemans-remote pull-runs` to fetch results" unless options[:sync]
|
|
746
|
+
exit exit_code unless exit_code.zero?
|
|
747
|
+
return
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
say_status :fanout, "#{batches.size} sandboxes, one per task"
|
|
751
|
+
failures = launch_batches(bench, batches, models, provisioner)
|
|
752
|
+
say_status :detached, "`lemans-remote status` to watch, `lemans-remote pull-runs` to fetch results"
|
|
753
|
+
raise Thor::Error, "lemans-remote: failed to launch: #{failures.join("; ")}" if failures.any?
|
|
754
|
+
rescue Lemans::ConfigError, RuntimeError => e
|
|
755
|
+
raise Thor::Error, "lemans-remote: #{e.message}"
|
|
756
|
+
end
|
|
757
|
+
|
|
758
|
+
desc "status", "List lemans-remote runs (--history adds completed runs whose sandboxes are gone)"
|
|
759
|
+
option :history, type: :boolean, default: false, desc: "Also read the vault manifests (spins a short-lived helper sandbox)"
|
|
760
|
+
def status
|
|
761
|
+
rows = Fleet.rows.map { [_1.run_id, _1.status, _1.started_at || "?", _1.state, _1.sandbox.id] }
|
|
762
|
+
if options[:history]
|
|
763
|
+
seen = rows.map(&:first)
|
|
764
|
+
manifests = with_vault { |vault, sandbox| vault.manifests(sandbox) }
|
|
765
|
+
manifests.reject { seen.include?(_1["run_id"]) }.each do |manifest|
|
|
766
|
+
note = manifest["pulled_at"] ? "pulled" : "-"
|
|
767
|
+
rows << [manifest["run_id"], manifest["status"], manifest["started_at"] || "?", "gone", note]
|
|
768
|
+
end
|
|
769
|
+
end
|
|
770
|
+
|
|
771
|
+
if rows.empty?
|
|
772
|
+
say "no lemans-remote runs#{options[:history] ? "" : " — try --history for completed ones"}"
|
|
773
|
+
return
|
|
774
|
+
end
|
|
775
|
+
|
|
776
|
+
rows.sort_by! { _1[2].to_s }
|
|
777
|
+
rows.reverse!
|
|
778
|
+
print_table([%w[run status started sandbox id]] + rows)
|
|
779
|
+
rescue Lemans::ConfigError, RuntimeError => e
|
|
780
|
+
raise Thor::Error, "lemans-remote: #{e.message}"
|
|
781
|
+
end
|
|
782
|
+
|
|
783
|
+
map "pull-runs" => :pull_runs
|
|
784
|
+
desc "pull-runs [RUN_IDS...]", "Download archived runs from the vault into the local runs directory"
|
|
785
|
+
option :all, type: :boolean, default: false, desc: "Pull every completed run, even ones already pulled"
|
|
786
|
+
option :runs_dir, default: "runs", desc: "Local directory to sync the results into"
|
|
787
|
+
def pull_runs(*run_ids)
|
|
788
|
+
pulled = []
|
|
789
|
+
with_vault do |vault, sandbox|
|
|
790
|
+
manifests = vault.manifests(sandbox)
|
|
791
|
+
known = manifests.map { _1["run_id"] }
|
|
792
|
+
targets =
|
|
793
|
+
if run_ids.any?
|
|
794
|
+
missing = run_ids - known
|
|
795
|
+
raise Thor::Error, "lemans-remote: no such run(s) in the vault: #{missing.join(", ")}" if missing.any?
|
|
796
|
+
|
|
797
|
+
run_ids
|
|
798
|
+
else
|
|
799
|
+
manifests.select { %w[success failed].include?(_1["status"]) }
|
|
800
|
+
.reject { !options[:all] && _1["pulled_at"] }
|
|
801
|
+
.map { _1["run_id"] }
|
|
802
|
+
end
|
|
803
|
+
say_status :pull, "nothing new to pull (use --all to re-pull)", :yellow if targets.empty?
|
|
804
|
+
|
|
805
|
+
targets.each do |run_id|
|
|
806
|
+
results = vault.download_runs(sandbox, run_id, options[:runs_dir])
|
|
807
|
+
if results
|
|
808
|
+
vault.mark_pulled(sandbox, run_id)
|
|
809
|
+
pulled << run_id
|
|
810
|
+
say_status :pulled, "#{run_id} (#{results.size} result(s))", :green
|
|
811
|
+
results.take(10).each { say " #{_1}" }
|
|
812
|
+
say " … and #{results.size - 10} more" if results.size > 10
|
|
813
|
+
else
|
|
814
|
+
say_status :missing, "#{run_id} has no runs.tar.gz (still running?)", :yellow
|
|
815
|
+
end
|
|
816
|
+
end
|
|
817
|
+
end
|
|
818
|
+
say_status :done, "#{pulled.size} run(s) synced into #{options[:runs_dir]}/", :green if pulled.any?
|
|
819
|
+
rescue Lemans::ConfigError, RuntimeError => e
|
|
820
|
+
raise Thor::Error, "lemans-remote: #{e.message}"
|
|
821
|
+
end
|
|
822
|
+
|
|
823
|
+
desc "clobber [RUN_IDS...]", "Delete archived runs from the vault (--all wipes the whole volume)"
|
|
824
|
+
option :all, type: :boolean, default: false, desc: "Delete every archived run and the volume itself"
|
|
825
|
+
option :force, type: :boolean, default: false, aliases: "-f", desc: "Skip the confirmation"
|
|
826
|
+
def clobber(*run_ids)
|
|
827
|
+
raise Thor::Error, "lemans-remote: pass run id(s) or --all" if run_ids.empty? && !options[:all]
|
|
828
|
+
|
|
829
|
+
live = Fleet.live
|
|
830
|
+
if options[:all]
|
|
831
|
+
unless live.empty?
|
|
832
|
+
raise Thor::Error, "lemans-remote: #{live.size} run(s) still live (#{live.map(&:run_id).join(", ")}) — wait for them to finish"
|
|
833
|
+
end
|
|
834
|
+
return unless options[:force] || yes?("Delete the whole #{Vault::NAME} volume and every stopped run sandbox? [y/N]")
|
|
835
|
+
|
|
836
|
+
Fleet.rows.each { safe_delete_sandbox(_1.sandbox) }
|
|
837
|
+
if Vault.drop
|
|
838
|
+
say_status :clobbered, "volume #{Vault::NAME} deleted (recreated on the next run)", :green
|
|
839
|
+
else
|
|
840
|
+
say_status :absent, "volume #{Vault::NAME} does not exist", :yellow
|
|
841
|
+
end
|
|
842
|
+
return
|
|
843
|
+
end
|
|
844
|
+
|
|
845
|
+
still_live = live.select { run_ids.include?(_1.run_id) }
|
|
846
|
+
raise Thor::Error, "lemans-remote: still live: #{still_live.map(&:run_id).join(", ")}" if still_live.any?
|
|
847
|
+
return unless options[:force] || yes?("Delete #{run_ids.join(", ")} from the vault? [y/N]")
|
|
848
|
+
|
|
849
|
+
rows = Fleet.rows
|
|
850
|
+
with_vault do |vault, sandbox|
|
|
851
|
+
run_ids.each do |run_id|
|
|
852
|
+
vault.clobber(sandbox, run_id)
|
|
853
|
+
rows.select { _1.run_id == run_id }.each { safe_delete_sandbox(_1.sandbox) }
|
|
854
|
+
say_status :clobbered, run_id, :green
|
|
855
|
+
end
|
|
856
|
+
end
|
|
857
|
+
rescue Lemans::ConfigError, RuntimeError => e
|
|
858
|
+
raise Thor::Error, "lemans-remote: #{e.message}"
|
|
859
|
+
end
|
|
860
|
+
|
|
861
|
+
desc "deprovision", "Delete the orchestrator snapshot"
|
|
862
|
+
option :name, desc: "Delete a specific snapshot by name (default: the one for this shape and version)"
|
|
863
|
+
def deprovision
|
|
864
|
+
provisioner = build_provisioner
|
|
865
|
+
target = options[:name] || provisioner.name
|
|
866
|
+
if provisioner.drop(target)
|
|
867
|
+
say_status :dropped, target, :green
|
|
868
|
+
else
|
|
869
|
+
say_status :absent, "#{target} — nothing to drop", :yellow
|
|
870
|
+
end
|
|
871
|
+
rescue Lemans::ConfigError, RuntimeError => e
|
|
872
|
+
raise Thor::Error, "lemans-remote: #{e.message}"
|
|
873
|
+
end
|
|
874
|
+
|
|
875
|
+
desc "version", "Print the lemans version this tool provisions and runs"
|
|
876
|
+
def version
|
|
877
|
+
say Lemans::VERSION
|
|
878
|
+
end
|
|
879
|
+
|
|
880
|
+
private
|
|
881
|
+
|
|
882
|
+
def build_provisioner
|
|
883
|
+
Provisioner.new(cpus: options[:cpus].to_i, memory_gb: options[:memory].to_i, disk_gb: options[:disk].to_i)
|
|
884
|
+
end
|
|
885
|
+
|
|
886
|
+
FANOUT_THREADS = 4
|
|
887
|
+
|
|
888
|
+
def plan_batches(bench, tasks)
|
|
889
|
+
return [tasks] if options[:sync] || options[:run_in_band]
|
|
890
|
+
|
|
891
|
+
expanded = tasks.any? ? tasks : bench.tasks.map(&:name)
|
|
892
|
+
expanded.map { [_1] }
|
|
893
|
+
end
|
|
894
|
+
|
|
895
|
+
def launch_batch(bench, tasks, models, provisioner)
|
|
896
|
+
Runner.new(
|
|
897
|
+
bench: bench,
|
|
898
|
+
snapshot: provisioner.name,
|
|
899
|
+
run_id: build_run_id(bench, tasks, models),
|
|
900
|
+
tasks: tasks,
|
|
901
|
+
models: models,
|
|
902
|
+
extra_args: options[:args].strip,
|
|
903
|
+
extra_env: options[:env] || [],
|
|
904
|
+
timeout_sec: Lemans::Units.seconds(options[:timeout], field: "--timeout").to_i,
|
|
905
|
+
runs_dir: options[:runs_dir],
|
|
906
|
+
keep: options[:keep],
|
|
907
|
+
sync: options[:sync],
|
|
908
|
+
shell: shell
|
|
909
|
+
).call
|
|
910
|
+
end
|
|
911
|
+
|
|
912
|
+
def launch_batches(bench, batches, models, provisioner)
|
|
913
|
+
queue = Queue.new
|
|
914
|
+
batches.each { queue << _1 }
|
|
915
|
+
failures = Queue.new
|
|
916
|
+
|
|
917
|
+
threads = [FANOUT_THREADS, batches.size].min.times.map do
|
|
918
|
+
Thread.new do
|
|
919
|
+
loop do
|
|
920
|
+
batch =
|
|
921
|
+
begin
|
|
922
|
+
queue.pop(true)
|
|
923
|
+
rescue ThreadError
|
|
924
|
+
break
|
|
925
|
+
end
|
|
926
|
+
begin
|
|
927
|
+
launch_batch(bench, batch, models, provisioner)
|
|
928
|
+
rescue StandardError => e
|
|
929
|
+
failures << "#{batch.join(",")} (#{e.message})"
|
|
930
|
+
end
|
|
931
|
+
end
|
|
932
|
+
end
|
|
933
|
+
end
|
|
934
|
+
threads.each(&:join)
|
|
935
|
+
|
|
936
|
+
Array.new(failures.size) { failures.pop }
|
|
937
|
+
end
|
|
938
|
+
|
|
939
|
+
def build_run_id(bench, tasks, models)
|
|
940
|
+
parts = [bench.root.basename.to_s]
|
|
941
|
+
parts << (tasks.size == 1 ? tasks.first : "#{tasks.size}tasks") if tasks.any?
|
|
942
|
+
if models.any?
|
|
943
|
+
short = models.first.split("/").last
|
|
944
|
+
short += "+#{models.size - 1}" if models.size > 1
|
|
945
|
+
parts << short
|
|
946
|
+
end
|
|
947
|
+
parts << (ENV["LEMANS_REMOTE_USER"] || ENV["USER"] || "anon")
|
|
948
|
+
parts << Time.now.utc.strftime("%Y%m%dT%H%M%S")
|
|
949
|
+
parts << SecureRandom.alphanumeric(4).downcase
|
|
950
|
+
parts.map { name_part(_1) }.reject(&:empty?).join("-")
|
|
951
|
+
end
|
|
952
|
+
|
|
953
|
+
def name_part(value) = value.to_s.downcase.gsub(/[^a-z0-9._+]+/, "-").gsub(/\A-+|-+\z/, "")
|
|
954
|
+
|
|
955
|
+
def with_vault(&block)
|
|
956
|
+
provisioner = build_provisioner
|
|
957
|
+
unless provisioner.provisioned?
|
|
958
|
+
raise Thor::Error, "lemans-remote: snapshot #{provisioner.name} not found — run `lemans-remote provision` first"
|
|
959
|
+
end
|
|
960
|
+
|
|
961
|
+
vault = VaultClient.new(snapshot: provisioner.name)
|
|
962
|
+
vault.with_helper { |sandbox| block.call(vault, sandbox) }
|
|
963
|
+
end
|
|
964
|
+
|
|
965
|
+
def safe_delete_sandbox(sandbox)
|
|
966
|
+
sandbox.delete
|
|
967
|
+
rescue StandardError => e
|
|
968
|
+
warn "lemans-remote: could not delete sandbox #{sandbox.id}: #{e.message}"
|
|
969
|
+
end
|
|
970
|
+
end
|
|
971
|
+
end
|
|
972
|
+
|
|
973
|
+
begin
|
|
974
|
+
LemansRemote::CLI.start(ARGV)
|
|
975
|
+
rescue Interrupt
|
|
976
|
+
warn "\nlemans-remote: interrupted"
|
|
977
|
+
exit 130
|
|
978
|
+
rescue => e # rubocop:disable Style/RescueStandardError
|
|
979
|
+
raise e if $DEBUG
|
|
980
|
+
|
|
981
|
+
warn e.message
|
|
982
|
+
warn e.backtrace.take(10).join("\n") if ENV["LEMANS_DEBUG"] == "1"
|
|
983
|
+
exit 1
|
|
984
|
+
end
|
data/lib/lemans/version.rb
CHANGED
data/lib/miniswen/agent.rb
CHANGED
|
@@ -400,7 +400,14 @@ module Miniswen
|
|
|
400
400
|
error = +""
|
|
401
401
|
error << "Unknown tool '#{call[:name]}'." if call[:name] != "bash"
|
|
402
402
|
arguments = call[:arguments]
|
|
403
|
-
|
|
403
|
+
if !arguments.is_a?(Hash) || !arguments["command"]
|
|
404
|
+
error << "Missing 'command' argument in bash tool call."
|
|
405
|
+
elsif arguments["command"].to_s.include?("\0")
|
|
406
|
+
# Process.spawn rejects strings with NUL bytes, so the command could
|
|
407
|
+
# never reach a shell.
|
|
408
|
+
error << "The 'command' argument contains a null byte (\\x00) and cannot be executed. " \
|
|
409
|
+
"Resend the command without null bytes."
|
|
410
|
+
end
|
|
404
411
|
return error unless error.empty?
|
|
405
412
|
end
|
|
406
413
|
nil
|
|
@@ -497,7 +504,9 @@ module Miniswen
|
|
|
497
504
|
)
|
|
498
505
|
payload(response)
|
|
499
506
|
rescue RubyLLM::Error => e
|
|
500
|
-
|
|
507
|
+
body = e.response&.body.to_s
|
|
508
|
+
detail = body.empty? ? e.message : "#{e.message}: #{body[0, 1000]}"
|
|
509
|
+
raise InfrastructureError, "miniswen: the model call failed: #{detail}"
|
|
501
510
|
rescue Faraday::SSLError, Faraday::ConnectionFailed, Faraday::TimeoutError => e
|
|
502
511
|
raise InfrastructureError, "miniswen: the model call failed: #{e.class}: #{e.message}"
|
|
503
512
|
end
|
data/lib/miniswen/version.rb
CHANGED
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: 0.2.
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Svyatoslav Kryukov
|
|
@@ -121,6 +121,7 @@ files:
|
|
|
121
121
|
- LICENSE.txt
|
|
122
122
|
- README.md
|
|
123
123
|
- exe/lemans
|
|
124
|
+
- exe/lemans-remote
|
|
124
125
|
- lib/lemans.rb
|
|
125
126
|
- lib/lemans/agents.rb
|
|
126
127
|
- lib/lemans/agents/base.rb
|