ace-llm-providers-cli 0.35.2 → 0.36.1

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: 5bac39a45138ac76601934b27fe805438a6ce47a48dfe74da044c267ba80c92e
4
- data.tar.gz: 6dd39dd18370dff38db534c0e95848b735061ce336652324fa3df7b905bd5432
3
+ metadata.gz: b9149be64f8f384f5636b393bfa3cf12f81aa9488e486225ed2966c731ef2a0f
4
+ data.tar.gz: 1fe4000a4a8000d49e04796a2eee4030b588cc3ef06babb2080b2e7feb66dedf
5
5
  SHA512:
6
- metadata.gz: c1b64b8309c1a2ec15e621d07fc074840325ea8c9893cbe9e109b0775d326f581e3ef4eaeb1b0155aaf6194d29fec5c5a459d0c8cb2adc15854d9c7ec4a2f4cb
7
- data.tar.gz: 676c4e56fcee33792c4595fe8291cf2ba9834d3e450a452f93de04fd69733212138a38cec9312b4b2ff5ebbde44f798da69e97a63df829024d12e56f7b9a2381
6
+ metadata.gz: 7fc14db03c5a90eae90ec8855bb7bd801d5a75c5174488335b1201bd10d767c4f202eb192008b6a932c3c717e4913df2ae2cdd8650a16ebfd1921d3d50b57687
7
+ data.tar.gz: 1f2b33eb5461854fb293d583eebf87d723c0aa2ecd560995a6b05e56f00da0ba8570fedcd9531c853ccd24b055f64129721961d87988fd599378734c18507eca
data/CHANGELOG.md CHANGED
@@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.36.1] - 2026-10-01
11
+
12
+ ### Technical
13
+ - Observe descendant exit with a bounded monotonic wait in cleanup tests, retaining the process-absence predicate and adding a live-child negative control.
14
+
15
+ ## [0.36.0] - 2026-09-29
16
+
17
+ ### Added
18
+ - `Models::CaptureResult`: typed outcome of one subprocess capture (completed / deadline-exceeded / transport failure / spawn failure) carrying raw streams internally, plus exit status, signal, configured deadline, monotonic elapsed time, and an opaque invocation ID generated before spawn. Evidence leaves only as bounded, redacted excerpts.
19
+
20
+ ### Changed
21
+ - `SafeCapture.call` returns a `CaptureResult` instead of a bare `[stdout, stderr, status]` tuple and no longer raises on deadline expiry; spawn failures (binary not found) become a typed outcome instead of a raw `Errno`. Process-group cleanup, partial-stream retention, and the Linux subreaper supervisor are preserved. Deadline measurement uses the monotonic clock.
22
+ - All CLI provider clients (codex, codexoai, claude, claudeoai, gemini, pi, opencode, agy) consume the typed result and raise `ProviderError`s that carry structured execution evidence; provider-reported auth failures (pi 401) still surface as `AuthenticationError`.
23
+
10
24
  ## [0.35.2] - 2026-09-29
11
25
 
12
26
  ### Changed
@@ -57,9 +57,10 @@ module Ace
57
57
  )
58
58
 
59
59
  cmd = build_agy_command(prompt, options)
60
- stdout, stderr, status = execute_agy_command(cmd, working_dir: working_dir, options: options)
60
+ capture = execute_agy_command(cmd, working_dir: working_dir, options: options)
61
+ capture.raise_unless_success
61
62
 
62
- parse_agy_response(stdout, stderr, status, prompt)
63
+ parse_agy_response(capture, prompt)
63
64
  rescue => e
64
65
  handle_agy_error(e)
65
66
  end
@@ -177,17 +178,18 @@ module Ace
177
178
  )
178
179
  end
179
180
 
180
- def parse_agy_response(stdout, stderr, status, prompt)
181
- response = if stream_json_output?(stdout)
182
- parse_stream_json(stdout)
181
+ def parse_agy_response(capture, prompt)
182
+ response = if stream_json_output?(capture.stdout)
183
+ parse_stream_json(capture.stdout)
183
184
  else
184
- parse_json_or_text(stdout)
185
+ parse_json_or_text(capture.stdout)
185
186
  end
186
187
 
187
188
  error_message = response["error"].to_s
188
- run_status = response.fetch("status", status.success? ? "SUCCESS" : "ERROR")
189
- if !status.success? || run_status != "SUCCESS"
190
- raise Ace::LLM::ProviderError, build_failure_message(error_message, stderr, stdout, run_status)
189
+ run_status = response.fetch("status", "SUCCESS")
190
+ if run_status != "SUCCESS"
191
+ raise Ace::LLM::ProviderError,
192
+ build_failure_message(error_message, capture.stderr, capture.stdout, run_status)
191
193
  end
192
194
 
193
195
  text = extract_response_text(response)
@@ -60,15 +60,16 @@ module Ace
60
60
  working_dir: options[:working_dir],
61
61
  subprocess_env: subprocess_env
62
62
  )
