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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/herdr/config.yml +0 -4
  3. data/CHANGELOG.md +12 -0
  4. data/docs/usage.md +57 -35
  5. data/lib/ace/herdr/cli/commands/inbox.rb +83 -21
  6. data/lib/ace/herdr/cli/commands/native_source.rb +56 -0
  7. data/lib/ace/herdr/cli.rb +3 -0
  8. data/lib/ace/herdr/errors.rb +23 -0
  9. data/lib/ace/herdr/molecules/bounded_process.rb +261 -52
  10. data/lib/ace/herdr/molecules/delivery_record_store.rb +81 -6
  11. data/lib/ace/herdr/molecules/guarded_native_origin.rb +59 -0
  12. data/lib/ace/herdr/molecules/herdr_executor.rb +6 -3
  13. data/lib/ace/herdr/molecules/inbox_cli_input.rb +36 -0
  14. data/lib/ace/herdr/molecules/inbox_context_client.rb +88 -0
  15. data/lib/ace/herdr/molecules/inbox_context_completion_client.rb +119 -0
  16. data/lib/ace/herdr/molecules/inbox_context_key.rb +63 -0
  17. data/lib/ace/herdr/molecules/inbox_context_lifetime.rb +122 -0
  18. data/lib/ace/herdr/molecules/inbox_context_native_process.rb +92 -0
  19. data/lib/ace/herdr/molecules/inbox_context_service_configuration.rb +183 -0
  20. data/lib/ace/herdr/molecules/inbox_context_store.rb +159 -0
  21. data/lib/ace/herdr/molecules/inbox_context_wire.rb +76 -0
  22. data/lib/ace/herdr/molecules/inbox_direct_effect_binding.rb +92 -0
  23. data/lib/ace/herdr/molecules/inbox_direct_result.rb +65 -0
  24. data/lib/ace/herdr/molecules/native_queue_executor.rb +10 -21
  25. data/lib/ace/herdr/molecules/native_source.rb +189 -0
  26. data/lib/ace/herdr/molecules/protected_inbox_selection.rb +126 -0
  27. data/lib/ace/herdr/molecules/protected_native_control.rb +129 -4
  28. data/lib/ace/herdr/native_source/HERDR-LICENSE +201 -0
  29. data/lib/ace/herdr/native_source/guarded-prompt.patch +3493 -0
  30. data/lib/ace/herdr/native_source/selection.json +24 -0
  31. data/lib/ace/herdr/organisms/inbox.rb +293 -203
  32. data/lib/ace/herdr/organisms/inbox_context_direct_effects.rb +285 -0
  33. data/lib/ace/herdr/organisms/inbox_context_effects.rb +30 -0
  34. data/lib/ace/herdr/organisms/inbox_context_listener.rb +151 -0
  35. data/lib/ace/herdr/organisms/inbox_context_owner.rb +550 -0
  36. data/lib/ace/herdr/organisms/inbox_context_server.rb +70 -0
  37. data/lib/ace/herdr/organisms/inbox_context_service.rb +107 -0
  38. data/lib/ace/herdr/organisms/inbox_guarded_wake.rb +117 -0
  39. data/lib/ace/herdr/organisms/native_source_builder.rb +183 -0
  40. data/lib/ace/herdr/version.rb +1 -1
  41. data/lib/ace/herdr.rb +5 -28
  42. metadata +64 -5
  43. data/lib/ace/herdr/organisms/protected_inbox.rb +0 -73
@@ -1,15 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "open3"
3
+ require "fiddle"
4
4
  require "timeout"
5
+ require "fcntl"
5
6
 
6
7
  module Ace
7
8
  module Herdr
8
9
  module Molecules
9
10
  # Runs a child process in its own process group with a real deadline.
10
11
  # Pipes drain with bounded memory and a stalled child is killed at the
11
- # deadline, so the caller never blocks in cleanup past the timeout the
12
- # way an Open3.capture3 + Timeout.timeout combination can.
12
+ # execution deadline. Cleanup adds at most one second of owned reaping;
13
+ # unconfirmed cleanup transfers eventual reaping and reports uncertainty.
13
14
  module BoundedProcess
14
15
  # Immutable outcome of one bounded child run.
15
16
  Result = Struct.new(:stdout, :stderr, :status, :oversized)
@@ -23,39 +24,160 @@ module Ace
23
24
  end
