phronomy 0.13.0 → 0.15.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +155 -0
  3. data/README.md +266 -38
  4. data/benchmark/bench_agent_invoke.rb +2 -3
  5. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +14 -67
  6. data/docs/decisions/011-delegate-transport-policy-to-adapters.md +82 -0
  7. data/docs/mcp-client.md +75 -0
  8. data/examples/workflows/agent_event_mapping.rb +104 -0
  9. data/examples/workflows/generic_task_event_mapping.rb +58 -0
  10. data/gemfiles/mcp_1_0.gemfile +9 -0
  11. data/lib/phronomy/agent/agent_invocation.rb +385 -0
  12. data/lib/phronomy/agent/agent_invocation_registry.rb +75 -0
  13. data/lib/phronomy/agent/agent_invocation_session_builder.rb +448 -0
  14. data/lib/phronomy/agent/approval_evaluation_request.rb +102 -0
  15. data/lib/phronomy/agent/async_event_api.rb +471 -0
  16. data/lib/phronomy/agent/base.rb +509 -420
  17. data/lib/phronomy/agent/context/capability/base.rb +57 -119
  18. data/lib/phronomy/agent/llm_operation_result.rb +23 -0
  19. data/lib/phronomy/agent/phase_machine_builder.rb +75 -136
  20. data/lib/phronomy/agent/tool_approval_request.rb +121 -0
  21. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -15
  22. data/lib/phronomy/agent/tool_executor.rb +47 -69
  23. data/lib/phronomy/agent/tool_invocation.rb +634 -0
  24. data/lib/phronomy/agent/tool_invocation_session_builder.rb +378 -0
  25. data/lib/phronomy/agent.rb +21 -9
  26. data/lib/phronomy/configuration.rb +58 -53
  27. data/lib/phronomy/diagnostics.rb +1 -1
  28. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +230 -118
  29. data/lib/phronomy/engine/concurrency/cancellation_token.rb +5 -1
  30. data/lib/phronomy/engine/concurrency/pool_registry.rb +8 -3
  31. data/lib/phronomy/engine/event_loop.rb +507 -303
  32. data/lib/phronomy/engine/fsm_session.rb +181 -140
  33. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +1 -1
  34. data/lib/phronomy/engine/runtime/shutdown_result.rb +62 -0
  35. data/lib/phronomy/engine/runtime/task_registry.rb +62 -15
  36. data/lib/phronomy/engine/runtime.rb +247 -57
  37. data/lib/phronomy/engine/task.rb +5 -10
  38. data/lib/phronomy/event.rb +8 -8
  39. data/lib/phronomy/generator_verifier.rb +253 -142
  40. data/lib/phronomy/invalid_async_entry_action_error.rb +9 -0
  41. data/lib/phronomy/invalid_async_transition_action_error.rb +11 -0
  42. data/lib/phronomy/invalid_async_workflow_action_error.rb +9 -0
  43. data/lib/phronomy/invocation_context.rb +5 -19
  44. data/lib/phronomy/llm_adapter/base.rb +25 -34
  45. data/lib/phronomy/metrics.rb +6 -3
  46. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +54 -89
  47. data/lib/phronomy/stream_callback_error.rb +35 -0
  48. data/lib/phronomy/testing/scheduler_helpers.rb +12 -3
  49. data/lib/phronomy/tools/mcp.rb +410 -81
  50. data/lib/phronomy/version.rb +1 -1
  51. data/lib/phronomy/workflow/phase_machine_builder.rb +129 -182
  52. data/lib/phronomy/workflow.rb +122 -261
  53. data/lib/phronomy/workflow_context.rb +55 -104
  54. data/lib/phronomy/workflow_runner.rb +239 -291
  55. data/lib/phronomy.rb +30 -23
  56. data/scripts/check_readme_runnable.rb +4 -1
  57. metadata +63 -11
  58. data/lib/phronomy/agent/concerns/retryable.rb +0 -103
  59. data/lib/phronomy/agent/context/capability/scope_policy.rb +0 -54
  60. data/lib/phronomy/agent/invocation_context.rb +0 -171
  61. data/lib/phronomy/agent/invocation_session.rb +0 -346
  62. data/lib/phronomy/agent/suspended_session_registry.rb +0 -54
  63. data/lib/phronomy/engine/concurrency/concurrency_gate.rb +0 -157
  64. data/lib/phronomy/engine/concurrency/gate_registry.rb +0 -51