63
- stdout, stderr, status = execute_claude_command(
63
+ capture = execute_claude_command(
64
64
  cmd,
65
65
  prompt,
66
66
  subprocess_env: subprocess_env,
67
67
  working_dir: working_dir,
68
68
  subprocess_command_prefix: options[:subprocess_command_prefix]
69
69
  )
70
+ capture.raise_unless_success
70
71
 
71
- parse_claude_response(stdout, stderr, status, prompt, options)
72
+ parse_claude_response(capture, prompt, options)
72
73
  rescue => e
73
74
  handle_claude_error(e)
74
75
  end
@@ -279,18 +280,20 @@ module Ace
279
280
  filtered
280
281
  end
281
282
 
282
- def parse_claude_response(stdout, stderr, status, prompt, options)
283
- unless status.success?
284
- error_msg = stderr.empty? ? stdout : stderr
285
- raise Ace::LLM::ProviderError, "Claude CLI failed: #{error_msg}"
283
+ def parse_claude_response(capture, prompt, options)
284
+ unless capture.status.success?
285
+ error_msg = capture.stderr.empty? ? capture.stdout : capture.stderr
286
+ raise capture.provider_error("Claude CLI failed: #{error_msg}")
286
287
  end
287
288
 
288
289
  begin
289
290
  # Allow duplicate keys to avoid warnings from Claude CLI output
290
291
  # Some versions of Claude CLI may return JSON with duplicate keys
291
- response = JSON.parse(stdout, allow_duplicate_key: true)
292
+ response = JSON.parse(capture.stdout, allow_duplicate_key: true)
292
293
  rescue JSON::ParserError => e
293
- raise Ace::LLM::ProviderError, "Failed to parse Claude response: #{e.message}"
294
+ raise capture.with_no_response_evidence(
295
+ Ace::LLM::ProviderError.new("Failed to parse Claude response: #{e.message}")
296
+ )
294
297
  end
295
298
 
296
299
  if response["is_error"]
@@ -301,7 +304,9 @@ module Ace
301
304
  text = extract_claude_text(response)
302
305
 
303
306
  if text.strip.empty?
304
- raise Ace::LLM::ProviderError, build_response_error(response)
307
+ raise capture.with_no_response_evidence(
308
+ Ace::LLM::ProviderError.new(build_response_error(response))
309
+ )
305
310
  end
306
311
 
307
312
  # Build metadata
@@ -57,15 +57,16 @@ module Ace
57
57
  prompt = rewrite_skill_commands(prompt, working_dir: working_dir)
58
58
 
59
59
  cmd = build_claude_command(options)
60
- stdout, stderr, status = execute_claude_command(
60
+ capture = execute_claude_command(
61
61
  cmd,
62
62
  prompt,
63
63
  subprocess_env: subprocess_env,
64
64
  working_dir: working_dir,
65
65
  subprocess_command_prefix: options[:subprocess_command_prefix]
66
66
  )
67
+ capture.raise_unless_success
67
68
 
68
- parse_claude_response(stdout, stderr, status, prompt, options)
69
+ parse_claude_response(capture, prompt, options)
69
70
  rescue => e
70
71
  handle_claude_error(e)
71
72
  end
@@ -229,16 +230,18 @@ module Ace
229
230
  "sonnet"
230
231
  end
231
232
 
232
- def parse_claude_response(stdout, stderr, status, prompt, options)
233
- unless status.success?
234
- error_msg = stderr.empty? ? stdout : stderr
235
- raise Ace::LLM::ProviderError, "Claude OAI CLI failed: #{error_msg}"
233
+ def parse_claude_response(capture, prompt, options)
234
+ unless capture.status.success?
235
+ error_msg = capture.stderr.empty? ? capture.stdout : capture.stderr
236
+ raise capture.provider_error("Claude OAI CLI failed: #{error_msg}")
236
237
  end
237
238
 
238
239
  begin
239
- response = JSON.parse(stdout, allow_duplicate_key: true)
240
+ response = JSON.parse(capture.stdout, allow_duplicate_key: true)
240
241
  rescue JSON::ParserError => e
241
- raise Ace::LLM::ProviderError, "Failed to parse Claude OAI response: #{e.message}"
242
+ raise capture.with_no_response_evidence(
243
+ Ace::LLM::ProviderError.new("Failed to parse Claude OAI response: #{e.message}")
244
+ )
242
245
  end
243
246
 
244
247
  text = response["result"] || response["response"] || ""
@@ -83,8 +83,9 @@ module Ace
83
83
  completed = false
84
84
  result = nil
85
85
  begin
86
- stdout, stderr, status = execute_codex_command(cmd, prompt, execution_options)
87
- result = parse_codex_response(stdout, stderr, status, prompt, execution_options)
86
+ capture = execute_codex_command(cmd, prompt, execution_options)
87
+ capture.raise_unless_success
88
+ result = parse_codex_response(capture, prompt, execution_options)
88
89
  completed = true
89
90
  result
90
91
  ensure
@@ -320,10 +321,10 @@ module Ace
320
321
  )
321
322
  end
322
323
 
323
- def parse_codex_response(stdout, stderr, status, prompt, options)
324
- unless status.success?
325
- error_msg = stderr.empty? ? stdout : stderr
326
- raise Ace::LLM::ProviderError, "Codex CLI failed: #{error_msg}"
324
+ def parse_codex_response(capture, prompt, options)
325
+ unless capture.status.success?
326
+ error_msg = capture.stderr.empty? ? capture.stdout : capture.stderr
327
+ raise capture.provider_error("Codex CLI failed: #{error_msg}")
327
328
  end
328
329
 
329
330
  # The native last-message file contains only the final response,
@@ -331,7 +332,11 @@ module Ace
331
332
  last_message_path = options[:last_message_file]
332
333
  if last_message_path
333
334
  text = File.file?(last_message_path) ? File.read(last_message_path).strip : ""
334
- raise Ace::LLM::ProviderError, "Codex CLI produced no final message" if text.empty?
335
+ if text.empty?
336
+ raise capture.with_no_response_evidence(
337
+ Ace::LLM::ProviderError.new("Codex CLI produced no final message")
338
+ )
339
+ end
335
340
 
336
341
  return {text: text, metadata: build_synthetic_metadata(text, prompt)}
337
342
  end
@@ -340,7 +345,7 @@ module Ace
340
345
  # only when a real CLI banner is present, not when report prose
341
346
  # happens to contain a standalone `codex` line.
342
347
  # Codex output includes metadata lines and the actual response
343
- lines = stdout.split("\n")
348
+ lines = capture.stdout.split("\n")
344
349
 
345
350
  has_cli_banner = lines.first(20).any? { |line| line.start_with?("OpenAI Codex v") }
346
351
  response_start = lines.find_index { |line| line.strip == "codex" } if has_cli_banner
@@ -353,7 +358,7 @@ module Ace
353
358
  text = response_lines.join("\n").strip
354
359
  else
355
360
  # Fallback: use entire output if we can't parse the format
356
- text = stdout.strip
361
+ text = capture.stdout.strip
357
362
  end
358
363
 
359
364
  # Build metadata
@@ -58,9 +58,10 @@ module Ace
58
58
  prompt = rewrite_skill_commands(prompt, working_dir: working_dir)
59
59
 
60
60
  cmd = build_codex_oai_command(prompt, options, working_dir: working_dir)
61
- stdout, stderr, status = execute_codex_command(cmd, prompt, options)
61
+ capture = execute_codex_command(cmd, prompt, options)
62
+ capture.raise_unless_success
62
63
 
63
- parse_codex_response(stdout, stderr, status, prompt, options)
64
+ parse_codex_response(capture, prompt, options)
64
65
  rescue => e
65
66
  handle_codex_error(e)
66
67
  end
@@ -203,13 +204,13 @@ module Ace
203
204
  )