24
25
  end
25
26
 
27
+ CLEANUP_TIMEOUT = 1.0
28
+ DARWIN_GROUP_PID_LIMIT = 4096
29
+
30
+ # No detached waiter may reap this leader before its owned group is
31
+ # signalled: the unreaped child pins its PID even after normal exit.
32
+ class OwnedChild
33
+ attr_reader :pid
34
+
35
+ def initialize(pid = nil)
36
+ @pid, @status, @owned = pid, nil, !pid.nil?
37
+ @group_signalled = false
38
+ end
39
+
40
+ def start!(command, options)
41
+ raise PostLaunchError, "original child handle cannot be replaced" if started?
42
+ @pid = Process.spawn(*command, **options)
43
+ @owned = true
44
+ end
45
+
46
+ def group_signalled? = @group_signalled
47
+
48
+ def group_signalled!
49
+ raise PostLaunchError, "original child ownership is unavailable" unless alive?
50
+ @group_signalled = true
51
+ end
52
+
53
+ def started?
54
+ !@pid.nil?
55
+ end
56
+
57
+ def alive?
58
+ @owned && @status.nil?
59
+ end
60
+
61
+ def join(timeout)
62
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
63
+ loop do
64
+ unless @status
65
+ result = Process.waitpid2(@pid, Process::WNOHANG)
66
+ @status = result.last if result
67
+ end
68
+ return self if @status
69
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
70
+ return nil unless remaining.positive?
71
+ sleep [remaining, 0.002].min
72
+ end
73
+ rescue Errno::ECHILD
74
+ @owned = false
75
+ raise PostLaunchError, "original child ownership is unavailable"
76
+ end
77
+
78
+ def exited?
79
+ BoundedProcess.nonreaping_exit?(@pid)
80
+ rescue Errno::ECHILD
81
+ @owned = false
82
+ raise PostLaunchError, "original child ownership is unavailable"
83
+ end
84
+
85
+ def release_to_reaper!
86
+ raise PostLaunchError, "original child ownership is unavailable" unless alive?
87
+ Process.detach(@pid)
88
+ @owned = false
89
+ rescue Errno::ECHILD
90
+ @owned = false
91
+ raise PostLaunchError, "original child ownership is unavailable"
92
+ end
93
+
94
+ def value
95
+ raise PostLaunchError, "owned child has not been reaped" unless @status
96
+ @status
97
+ end
98
+ end
99
+
26
100
  module_function
27
101
 
28
102
  # @param argv [Array<String>] child argv (no shell)
29
103
  # @param stdin_data [String] payload written to the child's stdin
30
104
  # @param timeout_s [Numeric] wall-clock deadline for the whole run
31
- # @param output_limit [Integer] per-stream retained byte cap
105
+ # @param output_limit [Integer] stdout retained byte cap
106
+ # @param stderr_limit [Integer] stderr retained byte cap (defaults to stdout cap)
107
+ # @param cleanup_group [Boolean] signal owned group after observed leader exit, before reaping
32
108
  # @return [Result] stdout/stderr are capped; oversized reports truncation
33
109
  # @raise [Timeout::Error] when the child outlives the deadline (killed)
34
110
  # @raise [SystemCallError] spawn failures only (child never launched)
35
111
  # @raise [PostLaunchError] SystemCallError while the child was live
36
- def call(argv, stdin_data: "", timeout_s:, output_limit: 65_536, environment: nil)
112
+ def call(argv, stdin_data: "", timeout_s:, output_limit: 65_536, environment: nil, chdir: nil, descriptor_mapping: {}, stderr_limit: output_limit, cleanup_group: false)
37
113
  if environment && (!environment.is_a?(Hash) || !environment.all? { |key, value| key.is_a?(String) && value.is_a?(String) })
38
114
  raise ArgumentError, "bounded process environment must be explicit strings"
39
115
  end
