ace-herdr 0.1.0 → 0.3.0

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.
@@ -2,6 +2,7 @@
2
2
 
3
3
  require "open3"
4
4
  require "json"
5
+ require "timeout"
5
6
 
6
7
  module Ace
7
8
  module Herdr
@@ -12,6 +13,8 @@ module Ace
12
13
  # binary; tests substitute this class.
13
14
  class HerdrExecutor
14
15
  DEFAULT_BINARY = "herdr"
16
+ WAKE_OUTPUT_LIMIT = 65_536
17
+ DEFAULT_PROBE_TIMEOUT_S = 60
15
18
 
16
19
  def initialize(binary: DEFAULT_BINARY)
17
20
  @binary = binary
@@ -22,6 +25,28 @@ module Ace
22
25
  run!([@binary, "agent", "get", pane])
23
26
  end
24
27
 
28
+ # Structured live pane observation for the inbox: the caller holds
29
+ # its per-event lock across this probe, so a stalled herdr child is
30
+ # killed at the deadline and the timeout classifies as a retryable
31
+ # pre-submission failure.
32
+ def pane_get_bounded(pane, timeout_s: DEFAULT_PROBE_TIMEOUT_S)
33
+ result = BoundedProcess.call([@binary, "pane", "get", pane], stdin_data: "",
34
+ timeout_s: timeout_s, output_limit: WAKE_OUTPUT_LIMIT)
35
+ execution = ExecutionResult.new(
36
+ stdout: result.stdout.strip, stderr: result.stderr.strip,
37
+ success: result.status.success?, exit_code: result.status.exitstatus || -1
38
+ )
39
+ raise classify(execution, [@binary, "pane", "get", pane]) unless execution.success?
40
+
41
+ execution
42
+ rescue Timeout::Error
43
+ raise AgentNotReadyError, "pane probe timed out after #{timeout_s}s"
44
+ rescue BoundedProcess::PostLaunchError => e
45
+ raise AgentNotReadyError, "pane probe failed after launch: #{e.message}"
46
+ rescue SystemCallError
47
+ raise ExecutorUnavailableError, "herdr CLI not found or not executable: #{@binary}"
48
+ end
49
+
25
50
  # Start an agent in a pane at an interactive shell prompt.
26
51
  # Success means the agent was detected and is ready for input.
27
52
  def agent_start(name:, kind:, pane:, timeout_ms:)
@@ -35,6 +60,34 @@ module Ace
35
60
  run!([@binary, "agent", "prompt", pane, text])
36
61
  end
37
62
 
63
+ # A bounded, payload-free wake. The child deadline is managed by
64
+ # BoundedProcess (process-group kill), so a stalled process cannot
65
+ # keep the inbox event lock through Open3.capture3 cleanup.
66
+ def agent_prompt_bounded(pane:, text:, timeout_ms:)
67
+ cmd = [@binary, "agent", "prompt", pane, text]
68
+ result = BoundedProcess.call(cmd, stdin_data: "",
69
+ timeout_s: timeout_ms / 1000.0, output_limit: WAKE_OUTPUT_LIMIT)
70
+ execution = ExecutionResult.new(
71
+ stdout: result.stdout.strip, stderr: result.stderr.strip,
72
+ success: result.status.success?, exit_code: result.status.exitstatus || -1
73
+ )
74
+ unless execution.success?
75
+ error = classify(execution, cmd)
76
+ if result.oversized && error.is_a?(CommandError)
77
+ raise CommandError, "herdr wake output exceeded #{WAKE_OUTPUT_LIMIT} bytes (exit #{execution.exit_code})"
78
+ end
79
+ raise error
80
+ end
81
+
82
+ execution
83
+ rescue Timeout::Error
84
+ raise AgentNotReadyError, "herdr wake timed out after #{timeout_ms}ms"
85
+ rescue BoundedProcess::PostLaunchError => e
86
+ raise AgentNotReadyError, "herdr wake failed after launch: #{e.message}"
87
+ rescue SystemCallError => e
88
+ raise ExecutorUnavailableError, e.message
89
+ end
90
+
38
91
  # Wait until the agent reaches one of the requested states
