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
|
@@ -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
|