Class: AgentHarness::CommandExecutor

Inherits:
Object
  • Object
show all
Defined in:
lib/agent_harness/command_executor.rb

Overview

Executes shell commands with timeout support

Provides a clean interface for running CLI commands with proper error handling, timeout support, and result capture.

Examples:

Basic usage

executor = AgentHarness::CommandExecutor.new
result = executor.execute(["claude", "--print", "--prompt", "Hello"])
puts result.stdout

With timeout

result = executor.execute("claude --print", timeout: 300)

Direct Known Subclasses

DockerCommandExecutor

Defined Under Namespace

Classes: Result

Constant Summary collapse

PREPARATION_LOCK_POLL_INTERVAL =
0.01
PREPARATION_CLEANUP_GRACE_PERIOD =
5
PREPARATION_LOCK_ROOT =
File.join(Dir.tmpdir, "agent-harness-preparation-locks")

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(logger: nil) ⇒ CommandExecutor

Returns a new instance of CommandExecutor.



42
43
44
# File 'lib/agent_harness/command_executor.rb', line 42

def initialize(logger: nil)
  @logger = logger
end

Instance Attribute Details

#loggerObject (readonly)

Returns the value of attribute logger.



40
41
42
# File 'lib/agent_harness/command_executor.rb', line 40

def logger
  @logger
end

Instance Method Details

#available?(binary) ⇒ Boolean

Check if a binary is available

Parameters:

  • binary (String)

    binary name

Returns:

  • (Boolean)

    true if available



202
203
204
# File 'lib/agent_harness/command_executor.rb', line 202

def available?(binary)
  !which(binary).nil?
end

#execute(command, timeout: nil, idle_timeout: nil, env: {}, stdin_data: nil, preparation: nil, on_stdout_chunk: nil, on_stderr_chunk: nil, on_heartbeat: nil, heartbeat_interval: 1.0, observer: nil) ⇒ Result

Execute a command with optional timeout

Parameters:

  • command (Array<String>, String)

    command to execute

  • timeout (Integer, nil) (defaults to: nil)

    timeout in seconds

  • idle_timeout (Integer, Float, nil) (defaults to: nil)

    idle timeout in seconds based on output activity

  • env (Hash) (defaults to: {})

    environment variables

  • stdin_data (String, nil) (defaults to: nil)

    data to send to stdin

  • preparation (ExecutionPreparation, nil) (defaults to: nil)

    request-scoped bootstrap work for the runtime environment

  • on_stdout_chunk (Proc, nil) (defaults to: nil)

    callback for stdout chunks as they are produced

  • on_stderr_chunk (Proc, nil) (defaults to: nil)

    callback for stderr chunks as they are produced

  • on_heartbeat (Proc, nil) (defaults to: nil)

    callback invoked periodically while the command is running

  • heartbeat_interval (Integer, Float) (defaults to: 1.0)

    heartbeat interval in seconds

  • observer (Object, nil) (defaults to: nil)

    optional observer responding to on_stdout_chunk, on_stderr_chunk, and on_heartbeat

Returns:

  • (Result)

    execution result

Raises:



64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
# File 'lib/agent_harness/command_executor.rb', line 64

def execute(command, timeout: nil, idle_timeout: nil, env: {}, stdin_data: nil, preparation: nil,
  on_stdout_chunk: nil, on_stderr_chunk: nil, on_heartbeat: nil,
  heartbeat_interval: 1.0, observer: nil)
  validate_duration!(timeout, name: :timeout, allow_nil: true)
  validate_duration!(idle_timeout, name: :idle_timeout, allow_nil: true)
  validate_duration!(heartbeat_interval, name: :heartbeat_interval, allow_nil: true)

  cmd_array = normalize_command(command)
  cmd_string = cmd_array.shelljoin
  command_name = cmd_array.first
  start_time = current_time
  deadline = timeout_deadline(timeout)
  applied_preparation = []
  held_preparation_locks = []
  background_cleanup_scheduled = false

  log_debug("Executing command",
    command: cmd_string,
    timeout: timeout,
    idle_timeout: idle_timeout)

  held_preparation_locks = acquire_preparation_locks(
    preparation,
    env: env,
    timeout: timeout,
    deadline: deadline,
    command_name: command_name
  )
  apply_preparation(
    preparation,
    env: env,
    timeout: timeout,
    deadline: deadline,
    command_name: command_name,
    applied_preparation: applied_preparation
  )

  begin
    stdout, stderr, status = execute_streaming(
      cmd_array,
      timeout: remaining_timeout(deadline, timeout:, command_name: command_name),
      idle_timeout: idle_timeout,
      env: env,
      stdin_data: stdin_data,
      on_stdout_chunk: on_stdout_chunk,
      on_stderr_chunk: on_stderr_chunk,
      on_heartbeat: on_heartbeat,
      heartbeat_interval: heartbeat_interval,
      observer: observer
    )
  rescue TimeoutError => e
    raise e if e.is_a?(IdleTimeoutError)

    raise TimeoutError, "Command timed out after #{timeout} seconds: #{command_name}"
  end

  begin
    cleanup_preparation(
      applied_preparation,
      command_name: command_name,
      timeout: timeout,
      deadline: cleanup_deadline(deadline, timeout:)
    )
  rescue TimeoutError
    schedule_cleanup_preparation(
      applied_preparation,
      held_preparation_locks,
      command_name: command_name
    )
    background_cleanup_scheduled = true
    held_preparation_locks = []
  end
  duration = current_time - start_time

  Result.new(
    stdout: stdout,
    stderr: stderr,
    exit_code: status.exitstatus,
    duration: duration
  )
ensure
  pending_exception = $!
  unless background_cleanup_scheduled || applied_preparation.nil? || applied_preparation.empty?
    begin
      cleanup_preparation(
        applied_preparation,
        command_name: command_name,
        timeout: timeout,
        deadline: cleanup_deadline(deadline, timeout:)
      )
    rescue TimeoutError => e
      raise e if pending_exception.nil?

      if pending_exception.is_a?(TimeoutError)
        schedule_cleanup_preparation(
          applied_preparation,
          held_preparation_locks,
          command_name: command_name
        )
        background_cleanup_scheduled = true
        held_preparation_locks = []
      else
        # Preserve the original non-timeout exception; surface that
        # cleanup also timed out so callers know bootstrap state may
        # have leaked.
        raise pending_exception.class,
          "#{pending_exception.message} (cleanup also failed: #{e.message})"
      end
    rescue => e
      raise e if pending_exception.nil?

      # Surface cleanup failures even when unwinding from another exception,
      # so callers know request-scoped bootstrap state may have leaked.
      raise pending_exception.class,
        "#{pending_exception.message} (cleanup also failed: #{e.message})"
    end
  end
  unless background_cleanup_scheduled || held_preparation_locks.nil? || held_preparation_locks.empty?
    release_preparation_locks(held_preparation_locks)
  end
end

#which(binary) ⇒ String?

Check if a binary exists in PATH

Parameters:

  • binary (String)

    binary name

Returns:

  • (String, nil)

    full path or nil



190
191
192
193
194
195
196
# File 'lib/agent_harness/command_executor.rb', line 190

def which(binary)
  ENV["PATH"].split(File::PATH_SEPARATOR).each do |path|
    full_path = File.join(path, binary)
    return full_path if File.executable?(full_path)
  end
  nil
end