39
92
  def agent_wait(pane:, until_states:, timeout_ms:)
40
93
  cmd = [@binary, "agent", "wait", pane]
@@ -60,6 +113,12 @@ module Ace
60
113
  run!([@binary, "pane", "current", "--current"])
61
114
  end
62
115
 
116
+ # Structured live pane observation; callers must verify all identity
117
+ # fields before using a native queue target.
118
+ def pane_get(pane)
119
+ run!([@binary, "pane", "get", pane])
120
+ end
121
+
63
122
  # Inspect a pane's processes (positive proof of process exit for tidy)
64
123
  def pane_process_info(pane)
65
124
  run!([@binary, "pane", "process-info", "--pane", pane])
@@ -73,6 +132,18 @@ module Ace
73
132
  run!(cmd)
74
133
  end
75
134
 
135
+ def tab_get(tab)
136
+ run!([@binary, "tab", "get", tab])
137
+ end
138
+
139
+ def tab_focus(tab)
140
+ run!([@binary, "tab", "focus", tab])
141
+ end
142
+
143
+ def api_snapshot
144
+ run!([@binary, "api", "snapshot"])
145
+ end
146
+
76
147
  # --- terminal-control surface (spec 8wq.t.k84) -----------------------
77
148
 
78
149
  # List workspaces (tmux sessions analogue)
@@ -181,8 +252,8 @@ module Ace
181
252
  stdout: stdout.strip, stderr: stderr.strip,
182
253
  success: status.success?, exit_code: status.exitstatus || -1
183
254
  )
184
- rescue Errno::ENOENT
185
- raise ExecutorUnavailableError, "herdr CLI not found on PATH: #{@binary}"
255
+ rescue SystemCallError
256
+ raise ExecutorUnavailableError, "herdr CLI not found or not executable: #{@binary}"
186
257
  end
187
258
 
188
259
  def run_raw_stdout(cmd)
@@ -191,8 +262,8 @@ module Ace
191
262
  stdout: stdout, stderr: stderr.strip,
192
263
  success: status.success?, exit_code: status.exitstatus || -1
193
264
  )
194
- rescue Errno::ENOENT
195
- raise ExecutorUnavailableError, "herdr CLI not found on PATH: #{@binary}"
265
+ rescue SystemCallError
266
+ raise ExecutorUnavailableError, "herdr CLI not found or not executable: #{@binary}"
196
267
  end
197
268
 
198
269
  # Map a failed result to a typed error from herdr's error codes
@@ -200,6 +271,25 @@ module Ace
200
271
  # code is kept in the message so CLI failures carry it.
201
272
  def classify(result, cmd)
202
273
  code, message = error_code(result)
274
+ if code
275
+ return classify_code(code, message, result: result, cmd: cmd)
276
+ end
277
+
278
+ # No structured code: first nonempty of stderr/stdout — herdr
279
+ # writes some failures (socket errors) to stdout with an empty
280
+ # stderr, and empty strings are truthy in Ruby, so `||` chaining
281
+ # would hide the detail.
282
+ detail = [result.stderr, result.stdout].reject { |value| value.to_s.empty? }.first
283
+ if detail.to_s.match?(/no herdr server is running|cannot connect|connection refused|socket.*unavailable/i)
284
+ return ExecutorUnavailableError.new(detail)
285
+ end
286
+
287
+ CommandError.new(
288
+ "herdr command failed (exit #{result.exit_code}): #{cmd.join(" ")} #{detail}".strip
289
+ )
290
+ end
291
+
292
+ def classify_code(code, message, result:, cmd:)
203
293
  case code
204
294
  when "agent_blocked" then AgentBlockedError.new(tag_code(code, message))
205
295
  when "agent_prompt_stalled" then AgentNotReadyError.new(tag_code(code, message))
@@ -207,11 +297,10 @@ module Ace
207
297
  when "agent_not_found" then AgentNotFoundError.new(tag_code(code, message))
