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 +4 -4
- data/.release-please-manifest.json +1 -1
- data/CHANGELOG.md +16 -0
- data/README.md +76 -1
- 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 +21 -13
- data/lib/agent_harness/provider_health_check.rb +216 -58
- data/lib/agent_harness/provider_runtime.rb +20 -3
- data/lib/agent_harness/providers/adapter.rb +136 -0
- data/lib/agent_harness/providers/aider.rb +4 -0
- data/lib/agent_harness/providers/anthropic.rb +4 -0
- data/lib/agent_harness/providers/base.rb +46 -10
- data/lib/agent_harness/providers/codex.rb +53 -9
- data/lib/agent_harness/providers/cursor.rb +17 -1
- data/lib/agent_harness/providers/gemini.rb +34 -0
- data/lib/agent_harness/providers/github_copilot.rb +26 -6
- data/lib/agent_harness/providers/kilocode.rb +39 -0
- data/lib/agent_harness/providers/mistral_vibe.rb +4 -0
- data/lib/agent_harness/providers/opencode.rb +68 -1
- data/lib/agent_harness/providers/registry.rb +54 -0
- data/lib/agent_harness/version.rb +1 -1
- data/lib/agent_harness.rb +77 -6
- metadata +21 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2335caa6b19fc12579028ec7c9796c89e588629c7e076b957cac4c6b563d9fdb
|
|
4
|
+
data.tar.gz: f7bbe39c811548d1f92262ece7d696d9e8486a93c3d494c7cf397c294f41901e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e46f340daf52837fa0ae966b5bb2ce1865338b5b9d20d5901b380696e9fac91f35471ec1ef1512f08cc3305346185dfbfd20c2e4a05c2f7ac7d1b466e9dbc5f5
|
|
7
|
+
data.tar.gz: f6e5c23023174cabf7609b5bf61bc8d07e0a527fa6cc2b219eb19265c1f917897cd846360513c9819a33b970e4180443f0a98a9bc02746bf5c57bafc5ba16b1c
|
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` | `
|
|
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
|
-
|
|
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
|