116
+ if chdir && (!chdir.is_a?(String) || !chdir.start_with?("/"))
117
+ raise ArgumentError, "bounded process working directory must be absolute"
118
+ end
119
+ unless descriptor_mapping.is_a?(Hash) && descriptor_mapping.size <= 16 &&
120
+ descriptor_mapping.all? { |descriptor, handle|
121
+ descriptor.is_a?(Integer) && descriptor.between?(3, 63) &&
122
+ handle.is_a?(File) && !handle.closed? && handle.stat.file? &&
123
+ (handle.fcntl(Fcntl::F_GETFL) & Fcntl::O_ACCMODE) == Fcntl::O_RDONLY
124
+ }
125
+ raise ArgumentError, "bounded process descriptors must be explicit read-only regular files"
126
+ end
127
+ unless [output_limit, stderr_limit].all? { |limit| limit.is_a?(Integer) && limit.positive? } &&
128
+ [true, false].include?(cleanup_group)
129
+ raise ArgumentError, "bounded process limits and cleanup mode are invalid"
130
+ end
131
+ selected_descriptors = descriptor_mapping.dup.freeze
40
132
  command = environment ? [environment, *argv] : argv
41
- options = {pgroup: true}
133
+ options = {pgroup: true, close_others: true}.merge(selected_descriptors)
134
+ options[:chdir] = chdir if chdir
42
135
  options[:unsetenv_others] = true if environment
43
- Open3.popen3(*command, **options) do |stdin, stdout, stderr, waiter|
136
+ handles = []
137
+ waiter = nil
138
+ begin
139
+ child_stdin, stdin = IO.pipe
140
+ handles.concat([child_stdin, stdin])
141
+ stdout, child_stdout = IO.pipe
142
+ handles.concat([stdout, child_stdout])
143
+ stderr, child_stderr = IO.pipe
144
+ handles.concat([stderr, child_stderr])
145
+ handles.each(&:binmode)
146
+ waiter = OwnedChild.new
44
147
  begin
45
- run_loop(stdin, stdout, stderr, waiter,
46
- stdin_data: stdin_data, timeout_s: timeout_s, output_limit: output_limit)
47
- rescue SystemCallError => e
48
- # The child was already spawned: an I/O failure now cannot be
49
- # rewound into a pre-launch classification, and the child must
50
- # not outlive the failure inside popen3's cleanup wait.
51
- kill_group(waiter)
52
- waiter.join
53
- raise PostLaunchError, e.message
148
+ waiter.start!(command, options.merge(in: child_stdin, out: child_stdout, err: child_stderr))
149
+ close_error = close_handles([child_stdin, child_stdout, child_stderr])
150
+ raise close_error if close_error
151
+ run_loop(stdin, stdout, stderr, waiter, stdin_data: stdin_data,
152
+ timeout_s: timeout_s, output_limit: output_limit, stderr_limit: stderr_limit,
153
+ cleanup_group: cleanup_group)
154
+ rescue SystemCallError, IOError => error
155
+ raise unless waiter.started?
156
+ raise PostLaunchError, error.message
157
+ ensure
158
+ close_error = close_handles(handles)
159
+ # Closing one pipe cannot prevent original-child cleanup.
160
+ cleanup_child!(waiter) if waiter.started?
161
+ raise PostLaunchError.new(close_error.message), cause: close_error if close_error && waiter.started?
54
162
  end
163
+ ensure
164
+ close_handles(handles)
55
165
  end
56
166
  end
57
167
 
58
- def run_loop(stdin, stdout, stderr, waiter, stdin_data:, timeout_s:, output_limit:)
168
+ def close_handles(handles)
169
+ first_error = nil
170
+ handles.each do |io|
171
+ begin
172
+ io.close unless io.closed?
173
+ rescue SystemCallError, IOError => error
174
+ first_error ||= error
175
+ end
176
+ end
177
+ first_error
178
+ end
179
+
180
+ def run_loop(stdin, stdout, stderr, waiter, stdin_data:, timeout_s:, output_limit:, stderr_limit:, cleanup_group:)
59
181
  payload = stdin_data.to_s
60
182
  sent = 0
61
183
  stdin_closed = payload.empty?
@@ -64,59 +186,146 @@ module Ace
64
186
  streams = [stdout, stderr]
65
187
  oversized = false
66
188
  deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_s
67
- buffers = {stdout => +"", stderr => +""}
68
- streams = [stdout, stderr]
69
- oversized = false
70
- deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout_s
189
+ group_cleaned = false
71
190
 
72
- until streams.empty? && stdin_closed && waiter.join(0)
73
- remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
74
- if remaining <= 0
191
+ loop do
192
+ if streams.empty? && stdin_closed
193
+ if cleanup_group && !group_cleaned && waiter.exited?
75
194
  kill_group(waiter)