204
205
  end
205
206
 
206
- def parse_codex_response(stdout, stderr, status, prompt, options)
207
- unless status.success?
208
- error_msg = stderr.empty? ? stdout : stderr
209
- raise Ace::LLM::ProviderError, "Codex OAI CLI failed: #{error_msg}"
207
+ def parse_codex_response(capture, prompt, options)
208
+ unless capture.status.success?
209
+ error_msg = capture.stderr.empty? ? capture.stdout : capture.stderr
210
+ raise capture.provider_error("Codex OAI CLI failed: #{error_msg}")
210
211
  end
211
212
 
212
- lines = stdout.split("\n")
213
+ lines = capture.stdout.split("\n")
213
214
  response_start = lines.find_index { |line| line.include?("codex") }
214
215
 
215
216
  if response_start && response_start < lines.length - 1
@@ -217,7 +218,7 @@ module Ace
217
218
  response_lines = response_lines.reject { |line| line.include?("tokens used") }
218
219
  text = response_lines.join("\n").strip
219
220
  else
220
- text = stdout.strip
221
+ text = capture.stdout.strip
221
222
  end
222
223
 
223
224
  metadata = build_synthetic_metadata(text, prompt)
@@ -56,9 +56,10 @@ module Ace
56
56
  prompt = format_messages_as_prompt(messages)
57
57
 
58
58
  cmd = build_gemini_command(prompt, options)
59
- stdout, stderr, status = execute_gemini_command(cmd, prompt, options)
59
+ capture = execute_gemini_command(cmd, prompt, options)
60
+ capture.raise_unless_success
60
61
 
61
- parse_gemini_response(stdout, stderr, status, prompt, options)
62
+ parse_gemini_response(capture, prompt, options)
62
63
  end
63
64
 
64
65
  # List available Gemini models
@@ -274,15 +275,15 @@ module Ace
274
275
  )
275
276
  end
276
277
 
277
- def parse_gemini_response(stdout, stderr, status, prompt, options)
278
- unless status.success?
279
- error_msg = stderr.empty? ? stdout : stderr
280
- raise Ace::LLM::ProviderError, "Gemini CLI failed: #{error_msg}"
278
+ def parse_gemini_response(capture, prompt, options)
279
+ unless capture.status.success?
280
+ error_msg = capture.stderr.empty? ? capture.stdout : capture.stderr
281
+ raise capture.provider_error("Gemini CLI failed: #{error_msg}")
281
282
  end
282
283
 
283
284
  # Try to parse JSON output first
284
285
  begin
285
- parsed = JSON.parse(stdout)
286
+ parsed = JSON.parse(capture.stdout)
286
287
 
287
288
  # Extract response text from parsed JSON
288
289
  # Gemini CLI JSON format: { "response": "...", "stats": { ... } }
@@ -292,14 +293,14 @@ module Ace
292
293
  parsed["candidates"].first["content"] || parsed["candidates"].first["text"]
293
294
  else
294
295
  # Fallback to raw output if JSON structure unexpected
295
- stdout.strip
296
+ capture.stdout.strip
296
297
  end
297
298
 
298
299
  # Extract metadata from stats if available
299
300
  metadata = extract_metadata_from_json(parsed, prompt)
300
301
  rescue JSON::ParserError
301
302
  # Fallback to raw text output if JSON parsing fails
302
- text = stdout.strip
303
+ text = capture.stdout.strip
303
304
  metadata = build_synthetic_metadata(text, prompt)
304
305
  end
305
306
 
