ace-herdr 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7bf428cc5ca891abf1fc4214e10301ab30e50ca66b2cc122f14512152437b0e8
4
- data.tar.gz: f43847a527924ee7f2b2ba7266f47c18f741a04e012aef255cebfb121c05ad1d
3
+ metadata.gz: a3c3149a40374844ef590a17e1ff5908daf08153ebf64f3d82f776efb22985b8
4
+ data.tar.gz: 5c8ba6b8316a3c5f3808a138e7b4e543006107286edefa6d9b5fc12403a975d8
5
5
  SHA512:
6
- metadata.gz: 83223ba775d983ce92b32031a0e13d99d33cf9c95f2e8067816933051f0dfca77388c65241115e67eaf2d101d2c648cc72af134355a582b8b4e523bf4e2799c9
7
- data.tar.gz: 5f8dc827d8cad595d2821442386247c764ac7913c6d8371f19621a7f633db824a271a0791f8556bc9359da821f3fcd8cf6358eb12769befdd4ecbc33d4a25103
6
+ metadata.gz: ffd4b11b53a480492c625a3f43bab4710a732ef0dda2dae9d84bf5caa9a15aba156c4f9484abf47ac0f36c2caed0c8f36947c3788999b8e21a4070c8c3790e21
7
+ data.tar.gz: '03794e613cbce4ea3edcfdc06e8e04bff70d0e75afa8e3cc0755b85eceddbed6f79a2a2f1f245b6171dc140569b63353b9646746d1fdec78df2922d08558ccc5'
@@ -19,6 +19,10 @@ timeouts:
19
19
  # Delivery record root (ADR-029 short-name layout), relative to project root
20
20
  deliveries_dir: .ace-local/herdr/deliveries
21
21
 
22
+ # Absolute path to the supervisor-managed receipt verification key. Configure
23
+ # before enqueue; each event pins this key's fingerprint for reconciliation.
24
+ inbox_receipt_public_key:
25
+
22
26
  # Tidy (spec 8wq.t.1w0): archive delivered records strictly older than this
23
27
  # many days (age = record updated_at); failed/retryable/pending are never
24
28
  # touched
data/CHANGELOG.md CHANGED
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.3.0] - 2026-10-02
11
+
12
+ ### Added
13
+ - Durable `inbox enqueue`, `status`, and `deliver` commands for attempt-linked messages. Enqueue binds the live pane, terminal, agent, and native session; per-event locked claims and saved submission intent prevent duplicate dispatch after a crash. Codex and Pi use exact-session native queues, and idle agents receive a bounded, payload-free Herdr wake after accepted submission.
14
+ - Inbox status reports claim generation, target binding, accepted submission receipt, and uncertainty. `reconcile` accepts an operator or supervisor native-outcome receipt file only when its trusted detached signature and event, attempt, generation, digest, and full bound identity match; missing or mismatched proof returns a machine-readable refusal and keeps the event uncertain.
15
+ - Installed acceptance covers Codex delivery, signed uncertainty reconciliation, and idle Pi delivery; bounded wake tests cover subprocess stalls and structured errors.
16
+
17
+ ### Fixed
18
+ - Post-launch subprocess I/O failures terminate the child process group before surfacing, so a failed run can never leave an orphaned native client behind past the deadline.
19
+ - Post-launch subprocess I/O failures are classified separately from spawn failures, so a native client that already ran keeps its event uncertain instead of being re-queued for a duplicate submission; and a pane that changed agent is classified as target drift before agent-specific validation can mask it.
20
+ - Unreadable or malformed reconciliation receipt files surface as the documented machine-readable refusal while persistence failures still exit as command errors.
21
+ - Orphaned claims recover by proof: a claim saved before any submission intent requeues as retryable, while only intent-carrying claims stay uncertain; every spawn-time failure is classified at the process boundary as proven pre-launch (retryable) across both executors; and the inbox CLI reports persistence failures as command failures instead of receipt refusals.
22
+ - Target identity drift is classified before agent-specific event id rules, so a bound event whose pane changed agent lands in reconcilable uncertainty instead of looping on a pre-send validation error.
23
+ - Native queue bindings require an immutable session id for both Codex and Pi, so an agent restarting under the same name in a reused pane can never inherit an old event; signed replacement targets are validated against the replacement agent's own event-id and payload rules before a supersession re-binds the event.
24
+ - Pane probe responses are shape-checked and exit-checked before use, so malformed or failed `herdr pane get` output becomes a classified retryable error instead of stranding a claimed event or delivering against a failed observation; the Pi identity probe rejects non-object JSON the same way.
25
+ - The pane identity probe runs under a bounded child deadline (process-group kill), so a stalled herdr pane get can no longer hold the per-event inbox lock; a probe timeout classifies as a retryable pre-submission failure. The idle-wake decision rides the first delivered transition (a busy target can never be prompted by a crash-recovery retry), and enqueue rejects payloads that are not valid UTF-8 text with an actionable validation error.
26
+ - Pane observation launch failures are normalized to executor unavailability so a delivery probe can never strand an event in `claimed`; idle-wake retry outcomes persist across restarts; reverse-address files that are not JSON objects fail enqueue with an actionable validation error instead of crashing.
27
+ - Enqueue applies the target agent's native payload limit (Codex argv bound, Pi body bound) before persisting an event, validates recognized agent statuses before submission (undetermined statuses stay retryable pre-send), and shares one Pi event id validator between enqueue and delivery, so events that could never be delivered are rejected up front instead of queuing forever.
28
+ - Idle-agent wakes are persisted as pending before they are attempted and retried by later deliver calls (re-verifying the live target; identity drift demotes the event to uncertain) without ever resubmitting the native payload, so a crash or transient wake failure can no longer leave an accepted message undelivered to an idle agent. Pi targets now enforce the `inb-`/`wnk-` event id constraint at enqueue instead of queuing an event that can never be delivered.
29
+ - Native queue submission and the Pi identity probe enforce their deadline at the child boundary through a shared bounded-process molecule (process-group kill, bounded pipe draining, nonblocking partial stdin writes). A stalled or non-reading native client can no longer hold the per-event inbox lock past the configured timeout the way a cleanup-blocking `Timeout.timeout` around `Open3.capture3` allowed. Codex payloads are size-bounded before launch and a spawn-time `E2BIG` is classified as a proven pre-submission rejection, keeping the event retryable instead of stranding it uncertain.
30
+
10
31
  ## [0.2.0] - 2026-10-02