76
- raise Timeout::Error, "process did not finish within #{timeout_s}s"
195
+ group_cleaned = true
77
196
  end
197
+ break if (!cleanup_group || group_cleaned) && waiter.join(0)
198
+ end
199
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
200
+ if remaining <= 0
201
+ kill_group(waiter)
202
+ raise Timeout::Error, "process did not finish within #{timeout_s}s"
203
+ end
78
204
 
79
- ready = IO.select(streams, stdin_closed ? [] : [stdin], nil, [remaining, 0.02].min)
80
- next unless ready
205
+ ready = IO.select(streams, stdin_closed ? [] : [stdin], nil, [remaining, 0.02].min)
206
+ next unless ready
81
207
 
82
- ready.first.each do |io|
208
+ ready.first.each do |io|
209
+ begin
83
210
  chunk = io.read_nonblock(4096, exception: false)
84
- if chunk.nil?
85
- io.close
86
- streams.delete(io)
87
- elsif chunk != :wait_readable
88
- buffer = buffers.fetch(io)
89
- available = output_limit - buffer.bytesize
90
- oversized = true if chunk.bytesize > available
91
- buffer << chunk.byteslice(0, available) if available.positive?
92
- end
211
+ rescue Errno::EINTR
212
+ # No bytes were returned. Keep the original child/pipe and
213
+ # return to the same absolute-deadline loop before reading again.
214
+ next
215
+ end
216
+ if chunk.nil?
217
+ io.close
218
+ streams.delete(io)
219
+ elsif chunk != :wait_readable
220
+ buffer = buffers.fetch(io)
221
+ limit = io.equal?(stderr) ? stderr_limit : output_limit
222
+ available = limit - buffer.bytesize
223
+ oversized = true if chunk.bytesize > available
224
+ buffer << chunk.byteslice(0, available) if available.positive?
93
225
  end
226
+ end
94
227
 
95
- next if stdin_closed || !ready[1].to_a.include?(stdin)
228
+ next if stdin_closed || !ready[1].to_a.include?(stdin)
96
229
 
97
- begin
98
- # Nonblocking with partial writes: a child that stops reading
99
- # must never trap us inside one large stdin.write; the loop
100
- # re-checks the deadline between write attempts.
101
- written = stdin.write_nonblock(payload.byteslice(sent, payload.bytesize - sent),
102
- exception: false)
103
- sent += written if written != :wait_writable
104
- rescue Errno::EPIPE, IOError
105
- sent = payload.bytesize
106
- end
107
- if sent >= payload.bytesize
108
- stdin.close
109
- stdin_closed = true
110
- end
230
+ begin
231
+ # Nonblocking with partial writes: a child that stops reading
232
+ # must never trap us inside one large stdin.write; the loop
233
+ # re-checks the deadline between write attempts.
234
+ written = stdin.write_nonblock(payload.byteslice(sent, payload.bytesize - sent),
235
+ exception: false)
236
+ sent += written if written != :wait_writable
237
+ rescue Errno::EPIPE, IOError
238
+ sent = payload.bytesize
111
239
  end
240
+ if sent >= payload.bytesize
241
+ stdin.close
242
+ stdin_closed = true
243
+ end
244
+ end
112
245
 
113
246
  Result.new(buffers.fetch(stdout), buffers.fetch(stderr), waiter.value, oversized)
114
247
  end
115
248
 
