agent-harness 0.5.6 → 0.5.8
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/CHANGELOG.md +26 -0
- data/README.md +216 -3
- data/lib/agent_harness/authentication.rb +28 -9
- data/lib/agent_harness/command_executor.rb +453 -32
- data/lib/agent_harness/docker_command_executor.rb +23 -3
- data/lib/agent_harness/error_taxonomy.rb +10 -0
- data/lib/agent_harness/errors.rb +5 -0
- data/lib/agent_harness/orchestration/conductor.rb +40 -16
- data/lib/agent_harness/orchestration/provider_manager.rb +46 -18
- data/lib/agent_harness/provider_health_check.rb +243 -63
- data/lib/agent_harness/provider_runtime.rb +20 -3
- data/lib/agent_harness/providers/adapter.rb +717 -0
- data/lib/agent_harness/providers/aider.rb +59 -0
- data/lib/agent_harness/providers/anthropic.rb +98 -0
- data/lib/agent_harness/providers/base.rb +46 -10
- data/lib/agent_harness/providers/codex.rb +68 -9
- data/lib/agent_harness/providers/cursor.rb +90 -2
- data/lib/agent_harness/providers/gemini.rb +43 -0
- data/lib/agent_harness/providers/github_copilot.rb +38 -6
- data/lib/agent_harness/providers/kilocode.rb +39 -0
- data/lib/agent_harness/providers/mistral_vibe.rb +13 -0
- data/lib/agent_harness/providers/opencode.rb +77 -1
- data/lib/agent_harness/providers/registry.rb +446 -18
- data/lib/agent_harness/version.rb +1 -1
- data/lib/agent_harness.rb +105 -6
- metadata +21 -1
|
@@ -39,23 +39,47 @@ module AgentHarness
|
|
|
39
39
|
#
|
|
40
40
|
# @param command [Array<String>, String] command to execute
|
|
41
41
|
# @param timeout [Integer, nil] timeout in seconds
|
|
42
|
+
# @param idle_timeout [Integer, Float, nil] idle timeout in seconds based on output activity
|
|
42
43
|
# @param env [Hash] environment variables
|
|
43
44
|
# @param stdin_data [String, nil] data to send to stdin
|
|
45
|
+
# @param on_stdout_chunk [Proc, nil] callback for stdout chunks as they are produced
|
|
46
|
+
# @param on_stderr_chunk [Proc, nil] callback for stderr chunks as they are produced
|
|
47
|
+
# @param on_heartbeat [Proc, nil] callback invoked periodically while the command is running
|
|
48
|
+
# @param heartbeat_interval [Integer, Float] heartbeat interval in seconds
|
|
49
|
+
# @param observer [Object, nil] optional observer responding to
|
|
50
|
+
# +on_stdout_chunk+, +on_stderr_chunk+, and +on_heartbeat+
|
|
44
51
|
# @return [Result] execution result
|
|
45
52
|
# @raise [TimeoutError] if the command times out
|
|
46
|
-
|
|
53
|
+
# @raise [IdleTimeoutError] if the command exceeds the idle timeout
|
|
54
|
+
def execute(command, timeout: nil, idle_timeout: nil, env: {}, stdin_data: nil,
|
|
55
|
+
on_stdout_chunk: nil, on_stderr_chunk: nil, on_heartbeat: nil,
|
|
56
|
+
heartbeat_interval: 1.0, observer: nil)
|
|
57
|
+
validate_duration!(timeout, name: :timeout, allow_nil: true)
|
|
58
|
+
validate_duration!(idle_timeout, name: :idle_timeout, allow_nil: true)
|
|
59
|
+
validate_duration!(heartbeat_interval, name: :heartbeat_interval, allow_nil: true)
|
|
60
|
+
|
|
47
61
|
cmd_array = normalize_command(command)
|
|
48
62
|
cmd_string = cmd_array.shelljoin
|
|
49
63
|
|
|
50
|
-
log_debug("Executing command",
|
|
64
|
+
log_debug("Executing command",
|
|
65
|
+
command: cmd_string,
|
|
66
|
+
timeout: timeout,
|
|
67
|
+
idle_timeout: idle_timeout)
|
|
51
68
|
|
|
52
69
|
start_time = Time.now
|
|
53
70
|
|
|
54
|
-
stdout, stderr, status =
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
71
|
+
stdout, stderr, status = execute_streaming(
|
|
72
|
+
cmd_array,
|
|
73
|
+
timeout: timeout,
|
|
74
|
+
idle_timeout: idle_timeout,
|
|
75
|
+
env: env,
|
|
76
|
+
stdin_data: stdin_data,
|
|
77
|
+
on_stdout_chunk: on_stdout_chunk,
|
|
78
|
+
on_stderr_chunk: on_stderr_chunk,
|
|
79
|
+
on_heartbeat: on_heartbeat,
|
|
80
|
+
heartbeat_interval: heartbeat_interval,
|
|
81
|
+
observer: observer
|
|
82
|
+
)
|
|
59
83
|
|
|
60
84
|
duration = Time.now - start_time
|
|
61
85
|
|
|
@@ -102,47 +126,444 @@ module AgentHarness
|
|
|
102
126
|
|
|
103
127
|
private
|
|
104
128
|
|
|
105
|
-
def
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
129
|
+
def execute_streaming(cmd_array, timeout:, idle_timeout:, env:, stdin_data:,
|
|
130
|
+
on_stdout_chunk:, on_stderr_chunk:, on_heartbeat:, heartbeat_interval:, observer:)
|
|
131
|
+
stdout = +""
|
|
132
|
+
stderr = +""
|
|
109
133
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
stdin
|
|
134
|
+
Open3.popen3(env, *cmd_array, pgroup: true) do |stdin, stdout_io, stderr_io, wait_thr|
|
|
135
|
+
unless selectable_streams?(stdin, stdout_io, stderr_io)
|
|
136
|
+
return execute_buffered(
|
|
137
|
+
stdin,
|
|
138
|
+
stdout_io,
|
|
139
|
+
stderr_io,
|
|
140
|
+
wait_thr,
|
|
141
|
+
stdin_data: stdin_data,
|
|
142
|
+
stdout: stdout,
|
|
143
|
+
stderr: stderr,
|
|
144
|
+
timeout: timeout,
|
|
145
|
+
idle_timeout: idle_timeout,
|
|
146
|
+
cmd_array: cmd_array,
|
|
147
|
+
on_stdout_chunk: on_stdout_chunk,
|
|
148
|
+
on_stderr_chunk: on_stderr_chunk,
|
|
149
|
+
on_heartbeat: on_heartbeat,
|
|
150
|
+
heartbeat_interval: heartbeat_interval,
|
|
151
|
+
observer: observer
|
|
152
|
+
)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
start_time = monotonic_time
|
|
156
|
+
last_output_at = start_time
|
|
157
|
+
last_heartbeat_at = start_time
|
|
158
|
+
stdin_buffer = stdin_data.is_a?(String) ? stdin_data : stdin_data.to_s
|
|
159
|
+
stdin_offset = 0
|
|
160
|
+
streams = {
|
|
161
|
+
stdout_io => [stdout, on_stdout_chunk, :on_stdout_chunk],
|
|
162
|
+
stderr_io => [stderr, on_stderr_chunk, :on_stderr_chunk]
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
until streams.empty? && stdin.nil?
|
|
166
|
+
ready = IO.select(streams.keys, stdin ? [stdin] : nil, nil, 0)
|
|
167
|
+
|
|
168
|
+
if ready
|
|
169
|
+
stdin, stdin_offset, last_output_at = process_ready_streams(
|
|
170
|
+
ready,
|
|
171
|
+
streams: streams,
|
|
172
|
+
stdin: stdin,
|
|
173
|
+
stdin_buffer: stdin_buffer,
|
|
174
|
+
stdin_offset: stdin_offset,
|
|
175
|
+
last_output_at: last_output_at,
|
|
176
|
+
observer: observer,
|
|
177
|
+
wait_thr: wait_thr
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
if process_exited?(wait_thr)
|
|
181
|
+
stdin, last_output_at = finalize_exited_process(
|
|
182
|
+
stdin, streams, observer, last_output_at, wait_thr,
|
|
183
|
+
timeout: timeout, idle_timeout: idle_timeout, start_time: start_time, cmd_array: cmd_array
|
|
184
|
+
)
|
|
185
|
+
next
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
now = monotonic_time
|
|
189
|
+
if should_emit_heartbeat?(on_heartbeat, observer, heartbeat_interval, now - last_heartbeat_at)
|
|
190
|
+
emit_heartbeat(
|
|
191
|
+
on_heartbeat,
|
|
192
|
+
observer,
|
|
193
|
+
elapsed: now - start_time,
|
|
194
|
+
idle_for: now - last_output_at,
|
|
195
|
+
wait_thr: wait_thr
|
|
196
|
+
)
|
|
197
|
+
last_heartbeat_at = now
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
check_wall_timeout!(timeout, now - start_time, wait_thr, cmd_array)
|
|
201
|
+
check_idle_timeout!(idle_timeout, now - last_output_at, wait_thr, cmd_array)
|
|
202
|
+
next
|
|
114
203
|
end
|
|
115
|
-
stdin.close
|
|
116
204
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
205
|
+
if process_exited?(wait_thr)
|
|
206
|
+
stdin, last_output_at = finalize_exited_process(
|
|
207
|
+
stdin, streams, observer, last_output_at, wait_thr,
|
|
208
|
+
timeout: timeout, idle_timeout: idle_timeout, start_time: start_time, cmd_array: cmd_array
|
|
209
|
+
)
|
|
210
|
+
next
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
now = monotonic_time
|
|
214
|
+
check_wall_timeout!(timeout, now - start_time, wait_thr, cmd_array)
|
|
215
|
+
check_idle_timeout!(idle_timeout, now - last_output_at, wait_thr, cmd_array)
|
|
216
|
+
|
|
217
|
+
if should_emit_heartbeat?(on_heartbeat, observer, heartbeat_interval, now - last_heartbeat_at)
|
|
218
|
+
emit_heartbeat(
|
|
219
|
+
on_heartbeat,
|
|
220
|
+
observer,
|
|
221
|
+
elapsed: now - start_time,
|
|
222
|
+
idle_for: now - last_output_at,
|
|
223
|
+
wait_thr: wait_thr
|
|
224
|
+
)
|
|
225
|
+
last_heartbeat_at = now
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
ready = IO.select(
|
|
229
|
+
streams.keys,
|
|
230
|
+
stdin ? [stdin] : nil,
|
|
231
|
+
nil,
|
|
232
|
+
select_timeout(
|
|
233
|
+
timeout,
|
|
234
|
+
idle_timeout,
|
|
235
|
+
heartbeat_interval,
|
|
236
|
+
elapsed: now - start_time,
|
|
237
|
+
idle_for: now - last_output_at,
|
|
238
|
+
heartbeat_age: now - last_heartbeat_at,
|
|
239
|
+
heartbeat_requested: on_heartbeat || observer_responds_to?(observer, :on_heartbeat)
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
unless ready
|
|
244
|
+
if process_exited?(wait_thr)
|
|
245
|
+
stdin, last_output_at = finalize_exited_process(
|
|
246
|
+
stdin, streams, observer, last_output_at, wait_thr,
|
|
247
|
+
timeout: timeout, idle_timeout: idle_timeout, start_time: start_time, cmd_array: cmd_array
|
|
248
|
+
)
|
|
249
|
+
end
|
|
250
|
+
next
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
stdin, stdin_offset, last_output_at = process_ready_streams(
|
|
254
|
+
ready,
|
|
255
|
+
streams: streams,
|
|
256
|
+
stdin: stdin,
|
|
257
|
+
stdin_buffer: stdin_buffer,
|
|
258
|
+
stdin_offset: stdin_offset,
|
|
259
|
+
last_output_at: last_output_at,
|
|
260
|
+
observer: observer,
|
|
261
|
+
wait_thr: wait_thr
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
if process_exited?(wait_thr)
|
|
265
|
+
stdin, last_output_at = finalize_exited_process(
|
|
266
|
+
stdin, streams, observer, last_output_at, wait_thr,
|
|
267
|
+
timeout: timeout, idle_timeout: idle_timeout, start_time: start_time, cmd_array: cmd_array
|
|
268
|
+
)
|
|
269
|
+
end
|
|
121
270
|
end
|
|
271
|
+
|
|
272
|
+
# Supervise the wait for process exit so a child that closed its
|
|
273
|
+
# stdio but keeps running cannot hang past the configured timeouts.
|
|
274
|
+
unless process_exited?(wait_thr)
|
|
275
|
+
supervise_process_exit(
|
|
276
|
+
wait_thr,
|
|
277
|
+
timeout: timeout,
|
|
278
|
+
idle_timeout: idle_timeout,
|
|
279
|
+
start_time: start_time,
|
|
280
|
+
last_output_at: last_output_at,
|
|
281
|
+
cmd_array: cmd_array,
|
|
282
|
+
on_heartbeat: on_heartbeat,
|
|
283
|
+
observer: observer,
|
|
284
|
+
heartbeat_interval: heartbeat_interval,
|
|
285
|
+
last_heartbeat_at: last_heartbeat_at
|
|
286
|
+
)
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
[stdout, stderr, wait_thr.value]
|
|
122
290
|
end
|
|
291
|
+
end
|
|
123
292
|
|
|
124
|
-
|
|
293
|
+
def log_debug(message, **context)
|
|
294
|
+
@logger&.debug("[AgentHarness::CommandExecutor] #{message}: #{context.inspect}")
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def selectable_streams?(*streams)
|
|
298
|
+
streams.all? { |stream| stream.is_a?(IO) }
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def execute_buffered(stdin, stdout_io, stderr_io, wait_thr, stdin_data:, stdout:, stderr:, timeout:, idle_timeout:,
|
|
302
|
+
cmd_array:, on_stdout_chunk:, on_stderr_chunk:, on_heartbeat:, heartbeat_interval:, observer:)
|
|
303
|
+
validate_buffered_execution_support!(
|
|
304
|
+
idle_timeout: idle_timeout,
|
|
305
|
+
on_heartbeat: on_heartbeat,
|
|
306
|
+
heartbeat_interval: heartbeat_interval,
|
|
307
|
+
observer: observer
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
result = lambda do
|
|
311
|
+
write_stdin_buffered(stdin, stdin_data)
|
|
312
|
+
|
|
313
|
+
stdout_chunk = stdout_io.read.to_s
|
|
314
|
+
stderr_chunk = stderr_io.read.to_s
|
|
315
|
+
|
|
316
|
+
unless stdout_chunk.empty?
|
|
317
|
+
stdout << stdout_chunk
|
|
318
|
+
emit_chunk(on_stdout_chunk, observer, :on_stdout_chunk, stdout_chunk, wait_thr: wait_thr)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
unless stderr_chunk.empty?
|
|
322
|
+
stderr << stderr_chunk
|
|
323
|
+
emit_chunk(on_stderr_chunk, observer, :on_stderr_chunk, stderr_chunk, wait_thr: wait_thr)
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
[stdout, stderr, wait_thr.value]
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
return result.call unless timeout
|
|
330
|
+
|
|
331
|
+
Timeout.timeout(timeout) do
|
|
332
|
+
result.call
|
|
333
|
+
end
|
|
125
334
|
rescue Timeout::Error
|
|
335
|
+
terminate_process(wait_thr)
|
|
126
336
|
raise TimeoutError, "Command timed out after #{timeout} seconds: #{cmd_array.first}"
|
|
127
337
|
end
|
|
128
338
|
|
|
129
|
-
def
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
339
|
+
def write_stdin_buffered(stdin, stdin_data)
|
|
340
|
+
return unless stdin
|
|
341
|
+
|
|
342
|
+
stdin.write(stdin_data.to_s)
|
|
343
|
+
close_stream(stdin)
|
|
344
|
+
rescue Errno::EPIPE, IOError
|
|
345
|
+
close_stream(stdin)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def write_stdin_nonblock(stdin, stdin_buffer, stdin_offset)
|
|
349
|
+
return stdin_buffer.bytesize if stdin_offset >= stdin_buffer.bytesize
|
|
350
|
+
|
|
351
|
+
chunk = stdin_buffer.byteslice(stdin_offset, 4096)
|
|
352
|
+
written = stdin.write_nonblock(chunk, exception: false)
|
|
353
|
+
return stdin_offset if written == :wait_writable
|
|
354
|
+
|
|
355
|
+
stdin_offset + written
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def process_ready_streams(ready, streams:, stdin:, stdin_buffer:, stdin_offset:, last_output_at:, observer:, wait_thr:)
|
|
359
|
+
ready[1]&.each do |io|
|
|
360
|
+
stdin_offset = write_stdin_nonblock(io, stdin_buffer, stdin_offset)
|
|
361
|
+
next unless stdin_offset >= stdin_buffer.bytesize
|
|
362
|
+
|
|
363
|
+
close_stream(io)
|
|
364
|
+
stdin = nil
|
|
365
|
+
rescue Errno::EPIPE, IOError
|
|
366
|
+
close_stream(io)
|
|
367
|
+
stdin = nil
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
ready[0]&.each do |io|
|
|
371
|
+
chunk = io.read_nonblock(4096, exception: false)
|
|
372
|
+
|
|
373
|
+
case chunk
|
|
374
|
+
when :wait_readable
|
|
375
|
+
next
|
|
376
|
+
when nil
|
|
377
|
+
streams.delete(io)
|
|
378
|
+
io.close
|
|
379
|
+
else
|
|
380
|
+
buffer, callback, observer_method = streams.fetch(io) do
|
|
381
|
+
raise KeyError, "Unexpected ready stream for command execution"
|
|
382
|
+
end
|
|
383
|
+
buffer << chunk
|
|
384
|
+
last_output_at = monotonic_time
|
|
385
|
+
emit_chunk(callback, observer, observer_method, chunk, wait_thr: wait_thr)
|
|
133
386
|
end
|
|
134
|
-
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
[stdin, stdin_offset, last_output_at]
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def close_stream(stream)
|
|
393
|
+
return unless stream
|
|
394
|
+
|
|
395
|
+
stream.close
|
|
396
|
+
rescue IOError
|
|
397
|
+
nil
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def process_exited?(wait_thr)
|
|
401
|
+
!wait_thr.join(0).nil?
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
# Drain remaining output from an exited process's streams.
|
|
405
|
+
#
|
|
406
|
+
# Uses nonblocking reads with timeout supervision so that descendant
|
|
407
|
+
# processes holding stdout/stderr open cannot hang past the configured
|
|
408
|
+
# wall-clock or idle timeout.
|
|
409
|
+
def finalize_exited_process(stdin, streams, observer, last_output_at, wait_thr,
|
|
410
|
+
timeout: nil, idle_timeout: nil, start_time: nil, cmd_array: nil)
|
|
411
|
+
close_stream(stdin) if stdin
|
|
412
|
+
|
|
413
|
+
until streams.empty?
|
|
414
|
+
ready = IO.select(streams.keys, nil, nil, 0.1)
|
|
415
|
+
|
|
416
|
+
if ready
|
|
417
|
+
ready[0].each do |io|
|
|
418
|
+
chunk = io.read_nonblock(4096, exception: false)
|
|
419
|
+
|
|
420
|
+
case chunk
|
|
421
|
+
when :wait_readable
|
|
422
|
+
next
|
|
423
|
+
when nil
|
|
424
|
+
streams.delete(io)
|
|
425
|
+
close_stream(io)
|
|
426
|
+
else
|
|
427
|
+
buffer, callback, observer_method = streams.fetch(io)
|
|
428
|
+
buffer << chunk
|
|
429
|
+
last_output_at = monotonic_time
|
|
430
|
+
emit_chunk(callback, observer, observer_method, chunk, wait_thr: wait_thr)
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
now = monotonic_time
|
|
436
|
+
check_wall_timeout!(timeout, now - start_time, wait_thr, cmd_array) if start_time
|
|
437
|
+
check_idle_timeout!(idle_timeout, now - last_output_at, wait_thr, cmd_array)
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
[nil, last_output_at]
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Poll for process exit with timeout and heartbeat supervision.
|
|
444
|
+
# Called after all streams are closed when the child is still running.
|
|
445
|
+
def supervise_process_exit(wait_thr, timeout:, idle_timeout:, start_time:,
|
|
446
|
+
last_output_at:, cmd_array:, on_heartbeat:, observer:,
|
|
447
|
+
heartbeat_interval:, last_heartbeat_at:)
|
|
448
|
+
loop do
|
|
449
|
+
break if process_exited?(wait_thr)
|
|
135
450
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
451
|
+
now = monotonic_time
|
|
452
|
+
check_wall_timeout!(timeout, now - start_time, wait_thr, cmd_array)
|
|
453
|
+
check_idle_timeout!(idle_timeout, now - last_output_at, wait_thr, cmd_array)
|
|
139
454
|
|
|
140
|
-
|
|
455
|
+
if should_emit_heartbeat?(on_heartbeat, observer, heartbeat_interval, now - last_heartbeat_at)
|
|
456
|
+
emit_heartbeat(
|
|
457
|
+
on_heartbeat,
|
|
458
|
+
observer,
|
|
459
|
+
elapsed: now - start_time,
|
|
460
|
+
idle_for: now - last_output_at,
|
|
461
|
+
wait_thr: wait_thr
|
|
462
|
+
)
|
|
463
|
+
last_heartbeat_at = now
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
wait_timeout = select_timeout(
|
|
467
|
+
timeout, idle_timeout, heartbeat_interval,
|
|
468
|
+
elapsed: now - start_time,
|
|
469
|
+
idle_for: now - last_output_at,
|
|
470
|
+
heartbeat_age: now - last_heartbeat_at,
|
|
471
|
+
heartbeat_requested: on_heartbeat || observer_responds_to?(observer, :on_heartbeat)
|
|
472
|
+
)
|
|
473
|
+
# Sleep briefly before re-checking, bounded by the next deadline
|
|
474
|
+
sleep([wait_timeout || 0.1, 0.1].min)
|
|
141
475
|
end
|
|
142
476
|
end
|
|
143
477
|
|
|
144
|
-
def
|
|
145
|
-
|
|
478
|
+
def validate_duration!(value, name:, allow_nil: false)
|
|
479
|
+
return if allow_nil && value.nil?
|
|
480
|
+
return if value.is_a?(Numeric) && value.positive?
|
|
481
|
+
|
|
482
|
+
raise InvalidDurationError, "#{name} must be a positive number"
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
def validate_buffered_execution_support!(idle_timeout:, on_heartbeat:, heartbeat_interval:, observer:)
|
|
486
|
+
heartbeat_requested = on_heartbeat || observer_responds_to?(observer, :on_heartbeat)
|
|
487
|
+
unsupported_supervision = idle_timeout || (heartbeat_requested && !heartbeat_interval.nil?)
|
|
488
|
+
return unless unsupported_supervision
|
|
489
|
+
|
|
490
|
+
raise ArgumentError, "Buffered command execution does not support idle timeouts or heartbeats"
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
def monotonic_time
|
|
494
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
def check_wall_timeout!(timeout, elapsed, wait_thr, cmd_array)
|
|
498
|
+
return unless timeout && elapsed >= timeout
|
|
499
|
+
|
|
500
|
+
terminate_process(wait_thr)
|
|
501
|
+
raise TimeoutError, "Command timed out after #{timeout} seconds: #{cmd_array.first}"
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def check_idle_timeout!(idle_timeout, idle_for, wait_thr, cmd_array)
|
|
505
|
+
return unless idle_timeout && idle_for >= idle_timeout
|
|
506
|
+
|
|
507
|
+
terminate_process(wait_thr)
|
|
508
|
+
raise IdleTimeoutError,
|
|
509
|
+
"Command exceeded idle timeout after #{idle_timeout} seconds: #{cmd_array.first}"
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def should_emit_heartbeat?(callback, observer, heartbeat_interval, heartbeat_age)
|
|
513
|
+
return false unless callback || observer_responds_to?(observer, :on_heartbeat)
|
|
514
|
+
return false if heartbeat_interval.nil?
|
|
515
|
+
|
|
516
|
+
heartbeat_age >= heartbeat_interval
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def emit_chunk(callback, observer, observer_method, chunk, wait_thr: nil)
|
|
520
|
+
callback&.call(chunk)
|
|
521
|
+
observer.public_send(observer_method, chunk) if observer_responds_to?(observer, observer_method)
|
|
522
|
+
rescue
|
|
523
|
+
terminate_process(wait_thr) if wait_thr
|
|
524
|
+
raise
|
|
525
|
+
end
|
|
526
|
+
|
|
527
|
+
def emit_heartbeat(callback, observer, elapsed:, idle_for:, wait_thr: nil)
|
|
528
|
+
callback&.call(elapsed: elapsed, idle_for: idle_for)
|
|
529
|
+
observer.on_heartbeat(elapsed: elapsed, idle_for: idle_for) if observer_responds_to?(observer, :on_heartbeat)
|
|
530
|
+
rescue
|
|
531
|
+
terminate_process(wait_thr) if wait_thr
|
|
532
|
+
raise
|
|
533
|
+
end
|
|
534
|
+
|
|
535
|
+
def observer_responds_to?(observer, method_name)
|
|
536
|
+
observer&.respond_to?(method_name)
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def select_timeout(timeout, idle_timeout, heartbeat_interval, elapsed:, idle_for:, heartbeat_age:, heartbeat_requested:)
|
|
540
|
+
timeouts = []
|
|
541
|
+
timeouts << (timeout - elapsed) if timeout
|
|
542
|
+
timeouts << (idle_timeout - idle_for) if idle_timeout
|
|
543
|
+
timeouts << (heartbeat_interval - heartbeat_age) if heartbeat_requested && heartbeat_interval
|
|
544
|
+
|
|
545
|
+
min_timeout = timeouts.min
|
|
546
|
+
return nil unless min_timeout
|
|
547
|
+
|
|
548
|
+
[min_timeout, 0].max
|
|
549
|
+
end
|
|
550
|
+
|
|
551
|
+
def terminate_process(wait_thr)
|
|
552
|
+
pid = wait_thr.pid
|
|
553
|
+
signal_process("TERM", pid)
|
|
554
|
+
Timeout.timeout(1) { wait_thr.join }
|
|
555
|
+
rescue Errno::ESRCH, Timeout::Error
|
|
556
|
+
begin
|
|
557
|
+
signal_process("KILL", pid)
|
|
558
|
+
rescue Errno::ESRCH
|
|
559
|
+
nil
|
|
560
|
+
end
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
def signal_process(signal, pid)
|
|
564
|
+
Process.kill(signal, -pid)
|
|
565
|
+
rescue Errno::ESRCH
|
|
566
|
+
Process.kill(signal, pid)
|
|
146
567
|
end
|
|
147
568
|
end
|
|
148
569
|
end
|
|
@@ -35,12 +35,20 @@ module AgentHarness
|
|
|
35
35
|
#
|
|
36
36
|
# @param command [Array<String>, String] command to execute
|
|
37
37
|
# @param timeout [Integer, nil] timeout in seconds
|
|
38
|
+
# @param idle_timeout [Integer, Float, nil] idle timeout in seconds based on output activity
|
|
38
39
|
# @param env [Hash] environment variables to set in the container
|
|
39
40
|
# @param stdin_data [String, nil] data to send to stdin
|
|
40
41
|
# @return [Result] execution result
|
|
41
|
-
def execute(command, timeout: nil, env: {}, stdin_data: nil)
|
|
42
|
+
def execute(command, timeout: nil, idle_timeout: nil, env: {}, stdin_data: nil, **execution_options)
|
|
42
43
|
docker_cmd = build_docker_command(command, env: env, stdin_data: stdin_data)
|
|
43
|
-
super(
|
|
44
|
+
super(
|
|
45
|
+
docker_cmd,
|
|
46
|
+
timeout: timeout,
|
|
47
|
+
idle_timeout: idle_timeout,
|
|
48
|
+
env: {},
|
|
49
|
+
stdin_data: stdin_data,
|
|
50
|
+
**execution_options
|
|
51
|
+
)
|
|
44
52
|
end
|
|
45
53
|
|
|
46
54
|
# Check if a binary exists inside the container
|
|
@@ -62,11 +70,23 @@ module AgentHarness
|
|
|
62
70
|
|
|
63
71
|
def build_docker_command(command, env:, stdin_data:)
|
|
64
72
|
cmd = ["docker", "exec"]
|
|
73
|
+
unset_env_keys = []
|
|
65
74
|
|
|
66
|
-
env.each
|
|
75
|
+
env.each do |key, value|
|
|
76
|
+
if value.nil?
|
|
77
|
+
unset_env_keys << key
|
|
78
|
+
next
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
cmd.push("--env", "#{key}=#{value}")
|
|
82
|
+
end
|
|
67
83
|
cmd.push("-i") if stdin_data
|
|
68
84
|
|
|
69
85
|
cmd.push(@container_id)
|
|
86
|
+
unless unset_env_keys.empty?
|
|
87
|
+
cmd.push("env")
|
|
88
|
+
unset_env_keys.each { |key| cmd.push("-u", key) }
|
|
89
|
+
end
|
|
70
90
|
|
|
71
91
|
cmd.concat(normalize_command(command))
|
|
72
92
|
end
|
|
@@ -39,6 +39,11 @@ module AgentHarness
|
|
|
39
39
|
action: :retry_with_backoff,
|
|
40
40
|
retryable: true
|
|
41
41
|
},
|
|
42
|
+
idle_timeout: {
|
|
43
|
+
description: "Operation exceeded idle timeout",
|
|
44
|
+
action: :escalate,
|
|
45
|
+
retryable: false
|
|
46
|
+
},
|
|
42
47
|
sandbox_failure: {
|
|
43
48
|
description: "Sandbox setup failed",
|
|
44
49
|
action: :escalate,
|
|
@@ -58,6 +63,9 @@ module AgentHarness
|
|
|
58
63
|
# @param patterns [Hash<Symbol, Array<Regexp>>] provider-specific patterns
|
|
59
64
|
# @return [Symbol] error category
|
|
60
65
|
def classify(error, patterns = {})
|
|
66
|
+
return :idle_timeout if error.is_a?(IdleTimeoutError)
|
|
67
|
+
return :timeout if error.is_a?(TimeoutError)
|
|
68
|
+
|
|
61
69
|
message = error.message.to_s.downcase
|
|
62
70
|
|
|
63
71
|
# Check provider-specific patterns first
|
|
@@ -112,6 +120,8 @@ module AgentHarness
|
|
|
112
120
|
|
|
113
121
|
def classify_generic(message)
|
|
114
122
|
case message
|
|
123
|
+
when /idle.?timeout/i
|
|
124
|
+
:idle_timeout
|
|
115
125
|
when /rate.?limit|too many requests|429/i
|
|
116
126
|
:rate_limited
|
|
117
127
|
when /quota|usage.?limit|billing/i
|
data/lib/agent_harness/errors.rb
CHANGED
|
@@ -22,6 +22,11 @@ module AgentHarness
|
|
|
22
22
|
# Execution errors
|
|
23
23
|
class TimeoutError < Error; end
|
|
24
24
|
|
|
25
|
+
# Raised when a duration argument is invalid (non-positive)
|
|
26
|
+
class InvalidDurationError < ArgumentError; end
|
|
27
|
+
|
|
28
|
+
class IdleTimeoutError < TimeoutError; end
|
|
29
|
+
|
|
25
30
|
class CommandExecutionError < Error; end
|
|
26
31
|
|
|
27
32
|
# Rate limiting and circuit breaker errors
|