@@ -0,0 +1,197 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ require "ace/llm/models/execution_evidence"
6
+
7
+ module Ace
8
+ module LLM
9
+ module Providers
10
+ module CLI
11
+ module Models
12
+ # Typed outcome of one SafeCapture subprocess invocation.
13
+ #
14
+ # Carries the raw captured streams and the process facts (outcome kind,
15
+ # exit/signal, configured deadline, monotonic elapsed time, invocation
16
+ # correlation ID) so classification downstream can key on evidence
17
+ # instead of error-message prose. Raw streams stay internal to this
18
+ # object; only bounded excerpts leave through #execution_evidence.
19
+ #
20
+ # This is a model - pure immutable data.
21
+ class CaptureResult
22
+ OUTCOME_COMPLETED = :completed
23
+ OUTCOME_DEADLINE_EXCEEDED = :deadline_exceeded
24
+ OUTCOME_TRANSPORT_FAILURE = :transport_failure
25
+ OUTCOME_SPAWN_FAILURE = :spawn_failure
26
+
27
+ MAX_MESSAGE_LENGTH = 2000
28
+
29
+ # Generate the correlation ID for an invocation before spawning.
30
+ # @return [String] opaque 8-character ID
31
+ def self.next_invocation_id
32
+ SecureRandom.alphanumeric(8)
33
+ end
34
+
35
+ # @param outcome [Symbol] one of the OUTCOME_* constants
36
+ # @param stdout [String] captured stdout (empty when nothing captured)
37
+ # @param stderr [String] captured stderr (empty when nothing captured)
38
+ # @param status [Process::Status, nil] exit status (nil when the process never exited on its own)
39
+ # @param signal [String, nil] terminating signal name (derived from status when not given)
40
+ # @param provider_name [String] human-facing provider label
41
+ # @param invocation_id [String] correlation ID generated before spawn
42
+ # @param deadline_seconds [Numeric, nil] configured deadline
43
+ # @param elapsed_seconds [Numeric, nil] monotonic elapsed time
44
+ # @param spawn_error [Exception, nil] exception that prevented spawning
45
+ def initialize(outcome:, stdout: "", stderr: "", status: nil, signal: nil, provider_name: "CLI",
46
+ invocation_id: Models::CaptureResult.next_invocation_id, deadline_seconds: nil,
47
+ elapsed_seconds: nil, spawn_error: nil)
48
+ @outcome = outcome
49
+ @stdout = stdout.to_s
50
+ @stderr = stderr.to_s
51
+ @status = status
52
+ @signal = signal
53
+ @provider_name = provider_name
54
+ @invocation_id = invocation_id
55
+ @deadline_seconds = deadline_seconds
56
+ @elapsed_seconds = elapsed_seconds
57
+ @spawn_error = spawn_error
58
+ freeze
59
+ end
60
+
61
+ attr_reader :outcome, :stdout, :stderr, :status, :signal, :provider_name,
62
+ :invocation_id, :deadline_seconds, :elapsed_seconds, :spawn_error
63
+
64
+ # True when the process ran to its own completion and exited zero.
65
+ def success?
66
+ outcome == OUTCOME_COMPLETED && status&.success? ? true : false
67
+ end
68
+
69
+ # True when the provider process was actually spawned. Failed spawns
70
+ # never began execution, so replaying them cannot repeat side effects.
71
+ def execution_began?
72
+ outcome != OUTCOME_SPAWN_FAILURE
73
+ end
74
+
75
+ def exit_status
76
+ status&.exitstatus
77
+ end
78
+
79
+ def signal
80
+ return @signal if @signal
81
+ return nil if outcome == OUTCOME_SPAWN_FAILURE
82
+
83
+ termsig = status&.termsig
84
+ termsig ? (Signal.list.key(termsig) || "signal #{termsig}") : nil
85
+ end
86
+
87
+ # Structured evidence for classification, with bounded output excerpts.
88
+ # @return [Ace::LLM::Models::ExecutionEvidence]
89
+ def execution_evidence
90
+ Ace::LLM::Models::ExecutionEvidence.new(
91
+ outcome: evidence_outcome,
92
+ invocation_id: invocation_id,
93
+ provider_name: provider_name,
94
+ deadline_seconds: deadline_seconds,
95
+ elapsed_seconds: elapsed_seconds,
96
+ exit_status: exit_status,
97
+ signal: signal,
98
+ execution_began: execution_began?,
99
+ stdout_excerpt: stdout,
100
+ stderr_excerpt: stderr
101
+ )
102
+ end
103
+
104
+ # Build the ProviderError describing this outcome, with evidence attached.
105
+ # Explicit caller messages can embed raw CLI output, so they are
106
+ # redacted and bounded here — never trusted as-is.
107
+ # @return [Ace::LLM::ProviderError]
108
+ def provider_error(message = nil)
109
+ text = Ace::LLM::Models::ExecutionEvidence.redact(message || default_error_message)
110
+ text = "#{text[0, MAX_MESSAGE_LENGTH]}…[truncated]" if text.length > MAX_MESSAGE_LENGTH
111
+ error = Ace::LLM::ProviderError.new(text)
112
+ error.execution_evidence = execution_evidence
113
+ error
114
+ end
115
+
116
+ # Raise ProviderError unless the invocation completed with exit 0.
117
+ # Deadline expiry, transport failure, spawn failure, and nonzero exit
118
+ # each surface as a distinct, evidence-carrying error.
119
+ def raise_unless_success
120
+ raise provider_error unless success?
121
+
122
+ self
123
+ end
124
+
125
+ # Attach "session ended without a final response" evidence to an error
126
+ # raised while parsing a completed (exit-zero) capture — e.g. a CLI
127
+ # that exited cleanly but produced no final message. The message is
128
+ # redacted before being carried forward.
129
+ # @param error [Ace::LLM::ProviderError]
130
+ # @return [Ace::LLM::ProviderError]
131
+ def with_no_response_evidence(error)
132
+ message = Ace::LLM::Models::ExecutionEvidence.redact(error.message.to_s)
133
+ message = "#{message[0, MAX_MESSAGE_LENGTH]}…[truncated]" if message.length > MAX_MESSAGE_LENGTH
134
+ redacted = error.exception(message)
135
+ evidence = Ace::LLM::Models::ExecutionEvidence.new(
136
+ outcome: :no_response,
137
+ invocation_id: invocation_id,
138
+ provider_name: provider_name,
139
+ deadline_seconds: deadline_seconds,
140
+ elapsed_seconds: elapsed_seconds,
141
+ exit_status: exit_status,
142
+ execution_began: true,
143
+ stdout_excerpt: stdout,
144
+ stderr_excerpt: stderr
145
+ )
146
+ redacted.execution_evidence ||= evidence
147
+ redacted
148
+ end
149
+
150
+ private
151
+
152
+ def evidence_outcome
153
+ case outcome
154
+ when OUTCOME_COMPLETED then :nonzero_exit
155
+ when OUTCOME_DEADLINE_EXCEEDED then :deadline_exceeded
156
+ when OUTCOME_TRANSPORT_FAILURE, OUTCOME_SPAWN_FAILURE then :transport_failure
157
+ end
158
+ end
159
+
160
+ def default_error_message
161
+ case outcome
162
+ when OUTCOME_DEADLINE_EXCEEDED
163
+ "#{provider_name} CLI execution exceeded its #{format_seconds(deadline_seconds)} deadline"
164
+ when OUTCOME_TRANSPORT_FAILURE
165
+ "#{provider_name} CLI session ended unexpectedly" + (signal ? " (#{signal})" : "")
166
+ when OUTCOME_SPAWN_FAILURE
167
+ "#{provider_name} CLI could not be started: #{spawn_error_class}"
168
+ else
169
+ base = "#{provider_name} CLI failed with exit status #{exit_status}"
170
+ (detail = failure_detail) ? "#{base}: #{detail}" : base
171
+ end
172
+ end
173
+
174
+ # First non-empty captured line, bounded and redacted — keeps
175
+ # actionable CLI diagnostics in the error without leaking
176
+ # transcripts or credentials.
177
+ def failure_detail
178
+ source = [stderr, stdout].map { |stream| stream.to_s.lines.map(&:strip) }.flatten
179
+ detail = source.reject(&:empty?).first.to_s
180
+ detail = Ace::LLM::Models::ExecutionEvidence.redact(detail)
181
+ detail = "#{detail[0, 300]}…[truncated]" if detail.length > 300
182
+ detail.empty? ? nil : detail
183
+ end
184
+
185
+ def spawn_error_class
186
+ spawn_error ? spawn_error.class.name : "unknown error"
187
+ end
188
+
189
+ def format_seconds(value)
190
+ value.is_a?(Numeric) ? format("%.1fs", value) : value.to_s
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end
@@ -17,7 +17,15 @@ module Ace
17
17
  def run(command)