@@ -3,38 +3,33 @@
3
3
  module Phronomy
4
4
  # Event-driven execution wrapper for a single FSM session.
5
5
  #
6
- # Used by both WorkflowRunner (for Workflow) and Agent::InvocationSession
7
- # (for Agent invoke). Not Workflow-specific.
8
- #
9
- # Created by a runner and registered with EventLoop. All public methods
10
- # are called from the EventLoop thread — FSMSession is NOT thread-safe and must
11
- # not be accessed concurrently from multiple threads.
12
- #
6
+ # All public methods are called from the Runtime-owned EventLoop thread.
7
+ # FSMSession owns FSM execution only; it does not own external Task handles,
8
+ # activity tokens, callback correlation, or domain-specific stale-event policy.
13
9
  class FSMSession
14
10
  FINISH = WorkflowRunner::FINISH
15
11
 
16
- # @return [String] workflow thread_id (matches WorkflowContext#thread_id)
17
- attr_reader :id
18
-
19
- # @param id [String]
20
- # @param context [Object] includes Phronomy::WorkflowContext
21
- # @param entry_point [Symbol] initial state name
22
- # @param entry_actions [Hash] { state_name => [callable, ...] }
23
- # @param auto_state_set [Hash] { state_name => true }
24
- # @param declared_states [Array<Symbol>] all action state names
25
- # @param wait_state_names [Array<Symbol>]
26
- # @param external_events [Hash] { event_name => [{from:, to:, guard:}] }
27
- # @param phase_machine_class [Class] state_machines-backed phase tracker class
28
- # @param recursion_limit [Integer]
29
- # @param action_timeouts [Hash] { state_name => seconds }
30
- # @param resume_event [Symbol, nil] external event to fire when resuming
31
- # @param resume_phase [Symbol, nil] wait state name to resume from
32
- # @api private
33
- def initialize(id:, context:, entry_point:, entry_actions:, auto_state_set:,
34
- declared_states:, wait_state_names:, external_events:, phase_machine_class:,
35
- recursion_limit:, action_timeouts: {}, resume_event: nil, resume_phase: nil)
12
+ attr_reader :id, :context
13
+
14
+ def initialize(
15
+ id:,
16
+ context:,
17
+ entry_point:,
18
+ entry_actions:,
19
+ auto_state_set:,
20
+ declared_states:,
21
+ wait_state_names:,
22
+ external_events:,
23
+ phase_machine_class:,
24
+ recursion_limit:,
25
+ event_loop:,
26
+ resume_event: nil,
27
+ resume_phase: nil,
28
+ stable_observer: nil
29
+ )
36
30
  @id = id
37
31
  @ctx = context
32
+ @context = context
38
33
  @entry_point = entry_point
39
34
  @entry_actions = entry_actions
40
35
  @auto_state_set = auto_state_set
@@ -43,153 +38,145 @@ module Phronomy
43
38
  @external_events = external_events
44
39
  @phase_machine_class = phase_machine_class
45
40
  @recursion_limit = recursion_limit
46
- @action_timeouts = action_timeouts
41
+ @event_loop = event_loop
47
42
  @resume_event = resume_event
48
43
  @resume_phase = resume_phase
44
+ @stable_observer = stable_observer
49
45
  @step = 0
50
46
  @done = false
51
47
  @current_state = nil
52
48
  @tracker = nil
53
49
  end
54
50
 
