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.
- checksums.yaml +4 -4
- data/.ace-defaults/herdr/config.yml +4 -0
- data/CHANGELOG.md +28 -0
- data/README.md +3 -0
- data/docs/usage.md +57 -2
- data/exe/ace-herdr +0 -1
- data/lib/ace/herdr/atoms/json_find.rb +46 -0
- data/lib/ace/herdr/cli/commands/inbox.rb +88 -0
- data/lib/ace/herdr/cli.rb +4 -0
- data/lib/ace/herdr/errors.rb +3 -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 +96 -7
- data/lib/ace/herdr/molecules/native_queue_executor.rb +120 -0
- data/lib/ace/herdr/organisms/control_surface.rb +20 -32
- data/lib/ace/herdr/organisms/deliverer.rb +2 -0
- data/lib/ace/herdr/organisms/inbox.rb +484 -0
- data/lib/ace/herdr/organisms/runtime_adapter.rb +555 -0
- data/lib/ace/herdr/organisms/tidy.rb +2 -2
- data/lib/ace/herdr/version.rb +1 -1
- data/lib/ace/herdr.rb +17 -0
- data/lib/ace/runtime/adapters/herdr.rb +6 -0
- metadata +26 -5
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
|
@@ -5,6 +5,34 @@ All notable changes to `ace-herdr` will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
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
|
+
|
|
31
|
+
## [0.2.0] - 2026-10-02
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
- `Ace::Herdr::Organisms::RuntimeAdapter` and the lazy `ace/runtime/adapters/herdr` entrypoint implement the shared ace-runtime intent contract. It preserves native agent prompt and wait semantics, polls all four lifecycle observations, and maps Herdr failures to contract errors. Native tab and pane probes support context, focus, and retained pane preparation. The packaged shared adapter contract suite runs against a Herdr-shaped fake executor.
|
|
35
|
+
|
|
8
36
|
## [0.1.0] - 2026-09-27
|
|
9
37
|
|
|
10
38
|
### Added
|
data/README.md
CHANGED
|
@@ -26,12 +26,15 @@
|
|
|
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.
|
|
32
34
|
- Dispatch a subagent in one command with deterministic defaults and no LLM decisions.
|
|
33
35
|
- Wait for agent readiness or completion without scraping pane noise.
|
|
34
36
|
- Close out finished agent panes (rename/close) and return their result.
|
|
37
|
+
- Use Herdr through the shared `ace-runtime` Ruby intent API, with workspace/session and tab/window mapping handled by the adapter.
|
|
35
38
|
|
|
36
39
|
## Installation
|
|
37
40
|
|
data/docs/usage.md
CHANGED
|
@@ -3,8 +3,8 @@ doc-type: user
|
|
|
3
3
|
title: ace-herdr Usage
|
|
4
4
|
purpose: Full CLI and configuration reference for ace-herdr: push delivery, agent bootstrap, the terminal-control surface (list, send, capture, wait, presets), and tidy cleanup.
|
|
5
5
|
ace-docs:
|
|
6
|
-
last-updated: 2026-
|
|
7
|
-
last-checked: 2026-
|
|
6
|
+
last-updated: 2026-10-02
|
|
7
|
+
last-checked: 2026-10-02
|
|
8
8
|
---
|
|
9
9
|
|
|
10
10
|
# Usage
|
|
@@ -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]`
|
|
@@ -31,6 +32,60 @@ Control commands print exactly one deterministic JSON line on stdout; `--quiet`
|
|
|
31
32
|
|
|
32
33
|
herdr *sessions* (server persistence) are intentionally not exposed; the tmux session analogue is the herdr **workspace**.
|
|
33
34
|
|
|
35
|
+
## ace-runtime adapter
|
|
36
|
+
|
|
37
|
+
Install `ace-herdr` alongside `ace-runtime`, then resolve the adapter with `Ace::Runtime.resolve("herdr")`. The contract's `session` is a Herdr **workspace**, `window` is a **tab**, and `pane` is an opaque Herdr pane ID. The adapter does not construct pane IDs. It locates the caller through `HERDR_SESSION` and `HERDR_PANE` and reads the explicit pane with `pane get`; the pane's native workspace is authoritative and a conflicting `HERDR_WORKSPACE_ID` hint is rejected instead of honored. Outside Herdr, `context` reports `in_runtime: false`; operations requiring a caller workspace raise `Ace::Runtime::RuntimeUnavailableError`.
|
|
38
|
+
|
|
39
|
+
The adapter implements `context`, `ensure_window`, `prepare_pane`, `focus`, ordered `send` and its convenience methods, `capture`, `wait_output`, `wait_agent`, `wait_lifecycle`, `close_window`, `list_windows`, and `list_panes`. `ensure_window` scopes the sanitized tab label to the caller workspace. It records the tab's root, preset, and prepared pane under `~/.ace/local/herdr/runtime-tabs/` — keyed by workspace and label, so adapter instances from any working directory or requested root share one record, create through the same lock, and serialize prepared-pane creation through it. Later instances verify an idempotent request and reject a conflicting one; a tab whose ownership cannot be proven is never closed or replaced (a failed create rolls back only tabs the same attempt created). `prepare_pane` splits a retained shell target and returns the native pane ID after verifying it exists and has a shell process.
|
|
40
|
+
|
|
41
|
+
Sends probe for a live agent. Plain panes receive raw text and keys in order; agent panes receive one self-submitting `agent prompt`, with one trailing Enter dropped and reported. Agent waits use native `agent wait` states (`idle`, `working`, `blocked`, `done`). Contract timeout values are seconds and are converted to Herdr milliseconds. Lifecycle waits observe `tab get`, focused tab state, `pane get`, and `pane process-info`; a missing pane keeps `pane-exists` waiting but immediately satisfies `pane-exited`. A pane with only its retained shell also satisfies `pane-exited` because no submitted foreground command remains. This observation does not prove assignment success or authorize cleanup.
|
|
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`.
|
|
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
|
+
|
|
34
89
|
## tmux-intent ↔ herdr-command parity
|
|
35
90
|
|
|
36
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,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Herdr
|
|
5
|
+
module Atoms
|
|
6
|
+
# Shared recursive extraction from herdr's native JSON responses
|
|
7
|
+
# (ids nest under result.*; native shapes vary slightly between
|
|
8
|
+
# commands). Pure functions — no side effects.
|
|
9
|
+
module JsonFind
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Walk a path of hash keys; nil unless every hop is a Hash. A hop
|
|
13
|
+
# resolves only to a non-empty String.
|
|
14
|
+
def dig_value(json, path)
|
|
15
|
+
current = json
|
|
16
|
+
path.each do |key|
|
|
17
|
+
return nil unless current.is_a?(Hash)
|
|
18
|
+
|
|
19
|
+
current = current[key]
|
|
20
|
+
end
|
|
21
|
+
current.is_a?(String) && !current.empty? ? current : nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Depth-first search for the first non-empty String under any of
|
|
25
|
+
# the given keys (tolerant extraction of ids from native shapes).
|
|
26
|
+
def find_value(json, keys)
|
|
27
|
+
case json
|
|
28
|
+
when Hash
|
|
29
|
+
keys.each { |key| return json[key] if json[key].is_a?(String) && !json[key].empty? }
|
|
30
|
+
json.each_value do |value|
|
|
31
|
+
found = find_value(value, keys)
|
|
32
|
+
return found if found
|
|
33
|
+
end
|
|
34
|
+
nil
|
|
35
|
+
when Array
|
|
36
|
+
json.each do |value|
|
|
37
|
+
found = find_value(value, keys)
|
|
38
|
+
return found if found
|
|
39
|
+
end
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -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
|
data/lib/ace/herdr/errors.rb
CHANGED
|
@@ -22,6 +22,9 @@ module Ace
|
|
|
22
22
|
end
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
+
# Native timeout is distinct from a prompt accepted but not observed.
|
|
26
|
+
class ExecutorTimeoutError < AgentNotReadyError; end
|
|
27
|
+
|
|
25
28
|
# Target pane does not exist (pane_not_found)
|
|
26
29
|
class PaneNotFoundError < ExecutorError; end
|
|
27
30
|
|
|
@@ -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
|
|