249
+ def cleanup_child!(waiter)
250
+ return unless waiter.alive?
251
+ signal_error = nil
252
+ begin
253
+ kill_group(waiter)
254
+ rescue SystemCallError => error
255
+ signal_error = error
256
+ end
257
+ unless waiter.join(CLEANUP_TIMEOUT)
258
+ # Transfer only this still-exclusively-owned child to an eventual
259
+ # reaper. No signal or positive result can use it after transfer.
260
+ waiter.release_to_reaper!
261
+ raise PostLaunchError, "owned child cleanup could not be confirmed within deadline"
262
+ end
263
+ raise PostLaunchError.new(signal_error.message), cause: signal_error if signal_error
264
+ end
265
+
266
+ # POSIX waitid observes only this original child's termination and
267
+ # leaves it waitable. No stream/timeout/PID liveness inference is used.
268
+ # Constants: Linux v6.12 include/uapi/linux/wait.h; XNU bsd/sys/wait.h.
269
+ def nonreaping_exit?(pid)
270
+ nowait = if RUBY_PLATFORM.include?("linux")
271
+ 0x01000000
272
+ elsif RUBY_PLATFORM.include?("darwin")
273
+ 0x00000020
274
+ else
275
+ raise PostLaunchError, "nonreaping child observation is unsupported"
276
+ end
277
+ buffer = Fiddle::Pointer.malloc(512, Fiddle::RUBY_FREE)
278
+ buffer[0, 512] = "\0" * 512
279
+ function = Fiddle::Function.new(Fiddle.dlopen(nil)["waitid"],
280
+ [Fiddle::TYPE_INT, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT], Fiddle::TYPE_INT)
281
+ result = function.call(1, pid, buffer, 4 | 1 | nowait)
282
+ raise SystemCallError.new("waitid", Fiddle.last_error) unless result.zero?
283
+ buffer[0, Fiddle::SIZEOF_INT].unpack1("i!") == Signal.list.fetch("CHLD")
284
+ rescue Fiddle::DLError => error
285
+ raise PostLaunchError, error.message
286
+ end
287
+
116
288
  def kill_group(waiter)
289
+ return unless waiter.alive?
290
+ return if waiter.is_a?(OwnedChild) && waiter.group_signalled?
117
291
  Process.kill("KILL", -waiter.pid)
292
+ waiter.group_signalled! if waiter.is_a?(OwnedChild)
118
293
  rescue Errno::ESRCH
119
294
  nil
295
+ rescue Errno::EPERM
296
+ # XNU killpg1 excludes SZOMB members and can return EPERM for
297
+ # the owned zombie alone. Genuine permission failures still fail:
298
+ # prove the complete group contains only our unreaped child.
299
+ raise unless darwin_singleton_exited_group?(waiter)
300
+ end
301
+
302
+ def darwin_singleton_exited_group?(waiter)
303
+ return false unless darwin_platform? && waiter.is_a?(OwnedChild) &&
304
+ waiter.alive? && waiter.exited?
305
+ return false unless darwin_group_pids(waiter.pid) == [waiter.pid]
306
+ waiter.alive? && waiter.exited? && waiter.alive?
307
+ rescue SystemCallError, IOError, Fiddle::DLError, PostLaunchError
308
+ false
309
+ end
310
+
311
+ def darwin_platform? = RUBY_PLATFORM.include?("darwin")
312
+
313
+ # libproc enumerates allproc AND zombproc under the kernel process
314
+ # list lock. A full buffer is potentially truncated, never proof.
315
+ # This observes only the owned group, not children escaping it.
316
+ def darwin_group_pids(pid)
317
+ library = Fiddle.dlopen("/usr/lib/libproc.dylib")
318
+ function = Fiddle::Function.new(library["proc_listpids"],
319
+ [Fiddle::TYPE_INT, Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_INT], Fiddle::TYPE_INT)
320
+ capacity = DARWIN_GROUP_PID_LIMIT * Fiddle::SIZEOF_INT
321
+ buffer = Fiddle::Pointer.malloc(capacity, Fiddle::RUBY_FREE)
322
+ buffer[0, capacity] = "\0" * capacity
323
+ # PROC_PGRP_ONLY=2, from XNU bsd/sys/proc_info.h.
324
+ size = function.call(2, pid, buffer, capacity)
325
+ return nil unless size.positive? && size < capacity && (size % Fiddle::SIZEOF_INT).zero?
326
+ pids = buffer[0, size].unpack("i!*")
327
+ return nil unless pids.all?(&:positive?) && pids.uniq == pids
328
+ pids
120
329
  end
121
330
  end
122
331
  end
@@ -11,6 +11,8 @@ module Ace
11
11
  # yields a torn record and the answer stays re-deliverable. A per-event
12
12
  # flock serializes concurrent deliveries of the same event.
13
13
  module DeliveryRecordStore
14
+ class LockUnavailable < StandardError; end
15
+
14
16
  module_function
15
17
 
16
18
  # @return [Models::DeliveryRecord, nil] the record, falling back to
@@ -41,6 +43,12 @@ module Ace
41
43
 
42
44
  # @return [String] path the record was written to
43
45
  def save(record, deliveries_dir)
