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.
@@ -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
- def execute(command, timeout: nil, env: {}, stdin_data: nil)
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", command: cmd_string, timeout: timeout)
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 = if timeout
55
- execute_with_timeout(cmd_array, timeout: timeout, env: env, stdin_data: stdin_data)
56
- else
57
- execute_without_timeout(cmd_array, env: env, stdin_data: stdin_data)
58
- end
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 execute_with_timeout(cmd_array, timeout:, env:, stdin_data:)
106
- stdout = ""
107
- stderr = ""
108
- status = nil
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
- Timeout.timeout(timeout) do
111
- Open3.popen3(env, *cmd_array) do |stdin, stdout_io, stderr_io, wait_thr|
112
- if stdin_data
113
- stdin.write(stdin_data)
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
- # Read output streams
118
- stdout = stdout_io.read
119
- stderr = stderr_io.read
120
- status = wait_thr.value
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
- [stdout, stderr, status]
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 execute_without_timeout(cmd_array, env:, stdin_data:)
130
- Open3.popen3(env, *cmd_array) do |stdin, stdout_io, stderr_io, wait_thr|
131
- if stdin_data
132
- stdin.write(stdin_data)
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
- stdin.close
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
- stdout = stdout_io.read
137
- stderr = stderr_io.read
138
- status = wait_thr.value
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
- [stdout, stderr, status]
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 log_debug(message, **context)
145
- @logger&.debug("[AgentHarness::CommandExecutor] #{message}: #{context.inspect}")
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(docker_cmd, timeout: timeout, env: {}, stdin_data: stdin_data)
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 { |key, value| cmd.push("--env", "#{key}=#{value}") }
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
@@ -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