11
32
 
12
33
  ### Added
data/README.md CHANGED
@@ -26,6 +26,8 @@
26
26
  4. Retryable failures back off on a fixed deterministic schedule; terminal failures are reported and persisted in the delivery record.
27
27
  5. `ace-herdr dispatch`, `wait`, and `close` give agents one-command subagent lifecycle with sensible defaults (same workspace as the caller, label = task id, prompt from file or stdin).
28
28
 
29
+ The `inbox` command records one attempt-linked agent message before native submission. It binds the live terminal, agent session, and configured receipt verification key at enqueue, serializes claims by event ID, and exposes uncertain outcomes through `inbox status`. An uncertain event stays uncertain until `inbox reconcile --receipt FILE` receives a matching, signed operator or supervisor observation of consumption or supersession; missing or mismatched proof returns a JSON refusal without resending. See the [usage guide](docs/usage.md#durable-agent-inbox) for states, key configuration, and receipt shape.
30
+
29
31
  ## Use Cases
30
32
 
31
33
  - Push an HITL answer to the pane that asked, even if its agent was restarted since.
data/docs/usage.md CHANGED
@@ -14,6 +14,7 @@ ace-docs:
14
14
  ## Command Surface
15
15
 
16
16
  - `ace-herdr deliver [OPTIONS]`
17
+ - `ace-herdr inbox enqueue|status|deliver|reconcile [OPTIONS]`
17
18
  - `ace-herdr dispatch [OPTIONS]`
18
19
  - `ace-herdr list [--panes|--tabs|--workspaces] [--workspace ID] [--quiet]`
19
20
  - `ace-herdr send [--cmd TEXT] [--msg TEXT...] [--key NAME...] --pane ID [--quiet]`
@@ -41,6 +42,50 @@ Sends probe for a live agent. Plain panes receive raw text and keys in order; ag
41
42
 
42
43
  Native `agent_blocked` becomes `SendRejectedError`; `agent_prompt_stalled` becomes `SendStalledError` and must not be automatically resent. Native wait timeouts become `WaitTimeoutError`, missing targets become `TargetNotFoundError`, and an unavailable binary or socket becomes `RuntimeUnavailableError`.
43
44
 
45
+ ## Durable agent inbox
46
+
47
+ Use a stable event ID for one message and the assignment attempt that owns it. The reverse-address JSON contains `session` and `pane`, as with `deliver`.
48
+
49
+ ```bash
50
+ ace-herdr inbox enqueue --event inb-12345678 --attempt ATTEMPT --ref ref.json --file prompt.txt
51
+ ace-herdr inbox status --event inb-12345678 --format json
52
+ ace-herdr inbox deliver --event inb-12345678
53
+ ```
54
+
55
+ `enqueue` checks the live pane, durable terminal, agent kind, and native thread identity. Repeating the same event, attempt, target, and payload returns its existing record; a changed value is an error. `deliver` claims the record once. Idle and busy Codex or Pi agents receive an exact-session native queue submission. After an accepted idle submission, Herdr sends a generic wake prompt without the inbox payload. A pane replacement can receive at most that generic wake, never the payload. The JSON result includes `state`, `claim_generation`, `binding`, `last_error`, and any submission receipt.
56
+
57
+ `queued` can be retried after a proven pre-submission rejection. `delivered` means native submission was accepted; it does not mean the agent consumed the message. A crash after claim, changed target identity, or a native result without a verified acceptance receipt is `uncertain`; another `deliver` call does not resend it. A `completed` record has a positively verified consumption receipt. A superseded uncertain record can return to `queued` only after positive nonconsumption proof.
58
+
59
+ ```bash
60
+ ace-herdr inbox reconcile --event inb-12345678 --receipt proof.json
61
+ ```
62
+
63
+ The receipt is a file supplied from an operator or supervisor observation of the native outcome. Codex and Pi expose submission but no queryable consumption or eviction proof, so `reconcile` never polls the native queue or infers an outcome from age. Before enqueue, the supervisor sets `inbox_receipt_public_key` in `.ace/herdr/config.yml` to the absolute path of its trusted RSA public key. Each event pins that key's fingerprint; a later process cannot substitute a different key. Protect this configuration from delivery requesters. The receipt file must have a detached SHA-256 RSA signature at `FILE.sig`. The private key stays with the operator or supervisor. Its JSON must include the recorded event, attempt, claim generation, payload digest, and complete binding, plus an outcome (`consumed` or `superseded`), an identified observer, and a native observation reference:
64
+
65
+ ```json
66
+ {
67
+ "event_id": "inb-12345678",
68
+ "attempt_id": "ATTEMPT",
69
+ "claim_generation": 1,
70
+ "payload_sha256": "<digest from inbox status>",
71
+ "binding": {"session": "<exact binding from inbox status>"},
72
+ "outcome": "consumed",
73
+ "observer": {"role": "operator", "id": "operator-id"},
74
+ "evidence": {"kind": "consumed_acknowledged", "native_reference": "session-log:42", "observation": "message consumed and acknowledged"}
75
+ }
76
+ ```
77
+
78
+ Copy the **entire** `binding` object from `inbox status`; the shortened object above only illustrates the field. `consumed` requires `evidence.kind: consumed_acknowledged`. `superseded` requires `queue_evicted`, `queue_expired`, or `thread_replaced`, with an observation that the old queue entry cannot be consumed. A matching receipt changes `uncertain` to `completed` for consumption, or to `queued` for proven supersession. The original target binding stays pinned. To authorize a replacement native session, include `replacement_target` in the signed receipt with the complete target identity from a fresh Herdr pane observation; reconciliation verifies its session, pane, terminal, agent, and thread against that live replacement address before saving it. Without that field, a new claim can only use the original target. A missing or mismatched receipt returns JSON with unchanged `state: uncertain` and `reconciliation_refusal`; it does not resend. Keep the observation record with the receipt. Signature validation authenticates the configured key; the operator or supervisor remains responsible for checking the cited native outcome before signing.
79
+
80
+ After inspecting the native outcome and writing `proof.json`, the trusted operator signs the exact bytes:
81
+
82
+ ```bash
83
+ openssl dgst -sha256 -sign operator-private.pem -out proof.json.sig proof.json
84
+ ace-herdr inbox reconcile --event inb-12345678 --receipt proof.json
85
+ ```
86
+
87
+ The command verifies the detached signature against the configured public key before any transition. Missing, malformed, or self-signed receipts leave the event uncertain and return a machine-readable refusal. Keep the signed receipt and source observation for audit.
88
+
44
89
  ## tmux-intent ↔ herdr-command parity
45
90
 
46
91
  Every common terminal-control intent available through `ace-tmux` is available through `ace-herdr` (herdr 0.9.1). Parity is of INTENT and FLAG VOCABULARY, not byte-format: ace-herdr keeps one-line JSON where ace-tmux renders human tables.
data/exe/ace-herdr CHANGED
@@ -1,7 +1,6 @@
1
1
  #!/usr/bin/env ruby
2
2
  # frozen_string_literal: true
3
3
 
4
- require "bundler/setup"
5
4
  require "ace/herdr"
6
5
 
7
6
  # No args -> show help
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "openssl"
5
+ require "ace/support/cli"
6
+ require_relative "support"
7
+
8
+ module Ace
9
+ module Herdr
10
+ module CLI
11
+ module Commands
12
+ class Inbox < Ace::Support::Cli::Command
13
+ include Ace::Support::Cli::Base
14
+ include Runtime
15
+
16
+ desc "Enqueue, inspect, deliver, or reconcile a durable agent inbox event"
17
+ argument :operation, desc: "enqueue, status, deliver, or reconcile"
18
+ option :event, type: :string, desc: "Stable event ID"
19
+ option :attempt, type: :string, desc: "Assignment attempt ID"
20
+ option :ref, type: :string, desc: "Herdr reverse-address JSON file"
21
+ option :file, type: :string, desc: "Payload file"
22
+ option :receipt, type: :string, desc: "Native proof JSON file"
23
+ option :format, type: :string, desc: "Output format (json)"
24
+
25
+ def initialize(executor: nil, native: nil)
26
+ @executor = executor
27
+ @native = native
28
+ end
29
+
30
+ def call(operation: nil, **options)
31
+ inbox = nil
32
+ translate_errors do
33
+ cli_error("--event is required") if options[:event].to_s.empty?
34
+ cli_error("only --format json is supported") if options[:format] && options[:format] != "json"
35
+ inbox = Organisms::Inbox.new(executor: executor,
36
+ native: @native || Molecules::NativeQueueExecutor.new,
37
+ deliveries_dir: deliveries_dir,
38
+ receipt_public_key: %w[enqueue reconcile].include?(operation) ? receipt_public_key : nil)
39
+ result = case operation
40
+ when "enqueue"
41
+ %i[attempt ref file].each { |key| cli_error("--#{key} is required") if options[key].to_s.empty? }
42
+ inbox.enqueue(event: options[:event], attempt: options[:attempt],
43
+ ref: options[:ref], payload: File.binread(options[:file]))
44
+ when "status"
45
+ inbox.status(event: options[:event])
46
+ when "deliver"
47
+ inbox.deliver(event: options[:event])
48
+ when "reconcile"
49
+ path = options[:receipt]
50
+ begin
51
+ bytes = path.to_s.empty? ? nil : File.binread(path)
52
+ receipt = bytes && JSON.parse(bytes)
53
+ signature = bytes && File.binread("#{path}.sig")
54
+ rescue SystemCallError, JSON::ParserError => e
55
+ # Receipt input is caller-owned: unreadable or malformed
56
+ # proof files surface as the documented refusal, never as
57
+ # command failures.
58
+ puts JSON.generate(inbox.status(event: options[:event]).merge(
59
+ "reconciliation_refusal" => "invalid receipt: #{e.message}"))
60
+ return
61
+ end
62
+ inbox.reconcile(event: options[:event], receipt: receipt,
63
+ signed_bytes: bytes, signature: signature)
64
+ else
65
+ cli_error("operation must be enqueue, status, deliver, or reconcile")
66
+ end
67
+ puts JSON.generate(result)
68
+ end
69
+ rescue Errno::ENOENT, JSON::ParserError => e
70
+ raise Ace::Support::Cli::Error, e.message
71
+ end
72
+
73
+ private
74
+
75
+ def receipt_public_key
76
+ path = config["inbox_receipt_public_key"]
77
+ return nil unless path.is_a?(String) && path.start_with?("/")
78
+
79
+ key = OpenSSL::PKey.read(File.read(path))
80
+ key if key.is_a?(OpenSSL::PKey::RSA) && !key.private?
81
+ rescue SystemCallError, OpenSSL::PKey::PKeyError
82
+ nil
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
data/lib/ace/herdr/cli.rb CHANGED
@@ -4,6 +4,7 @@ require "ace/support/cli"
4
4
  require "ace/core"
5
5
  require_relative "../herdr"
6
6
  require_relative "cli/commands/deliver"
7
+ require_relative "cli/commands/inbox"
7
8
  require_relative "cli/commands/dispatch"
8
9
  require_relative "cli/commands/wait"
9
10
  require_relative "cli/commands/close"
@@ -26,6 +27,7 @@ module Ace
26
27
  # Application commands with descriptions (for help output)
27
28
  REGISTERED_COMMANDS = [
28
29
  ["deliver", "Push an answer to an agent pane (ace-hitl delivery contract)"],
30
+ ["inbox", "Durable agent message queue and reconciliation"],
29
31
  ["dispatch", "Start an agent in one command: tab + agent + prompt"],
30
32
  ["list", "List live panes, tabs, or workspaces as one JSON line"],
31
33
  ["send", "Send a command, raw text, or named keys to a pane"],
@@ -40,6 +42,7 @@ module Ace
40
42
 
41
43
  HELP_EXAMPLES = [
42
44
  "ace-herdr deliver --session ws-1 --pane p5 --event-id evt-1 --answer-file answer.md",
45
+ "ace-herdr inbox enqueue --event evt-1 --attempt att-1 --ref ref.json --file prompt.txt",
43
46
  "echo 'the answer' | ace-herdr deliver --pane p5",
44
47
  "ace-herdr dispatch --label 8wm.t.vs0 --kind pi --prompt-file prompt.md",
45
48
  "ace-herdr list --workspace w1",
@@ -64,6 +67,7 @@ module Ace
64
67
 
65
68
  # Register commands
66
69
  register "deliver", CLI::Commands::Deliver.new
70
+ register "inbox", CLI::Commands::Inbox.new
67
71
  register "dispatch", CLI::Commands::Dispatch.new
68
72
  register "list", CLI::Commands::List.new
69
73
  register "send", CLI::Commands::Send.new
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
+ require "digest"
4
5
 
5
6
  module Ace
6
7
  module Herdr
@@ -17,14 +18,23 @@ module Ace
17
18
  # before the prompt never loses the content; it can be re-delivered
18
19
  # via resume. Record files are 0600 (answers may be sensitive).
19
20
  class DeliveryRecord
20
- STATES = %w[pending delivered retryable failed].freeze
21
+ STATES = %w[pending delivered retryable failed queued claimed uncertain completed].freeze
21
22
 
22
23
  attr_reader :event_id, :session, :pane, :answer_digest, :answer,
23
- :state, :attempts, :history, :created_at, :updated_at
24
+ :state, :attempts, :history, :created_at, :updated_at, :inbox
24
25
 
25
26
  def initialize(event_id:, session:, pane:, answer_digest:, answer: nil,
26
- state: "pending", attempts: 0, history: [], created_at: nil, updated_at: nil)
27
+ state: "pending", attempts: 0, history: [], created_at: nil, updated_at: nil, inbox: nil)
27
28
  raise ArgumentError, "unknown state: #{state}" unless STATES.include?(state)
29
+ if inbox
30
+ unless inbox.is_a?(Hash) && inbox["attempt_id"].is_a?(String) &&
31
+ inbox["claim_generation"].is_a?(Integer) && inbox["claim_generation"] >= 0 &&
32
+ answer.is_a?(String) && Digest::SHA256.hexdigest(answer) == answer_digest
33
+ raise ArgumentError, "invalid inbox record"
34
+ end
35
+ elsif %w[queued claimed uncertain completed].include?(state)
36
+ raise ArgumentError, "inbox state requires inbox metadata"
37
+ end
28
38
 
29
39
  @event_id = event_id
30
40
  @session = session
@@ -36,6 +46,7 @@ module Ace
36
46
  @history = history.dup.freeze
37
47
  @created_at = created_at
38
48
  @updated_at = updated_at
49
+ @inbox = inbox && deep_freeze(JSON.parse(JSON.generate(inbox)))
39
50
  freeze
40
51
  end
41
52
 
@@ -45,7 +56,7 @@ module Ace
45
56
  answer_digest: hash["answer_digest"], answer: hash["answer"],
46
57
  state: hash["state"] || "pending",
47
58
  attempts: hash["attempts"] || 0, history: hash["history"] || [],
48
- created_at: hash["created_at"], updated_at: hash["updated_at"]
59
+ created_at: hash["created_at"], updated_at: hash["updated_at"], inbox: hash["inbox"]
49
60
  )
50
61
  end
51
62
 
@@ -59,7 +70,8 @@ module Ace
59
70
  "answer_digest" => answer_digest, "answer" => answer,
60
71
  "state" => state,
61
72
  "attempts" => attempts, "history" => history,
62
- "created_at" => created_at, "updated_at" => updated_at
73
+ "created_at" => created_at, "updated_at" => updated_at,
74
+ "inbox" => inbox
63
75
  }