46
+ with_inventory_lock(deliveries_dir, exclusive: false, create: true) do
47
+ save_held(record, deliveries_dir)
48
+ end
49
+ end
50
+
51
+ def save_held(record, deliveries_dir)
44
52
  FileUtils.mkdir_p(deliveries_dir)
45
53
  path = path_for(deliveries_dir, record.event_id)
46
54
  tmp = "#{path}.tmp.#{Process.pid}"
@@ -56,19 +64,79 @@ module Ace
56
64
 
57
65
  # Hold the exclusive per-event lock while delivering; concurrent
58
66
  # callers block until the winner finishes, then observe its record.
59
- def with_lock(deliveries_dir, event_id, create: true)
60
- FileUtils.mkdir_p(deliveries_dir) if create
61
- flags = create ? "a" : File::RDWR | File::NOFOLLOW
62
- File.open(lock_path(deliveries_dir, event_id), flags) do |lock|
63
- lock.flock(File::LOCK_EX)
67
+ def with_lock(deliveries_dir, event_id, create: true, deadline: nil)
68
+ with_locks(deliveries_dir, [event_id], create: create, deadline: deadline) { yield }
69
+ end
70
+
71
+ def with_locks(deliveries_dir, event_ids, create: false, deadline: nil)
72
+ with_inventory_lock(deliveries_dir, exclusive: false, create: create, deadline: deadline) do
73
+ with_event_locks_held(deliveries_dir, event_ids, create: create, deadline: deadline) { yield }
74
+ end
75
+ end
76
+
77
+ # Global inventory always precedes event locks. Normal creators/writers
78
+ # share it; maintenance exclusively holds it through its entire mutation
79
+ # boundary. Reentrancy never upgrades a writer's shared lock.
80
+ def with_inventory_lock(deliveries_dir, exclusive:, create: false, prepare_directory: true, deadline: nil)
81
+ admission_deadline!(deadline) unless deadline.nil?
82
+ held = Thread.current[:ace_herdr_delivery_inventory_locks] ||= {}
83
+ if (existing = held[deliveries_dir])
84
+ raise ArgumentError, "delivery inventory lock cannot be upgraded" if exclusive && existing != :exclusive
85
+ return yield
86
+ end
87
+ FileUtils.mkdir_p(deliveries_dir) if create && prepare_directory
88
+ flags = File::RDWR | File::NOFOLLOW | (create ? File::CREAT : 0)
89
+ flags |= File::NONBLOCK unless deadline.nil?
90
+ File.open(File.join(deliveries_dir, ".inventory.lock"), flags, 0o600) do |lock|
91
+ raise ArgumentError, "delivery inventory lock is not a regular file" unless lock.stat.file?
92
+ acquire_lock!(lock, exclusive ? File::LOCK_EX : File::LOCK_SH, deadline)
93
+ held[deliveries_dir] = exclusive ? :exclusive : :shared
64
94
  begin
65
- return yield
95
+ yield
66
96
  ensure
97
+ held.delete(deliveries_dir)
67
98
  lock.flock(File::LOCK_UN)
68
99
  end
69
100
  end
70
101
  end
71
102
 
103
+ def with_event_locks_held(deliveries_dir, event_ids, create: false, deadline: nil)
104
+ FileUtils.mkdir_p(deliveries_dir) if create
105
+ flags = create ? "a" : File::RDWR | File::NOFOLLOW
106
+ flags = File::WRONLY | File::APPEND | File::CREAT | File::NOFOLLOW if deadline && create
107
+ flags |= File::NONBLOCK unless deadline.nil?
108
+ handles = []
109
+ event_ids.sort.each do |event_id|
110
+ lock = File.open(lock_path(deliveries_dir, event_id), flags)
111
+ handles << lock
112
+ raise ArgumentError, "delivery event lock is not a regular file" unless lock.stat.file?
113
+ acquire_lock!(lock, File::LOCK_EX, deadline)
114
+ end
115
+ yield
116
+ ensure
117
+ handles&.reverse_each do |lock|
118
+ lock.flock(File::LOCK_UN)
119
+ lock.close
120
+ end
121
+ end
122
+
123
+ def acquire_lock!(lock, mode, deadline)
124
+ admission_deadline!(deadline) unless deadline.nil?
125
+ unless lock.flock(mode | (deadline ? File::LOCK_NB : 0))
126
+ raise LockUnavailable, "delivery exclusion is busy"
127
+ end
128
+ admission_deadline!(deadline) unless deadline.nil?
129
+ end
130
+
131
+ def admission_deadline!(deadline)
132
+ unless (deadline.is_a?(Integer) || deadline.is_a?(Float)) && deadline.finite?
133
+ raise ArgumentError, "delivery deadline must be finite"
134
+ end
135
+ if Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
136
+ raise LockUnavailable, "delivery admission deadline expired"
137
+ end
138
+ end
139
+
72
140
  def path_for(deliveries_dir, event_id)
