phronomy 0.7.0 → 0.7.1

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -7
  3. data/CHANGELOG.md +151 -1
  4. data/README.md +155 -32
  5. data/Rakefile +33 -0
  6. data/benchmark/baseline.json +1 -1
  7. data/benchmark/bench_regression.rb +1 -0
  8. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
  9. data/docs/decisions/006-no-built-in-guardrails.md +20 -2
  10. data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
  11. data/lib/phronomy/agent/base.rb +250 -65
  12. data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
  13. data/lib/phronomy/agent/fsm.rb +41 -64
  14. data/lib/phronomy/agent/orchestrator.rb +146 -121
  15. data/lib/phronomy/agent/parallel_tool_chat.rb +79 -22
  16. data/lib/phronomy/agent/react_agent.rb +8 -0
  17. data/lib/phronomy/async_queue.rb +155 -0
  18. data/lib/phronomy/blocking_adapter_pool.rb +435 -0
  19. data/lib/phronomy/cancellation_scope.rb +123 -0
  20. data/lib/phronomy/cancellation_token.rb +43 -2
  21. data/lib/phronomy/concurrency_gate.rb +155 -0
  22. data/lib/phronomy/configuration.rb +142 -0
  23. data/lib/phronomy/deadline.rb +63 -0
  24. data/lib/phronomy/diagnostics.rb +62 -0
  25. data/lib/phronomy/embeddings/base.rb +17 -0
  26. data/lib/phronomy/eval/runner.rb +9 -9
  27. data/lib/phronomy/event_loop.rb +181 -43
  28. data/lib/phronomy/fsm_session.rb +50 -4
  29. data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
  30. data/lib/phronomy/invocation_context.rb +152 -0
  31. data/lib/phronomy/knowledge_source/base.rb +18 -0
  32. data/lib/phronomy/llm_adapter/base.rb +104 -0
  33. data/lib/phronomy/llm_adapter/ruby_llm.rb +41 -0
  34. data/lib/phronomy/llm_adapter.rb +20 -0
  35. data/lib/phronomy/metrics.rb +38 -0
  36. data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
  37. data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
  38. data/lib/phronomy/runtime/gate_registry.rb +52 -0
  39. data/lib/phronomy/runtime/pool_registry.rb +57 -0
  40. data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
  41. data/lib/phronomy/runtime/scheduler.rb +98 -0
  42. data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
  43. data/lib/phronomy/runtime/task_registry.rb +48 -0
  44. data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
  45. data/lib/phronomy/runtime/timer_queue.rb +106 -0
  46. data/lib/phronomy/runtime/timer_service.rb +42 -0
  47. data/lib/phronomy/runtime.rb +374 -0
  48. data/lib/phronomy/task/backend.rb +80 -0
  49. data/lib/phronomy/task/fiber_backend.rb +157 -0
  50. data/lib/phronomy/task/immediate_backend.rb +89 -0
  51. data/lib/phronomy/task/thread_backend.rb +84 -0
  52. data/lib/phronomy/task.rb +275 -0
  53. data/lib/phronomy/task_group.rb +265 -0
  54. data/lib/phronomy/testing/fake_clock.rb +109 -0
  55. data/lib/phronomy/testing/fake_scheduler.rb +104 -0
  56. data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
  57. data/lib/phronomy/testing.rb +12 -0
  58. data/lib/phronomy/tool/base.rb +110 -2
  59. data/lib/phronomy/tool/mcp_tool.rb +47 -16
  60. data/lib/phronomy/tool/scope_policy.rb +50 -0
  61. data/lib/phronomy/tool_executor.rb +106 -0
  62. data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
  63. data/lib/phronomy/vector_store/async_backend.rb +110 -0
  64. data/lib/phronomy/vector_store/base.rb +7 -0
  65. data/lib/phronomy/version.rb +1 -1
  66. data/lib/phronomy/workflow.rb +52 -5
  67. data/lib/phronomy/workflow_context.rb +29 -2
  68. data/lib/phronomy/workflow_runner.rb +74 -3
  69. data/lib/phronomy.rb +42 -0
  70. metadata +40 -2
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class Runtime
5
+ # Synchronous scheduler for use in tests.
6
+ #
7
+ # Each spawned task is executed immediately on the calling thread using
8
+ # {Task::ImmediateBackend}. No new threads are created, so a
9
+ # {Runtime} that uses +FakeScheduler+ does not increase the process
10
+ # Thread count on {Runtime#spawn}.
11
+ #
12
+ # In addition to the basic synchronous execution, +FakeScheduler+ records
13
+ # all task lifecycle events in {#event_log} and all spawned tasks in
14
+ # {#tasks}. This allows specs to assert event ordering and task state
15
+ # without relying on wall-clock sleeps.
16
+ #
17
+ # === tick / tick_until
18
+ #
19
+ # Because +FakeScheduler+ uses {Task::ImmediateBackend}, every task runs
20
+ # to completion before {Runtime#spawn} returns. Consequently {#tick} is
21
+ # semantically a no-op -- the "ready task" has already executed. It is
22
+ # provided so that test code written against the cooperative scheduler
23
+ # interface compiles and documents intent (e.g. "advance by one step").
24
+ #
25
+ # === pending_timers
26
+ #
27
+ # If a {Phronomy::Testing::FakeClock} is injected via {#clock=}, its
28
+ # pending callbacks are surfaced as +pending_timers+.
29
+ #
30
+ # @example
31
+ # runtime = Phronomy::Runtime.new(scheduler: Phronomy::Runtime::FakeScheduler.new)
32
+ # task = runtime.spawn(name: "agent-test") { 42 }
33
+ # expect(task.await).to eq(42)
34
+ # expect(task.status).to eq(:completed)
35
+ # @api private
36
+ class FakeScheduler < Scheduler
37
+ # @return [Array<Hash>] ordered list of task lifecycle events.
38
+ # Each entry is +{ type:, task_name:, at: }+ where +type+ is one of
39
+ # +:spawned+, +:started+, +:completed+, +:cancelled+, +:failed+ and
40
+ # +at+ is a Float monotonic timestamp (seconds).
41
+ attr_reader :event_log
42
+
43
+ # @return [Array<Hash>] all tasks spawned by this scheduler.
44
+ # Each entry is +{ task:, name:, status: }+.
45
+ attr_reader :tasks
46
+
47
+ # Optional {Phronomy::Testing::FakeClock} used to timestamp events and
48
+ # surface pending timers. When +nil+, a real monotonic clock is used.
49
+ # @return [Phronomy::Testing::FakeClock, nil]
50
+ attr_accessor :clock
51
+
52
+ def initialize
53
+ @event_log = []
54
+ @tasks = []
55
+ @clock = nil
56
+ @mutex = Mutex.new
57
+ end
58
+
59
+ # Spawns +block+ as a {Task} backed by {Task::ImmediateBackend}.
60
+ # The block executes synchronously before this method returns.
61
+ # Lifecycle events are recorded in {#event_log}.
62
+ #
63
+ # @param name [String, nil]
64
+ # @param parent [Task, nil]
65
+ # @return [Task]
66
+ # @api private
67
+ def spawn(name:, parent:, &block)
68
+ _log_event(:spawned, name)
69
+ task = Task.spawn(name: name, parent: parent, backend_class: Task::ImmediateBackend) do
70
+ _log_event(:started, name)
71
+ begin
72
+ result = block.call
73
+ _log_event(:completed, name)
74
+ result
75
+ rescue CancellationError
76
+ _log_event(:cancelled, name)
77
+ raise
78
+ rescue => e
79
+ _log_event(:failed, name)
80
+ raise e
81
+ end
82
+ end
83
+ @mutex.synchronize { @tasks << {task: task, name: name, status: task.status} }
84
+ task
85
+ end
86
+
87
+ # Execute one ready task.
88
+ #
89
+ # Because {Task::ImmediateBackend} runs tasks synchronously inside
90
+ # {#spawn}, all ready tasks have already executed by the time this
91
+ # method is called. This method is a no-op provided for API
92
+ # compatibility with cooperative scheduler interfaces.
93
+ #
94
+ # @return [self]
95
+ # @api private
96
+ def tick
97
+ self
98
+ end
99
+
100
+ # Run +block+ repeatedly until it returns truthy or +max_ticks+ is
101
+ # reached. Because tasks execute synchronously, the condition is
102
+ # evaluated once; if it is already met this method returns immediately.
103
+ #
104
+ # @param max_ticks [Integer] safety bound (default: 1000)
105
+ # @yield condition evaluated after each tick
106
+ # @return [Boolean] +true+ if condition was satisfied
107
+ # @api private
108
+ def tick_until(max_ticks: 1000)
109
+ max_ticks.times do
110
+ return true if yield
111
+ tick
112
+ end
113
+ yield ? true : false
114
+ end
115
+
116
+ # Returns a list of pending timer entries surfaced from the injected
117
+ # {clock}. Returns an empty array when no clock is set.
118
+ #
119
+ # @return [Array<Hash>] each entry: +{ fire_at:, description: }+
120
+ # @api private
121
+ def pending_timers
122
+ return [] unless @clock
123
+
124
+ @clock.pending_timer_entries
125
+ end
126
+
127
+ # Assert that the named tasks completed in the given order.
128
+ # Raises +RSpec::Expectations::ExpectationNotMetError+ if order is wrong.
129
+ # Intended for use inside RSpec examples.
130
+ #
131
+ # @param names [Array<String, nil>] task names in expected order
132
+ # @return [void]
133
+ # @api private
134
+ def assert_order(*names)
135
+ completed = @event_log.select { |e| e[:type] == :completed }.map { |e| e[:task_name] }
136
+ indices = names.map { |n| completed.index(n) }
137
+ unless indices.none?(&:nil?) && indices == indices.sort
138
+ raise RSpec::Expectations::ExpectationNotMetError,
139
+ "Expected tasks to complete in order #{names.inspect} " + "but completed order was #{completed.inspect}"
140
+ end
141
+ end
142
+
143
+ # Assert that the named tasks reached +:cancelled+ state.
144
+ #
145
+ # @param names [Array<String, nil>] task names expected to be cancelled
146
+ # @return [void]
147
+ # @api private
148
+ def assert_cancelled(*names)
149
+ cancelled = @event_log.select { |e| e[:type] == :cancelled }.map { |e| e[:task_name] }
150
+ missing = names.reject { |n| cancelled.include?(n) }
151
+ return if missing.empty?
152
+
153
+ raise RSpec::Expectations::ExpectationNotMetError,
154
+ "Expected tasks #{missing.inspect} to be cancelled " + "but cancelled tasks were #{cancelled.inspect}"
155
+ end
156
+
157
+ private
158
+
159
+ def _log_event(type, task_name)
160
+ at = @clock ? @clock.now : Process.clock_gettime(Process::CLOCK_MONOTONIC)
161
+ @mutex.synchronize { @event_log << {type: type, task_name: task_name, at: at} }
162
+ end
163
+ end
164
+ end
165
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class Runtime
5
+ # Lazy cache of {ConcurrencyGate} instances, keyed by resource name.
6
+ #
7
+ # Gate concurrency caps are read from {Phronomy::Configuration} when a gate
8
+ # is first accessed; subsequent calls return the cached instance. Call
9
+ # {#reset} to drop the cache and force a rebuild on the next access.
10
+ # @api private
11
+ class GateRegistry
12
+ GATE_CONFIG_MAP = {
13
+ agent: :max_concurrent_agent_tasks,
14
+ tool: :max_concurrent_tool_tasks,
15
+ workflow: :max_concurrent_workflow_tasks,
16
+ llm: :max_concurrent_llm_calls,
17
+ rag: :max_concurrent_rag_fetches,
18
+ vector: :max_concurrent_vector_searches
19
+ }.freeze
20
+ private_constant :GATE_CONFIG_MAP
21
+
22
+ def initialize
23
+ @mutex = Mutex.new
24
+ @gates = {}
25
+ end
26
+
27
+ # Returns (or lazily creates) the gate for +name+.
28
+ # @param name [Symbol]
29
+ # @return [ConcurrencyGate]
30
+ # @api private
31
+ def get(name)
32
+ @mutex.synchronize { @gates[name] ||= _build(name) }
33
+ end
34
+
35
+ # Drops the cached gate for +name+ so the next {#get} rebuilds it.
36
+ # @param name [Symbol]
37
+ # @return [void]
38
+ # @api private
39
+ def reset(name)
40
+ @mutex.synchronize { @gates.delete(name) }
41
+ end
42
+
43
+ private
44
+
45
+ def _build(name)
46
+ config_key = GATE_CONFIG_MAP[name]
47
+ max = config_key ? Phronomy.configuration.public_send(config_key) : nil
48
+ ConcurrencyGate.new(max_concurrent: max, name: name)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class Runtime
5
+ # Registry and lifecycle manager for {BlockingAdapterPool} instances.
6
+ #
7
+ # Maintains one unnamed "default" pool (accessed via {#default_pool}) and
8
+ # an arbitrary number of named pools (accessed via {#named_pool}).
9
+ # All pools are shut down together by {#shutdown}.
10
+ # @api private
11
+ class PoolRegistry
12
+ def initialize
13
+ @mutex = Mutex.new
14
+ @pools = {}
15
+ @default = nil
16
+ end
17
+
18
+ # Returns (or lazily creates) the unnamed default pool.
19
+ # @param pool_size [Integer]
20
+ # @param queue_size [Integer]
21
+ # @return [BlockingAdapterPool]
22
+ # @api private
23
+ def default_pool(pool_size: 10, queue_size: 100)
24
+ @default ||= BlockingAdapterPool.new(
25
+ name: :default,
26
+ pool_size: pool_size,
27
+ queue_size: queue_size
28
+ )
29
+ end
30
+
31
+ # Returns (or lazily creates) a named pool.
32
+ # @param name [Symbol, String]
33
+ # @param size [Integer]
34
+ # @param queue_size [Integer]
35
+ # @return [BlockingAdapterPool]
36
+ # @api private
37
+ def named_pool(name, size: 10, queue_size: 100)
38
+ @mutex.synchronize do
39
+ @pools[name.to_sym] ||= BlockingAdapterPool.new(
40
+ name: name,
41
+ pool_size: size,
42
+ queue_size: queue_size
43
+ )
44
+ end
45
+ end
46
+
47
+ # Shuts down the default pool and all named pools.
48
+ # @return [void]
49
+ # @api private
50
+ def shutdown
51
+ @default&.shutdown
52
+ pools = @mutex.synchronize { @pools.values.dup }
53
+ pools.each(&:shutdown)
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class Runtime
5
+ # Internal store for task-centric counters and latency samples.
6
+ #
7
+ # All access is mutex-protected. The ring buffers for wait/run times are
8
+ # bounded to {WINDOW} samples so that long-lived runtimes do not grow
9
+ # unbounded.
10
+ # @api private
11
+ class RuntimeMetrics
12
+ WINDOW = 1000
13
+ private_constant :WINDOW
14
+
15
+ def initialize
16
+ @mutex = Mutex.new
17
+ @active_by_type = Hash.new(0)
18
+ @wait_ms = []
19
+ @run_ms = []
20
+ @cancelled = Hash.new(0)
21
+ @failed = Hash.new(0)
22
+ @starvation_count = 0
23
+ end
24
+
25
+ # Records that a new task of +type+ has been spawned.
26
+ # @param type [Symbol]
27
+ # @return [void]
28
+ # @api private
29
+ def record_start(type)
30
+ @mutex.synchronize { @active_by_type[type] += 1 }
31
+ end
32
+
33
+ # Appends a wait-time sample (milliseconds from spawn to start).
34
+ # @param wait_ms [Float]
35
+ # @return [void]
36
+ # @api private
37
+ def record_wait(wait_ms)
38
+ @mutex.synchronize do
39
+ @wait_ms << wait_ms
40
+ @wait_ms.shift if @wait_ms.size > WINDOW
41
+ end
42
+ end
43
+
44
+ # Records completion of a task (decrements active count, appends run time).
45
+ # @param type [Symbol]
46
+ # @param outcome [:completed, :cancelled, :failed]
47
+ # @param run_start_ms [Integer] monotonic millisecond timestamp from task start
48
+ # @return [void]
49
+ # @api private
50
+ def record_end(type, outcome, run_start_ms)
51
+ run_ms = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - run_start_ms
52
+ @mutex.synchronize do
53
+ @active_by_type[type] = [@active_by_type[type] - 1, 0].max
54
+ @run_ms << run_ms
55
+ @run_ms.shift if @run_ms.size > WINDOW
56
+ case outcome
57
+ when :cancelled then @cancelled[type] += 1
58
+ when :failed then @failed[type] += 1
59
+ end
60
+ end
61
+ end
62
+
63
+ # Increments the CPU-starvation counter (task ran without yielding over
64
+ # the configured +blocking_detect_threshold_ms+ threshold).
65
+ # @return [void]
66
+ # @api private
67
+ def increment_starvation
68
+ @mutex.synchronize { @starvation_count += 1 }
69
+ end
70
+
71
+ # Returns the current starvation counter value.
72
+ # @return [Integer]
73
+ # @api private
74
+ def starvation_count
75
+ @mutex.synchronize { @starvation_count }
76
+ end
77
+
78
+ # Returns the full task-centric metrics hash (see {Runtime#task_snapshot}).
79
+ # @return [Hash{Symbol => Numeric}]
80
+ # @api private
81
+ def snapshot
82
+ @mutex.synchronize do
83
+ active = @active_by_type.dup
84
+ wait = @wait_ms.dup
85
+ run = @run_ms.dup
86
+ cancelled = @cancelled.values.sum
87
+ failed = @failed.values.sum
88
+ starvation = @starvation_count
89
+ {
90
+ active_agent_tasks: active[:agent].to_i,
91
+ active_tool_tasks: active[:tool].to_i,
92
+ active_workflow_tasks: active[:workflow].to_i,
93
+ active_rag_tasks: active[:rag].to_i,
94
+ active_llm_tasks: active[:llm].to_i,
95
+ task_wait_time_p50_ms: _percentile(wait, 50),
96
+ task_wait_time_p95_ms: _percentile(wait, 95),
97
+ task_run_time_p50_ms: _percentile(run, 50),
98
+ task_run_time_p95_ms: _percentile(run, 95),
99
+ cancelled_tasks: cancelled,
100
+ failed_tasks: failed,
101
+ non_yield_threshold_violation_count: starvation
102
+ }
103
+ end
104
+ end
105
+
106
+ private
107
+
108
+ def _percentile(samples, pct)
109
+ return 0.0 if samples.empty?
110
+
111
+ sorted = samples.sort
112
+ idx = ((pct / 100.0) * (sorted.size - 1)).round
113
+ sorted[idx].round(3)
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class Runtime
5
+ # Abstract base class for Runtime scheduler backends.
6
+ #
7
+ # A scheduler is responsible for turning +Runtime#spawn+ calls into
8
+ # runnable {Task} objects. Concrete subclasses decide whether tasks
9
+ # execute on threads, Fibers, or some other execution primitive.
10
+ #
11
+ # The interface is intentionally minimal: callers only see {Task}
12
+ # objects and never interact with the scheduler directly.
13
+ class Scheduler
14
+ # Thread-local key under which the active scheduler is stored.
15
+ # Shared with {Task::FiberBackend} (same symbol).
16
+ # @api private
17
+ SCHEDULER_KEY = :phronomy_deterministic_scheduler
18
+
19
+ # Returns the scheduler currently dispatching on this OS thread, or +nil+
20
+ # when running outside a cooperative (Fiber-based) scheduler context.
21
+ #
22
+ # Uses +Thread#thread_variable_get+ (not +Thread#[]+) so that the value is
23
+ # visible across all Fibers running on the same OS thread.
24
+ #
25
+ # @return [Scheduler, nil]
26
+ # @api private
27
+ def self.current
28
+ Thread.current.thread_variable_get(SCHEDULER_KEY)
29
+ end
30
+
31
+ # Creates a new scheduler-aware signal object for this scheduler.
32
+ # Signalling primitives use this instead of +ConditionVariable+ when a
33
+ # cooperative scheduler is active.
34
+ #
35
+ # Default implementation raises +NotImplementedError+. Subclasses that
36
+ # support cooperative suspension (e.g. {DeterministicScheduler}) must
37
+ # override this.
38
+ #
39
+ # @return [Object] an opaque signal handle understood by {#wait_for_signal}
40
+ # and {#raise_signal}
41
+ # @api private
42
+ def new_signal
43
+ raise NotImplementedError, "#{self.class}#new_signal is not implemented"
44
+ end
45
+
46
+ # Suspends the current execution unit (Fiber or Thread) until +signal+ is
47
+ # raised via {#raise_signal}.
48
+ #
49
+ # @param signal [Object] signal handle returned by {#new_signal}
50
+ # @return [void]
51
+ # @api private
52
+ def wait_for_signal(signal)
53
+ raise NotImplementedError, "#{self.class}#wait_for_signal is not implemented"
54
+ end
55
+
56
+ # Wakes up one waiter suspended on +signal+.
57
+ #
58
+ # @param signal [Object] signal handle returned by {#new_signal}
59
+ # @return [void]
60
+ # @api private
61
+ def raise_signal(signal)
62
+ raise NotImplementedError, "#{self.class}#raise_signal is not implemented"
63
+ end
64
+
65
+ # Wakes up all waiters suspended on +signal+.
66
+ #
67
+ # @param signal [Object] signal handle returned by {#new_signal}
68
+ # @return [void]
69
+ # @api private
70
+ def raise_signal_all(signal)
71
+ raise NotImplementedError, "#{self.class}#raise_signal_all is not implemented"
72
+ end
73
+
74
+ # Spawns a new task.
75
+ #
76
+ # @param name [String, nil] optional human-readable label
77
+ # @param parent [Task, nil] parent task for cancellation propagation
78
+ # @yield block to execute concurrently (or synchronously, depending on
79
+ # the concrete scheduler)
80
+ # @return [Task]
81
+ # @api private
82
+ def spawn(name:, parent:, &block)
83
+ raise NotImplementedError, "#{self.class}#spawn is not implemented"
84
+ end
85
+
86
+ # Cooperative yield point.
87
+ #
88
+ # Default implementation is a no-op. Thread-based subclasses should
89
+ # override with +Thread.pass+; fiber-based subclasses should switch to the
90
+ # next runnable fiber.
91
+ # @return [void]
92
+ # @api private
93
+ def yield
94
+ # no-op by default; subclasses override
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class Runtime
5
+ # A drop-in replacement for {TimerQueue} that delegates timer scheduling to
6
+ # a {DeterministicScheduler} instead of spawning a dedicated background OS thread.
7
+ #
8
+ # When a {Runtime} is backed by {DeterministicScheduler} (e.g. the +:fiber+
9
+ # runtime backend), {Runtime#timer_queue} returns an instance of this adapter
10
+ # rather than a {TimerQueue}. This eliminates the +phronomy-timer-queue+
11
+ # background thread for Fiber-based runtimes.
12
+ #
13
+ # Timer callbacks are fired during {DeterministicScheduler#run_until_idle}
14
+ # when {DeterministicScheduler#autorun?} is +true+ (i.e. the +:fiber+ backend).
15
+ # They can also be fired explicitly by calling
16
+ # {DeterministicScheduler#fire_real_timers}.
17
+ #
18
+ # == Known Limitation (Issue #331)
19
+ #
20
+ # Timers that require an actual wall-clock sleep (e.g. a deadline of 10 s
21
+ # from now that will not be reached until real time elapses) will not fire
22
+ # automatically: +run_until_idle+ does not block waiting for future deadlines.
23
+ # This is an accepted limitation of the current stepping-stone implementation.
24
+ # Full resolution requires integrating the cooperative scheduler with the
25
+ # {EventLoop} tick cycle so that a single event-loop iteration checks both
26
+ # ready Fibers and expired wall-clock timers.
27
+ #
28
+ # Use the +:thread+ runtime backend (default) for production workloads that
29
+ # depend on real-time deadline enforcement.
30
+ #
31
+ # @see DeterministicScheduler#schedule_real_after
32
+ # @see DeterministicScheduler#fire_real_timers
33
+ # Bridges wall-clock timers to the cooperative {DeterministicScheduler}.
34
+ #
35
+ # Registers a recurring timer callback with the scheduler's {TimerQueue}
36
+ # so that Fiber-based tasks can await real time without blocking OS threads.
37
+ # @api private
38
+ class SchedulerTimerAdapter
39
+ # @param scheduler [DeterministicScheduler]
40
+ # @api private
41
+ def initialize(scheduler)
42
+ @scheduler = scheduler
43
+ end
44
+
45
+ # Schedules a one-shot callback to fire after +seconds+ from now.
46
+ # Delegates to {DeterministicScheduler#schedule_real_after}.
47
+ #
48
+ # Raises {PoolShutdownError} after {#shutdown} has been called, matching
49
+ # the behaviour of {TimerQueue#schedule}.
50
+ #
51
+ # @param seconds [Numeric] delay before the callback fires
52
+ # @yield called when the deadline is reached
53
+ # @return [self]
54
+ # @api private
55
+ def schedule(seconds:, &callback)
56
+ raise Phronomy::PoolShutdownError, "SchedulerTimerAdapter has been shut down" if @stopped
57
+
58
+ @scheduler.schedule_real_after(seconds, &callback)
59
+ self
60
+ end
61
+
62
+ # No-op: there is no background thread to stop.
63
+ # Present for API compatibility with {TimerQueue}.
64
+ # @return [self]
65
+ # @api private
66
+ def shutdown
67
+ @stopped = true
68
+ self
69
+ end
70
+
71
+ # Returns the number of pending (not yet fired) callbacks.
72
+ # @return [Integer]
73
+ # @api private
74
+ def pending_count
75
+ @scheduler.pending_real_timer_count
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class Runtime
5
+ # Internal registry of active {Task} instances for a {Runtime}.
6
+ #
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.
12
+ # @api private
13
+ class TaskRegistry
14
+ def initialize
15
+ @mutex = Mutex.new
16
+ @tasks = []
17
+ end
18
+
19
+ # Adds +task+ to the registry unless it already completed synchronously.
20
+ # @param task [Task]
21
+ # @return [void]
22
+ # @api private
23
+ def register(task)
24
+ @mutex.synchronize { @tasks << task unless task.done? }
25
+ end
26
+
27
+ # Removes +task+ from the registry (called from the task's ensure block).
28
+ # @param task [Task]
29
+ # @return [void]
30
+ # @api private
31
+ def deregister(task)
32
+ @mutex.synchronize { @tasks.delete(task) }
33
+ end
34
+
35
+ # Waits for all registered tasks to finish (used by {Runtime#shutdown}).
36
+ # @return [void]
37
+ # @api private
38
+ def drain
39
+ tasks = @mutex.synchronize { @tasks.dup }
40
+ tasks.each do |t|
41
+ t.join
42
+ rescue
43
+ nil
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class Runtime
5
+ # Thread-based scheduler: spawns each task in a new OS thread.
6
+ #
7
+ # This is the default scheduler used by {Runtime} in production.
8
+ # It delegates directly to {Task.spawn} with the default
9
+ # {Task::ThreadBackend}, preserving the pre-282 behaviour exactly.
10
+ # @api private
11
+ class ThreadScheduler < Scheduler
12
+ # Spawns +block+ as a new {Task} backed by a Thread.
13
+ #
14
+ # @param name [String, nil]
15
+ # @param parent [Task, nil]
16
+ # @return [Task]
17
+ # @api private
18
+ def spawn(name:, parent:, &block)
19
+ Task.spawn(name: name, parent: parent, &block)
20
+ end
21
+
22
+ # Yields the current thread's time slice to other runnable threads.
23
+ # @return [void]
24
+ # @api private
25
+ def yield
26
+ Thread.pass
27
+ end
28
+ end
29
+ end
30
+ end