18
18
  enable_child_subreaper
19
19
  ready_fd = Integer(ENV.delete("ACE_SAFE_CAPTURE_READY_FD"))
20
- command_pid = Process.spawn(*command, pgroup: true, ready_fd => :close)
20
+ command_pid = begin
21
+ Process.spawn(*command, pgroup: true, ready_fd => :close)
22
+ rescue StandardError => e
23
+ # The child never started: tell the capture through the
24
+ # readiness pipe so it records a spawn failure, not an
25
+ # executed (nonzero-exit) child session.
26
+ report_spawn_failure(ready_fd, e)
27
+ raise
28
+ end
21
29
  command_pgid = command_pid
22
30
  IO.for_fd(ready_fd).tap { |io| io.write("1"); io.close }
23
31
 
@@ -30,6 +38,15 @@ module Ace
30
38
  exit(status.exitstatus || 128 + status.termsig)
31
39
  end
32
40
 
41
+ def report_spawn_failure(ready_fd, error)
42
+ IO.for_fd(ready_fd).tap do |io|
43
+ io.write("S#{error.class.name}: #{error.message}")
44
+ io.close
45
+ end
46
+ rescue SystemCallError, IOError
47
+ nil
48
+ end
49
+
33
50
  def enable_child_subreaper
34
51
  previous_deprecated = Warning[:deprecated]
35
52
  Warning[:deprecated] = false
@@ -3,6 +3,8 @@
3
3
  require "open3"
4
4
  require "rbconfig"
5
5
 
6
+ require_relative "../models/capture_result"
7
+
6
8
  module Ace
7
9
  module LLM
8
10
  module Providers
@@ -17,6 +19,10 @@ module Ace
17
19
  # Uses Open3.popen3 and, on Linux, a dedicated child-subreaper supervisor
18
20
  # so the complete command tree is terminated and reaped without thread
19
21
  # interruption or closed-stream races.
22
+ #
23
+ # Returns a typed Models::CaptureResult instead of a bare tuple so
24
+ # callers can distinguish completed runs from deadline expiry, transport
25
+ # failure, and spawn failure, and carry that evidence into errors.
20
26
  class SafeCapture
21
27
  # @param cmd [Array<String>] Command arguments
22
28
  # @param timeout [Integer] Timeout in seconds
@@ -26,85 +32,246 @@ module Ace
26
32
  # @param provider_name [String] Provider name for error messages
27
33
  # @param isolate_process_group [Boolean] Spawn subprocess in isolated process group
28
34
  # @param cleanup_group_on_exit [Boolean] Clean up descendants on success
29
- # @return [Array(String, String, Process::Status)] [stdout, stderr, status]
30
- # @raise [Ace::LLM::ProviderError] on timeout
35
+ # @return [Models::CaptureResult] typed capture outcome with raw streams
31
36
  def self.call(cmd, timeout:, stdin_data: nil, chdir: nil, env: nil, provider_name: "CLI",
32
37
  command_prefix: nil,
33
38
  isolate_process_group: true, cleanup_group_on_exit: true)
34
39
  normalized_timeout = normalize_timeout(timeout)
35
- opts = {}
36
- opts[:chdir] = chdir if chdir
37
- opts[:pgroup] = true if isolate_process_group
38
-
39
- full_cmd = Array(command_prefix) + cmd
40
- supervised = isolate_process_group && cleanup_group_on_exit && RUBY_PLATFORM.include?("linux")
41
- full_cmd = supervisor_command(full_cmd) if supervised
42
- ready_reader, ready_writer = IO.pipe if supervised
43
- opts[ready_writer.fileno] = ready_writer if supervised
44
- spawn_env = env&.dup || {}
45
- spawn_env["ACE_SAFE_CAPTURE_READY_FD"] = ready_writer.fileno.to_s if supervised
46
- args = spawn_env.empty? ? full_cmd : [spawn_env, *full_cmd]
47
-
48
- Open3.popen3(*args, **opts) do |stdin, stdout, stderr, wait_thr|
49
- ready_writer&.close
50
- ready_reader&.read(1)
51
- ready_reader&.close
52
-
53
- pid = wait_thr.pid
54
- pgid = safe_getpgid(pid)
55
- debug_log(provider_name, "spawn pid=#{pid} pgid=#{pgid || "n/a"}")
56
-
57
- begin
58
- stdin.write(stdin_data) if stdin_data
59
- rescue Errno::EPIPE
60
- # Subprocess exited before consuming stdin — continue to capture stderr for the real error
40
+ invocation_id = Models::CaptureResult.next_invocation_id
41
+ started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
42
+
43
+ spawn_result = begin
44
+ spawn_subprocess(
45
+ cmd, chdir: chdir, env: env, command_prefix: command_prefix,
46
+ isolate_process_group: isolate_process_group,
47
+ cleanup_group_on_exit: cleanup_group_on_exit
48
+ )
49
+ rescue SystemCallError => e
50
+ return spawn_failure_result(
51
+ e, provider_name: provider_name, invocation_id: invocation_id,
52
+ deadline_seconds: normalized_timeout, started_at: started_at
53
+ )
54
+ end
55
+
56
+ begin
57
+ run_capture(
58
+ spawn_result,
59
+ provider_name: provider_name,
60
+ stdin_data: stdin_data,
61
+ isolate_process_group: isolate_process_group,
62
+ cleanup_group_on_exit: cleanup_group_on_exit,
63
+ normalized_timeout: normalized_timeout,
64
+ invocation_id: invocation_id,
65
+ started_at: started_at
66
+ )
67
+ rescue SystemCallError => e
68
+ # popen3 raises before the capture block runs when the command
69
+ # cannot be spawned (e.g. binary not found).
70
+ spawn_failure_result(
71
+ e, provider_name: provider_name, invocation_id: invocation_id,
72
+ deadline_seconds: normalized_timeout, started_at: started_at
73
+ )
74
+ ensure
75
+ spawn_result.close_setup_handles
76
+ end
77
+ end
78
+
79
+ class << self
80
+ private
81
+
82
+ SpawnSetup = Struct.new(:args, :opts, :supervised, :ready_reader, :ready_writer, :readiness,
83
+ keyword_init: true) do
84
+ def supervised?
85
+ supervised
61
86
  end