73
141
  File.join(deliveries_dir, "#{event_id}.json")
74
142
  end
@@ -116,6 +184,12 @@ module Ace
116
184
  # archived event just reports its archive path.
117
185
  # @return [String] the archive path the record lives at
118
186
  def archive(deliveries_dir, event_id)
187
+ with_inventory_lock(deliveries_dir, exclusive: false, create: true) do
188
+ archive_held(deliveries_dir, event_id)
189
+ end
190
+ end
191
+
192
+ def archive_held(deliveries_dir, event_id)
119
193
  dest = File.join(archive_dir(deliveries_dir), "#{event_id}.json")
120
194
  return dest if !File.exist?(path_for(deliveries_dir, event_id)) && File.exist?(dest)
121
195
 
@@ -148,6 +222,7 @@ module Ace
148
222
  rescue JSON::ParserError, ArgumentError
149
223
  nil
150
224
  end
225
+ private_class_method :save_held, :archive_held, :with_event_locks_held
151
226
  end
152
227
  end
153
228
  end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+ require "ace/runtime/errors"
3
+
4
+ module Ace
5
+ module Herdr
6
+ module Molecules
7
+ # Closed N1 producer value. Native endpoint selection and kernel capture
8
+ # belong to ProtectedNativeControl; this value never discovers a target.
9
+ module GuardedNativeOrigin
10
+ UUID = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/
11
+ TERMINAL = /\Aterm_[0-9a-f]{2,48}\z/
12
+ CHILD_FIELDS = %w[gid groups host parent_pid pid started_at uid].freeze
13
+ module_function
14
+
15
+ def verify!(value, terminal_id:, child:)
16
+ unless value.is_a?(Hash) && value.keys.all? { |key| key.is_a?(String) } && value.keys.sort == %w[child runtime_incarnation terminal_id] &&
17
+ value["terminal_id"] == terminal_id && terminal_id.is_a?(String) && terminal_id.ascii_only? && TERMINAL.match?(terminal_id) &&
18
+ value["runtime_incarnation"].is_a?(String) && value["runtime_incarnation"].ascii_only? && UUID.match?(value["runtime_incarnation"])
19
+ unavailable!
20
+ end
21
+ identity = value.fetch("child")
22
+ unless identity.is_a?(Hash) && identity.keys.all? { |key| key.is_a?(String) } && identity.keys.sort == CHILD_FIELDS && identity == child &&
23
+ %w[pid parent_pid].all? { |key| uint32?(identity[key]) && identity[key].positive? } &&
24
+ %w[uid gid].all? { |key| uint32?(identity[key]) } && identity["groups"].is_a?(Array) &&
25
+ identity["groups"].size <= 256 && identity["groups"].all? { |group| uint32?(group) } &&
26
+ identity["groups"] == identity["groups"].sort.uniq && valid_birth?(identity["started_at"]) &&
27
+ identity["host"].is_a?(String) && identity["host"].valid_encoding? && (identity["host"].encoding == Encoding::UTF_8 || identity["host"].ascii_only?) &&
28
+ identity["host"].bytesize.between?(1, 255) && !identity["host"].match?(/\p{Cc}/)
29
+ unavailable!
30
+ end
31
+ immutable(value)
32
+ end
33
+
34
+ def uint32?(value) = value.is_a?(Integer) && value.between?(0, 0xffff_ffff)
35
+
36
+ def valid_birth?(value)
37
+ return false unless value.is_a?(String) && value.ascii_only?
38
+ parts = value.split(":", -1)
39
+ parts.size == 3 && parts[0] == "linux" && UUID.match?(parts[1]) &&
40
+ parts[2].match?(/\A[0-9]+\z/) && parts[2].to_i.between?(1, 0xffff_ffff_ffff_ffff)
41
+ end
42
+
43
+ def immutable(value)
44
+ case value
45
+ when Hash then value.to_h { |key, item| [key.dup.freeze, immutable(item)] }.freeze
46
+ when Array then value.map { |item| immutable(item) }.freeze
47
+ when String then value.dup.freeze
48
+ else value.freeze
49
+ end
50
+ end
51
+
52
+ def unavailable!
53
+ raise Ace::Runtime::RuntimeUnavailableError, "original native guarded origin is unavailable"
54
+ end
55
+ private_class_method :uint32?, :valid_birth?, :immutable, :unavailable!
56
+ end
57
+ end
58
+ end
59
+ end
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../errors"
3
4
  require "open3"
