agent-harness 0.5.6 → 0.5.7

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: 946d7c425aff8c96536bc30def7e0abdba7ff7d82020f4941674a8c93be63526
4
- data.tar.gz: ffc9d707f89ab60bf9cc59b4e5ccbcd2570a3aaa07e97c5a10bfb731f5dc07a0
3
+ metadata.gz: 2335caa6b19fc12579028ec7c9796c89e588629c7e076b957cac4c6b563d9fdb
4
+ data.tar.gz: f7bbe39c811548d1f92262ece7d696d9e8486a93c3d494c7cf397c294f41901e
5
5
  SHA512:
6
- metadata.gz: c7b0dcef83c7a31be09a87884211a8ba03c0c93fb845e666423cd17eb70a358dd122db7e57ce04271fa5e114013adbc0007394211286c23b03cfa6a2a600c68f
7
- data.tar.gz: a8f14eb24039afd0a93ec1eb064b59ebbe6d03aafd61ab1859c64729d2253140afebacc5c8ee761578337c671c074425784c33676808534cdf7b8ad809a2df32
6
+ metadata.gz: e46f340daf52837fa0ae966b5bb2ce1865338b5b9d20d5901b380696e9fac91f35471ec1ef1512f08cc3305346185dfbfd20c2e4a05c2f7ac7d1b466e9dbc5f5
7
+ data.tar.gz: f6e5c23023174cabf7609b5bf61bc8d07e0a527fa6cc2b219eb19265c1f917897cd846360513c9819a33b970e4180443f0a98a9bc02746bf5c57bafc5ba16b1c
@@ -1,3 +1,3 @@
1
1
  {
2
- ".": "0.5.6"
2
+ ".": "0.5.7"
3
3
  }
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.5.7](https://github.com/viamin/agent-harness/compare/agent-harness/v0.5.6...agent-harness/v0.5.7) (2026-04-07)
4
+
5
+
6
+ ### Bug Fixes
7
+
8
+ * 59: fix(codex): replace invalid '--sandbox none' for externally sandboxed runs ([#89](https://github.com/viamin/agent-harness/issues/89)) ([6b0fac5](https://github.com/viamin/agent-harness/commit/6b0fac5e9b83c70c3a55004d9c5ad354a6d3dced))
9
+ * 60: feat: support per-request executor overrides for orchestrated send_message ([#87](https://github.com/viamin/agent-harness/issues/87)) ([7711770](https://github.com/viamin/agent-harness/commit/7711770ab3c9a6659171df1033038acf08b8d5a9))
10
+ * 61: feat: support idle-timeout and streaming execution hooks for long-running provider runs ([#90](https://github.com/viamin/agent-harness/issues/90)) ([adf7558](https://github.com/viamin/agent-harness/commit/adf75583994f78af9d6f5b44eba729b616b46f02))
11
+ * 62: feat: support per-request environment unsets for provider execution ([#76](https://github.com/viamin/agent-harness/issues/76)) ([2dfdb8e](https://github.com/viamin/agent-harness/commit/2dfdb8e6138f21ca0dd1a9e0b2d7cc17d1776612))
12
+ * 63: fix(copilot): align GithubCopilot provider with a real installable CLI contract ([#75](https://github.com/viamin/agent-harness/issues/75)) ([9e585e7](https://github.com/viamin/agent-harness/commit/9e585e79252ac8431fb99bc16bc9ebbecb83439e))
13
+ * 65: feat(installers): make Codex CLI installation/version support a first-class provider contract ([#79](https://github.com/viamin/agent-harness/issues/79)) ([a3f5849](https://github.com/viamin/agent-harness/commit/a3f5849db1bcfa162af98fa5339cf8b8f5314920))
14
+ * 66: feat(installers): make Gemini CLI installation/version support a first-class provider contract ([#80](https://github.com/viamin/agent-harness/issues/80)) ([e349ab6](https://github.com/viamin/agent-harness/commit/e349ab64601cb2fe9bafb488e6b4f96fbcbf012b))
15
+ * 67: feat(installers): make Kilocode CLI installation/version support a first-class provider contract ([#81](https://github.com/viamin/agent-harness/issues/81)) ([547baf5](https://github.com/viamin/agent-harness/commit/547baf5be93939a00cdac1e3d17824eb6dbfb324))
16
+ * 68: feat(installers): make OpenCode CLI installation/version support a first-class provider contract ([#83](https://github.com/viamin/agent-harness/issues/83)) ([21caaf4](https://github.com/viamin/agent-harness/commit/21caaf4e003c41a7fa127e7244c4a61abad7f1cb))
17
+ * 72: feat(providers): add first-class smoke-test/health-check execution contracts for CLI providers ([#85](https://github.com/viamin/agent-harness/issues/85)) ([7c0db3a](https://github.com/viamin/agent-harness/commit/7c0db3a5aa93e62e38240821dfb00b489ad3793b))
18
+
3
19
  ## [0.5.6](https://github.com/viamin/agent-harness/compare/agent-harness/v0.5.5...agent-harness/v0.5.6) (2026-03-30)
4
20
 
5
21
 
data/README.md CHANGED
@@ -106,7 +106,25 @@ end
106
106
  | `:codex` | `codex` | OpenAI Codex CLI |
107
107
  | `:aider` | `aider` | Aider coding assistant |
108
108
  | `:opencode` | `opencode` | OpenCode CLI |
109
- | `:kilocode` | `kilocode` | Kilocode CLI |
109
+ | `:kilocode` | `kilo` | Kilocode CLI |
110
+
111
+ ### Provider Install Contracts
112
+
113
+ Provider classes can expose install metadata for downstream apps that build
114
+ their own agent images.
115
+
116
+ ```ruby
117
+ contract = AgentHarness.provider_install_contract(:gemini)
118
+
119
+ contract[:package_name]
120
+ # => "@google/gemini-cli"
121
+
122
+ contract[:default_version]
123
+ # => "0.35.3"
124
+
125
+ contract[:install_command]
126
+ # => ["npm", "install", "-g", "--ignore-scripts", "@google/gemini-cli@0.35.3"]
127
+ ```
110
128
 
111
129
  ### Direct Provider Access
112
130
 
@@ -125,6 +143,50 @@ AgentHarness::Providers::Registry.instance.all
125
143
  # => [:claude, :cursor, :gemini, :github_copilot, :codex, :opencode, :kilocode, :aider]
126
144
  ```
127
145
 
146
+ ### Provider Installation Contracts
147
+
148
+ Downstream apps can ask `agent-harness` for provider-specific CLI install
149
+ metadata instead of hardcoding package names, binary names, or supported
150
+ versions out-of-band.
151
+
152
+ ```ruby
153
+ contract = AgentHarness.provider_installation_contract(:kilocode, version: "7.1.3")
154
+
155
+ contract
156
+ # {
157
+ # source: { type: :npm, package: "@kilocode/cli" },
158
+ # install_command: ["npm", "install", "-g", "--ignore-scripts", "@kilocode/cli@7.1.3"],
159
+ # binary_name: "kilo",
160
+ # default_version: "7.1.3",
161
+ # supported_version_requirement: "= 7.1.3"
162
+ # }
163
+ ```
164
+
165
+ The Kilocode runtime adapter expects the `kilo` binary and executes prompts via
166
+ `kilo run ...`, so the install contract and runtime behavior stay aligned in
167
+ tests.
168
+
169
+ Providers that expose installation contracts can also be queried through the
170
+ generic API:
171
+
172
+ ```ruby
173
+ opencode_install = AgentHarness.installation_contract(:opencode)
174
+
175
+ opencode_install
176
+ # => {
177
+ # source: :npm,
178
+ # package_name: "opencode-ai",
179
+ # version: "1.3.2",
180
+ # version_requirement: [">= 1.3.2", "< 1.4.0"],
181
+ # binary_name: "opencode",
182
+ # install_command: ["npm", "install", "-g", "--ignore-scripts", "opencode-ai@1.3.2"]
183
+ # }
184
+ ```
185
+
186
+ For providers with install contracts, the metadata tracks the CLI version
187
+ supported by the current `agent-harness` release, and the runtime adapter
188
+ tests assert that the expected binary remains aligned with that contract.
189
+
128
190
  ### Custom Providers
129
191
 
130
192
  ```ruby
@@ -138,6 +200,19 @@ class MyProvider < AgentHarness::Providers::Base
138
200
  "my-cli"
139
201
  end
140
202
 
203
+ def install_contract(version: "1.2.3")
204
+ {
205
+ provider: provider_name,
206
+ source_type: :npm,
207
+ package_name: "@acme/my-cli",
208
+ supported_version_requirement: Gem::Requirement.new("~> 1.2"),
209
+ default_version: "1.2.3",
210
+ resolved_version: version,
211
+ binary_name: binary_name,
212
+ install_command: ["npm", "install", "-g", "@acme/my-cli@#{version}"]
213
+ }
214
+ end
215
+
141
216
  def available?
142
217
  system("which my-cli > /dev/null 2>&1")
143
218
  end
@@ -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