62
- stdin.close
63
87
 
64
- out_reader = Thread.new { safe_read_stream(stdout) }
65
- err_reader = Thread.new { safe_read_stream(stderr) }
66
- out_reader.report_on_exception = false
67
- err_reader.report_on_exception = false
88
+ # Read the supervisor's readiness message (small, bounded; the
89
+ # supervisor closes the pipe after writing). "1" means the child
90
+ # group spawned; "S<detail>" reports a child spawn failure.
91
+ def activate_streams
92
+ ready_writer&.close
93
+ self.readiness = ready_reader&.read
94
+ ready_reader&.close
95
+ end
68
96
 
69
- unless wait_thr.join(normalized_timeout)
70
- # Timeout: kill subprocess group (and descendants), then clean up
71
- terminate_subprocess_tree(
72
- pid: pid, pgid: pgid, provider_name: provider_name, supervised: supervised
73
- )
74
- unless wait_thr.join(5)
75
- terminate_group_or_pid("KILL", pid, pgid)
76
- wait_thr.join(5)
77
- end
97
+ # StandardError describing a supervisor-reported child spawn
98
+ # failure, or nil when the child group started normally.
99
+ def child_spawn_failure
100
+ return nil if supervised != true || readiness.nil.
101
+
102
+ message = readiness.to_s
103
+ return nil unless message.start_with?("S")
78
104
 
79
- stdout.close unless stdout.closed?
80
- stderr.close unless stderr.closed?
81
- out_reader.join(1)
82
- err_reader.join(1)
83
- out_reader.kill if out_reader.alive?
84
- err_reader.kill if err_reader.alive?
85
- raise Ace::LLM::ProviderError,
86
- "#{provider_name} CLI execution timed out after #{normalized_timeout} seconds"
105
+ StandardError.new("child process failed to start: #{message[1..].strip}")
87
106
  end
88
107
 
89
- status = wait_thr.value
90
- if isolate_process_group && cleanup_group_on_exit && !supervised
91
- terminate_descendants_after_success(pid: pid, pgid: pgid, provider_name: provider_name)
108
+ def close_setup_handles
109
+ [ready_reader, ready_writer].each do |io|
110
+ next unless io
111
+
112
+ io.close unless io.closed?
113
+ end
92
114
  end
115
+ end
116
+ private_constant :SpawnSetup
93
117
 
94
- [out_reader.value, err_reader.value, status]
118
+ def spawn_failure_result(error, provider_name:, invocation_id:, deadline_seconds:, started_at:)
119
+ Models::CaptureResult.new(
120
+ outcome: Models::CaptureResult::OUTCOME_SPAWN_FAILURE,
121
+ provider_name: provider_name,
122
+ invocation_id: invocation_id,
123
+ deadline_seconds: deadline_seconds,
124
+ elapsed_seconds: elapsed_since(started_at),
125
+ spawn_error: error
126
+ )
95
127
  end
96
- ensure
97
- ready_reader&.close unless ready_reader&.closed?
98
- ready_writer&.close unless ready_writer&.closed?
99
- end
100
128
 
101
- class << self
102
- private
129
+ def run_capture(spawn_result, provider_name:, stdin_data:, isolate_process_group:,
130
+ cleanup_group_on_exit:, normalized_timeout:, invocation_id:, started_at:)
131
+ Open3.popen3(*spawn_result.args, **spawn_result.opts) do |stdin, stdout, stderr, wait_thr|
132
+ spawn_result.activate_streams
133
+ if (spawn_failure = spawn_result.child_spawn_failure)
134
+ return Models::CaptureResult.new(
135
+ outcome: Models::CaptureResult::OUTCOME_SPAWN_FAILURE,
136
+ provider_name: provider_name,
137
+ invocation_id: invocation_id,
138
+ deadline_seconds: normalized_timeout,
139
+ elapsed_seconds: elapsed_since(started_at),
140
+ spawn_error: spawn_failure
141
+ )
142
+ end
143
+
144
+ pid = wait_thr.pid
145
+ pgid = safe_getpgid(pid)
146
+ debug_log(provider_name, "spawn pid=#{pid} pgid=#{pgid || "n/a"} invocation=#{invocation_id}")
147
+
148
+ begin
149
+ stdin.write(stdin_data) if stdin_data
150
+ rescue Errno::EPIPE
151
+ # Subprocess exited before consuming stdin — continue to capture stderr for the real error
152
+ end
153
+ stdin.close
103
154
 
