little_ghost 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,452 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module LittleGhost
6
+ class Assembly
7
+ MAX_STEP_OUTPUT_BYTES = 64 * 1024 # :nodoc:
8
+ MAX_STEP_EVENTS = 10_000 # :nodoc:
9
+ MAX_STEP_EVENT_BYTES = 10 * 1024 * 1024 # :nodoc:
10
+
11
+ # One attempt to execute an assembly step.
12
+ Attempt = Data.define(:number, :status, :started_at, :finished_at, :usage, :error) do
13
+ def initialize(number:, status:, started_at:, finished_at:, usage: Usage.new, error: nil)
14
+ super(number:, status: status.to_sym, started_at:, finished_at:, usage:, error: error&.to_s&.freeze)
15
+ end
16
+ end
17
+
18
+ # One logical child execution in a composite assembly.
19
+ Step = Data.define(
20
+ :id, :parent_id, :predecessor_ids, :branch_id, :participant,
21
+ :assembly_id, :assembly_kind, :status, :attempts, :usage, :output, :output_truncated
22
+ ) do
23
+ def initialize(id:, participant:, assembly_id:, assembly_kind:, status:, attempts:, usage:,
24
+ parent_id: nil, predecessor_ids: [], branch_id: nil, output: nil, output_truncated: false)
25
+ super(
26
+ id: id.to_s.freeze,
27
+ parent_id: parent_id&.to_s&.freeze,
28
+ predecessor_ids: Array(predecessor_ids).map { |value| value.to_s.freeze }.freeze,
29
+ branch_id: branch_id&.to_s&.freeze,
30
+ participant: participant.to_s.freeze,
31
+ assembly_id: assembly_id.to_s.freeze,
32
+ assembly_kind: assembly_kind.to_sym,
33
+ status: status.to_sym,
34
+ attempts: Array(attempts).freeze,
35
+ usage:,
36
+ output:,
37
+ output_truncated: output_truncated == true
38
+ )
39
+ end
40
+ end
41
+
42
+ # Immutable queries over the steps returned by one assembly invocation.
43
+ class Trajectory
44
+ include Enumerable
45
+
46
+ # Immutable steps in execution order.
47
+ attr_reader :steps
48
+
49
+ # Builds query indexes for +steps+.
50
+ def initialize(steps)
51
+ @steps = Array(steps).freeze
52
+ @by_id = @steps.to_h { |step| [step.id, step] }.freeze
53
+ freeze
54
+ end
55
+
56
+ # Iterates through steps in execution order.
57
+ def each(&block) = steps.each(&block)
58
+ # Finds one step by its stable ID.
59
+ def step(id) = @by_id[id.to_s]
60
+ # Returns steps whose parent is +id+.
61
+ def children(id) = steps.select { |item| item.parent_id == id.to_s }.freeze
62
+ # Returns attempts belonging to one step.
63
+ def attempts_for(id) = step(id)&.attempts || [].freeze
64
+
65
+ # Returns declared predecessor-to-step ID pairs.
66
+ def transitions
67
+ steps.flat_map do |item|
68
+ item.predecessor_ids.map { |predecessor| [predecessor, item.id].freeze }
69
+ end.freeze
70
+ end
71
+
72
+ # Indicates whether attempts from two steps overlapped in time.
73
+ def concurrent?(first_id, second_id)
74
+ first = step(first_id)
75
+ second = step(second_id)
76
+ return false unless first && second
77
+
78
+ first.attempts.any? do |left|
79
+ second.attempts.any? do |right|
80
+ left.started_at < right.finished_at && right.started_at < left.finished_at
81
+ end
82
+ end
83
+ end
84
+ end
85
+
86
+ StepExecution = Data.define(:result, :step, :events, :transition) # :nodoc:
87
+
88
+ private
89
+
90
+ def execute_assembly_step(
91
+ reference:,
92
+ participant:,
93
+ input:,
94
+ history:,
95
+ context:,
96
+ cancellation_token:,
97
+ deadline:,
98
+ settings:,
99
+ template_locals:,
100
+ template_paths:,
101
+ parent_operation_id:,
102
+ policies: {},
103
+ parent_id: nil,
104
+ predecessor_ids: [],
105
+ branch_id: nil,
106
+ checkpoint: nil,
107
+ build_options: {},
108
+ step_id: SecureRandom.uuid
109
+ )
110
+ policy = normalize_step_policy(policies)
111
+ attempts = []
112
+ usage = Usage.new
113
+
114
+ (policy.fetch(:retries) + 1).times do |index|
115
+ attempt_number = index + 1
116
+ started_at = Time.now
117
+ child = if build_options.any? && runtime.respond_to?(:build_agent)
118
+ runtime.build_agent(reference, run:, **build_options)
119
+ else
120
+ runtime.build_assembly(reference, run:, **build_options)
121
+ end
122
+ child_deadline = step_deadline(deadline, policy[:timeout])
123
+ attempt_events = []
124
+ attempt_event_bytes = 0
125
+ result = nil
126
+ attempt_usage = Usage.new
127
+ begin
128
+ with_active_assembly(child) do
129
+ child.stream(
130
+ input,
131
+ history:,
132
+ context: isolated_assembly_state(context),
133
+ cancellation_token:,
134
+ deadline: child_deadline,
135
+ settings:,
136
+ template_locals: template_locals.merge(runtime.template_locals(run:, agent: child)),
137
+ template_paths:,
138
+ parent_operation_id:,
139
+ checkpoint:
140
+ ).each do |event|
141
+ result = event.data[:result] if event.type == :invocation_stop
142
+ attempt_usage = event.data[:usage] || attempt_usage if event.type == :invocation_error
143
+ attempt_event_bytes = buffer_assembly_event!(attempt_events, event, bytes: attempt_event_bytes)
144
+ end
145
+ end
146
+ raise ProtocolError, "assembly step #{participant.inspect} did not return a result" unless result
147
+
148
+ attempt_usage = result.usage
149
+ usage += attempt_usage
150
+ attempts << Attempt.new(
151
+ number: attempt_number,
152
+ status: :completed,
153
+ started_at:,
154
+ finished_at: Time.now,
155
+ usage: attempt_usage
156
+ )
157
+ output, truncated = projected_step_output(result.output)
158
+ nested_steps = reparent_steps(result.steps, step_id)
159
+ step = Step.new(
160
+ id: step_id,
161
+ parent_id:,
162
+ predecessor_ids:,
163
+ branch_id:,
164
+ participant:,
165
+ assembly_id: child.class.respond_to?(:assembly_id) ? child.class.assembly_id : participant,
166
+ assembly_kind: child.class.respond_to?(:assembly_kind) ? child.class.assembly_kind : :assembly,
167
+ status: :completed,
168
+ attempts:,
169
+ usage:,
170
+ output:,
171
+ output_truncated: truncated
172
+ )
173
+ combined = copy_run_result(result, steps: [step, *nested_steps])
174
+ events = attempt_events.map do |event|
175
+ (event.type == :invocation_stop) ? StreamEvent.build(event.type, **event.data.merge(result: combined)) : event
176
+ end
177
+ transition = child.assembly_transition if child.respond_to?(:assembly_transition)
178
+ return StepExecution.new(result: combined, step:, events: events.freeze, transition:)
179
+ rescue => error
180
+ if own_step_timeout?(error, deadline, child_deadline, policy[:timeout])
181
+ error = AssemblyStepTimeoutError.new("assembly step #{participant.inspect} exceeded its timeout")
182
+ end
183
+ attempt_usage = error_usage(attempt_events, attempt_usage)
184
+ usage += attempt_usage
185
+ attempts << Attempt.new(
186
+ number: attempt_number,
187
+ status: :failed,
188
+ started_at:,
189
+ finished_at: Time.now,
190
+ usage: attempt_usage,
191
+ error: error.class.name
192
+ )
193
+ retrying = retry_step?(error, policy, index)
194
+ annotate_step_error(error, step_id:, attempts:, usage:, child:, participant:)
195
+ if block_given?
196
+ yield StreamEvent.build(
197
+ :assembly_step_error,
198
+ step_id:,
199
+ participant: participant.to_s,
200
+ attempt: attempt_number,
201
+ error_type: error.class.name,
202
+ usage:,
203
+ terminal: !retrying
204
+ )
205
+ end
206
+ raise error unless retrying
207
+
208
+ if block_given?
209
+ yield StreamEvent.build(
210
+ :assembly_step_retry,
211
+ step_id:,
212
+ participant: participant.to_s,
213
+ attempt: attempt_number,
214
+ error_type: error.class.name,
215
+ delay: policy.fetch(:retry_delay)
216
+ )
217
+ end
218
+ wait_for_retry(policy.fetch(:retry_delay), cancellation_token, deadline)
219
+ ensure
220
+ begin
221
+ child.close
222
+ rescue => cleanup_error
223
+ cleanup_usage = usage
224
+ annotate_step_error(
225
+ cleanup_error,
226
+ step_id:,
227
+ attempts:,
228
+ usage: cleanup_usage,
229
+ child:,
230
+ participant:
231
+ )
232
+ if block_given?
233
+ yield StreamEvent.build(
234
+ :assembly_step_error,
235
+ step_id:,
236
+ participant: participant.to_s,
237
+ attempt: attempt_number,
238
+ error_type: cleanup_error.class.name,
239
+ usage: cleanup_usage,
240
+ terminal: true
241
+ )
242
+ end
243
+ raise
244
+ end
245
+ end
246
+ end
247
+ end
248
+
249
+ def normalize_step_policy(values)
250
+ self.class.validate_step_policy!(values)
251
+ end
252
+
253
+ class << self
254
+ def validate_step_policy!(values) # :nodoc:
255
+ values = values.compact
256
+ retries = Integer(values.fetch(:retries, 0))
257
+ raise ArgumentError, "retries must be at least 0" if retries.negative?
258
+
259
+ retry_on = Array(values[:retry_on])
260
+ if retries.positive? && retry_on.empty?
261
+ raise ArgumentError, "retry_on is required when retries is greater than 0"
262
+ end
263
+ unless retry_on.all? { |error| error.is_a?(Class) && error <= Exception }
264
+ raise ArgumentError, "retry_on must contain exception classes"
265
+ end
266
+ timeout = Float(values[:timeout]) if values[:timeout]
267
+ retry_delay = Float(values.fetch(:retry_delay, 0))
268
+ raise ArgumentError, "timeout must be positive" if timeout && (!timeout.positive? || !timeout.finite?)
269
+ raise ArgumentError, "retry_delay must be non-negative" if retry_delay.negative? || !retry_delay.finite?
270
+
271
+ {retries:, retry_on: retry_on.freeze, timeout:, retry_delay:}.freeze
272
+ end
273
+ end
274
+
275
+ def retry_step?(error, policy, index)
276
+ return false if index >= policy.fetch(:retries)
277
+ return false if error.is_a?(CancelledError) || error.is_a?(CleanupError)
278
+
279
+ policy.fetch(:retry_on).any? { |type| error.is_a?(type) }
280
+ end
281
+
282
+ def step_deadline(parent_deadline, timeout)
283
+ local = Time.now + timeout if timeout
284
+ [parent_deadline, local].compact.min
285
+ end
286
+
287
+ def own_step_timeout?(error, parent_deadline, child_deadline, timeout)
288
+ error.is_a?(DeadlineExceededError) && timeout && child_deadline && child_deadline != parent_deadline
289
+ end
290
+
291
+ def wait_for_retry(delay, cancellation_token, deadline)
292
+ return if delay.zero?
293
+
294
+ stop_at = Time.now + delay
295
+ loop do
296
+ cancellation_token.raise_if_cancelled!
297
+ raise DeadlineExceededError, "The run deadline was reached" if deadline && Time.now >= deadline
298
+
299
+ remaining = stop_at - Time.now
300
+ break unless remaining.positive?
301
+
302
+ cancellation_token.wait([remaining, 0.05].min)
303
+ end
304
+ end
305
+
306
+ def error_usage(events, fallback)
307
+ events.reverse_each do |event|
308
+ return event.data[:usage] if event.type == :invocation_error && event.data[:usage]
309
+ end
310
+ fallback
311
+ end
312
+
313
+ def annotate_step_error(error, step_id:, attempts:, usage:, child:, participant:)
314
+ error.instance_variable_set(:@little_ghost_step_id, step_id)
315
+ error.instance_variable_set(:@little_ghost_step_attempts, attempts.dup.freeze)
316
+ error.instance_variable_set(:@little_ghost_step_usage, usage)
317
+ error.instance_variable_set(
318
+ :@little_ghost_step_assembly_id,
319
+ child.class.respond_to?(:assembly_id) ? child.class.assembly_id : participant.to_s
320
+ )
321
+ error.instance_variable_set(
322
+ :@little_ghost_step_assembly_kind,
323
+ child.class.respond_to?(:assembly_kind) ? child.class.assembly_kind : :assembly
324
+ )
325
+ end
326
+
327
+ def projected_step_output(output)
328
+ text = output.is_a?(String) ? output : JSON.generate(output)
329
+ return [deep_frozen_assembly_value(output), false] if text.bytesize <= MAX_STEP_OUTPUT_BYTES
330
+
331
+ [nil, true]
332
+ rescue JSON::GeneratorError, TypeError
333
+ [nil, true]
334
+ end
335
+
336
+ def reparent_steps(steps, parent_id)
337
+ Array(steps).map do |step|
338
+ next step if step.parent_id
339
+
340
+ Step.new(**step.to_h.merge(parent_id:))
341
+ end
342
+ end
343
+
344
+ def copy_run_result(result, usage: result.usage, steps: result.steps)
345
+ RunResult.new(
346
+ message: result.message,
347
+ stop_reason: result.stop_reason,
348
+ usage:,
349
+ messages: result.messages,
350
+ state: result.state,
351
+ structured_result: result.structured_result,
352
+ steps:
353
+ )
354
+ end
355
+
356
+ def isolated_assembly_state(value)
357
+ case value
358
+ when Hash
359
+ value.to_h { |key, item| [isolated_assembly_state(key), isolated_assembly_state(item)] }
360
+ when Array
361
+ value.map { |item| isolated_assembly_state(item) }
362
+ when String
363
+ value.dup
364
+ when NilClass, TrueClass, FalseClass, Numeric, Symbol
365
+ value
366
+ else
367
+ raise ArgumentError, "assembly context must contain only JSON-like state"
368
+ end
369
+ end
370
+
371
+ def deep_frozen_assembly_value(value)
372
+ copied = isolated_assembly_state(value)
373
+ case copied
374
+ when Hash
375
+ copied.each do |key, item|
376
+ deep_freeze_assembly_value(key)
377
+ deep_freeze_assembly_value(item)
378
+ end
379
+ when Array
380
+ copied.each { |item| deep_freeze_assembly_value(item) }
381
+ end
382
+ copied.freeze
383
+ end
384
+
385
+ def deep_freeze_assembly_value(value)
386
+ case value
387
+ when Hash
388
+ value.each do |key, item|
389
+ deep_freeze_assembly_value(key)
390
+ deep_freeze_assembly_value(item)
391
+ end
392
+ when Array
393
+ value.each { |item| deep_freeze_assembly_value(item) }
394
+ end
395
+ value.freeze
396
+ end
397
+
398
+ def buffer_assembly_event!(buffer, event, bytes:)
399
+ raise AssemblyLimitError, "assembly step emitted too many events" if buffer.length >= MAX_STEP_EVENTS
400
+
401
+ bytes += buffered_assembly_size(event)
402
+ raise AssemblyLimitError, "assembly step emitted too much event data" if bytes > MAX_STEP_EVENT_BYTES
403
+
404
+ buffer << event
405
+ bytes
406
+ end
407
+
408
+ def buffered_assembly_size(value, ancestors = {}, depth = 0)
409
+ return MAX_STEP_EVENT_BYTES + 1 if depth > 32
410
+
411
+ identity = value.object_id
412
+ return 0 if ancestors.key?(identity)
413
+
414
+ case value
415
+ when String
416
+ value.bytesize
417
+ when Hash
418
+ ancestors[identity] = true
419
+ value.sum do |key, item|
420
+ buffered_assembly_size(key, ancestors, depth + 1) + buffered_assembly_size(item, ancestors, depth + 1)
421
+ end
422
+ when Array
423
+ ancestors[identity] = true
424
+ value.sum { |item| buffered_assembly_size(item, ancestors, depth + 1) }
425
+ when Data
426
+ ancestors[identity] = true
427
+ value.members.sum { |member| buffered_assembly_size(value.public_send(member), ancestors, depth + 1) }
428
+ else
429
+ 64
430
+ end
431
+ ensure
432
+ ancestors.delete(identity) if identity
433
+ end
434
+
435
+ def enqueue_assembly_event(queue, value, cancellation_token)
436
+ loop do
437
+ cancellation_token.raise_if_cancelled!
438
+ queue.push(value, true)
439
+ return
440
+ rescue ThreadError
441
+ cancellation_token.wait(0.01)
442
+ end
443
+ end
444
+
445
+ def enqueue_assembly_terminal(queue, value)
446
+ queue.push(value, true)
447
+ rescue ThreadError
448
+ queue.pop(true)
449
+ retry
450
+ end
451
+ end
452
+ end
@@ -13,6 +13,14 @@ module LittleGhost
13
13
  class CredentialError < ConfigurationError; end