4
5
  require "json"
5
6
  require "timeout"
7
+ require_relative "bounded_process"
6
8
 
7
9
  module Ace
8
10
  module Herdr
@@ -16,8 +18,9 @@ module Ace
16
18
  WAKE_OUTPUT_LIMIT = 65_536
17
19
  DEFAULT_PROBE_TIMEOUT_S = 60
18
20
 
19
- def initialize(binary: DEFAULT_BINARY)
21
+ def initialize(binary: DEFAULT_BINARY, process: BoundedProcess)
20
22
  @binary = binary
23
+ @process = process
21
24
  end
22
25
 
23
26
  # Probe the agent living in a pane (raises AgentNotFoundError when none)
@@ -30,7 +33,7 @@ module Ace
30
33
  # killed at the deadline and the timeout classifies as a retryable
31
34
  # pre-submission failure.
32
35
  def pane_get_bounded(pane, timeout_s: DEFAULT_PROBE_TIMEOUT_S)
33
- result = BoundedProcess.call([@binary, "pane", "get", pane], stdin_data: "",
36
+ result = @process.call([@binary, "pane", "get", pane], stdin_data: "",
34
37
  timeout_s: timeout_s, output_limit: WAKE_OUTPUT_LIMIT)
35
38
  execution = ExecutionResult.new(
36
39
  stdout: result.stdout.strip, stderr: result.stderr.strip,
@@ -65,7 +68,7 @@ module Ace
65
68
  # keep the inbox event lock through Open3.capture3 cleanup.
66
69
  def agent_prompt_bounded(pane:, text:, timeout_ms:)
67
70
  cmd = [@binary, "agent", "prompt", pane, text]
68
- result = BoundedProcess.call(cmd, stdin_data: "",
71
+ result = @process.call(cmd, stdin_data: "",
69
72
  timeout_s: timeout_ms / 1000.0, output_limit: WAKE_OUTPUT_LIMIT)
70
73
  execution = ExecutionResult.new(
71
74
  stdout: result.stdout.strip, stderr: result.stderr.strip,
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+ require_relative "inbox_context_store"
3
+
4
+ module Ace
5
+ module Herdr
6
+ module Molecules
7
+ module InboxCliInput
8
+ module_function
9
+ def read(path, limit:)
10
+ unless path.is_a?(String) && !path.empty? && !path.include?("\0")
11
+ raise ValidationError, "inbox input path differs"
12
+ end
13
+ before = File.lstat(path)
14
+ raise ValidationError, "inbox input is not a bounded regular file" unless before.file? && !before.symlink? && before.size.between?(1, limit)
15
+ snapshot = ->(stat) { [stat.dev, stat.ino, stat.size, stat.mtime, stat.ctime] }
16
+ File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |handle|
17
+ raise ValidationError, "inbox input handle changed" unless snapshot.call(before) == snapshot.call(handle.stat)
18
+ bytes = handle.read(before.size + 1)
19
+ unless bytes && bytes.bytesize == before.size && snapshot.call(before) == snapshot.call(handle.stat) &&
20
+ snapshot.call(before) == snapshot.call(File.lstat(path))
21
+ raise ValidationError, "inbox input changed"
22
+ end
23
+ bytes.freeze
24
+ end
25
+ rescue SystemCallError, IOError
26
+ raise ValidationError, "inbox input is unavailable"
27
+ end
28
+
29
+ def reverse(path)
30
+ value = InboxContextStore.decode(read(path, limit: 16_384), limit: 16_384)
31
+ InboxDirectEffectBinding.reverse!(value)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end