55
- # Begins workflow execution. Called by EventLoop on :start event.
56
51
  def start
57
52
  if @resume_event
58
- # Resume from wait state: position tracker at the wait state, then fire the
59
- # external event. state_machines fires before_transition (exit) and
60
- # after_transition (entry) callbacks, so both actions execute here.
61
53
  @current_state = @resume_phase
62
54
  @tracker = build_tracker(@current_state)
63
55
  @tracker.context = @ctx
64
- @tracker.session_id = @id if @tracker.respond_to?(:session_id=)
65
- fire_and_advance!(@resume_event)
56
+ fire_and_advance!(
57
+ Phronomy::Event.new(
58
+ type: @resume_event,
59
+ target_id: @id,
60
+ payload: nil
61
+ )
62
+ )
66
63
  else
67
- # Fresh start: state_machines does not fire callbacks on initialization,
68
- # so we invoke the entry action for the initial state manually.
69
64
  @current_state = @entry_point
70
65
  @tracker = build_tracker(@current_state)
71
66
  @tracker.context = @ctx
72
- @tracker.session_id = @id if @tracker.respond_to?(:session_id=)
73
- (@entry_actions[@current_state] || []).each do |c|
74
- result = c.call(@ctx)
75
- if result.is_a?(Phronomy::Task)
76
- # Awaitable action: resume via on_complete without blocking EventLoop.
77
- @tracker.async_pending = true
78
- session_id = @id
79
- current_state_name = @current_state
80
- timeout_secs = @action_timeouts[current_state_name]
81
- if timeout_secs
82
- Phronomy::Runtime.instance.timer_queue.schedule(seconds: timeout_secs) do
83
- next if result.done?
84
-
85
- event_loop.post(
86
- Event.new(
87
- type: :error,
88
- target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID,
89
- payload: {session_id: session_id, result: Phronomy::ActionTimeoutError.new(
90
- "Action in state #{current_state_name.inspect} timed out after #{timeout_secs}s"
91
- )}
92
- )
93
- )
94
- end
95
- end
96
- result.on_complete do |task_result, error|
97
- if error
98
- event_loop.post(Event.new(type: :error, target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID, payload: {session_id: session_id, result: error}))
99
- next
100
- end
101
- if _fsm_context?(task_result)
102
- event_loop.post(Event.new(type: :action_completed, target_id: session_id, payload: task_result))
103
- else
104
- event_loop.post(Event.new(type: :state_completed, target_id: session_id, payload: nil))
105
- end
106
- end
107
- break # Only one async action at a time per state
108
- elsif _fsm_context?(result)
109
- @ctx = result
110
- end
111
- end
67
+ run_initial_entry_actions!
112
68
  @tracker.context = @ctx
113
- advance_or_halt unless @tracker.async_pending
69
+ advance_or_halt
114
70
  end
115
- rescue => e
116
- finish_with_error(e)
71
+ rescue => error
72
+ finish_with_error(error)
117
73
  end
118
74
 
119
- # Processes an event dispatched from EventLoop.
120
- # Called for :state_completed, :action_completed, and all user-defined external events.
121
- #
122
- # @param event [Phronomy::Event]
123
- # @api private
124
75
  def handle(event)
125
76
  return if @done
126
77
 
127
- if event.type == :action_completed
128
- # An awaitable entry action completed: update context and advance.
129
- @ctx = event.payload if _fsm_context?(event.payload)
130
- @tracker.context = @ctx
131
- @tracker.async_pending = false # Reset flag set by start or fire_and_advance!
132
- advance_or_halt
78
+ context_disposition = apply_context_event(event)
79
+ return if context_disposition == :consume
80
+
81
+ if context_disposition &&
82
+ !has_external_event_from?(@current_state, event.type)
133
83
  return
134
84
  end
135
85
 
