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 +4 -4
- data/.ace-defaults/herdr/config.yml +4 -0
- data/CHANGELOG.md +21 -0
- data/README.md +2 -0
- data/docs/usage.md +45 -0
- data/exe/ace-herdr +0 -1
- data/lib/ace/herdr/cli/commands/inbox.rb +88 -0
- data/lib/ace/herdr/cli.rb +4 -0
- data/lib/ace/herdr/models/delivery_record.rb +42 -7
- data/lib/ace/herdr/molecules/bounded_process.rb +118 -0
- data/lib/ace/herdr/molecules/delivery_record_store.rb +3 -0
- data/lib/ace/herdr/molecules/herdr_executor.rb +59 -4
- data/lib/ace/herdr/molecules/native_queue_executor.rb +120 -0
- data/lib/ace/herdr/organisms/deliverer.rb +2 -0
- data/lib/ace/herdr/organisms/inbox.rb +484 -0
- data/lib/ace/herdr/organisms/tidy.rb +2 -2
- data/lib/ace/herdr/version.rb +1 -1
- data/lib/ace/herdr.rb +3 -0
- metadata +8 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a3c3149a40374844ef590a17e1ff5908daf08153ebf64f3d82f776efb22985b8
|
|
4
|
+
data.tar.gz: 5c8ba6b8316a3c5f3808a138e7b4e543006107286edefa6d9b5fc12403a975d8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
@@ -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
|
|
201
|
-
raise ExecutorUnavailableError, "herdr CLI not found
|
|
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
|
|
211
|
-
raise ExecutorUnavailableError, "herdr CLI not found
|
|
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
|