ace-herdr 0.3.2 → 0.5.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 +0 -4
- data/CHANGELOG.md +30 -0
- data/docs/usage.md +65 -32
- data/lib/ace/herdr/cli/commands/inbox.rb +81 -31
- data/lib/ace/herdr/cli/commands/native_source.rb +56 -0
- data/lib/ace/herdr/cli/commands/support.rb +2 -6
- data/lib/ace/herdr/cli.rb +3 -0
- data/lib/ace/herdr/errors.rb +23 -0
- data/lib/ace/herdr/molecules/bounded_process.rb +266 -51
- data/lib/ace/herdr/molecules/delivery_record_store.rb +81 -5
- data/lib/ace/herdr/molecules/guarded_native_origin.rb +59 -0
- data/lib/ace/herdr/molecules/herdr_executor.rb +6 -3
- data/lib/ace/herdr/molecules/inbox_cli_input.rb +36 -0
- data/lib/ace/herdr/molecules/inbox_context_client.rb +88 -0
- data/lib/ace/herdr/molecules/inbox_context_completion_client.rb +119 -0
- data/lib/ace/herdr/molecules/inbox_context_key.rb +63 -0
- data/lib/ace/herdr/molecules/inbox_context_lifetime.rb +122 -0
- data/lib/ace/herdr/molecules/inbox_context_native_process.rb +92 -0
- data/lib/ace/herdr/molecules/inbox_context_service_configuration.rb +183 -0
- data/lib/ace/herdr/molecules/inbox_context_store.rb +159 -0
- data/lib/ace/herdr/molecules/inbox_context_wire.rb +76 -0
- data/lib/ace/herdr/molecules/inbox_direct_effect_binding.rb +92 -0
- data/lib/ace/herdr/molecules/inbox_direct_result.rb +65 -0
- data/lib/ace/herdr/molecules/native_queue_executor.rb +10 -21
- data/lib/ace/herdr/molecules/native_source.rb +189 -0
- data/lib/ace/herdr/molecules/protected_inbox_selection.rb +126 -0
- data/lib/ace/herdr/molecules/protected_native_control.rb +259 -0
- data/lib/ace/herdr/native_source/HERDR-LICENSE +201 -0
- data/lib/ace/herdr/native_source/guarded-prompt.patch +3493 -0
- data/lib/ace/herdr/native_source/selection.json +24 -0
- data/lib/ace/herdr/organisms/deliverer.rb +2 -3
- data/lib/ace/herdr/organisms/inbox.rb +384 -137
- data/lib/ace/herdr/organisms/inbox_context_direct_effects.rb +285 -0
- data/lib/ace/herdr/organisms/inbox_context_effects.rb +30 -0
- data/lib/ace/herdr/organisms/inbox_context_listener.rb +151 -0
- data/lib/ace/herdr/organisms/inbox_context_owner.rb +550 -0
- data/lib/ace/herdr/organisms/inbox_context_server.rb +70 -0
- data/lib/ace/herdr/organisms/inbox_context_service.rb +107 -0
- data/lib/ace/herdr/organisms/inbox_guarded_wake.rb +117 -0
- data/lib/ace/herdr/organisms/native_source_builder.rb +183 -0
- data/lib/ace/herdr/organisms/runtime_adapter.rb +30 -1
- data/lib/ace/herdr/version.rb +1 -1
- data/lib/ace/herdr.rb +5 -28
- metadata +69 -8
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../errors"
|
|
3
4
|
require "digest"
|
|
4
5
|
require "json"
|
|
5
6
|
require "time"
|
|
6
7
|
require "securerandom"
|
|
7
|
-
require "openssl"
|
|
8
8
|
require "ace/hitl/contract"
|
|
9
|
+
require_relative "../molecules/native_queue_executor"
|
|
10
|
+
require_relative "inbox_guarded_wake"
|
|
11
|
+
require_relative "../molecules/inbox_direct_effect_binding"
|
|
9
12
|
|
|
10
13
|
module Ace
|
|
11
14
|
module Herdr
|
|
@@ -13,9 +16,12 @@ module Ace
|
|
|
13
16
|
# Durable event inbox. All transitions for one event are serialized by
|
|
14
17
|
# DeliveryRecordStore; a saved submission intent is never replayed.
|
|
15
18
|
class Inbox
|
|
19
|
+
include InboxGuardedWake
|
|
16
20
|
class IdentityDriftError < ValidationError; end
|
|
21
|
+
class MissingEventError < ValidationError; end
|
|
17
22
|
|
|
18
23
|
EVENT = /\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/
|
|
24
|
+
TARGET_IDENTITY = %w[session pane terminal_id agent thread thread_kind].freeze
|
|
19
25
|
THREAD_ID = /\A[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}\z/
|
|
20
26
|
PI_PATH = /_([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})\.jsonl\z/
|
|
21
27
|
# Single validator for Pi queue event ids, shared by enqueue and
|
|
@@ -28,17 +34,148 @@ module Ace
|
|
|
28
34
|
AGENT_STATUSES = %w[idle busy working blocked done].freeze
|
|
29
35
|
WAKE_STATUSES = %w[idle done].freeze
|
|
30
36
|
|
|
31
|
-
|
|
37
|
+
# The maintained CLI uses the configured durable delivery store.
|
|
38
|
+
def self.from_config(config: Ace::Herdr.config, root: Dir.pwd,
|
|
39
|
+
executor: Molecules::HerdrExecutor.new, native: Molecules::NativeQueueExecutor.new)
|
|
40
|
+
new(executor: executor, native: native,
|
|
41
|
+
deliveries_dir: File.expand_path(config["deliveries_dir"] || ".ace-local/herdr/deliveries", root))
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Enumerate retained event identities through the same store/lock owner.
|
|
45
|
+
# Unreadable unattributable records cannot be treated as another attempt.
|
|
46
|
+
def self.retained_events(deliveries_dir:, attempt:)
|
|
47
|
+
raise ValidationError, "invalid attempt id" unless attempt.is_a?(String) && EVENT.match?(attempt)
|
|
48
|
+
retained_records(deliveries_dir: deliveries_dir).filter_map do |record|
|
|
49
|
+
record.event_id if record.inbox.fetch("attempt_id") == attempt
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The same existing per-event locks remain held through the consumer's
|
|
54
|
+
# complete validation and retirement transaction, not just enumeration.
|
|
55
|
+
def self.with_retained_records(deliveries_dir:, deadline: nil, &block)
|
|
56
|
+
stat = File.lstat(deliveries_dir)
|
|
57
|
+
raise ValidationError, "retained inbox directory is unsafe" unless stat.directory? && !stat.symlink?
|
|
58
|
+
# An empty retained store needs the coordination lock too. Creating
|
|
59
|
+
# only this lock never creates a missing context or a delivery record.
|
|
60
|
+
Molecules::DeliveryRecordStore.with_inventory_lock(deliveries_dir, exclusive: true, create: true, prepare_directory: false, deadline: deadline) do
|
|
61
|
+
with_retained_records_held(deliveries_dir: deliveries_dir, deadline: deadline, &block)
|
|
62
|
+
end
|
|
63
|
+
rescue SystemCallError
|
|
64
|
+
raise ValidationError, "retained inbox inventory is unavailable"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.with_retained_records_held(deliveries_dir:, deadline: nil, &block)
|
|
68
|
+
raise ArgumentError, "retained inbox block is required" unless block
|
|
69
|
+
roots = [deliveries_dir, Molecules::DeliveryRecordStore.archive_dir(deliveries_dir)]
|
|
70
|
+
names = lambda do
|
|
71
|
+
roots.flat_map do |dir|
|
|
72
|
+
next [] if dir != deliveries_dir && !File.exist?(dir) && !File.symlink?(dir)
|
|
73
|
+
stat = File.lstat(dir)
|
|
74
|
+
raise ValidationError, "retained inbox directory is unsafe" unless stat.directory? && !stat.symlink?
|
|
75
|
+
Dir.children(dir).filter_map { |name| name.delete_suffix(".json") if name.end_with?(".json") }
|
|
76
|
+
end.uniq.sort
|
|
77
|
+
end
|
|
78
|
+
ids = names.call
|
|
79
|
+
raise ValidationError, "retained inbox inventory exceeds bounds" if ids.size > 100_000
|
|
80
|
+
held = Thread.current[:ace_herdr_retained_inventory_locks] ||= {}
|
|
81
|
+
raise ValidationError, "retained inbox inventory already held" if held.key?(deliveries_dir)
|
|
82
|
+
raise ValidationError, "retained inbox event id is invalid" unless ids.all? { |event| EVENT.match?(event) }
|
|
83
|
+
Molecules::DeliveryRecordStore.with_locks(deliveries_dir, ids, create: false, deadline: deadline) do
|
|
84
|
+
raise ValidationError, "retained inbox inventory changed" unless names.call == ids
|
|
85
|
+
held[deliveries_dir] = ids.freeze
|
|
86
|
+
begin
|
|
87
|
+
result = block.call(retained_records(deliveries_dir: deliveries_dir))
|
|
88
|
+
raise ValidationError, "retained inbox inventory changed" unless names.call == ids
|
|
89
|
+
result
|
|
90
|
+
ensure
|
|
91
|
+
held.delete(deliveries_dir)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
rescue SystemCallError, JSON::ParserError, ArgumentError, KeyError, TypeError
|
|
95
|
+
raise ValidationError, "retained inbox inventory is unavailable"
|
|
96
|
+
end
|
|
97
|
+
private_class_method :with_retained_records_held
|
|
98
|
+
|
|
99
|
+
# Exhaustive live and archived inventory: no attempt filter may hide an
|
|
100
|
+
# unknown record, and live precedence cannot hide conflicting copies.
|
|
101
|
+
def self.retained_records(deliveries_dir:)
|
|
102
|
+
directories = [deliveries_dir, Molecules::DeliveryRecordStore.archive_dir(deliveries_dir)]
|
|
103
|
+
inventory = lambda do
|
|
104
|
+
directories.flat_map do |dir|
|
|
105
|
+
next [] if dir != deliveries_dir && !File.exist?(dir) && !File.symlink?(dir)
|
|
106
|
+
stat = File.lstat(dir)
|
|
107
|
+
raise ValidationError, "retained inbox directory is unsafe" unless stat.directory? && !stat.symlink?
|
|
108
|
+
Dir.children(dir).filter_map { |name| name.delete_suffix(".json") if name.end_with?(".json") }
|
|
109
|
+
end.uniq.sort
|
|
110
|
+
end
|
|
111
|
+
ids = inventory.call
|
|
112
|
+
raise ValidationError, "retained inbox inventory exceeds bounds" if ids.size > 100_000
|
|
113
|
+
records = ids.map do |event|
|
|
114
|
+
raise ValidationError, "retained inbox event id is invalid" unless EVENT.match?(event)
|
|
115
|
+
read_record = lambda do
|
|
116
|
+
copies = directories.filter_map do |dir|
|
|
117
|
+
path = Molecules::DeliveryRecordStore.path_for(dir, event)
|
|
118
|
+
next unless File.exist?(path) || File.symlink?(path)
|
|
119
|
+
before = File.lstat(path)
|
|
120
|
+
raise ValidationError, "retained inbox record is unsafe" unless before.file? && !before.symlink? && before.size.between?(1, 1_048_576)
|
|
121
|
+
File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |handle|
|
|
122
|
+
snapshot = ->(stat) { [stat.dev, stat.ino, stat.size, stat.mtime, stat.ctime] }
|
|
123
|
+
raise ValidationError, "retained inbox record changed" unless snapshot.call(before) == snapshot.call(handle.stat)
|
|
124
|
+
bytes = handle.read(before.size + 1)
|
|
125
|
+
raise ValidationError, "retained inbox record changed" unless bytes.bytesize == before.size &&
|
|
126
|
+
snapshot.call(before) == snapshot.call(handle.stat) && snapshot.call(before) == snapshot.call(File.lstat(path))
|
|
127
|
+
value = JSON.parse(bytes.dup.force_encoding(Encoding::UTF_8), create_additions: false,
|
|
128
|
+
max_nesting: 32, allow_duplicate_key: false, allow_comments: false)
|
|
129
|
+
record = Models::DeliveryRecord.from_h(value)
|
|
130
|
+
unless record.event_id == event && record.inbox && record.inbox.fetch("attempt_id").is_a?(String) &&
|
|
131
|
+
EVENT.match?(record.inbox.fetch("attempt_id"))
|
|
132
|
+
raise ValidationError, "retained inbox event is unavailable"
|
|
133
|
+
end
|
|
134
|
+
record
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
if copies.empty? || copies.map(&:to_h).uniq.length != 1
|
|
138
|
+
raise ValidationError, "retained inbox copies conflict"
|
|
139
|
+
end
|
|
140
|
+
copies.first
|
|
141
|
+
end
|
|
142
|
+
if Thread.current[:ace_herdr_retained_inventory_locks]&.dig(deliveries_dir)&.include?(event)
|
|
143
|
+
read_record.call
|
|
144
|
+
else
|
|
145
|
+
Molecules::DeliveryRecordStore.with_lock(deliveries_dir, event, create: false, &read_record)
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
raise ValidationError, "retained inbox inventory changed" unless inventory.call == ids
|
|
149
|
+
records.freeze
|
|
150
|
+
rescue SystemCallError, JSON::ParserError, ArgumentError, KeyError, TypeError
|
|
151
|
+
raise ValidationError, "retained inbox inventory is unavailable"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def initialize(executor:, deliveries_dir:, native: Molecules::NativeQueueExecutor.new)
|
|
32
155
|
@executor = executor
|
|
33
156
|
@native = native
|
|
34
157
|
@deliveries_dir = deliveries_dir
|
|
35
|
-
@receipt_public_key = receipt_public_key
|
|
36
158
|
end
|
|
37
159
|
|
|
38
|
-
|
|
160
|
+
# Project the accepted runtime owner before consuming an answer. Native
|
|
161
|
+
# observation may verify this identity, but cannot choose its replacement.
|
|
162
|
+
def target_from_owner(owner)
|
|
163
|
+
unless owner.is_a?(Hash) && owner["runtime"] == "herdr" &&
|
|
164
|
+
owner["agent_session"].is_a?(Hash) && owner.dig("agent_session", "agent") == owner["agent"]
|
|
165
|
+
raise ValidationError, "accepted native owner is unavailable"
|
|
166
|
+
end
|
|
167
|
+
target = owner.slice("session", "pane", "terminal_id", "agent").merge(
|
|
168
|
+
"thread" => owner.dig("agent_session", "value"), "thread_kind" => owner.dig("agent_session", "kind"))
|
|
169
|
+
validate_expected_target!(target)
|
|
170
|
+
target.transform_values { |value| value.dup.freeze }.freeze
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def context_root = @deliveries_dir.dup.freeze
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def enqueue(event:, attempt:, ref:, payload:, managed_envelope: nil, expected_target: nil, original_context: nil, original_binding_digest: nil)
|
|
39
177
|
validate_id!(event, "event")
|
|
40
178
|
validate_id!(attempt, "attempt")
|
|
41
|
-
raise ValidationError, "trusted receipt public key is unavailable" unless @receipt_public_key
|
|
42
179
|
payload = payload.to_s
|
|
43
180
|
raise ValidationError, "payload is required" if payload.empty?
|
|
44
181
|
raise ValidationError, "payload contains NUL" if payload.include?("\0")
|
|
@@ -49,15 +186,51 @@ module Ace
|
|
|
49
186
|
raise ValidationError, "payload is not valid UTF-8 text"
|
|
50
187
|
end
|
|
51
188
|
|
|
189
|
+
if original_context
|
|
190
|
+
Molecules::InboxDirectEffectBinding.original!(original_context)
|
|
191
|
+
unless original_binding_digest.is_a?(String) && Molecules::InboxDirectEffectBinding::SHA.match?(original_binding_digest)
|
|
192
|
+
raise ValidationError, "original launch record digest differs"
|
|
193
|
+
end
|
|
194
|
+
elsif original_binding_digest
|
|
195
|
+
raise ValidationError, "original digest requires its context association"
|
|
196
|
+
end
|
|
52
197
|
address = address_for(ref)
|
|
53
198
|
digest = Digest::SHA256.hexdigest(payload)
|
|
199
|
+
envelope = if managed_envelope
|
|
200
|
+
Ace::Hitl::Contract::ManagedEnvelope.load(managed_envelope, expected: {
|
|
201
|
+
attempt_id: attempt, payload_sha256: digest,
|
|
202
|
+
reverse: {"schema" => Ace::Hitl::Providers::Ref::SCHEMA, "session" => address.session, "pane" => address.pane}
|
|
203
|
+
})
|
|
204
|
+
end
|
|
205
|
+
if envelope && Ace::Hitl::Contract::SecretGate::PATTERN.match?(payload)
|
|
206
|
+
raise ValidationError, "secret-bearing managed native answers are prohibited"
|
|
207
|
+
end
|
|
208
|
+
if envelope && expected_target.nil?
|
|
209
|
+
raise ValidationError, "managed native delivery requires the accepted original target"
|
|
210
|
+
end
|
|
211
|
+
unless expected_target.nil?
|
|
212
|
+
validate_expected_target!(expected_target)
|
|
213
|
+
expected_target = expected_target.slice(*TARGET_IDENTITY)
|
|
214
|
+
unless expected_target.values_at("session", "pane") == [address.session, address.pane]
|
|
215
|
+
raise ValidationError, "accepted original target differs from reverse address"
|
|
216
|
+
end
|
|
217
|
+
end
|
|
54
218
|
with_event(event) do |record|
|
|
55
219
|
if record
|
|
56
220
|
validate_match!(record, attempt, address, digest)
|
|
221
|
+
unless record.inbox["managed_envelope"] == envelope && record.inbox["original_context"] == original_context && record.inbox["original_binding_digest"] == original_binding_digest
|
|
222
|
+
raise ValidationError, "managed envelope conflicts with immutable inbox event"
|
|
223
|
+
end
|
|
224
|
+
if expected_target && record.inbox["origin_target"] != expected_target
|
|
225
|
+
raise IdentityDriftError, "original native target conflicts with immutable inbox event"
|
|
226
|
+
end
|
|
57
227
|
next public_record(record)
|
|
58
228
|
end
|
|
59
229
|
|
|
60
|
-
target = observe_target(address.session, address.pane)
|
|
230
|
+
target = observe_target(address.session, address.pane, target: expected_target)
|
|
231
|
+
if expected_target && !TARGET_IDENTITY.all? { |key| target[key] == expected_target[key] }
|
|
232
|
+
raise IdentityDriftError, "native target differs from accepted original owner"
|
|
233
|
+
end
|
|
61
234
|
if target["agent"] == "pi" && !PI_EVENT_ID.match?(event)
|
|
62
235
|
raise ValidationError,
|
|
63
236
|
"Pi queue requires an inbox (inb-) or wake (wnk-) event id with at " \
|
|
@@ -65,7 +238,7 @@ module Ace
|
|
|
65
238
|
end
|
|
66
239
|
payload_limit = target["agent"] == "pi" ?
|
|
67
240
|
Molecules::NativeQueueExecutor::PI_PAYLOAD_LIMIT_BYTES :
|
|
68
|
-
Molecules::NativeQueueExecutor::
|
|
241
|
+
Molecules::NativeQueueExecutor::CODEX_PAYLOAD_LIMIT_BYTES
|
|
69
242
|
if payload.bytesize > payload_limit
|
|
70
243
|
raise ValidationError,
|
|
71
244
|
"payload exceeds the #{target['agent']} native queue limit " \
|
|
@@ -75,30 +248,170 @@ module Ace
|
|
|
75
248
|
event_id: event, session: address.session, pane: address.pane,
|
|
76
249
|
answer_digest: digest, answer: payload, state: "queued",
|
|
77
250
|
inbox: {"attempt_id" => attempt, "claim_generation" => 0,
|
|
78
|
-
"
|
|
251
|
+
"managed_envelope" => envelope, "original_context" => original_context, "original_binding_digest" => original_binding_digest,
|
|
252
|
+
"origin_target" => target.slice(*TARGET_IDENTITY),
|
|
79
253
|
"target" => target, "binding" => target.merge("payload_sha256" => digest)}
|
|
80
254
|
)
|
|
81
255
|
save(record)
|
|
82
256
|
public_record(record)
|
|
83
257
|
end
|
|
258
|
+
rescue Ace::Hitl::Contract::InvalidEnvelope => e
|
|
259
|
+
raise ValidationError, e.message
|
|
84
260
|
end
|
|
85
261
|
|
|
86
262
|
def status(event:)
|
|
87
263
|
validate_id!(event, "event")
|
|
88
264
|
with_event(event) do |record|
|
|
89
|
-
raise
|
|
265
|
+
raise MissingEventError, "unknown inbox event: #{event}" unless record&.inbox
|
|
90
266
|
|
|
91
267
|
public_record(record)
|
|
92
268
|
end
|
|
93
269
|
end
|
|
94
270
|
|
|
95
|
-
|
|
271
|
+
# Direct completion readback uses the existing event lock and immutable ledger.
|
|
272
|
+
def verify_direct_enqueue(binding)
|
|
273
|
+
selection = binding.fetch("selection")
|
|
274
|
+
address = address_for(selection.fetch("reverse"))
|
|
275
|
+
with_event(binding.fetch("event_id"), create_lock: false) do |record|
|
|
276
|
+
raise ValidationError, "direct enqueue record is unavailable" unless record&.inbox
|
|
277
|
+
validate_match!(record, binding.fetch("attempt_id"), address, selection.fetch("payload_sha256"))
|
|
278
|
+
origin = record.inbox.fetch("origin_target")
|
|
279
|
+
validate_expected_target!(origin)
|
|
280
|
+
unless record.answer.bytesize == selection.fetch("payload_bytes") &&
|
|
281
|
+
record.inbox.fetch("original_context") == binding.slice(*Molecules::InboxDirectEffectBinding::ORIGINAL_FIELDS) &&
|
|
282
|
+
record.inbox["original_binding_digest"].is_a?(String) && Molecules::InboxDirectEffectBinding::SHA.match?(record.inbox.fetch("original_binding_digest")) &&
|
|
283
|
+
origin.values_at("session", "pane") == [address.session, address.pane] &&
|
|
284
|
+
record.inbox.fetch("binding").fetch("payload_sha256") == record.answer_digest
|
|
285
|
+
raise ValidationError, "direct enqueue retained association differs"
|
|
286
|
+
end
|
|
287
|
+
public_record(record)
|
|
288
|
+
end
|
|
289
|
+
rescue KeyError, TypeError, SystemCallError
|
|
290
|
+
raise ValidationError, "direct enqueue retained record is unavailable"
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def verify_direct_delivery(binding, require_idle: false)
|
|
294
|
+
with_event(binding.fetch("event_id"), create_lock: false) do |record|
|
|
295
|
+
unless record&.inbox && record.inbox.fetch("attempt_id") == binding.fetch("attempt_id") &&
|
|
296
|
+
record.inbox.fetch("original_context") == binding.slice(*Molecules::InboxDirectEffectBinding::ORIGINAL_FIELDS) &&
|
|
297
|
+
record.inbox["original_binding_digest"].is_a?(String) && Molecules::InboxDirectEffectBinding::SHA.match?(record.inbox.fetch("original_binding_digest"))
|
|
298
|
+
raise ValidationError, "direct delivery retained association differs"
|
|
299
|
+
end
|
|
300
|
+
validate_expected_target!(record.inbox.fetch("origin_target"))
|
|
301
|
+
unless record.inbox.fetch("origin_target").values_at("session", "pane") == [record.session, record.pane] &&
|
|
302
|
+
record.inbox.fetch("binding").fetch("payload_sha256") == record.answer_digest
|
|
303
|
+
raise ValidationError, "direct delivery retained target differs"
|
|
304
|
+
end
|
|
305
|
+
expected = binding.fetch("selection").fetch("expected_claim_generation")
|
|
306
|
+
generation = record.inbox.fetch("claim_generation")
|
|
307
|
+
raise ValidationError, "direct delivery retained generation differs" unless generation >= expected
|
|
308
|
+
idle = case record.state
|
|
309
|
+
when "queued"
|
|
310
|
+
!record.inbox["submission_intent"] && (generation.zero? || record.history.last&.fetch("action", nil) == "pre-submit-rejection")
|
|
311
|
+
when "delivered"
|
|
312
|
+
guarded_queue_record!(record, binding, record.inbox.slice("claim_generation", "claim_owner"))
|
|
313
|
+
receipt = record.inbox["receipt"]
|
|
314
|
+
receipt.is_a?(Hash) && receipt.slice("event_id", "attempt_id", "claim_generation", "payload_sha256", "binding") ==
|
|
315
|
+
{"event_id" => record.event_id, "attempt_id" => binding.fetch("attempt_id"), "claim_generation" => generation,
|
|
316
|
+
"payload_sha256" => record.answer_digest, "binding" => record.inbox.fetch("binding")} &&
|
|
317
|
+
%w[none sent pending not_issued].include?(record.inbox.dig("wake", "status"))
|
|
318
|
+
else
|
|
319
|
+
false
|
|
320
|
+
end
|
|
321
|
+
raise ValidationError, "direct delivery completion is not retained" if require_idle && !idle
|
|
322
|
+
{"record" => public_record(record), "idle" => !!idle}
|
|
323
|
+
end
|
|
324
|
+
rescue KeyError, TypeError, SystemCallError
|
|
325
|
+
raise ValidationError, "direct delivery retained record is unavailable"
|
|
326
|
+
end
|
|
327
|
+
|
|
328
|
+
# Protected canonical reads only use an already registered event's lock.
|
|
329
|
+
# Missing retention is unavailable evidence, never query-time repair.
|
|
330
|
+
def retained_status(event:, deadline: nil)
|
|
331
|
+
validate_id!(event, "event")
|
|
332
|
+
with_event(event, create_lock: false, deadline: deadline) do |record|
|
|
333
|
+
raise MissingEventError, "unknown inbox event: #{event}" unless record&.inbox
|
|
334
|
+
public_record(record)
|
|
335
|
+
end
|
|
336
|
+
rescue SystemCallError, Molecules::DeliveryRecordStore::LockUnavailable
|
|
337
|
+
raise ValidationError, "retained inbox lock is unavailable"
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
# Called while the fixed context owner holds its admission transaction.
|
|
341
|
+
# Only the event claim is written here; no native observation or effect.
|
|
342
|
+
def prepare_direct_delivery(event:, expected_claim_generation:, expected_attempt:, claim_owner:, operation_id:, key_generation:, effect_binding:)
|
|
343
|
+
validate_id!(event, "event")
|
|
344
|
+
unless claim_owner.is_a?(String) && claim_owner.match?(/\A[0-9a-f]{64}\z/)
|
|
345
|
+
raise ValidationError, "direct claim owner differs"
|
|
346
|
+
end
|
|
347
|
+
with_event(event) do |record|
|
|
348
|
+
unless record&.inbox && record.inbox.fetch("attempt_id") == expected_attempt &&
|
|
349
|
+
expected_claim_generation.is_a?(Integer) && expected_claim_generation >= 0
|
|
350
|
+
raise ValidationError, "direct claim original association differs"
|
|
351
|
+
end
|
|
352
|
+
verified = Molecules::InboxDirectEffectBinding.verify!(effect_binding, key_generation: key_generation)
|
|
353
|
+
unless verified.fetch("selection") == {"expected_claim_generation" => expected_claim_generation} && verified.fetch("event_id") == event &&
|
|
354
|
+
verified.fetch("attempt_id") == expected_attempt && verified.slice(*Molecules::InboxDirectEffectBinding::ORIGINAL_FIELDS) == record.inbox.fetch("original_context") &&
|
|
355
|
+
operation_id.is_a?(String) && operation_id.match?(/\A[0-9a-f]{32}\z/) &&
|
|
356
|
+
claim_owner == Digest::SHA256.hexdigest(JSON.generate([record.inbox.fetch("original_context").fetch("inbox_context_id"), operation_id]))
|
|
357
|
+
raise ValidationError, "direct queue issuer association differs"
|
|
358
|
+
end
|
|
359
|
+
current = record.inbox.fetch("claim_generation")
|
|
360
|
+
raise ValidationError, "direct claim expected generation is future" if expected_claim_generation > current
|
|
361
|
+
if record.state == "delivered" && expected_claim_generation == current && %w[pending not_issued].include?(record.inbox.dig("wake", "status"))
|
|
362
|
+
claim = record.inbox.slice("claim_generation", "claim_owner").merge("kind" => "wake")
|
|
363
|
+
guarded_queue_record!(record, effect_binding, claim)
|
|
364
|
+
unless record.inbox.fetch("queue_issuer").fetch("key_generation") == key_generation
|
|
365
|
+
raise ValidationError, "guarded wake key generation differs"
|
|
366
|
+
end
|
|
367
|
+
wake = record.inbox.fetch("wake").reject { |key, _| key == "code" }.merge("status" => "pending",
|
|
368
|
+
"operation_id" => operation_id, "input_sha256" => effect_binding.fetch("input_sha256"))
|
|
369
|
+
save(record.advance_inbox(state: "delivered", inbox: record.inbox.merge("wake" => wake),
|
|
370
|
+
detail: {"action" => "wake-adopt"}, timestamp: Time.now.utc.iso8601))
|
|
371
|
+
next claim
|
|
372
|
+
end
|
|
373
|
+
next nil if expected_claim_generation < current || %w[delivered completed uncertain].include?(record.state)
|
|
374
|
+
unless record.state == "queued" && !record.inbox["submission_intent"] &&
|
|
375
|
+
(current.zero? || record.history.last&.fetch("action", nil) == "pre-submit-rejection")
|
|
376
|
+
raise ValidationError, "direct claim is not known pre-submission"
|
|
377
|
+
end
|
|
378
|
+
claim = record.inbox.reject { |key, _| key == "canonical_completion" }.merge("claim_owner" => claim_owner, "claim_generation" => current + 1,
|
|
379
|
+
"queue_issuer" => {"operation_id" => operation_id, "key_generation" => key_generation, "input_sha256" => verified.fetch("input_sha256")})
|
|
380
|
+
save(record.advance_inbox(state: "claimed", inbox: claim,
|
|
381
|
+
detail: {"action" => "claim", "claim_generation" => current + 1, "claim_owner" => claim_owner}, timestamp: Time.now.utc.iso8601))
|
|
382
|
+
{"kind" => "queue", "claim_generation" => current + 1, "claim_owner" => claim_owner}
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
def deliver(event:, expected_claim_generation: nil, expected_attempt: nil, prepared_claim: nil, &guarded_submission)
|
|
96
387
|
validate_id!(event, "event")
|
|
97
388
|
with_event(event) do |record|
|
|
98
389
|
raise ValidationError, "unknown inbox event: #{event}" unless record&.inbox
|
|
99
|
-
|
|
390
|
+
if prepared_claim
|
|
391
|
+
unless prepared_claim.is_a?(Hash) && prepared_claim.keys.sort == %w[claim_generation claim_owner] &&
|
|
392
|
+
record.state == "claimed" && !record.inbox["submission_intent"] &&
|
|
393
|
+
record.inbox.slice("claim_generation", "claim_owner") == prepared_claim &&
|
|
394
|
+
record.inbox.fetch("attempt_id") == expected_attempt &&
|
|
395
|
+
expected_claim_generation.is_a?(Integer) && prepared_claim.fetch("claim_generation") == expected_claim_generation + 1
|
|
396
|
+
raise ValidationError, "prepared direct claim changed"
|
|
397
|
+
end
|
|
398
|
+
elsif !expected_claim_generation.nil?
|
|
399
|
+
unless expected_claim_generation.is_a?(Integer) && expected_claim_generation >= 0 &&
|
|
400
|
+
expected_attempt.is_a?(String) && EVENT.match?(expected_attempt) &&
|
|
401
|
+
record.inbox.fetch("attempt_id") == expected_attempt
|
|
402
|
+
raise ValidationError, "protected delivery original association differs"
|
|
403
|
+
end
|
|
404
|
+
current = record.inbox.fetch("claim_generation")
|
|
405
|
+
raise ValidationError, "protected delivery expected generation is future" if expected_claim_generation > current
|
|
406
|
+
next public_record(record) if expected_claim_generation < current || %w[delivered completed uncertain].include?(record.state)
|
|
407
|
+
unless record.state == "queued" && !record.inbox["submission_intent"] &&
|
|
408
|
+
(current.zero? || record.history.last&.fetch("action", nil) == "pre-submit-rejection")
|
|
409
|
+
raise ValidationError, "protected delivery is not known pre-submission"
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
next retry_wake(record) if record.state == "delivered" && wake_pending?(record) && !record.inbox["original_context"]
|
|
100
413
|
next public_record(record) if %w[delivered completed uncertain].include?(record.state)
|
|
101
|
-
unless record.state == "queued"
|
|
414
|
+
unless record.state == "queued" || prepared_claim
|
|
102
415
|
if record.inbox["submission_intent"]
|
|
103
416
|
# A previous owner crashed after saving submission intent:
|
|
104
417
|
# the submission boundary is unknown to a new process.
|
|
@@ -114,12 +427,14 @@ module Ace
|
|
|
114
427
|
next public_record(record)
|
|
115
428
|
end
|
|
116
429
|
|
|
117
|
-
claim = record.inbox.merge(
|
|
430
|
+
claim = prepared_claim ? record.inbox : record.inbox.reject { |key, _| key == "canonical_completion" }.merge(
|
|
118
431
|
"claim_owner" => "#{Process.pid}:#{SecureRandom.hex(8)}",
|
|
119
432
|
"claim_generation" => record.inbox.fetch("claim_generation", 0) + 1
|
|
120
433
|
)
|
|
121
|
-
|
|
122
|
-
|
|
434
|
+
unless prepared_claim
|
|
435
|
+
record = transition(record, "claimed", claim, "claim")
|
|
436
|
+
save(record)
|
|
437
|
+
end
|
|
123
438
|
|
|
124
439
|
begin
|
|
125
440
|
binding = observe(record)
|
|
@@ -141,7 +456,7 @@ module Ace
|
|
|
141
456
|
record = transition(record, "uncertain", intent, "submit-intent")
|
|
142
457
|
save(record)
|
|
143
458
|
|
|
144
|
-
result = submit(record, binding)
|
|
459
|
+
result = submit(record, binding, guarded_submission)
|
|
145
460
|
if result["pre_submit"]
|
|
146
461
|
# Missing executable is the only proven pre-submission error.
|
|
147
462
|
record = transition(record, "queued", bound.merge("last_error" => result["error"]),
|
|
@@ -155,18 +470,26 @@ module Ace
|
|
|
155
470
|
# but its DECISION rides the first delivered transition: a crash
|
|
156
471
|
# between saves must never leave the wake field missing (which
|
|
157
472
|
# later reads as pending) for a busy target.
|
|
158
|
-
wake = if
|
|
473
|
+
wake = if binding.fetch("agent") == "codex"
|
|
474
|
+
{"status" => "none", "reason" => "terminal prompt was submitted"}
|
|
475
|
+
elsif WAKE_STATUSES.include?(binding["agent_status"])
|
|
159
476
|
{"status" => "pending"}
|
|
160
477
|
else
|
|
161
478
|
{"status" => "none", "reason" => "busy target uses the native queue form"}
|
|
162
479
|
end
|
|
480
|
+
if record.inbox["original_context"]
|
|
481
|
+
issuer = queue_issuer!(record)
|
|
482
|
+
wake = {"status" => wake.fetch("status"), "claim_generation" => intent.fetch("claim_generation"),
|
|
483
|
+
"binding_digest" => intent.fetch("original_binding_digest"), "operation_id" => issuer.fetch("operation_id"),
|
|
484
|
+
"input_sha256" => issuer.fetch("input_sha256")}
|
|
485
|
+
end
|
|
163
486
|
record = transition(record, "delivered",
|
|
164
487
|
intent.merge("receipt" => receipt).merge("wake" => wake), "accepted")
|
|
165
488
|
save(record)
|
|
166
489
|
# A pending wake is attempted after the decision is durable, so
|
|
167
490
|
# a crash or transient failure can be recovered by a later
|
|
168
491
|
# deliver call without ever resubmitting the message.
|
|
169
|
-
record = attempt_wake(record, binding) if wake["status"] == "pending"
|
|
492
|
+
record = attempt_wake(record, binding) if wake["status"] == "pending" && !record.inbox["original_context"]
|
|
170
493
|
else
|
|
171
494
|
record = transition(record, "uncertain", intent.merge("last_error" => result["error"]),
|
|
172
495
|
"submission-uncertain", result["error"])
|
|
@@ -176,122 +499,21 @@ module Ace
|
|
|
176
499
|
end
|
|
177
500
|
end
|
|
178
501
|
|
|
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
502
|
private
|
|
251
503
|
|
|
252
|
-
def key_fingerprint
|
|
253
|
-
Digest::SHA256.hexdigest(@receipt_public_key.public_to_der)
|
|
254
|
-
end
|
|
255
504
|
|
|
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
505
|
|
|
266
|
-
nil
|
|
267
|
-
rescue JSON::ParserError, OpenSSL::PKey::PKeyError
|
|
268
|
-
"receipt signature is invalid"
|
|
269
|
-
end
|
|
270
506
|
|
|
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
507
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def with_event(event, create_lock: true, deadline: nil)
|
|
511
|
+
Molecules::DeliveryRecordStore.with_lock(@deliveries_dir, event, create: create_lock, deadline: deadline) do
|
|
512
|
+
record = Molecules::DeliveryRecordStore.load(@deliveries_dir, event)
|
|
513
|
+
if record
|
|
514
|
+
Ace::Hitl::Providers::Ref.new(session: record.session, pane: record.pane, canonical: true)
|
|
515
|
+
end
|
|
516
|
+
yield record
|
|
295
517
|
end
|
|
296
518
|
end
|
|
297
519
|
|
|
@@ -313,13 +535,20 @@ module Ace
|
|
|
313
535
|
hash = ref.is_a?(Hash) ? ref : JSON.parse(File.read(ref))
|
|
314
536
|
raise ValidationError, "invalid ref: expected a JSON object" unless hash.is_a?(Hash)
|
|
315
537
|
Ace::Hitl::Providers::Ref.new(
|
|
316
|
-
session:
|
|
317
|
-
pane: Ace::Hitl::Providers::Ref.validate!(hash.fetch("pane"), "pane")
|
|
538
|
+
session: hash.fetch("session"), pane: hash.fetch("pane"), canonical: !ref.is_a?(Hash)
|
|
318
539
|
)
|
|
319
540
|
rescue Errno::ENOENT, JSON::ParserError, KeyError, TypeError => e
|
|
320
541
|
raise ValidationError, "invalid ref: #{e.message}"
|
|
321
542
|
end
|
|
322
543
|
|
|
544
|
+
def validate_expected_target!(target)
|
|
545
|
+
unless target.is_a?(Hash) && TARGET_IDENTITY.all? { |key| target[key].is_a?(String) && !target[key].empty? } &&
|
|
546
|
+
%w[codex pi].include?(target["agent"]) && target["thread_kind"] == "id" &&
|
|
547
|
+
THREAD_ID.match?(target["thread"]) && target["terminal_id"].bytesize <= 64
|
|
548
|
+
raise ValidationError, "accepted original native target is incomplete or invalid"
|
|
549
|
+
end
|
|
550
|
+
end
|
|
551
|
+
|
|
323
552
|
def validate_match!(record, attempt, address, digest)
|
|
324
553
|
unless record.inbox && record.inbox["attempt_id"] == attempt &&
|
|
325
554
|
record.session == address.session && record.pane == address.pane &&
|
|
@@ -335,9 +564,9 @@ module Ace
|
|
|
335
564
|
# Target drift is classified BEFORE agent-specific event id rules:
|
|
336
565
|
# a bound event whose pane changed agent (e.g. codex -> pi) must
|
|
337
566
|
# reconcile, not loop on a pre-send validation error.
|
|
338
|
-
stable =
|
|
567
|
+
stable = TARGET_IDENTITY
|
|
339
568
|
if target && !stable.all? { |key| target[key] == binding[key] }
|
|
340
|
-
raise IdentityDriftError, "target identity changed since enqueue;
|
|
569
|
+
raise IdentityDriftError, "target identity changed since enqueue; delivery remains uncertain"
|
|
341
570
|
end
|
|
342
571
|
if binding["agent"] == "pi" && !PI_EVENT_ID.match?(record.event_id)
|
|
343
572
|
raise ValidationError, "Pi queue requires an inbox or wake event ID"
|
|
@@ -360,7 +589,7 @@ module Ace
|
|
|
360
589
|
# An agent change is target drift: classify it as reconciliation-
|
|
361
590
|
# worthy before any agent-specific validation can mask it.
|
|
362
591
|
if target && target["agent"] != agent
|
|
363
|
-
raise IdentityDriftError, "target identity changed since enqueue;
|
|
592
|
+
raise IdentityDriftError, "target identity changed since enqueue; delivery remains uncertain"
|
|
364
593
|
end
|
|
365
594
|
raise ValidationError, "unsupported native agent" unless %w[codex pi].include?(agent)
|
|
366
595
|
session = pane["agent_session"]
|
|
@@ -399,9 +628,23 @@ module Ace
|
|
|
399
628
|
"agent_status" => status}
|
|
400
629
|
end
|
|
401
630
|
|
|
402
|
-
def submit(record, binding)
|
|
403
|
-
|
|
631
|
+
def submit(record, binding, guarded_submission)
|
|
632
|
+
if binding.fetch("agent") == "codex"
|
|
633
|
+
if record.inbox["original_context"]
|
|
634
|
+
raise ValidationError, "original terminal submission owner is unavailable" unless guarded_submission
|
|
635
|
+
result = guarded_submission.call(record.answer)
|
|
636
|
+
return {"accepted" => true} if result["outcome"] == "submitted"
|
|
637
|
+
return {"accepted" => false, "pre_submit" => true, "error" => result.fetch("code")} if result["outcome"] == "not_issued"
|
|
638
|
+
return {"accepted" => false, "error" => "original terminal submission outcome unknown"}
|
|
639
|
+
end
|
|
640
|
+
result = @executor.agent_prompt_bounded(pane: binding.fetch("pane"), text: record.answer,
|
|
641
|
+
timeout_ms: 10_000)
|
|
642
|
+
return {"accepted" => true, "stdout" => result.stdout}
|
|
643
|
+
end
|
|
644
|
+
@native.submit(agent: binding.fetch("agent"), thread: binding.fetch("thread"),
|
|
404
645
|
event_id: record.event_id, digest: record.answer_digest, payload: record.answer)
|
|
646
|
+
rescue ExecutorUnavailableError => e
|
|
647
|
+
{"accepted" => false, "pre_submit" => true, "error" => e.message}
|
|
405
648
|
rescue ExecutorError => e
|
|
406
649
|
{"accepted" => false, "error" => e.message}
|
|
407
650
|
end
|
|
@@ -438,6 +681,7 @@ module Ace
|
|
|
438
681
|
end
|
|
439
682
|
|
|
440
683
|
def wake_pending?(record)
|
|
684
|
+
return false if record.inbox.dig("binding", "agent") == "codex"
|
|
441
685
|
wake = record.inbox["wake"]
|
|
442
686
|
wake.nil? || wake["status"] == "pending"
|
|
443
687
|
end
|
|
@@ -457,6 +701,7 @@ module Ace
|
|
|
457
701
|
end
|
|
458
702
|
|
|
459
703
|
def wake_idle(binding)
|
|
704
|
+
raise ValidationError, "Codex native queue has no terminal wake operation" unless binding.fetch("agent") == "pi"
|
|
460
705
|
@executor.agent_prompt_bounded(pane: binding["pane"],
|
|
461
706
|
text: "Check your native queued messages.", timeout_ms: 10_000)
|
|
462
707
|
nil
|
|
@@ -468,13 +713,15 @@ module Ace
|
|
|
468
713
|
{"event_id" => record.event_id, "attempt_id" => record.inbox["attempt_id"],
|
|
469
714
|
"session" => record.session, "pane" => record.pane,
|
|
470
715
|
"payload_sha256" => record.answer_digest, "state" => record.state,
|
|
471
|
-
"
|
|
716
|
+
"managed_envelope" => record.inbox["managed_envelope"],
|
|
717
|
+
"original_context" => record.inbox["original_context"],
|
|
718
|
+
"original_binding_digest" => record.inbox["original_binding_digest"],
|
|
472
719
|
"claim_generation" => record.inbox["claim_generation"],
|
|
473
720
|
"claim_owner" => record.inbox["claim_owner"],
|
|
474
721
|
"submission_intent" => !!record.inbox["submission_intent"],
|
|
722
|
+
"origin_target" => record.inbox["origin_target"],
|
|
475
723
|
"target" => record.inbox["target"],
|
|
476
724
|
"binding" => record.inbox["binding"], "receipt" => record.inbox["receipt"],
|
|
477
|
-
"reconciliation" => record.inbox["reconciliation"],
|
|
478
725
|
"wake" => record.inbox["wake"],
|
|
479
726
|
"last_error" => record.inbox["last_error"]}
|
|
480
727
|
end
|