136
- # When :state_completed arrives from an async Task (non-WorkflowContext result),
137
- # async_pending may still be true from the spawn. Clear it before advancing.
138
- @tracker.async_pending = false if event.type == :state_completed && @tracker.async_pending
139
-
140
- fire_and_advance!(event.type)
141
- rescue => e
142
- finish_with_error(e)
86
+ fire_and_advance!(event)
87
+ rescue => error
88
+ finish_with_error(error)
143
89
  end
144
90
 
145
91
  private
146
92
 
147
- # Fires event_name on the phase tracker, updates @current_state, then
148
- # calls advance_or_halt to decide what to do next.
149
- def fire_and_advance!(event_name)
93
+ def run_initial_entry_actions!
94
+ Array(@entry_actions[@current_state]).each do |callable|
95
+ result = callable.call(@ctx)
96
+ apply_synchronous_action_result!(result, @current_state)
97
+ end
98
+ end
99
+
100
+ def apply_synchronous_action_result!(result, state_name)
101
+ if result.is_a?(Phronomy::Task)
102
+ raise Phronomy::InvalidAsyncEntryActionError,
103
+ "Entry action for state #{state_name.inspect} returned Phronomy::Task. " \
104
+ "Start the asynchronous operation, register its callback/listener, " \
105
+ "and return the WorkflowContext or nil."
106
+ end
107
+
108
+ if _fsm_context?(result)
109
+ @ctx = result
110
+ @context = result
111
+ end
112
+ end
113
+
114
+ def apply_context_event(event)
115
+ return false unless @ctx.respond_to?(:handle_fsm_event)
116
+
117
+ result = @ctx.handle_fsm_event(event)
118
+ return :consume if result == :consume
119
+
120
+ if _fsm_context?(result)
121
+ @ctx = result
122
+ @context = result
123
+ @tracker.context = @ctx
124
+ true
125
+ else
126
+ !!result
127
+ end
128
+ end
129
+
130
+ def fire_and_advance!(event)
150
131
  if @step >= @recursion_limit
151
132
  raise Phronomy::RecursionLimitError,
152
133
  "Recursion limit (#{@recursion_limit}) exceeded"
153
134
  end
154
135
 
155
- fire_event!(@tracker, event_name, @current_state)
136
+ @tracker.context = @ctx
137
+ clear_selected_transition!
138
+ @tracker.current_event = event if @tracker.respond_to?(:current_event=)
139
+ transitioned = fire_event!(@tracker, event.type, @current_state)
140
+ return unless transitioned
141
+
156
142
  @ctx = @tracker.context
157
- next_phase = @tracker.phase.to_sym
158
- # When next_phase == @current_state, no transition matched → treat as terminal.
159
- @current_state = (next_phase == @current_state) ? FINISH : next_phase
143
+ @context = @ctx
144
+ @current_state = @tracker.phase.to_sym
160
145
  @step += 1
146
+ advance_or_halt
147
+ ensure
148
+ @tracker.current_event = nil if @tracker&.respond_to?(:current_event=)
149
+ clear_selected_transition!
150
+ end
161
151
 
162
- # If an entry action returned a Task, the after_transition callback set
163
- # async_pending = true and spawned a thread. Skip advance_or_halt — the
164
- # background thread will post :action_completed or :state_completed.
165
- if @tracker.async_pending
166
- @tracker.async_pending = false
167
- return
168
- end
152
+ def clear_selected_transition!
153
+ return unless @tracker
169
154
 
170
- advance_or_halt
155
+ if @tracker.respond_to?(:selected_transition_action=)
156
+ @tracker.selected_transition_action = nil
157
+ end
158
+ if @tracker.respond_to?(:selected_transition_metadata=)
159
+ @tracker.selected_transition_metadata = nil
160
+ end
171
161
  end
172
162
 
173
- # Determines the next action after the FSM has entered @current_state.
174
163
  def advance_or_halt
175
164
  return finish! if @current_state == FINISH
176
165
 
166
+ notify_stable_state!
167
+
177
168
  if @wait_state_names.include?(@current_state)