64
76
  end
65
77
 
@@ -84,7 +96,7 @@ module Ace
84
96
  state: state || self.state,
85
97
  attempts: attempts,
86
98
  history: history + [detail.merge("at" => timestamp)],
87
- created_at: created_at || timestamp, updated_at: timestamp
99
+ created_at: created_at || timestamp, updated_at: timestamp, inbox: inbox
88
100
  )
89
101
  end
90
102
 
@@ -100,9 +112,32 @@ module Ace
100
112
  state: state,
101
113
  attempts: attempts + 1,
102
114
  history: appended.history,
103
- created_at: appended.created_at, updated_at: appended.updated_at
115
+ created_at: appended.created_at, updated_at: appended.updated_at, inbox: inbox
104
116
  )
105
117
  end
118
+
119
+ def advance_inbox(state:, inbox:, detail:, timestamp:)
120
+ self.class.new(
121
+ event_id: event_id, session: session, pane: pane,
122
+ answer_digest: answer_digest, answer: answer,
123
+ state: state, attempts: attempts + (detail["action"] == "claim" ? 1 : 0),
124
+ history: history + [detail.merge("at" => timestamp)],
125
+ created_at: created_at || timestamp, updated_at: timestamp,
126
+ inbox: inbox
127
+ )
128
+ end
129
+
130
+ private
131
+
132
+ def deep_freeze(value)
133
+ case value
134
+ when Hash
135
+ value.each { |key, item| deep_freeze(key); deep_freeze(item) }
136
+ when Array
137
+ value.each { |item| deep_freeze(item) }
138
+ end
139
+ value.freeze
140
+ end
106
141
  end
