ace-herdr 0.4.0 → 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 +12 -0
- data/docs/usage.md +57 -35
- data/lib/ace/herdr/cli/commands/inbox.rb +83 -21
- data/lib/ace/herdr/cli/commands/native_source.rb +56 -0
- data/lib/ace/herdr/cli.rb +3 -0
- data/lib/ace/herdr/errors.rb +23 -0
- data/lib/ace/herdr/molecules/bounded_process.rb +261 -52
- data/lib/ace/herdr/molecules/delivery_record_store.rb +81 -6
- 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 +129 -4
- 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/inbox.rb +293 -203
- 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/version.rb +1 -1
- data/lib/ace/herdr.rb +5 -28
- metadata +64 -5
- data/lib/ace/herdr/organisms/protected_inbox.rb +0 -73
|
@@ -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,7 +16,9 @@ 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/
|
|
19
24
|
TARGET_IDENTITY = %w[session pane terminal_id agent thread thread_kind].freeze
|
|
@@ -29,60 +34,127 @@ module Ace
|
|
|
29
34
|
AGENT_STATUSES = %w[idle busy working blocked done].freeze
|
|
30
35
|
WAKE_STATUSES = %w[idle done].freeze
|
|
31
36
|
|
|
32
|
-
#
|
|
33
|
-
# receipt consumers. Configuration remains supervisor-owned.
|
|
37
|
+
# The maintained CLI uses the configured durable delivery store.
|
|
34
38
|
def self.from_config(config: Ace::Herdr.config, root: Dir.pwd,
|
|
35
39
|
executor: Molecules::HerdrExecutor.new, native: Molecules::NativeQueueExecutor.new)
|
|
36
|
-
path = config["inbox_receipt_public_key"]
|
|
37
|
-
key = begin
|
|
38
|
-
candidate = OpenSSL::PKey.read(File.read(path)) if path.is_a?(String) && path.start_with?("/")
|
|
39
|
-
candidate if candidate.is_a?(OpenSSL::PKey::RSA) && !candidate.private?
|
|
40
|
-
rescue SystemCallError, OpenSSL::PKey::PKeyError
|
|
41
|
-
nil
|
|
42
|
-
end
|
|
43
40
|
new(executor: executor, native: native,
|
|
44
|
-
deliveries_dir: File.expand_path(config["deliveries_dir"] || ".ace-local/herdr/deliveries", root)
|
|
45
|
-
receipt_public_key: key)
|
|
41
|
+
deliveries_dir: File.expand_path(config["deliveries_dir"] || ".ace-local/herdr/deliveries", root))
|
|
46
42
|
end
|
|
47
43
|
|
|
48
44
|
# Enumerate retained event identities through the same store/lock owner.
|
|
49
45
|
# Unreadable unattributable records cannot be treated as another attempt.
|
|
50
46
|
def self.retained_events(deliveries_dir:, attempt:)
|
|
51
47
|
raise ValidationError, "invalid attempt id" unless attempt.is_a?(String) && EVENT.match?(attempt)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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|
|
|
57
114
|
raise ValidationError, "retained inbox event id is invalid" unless EVENT.match?(event)
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
Molecules::DeliveryRecordStore.path_for(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
record
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
66
135
|
end
|
|
67
|
-
record.to_h
|
|
68
136
|
end
|
|
69
|
-
|
|
70
|
-
# precedence hide a corrupt or conflicting archived copy.
|
|
71
|
-
if copies.empty? || copies.uniq.length != 1
|
|
137
|
+
if copies.empty? || copies.map(&:to_h).uniq.length != 1
|
|
72
138
|
raise ValidationError, "retained inbox copies conflict"
|
|
73
139
|
end
|
|
74
|
-
copies.first
|
|
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)
|
|
75
146
|
end
|
|
76
147
|
end
|
|
77
|
-
|
|
148
|
+
raise ValidationError, "retained inbox inventory changed" unless inventory.call == ids
|
|
149
|
+
records.freeze
|
|
150
|
+
rescue SystemCallError, JSON::ParserError, ArgumentError, KeyError, TypeError
|
|
78
151
|
raise ValidationError, "retained inbox inventory is unavailable"
|
|
79
152
|
end
|
|
80
153
|
|
|
81
|
-
def initialize(executor:,
|
|
154
|
+
def initialize(executor:, deliveries_dir:, native: Molecules::NativeQueueExecutor.new)
|
|
82
155
|
@executor = executor
|
|
83
156
|
@native = native
|
|
84
157
|
@deliveries_dir = deliveries_dir
|
|
85
|
-
@receipt_public_key = receipt_public_key
|
|
86
158
|
end
|
|
87
159
|
|
|
88
160
|
# Project the accepted runtime owner before consuming an answer. Native
|
|
@@ -98,10 +170,12 @@ module Ace
|
|
|
98
170
|
target.transform_values { |value| value.dup.freeze }.freeze
|
|
99
171
|
end
|
|
100
172
|
|
|
101
|
-
def
|
|
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)
|
|
102
177
|
validate_id!(event, "event")
|
|
103
178
|
validate_id!(attempt, "attempt")
|
|
104
|
-
raise ValidationError, "trusted receipt public key is unavailable" unless @receipt_public_key
|
|
105
179
|
payload = payload.to_s
|
|
106
180
|
raise ValidationError, "payload is required" if payload.empty?
|
|
107
181
|
raise ValidationError, "payload contains NUL" if payload.include?("\0")
|
|
@@ -112,6 +186,14 @@ module Ace
|
|
|
112
186
|
raise ValidationError, "payload is not valid UTF-8 text"
|
|
113
187
|
end
|
|
114
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
|
|
115
197
|
address = address_for(ref)
|
|
116
198
|
digest = Digest::SHA256.hexdigest(payload)
|
|
117
199
|
envelope = if managed_envelope
|
|
@@ -136,7 +218,7 @@ module Ace
|
|
|
136
218
|
with_event(event) do |record|
|
|
137
219
|
if record
|
|
138
220
|
validate_match!(record, attempt, address, digest)
|
|
139
|
-
unless record.inbox["managed_envelope"] == envelope
|
|
221
|
+
unless record.inbox["managed_envelope"] == envelope && record.inbox["original_context"] == original_context && record.inbox["original_binding_digest"] == original_binding_digest
|
|
140
222
|
raise ValidationError, "managed envelope conflicts with immutable inbox event"
|
|
141
223
|
end
|
|
142
224
|
if expected_target && record.inbox["origin_target"] != expected_target
|
|
@@ -156,7 +238,7 @@ module Ace
|
|
|
156
238
|
end
|
|
157
239
|
payload_limit = target["agent"] == "pi" ?
|
|
158
240
|
Molecules::NativeQueueExecutor::PI_PAYLOAD_LIMIT_BYTES :
|
|
159
|
-
Molecules::NativeQueueExecutor::
|
|
241
|
+
Molecules::NativeQueueExecutor::CODEX_PAYLOAD_LIMIT_BYTES
|
|
160
242
|
if payload.bytesize > payload_limit
|
|
161
243
|
raise ValidationError,
|
|
162
244
|
"payload exceeds the #{target['agent']} native queue limit " \
|
|
@@ -166,8 +248,7 @@ module Ace
|
|
|
166
248
|
event_id: event, session: address.session, pane: address.pane,
|
|
167
249
|
answer_digest: digest, answer: payload, state: "queued",
|
|
168
250
|
inbox: {"attempt_id" => attempt, "claim_generation" => 0,
|
|
169
|
-
"
|
|
170
|
-
"managed_envelope" => envelope,
|
|
251
|
+
"managed_envelope" => envelope, "original_context" => original_context, "original_binding_digest" => original_binding_digest,
|
|
171
252
|
"origin_target" => target.slice(*TARGET_IDENTITY),
|
|
172
253
|
"target" => target, "binding" => target.merge("payload_sha256" => digest)}
|
|
173
254
|
)
|
|
@@ -181,31 +262,156 @@ module Ace
|
|
|
181
262
|
def status(event:)
|
|
182
263
|
validate_id!(event, "event")
|
|
183
264
|
with_event(event) do |record|
|
|
184
|
-
raise
|
|
265
|
+
raise MissingEventError, "unknown inbox event: #{event}" unless record&.inbox
|
|
185
266
|
|
|
186
267
|
public_record(record)
|
|
187
268
|
end
|
|
188
269
|
end
|
|
189
270
|
|
|
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
|
+
|
|
190
328
|
# Protected canonical reads only use an already registered event's lock.
|
|
191
329
|
# Missing retention is unavailable evidence, never query-time repair.
|
|
192
|
-
def retained_status(event:)
|
|
330
|
+
def retained_status(event:, deadline: nil)
|
|
193
331
|
validate_id!(event, "event")
|
|
194
|
-
with_event(event, create_lock: false) do |record|
|
|
195
|
-
raise
|
|
332
|
+
with_event(event, create_lock: false, deadline: deadline) do |record|
|
|
333
|
+
raise MissingEventError, "unknown inbox event: #{event}" unless record&.inbox
|
|
196
334
|
public_record(record)
|
|
197
335
|
end
|
|
198
|
-
rescue SystemCallError
|
|
336
|
+
rescue SystemCallError, Molecules::DeliveryRecordStore::LockUnavailable
|
|
199
337
|
raise ValidationError, "retained inbox lock is unavailable"
|
|
200
338
|
end
|
|
201
339
|
|
|
202
|
-
|
|
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)
|
|
203
387
|
validate_id!(event, "event")
|
|
204
388
|
with_event(event) do |record|
|
|
205
389
|
raise ValidationError, "unknown inbox event: #{event}" unless record&.inbox
|
|
206
|
-
|
|
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"]
|
|
207
413
|
next public_record(record) if %w[delivered completed uncertain].include?(record.state)
|
|
208
|
-
unless record.state == "queued"
|
|
414
|
+
unless record.state == "queued" || prepared_claim
|
|
209
415
|
if record.inbox["submission_intent"]
|
|
210
416
|
# A previous owner crashed after saving submission intent:
|
|
211
417
|
# the submission boundary is unknown to a new process.
|
|
@@ -221,12 +427,14 @@ module Ace
|
|
|
221
427
|
next public_record(record)
|
|
222
428
|
end
|
|
223
429
|
|
|
224
|
-
claim = record.inbox.merge(
|
|
430
|
+
claim = prepared_claim ? record.inbox : record.inbox.reject { |key, _| key == "canonical_completion" }.merge(
|
|
225
431
|
"claim_owner" => "#{Process.pid}:#{SecureRandom.hex(8)}",
|
|
226
432
|
"claim_generation" => record.inbox.fetch("claim_generation", 0) + 1
|
|
227
433
|
)
|
|
228
|
-
|
|
229
|
-
|
|
434
|
+
unless prepared_claim
|
|
435
|
+
record = transition(record, "claimed", claim, "claim")
|
|
436
|
+
save(record)
|
|
437
|
+
end
|
|
230
438
|
|
|
231
439
|
begin
|
|
232
440
|
binding = observe(record)
|
|
@@ -248,7 +456,7 @@ module Ace
|
|
|
248
456
|
record = transition(record, "uncertain", intent, "submit-intent")
|
|
249
457
|
save(record)
|
|
250
458
|
|
|
251
|
-
result = submit(record, binding)
|
|
459
|
+
result = submit(record, binding, guarded_submission)
|
|
252
460
|
if result["pre_submit"]
|
|
253
461
|
# Missing executable is the only proven pre-submission error.
|
|
254
462
|
record = transition(record, "queued", bound.merge("last_error" => result["error"]),
|
|
@@ -262,18 +470,26 @@ module Ace
|
|
|
262
470
|
# but its DECISION rides the first delivered transition: a crash
|
|
263
471
|
# between saves must never leave the wake field missing (which
|
|
264
472
|
# later reads as pending) for a busy target.
|
|
265
|
-
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"])
|
|
266
476
|
{"status" => "pending"}
|
|
267
477
|
else
|
|
268
478
|
{"status" => "none", "reason" => "busy target uses the native queue form"}
|
|
269
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
|
|
270
486
|
record = transition(record, "delivered",
|
|
271
487
|
intent.merge("receipt" => receipt).merge("wake" => wake), "accepted")
|
|
272
488
|
save(record)
|
|
273
489
|
# A pending wake is attempted after the decision is durable, so
|
|
274
490
|
# a crash or transient failure can be recovered by a later
|
|
275
491
|
# deliver call without ever resubmitting the message.
|
|
276
|
-
record = attempt_wake(record, binding) if wake["status"] == "pending"
|
|
492
|
+
record = attempt_wake(record, binding) if wake["status"] == "pending" && !record.inbox["original_context"]
|
|
277
493
|
else
|
|
278
494
|
record = transition(record, "uncertain", intent.merge("last_error" => result["error"]),
|
|
279
495
|
"submission-uncertain", result["error"])
|
|
@@ -283,158 +499,16 @@ module Ace
|
|
|
283
499
|
end
|
|
284
500
|
end
|
|
285
501
|
|
|
286
|
-
def reconcile(event:, receipt:, signed_bytes: nil, signature: nil, expected_registration: nil)
|
|
287
|
-
reconcile_record(event: event, receipt: receipt, signed_bytes: signed_bytes,
|
|
288
|
-
signature: signature, expected_registration: expected_registration, settle: true)
|
|
289
|
-
end
|
|
290
|
-
|
|
291
|
-
# Canonical consumers reverify retained signed settlement without creating
|
|
292
|
-
# a new local transition. The same event lock and proof owner are used.
|
|
293
|
-
def verify_reconciliation(event:, receipt:, signed_bytes:, signature:, expected_registration:)
|
|
294
|
-
unless expected_registration.is_a?(Hash) && expected_registration.keys.sort ==
|
|
295
|
-
%w[event_id attempt_id payload_sha256 receipt_key_sha256].sort &&
|
|
296
|
-
%w[event_id attempt_id].all? { |key| expected_registration[key].is_a?(String) && EVENT.match?(expected_registration[key]) } &&
|
|
297
|
-
%w[payload_sha256 receipt_key_sha256].all? { |key| expected_registration[key].is_a?(String) && expected_registration[key].match?(/\A[0-9a-f]{64}\z/) }
|
|
298
|
-
raise ValidationError, "canonical reconciliation requires exact registration"
|
|
299
|
-
end
|
|
300
|
-
reconcile_record(event: event, receipt: receipt, signed_bytes: signed_bytes,
|
|
301
|
-
signature: signature, expected_registration: expected_registration, settle: false)
|
|
302
|
-
rescue SystemCallError
|
|
303
|
-
raise ValidationError, "retained inbox lock is unavailable"
|
|
304
|
-
end
|
|
305
|
-
|
|
306
502
|
private
|
|
307
503
|
|
|
308
|
-
def reconcile_record(event:, receipt:, signed_bytes:, signature:, expected_registration:, settle:)
|
|
309
|
-
validate_id!(event, "event")
|
|
310
|
-
with_event(event, create_lock: settle) do |record|
|
|
311
|
-
raise ValidationError, "unknown inbox event: #{event}" unless record&.inbox
|
|
312
|
-
# The consumer's accepted registration must match under the same
|
|
313
|
-
# event lock that verifies and settles the signed observation.
|
|
314
|
-
unless expected_registration.nil?
|
|
315
|
-
registration = public_record(record).slice("event_id", "attempt_id", "payload_sha256", "receipt_key_sha256")
|
|
316
|
-
unless expected_registration == registration
|
|
317
|
-
raise ValidationError, "inbox event differs from expected registration"
|
|
318
|
-
end
|
|
319
|
-
end
|
|
320
|
-
# `delivered` only proves native queue acceptance: the message may
|
|
321
|
-
# still be consumed or evicted afterwards, so a signed observation
|
|
322
|
-
# can reconcile it exactly like an uncertain outcome.
|
|
323
|
-
replay = %w[completed queued].include?(record.state) && receipt.is_a?(Hash) &&
|
|
324
|
-
receipt == record.inbox["reconciliation"]
|
|
325
|
-
unless replay || (settle && %w[uncertain delivered].include?(record.state))
|
|
326
|
-
raise ValidationError, "event is not reconcilable from state #{record.state}"
|
|
327
|
-
end
|
|
328
|
-
binding = record.inbox["binding"]
|
|
329
|
-
matches = receipt.is_a?(Hash) && receipt["event_id"] == event &&
|
|
330
|
-
receipt["attempt_id"] == record.inbox["attempt_id"] &&
|
|
331
|
-
receipt["claim_generation"] == record.inbox["claim_generation"] &&
|
|
332
|
-
receipt["payload_sha256"] == record.answer_digest &&
|
|
333
|
-
receipt["binding"] == binding
|
|
334
|
-
refusal = if !binding
|
|
335
|
-
"event has no verified binding"
|
|
336
|
-
elsif !matches
|
|
337
|
-
"reconciliation receipt does not match bound event"
|
|
338
|
-
end
|
|
339
|
-
refusal ||= proof_refusal(receipt) if receipt.is_a?(Hash)
|
|
340
|
-
refusal ||= signature_refusal(record, receipt, signed_bytes, signature)
|
|
341
|
-
replacement = nil
|
|
342
|
-
if !refusal && receipt["outcome"] == "superseded" && receipt.key?("replacement_target")
|
|
343
|
-
replacement = receipt["replacement_target"]
|
|
344
|
-
observed = begin
|
|
345
|
-
raise ValidationError, "replacement target must be an object" unless replacement.is_a?(Hash)
|
|
346
|
-
|
|
347
|
-
address = address_for(replacement)
|
|
348
|
-
observe_target(address.session, address.pane)
|
|
349
|
-
rescue ValidationError, ExecutorError, Ace::Hitl::Providers::InvalidRefError => e
|
|
350
|
-
refusal = "replacement target cannot be verified: #{e.message}"
|
|
351
|
-
nil
|
|
352
|
-
end
|
|
353
|
-
stable = TARGET_IDENTITY
|
|
354
|
-
unless replacement.is_a?(Hash) && observed &&
|
|
355
|
-
stable.all? { |key| replacement[key] == observed[key] }
|
|
356
|
-
refusal ||= "replacement target does not match the live native session"
|
|
357
|
-
end
|
|
358
|
-
# The replacement agent's own delivery rules must admit this
|
|
359
|
-
# event: its immutable-id requirement and payload bound.
|
|
360
|
-
if observed
|
|
361
|
-
if observed["agent"] == "pi" && !PI_EVENT_ID.match?(event)
|
|
362
|
-
refusal ||= "replacement pi target requires an inbox (inb-) or wake (wnk-) event id"
|
|
363
|
-
end
|
|
364
|
-
payload_limit = observed["agent"] == "pi" ?
|
|
365
|
-
Molecules::NativeQueueExecutor::PI_PAYLOAD_LIMIT_BYTES :
|
|
366
|
-
Molecules::NativeQueueExecutor::MAX_ARG_PAYLOAD_BYTES
|
|
367
|
-
if record.answer.to_s.bytesize > payload_limit
|
|
368
|
-
refusal ||= "payload exceeds the replacement agent's native queue limit"
|
|
369
|
-
end
|
|
370
|
-
end
|
|
371
|
-
end
|
|
372
|
-
next public_record(record).merge("reconciliation_refusal" => refusal) if refusal
|
|
373
|
-
# The consumer may crash after Herdr settles but before recording
|
|
374
|
-
# its observation reference. Re-verify the identical signed proof
|
|
375
|
-
# without another transition. A later claim has a new generation
|
|
376
|
-
# and fails the binding checks above.
|
|
377
|
-
next public_record(record) if replay
|
|
378
|
-
|
|
379
|
-
outcome = receipt["outcome"]
|
|
380
|
-
state = outcome == "consumed" ? "completed" : "queued"
|
|
381
|
-
inbox = record.inbox.merge("reconciliation" => receipt)
|
|
382
|
-
if state == "queued"
|
|
383
|
-
inbox = inbox.reject do |key, _|
|
|
384
|
-
%w[submission_intent claim_owner receipt].include?(key)
|
|
385
|
-
end
|
|
386
|
-
inbox = inbox.merge("target" => replacement) if replacement
|
|
387
|
-
end
|
|
388
|
-
record = transition(record, state, inbox, "reconcile-#{outcome}")
|
|
389
|
-
save(record)
|
|
390
|
-
public_record(record)
|
|
391
|
-
end
|
|
392
|
-
end
|
|
393
504
|
|
|
394
|
-
private
|
|
395
505
|
|
|
396
|
-
def key_fingerprint
|
|
397
|
-
Digest::SHA256.hexdigest(@receipt_public_key.public_to_der)
|
|
398
|
-
end
|
|
399
506
|
|
|
400
|
-
def signature_refusal(record, receipt, signed_bytes, signature)
|
|
401
|
-
return "trusted receipt public key is unavailable" unless @receipt_public_key
|
|
402
|
-
unless record.inbox["receipt_key_sha256"] == key_fingerprint
|
|
403
|
-
return "trusted receipt public key differs from the enqueued event"
|
|
404
|
-
end
|
|
405
|
-
return "receipt signature is missing" unless signed_bytes.is_a?(String) && signature.is_a?(String)
|
|
406
|
-
return "signed receipt content differs from parsed receipt" unless JSON.parse(signed_bytes) == receipt
|
|
407
|
-
return "receipt signature is invalid" unless @receipt_public_key.verify(
|
|
408
|
-
OpenSSL::Digest::SHA256.new, signature, signed_bytes)
|
|
409
507
|
|
|
410
|
-
nil
|
|
411
|
-
rescue JSON::ParserError, OpenSSL::PKey::PKeyError
|
|
412
|
-
"receipt signature is invalid"
|
|
413
|
-
end
|
|
414
508
|
|
|
415
|
-
# The receipt is an explicit operator or supervisor attestation of a
|
|
416
|
-
# native outcome. Native queue submission alone cannot prove consumption
|
|
417
|
-
# or nonconsumption; an absent or incomplete attestation is never retried.
|
|
418
|
-
def proof_refusal(receipt)
|
|
419
|
-
return "invalid reconciliation outcome" unless %w[consumed superseded].include?(receipt["outcome"])
|
|
420
|
-
observer = receipt["observer"]
|
|
421
|
-
unless observer.is_a?(Hash) && %w[operator supervisor].include?(observer["role"]) &&
|
|
422
|
-
observer["id"].is_a?(String) && !observer["id"].strip.empty?
|
|
423
|
-
return "receipt requires an identified operator or supervisor"
|
|
424
|
-
end
|
|
425
|
-
evidence = receipt["evidence"]
|
|
426
|
-
kinds = receipt["outcome"] == "consumed" ? %w[consumed_acknowledged] :
|
|
427
|
-
%w[queue_evicted queue_expired thread_replaced]
|
|
428
|
-
unless evidence.is_a?(Hash) && kinds.include?(evidence["kind"]) &&
|
|
429
|
-
evidence["native_reference"].is_a?(String) && !evidence["native_reference"].strip.empty? &&
|
|
430
|
-
evidence["observation"].is_a?(String) && !evidence["observation"].strip.empty?
|
|
431
|
-
return "receipt requires a native outcome observation and reference"
|
|
432
|
-
end
|
|
433
|
-
nil
|
|
434
|
-
end
|
|
435
509
|
|
|
436
|
-
def with_event(event, create_lock: true)
|
|
437
|
-
Molecules::DeliveryRecordStore.with_lock(@deliveries_dir, event, create: create_lock) do
|
|
510
|
+
def with_event(event, create_lock: true, deadline: nil)
|
|
511
|
+
Molecules::DeliveryRecordStore.with_lock(@deliveries_dir, event, create: create_lock, deadline: deadline) do
|
|
438
512
|
record = Molecules::DeliveryRecordStore.load(@deliveries_dir, event)
|
|
439
513
|
if record
|
|
440
514
|
Ace::Hitl::Providers::Ref.new(session: record.session, pane: record.pane, canonical: true)
|
|
@@ -492,7 +566,7 @@ module Ace
|
|
|
492
566
|
# reconcile, not loop on a pre-send validation error.
|
|
493
567
|
stable = TARGET_IDENTITY
|
|
494
568
|
if target && !stable.all? { |key| target[key] == binding[key] }
|
|
495
|
-
raise IdentityDriftError, "target identity changed since enqueue;
|
|
569
|
+
raise IdentityDriftError, "target identity changed since enqueue; delivery remains uncertain"
|
|
496
570
|
end
|
|
497
571
|
if binding["agent"] == "pi" && !PI_EVENT_ID.match?(record.event_id)
|
|
498
572
|
raise ValidationError, "Pi queue requires an inbox or wake event ID"
|
|
@@ -515,7 +589,7 @@ module Ace
|
|
|
515
589
|
# An agent change is target drift: classify it as reconciliation-
|
|
516
590
|
# worthy before any agent-specific validation can mask it.
|
|
517
591
|
if target && target["agent"] != agent
|
|
518
|
-
raise IdentityDriftError, "target identity changed since enqueue;
|
|
592
|
+
raise IdentityDriftError, "target identity changed since enqueue; delivery remains uncertain"
|
|
519
593
|
end
|
|
520
594
|
raise ValidationError, "unsupported native agent" unless %w[codex pi].include?(agent)
|
|
521
595
|
session = pane["agent_session"]
|
|
@@ -554,9 +628,23 @@ module Ace
|
|
|
554
628
|
"agent_status" => status}
|
|
555
629
|
end
|
|
556
630
|
|
|
557
|
-
def submit(record, binding)
|
|
558
|
-
|
|
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"),
|
|
559
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}
|
|
560
648
|
rescue ExecutorError => e
|
|
561
649
|
{"accepted" => false, "error" => e.message}
|
|
562
650
|
end
|
|
@@ -593,6 +681,7 @@ module Ace
|
|
|
593
681
|
end
|
|
594
682
|
|
|
595
683
|
def wake_pending?(record)
|
|
684
|
+
return false if record.inbox.dig("binding", "agent") == "codex"
|
|
596
685
|
wake = record.inbox["wake"]
|
|
597
686
|
wake.nil? || wake["status"] == "pending"
|
|
598
687
|
end
|
|
@@ -612,6 +701,7 @@ module Ace
|
|
|
612
701
|
end
|
|
613
702
|
|
|
614
703
|
def wake_idle(binding)
|
|
704
|
+
raise ValidationError, "Codex native queue has no terminal wake operation" unless binding.fetch("agent") == "pi"
|
|
615
705
|
@executor.agent_prompt_bounded(pane: binding["pane"],
|
|
616
706
|
text: "Check your native queued messages.", timeout_ms: 10_000)
|
|
617
707
|
nil
|
|
@@ -624,14 +714,14 @@ module Ace
|
|
|
624
714
|
"session" => record.session, "pane" => record.pane,
|
|
625
715
|
"payload_sha256" => record.answer_digest, "state" => record.state,
|
|
626
716
|
"managed_envelope" => record.inbox["managed_envelope"],
|
|
627
|
-
"
|
|
717
|
+
"original_context" => record.inbox["original_context"],
|
|
718
|
+
"original_binding_digest" => record.inbox["original_binding_digest"],
|
|
628
719
|
"claim_generation" => record.inbox["claim_generation"],
|
|
629
720
|
"claim_owner" => record.inbox["claim_owner"],
|
|
630
721
|
"submission_intent" => !!record.inbox["submission_intent"],
|
|
631
722
|
"origin_target" => record.inbox["origin_target"],
|
|
632
723
|
"target" => record.inbox["target"],
|
|
633
724
|
"binding" => record.inbox["binding"], "receipt" => record.inbox["receipt"],
|
|
634
|
-
"reconciliation" => record.inbox["reconciliation"],
|
|
635
725
|
"wake" => record.inbox["wake"],
|
|
636
726
|
"last_error" => record.inbox["last_error"]}
|
|
637
727
|
end
|