208
298
  when "tab_not_found" then TabNotFoundError.new(tag_code(code, message))
209
299
  when "workspace_not_found" then WorkspaceNotFoundError.new(tag_code(code, message))
210
- when "timeout" then AgentNotReadyError.new(tag_code(code, message))
300
+ when "timeout" then ExecutorTimeoutError.new(tag_code(code, message))
211
301
  else
212
302
  CommandError.new(
213
- "herdr command failed (exit #{result.exit_code}): #{cmd.join(" ")} " \
214
- "#{message || result.stderr}".strip
303
+ "herdr command failed (exit #{result.exit_code}): #{cmd.join(" ")}: #{tag_code(code, message)}"
215
304
  )
216
305
  end
217
306
  end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "timeout"
5
+
6
+ module Ace
7
+ module Herdr
8
+ module Molecules
9
+ # The provider-native submission boundary. A failed process does not
10
+ # prove that a message was not accepted, so only validation failures
11
+ # raised before this boundary are retryable without reconciliation.
12
+ #
13
+ # Child processes run under BoundedProcess: the deadline is enforced at
14
+ # the child boundary (process-group kill), never via a cleanup-blocking
15
+ # Timeout.timeout around capture3, so a stalled child cannot hold the
16
+ # inbox event lock past the configured timeout.
17
+ class NativeQueueExecutor
18
+ DEFAULT_SUBMIT_TIMEOUT_S = 60
19
+ DEFAULT_IDENTITY_TIMEOUT_S = 20
20
+ # Codex payloads ride in argv; a single argument beyond the OS
21
+ # per-argument limit fails at exec with E2BIG — before any client
22
+ # code ran — so the payload is bounded pre-launch and any E2BIG
23
+ # escape is classified as a proven pre-submission rejection.
24
+ MAX_ARG_PAYLOAD_BYTES = 65_536
25
+ PI_PAYLOAD_LIMIT_BYTES = 65_536
26
+
27
+ # runner: test seam. Callable (argv, stdin_data:, timeout_s:) ->
28
+ # [stdout, stderr, status]. Defaults to the bounded runner.
29
+ def initialize(codex: "codex", pi_client: nil, runner: nil,
30
+ submit_timeout_s: DEFAULT_SUBMIT_TIMEOUT_S,
31
+ identity_timeout_s: DEFAULT_IDENTITY_TIMEOUT_S)
32
+ @codex = codex
33
+ @pi_client = pi_client || ENV["ACE_HERDR_PI_QUEUE_CLIENT"] || "pi-overseer-queue-client"
34
+ @runner = runner
35
+ @submit_timeout_s = submit_timeout_s
36
+ @identity_timeout_s = identity_timeout_s
37
+ end
38
+
39
+ def pi_identity
40
+ result = execute([@pi_client, "--identity"], stdin_data: "",
41
+ timeout_s: @identity_timeout_s)
42
+ raise ExecutorError, "Pi identity probe failed: #{result.stderr}" unless result.status.success?
43
+
44
+ identity = JSON.parse(result.stdout)
45
+ raise ExecutorError, "Pi identity probe returned invalid JSON" unless identity.is_a?(Hash)
46
+
47
+ identity.fetch("session_id")
48
+ rescue Errno::ENOENT, Errno::EACCES => e
49
+ raise ExecutorUnavailableError, e.message
50
+ rescue JSON::ParserError, KeyError, TypeError
51
+ raise ExecutorError, "Pi identity probe returned invalid JSON"
52
+ rescue Timeout::Error
53
+ raise ExecutorError, "Pi identity probe timed out"
54
+ end
55
+
56
+ def submit(agent:, thread:, event_id:, digest:, payload:)
57
+ if agent == "pi" && (payload.empty? || payload.bytesize > PI_PAYLOAD_LIMIT_BYTES)
58
+ return {"accepted" => false, "pre_submit" => true, "error" => "Pi payload must be 1..65536 bytes"}
59
+ end
60
+ if agent == "codex" && payload.bytesize > MAX_ARG_PAYLOAD_BYTES
61
+ return {"accepted" => false, "pre_submit" => true,
62
+ "error" => "Codex payload must be at most #{MAX_ARG_PAYLOAD_BYTES} bytes for argv delivery"}
63
+ end
64
+ argv = if agent == "codex"
65
+ [@codex, "queue", "--thread", thread, "--message", payload]
66
+ elsif agent == "pi"
67
+ [@pi_client, "--delivery", event_id, "--session-id", thread,
68
+ "--payload-sha256", digest, "--payload-bytes", payload.bytesize.to_s]
69
+ else
70
+ raise ValidationError, "unsupported native queue agent: #{agent}"
71
+ end
72
+ result = execute(argv, stdin_data: agent == "pi" ? payload : "",
73
+ timeout_s: @submit_timeout_s)
74
+ raise ExecutorError, "native queue output exceeded the retained limit" if result.oversized
75
+ unless result.status.success?
76
+ return {"accepted" => false, "exit_code" => result.status.exitstatus,
77
+ "error" => result.stderr.strip}
78
+ end
79
+
80
+ if agent == "pi"
81
+ receipt = JSON.parse(result.stdout)
82
+ unless receipt.is_a?(Hash) && receipt["ok"] == true && receipt["id"] == event_id &&
83
+ receipt["session_id"] == thread && receipt["payload_sha256"] == digest
84
+ return {"accepted" => false, "error" => "Pi queue receipt identity or digest mismatch"}
85
+ end
86
+ end
87
+ {"accepted" => true, "exit_code" => result.status.exitstatus, "stdout" => result.stdout.strip}
88
+ rescue Errno::ENOENT, Errno::EACCES, Errno::E2BIG, ExecutorUnavailableError => e
89
+ # The executable never launched (or exec rejected the argv). No
90
+ # submission could have occurred, so this stays retryable.
91
+ {"accepted" => false, "pre_submit" => true, "error" => e.message}
92
+ rescue JSON::ParserError
93
+ {"accepted" => false, "error" => "Pi queue returned invalid receipt JSON"}
94
+ rescue Timeout::Error
95
+ {"accepted" => false, "error" => "native queue timed out after submission may have begun"}
96
+ end
97
+
98
+ private
99
+
100
+ def execute(argv, stdin_data:, timeout_s:)
101
+ if @runner
102
+ stdout, stderr, status = @runner.call(argv, stdin_data: stdin_data, timeout_s: timeout_s)
103
+ return BoundedProcess::Result.new(stdout, stderr, status, false)
104
+ end
105
+
106
+ BoundedProcess.call(argv, stdin_data: stdin_data, timeout_s: timeout_s)
107
+ rescue SystemCallError => e
108
+ # popen3 raises spawn failures before the child exists, so no
109
+ # submission can have begun: proven pre-launch, retryable.
110
+ # (Post-launch I/O failures arrive as PostLaunchError instead.)
111
+ raise ExecutorUnavailableError, e.message
112
+ rescue BoundedProcess::PostLaunchError => e
113
+ # The child was live, so the message may already have been accepted:
114
+ # keep this uncertain, never pre-submit retryable.
115
+ raise ExecutorError, e.message
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -146,9 +146,10 @@ module Ace
146
146
  # Create a tab from a preset (tmux `window` analogue) in the given