107
142
  end
108
143
  end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "timeout"
5
+
6
+ module Ace
7
+ module Herdr
8
+ module Molecules
9
+ # Runs a child process in its own process group with a real deadline.
10
+ # Pipes drain with bounded memory and a stalled child is killed at the
11
+ # deadline, so the caller never blocks in cleanup past the timeout the
12
+ # way an Open3.capture3 + Timeout.timeout combination can.
13
+ module BoundedProcess
14
+ # Immutable outcome of one bounded child run.
15
+ Result = Struct.new(:stdout, :stderr, :status, :oversized)
16
+
17
+ # A SystemCallError raised while the child was already running (pipe
18
+ # I/O after launch). Distinct from spawn failures: the child exists,
19
+ # so the caller cannot classify the run as pre-launch.
20
+ class PostLaunchError < StandardError
21
+ def initialize(message)
22
+ super("post-launch process failure: #{message}")
23
+ end
24
+ end
25
+
26
+ module_function
27
+
28
+ # @param argv [Array<String>] child argv (no shell)
29
+ # @param stdin_data [String] payload written to the child's stdin
30
+ # @param timeout_s [Numeric] wall-clock deadline for the whole run
31
+ # @param output_limit [Integer] per-stream retained byte cap
32
+ # @return [Result] stdout/stderr are capped; oversized reports truncation
33
+ # @raise [Timeout::Error] when the child outlives the deadline (killed)
34
+ # @raise [SystemCallError] spawn failures only (child never launched)
35
+ # @raise [PostLaunchError] SystemCallError while the child was live
36
+ def call(argv, stdin_data: "", timeout_s:, output_limit: 65_536)
37
+ Open3.popen3(*argv, pgroup: true) do |stdin, stdout, stderr, waiter|
38
+ begin
39
+ run_loop(stdin, stdout, stderr, waiter,
40
+ stdin_data: stdin_data, timeout_s: timeout_s, output_limit: output_limit)
41
+ rescue SystemCallError => e
42
+ # The child was already spawned: an I/O failure now cannot be
43
+ # rewound into a pre-launch classification, and the child must
44
+ # not outlive the failure inside popen3's cleanup wait.
45
+ kill_group(waiter)
46
+ waiter.join
47
+ raise PostLaunchError, e.message
48
+ end
49
+ end
50
+ end
51
+
52
+ def run_loop(stdin, stdout, stderr, waiter, stdin_data:, timeout_s:, output_limit:)
53
+ payload = stdin_data.to_s
54
+ sent = 0
55
+ stdin_closed = payload.empty?
56
+ stdin.close if stdin_closed
57
+ buffers = {stdout => +"", stderr => +""}
58
+ streams = [stdout, stderr]
59
+ oversized = false
60
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_s
61
+ buffers = {stdout => +"", stderr => +""}
62
+ streams = [stdout, stderr]
63
+ oversized = false
64
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_s
65
+
66
+ until streams.empty? && stdin_closed && waiter.join(0)
67
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
68
+ if remaining <= 0
69
+ kill_group(waiter)
70
+ raise Timeout::Error, "process did not finish within #{timeout_s}s"
71
+ end
72
+
73
+ ready = IO.select(streams, stdin_closed ? [] : [stdin], nil, [remaining, 0.02].min)
74
+ next unless ready
75
+
76
+ ready.first.each do |io|
77
+ chunk = io.read_nonblock(4096, exception: false)
78
+ if chunk.nil?
79
+ io.close
80
+ streams.delete(io)
81
+ elsif chunk != :wait_readable
82
+ buffer = buffers.fetch(io)
83
+ available = output_limit - buffer.bytesize
84
+ oversized = true if chunk.bytesize > available
85
+ buffer << chunk.byteslice(0, available) if available.positive?
86
+ end
87
+ end
88
+
89
+ next if stdin_closed || !ready[1].to_a.include?(stdin)
90
+
91
+ begin
92
+ # Nonblocking with partial writes: a child that stops reading
93
+ # must never trap us inside one large stdin.write; the loop
94
+ # re-checks the deadline between write attempts.
95
+ written = stdin.write_nonblock(payload.byteslice(sent, payload.bytesize - sent),
96
+ exception: false)
97
+ sent += written if written != :wait_writable
98
+ rescue Errno::EPIPE, IOError
99
+ sent = payload.bytesize
100
+ end
101
+ if sent >= payload.bytesize
102
+ stdin.close
103
+ stdin_closed = true
104
+ end
105
+ end
106
+
107
+ Result.new(buffers.fetch(stdout), buffers.fetch(stderr), waiter.value, oversized)
108
+ end
109
+
110
+ def kill_group(waiter)
111
+ Process.kill("KILL", -waiter.pid)
112
+ rescue Errno::ESRCH
113
+ nil
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -46,8 +46,11 @@ module Ace
46
46
  tmp = "#{path}.tmp.#{Process.pid}"