178
- return halt!
169
+ halt!
170
+ return
179
171
  end
180
172
 
181
173
  if @auto_state_set.key?(@current_state)
182
- event_loop.post(Event.new(type: :state_completed, target_id: @id, payload: nil))
174
+ post_session_event(:state_completed)
183
175
  return
184
176
  end
185
177
 
186
- if has_external_event_from?(@current_state)
187
- # Async IO pattern: the entry action spawned an IO thread that will post
188
- # an external event back. Stay registered; do nothing here.
189
- return
190
- end
178
+ return if has_external_event_from?(@current_state)
191
179
 
192
- # No transition declared — validate the state is known, then treat as terminal.
193
180
  unless @declared_states.include?(@current_state)
194
181
  raise ArgumentError, "State #{@current_state.inspect} is not defined"
195
182
  end
@@ -197,33 +184,96 @@ module Phronomy
197
184
  finish!
198
185
  end
199
186
 
187
+ def notify_stable_state!
188
+ return unless @stable_observer
189
+
190
+ @stable_observer.call(
191
+ {
192
+ state: @current_state,
193
+ context: @ctx
194
+ }
195
+ )
196
+ end
197
+
198
+ def post_session_event(type, payload = nil)
199
+ event = Phronomy::Event.new(
200
+ type: type,
201
+ target_id: @id,
202
+ payload: payload
203
+ )
204
+ accepted =
205
+ if @event_loop.respond_to?(:post_to_session)
206
+ @event_loop.post_to_session(event)
207
+ else
208
+ @event_loop.post(event)
209
+ end
210
+ return if accepted
211
+
212
+ raise Phronomy::RuntimeShutdownError,
213
+ "EventLoop rejected #{type.inspect} for FSMSession #{@id}"
214
+ end
215
+
200
216
  def finish!
217
+ return if @done
218
+
201
219
  @done = true
202
220
  @ctx.set_graph_metadata(thread_id: @id, phase: :__end__)
203
- event_loop.post(Event.new(type: :finished, target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID, payload: {session_id: @id, result: @ctx}))
221
+ post_terminal_event(:finished, @ctx)
204
222
  end
205
223
 
206
224
  def halt!
225
+ return if @done
226
+
207
227
  @done = true
208
228
  @ctx.set_graph_metadata(thread_id: @id, phase: @current_state)
209
- event_loop.post(Event.new(type: :halted, target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID, payload: {session_id: @id, result: @ctx}))
229
+ post_terminal_event(:halted, @ctx)
210
230
  end
211
231
 
212
- def finish_with_error(err)
232
+ def finish_with_error(error)
233
+ return if @done
234
+
213
235
  @done = true
214
- event_loop.post(Event.new(type: :error, target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID, payload: {session_id: @id, result: err}))
236
+ post_terminal_event(:error, error)
237
+ end
238
+
239
+ def post_terminal_event(type, result)
240
+ accepted = @event_loop.post(
241
+ Phronomy::Event.new(
242
+ type: type,
243
+ target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID,
244
+ payload: {session_id: @id, result: result}
245
+ )
246
+ )
247
+ return if accepted
248
+
249
+ Phronomy.configuration.logger&.warn(
250
+ "[Phronomy::FSMSession] EventLoop rejected terminal event " \
251
+ "#{type.inspect} for #{@id}"
252
+ )
215
253
  end
216
254
 
217
255
  def fire_event!(tracker, event_name, from_state)
218
- return if tracker.send(event_name)
256
+ unless tracker.respond_to?(event_name)
257
+ raise ArgumentError,
258
+ "Unknown FSM event #{event_name.inspect} for state #{from_state.inspect}"
259
+ end
260
+
261
+ return true if tracker.public_send(event_name)
262
+
263
+ # A declared external event whose guards all reject is a valid no-op.
264
+ # Applications use this to reject stale or unrelated correlated events.
265
+ return false if has_external_event_from?(from_state, event_name)
219
266
 
