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.
@@ -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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Herdr
5
- VERSION = "0.2.0"
5
+ VERSION = "0.3.0"
6
6
  end
7
7
  end
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"