47
47
  File.open(tmp, IO::CREAT | IO::TRUNC | IO::WRONLY, 0o600) do |file|
48
48
  file.write(JSON.generate(record.to_h))
49
+ file.flush
50
+ file.fsync
49
51
  end
50
52
  File.rename(tmp, path)
53
+ File.open(deliveries_dir) { |dir| dir.fsync }
51
54
  path
52
55
  end
53
56
 
@@ -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,8 @@ 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.
63
118
  def pane_get(pane)
64
119
  run!([@binary, "pane", "get", pane])
65
120
  end
@@ -197,8 +252,8 @@ module Ace
197
252
  stdout: stdout.strip, stderr: stderr.strip,
198
253
  success: status.success?, exit_code: status.exitstatus || -1
199
254
  )
200
- rescue Errno::ENOENT
201
- raise ExecutorUnavailableError, "herdr CLI not found on PATH: #{@binary}"
255
+ rescue SystemCallError
256
+ raise ExecutorUnavailableError, "herdr CLI not found or not executable: #{@binary}"
202
257
  end
203
258
 
204
259
  def run_raw_stdout(cmd)
@@ -207,8 +262,8 @@ module Ace
207
262
  stdout: stdout, stderr: stderr.strip,
208
263
  success: status.success?, exit_code: status.exitstatus || -1
209
264
  )
210
- rescue Errno::ENOENT
211
- raise ExecutorUnavailableError, "herdr CLI not found on PATH: #{@binary}"
265
+ rescue SystemCallError
266
+ raise ExecutorUnavailableError, "herdr CLI not found or not executable: #{@binary}"
212
267
  end
213
268
 
214
269
  # Map a failed result to a typed error from herdr's error codes