104
- def safe_read_stream(io)
105
- io.read
106
- rescue IOError
107
- ""
155
+ partial_stdout = +""
156
+ partial_stderr = +""
157
+ out_reader = Thread.new { safe_read_stream(stdout, partial_stdout) }
158
+ err_reader = Thread.new { safe_read_stream(stderr, partial_stderr) }
159
+ out_reader.report_on_exception = false
160
+ err_reader.report_on_exception = false
161
+
162
+ unless wait_thr.join(normalized_timeout)
163
+ # Deadline exceeded: kill subprocess group (and descendants), retain partial streams
164
+ terminate_subprocess_tree(
165
+ pid: pid, pgid: pgid, provider_name: provider_name, supervised: spawn_result.supervised?
166
+ )
167
+ unless wait_thr.join(5)
168
+ terminate_group_or_pid("KILL", pid, pgid)
169
+ wait_thr.join(5)
170
+ end
171
+
172
+ return Models::CaptureResult.new(
173
+ outcome: Models::CaptureResult::OUTCOME_DEADLINE_EXCEEDED,
174
+ stdout: drain_reader(out_reader, stdout, partial_stdout),
175
+ stderr: drain_reader(err_reader, stderr, partial_stderr),
176
+ provider_name: provider_name,
177
+ invocation_id: invocation_id,
178
+ deadline_seconds: normalized_timeout,
179
+ elapsed_seconds: elapsed_since(started_at)
180
+ )
181
+ end
182
+
183
+ status = wait_thr.value
184
+ if isolate_process_group && cleanup_group_on_exit && !spawn_result.supervised?
185
+ terminate_descendants_after_success(pid: pid, pgid: pgid, provider_name: provider_name)
186
+ end
187
+
188
+ outcome = if status.exited?
189
+ Models::CaptureResult::OUTCOME_COMPLETED
190
+ else
191
+ # Terminated by a signal we did not send: the session ended
192
+ # abruptly rather than the CLI finishing on its own.
193
+ Models::CaptureResult::OUTCOME_TRANSPORT_FAILURE
194
+ end
195
+
196
+ return Models::CaptureResult.new(
197
+ outcome: outcome,
198
+ stdout: out_reader.value.to_s,
199
+ stderr: err_reader.value.to_s,
200
+ status: status,
201
+ provider_name: provider_name,
202
+ invocation_id: invocation_id,
203
+ deadline_seconds: normalized_timeout,
204
+ elapsed_seconds: elapsed_since(started_at)
205
+ )
206
+ end
207
+ end
208
+
209
+ def spawn_subprocess(cmd, chdir:, env:, command_prefix:, isolate_process_group:,
210
+ cleanup_group_on_exit:)
211
+ opts = {}
212
+ opts[:chdir] = chdir if chdir
213
+ opts[:pgroup] = true if isolate_process_group
214
+
215
+ full_cmd = Array(command_prefix) + cmd
216
+ supervised = isolate_process_group && cleanup_group_on_exit && RUBY_PLATFORM.include?("linux")
217
+ full_cmd = supervisor_command(full_cmd) if supervised
218
+ ready_reader, ready_writer = IO.pipe if supervised
219
+ opts[ready_writer.fileno] = ready_writer if supervised
220
+ spawn_env = env&.dup || {}
221
+ spawn_env["ACE_SAFE_CAPTURE_READY_FD"] = ready_writer.fileno.to_s if supervised
222
+ args = spawn_env.empty? ? full_cmd : [spawn_env, *full_cmd]
223
+
224
+ SpawnSetup.new(
225
+ args: args,
226
+ opts: opts,
227
+ supervised: supervised,
228
+ ready_reader: ready_reader,
229
+ ready_writer: ready_writer,
230
+ readiness: nil
231
+ )
232
+ end
233
+
234
+ # Pull whatever partial output a reader thread captured without
235
+ # ever blocking past a short bound: a descendant outside the
236
+ # killed process group can keep the pipe open indefinitely. The
237
+ # buffer is caller-owned, so bytes already read survive even when
238
+ # the stream must be closed or the reader thread must be killed.
239
+ def drain_reader(reader, io, buffer)
240
+ if reader.join(2)
241
+ reader.value.to_s
242
+ else
243
+ io.close unless io.closed?
244
+ reader.join(1)
245
+ reader.kill if reader.alive?
246
+ # The reader may have been killed mid-chunk; finalize the
247
+ # partial buffer with the same UTF-8 scrubbing contract.
248
+ buffer.dup.force_encoding(Encoding::UTF_8).scrub!
249
+ end
250
+ end
251
+
252
+ # Incrementally buffer stream output into a caller-owned buffer so
253
+ # already-read bytes survive even when the stream must be closed
254
+ # mid-read or the reader thread must be killed.
255
+ def safe_read_stream(io, buffer)
256
+ loop do
257
+ chunk = io.read_nonblock(65_536, exception: false)
258
+ if chunk.nil?
259
+ break # EOF
260
+ elsif chunk == :wait_readable
261
+ IO.select([io])
262
+ next
263
+ end
264
+ buffer << chunk
265
+ end
266
+ # Pipes yield raw bytes; CLI output can contain non-UTF-8
267
+ # fragments. Finalize as valid UTF-8 (invalid bytes become
268
+ # replacement characters) so downstream prompt concatenation
269
+ # cannot raise Encoding::CompatibilityError.
270
+ buffer.force_encoding(Encoding::UTF_8)
271
+ buffer.scrub!
272
+ buffer
273
+ rescue IOError, SystemCallError
274
+ buffer
108
275
  end
109
276
 
110
277
  def normalize_timeout(value)
@@ -119,6 +286,10 @@ module Ace
119
286
  raise ArgumentError, "timeout must be a positive numeric value, got #{value.inspect}"
120
287
  end
121
288
 
289
+ def elapsed_since(started_at)
290
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - started_at
291
+ end
292
+
122
293
  def supervisor_command(command)
123
294
  supervisor = File.expand_path("process_supervisor.rb", __dir__)
124
295
  [RbConfig.ruby, "-W0", supervisor, *command]
@@ -54,9 +54,10 @@ module Ace
54
54
  full_prompt = build_full_prompt(prompt, options)
55
55
 
56
56
  cmd = build_opencode_command_with_prompt(full_prompt, options)
57
- stdout, stderr, status = execute_opencode_command(cmd, options: options)
57
+ capture = execute_opencode_command(cmd, options: options)
58
+ capture.raise_unless_success
58
59
 
59
- parse_opencode_response(stdout, stderr, status, full_prompt, options)
60
+ parse_opencode_response(capture, full_prompt, options)
60
61
  rescue => e
61
62
  handle_opencode_error(e)