14
14
  # Raised when an invocation payload or operation is invalid.
15
15
  class InvocationError < Error; end
16
+ # Base class for failures coordinating one or more assemblies.
17
+ class AssemblyError < Error; end
18
+ # Raised when an assembly exceeds its configured execution bound.
19
+ class AssemblyLimitError < AssemblyError; end
20
+ # Raised when an assembly cannot choose one valid next participant.
21
+ class AssemblyRoutingError < AssemblyError; end
22
+ # Raised when one assembly step exceeds its local timeout.
23
+ class AssemblyStepTimeoutError < AssemblyError; end
16
24
  # Raised when an invocation contains an unsupported input form.
17
25
  class UnsupportedInputError < InvocationError; end
18
26
  # Base class for provider response and protocol failures.
@@ -0,0 +1,206 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LittleGhost
4
+ # Runs one dormant Run in a supervised worker while the caller remains free to
5
+ # serve health checks, deliver interruptions, or coordinate process shutdown.
6
+ #
7
+ # execution = agent.start_execution(message: "Investigate transfer 481") do |event|
8
+ # event_buffer << event
9
+ # end
10
+ #
11
+ # execution.interrupt_response(message: "Include the latest ledger entry")
12
+ # execution.wait(deadline: Time.now + 30)
13
+ # execution.run.completed? # => true
14
+ #
15
+ # An execution owns its worker. The worker receives a snapshot of the caller's
16
+ # request-scoped ExecutionState. The Run continues to own its workspace,
17
+ # sandbox, session, entrypoint, and registered resources. +close+ requests
18
+ # cooperative cancellation and waits for both the worker and in-flight
19
+ # interruption calls.
20
+ class Execution
21
+ # The supervised Run and an exception raised outside the Run's ordinary
22
+ # terminal outcome, such as event delivery or cleanup failure.
23
+ attr_reader :run
24
+
25
+ class << self
26
+ # Starts +run+ immediately and returns its supervising Execution.
27
+ #
28
+ # The optional block receives each StreamEvent on the worker thread. It
29
+ # must be safe to call from that thread and should not retain sensitive
30
+ # event content longer than the application requires.
31
+ def start(run, &event_consumer)
32
+ new(run, event_consumer:).send(:start)
33
+ end
34
+ end
35
+
36
+ def initialize(run, event_consumer: nil) # :nodoc:
37
+ raise ArgumentError, "run must be a LittleGhost::Run" unless run.is_a?(Run)
38
+ unless event_consumer.nil? || event_consumer.respond_to?(:call)
39
+ raise ArgumentError, "event consumer must be callable"
40
+ end
41
+
42
+ @run = run
43
+ @event_consumer = event_consumer
44
+ @state = :pending
45
+ @error = nil
46
+ @mutex = Mutex.new
47
+ @condition = ConditionVariable.new
48
+ @active_interruptions = 0
49
+ @closing = false
50
+ @execution_state = ExecutionState.capture
51
+ end
52
+
53
+ # Returns +:pending+, +:running+, or +:finished+.
54
+ def state
55
+ @mutex.synchronize { @state }
56
+ end
57
+
58
+ # Returns an event-delivery or cleanup exception raised by the worker.
59
+ def error
60
+ @mutex.synchronize { @error }
61
+ end
62
+
63
+ # Indicates that the worker or an interruption call is still active.
64
+ def active?
65
+ @mutex.synchronize { @state != :finished || @active_interruptions.positive? }
66
+ end
67
+
68
+ # Indicates that the worker and all interruption calls have finished.
69
+ def finished?
70
+ !active?
71
+ end
72
+
73
+ # Prepares and delivers one interruption to the active run.
74
+ #
75
+ # +payload+ may be a message or a Hash containing +message+ and the options
76
+ # accepted by Run#interrupt_response. Runtime hooks receive the Hash before
77
+ # delivery, allowing them to materialize trusted application attachments.
78
+ # Calls may overlap, but +close+ prevents new calls and waits for calls that
79
+ # have already begun.
80
+ def interrupt_response(payload = nil, **options)
81
+ if payload.nil? && options.key?(:message)
82
+ payload = options.delete(:message)
83
+ end
84
+ interruption_started = false
85
+ begin_interruption!
86
+ interruption_started = true
87
+ run.interrupt_response_with do
88
+ prepared = run.prepare_interruption(interruption_payload(payload, options))
89
+ interruption_arguments(prepared, options)
90
+ end
91
+ ensure
92
+ finish_interruption! if interruption_started
93
+ end
94
+
95
+ # Requests cooperative cancellation and returns +self+.
96
+ def cancel
97
+ run.cancellation_token.cancel
98
+ self
99
+ end
100
+
101
+ # Waits for the worker and in-flight interruptions, then returns the Run.
102
+ #
103
+ # +deadline+ is an absolute Time. Reaching it raises DeadlineExceededError
104
+ # without cancelling the run. An event-delivery or cleanup failure raised by
105
+ # the worker is re-raised after all supervised work finishes.
106
+ def wait(deadline: nil)
107
+ worker = wait_until_finished(deadline:)
108
+ worker.join
109
+ caught = error
110
+ raise caught if caught
111
+
112
+ run
113
+ end
114
+
115
+ # Prevents new interruptions, requests cancellation, and waits for shutdown.
116
+ # The operation is idempotent. +deadline+ has the same meaning as in #wait.
117
+ def close(deadline: nil)
118
+ @mutex.synchronize { @closing = true }
119
+ cancel
120
+ wait(deadline:)
121
+ end
122
+
123
+ protected
124
+
125
+ def start # :nodoc:
126
+ @mutex.synchronize do
127
+ raise Error, "execution has already started" unless @state == :pending
128
+
129
+ @state = :running
130
+ @worker = Thread.new { execute }
131
+ end
132
+ self
133
+ end
134
+
135
+ private
136
+
137
+ def execute
138
+ ExecutionState.with(@execution_state) do
139
+ run.each { |event| @event_consumer&.call(event) }
140
+ end
141
+ rescue => caught
142
+ @mutex.synchronize { @error = caught }
143
+ ensure
144
+ @mutex.synchronize do
145
+ @state = :finished
146
+ @condition.broadcast
147
+ end
148
+ end
149
+
150
+ def begin_interruption!
151
+ @mutex.synchronize do
152
+ raise AgentInterruptError, "Execution is closing" if @closing
153
+ raise AgentInterruptError, "Execution has already finished" if @state == :finished
154
+
155
+ @active_interruptions += 1
156
+ end
157
+ end
158
+
159
+ def finish_interruption!
160
+ @mutex.synchronize do
161
+ @active_interruptions -= 1 if @active_interruptions.positive?
162
+ @condition.broadcast
163
+ end
164
+ end
165
+
166
+ def interruption_payload(payload, options)
167
+ values = payload.is_a?(Hash) ? payload.dup : {message: payload}
168
+ options.each { |key, value| values[key] = value }
169
+ values
170
+ end
171
+
172
+ def interruption_arguments(prepared, fallback)
173
+ unless prepared.is_a?(Hash)
174
+ return [prepared, fallback.slice(:interruption_id, :batch_key, :metadata, :cancellation_token, :deadline)]
175
+ end
176
+
177
+ values = prepared.transform_keys(&:to_sym)
178
+ message = values.delete(:message) { raise ArgumentError, "prepared interruption must include a message" }
179
+ allowed = values.slice(:interruption_id, :batch_key, :metadata, :cancellation_token, :deadline)
180
+ [message, fallback.slice(:interruption_id, :batch_key, :metadata, :cancellation_token, :deadline).merge(allowed)]
181
+ end
182
+
183
+ def wait_until_finished(deadline:)
184
+ monotonic_deadline = if deadline
185
+ monotonic_time + [deadline - Time.now, 0].max
186
+ end
187
+ @mutex.synchronize do
188
+ until @state == :finished && @active_interruptions.zero?
189
+ if monotonic_deadline
190
+ remaining = monotonic_deadline - monotonic_time
191
+ raise DeadlineExceededError, "Execution did not finish before the wait deadline" unless remaining.positive?
192
+
193
+ @condition.wait(@mutex, remaining)
194
+ else
195
+ @condition.wait(@mutex)
196
+ end
197
+ end
198
+ @worker
199
+ end
200
+ end
201
+
202
+ def monotonic_time
203
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
204
+ end
205
+ end
206
+ end