agent-harness 0.5.7 → 0.5.9
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/.release-please-manifest.json +1 -1
- data/AUDIT_DISPOSITION.md +111 -0
- data/CHANGELOG.md +21 -0
- data/README.md +140 -2
- data/lib/agent_harness/authentication.rb +28 -9
- data/lib/agent_harness/command_executor.rb +450 -13
- data/lib/agent_harness/docker_command_executor.rb +499 -8
- data/lib/agent_harness/error_taxonomy.rb +4 -4
- data/lib/agent_harness/execution_preparation.rb +64 -0
- data/lib/agent_harness/orchestration/provider_manager.rb +26 -6
- data/lib/agent_harness/provider_health_check.rb +28 -6
- data/lib/agent_harness/provider_runtime.rb +38 -11
- data/lib/agent_harness/providers/adapter.rb +596 -8
- data/lib/agent_harness/providers/aider.rb +71 -0
- data/lib/agent_harness/providers/anthropic.rb +110 -7
- data/lib/agent_harness/providers/base.rb +34 -5
- data/lib/agent_harness/providers/codex.rb +40 -9
- data/lib/agent_harness/providers/cursor.rb +76 -2
- data/lib/agent_harness/providers/gemini.rb +21 -6
- data/lib/agent_harness/providers/github_copilot.rb +12 -0
- data/lib/agent_harness/providers/kilocode.rb +16 -1
- data/lib/agent_harness/providers/mistral_vibe.rb +9 -0
- data/lib/agent_harness/providers/opencode.rb +64 -3
- data/lib/agent_harness/providers/registry.rb +392 -18
- data/lib/agent_harness/version.rb +1 -1
- data/lib/agent_harness.rb +29 -0
- metadata +3 -1
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
require "open3"
|
|
4
4
|
require "timeout"
|
|
5
5
|
require "shellwords"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require "tempfile"
|
|
8
|
+
require "tmpdir"
|
|
9
|
+
require "digest"
|
|
6
10
|
|
|
7
11
|
module AgentHarness
|
|
8
12
|
# Executes shell commands with timeout support
|
|
@@ -18,6 +22,10 @@ module AgentHarness
|
|
|
18
22
|
# @example With timeout
|
|
19
23
|
# result = executor.execute("claude --print", timeout: 300)
|
|
20
24
|
class CommandExecutor
|
|
25
|
+
PREPARATION_LOCK_POLL_INTERVAL = 0.01
|
|
26
|
+
PREPARATION_CLEANUP_GRACE_PERIOD = 5
|
|
27
|
+
PREPARATION_LOCK_ROOT = File.join(Dir.tmpdir, "agent-harness-preparation-locks")
|
|
28
|
+
|
|
21
29
|
# Result of a command execution
|
|
22
30
|
Result = Struct.new(:stdout, :stderr, :exit_code, :duration, keyword_init: true) do
|
|
23
31
|
def success?
|
|
@@ -42,6 +50,8 @@ module AgentHarness
|
|
|
42
50
|
# @param idle_timeout [Integer, Float, nil] idle timeout in seconds based on output activity
|
|
43
51
|
# @param env [Hash] environment variables
|
|
44
52
|
# @param stdin_data [String, nil] data to send to stdin
|
|
53
|
+
# @param preparation [ExecutionPreparation, nil] request-scoped bootstrap
|
|
54
|
+
# work for the runtime environment
|
|
45
55
|
# @param on_stdout_chunk [Proc, nil] callback for stdout chunks as they are produced
|
|
46
56
|
# @param on_stderr_chunk [Proc, nil] callback for stderr chunks as they are produced
|
|
47
57
|
# @param on_heartbeat [Proc, nil] callback invoked periodically while the command is running
|
|
@@ -51,7 +61,7 @@ module AgentHarness
|
|
|
51
61
|
# @return [Result] execution result
|
|
52
62
|
# @raise [TimeoutError] if the command times out
|
|
53
63
|
# @raise [IdleTimeoutError] if the command exceeds the idle timeout
|
|
54
|
-
def execute(command, timeout: nil, idle_timeout: nil, env: {}, stdin_data: nil,
|
|
64
|
+
def execute(command, timeout: nil, idle_timeout: nil, env: {}, stdin_data: nil, preparation: nil,
|
|
55
65
|
on_stdout_chunk: nil, on_stderr_chunk: nil, on_heartbeat: nil,
|
|
56
66
|
heartbeat_interval: 1.0, observer: nil)
|
|
57
67
|
validate_duration!(timeout, name: :timeout, allow_nil: true)
|
|
@@ -60,28 +70,70 @@ module AgentHarness
|
|
|
60
70
|
|
|
61
71
|
cmd_array = normalize_command(command)
|
|
62
72
|
cmd_string = cmd_array.shelljoin
|
|
73
|
+
command_name = cmd_array.first
|
|
74
|
+
start_time = current_time
|
|
75
|
+
deadline = timeout_deadline(timeout)
|
|
76
|
+
applied_preparation = []
|
|
77
|
+
held_preparation_locks = []
|
|
78
|
+
background_cleanup_scheduled = false
|
|
63
79
|
|
|
64
80
|
log_debug("Executing command",
|
|
65
81
|
command: cmd_string,
|
|
66
82
|
timeout: timeout,
|
|
67
83
|
idle_timeout: idle_timeout)
|
|
68
84
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
cmd_array,
|
|
85
|
+
held_preparation_locks = acquire_preparation_locks(
|
|
86
|
+
preparation,
|
|
87
|
+
env: env,
|
|
73
88
|
timeout: timeout,
|
|
74
|
-
|
|
89
|
+
deadline: deadline,
|
|
90
|
+
command_name: command_name
|
|
91
|
+
)
|
|
92
|
+
apply_preparation(
|
|
93
|
+
preparation,
|
|
75
94
|
env: env,
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
heartbeat_interval: heartbeat_interval,
|
|
81
|
-
observer: observer
|
|
95
|
+
timeout: timeout,
|
|
96
|
+
deadline: deadline,
|
|
97
|
+
command_name: command_name,
|
|
98
|
+
applied_preparation: applied_preparation
|
|
82
99
|
)
|
|
83
100
|
|
|
84
|
-
|
|
101
|
+
begin
|
|
102
|
+
stdout, stderr, status = execute_streaming(
|
|
103
|
+
cmd_array,
|
|
104
|
+
timeout: remaining_timeout(deadline, timeout:, command_name: command_name),
|
|
105
|
+
idle_timeout: idle_timeout,
|
|
106
|
+
env: env,
|
|
107
|
+
stdin_data: stdin_data,
|
|
108
|
+
on_stdout_chunk: on_stdout_chunk,
|
|
109
|
+
on_stderr_chunk: on_stderr_chunk,
|
|
110
|
+
on_heartbeat: on_heartbeat,
|
|
111
|
+
heartbeat_interval: heartbeat_interval,
|
|
112
|
+
observer: observer
|
|
113
|
+
)
|
|
114
|
+
rescue TimeoutError => e
|
|
115
|
+
raise e if e.is_a?(IdleTimeoutError)
|
|
116
|
+
|
|
117
|
+
raise TimeoutError, "Command timed out after #{timeout} seconds: #{command_name}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
begin
|
|
121
|
+
cleanup_preparation(
|
|
122
|
+
applied_preparation,
|
|
123
|
+
command_name: command_name,
|
|
124
|
+
timeout: timeout,
|
|
125
|
+
deadline: cleanup_deadline(deadline, timeout:)
|
|
126
|
+
)
|
|
127
|
+
rescue TimeoutError
|
|
128
|
+
schedule_cleanup_preparation(
|
|
129
|
+
applied_preparation,
|
|
130
|
+
held_preparation_locks,
|
|
131
|
+
command_name: command_name
|
|
132
|
+
)
|
|
133
|
+
background_cleanup_scheduled = true
|
|
134
|
+
held_preparation_locks = []
|
|
135
|
+
end
|
|
136
|
+
duration = current_time - start_time
|
|
85
137
|
|
|
86
138
|
Result.new(
|
|
87
139
|
stdout: stdout,
|
|
@@ -89,6 +141,46 @@ module AgentHarness
|
|
|
89
141
|
exit_code: status.exitstatus,
|
|
90
142
|
duration: duration
|
|
91
143
|
)
|
|
144
|
+
ensure
|
|
145
|
+
pending_exception = $!
|
|
146
|
+
unless background_cleanup_scheduled || applied_preparation.nil? || applied_preparation.empty?
|
|
147
|
+
begin
|
|
148
|
+
cleanup_preparation(
|
|
149
|
+
applied_preparation,
|
|
150
|
+
command_name: command_name,
|
|
151
|
+
timeout: timeout,
|
|
152
|
+
deadline: cleanup_deadline(deadline, timeout:)
|
|
153
|
+
)
|
|
154
|
+
rescue TimeoutError => e
|
|
155
|
+
raise e if pending_exception.nil?
|
|
156
|
+
|
|
157
|
+
if pending_exception.is_a?(TimeoutError)
|
|
158
|
+
schedule_cleanup_preparation(
|
|
159
|
+
applied_preparation,
|
|
160
|
+
held_preparation_locks,
|
|
161
|
+
command_name: command_name
|
|
162
|
+
)
|
|
163
|
+
background_cleanup_scheduled = true
|
|
164
|
+
held_preparation_locks = []
|
|
165
|
+
else
|
|
166
|
+
# Preserve the original non-timeout exception; surface that
|
|
167
|
+
# cleanup also timed out so callers know bootstrap state may
|
|
168
|
+
# have leaked.
|
|
169
|
+
raise pending_exception.class,
|
|
170
|
+
"#{pending_exception.message} (cleanup also failed: #{e.message})"
|
|
171
|
+
end
|
|
172
|
+
rescue => e
|
|
173
|
+
raise e if pending_exception.nil?
|
|
174
|
+
|
|
175
|
+
# Surface cleanup failures even when unwinding from another exception,
|
|
176
|
+
# so callers know request-scoped bootstrap state may have leaked.
|
|
177
|
+
raise pending_exception.class,
|
|
178
|
+
"#{pending_exception.message} (cleanup also failed: #{e.message})"
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
unless background_cleanup_scheduled || held_preparation_locks.nil? || held_preparation_locks.empty?
|
|
182
|
+
release_preparation_locks(held_preparation_locks)
|
|
183
|
+
end
|
|
92
184
|
end
|
|
93
185
|
|
|
94
186
|
# Check if a binary exists in PATH
|
|
@@ -126,6 +218,351 @@ module AgentHarness
|
|
|
126
218
|
|
|
127
219
|
private
|
|
128
220
|
|
|
221
|
+
def acquire_preparation_locks(preparation, env:, timeout:, deadline:, command_name:)
|
|
222
|
+
return [] if preparation.nil? || preparation.empty?
|
|
223
|
+
|
|
224
|
+
preparation.file_writes.each { |write| validate_preparation_path_security!(write.path) }
|
|
225
|
+
|
|
226
|
+
acquired_locks = []
|
|
227
|
+
|
|
228
|
+
preparation_lock_keys(preparation, env).each do |key|
|
|
229
|
+
acquired_locks << acquire_preparation_lock(key, timeout:, deadline:, command_name:)
|
|
230
|
+
end
|
|
231
|
+
acquired_locks
|
|
232
|
+
rescue
|
|
233
|
+
release_preparation_locks(acquired_locks) if acquired_locks && !acquired_locks.empty?
|
|
234
|
+
raise
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def acquire_preparation_lock(key, timeout:, deadline:, command_name:)
|
|
238
|
+
lock_path = preparation_lock_path(key)
|
|
239
|
+
FileUtils.mkdir_p(File.dirname(lock_path), mode: 0o700)
|
|
240
|
+
lock_file = File.open(lock_path, File::RDWR | File::CREAT, 0o600)
|
|
241
|
+
|
|
242
|
+
begin
|
|
243
|
+
if timeout.nil?
|
|
244
|
+
lock_file.flock(File::LOCK_EX)
|
|
245
|
+
else
|
|
246
|
+
until lock_file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
247
|
+
sleep([PREPARATION_LOCK_POLL_INTERVAL, remaining_timeout(deadline, timeout:, command_name:)].min)
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
rescue
|
|
251
|
+
lock_file.close unless lock_file.closed?
|
|
252
|
+
raise
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
{key: key, file: lock_file}
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def release_preparation_locks(held_preparation_locks)
|
|
259
|
+
held_preparation_locks.reverse_each do |lock|
|
|
260
|
+
file = lock[:file]
|
|
261
|
+
next if file.nil? || file.closed?
|
|
262
|
+
|
|
263
|
+
file.flock(File::LOCK_UN)
|
|
264
|
+
file.close
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def preparation_lock_keys(preparation, env)
|
|
269
|
+
preparation.file_writes.map do |write|
|
|
270
|
+
"#{preparation_lock_scope}:#{expand_preparation_path(write.path, env)}"
|
|
271
|
+
end.uniq.sort
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
def preparation_lock_scope
|
|
275
|
+
"host"
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
def preparation_lock_path(key)
|
|
279
|
+
File.join(PREPARATION_LOCK_ROOT, "#{Digest::SHA256.hexdigest(key)}.lock")
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def apply_preparation(preparation, env:, timeout:, deadline:, command_name:, applied_preparation:)
|
|
283
|
+
return if preparation.nil? || preparation.empty?
|
|
284
|
+
|
|
285
|
+
preparation.file_writes.each do |write|
|
|
286
|
+
validate_preparation_path_security!(write.path)
|
|
287
|
+
validate_preparation_path_env!(write.path, env)
|
|
288
|
+
validate_home_relative_preparation_path!(write.path, env)
|
|
289
|
+
resolved_path = expand_preparation_path(write.path, env)
|
|
290
|
+
created_directories = missing_parent_directories(resolved_path)
|
|
291
|
+
snapshot = within_timeout(deadline, timeout:, command_name:) do
|
|
292
|
+
snapshot_file_state(resolved_path)
|
|
293
|
+
end
|
|
294
|
+
applied_preparation << {
|
|
295
|
+
path: resolved_path,
|
|
296
|
+
snapshot: snapshot,
|
|
297
|
+
created_directories: created_directories
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
within_timeout(deadline, timeout:, command_name:) do
|
|
301
|
+
FileUtils.mkdir_p(File.dirname(resolved_path))
|
|
302
|
+
delete_preparation_path(resolved_path) if snapshot[:type] == :symlink
|
|
303
|
+
File.binwrite(resolved_path, write.content)
|
|
304
|
+
File.chmod(write.mode, resolved_path) if write.mode
|
|
305
|
+
end
|
|
306
|
+
rescue => e
|
|
307
|
+
begin
|
|
308
|
+
cleanup_preparation(
|
|
309
|
+
applied_preparation,
|
|
310
|
+
command_name: command_name,
|
|
311
|
+
timeout: timeout,
|
|
312
|
+
deadline: cleanup_deadline(deadline, timeout:)
|
|
313
|
+
)
|
|
314
|
+
rescue => cleanup_error
|
|
315
|
+
log_debug("Failed to clean up runtime preparation", error: cleanup_error.message)
|
|
316
|
+
end
|
|
317
|
+
raise e
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def cleanup_preparation(applied_preparation, command_name:, timeout: nil, deadline: nil)
|
|
322
|
+
applied_preparation.reverse_each do |entry|
|
|
323
|
+
within_timeout(deadline, timeout:, command_name:) do
|
|
324
|
+
unless entry[:restored]
|
|
325
|
+
restore_file_state(entry[:path], entry[:snapshot])
|
|
326
|
+
entry[:restored] = true
|
|
327
|
+
end
|
|
328
|
+
cleanup_created_directories(entry[:created_directories])
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
applied_preparation.clear
|
|
332
|
+
end
|
|
333
|
+
|
|
334
|
+
def missing_parent_directories(path)
|
|
335
|
+
directories = []
|
|
336
|
+
current = File.dirname(path)
|
|
337
|
+
|
|
338
|
+
until current == File.dirname(current) || File.exist?(current) || File.symlink?(current)
|
|
339
|
+
directories << current
|
|
340
|
+
current = File.dirname(current)
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
directories
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def cleanup_created_directories(directories)
|
|
347
|
+
directories.each do |directory|
|
|
348
|
+
next unless File.directory?(directory) && !File.symlink?(directory)
|
|
349
|
+
|
|
350
|
+
Dir.rmdir(directory)
|
|
351
|
+
rescue Errno::ENOENT
|
|
352
|
+
next
|
|
353
|
+
rescue Errno::ENOTEMPTY, Errno::EEXIST
|
|
354
|
+
break
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def schedule_cleanup_preparation(applied_preparation, held_preparation_locks, command_name:)
|
|
359
|
+
cleanup_deadline = timeout_deadline(PREPARATION_CLEANUP_GRACE_PERIOD)
|
|
360
|
+
Thread.new(applied_preparation, held_preparation_locks, cleanup_deadline, command_name) do |entries, locks, deadline_at, cleanup_command_name|
|
|
361
|
+
Thread.current.report_on_exception = false if Thread.current.respond_to?(:report_on_exception=)
|
|
362
|
+
|
|
363
|
+
begin
|
|
364
|
+
cleanup_preparation(
|
|
365
|
+
entries,
|
|
366
|
+
command_name: cleanup_command_name,
|
|
367
|
+
timeout: PREPARATION_CLEANUP_GRACE_PERIOD,
|
|
368
|
+
deadline: deadline_at
|
|
369
|
+
)
|
|
370
|
+
rescue => e
|
|
371
|
+
log_debug("Failed to clean up runtime preparation after timeout", error: e.message)
|
|
372
|
+
ensure
|
|
373
|
+
release_preparation_locks(locks) unless locks.nil? || locks.empty?
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
def expand_preparation_path(path, env)
|
|
379
|
+
expanded_path = path.gsub(/\$(\w+)|\$\{([^}]+)\}/) do
|
|
380
|
+
key = Regexp.last_match(1) || Regexp.last_match(2)
|
|
381
|
+
resolve_preparation_path_env_var(key, env)
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
if expanded_path == "~"
|
|
385
|
+
return File.expand_path(resolve_preparation_home(env))
|
|
386
|
+
end
|
|
387
|
+
|
|
388
|
+
if expanded_path.start_with?("~/")
|
|
389
|
+
return File.expand_path(File.join(resolve_preparation_home(env), expanded_path.delete_prefix("~/")))
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
File.expand_path(expanded_path)
|
|
393
|
+
end
|
|
394
|
+
|
|
395
|
+
def validate_preparation_path_env!(path, env)
|
|
396
|
+
path.scan(/\$(\w+)|\$\{([^}]+)\}/) do |match|
|
|
397
|
+
key = match.compact.first
|
|
398
|
+
resolve_preparation_path_env_var(key, env)
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def validate_preparation_path_security!(path)
|
|
403
|
+
if path.include?("\x00")
|
|
404
|
+
raise ArgumentError, "preparation path must not contain null bytes"
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
if path.include?("\n")
|
|
408
|
+
raise ArgumentError, "preparation path must not contain newline characters"
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
if path.include?("\r")
|
|
412
|
+
raise ArgumentError, "preparation path must not contain carriage return characters"
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
if path.include?("`")
|
|
416
|
+
raise ArgumentError, "preparation path must not contain backtick characters"
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
if path.include?(";")
|
|
420
|
+
raise ArgumentError, "preparation path must not contain semicolon characters"
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
if path.include?("|")
|
|
424
|
+
raise ArgumentError, "preparation path must not contain pipe characters"
|
|
425
|
+
end
|
|
426
|
+
|
|
427
|
+
if path.include?("$(")
|
|
428
|
+
raise ArgumentError, "preparation path must not contain command substitution"
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
if path.include?("..")
|
|
432
|
+
raise ArgumentError, "preparation path must not contain path traversal"
|
|
433
|
+
end
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def validate_home_relative_preparation_path!(path, env)
|
|
437
|
+
return unless path == "~" || path.start_with?("~/")
|
|
438
|
+
return unless env.key?("HOME")
|
|
439
|
+
|
|
440
|
+
home = env["HOME"]
|
|
441
|
+
raise ArgumentError, "HOME cannot be nil or empty for home-relative preparation paths" if home.nil? || home.empty?
|
|
442
|
+
raise ArgumentError, "HOME must not contain path traversal" if home.include?("..")
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def resolve_preparation_path_env_var(key, env)
|
|
446
|
+
unless env.key?(key)
|
|
447
|
+
raise ArgumentError, "#{key} cannot be nil or empty for env-backed preparation paths"
|
|
448
|
+
end
|
|
449
|
+
|
|
450
|
+
value = env[key]
|
|
451
|
+
raise ArgumentError, "#{key} cannot be nil or empty for env-backed preparation paths" if value.nil? || value.empty?
|
|
452
|
+
raise ArgumentError, "#{key} must not contain path traversal" if value.include?("..")
|
|
453
|
+
|
|
454
|
+
value
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def resolve_preparation_home(env)
|
|
458
|
+
if env.key?("HOME")
|
|
459
|
+
home = env["HOME"]
|
|
460
|
+
raise ArgumentError, "HOME cannot be nil or empty for home-relative preparation paths" if home.nil? || home.empty?
|
|
461
|
+
raise ArgumentError, "HOME must not contain path traversal" if home.include?("..")
|
|
462
|
+
|
|
463
|
+
return home
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
ENV["HOME"] || Dir.home
|
|
467
|
+
end
|
|
468
|
+
|
|
469
|
+
def snapshot_file_state(path)
|
|
470
|
+
stat = File.lstat(path)
|
|
471
|
+
|
|
472
|
+
if stat.symlink?
|
|
473
|
+
{
|
|
474
|
+
existed: true,
|
|
475
|
+
type: :symlink,
|
|
476
|
+
target: File.readlink(path)
|
|
477
|
+
}
|
|
478
|
+
elsif stat.file?
|
|
479
|
+
backup_file = Tempfile.new("agent-harness-preparation")
|
|
480
|
+
backup_path = backup_file.path
|
|
481
|
+
backup_file.close!
|
|
482
|
+
FileUtils.cp(path, backup_path, preserve: true)
|
|
483
|
+
|
|
484
|
+
{
|
|
485
|
+
existed: true,
|
|
486
|
+
type: :file,
|
|
487
|
+
backup_path: backup_path
|
|
488
|
+
}
|
|
489
|
+
else
|
|
490
|
+
raise ArgumentError, "preparation target must be a regular file or symlink: #{path}"
|
|
491
|
+
end
|
|
492
|
+
rescue Errno::ENOENT
|
|
493
|
+
{existed: false}
|
|
494
|
+
rescue
|
|
495
|
+
FileUtils.rm_f(backup_path) if defined?(backup_path) && backup_path
|
|
496
|
+
raise
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
def restore_file_state(path, snapshot)
|
|
500
|
+
if snapshot[:type] == :symlink
|
|
501
|
+
delete_preparation_path(path)
|
|
502
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
503
|
+
File.symlink(snapshot[:target], path)
|
|
504
|
+
elsif snapshot[:existed]
|
|
505
|
+
backup_path = snapshot.fetch(:backup_path)
|
|
506
|
+
raise ArgumentError, "missing runtime preparation backup: #{backup_path}" unless File.exist?(backup_path)
|
|
507
|
+
|
|
508
|
+
delete_preparation_path(path)
|
|
509
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
510
|
+
FileUtils.cp(backup_path, path, preserve: true)
|
|
511
|
+
else
|
|
512
|
+
delete_preparation_path(path)
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
# Only remove the backup after restore succeeds. If restore fails (e.g. the
|
|
516
|
+
# prepared path was replaced by a directory), the backup must survive so
|
|
517
|
+
# later cleanup retries can still restore the original user file.
|
|
518
|
+
FileUtils.rm_f(snapshot[:backup_path]) if snapshot[:type] == :file && snapshot[:backup_path]
|
|
519
|
+
end
|
|
520
|
+
|
|
521
|
+
def delete_preparation_path(path)
|
|
522
|
+
return unless File.exist?(path) || File.symlink?(path)
|
|
523
|
+
|
|
524
|
+
raise ArgumentError, "preparation target changed into a directory during execution: #{path}" if File.directory?(path) && !File.symlink?(path)
|
|
525
|
+
|
|
526
|
+
File.delete(path)
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
def timeout_deadline(timeout)
|
|
530
|
+
return nil if timeout.nil?
|
|
531
|
+
|
|
532
|
+
current_time + timeout
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
def cleanup_deadline(deadline, timeout:)
|
|
536
|
+
return nil if timeout.nil?
|
|
537
|
+
|
|
538
|
+
# Keep synchronous cleanup within the caller's original timeout budget.
|
|
539
|
+
# If cleanup overruns after a successful command or after a timeout/error,
|
|
540
|
+
# execute schedules bounded background cleanup instead of extending execute.
|
|
541
|
+
deadline
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def remaining_timeout(deadline, timeout:, command_name:)
|
|
545
|
+
return nil if deadline.nil?
|
|
546
|
+
|
|
547
|
+
remaining = deadline - current_time
|
|
548
|
+
raise TimeoutError, "Command timed out after #{timeout} seconds: #{command_name}" if remaining <= 0
|
|
549
|
+
|
|
550
|
+
remaining
|
|
551
|
+
end
|
|
552
|
+
|
|
553
|
+
def within_timeout(deadline, timeout:, command_name:)
|
|
554
|
+
remaining = remaining_timeout(deadline, timeout:, command_name:)
|
|
555
|
+
return yield if remaining.nil?
|
|
556
|
+
|
|
557
|
+
Timeout.timeout(remaining) { yield }
|
|
558
|
+
rescue Timeout::Error
|
|
559
|
+
raise TimeoutError, "Command timed out after #{timeout} seconds: #{command_name}"
|
|
560
|
+
end
|
|
561
|
+
|
|
562
|
+
def current_time
|
|
563
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
564
|
+
end
|
|
565
|
+
|
|
129
566
|
def execute_streaming(cmd_array, timeout:, idle_timeout:, env:, stdin_data:,
|
|
130
567
|
on_stdout_chunk:, on_stderr_chunk:, on_heartbeat:, heartbeat_interval:, observer:)
|
|
131
568
|
stdout = +""
|