62
63
  end
@@ -211,25 +212,27 @@ module Ace
211
212
  )
212
213
  end
213
214
 
214
- def parse_opencode_response(stdout, stderr, status, prompt, options)
215
- unless status.success?
216
- error_msg = stderr.empty? ? stdout : stderr
215
+ def parse_opencode_response(capture, prompt, options)
216
+ # Unreachable through generate (raise_unless_success precedes parse),
217
+ # kept for direct callers with clearer 400 diagnostics.
218
+ unless capture.status.success?
219
+ error_msg = capture.stderr.empty? ? capture.stdout : capture.stderr
217
220
 
218
221
  # Detect common error patterns for better error messages
219
222
  if error_msg.include?("400") || error_msg.include?("Bad Request")
220
223
  raise Ace::LLM::ProviderError, "OpenCode API request failed (400 Bad Request). The model or prompt may be invalid."
221
224
  end
222
225
 
223
- raise Ace::LLM::ProviderError, "OpenCode CLI failed: #{error_msg}"
226
+ raise capture.provider_error("OpenCode CLI failed: #{error_msg}")
224
227
  end
225
228
 
226
229
  begin
227
230
  # Try to parse as JSON first
228
- response = JSON.parse(stdout)
231
+ response = JSON.parse(capture.stdout)
229
232
  text = response["result"] || response["text"] || response["response"] || ""
230
233
  rescue JSON::ParserError
231
234
  # Fall back to treating entire output as text
232
- text = stdout.strip
235
+ text = capture.stdout.strip
233
236
  response = {}
234
237
  end
235
238
 
@@ -57,9 +57,20 @@ module Ace
57
57
  full_prompt = rewrite_skill_commands(full_prompt, working_dir: working_dir)
58
58
 
59
59
  cmd = build_pi_command(full_prompt, options, system_prompt: system_prompt)
60
- stdout, stderr, status = execute_pi_command(cmd, working_dir: working_dir, options: options)
60
+ capture = execute_pi_command(cmd, working_dir: working_dir, options: options)
61
+ begin
62
+ capture.raise_unless_success
63
+ rescue Ace::LLM::ProviderError
64
+ raise if !capture.status || capture.status.success?
61
65
 
62
- parse_pi_response(stdout, stderr, status, full_prompt, options)
66
+ # 401 output means credentials, not a generic CLI failure —
67
+ # AuthenticationError classifies as skip-to-next-provider.
68
+ raise auth_error if captured_auth_failure?(capture)
69
+
70
+ raise
71
+ end
72
+
73
+ parse_pi_response(capture, full_prompt, options)
63
74
  rescue => e
64
75
  handle_pi_error(e)
65
76
  end
@@ -275,18 +286,38 @@ module Ace
275
286
  )
276
287
  end
277
288
 
278
- def parse_pi_response(stdout, stderr, status, prompt, options)
279
- unless status.success?
280
- error_msg = stderr.empty? ? stdout : stderr
289
+ # 401/Unauthorized anywhere in captured output marks a credentials
290
+ # failure rather than a generic CLI error.
291
+ def captured_auth_failure?(capture)
292
+ output = "#{capture.stderr}\n#{capture.stdout}"
293
+ output.include?("401") || output.include?("Unauthorized")
294
+ end
281
295
 
282
- if error_msg.include?("401") || error_msg.include?("Unauthorized")
296
+ def auth_error
297
+ Ace::LLM::AuthenticationError.new("Pi authentication failed. Run 'pi login' to configure credentials.")
298
+ end
299
+
300
+ def parse_pi_response(capture, prompt, options)
301
+ # Unreachable through generate (raise_unless_success precedes parse),
302
+ # kept for direct callers: auth failures stay AuthenticationError.
303
+ unless capture.status.success?
304
+ error_msg = capture.stderr.empty? ? capture.stdout : capture.stderr
305
+
306
+ if captured_auth_failure?(capture)
283
307
  raise Ace::LLM::AuthenticationError, "Pi authentication failed. Run 'pi login' to configure credentials."
284
308
  end
285
309
 
286
- raise Ace::LLM::ProviderError, "Pi CLI failed: #{error_msg}"
310
+ raise capture.provider_error("Pi CLI failed: #{error_msg}")
287
311
  end
288
312
 
289
- text, usage, finish_reason = parse_ndjson(stdout)
313
+ text, usage, finish_reason = parse_ndjson(capture.stdout)
314
+ if text.to_s.strip.empty?
315
+ # Clean exit without a final response: an incomplete session,
316
+ # not a completed answer.
317
+ raise capture.with_no_response_evidence(
318
+ Ace::LLM::ProviderError.new("Pi CLI produced no final message")
319
+ )
320
+ end
290
321
  response = {"usage" => normalize_usage(usage), "finish_reason" => finish_reason}
291
322
 
292
323
  metadata = build_metadata(response, text, prompt, options)
@@ -4,7 +4,7 @@ module Ace
4
4
  module LLM
5
5
  module Providers
6
6
  module CLI
7
- VERSION = "0.35.2"
7
+ VERSION = "0.36.1"
8
8
  end
9
9
  end
10
10
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-llm-providers-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.35.2
4
+ version: 0.36.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-09-29 00:00:00.000000000 Z
10
+ date: 2026-10-01 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-llm
@@ -15,14 +15,14 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '0.41'
18
+ version: '0.42'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '0.41'
25
+ version: '0.42'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: rake
28
28
  requirement: !ruby/object:Gem::Requirement
@@ -110,6 +110,7 @@ files:
110
110
  - lib/ace/llm/providers/cli/codex_client.rb
111
111
  - lib/ace/llm/providers/cli/codex_oai_client.rb
112
112
  - lib/ace/llm/providers/cli/gemini_client.rb
113
+ - lib/ace/llm/providers/cli/models/capture_result.rb
113
114
  - lib/ace/llm/providers/cli/molecules/health_checker.rb
114
115
  - lib/ace/llm/providers/cli/molecules/process_supervisor.rb
115
116
  - lib/ace/llm/providers/cli/molecules/safe_capture.rb