220
267
  raise ArgumentError,
221
268
  "Transition from #{from_state.inspect} via event #{event_name.inspect} failed. " \
222
- "Ensure at least one guard matches or add a fallback (no-guard) transition."
269
+ "The event is not declared for the current state."
223
270
  end
224
271
 
225
- def has_external_event_from?(state)
226
- @external_events.any? { |_, transitions| transitions.any? { |t| t[:from] == state } }
272
+ def has_external_event_from?(state, event_name = nil)
273
+ events = event_name ? {event_name => @external_events[event_name]} : @external_events
274
+ events.any? do |_name, transitions|
275
+ Array(transitions).any? { |transition| transition[:from] == state }
276
+ end
227
277
  end
228
278
 
229
279
  def build_tracker(from_state)
@@ -232,17 +282,8 @@ module Phronomy
232
282
  machine
233
283
  end
234
284
 
235
- def event_loop
236
- Phronomy::EventLoop.instance
237
- end
238
-
239
- # Returns true when +obj+ is an FSM execution context (responds to
240
- # +set_graph_metadata+). Used to distinguish context objects from other
241
- # Task return values (strings, hashes, etc.) without hard-coding a specific
242
- # class. Both WorkflowContext and Agent::InvocationContext qualify.
243
- # @api private
244
- def _fsm_context?(obj)
245
- obj.respond_to?(:set_graph_metadata)
285
+ def _fsm_context?(object)
286
+ object.respond_to?(:set_graph_metadata)
246
287
  end
247
288
  end
248
289
  end
@@ -38,7 +38,7 @@ module Phronomy
38
38
  class DeterministicScheduler < Scheduler
39
39
  # Scheduler-aware signal for cooperative suspension.
40
40
  #
41
- # Used by {ConcurrencyGate} and {TaskGroup} to suspend a Fiber until a
41
+ # Used by {TaskGroup} to suspend a Fiber until a
42
42
  # slot or condition becomes available, without blocking the OS thread.
43
43
  # All methods must be called from within a {DeterministicScheduler} tick.
44
44
  # @api private
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class Runtime
5
+ # Immutable result returned by {Runtime#shutdown}.
6
+ #
7
+ # +runtime_outcome+ records whether execution remained healthy. It is
8
+ # intentionally independent from +cleanup_status+: a Runtime may fail
9
+ # during execution but still release every owned resource successfully.
10
+ # @api private
11
+ class ShutdownResult
12
+ attr_reader :runtime_outcome,
13
+ :cleanup_status,
14
+ :event_loop_status,
15
+ :task_registry_status,
16
+ :error
17
+
18
+ def self.not_started
19
+ new(
20
+ runtime_outcome: :terminated,
21
+ cleanup_status: :complete,
22
+ event_loop_status: :not_started,
23
+ task_registry_status: :empty
24
+ )
25
+ end
26
+
27
+ def initialize(
28
+ runtime_outcome:,
29
+ cleanup_status:,
30
+ event_loop_status:,
31
+ task_registry_status:,
32
+ error: nil
33
+ )
34
+ @runtime_outcome = runtime_outcome
35
+ @cleanup_status = cleanup_status
36
+ @event_loop_status = event_loop_status
37
+ @task_registry_status = task_registry_status
38
+ @error = error
39
+ freeze
40
+ end
41
+
42
+ # Returns true when every Runtime-owned resource is known to have stopped.
43
+ def cleanup_complete?
44
+ @cleanup_status == :complete
45
+ end
46
+
47
+ # Returns true only for a graceful, error-free shutdown.
48
+ # A cancellation may release every resource, but is not considered clean.
49
+ def clean?
50
+ @runtime_outcome == :terminated &&
51
+ @cleanup_status == :complete &&
52
+ %i[not_started terminated].include?(@event_loop_status) &&
53
+ @task_registry_status == :empty
54
+ end
55
+
56
+ # Runtime reset is safe when cleanup completed, even if execution failed.
57
+ def success?
58
+ cleanup_complete?
59
+ end
60
+ end
61
+ end
62
+ end
@@ -5,10 +5,8 @@ module Phronomy
5
5
  # Internal registry of active {Task} instances for a {Runtime}.
