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
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "timeout"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Herdr
|
|
8
|
+
module Molecules
|
|
9
|
+
# The provider-native submission boundary. A failed process does not
|
|
10
|
+
# prove that a message was not accepted, so only validation failures
|
|
11
|
+
# raised before this boundary are retryable without reconciliation.
|
|
12
|
+
#
|
|
13
|
+
# Child processes run under BoundedProcess: the deadline is enforced at
|
|
14
|
+
# the child boundary (process-group kill), never via a cleanup-blocking
|
|
15
|
+
# Timeout.timeout around capture3, so a stalled child cannot hold the
|
|
16
|
+
# inbox event lock past the configured timeout.
|
|
17
|
+
class NativeQueueExecutor
|
|
18
|
+
DEFAULT_SUBMIT_TIMEOUT_S = 60
|
|
19
|
+
DEFAULT_IDENTITY_TIMEOUT_S = 20
|
|
20
|
+
# Codex payloads ride in argv; a single argument beyond the OS
|
|
21
|
+
# per-argument limit fails at exec with E2BIG — before any client
|
|
22
|
+
# code ran — so the payload is bounded pre-launch and any E2BIG
|
|
23
|
+
# escape is classified as a proven pre-submission rejection.
|
|
24
|
+
MAX_ARG_PAYLOAD_BYTES = 65_536
|
|
25
|
+
PI_PAYLOAD_LIMIT_BYTES = 65_536
|
|
26
|
+
|
|
27
|
+
# runner: test seam. Callable (argv, stdin_data:, timeout_s:) ->
|
|
28
|
+
# [stdout, stderr, status]. Defaults to the bounded runner.
|
|
29
|
+
def initialize(codex: "codex", pi_client: nil, runner: nil,
|
|
30
|
+
submit_timeout_s: DEFAULT_SUBMIT_TIMEOUT_S,
|
|
31
|
+
identity_timeout_s: DEFAULT_IDENTITY_TIMEOUT_S)
|
|
32
|
+
@codex = codex
|
|
33
|
+
@pi_client = pi_client || ENV["ACE_HERDR_PI_QUEUE_CLIENT"] || "pi-overseer-queue-client"
|
|
34
|
+
@runner = runner
|
|
35
|
+
@submit_timeout_s = submit_timeout_s
|
|
36
|
+
@identity_timeout_s = identity_timeout_s
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def pi_identity
|
|
40
|
+
result = execute([@pi_client, "--identity"], stdin_data: "",
|
|
41
|
+
timeout_s: @identity_timeout_s)
|
|
42
|
+
raise ExecutorError, "Pi identity probe failed: #{result.stderr}" unless result.status.success?
|
|
43
|
+
|
|
44
|
+
identity = JSON.parse(result.stdout)
|
|
45
|
+
raise ExecutorError, "Pi identity probe returned invalid JSON" unless identity.is_a?(Hash)
|
|
46
|
+
|
|
47
|
+
identity.fetch("session_id")
|
|
48
|
+
rescue Errno::ENOENT, Errno::EACCES => e
|
|
49
|
+
raise ExecutorUnavailableError, e.message
|
|
50
|
+
rescue JSON::ParserError, KeyError, TypeError
|
|
51
|
+
raise ExecutorError, "Pi identity probe returned invalid JSON"
|
|
52
|
+
rescue Timeout::Error
|
|
53
|
+
raise ExecutorError, "Pi identity probe timed out"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def submit(agent:, thread:, event_id:, digest:, payload:)
|
|
57
|
+
if agent == "pi" && (payload.empty? || payload.bytesize > PI_PAYLOAD_LIMIT_BYTES)
|
|
58
|
+
return {"accepted" => false, "pre_submit" => true, "error" => "Pi payload must be 1..65536 bytes"}
|
|
59
|
+
end
|
|
60
|
+
if agent == "codex" && payload.bytesize > MAX_ARG_PAYLOAD_BYTES
|
|
61
|
+
return {"accepted" => false, "pre_submit" => true,
|
|
62
|
+
"error" => "Codex payload must be at most #{MAX_ARG_PAYLOAD_BYTES} bytes for argv delivery"}
|
|
63
|
+
end
|
|
64
|
+
argv = if agent == "codex"
|
|
65
|
+
[@codex, "queue", "--thread", thread, "--message", payload]
|
|
66
|
+
elsif agent == "pi"
|
|
67
|
+
[@pi_client, "--delivery", event_id, "--session-id", thread,
|
|
68
|
+
"--payload-sha256", digest, "--payload-bytes", payload.bytesize.to_s]
|
|
69
|
+
else
|
|
70
|
+
raise ValidationError, "unsupported native queue agent: #{agent}"
|
|
71
|
+
end
|
|
72
|
+
result = execute(argv, stdin_data: agent == "pi" ? payload : "",
|
|
73
|
+
timeout_s: @submit_timeout_s)
|
|
74
|
+
raise ExecutorError, "native queue output exceeded the retained limit" if result.oversized
|
|
75
|
+
unless result.status.success?
|
|
76
|
+
return {"accepted" => false, "exit_code" => result.status.exitstatus,
|
|
77
|
+
"error" => result.stderr.strip}
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
if agent == "pi"
|
|
81
|
+
receipt = JSON.parse(result.stdout)
|
|
82
|
+
unless receipt.is_a?(Hash) && receipt["ok"] == true && receipt["id"] == event_id &&
|
|
83
|
+
receipt["session_id"] == thread && receipt["payload_sha256"] == digest
|
|
84
|
+
return {"accepted" => false, "error" => "Pi queue receipt identity or digest mismatch"}
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
{"accepted" => true, "exit_code" => result.status.exitstatus, "stdout" => result.stdout.strip}
|
|
88
|
+
rescue Errno::ENOENT, Errno::EACCES, Errno::E2BIG, ExecutorUnavailableError => e
|
|
89
|
+
# The executable never launched (or exec rejected the argv). No
|
|
90
|
+
# submission could have occurred, so this stays retryable.
|
|
91
|
+
{"accepted" => false, "pre_submit" => true, "error" => e.message}
|
|
92
|
+
rescue JSON::ParserError
|
|
93
|
+
{"accepted" => false, "error" => "Pi queue returned invalid receipt JSON"}
|
|
94
|
+
rescue Timeout::Error
|
|
95
|
+
{"accepted" => false, "error" => "native queue timed out after submission may have begun"}
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def execute(argv, stdin_data:, timeout_s:)
|
|
101
|
+
if @runner
|
|
102
|
+
stdout, stderr, status = @runner.call(argv, stdin_data: stdin_data, timeout_s: timeout_s)
|
|
103
|
+
return BoundedProcess::Result.new(stdout, stderr, status, false)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
BoundedProcess.call(argv, stdin_data: stdin_data, timeout_s: timeout_s)
|
|
107
|
+
rescue SystemCallError => e
|
|
108
|
+
# popen3 raises spawn failures before the child exists, so no
|
|
109
|
+
# submission can have begun: proven pre-launch, retryable.
|
|
110
|
+
# (Post-launch I/O failures arrive as PostLaunchError instead.)
|
|
111
|
+
raise ExecutorUnavailableError, e.message
|
|
112
|
+
rescue BoundedProcess::PostLaunchError => e
|
|
113
|
+
# The child was live, so the message may already have been accepted:
|
|
114
|
+
# keep this uncertain, never pre-submit retryable.
|
|
115
|
+
raise ExecutorError, e.message
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -81,6 +81,7 @@ module Ace
|
|
|
81
81
|
|
|
82
82
|
Molecules::DeliveryRecordStore.with_lock(@deliveries_dir, event_id) do
|
|
83
83
|
record = Molecules::DeliveryRecordStore.load(@deliveries_dir, event_id)
|
|
84
|
+
raise ValidationError, "event belongs to the agent inbox" if record&.inbox
|
|
84
85
|
validate_existing_record(record, ref, digest) if record
|
|
85
86
|
record ||= Models::DeliveryRecord.new(
|
|
86
87
|
event_id: event_id, session: ref.session, pane: ref.pane,
|
|
@@ -98,6 +99,7 @@ module Ace
|
|
|
98
99
|
validate_event_id!(event_id)
|
|
99
100
|
Molecules::DeliveryRecordStore.with_lock(@deliveries_dir, event_id) do
|
|
100
101
|
record = Molecules::DeliveryRecordStore.load(@deliveries_dir, event_id)
|
|
102
|
+
raise ValidationError, "event belongs to the agent inbox" if record&.inbox
|
|
101
103
|
if record.nil? || record.answer.to_s.empty?
|
|
102
104
|
raise ValidationError,
|
|
103
105
|
"no recoverable answer stored for event #{event_id.inspect}"
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "openssl"
|
|
8
|
+
require "ace/hitl"
|
|
9
|
+
|
|
10
|
+
module Ace
|
|
11
|
+
module Herdr
|
|
12
|
+
module Organisms
|
|
13
|
+
# Durable event inbox. All transitions for one event are serialized by
|
|
14
|
+
# DeliveryRecordStore; a saved submission intent is never replayed.
|
|
15
|
+
class Inbox
|
|
16
|
+
class IdentityDriftError < ValidationError; end
|
|
17
|
+
|
|
18
|
+
EVENT = /\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/
|
|
19
|
+
THREAD_ID = /\A[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}\z/
|
|
20
|
+
PI_PATH = /_([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})\.jsonl\z/
|
|
21
|
+
# Single validator for Pi queue event ids, shared by enqueue and
|
|
22
|
+
# delivery observation so the two sites can never disagree again.
|
|
23
|
+
PI_EVENT_ID = /\A(?:inb|wnk)-[a-z0-9-]{8,64}\z/
|
|
24
|
+
# Agent statuses herdr reports (pane observations use busy; agent wait
|
|
25
|
+
# also names working/blocked). Anything else — including an explicit
|
|
26
|
+
# "unknown" — is undetermined and stays a retryable pre-submission
|
|
27
|
+
# rejection.
|
|
28
|
+
AGENT_STATUSES = %w[idle busy working blocked done].freeze
|
|
29
|
+
WAKE_STATUSES = %w[idle done].freeze
|
|
30
|
+
|
|
31
|
+
def initialize(executor:, native:, deliveries_dir:, receipt_public_key: nil)
|
|
32
|
+
@executor = executor
|
|
33
|
+
@native = native
|
|
34
|
+
@deliveries_dir = deliveries_dir
|
|
35
|
+
@receipt_public_key = receipt_public_key
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def enqueue(event:, attempt:, ref:, payload:)
|
|
39
|
+
validate_id!(event, "event")
|
|
40
|
+
validate_id!(attempt, "attempt")
|
|
41
|
+
raise ValidationError, "trusted receipt public key is unavailable" unless @receipt_public_key
|
|
42
|
+
payload = payload.to_s
|
|
43
|
+
raise ValidationError, "payload is required" if payload.empty?
|
|
44
|
+
raise ValidationError, "payload contains NUL" if payload.include?("\0")
|
|
45
|
+
# Callers may hand over binary-tagged bytes; persistence is JSON, so
|
|
46
|
+
# the payload must decode as UTF-8 regardless of its source tag.
|
|
47
|
+
payload = payload.dup.force_encoding(Encoding::UTF_8)
|
|
48
|
+
unless payload.valid_encoding?
|
|
49
|
+
raise ValidationError, "payload is not valid UTF-8 text"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
address = address_for(ref)
|
|
53
|
+
digest = Digest::SHA256.hexdigest(payload)
|
|
54
|
+
with_event(event) do |record|
|
|
55
|
+
if record
|
|
56
|
+
validate_match!(record, attempt, address, digest)
|
|
57
|
+
next public_record(record)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
target = observe_target(address.session, address.pane)
|
|
61
|
+
if target["agent"] == "pi" && !PI_EVENT_ID.match?(event)
|
|
62
|
+
raise ValidationError,
|
|
63
|
+
"Pi queue requires an inbox (inb-) or wake (wnk-) event id with at " \
|
|
64
|
+
"least 8 id characters; the id is immutable once enqueued"
|
|
65
|
+
end
|
|
66
|
+
payload_limit = target["agent"] == "pi" ?
|
|
67
|
+
Molecules::NativeQueueExecutor::PI_PAYLOAD_LIMIT_BYTES :
|
|
68
|
+
Molecules::NativeQueueExecutor::MAX_ARG_PAYLOAD_BYTES
|
|
69
|
+
if payload.bytesize > payload_limit
|
|
70
|
+
raise ValidationError,
|
|
71
|
+
"payload exceeds the #{target['agent']} native queue limit " \
|
|
72
|
+
"of #{payload_limit} bytes and could never be delivered"
|
|
73
|
+
end
|
|
74
|
+
record = Models::DeliveryRecord.new(
|
|
75
|
+
event_id: event, session: address.session, pane: address.pane,
|
|
76
|
+
answer_digest: digest, answer: payload, state: "queued",
|
|
77
|
+
inbox: {"attempt_id" => attempt, "claim_generation" => 0,
|
|
78
|
+
"receipt_key_sha256" => key_fingerprint,
|
|
79
|
+
"target" => target, "binding" => target.merge("payload_sha256" => digest)}
|
|
80
|
+
)
|
|
81
|
+
save(record)
|
|
82
|
+
public_record(record)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def status(event:)
|
|
87
|
+
validate_id!(event, "event")
|
|
88
|
+
with_event(event) do |record|
|
|
89
|
+
raise ValidationError, "unknown inbox event: #{event}" unless record&.inbox
|
|
90
|
+
|
|
91
|
+
public_record(record)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def deliver(event:)
|
|
96
|
+
validate_id!(event, "event")
|
|
97
|
+
with_event(event) do |record|
|
|
98
|
+
raise ValidationError, "unknown inbox event: #{event}" unless record&.inbox
|
|
99
|
+
next retry_wake(record) if record.state == "delivered" && wake_pending?(record)
|
|
100
|
+
next public_record(record) if %w[delivered completed uncertain].include?(record.state)
|
|
101
|
+
unless record.state == "queued"
|
|
102
|
+
if record.inbox["submission_intent"]
|
|
103
|
+
# A previous owner crashed after saving submission intent:
|
|
104
|
+
# the submission boundary is unknown to a new process.
|
|
105
|
+
record = transition(record, "uncertain", record.inbox,
|
|
106
|
+
"orphan-claim", "claim owner ended after submission intent")
|
|
107
|
+
else
|
|
108
|
+
# The claim was saved before any submission intent existed,
|
|
109
|
+
# so no submission could have started: provably pre-send.
|
|
110
|
+
record = transition(record, "queued", record.inbox.reject { |key, _| key == "claim_owner" },
|
|
111
|
+
"claim-recovery", "claim owner ended before submission intent")
|
|
112
|
+
end
|
|
113
|
+
save(record)
|
|
114
|
+
next public_record(record)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
claim = record.inbox.merge(
|
|
118
|
+
"claim_owner" => "#{Process.pid}:#{SecureRandom.hex(8)}",
|
|
119
|
+
"claim_generation" => record.inbox.fetch("claim_generation", 0) + 1
|
|
120
|
+
)
|
|
121
|
+
record = transition(record, "claimed", claim, "claim")
|
|
122
|
+
save(record)
|
|
123
|
+
|
|
124
|
+
begin
|
|
125
|
+
binding = observe(record)
|
|
126
|
+
rescue ValidationError, ExecutorError => e
|
|
127
|
+
state = e.is_a?(IdentityDriftError) ? "uncertain" : "queued"
|
|
128
|
+
record = transition(record, state, claim.merge("last_error" => e.message),
|
|
129
|
+
state == "uncertain" ? "identity-drift" : "pre-submit-rejection", e.message)
|
|
130
|
+
save(record)
|
|
131
|
+
next public_record(record)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
target = binding.reject { |key, _| key == "payload_sha256" }
|
|
135
|
+
bound = claim.merge("binding" => binding, "target" => target)
|
|
136
|
+
record = transition(record, "claimed", bound, "bind")
|
|
137
|
+
save(record)
|
|
138
|
+
# No later process may infer from a claimed record that submission
|
|
139
|
+
# did not occur. Persist intent immediately before the native call.
|
|
140
|
+
intent = bound.merge("submission_intent" => true)
|
|
141
|
+
record = transition(record, "uncertain", intent, "submit-intent")
|
|
142
|
+
save(record)
|
|
143
|
+
|
|
144
|
+
result = submit(record, binding)
|
|
145
|
+
if result["pre_submit"]
|
|
146
|
+
# Missing executable is the only proven pre-submission error.
|
|
147
|
+
record = transition(record, "queued", bound.merge("last_error" => result["error"]),
|
|
148
|
+
"pre-submit-rejection", result["error"])
|
|
149
|
+
elsif result["accepted"]
|
|
150
|
+
receipt = {"event_id" => event, "attempt_id" => bound["attempt_id"],
|
|
151
|
+
"claim_generation" => bound["claim_generation"],
|
|
152
|
+
"payload_sha256" => record.answer_digest, "binding" => binding,
|
|
153
|
+
"native_output" => result["stdout"]}
|
|
154
|
+
# The wake is a separate effect from the accepted submission,
|
|
155
|
+
# but its DECISION rides the first delivered transition: a crash
|
|
156
|
+
# between saves must never leave the wake field missing (which
|
|
157
|
+
# later reads as pending) for a busy target.
|
|
158
|
+
wake = if WAKE_STATUSES.include?(binding["agent_status"])
|
|
159
|
+
{"status" => "pending"}
|
|
160
|
+
else
|
|
161
|
+
{"status" => "none", "reason" => "busy target uses the native queue form"}
|
|
162
|
+
end
|
|
163
|
+
record = transition(record, "delivered",
|
|
164
|
+
intent.merge("receipt" => receipt).merge("wake" => wake), "accepted")
|
|
165
|
+
save(record)
|
|
166
|
+
# A pending wake is attempted after the decision is durable, so
|
|
167
|
+
# a crash or transient failure can be recovered by a later
|
|
168
|
+
# deliver call without ever resubmitting the message.
|
|
169
|
+
record = attempt_wake(record, binding) if wake["status"] == "pending"
|
|
170
|
+
else
|
|
171
|
+
record = transition(record, "uncertain", intent.merge("last_error" => result["error"]),
|
|
172
|
+
"submission-uncertain", result["error"])
|
|
173
|
+
end
|
|
174
|
+
save(record)
|
|
175
|
+
public_record(record)
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def reconcile(event:, receipt:, signed_bytes: nil, signature: nil)
|
|
180
|
+
validate_id!(event, "event")
|
|
181
|
+
with_event(event) do |record|
|
|
182
|
+
raise ValidationError, "unknown inbox event: #{event}" unless record&.inbox
|
|
183
|
+
# `delivered` only proves native queue acceptance: the message may
|
|
184
|
+
# still be consumed or evicted afterwards, so a signed observation
|
|
185
|
+
# can reconcile it exactly like an uncertain outcome.
|
|
186
|
+
unless %w[uncertain delivered].include?(record.state)
|
|
187
|
+
raise ValidationError, "event is not reconcilable from state #{record.state}"
|
|
188
|
+
end
|
|
189
|
+
binding = record.inbox["binding"]
|
|
190
|
+
matches = receipt.is_a?(Hash) && receipt["event_id"] == event &&
|
|
191
|
+
receipt["attempt_id"] == record.inbox["attempt_id"] &&
|
|
192
|
+
receipt["claim_generation"] == record.inbox["claim_generation"] &&
|
|
193
|
+
receipt["payload_sha256"] == record.answer_digest &&
|
|
194
|
+
receipt["binding"] == binding
|
|
195
|
+
refusal = if !binding
|
|
196
|
+
"event has no verified binding"
|
|
197
|
+
elsif !matches
|
|
198
|
+
"reconciliation receipt does not match bound event"
|
|
199
|
+
end
|
|
200
|
+
refusal ||= proof_refusal(receipt) if receipt.is_a?(Hash)
|
|
201
|
+
refusal ||= signature_refusal(record, receipt, signed_bytes, signature)
|
|
202
|
+
replacement = nil
|
|
203
|
+
if !refusal && receipt["outcome"] == "superseded" && receipt.key?("replacement_target")
|
|
204
|
+
replacement = receipt["replacement_target"]
|
|
205
|
+
observed = begin
|
|
206
|
+
raise ValidationError, "replacement target must be an object" unless replacement.is_a?(Hash)
|
|
207
|
+
|
|
208
|
+
address = address_for(replacement)
|
|
209
|
+
observe_target(address.session, address.pane)
|
|
210
|
+
rescue ValidationError, ExecutorError, Ace::Hitl::Providers::InvalidRefError => e
|
|
211
|
+
refusal = "replacement target cannot be verified: #{e.message}"
|
|
212
|
+
nil
|
|
213
|
+
end
|
|
214
|
+
stable = %w[session pane terminal_id agent thread thread_kind]
|
|
215
|
+
unless replacement.is_a?(Hash) && observed &&
|
|
216
|
+
stable.all? { |key| replacement[key] == observed[key] }
|
|
217
|
+
refusal ||= "replacement target does not match the live native session"
|
|
218
|
+
end
|
|
219
|
+
# The replacement agent's own delivery rules must admit this
|
|
220
|
+
# event: its immutable-id requirement and payload bound.
|
|
221
|
+
if observed
|
|
222
|
+
if observed["agent"] == "pi" && !PI_EVENT_ID.match?(event)
|
|
223
|
+
refusal ||= "replacement pi target requires an inbox (inb-) or wake (wnk-) event id"
|
|
224
|
+
end
|
|
225
|
+
payload_limit = observed["agent"] == "pi" ?
|
|
226
|
+
Molecules::NativeQueueExecutor::PI_PAYLOAD_LIMIT_BYTES :
|
|
227
|
+
Molecules::NativeQueueExecutor::MAX_ARG_PAYLOAD_BYTES
|
|
228
|
+
if record.answer.to_s.bytesize > payload_limit
|
|
229
|
+
refusal ||= "payload exceeds the replacement agent's native queue limit"
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
next public_record(record).merge("reconciliation_refusal" => refusal) if refusal
|
|
234
|
+
|
|
235
|
+
outcome = receipt["outcome"]
|
|
236
|
+
state = outcome == "consumed" ? "completed" : "queued"
|
|
237
|
+
inbox = record.inbox.merge("reconciliation" => receipt)
|
|
238
|
+
if state == "queued"
|
|
239
|
+
inbox = inbox.reject do |key, _|
|
|
240
|
+
%w[submission_intent claim_owner receipt].include?(key)
|
|
241
|
+
end
|
|
242
|
+
inbox = inbox.merge("target" => replacement) if replacement
|
|
243
|
+
end
|
|
244
|
+
record = transition(record, state, inbox, "reconcile-#{outcome}")
|
|
245
|
+
save(record)
|
|
246
|
+
public_record(record)
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
private
|
|
251
|
+
|
|
252
|
+
def key_fingerprint
|
|
253
|
+
Digest::SHA256.hexdigest(@receipt_public_key.public_to_der)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
def signature_refusal(record, receipt, signed_bytes, signature)
|
|
257
|
+
return "trusted receipt public key is unavailable" unless @receipt_public_key
|
|
258
|
+
unless record.inbox["receipt_key_sha256"] == key_fingerprint
|
|
259
|
+
return "trusted receipt public key differs from the enqueued event"
|
|
260
|
+
end
|
|
261
|
+
return "receipt signature is missing" unless signed_bytes.is_a?(String) && signature.is_a?(String)
|
|
262
|
+
return "signed receipt content differs from parsed receipt" unless JSON.parse(signed_bytes) == receipt
|
|
263
|
+
return "receipt signature is invalid" unless @receipt_public_key.verify(
|
|
264
|
+
OpenSSL::Digest::SHA256.new, signature, signed_bytes)
|
|
265
|
+
|
|
266
|
+
nil
|
|
267
|
+
rescue JSON::ParserError, OpenSSL::PKey::PKeyError
|
|
268
|
+
"receipt signature is invalid"
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# The receipt is an explicit operator or supervisor attestation of a
|
|
272
|
+
# native outcome. Native queue submission alone cannot prove consumption
|
|
273
|
+
# or nonconsumption; an absent or incomplete attestation is never retried.
|
|
274
|
+
def proof_refusal(receipt)
|
|
275
|
+
return "invalid reconciliation outcome" unless %w[consumed superseded].include?(receipt["outcome"])
|
|
276
|
+
observer = receipt["observer"]
|
|
277
|
+
unless observer.is_a?(Hash) && %w[operator supervisor].include?(observer["role"]) &&
|
|
278
|
+
observer["id"].is_a?(String) && !observer["id"].strip.empty?
|
|
279
|
+
return "receipt requires an identified operator or supervisor"
|
|
280
|
+
end
|
|
281
|
+
evidence = receipt["evidence"]
|
|
282
|
+
kinds = receipt["outcome"] == "consumed" ? %w[consumed_acknowledged] :
|
|
283
|
+
%w[queue_evicted queue_expired thread_replaced]
|
|
284
|
+
unless evidence.is_a?(Hash) && kinds.include?(evidence["kind"]) &&
|
|
285
|
+
evidence["native_reference"].is_a?(String) && !evidence["native_reference"].strip.empty? &&
|
|
286
|
+
evidence["observation"].is_a?(String) && !evidence["observation"].strip.empty?
|
|
287
|
+
return "receipt requires a native outcome observation and reference"
|
|
288
|
+
end
|
|
289
|
+
nil
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def with_event(event)
|
|
293
|
+
Molecules::DeliveryRecordStore.with_lock(@deliveries_dir, event) do
|
|
294
|
+
yield Molecules::DeliveryRecordStore.load(@deliveries_dir, event)
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
def save(record)
|
|
299
|
+
Molecules::DeliveryRecordStore.save(record, @deliveries_dir)
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def transition(record, state, inbox, action, error = nil)
|
|
303
|
+
detail = {"action" => action}
|
|
304
|
+
detail["error"] = error if error && !error.empty?
|
|
305
|
+
record.advance_inbox(state: state, inbox: inbox, detail: detail, timestamp: Time.now.utc.iso8601)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
def validate_id!(value, name)
|
|
309
|
+
raise ValidationError, "invalid #{name} id" unless value.is_a?(String) && EVENT.match?(value)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def address_for(ref)
|
|
313
|
+
hash = ref.is_a?(Hash) ? ref : JSON.parse(File.read(ref))
|
|
314
|
+
raise ValidationError, "invalid ref: expected a JSON object" unless hash.is_a?(Hash)
|
|
315
|
+
Ace::Hitl::Providers::Ref.new(
|
|
316
|
+
session: Ace::Hitl::Providers::Ref.validate!(hash.fetch("session"), "session"),
|
|
317
|
+
pane: Ace::Hitl::Providers::Ref.validate!(hash.fetch("pane"), "pane")
|
|
318
|
+
)
|
|
319
|
+
rescue Errno::ENOENT, JSON::ParserError, KeyError, TypeError => e
|
|
320
|
+
raise ValidationError, "invalid ref: #{e.message}"
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def validate_match!(record, attempt, address, digest)
|
|
324
|
+
unless record.inbox && record.inbox["attempt_id"] == attempt &&
|
|
325
|
+
record.session == address.session && record.pane == address.pane &&
|
|
326
|
+
record.answer_digest == digest
|
|
327
|
+
raise ValidationError, "event identity, attempt, target, or payload digest conflicts"
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def observe(record)
|
|
332
|
+
target = record.inbox["target"]
|
|
333
|
+
binding = observe_target(target ? target["session"] : record.session,
|
|
334
|
+
target ? target["pane"] : record.pane, target: target)
|
|
335
|
+
# Target drift is classified BEFORE agent-specific event id rules:
|
|
336
|
+
# a bound event whose pane changed agent (e.g. codex -> pi) must
|
|
337
|
+
# reconcile, not loop on a pre-send validation error.
|
|
338
|
+
stable = %w[session pane terminal_id agent thread thread_kind]
|
|
339
|
+
if target && !stable.all? { |key| target[key] == binding[key] }
|
|
340
|
+
raise IdentityDriftError, "target identity changed since enqueue; reconciliation is required"
|
|
341
|
+
end
|
|
342
|
+
if binding["agent"] == "pi" && !PI_EVENT_ID.match?(record.event_id)
|
|
343
|
+
raise ValidationError, "Pi queue requires an inbox or wake event ID"
|
|
344
|
+
end
|
|
345
|
+
binding.merge("payload_sha256" => record.answer_digest)
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def observe_target(expected_session, expected_pane, target: nil)
|
|
349
|
+
payload = @executor.pane_get_bounded(expected_pane).parsed_json
|
|
350
|
+
# Shape-check each level: parseable but malformed probe JSON must
|
|
351
|
+
# fail as a validation error, never as a TypeError that would
|
|
352
|
+
# strand the event in claimed.
|
|
353
|
+
result = payload.is_a?(Hash) ? payload["result"] : nil
|
|
354
|
+
pane = result.is_a?(Hash) ? result["pane"] : nil
|
|
355
|
+
raise ValidationError, "pane observation is unavailable" unless pane.is_a?(Hash)
|
|
356
|
+
raise IdentityDriftError, "pane identity changed" unless pane["pane_id"] == expected_pane
|
|
357
|
+
observed_session = pane["workspace_id"] || pane["session_id"]
|
|
358
|
+
raise IdentityDriftError, "runtime session identity changed" unless observed_session == expected_session
|
|
359
|
+
agent = pane["agent"]
|
|
360
|
+
# An agent change is target drift: classify it as reconciliation-
|
|
361
|
+
# worthy before any agent-specific validation can mask it.
|
|
362
|
+
if target && target["agent"] != agent
|
|
363
|
+
raise IdentityDriftError, "target identity changed since enqueue; reconciliation is required"
|
|
364
|
+
end
|
|
365
|
+
raise ValidationError, "unsupported native agent" unless %w[codex pi].include?(agent)
|
|
366
|
+
session = pane["agent_session"]
|
|
367
|
+
raise IdentityDriftError, "native session identity is unavailable" unless session.is_a?(Hash) && session["agent"] == agent
|
|
368
|
+
thread = session["value"].to_s
|
|
369
|
+
kind = session["kind"]
|
|
370
|
+
if agent == "pi" && kind == "path"
|
|
371
|
+
thread = PI_PATH.match(thread)&.captures&.first.to_s
|
|
372
|
+
kind = "id"
|
|
373
|
+
end
|
|
374
|
+
# Only an immutable session UUID may carry a binding: a name
|
|
375
|
+
# survives an agent restart in the same pane/terminal, so a reused
|
|
376
|
+
# session could inherit an old event.
|
|
377
|
+
unless kind == "id" && THREAD_ID.match?(thread)
|
|
378
|
+
raise ValidationError,
|
|
379
|
+
"native queue binding requires an immutable session id " \
|
|
380
|
+
"(got #{kind.inspect})"
|
|
381
|
+
end
|
|
382
|
+
raise ValidationError, "Pi queue requires a session ID" if agent == "pi" && kind != "id"
|
|
383
|
+
raw_terminal = pane["terminal_id"]
|
|
384
|
+
terminal = raw_terminal.to_s.strip if raw_terminal.is_a?(String) || raw_terminal.is_a?(Integer)
|
|
385
|
+
if terminal.nil? || terminal.empty? || terminal.bytesize > 64
|
|
386
|
+
raise ValidationError, "durable terminal identity is unavailable"
|
|
387
|
+
end
|
|
388
|
+
if agent == "pi" && @native.pi_identity != thread
|
|
389
|
+
raise IdentityDriftError, "live Pi session identity differs"
|
|
390
|
+
end
|
|
391
|
+
status = pane["agent_status"].to_s
|
|
392
|
+
unless AGENT_STATUSES.include?(status)
|
|
393
|
+
# An undetermined status cannot decide the wake form; fail the
|
|
394
|
+
# submission provably before the submission boundary (retryable).
|
|
395
|
+
raise ValidationError, "native agent status is unrecognized: #{status.inspect}"
|
|
396
|
+
end
|
|
397
|
+
{"session" => expected_session, "pane" => expected_pane, "terminal_id" => terminal,
|
|
398
|
+
"agent" => agent, "thread" => thread, "thread_kind" => kind,
|
|
399
|
+
"agent_status" => status}
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def submit(record, binding)
|
|
403
|
+
@native.submit(agent: binding["agent"], thread: binding["thread"],
|
|
404
|
+
event_id: record.event_id, digest: record.answer_digest, payload: record.answer)
|
|
405
|
+
rescue ExecutorError => e
|
|
406
|
+
{"accepted" => false, "error" => e.message}
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
# A delivered record still owes its idle wake after a crash (no wake
|
|
410
|
+
# entry) or a transient failure (status pending). Re-verify the live
|
|
411
|
+
# target first — identity drift demotes the event to uncertain — then
|
|
412
|
+
# retry the payload-free wake. The native payload is never resubmitted.
|
|
413
|
+
def retry_wake(record)
|
|
414
|
+
begin
|
|
415
|
+
binding = observe(record)
|
|
416
|
+
rescue IdentityDriftError => e
|
|
417
|
+
record = transition(record, "uncertain", record.inbox.merge("last_error" => e.message),
|
|
418
|
+
"identity-drift", e.message)
|
|
419
|
+
save(record)
|
|
420
|
+
return public_record(record)
|
|
421
|
+
rescue ExecutorError, ValidationError => e
|
|
422
|
+
record = transition(record, "delivered",
|
|
423
|
+
record.inbox.merge("wake" => record.inbox.fetch("wake", {"status" => "pending"})
|
|
424
|
+
.merge("status" => "pending", "error" => e.message)),
|
|
425
|
+
"wake-retry-blocked", e.message)
|
|
426
|
+
save(record)
|
|
427
|
+
return public_record(record)
|
|
428
|
+
end
|
|
429
|
+
unless WAKE_STATUSES.include?(binding["agent_status"])
|
|
430
|
+
# The live target works on its native queue; no prompt is owed.
|
|
431
|
+
record = transition(record, "delivered",
|
|
432
|
+
record.inbox.merge("wake" => {"status" => "none",
|
|
433
|
+
"reason" => "busy target uses the native queue form"}), "wake-none")
|
|
434
|
+
save(record)
|
|
435
|
+
return public_record(record)
|
|
436
|
+
end
|
|
437
|
+
public_record(attempt_wake(record, binding))
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def wake_pending?(record)
|
|
441
|
+
wake = record.inbox["wake"]
|
|
442
|
+
wake.nil? || wake["status"] == "pending"
|
|
443
|
+
end
|
|
444
|
+
|
|
445
|
+
def attempt_wake(record, binding)
|
|
446
|
+
wake_error = wake_idle(binding)
|
|
447
|
+
wake = if wake_error
|
|
448
|
+
{"status" => "pending", "error" => wake_error}
|
|
449
|
+
else
|
|
450
|
+
{"status" => "sent"}
|
|
451
|
+
end
|
|
452
|
+
detail = wake_error ? "wake-failed" : "wake-sent"
|
|
453
|
+
saved = transition(record, "delivered",
|
|
454
|
+
record.inbox.merge("wake" => wake).compact, detail, wake_error)
|
|
455
|
+
save(saved)
|
|
456
|
+
saved
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
def wake_idle(binding)
|
|
460
|
+
@executor.agent_prompt_bounded(pane: binding["pane"],
|
|
461
|
+
text: "Check your native queued messages.", timeout_ms: 10_000)
|
|
462
|
+
nil
|
|
463
|
+
rescue StandardError => e
|
|
464
|
+
e.message
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
def public_record(record)
|
|
468
|
+
{"event_id" => record.event_id, "attempt_id" => record.inbox["attempt_id"],
|
|
469
|
+
"session" => record.session, "pane" => record.pane,
|
|
470
|
+
"payload_sha256" => record.answer_digest, "state" => record.state,
|
|
471
|
+
"receipt_key_sha256" => record.inbox["receipt_key_sha256"],
|
|
472
|
+
"claim_generation" => record.inbox["claim_generation"],
|
|
473
|
+
"claim_owner" => record.inbox["claim_owner"],
|
|
474
|
+
"submission_intent" => !!record.inbox["submission_intent"],
|
|
475
|
+
"target" => record.inbox["target"],
|
|
476
|
+
"binding" => record.inbox["binding"], "receipt" => record.inbox["receipt"],
|
|
477
|
+
"reconciliation" => record.inbox["reconciliation"],
|
|
478
|
+
"wake" => record.inbox["wake"],
|
|
479
|
+
"last_error" => record.inbox["last_error"]}
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
end
|
|
484
|
+
end
|
|
@@ -90,7 +90,7 @@ module Ace
|
|
|
90
90
|
record = entry[:record]
|
|
91
91
|
if record.nil?
|
|
92
92
|
report[:deliveries][:preserved] << {event_id: event_id, reason: "unreadable"}
|
|
93
|
-
elsif record.state != "delivered"
|
|
93
|
+
elsif record.inbox || record.state != "delivered"
|
|
94
94
|
report[:deliveries][:protected] << {event_id: event_id, state: record.state}
|
|
95
95
|
elsif (updated_at = parse_updated_at(record))
|
|
96
96
|
entry = {event_id: event_id, updated_at: record.updated_at}
|
|
@@ -138,7 +138,7 @@ module Ace
|
|
|
138
138
|
fresh = @record_store.load_revalidated(@deliveries_dir, event_id)
|
|
139
139
|
if fresh == :unreadable
|
|
140
140
|
outcome = {reason: "unreadable"}
|
|
141
|
-
elsif fresh&.delivered? && older_than_retention?(fresh)
|
|
141
|
+
elsif fresh&.delivered? && !fresh.inbox && older_than_retention?(fresh)
|
|
142
142
|
outcome = {archive_path: @record_store.archive(@deliveries_dir, event_id)}
|
|
143
143
|
end
|
|
144
144
|
end
|
data/lib/ace/herdr/version.rb
CHANGED
data/lib/ace/herdr.rb
CHANGED
|
@@ -38,12 +38,15 @@ require_relative "herdr/atoms/answer_digest"
|
|
|
38
38
|
require_relative "herdr/atoms/json_find"
|
|
39
39
|
require_relative "herdr/models/delivery_record"
|
|
40
40
|
require_relative "herdr/models/dispatch_outcome"
|
|
41
|
+
require_relative "herdr/molecules/bounded_process"
|
|
41
42
|
require_relative "herdr/molecules/herdr_executor"
|
|
43
|
+
require_relative "herdr/molecules/native_queue_executor"
|
|
42
44
|
require_relative "herdr/molecules/pane_tidy_probe"
|
|
43
45
|
require_relative "herdr/molecules/delivery_record_store"
|
|
44
46
|
require_relative "herdr/molecules/preset_loader"
|
|
45
47
|
require_relative "herdr/molecules/preset_resolver"
|
|
46
48
|
require_relative "herdr/organisms/deliverer"
|
|
49
|
+
require_relative "herdr/organisms/inbox"
|
|
47
50
|
require_relative "herdr/organisms/dispatcher"
|
|
48
51
|
require_relative "herdr/organisms/control_surface"
|
|
49
52
|
require_relative "herdr/organisms/runtime_adapter"
|