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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 72028d0d807d2463a560089be39be6a124699577b91c1cf576417ff1e47b7e0c
4
- data.tar.gz: 23453c36835c9c339f232a3fb3b1a0106a3c3d7f5c0e052d269a27884c46fd38
3
+ metadata.gz: d451c9e558e461c51e7be30bdc17d9921a0ae8f028d82946dd5c0538620c5c31
4
+ data.tar.gz: 3ff346d1263965339b9e51b51eaefade1fdd581048cc6b21c876836503cef3c8
5
5
  SHA512:
6
- metadata.gz: 5de817d297877f27179ed2e39fe8d513eda28665ec97aebeaa9d7bdbddc5dd9ba70028bef43a9cc40c5d718af5adfe7dd1938ebb532f8ffa6ff826ccba770408
7
- data.tar.gz: f9c18057384bd81ad1043f9aabe0860a81b5d6ea89429a2266b1992cd4b0c7004f78d6165aef672017fa8af705b8448cd1b496ee28e9abc0bf4ae2d1a2e76236
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lemans
4
- VERSION = "0.2.0"
4
+ VERSION = "0.2.1"
5
5
  end
@@ -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
- error << "Missing 'command' argument in bash tool call." unless arguments.is_a?(Hash) && arguments["command"]
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
- raise InfrastructureError, "miniswen: the model call failed: #{e.message}"
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Miniswen
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
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: 0.2.0
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