6
6
  #
7
7
  # Tracks every task that has been spawned but not yet completed so that
8
- # {Runtime#shutdown} can drain them. Tasks that complete synchronously
9
- # (e.g. under {FakeScheduler} / {ImmediateBackend}) deregister themselves
10
- # before the caller returns from {Runtime#spawn}, so they are never added
11
- # to the registry in the first place.
8
+ # {Runtime#shutdown} can drain them. Tasks that complete synchronously
9
+ # deregister themselves before the caller returns from {Runtime#spawn}.
12
10
  # @api private
13
11
  class TaskRegistry
14
12
  def initialize
@@ -16,33 +14,82 @@ module Phronomy
16
14
  @tasks = []
17
15
  end
18
16
 
19
- # Adds +task+ to the registry unless it already completed synchronously.
20
- # @param task [Task]
21
- # @return [void]
17
+ # Adds +task+ unless it already completed synchronously.
22
18
  # @api private
23
19
  def register(task)
24
20
  @mutex.synchronize { @tasks << task unless task.done? }
25
21
  end
26
22
 
27
- # Removes +task+ from the registry (called from the task's ensure block).
28
- # @param task [Task]
29
- # @return [void]
23
+ # Removes +task+ from the registry.
30
24
  # @api private
31
25
  def deregister(task)
32
26
  @mutex.synchronize { @tasks.delete(task) }
33
27
  end
34
28
 
35
- # Waits for all registered tasks to finish (used by {Runtime#shutdown}).
36
- # @return [void]
29
+ # Waits for registered tasks until the absolute monotonic +deadline+.
30
+ # The registry is re-snapshotted because tasks may create follow-up tasks
31
+ # while Runtime shutdown is draining accepted work.
32
+ #
33
+ # @param deadline [Numeric] absolute Process::CLOCK_MONOTONIC value
34
+ # @return [Symbol] +:empty+ or +:timeout+
35
+ # @api private
36
+ def drain_until(deadline)
37
+ loop do
38
+ tasks = snapshot
39
+ return :empty if tasks.empty?
40
+
41
+ tasks.each do |task|
42
+ remaining = deadline - monotonic_now
43
+ return :timeout if remaining <= 0
44
+
45
+ begin
46
+ task.join(remaining)
47
+ rescue
48
+ # Some backends re-raise the task error from join. The task's
49
+ # ensure block still deregisters it, so continue the drain.
50
+ nil
51
+ end
52
+
53
+ return :timeout if task.alive? && monotonic_now >= deadline
54
+ end
55
+
56
+ return :empty if empty?
57
+ return :timeout if monotonic_now >= deadline
58
+ end
59
+ end
60
+
61
+ # Compatibility helper for callers that explicitly require an unbounded
62
+ # drain. Runtime#shutdown uses {#drain_until}.
37
63
  # @api private
38
64
  def drain
39
- tasks = @mutex.synchronize { @tasks.dup }
40
- tasks.each do |t|
41
- t.join
65
+ snapshot.each do |task|
66
+ task.join
42
67
  rescue
43
68
  nil
44
69
  end
45
70
  end
71
+
72
+ # @return [Boolean]
73
+ # @api private
74
+ def empty?
75
+ @mutex.synchronize { @tasks.empty? }
76
+ end
77
+
78
+ # @return [Integer]
79
+ # @api private
80
+ def size
81
+ @mutex.synchronize { @tasks.size }
82
+ end
83
+
84
+ private
85
+
86
+ def snapshot
87
+ @mutex.synchronize { @tasks.dup }
88
+ end
89
+
90
+ def monotonic_now
91
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
92
+ end
46
93
  end
47
94
  end
48
95
  end