147
147
  # or resolved workspace; --cwd overrides the resolved tab cwd. The
148
148
  # layout is validated before the tab is created.
149
- def create_tab(preset_name, workspace_id: nil, cwd: nil)
149
+ def create_tab(preset_name, workspace_id: nil, cwd: nil, label: nil)
150
150
  workspace_id = resolve_workspace_id(workspace_id)
151
151
  resolved = resolve_tab_preset(preset_name)
152
+ resolved = resolved.merge("label" => label) if label
152
153
  preflight_tab!(resolved)
153
154
  instantiate_tab(
154
155
  resolved, workspace_id: workspace_id,
@@ -248,13 +249,20 @@ module Ace
248
249
  tab_id = dig_value(tab_json, %w[result tab tab_id]) ||
249
250
  raise(TargetResolutionError, "could not read the new tab id from herdr tab create output")
250
251
 
251
- pane_specs = Array(tab_spec["panes"])
252
- root_pane_id = dig_value(tab_json, %w[result root_pane pane_id]) ||
253
- raise(TargetResolutionError, "could not read the root pane id from herdr tab create output")
254
- placed = place_panes(tab_spec, pane_specs, tab_cwd, root_pane_id)
255
-
256
- commands = run_pane_commands(placed)
257
- agents = start_pane_agents(placed, workspace_id: workspace_id)
252
+ begin
253
+ pane_specs = Array(tab_spec["panes"])
254
+ root_pane_id = dig_value(tab_json, %w[result root_pane pane_id]) ||
255
+ raise(TargetResolutionError, "could not read the root pane id from herdr tab create output")
256
+ placed = place_panes(tab_spec, pane_specs, tab_cwd, root_pane_id)
257
+
258
+ commands = run_pane_commands(placed)
259
+ agents = start_pane_agents(placed, workspace_id: workspace_id)
260
+ rescue StandardError => e
261
+ # Surface the created tab id and the original error class so
262
+ # the owner can roll back exactly this tab and keep
263
+ # mapping-relevant semantics (e.g. AgentBlockedError).
264
+ raise TabMaterializationError.new(tab_id: tab_id, message: "#{e.class}: #{e.message}")
265
+ end
258
266
 
259
267
  {
260
268
  tab: tab_id,
@@ -386,33 +394,13 @@ module Ace
386
394
  end
387
395
 
388
396
  def dig_value(json, path)
389
- current = json
390
- path.each do |key|
391
- return nil unless current.is_a?(Hash)
392
-
393
- current = current[key]
394
- end
395
- current.is_a?(String) && !current.empty? ? current : nil
397
+ Atoms::JsonFind.dig_value(json, path)
396
398
  end
397
399
 
398
- # Recursive search mirroring the Dispatcher's tolerant extraction of
399
- # ids from native responses (native shapes nest under result.*)
400
+ # Tolerant extraction of ids from native responses (native shapes
401
+ # nest under result.*); shared atom, same semantics as Dispatcher.
400
402
  def find_value(json, keys)
401
- case json
402
- when Hash
403
- keys.each { |key| return json[key] if json[key].is_a?(String) && !json[key].empty? }
404
- json.each_value do |value|
405
- found = find_value(value, keys)
406
- return found if found
407
- end
408
- nil
409
- when Array
410
- json.each do |value|
411
- found = find_value(value, keys)
412
- return found if found
413
- end
414
- nil
415
- end
403
+ Atoms::JsonFind.find_value(json, keys)
416
404
  end
417
405
 
418
406
  # --- send: validation -------------------------------------------------
@@ -81,6 +81,7 @@ module Ace
81
81
 
82
82
  Molecules::DeliveryRecordStore.with_lock(@deliveries_dir, event_id) do
83
83
  record = Molecules::DeliveryRecordStore.load(@deliveries_dir, event_id)
84
+ raise ValidationError, "event belongs to the agent inbox" if record&.inbox
84
85
  validate_existing_record(record, ref, digest) if record
85
86
  record ||= Models::DeliveryRecord.new(
86
87
  event_id: event_id, session: ref.session, pane: ref.pane,
@@ -98,6 +99,7 @@ module Ace
98
99
  validate_event_id!(event_id)
99
100
  Molecules::DeliveryRecordStore.with_lock(@deliveries_dir, event_id) do
100
101
  record = Molecules::DeliveryRecordStore.load(@deliveries_dir, event_id)
102
+ raise ValidationError, "event belongs to the agent inbox" if record&.inbox
101
103
  if record.nil? || record.answer.to_s.empty?
102
104
  raise ValidationError,
103
105
  "no recoverable answer stored for event #{event_id.inspect}"