phronomy 0.17.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +122 -1134
- data/README.md +70 -1280
- data/benchmark/bench_regression.rb +25 -5
- data/docs/changelog/0.14-and-earlier.md +1137 -0
- data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
- data/docs/decisions/009-state-store-abstraction.md +1 -1
- data/docs/decisions/010-cooperative-first-concurrency.md +155 -235
- data/docs/decisions/014-unified-persistence-durable-state.md +268 -0
- data/docs/features.md +93 -0
- data/docs/getting-started.md +387 -0
- data/docs/migrations/0.15.md +35 -0
- data/docs/migrations/0.16.md +43 -0
- data/docs/migrations/0.19.md +148 -0
- data/docs/runtime-and-concurrency.md +349 -0
- data/examples/workflows/generic_task_event_mapping.rb +14 -6
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +2 -2
- data/lib/phronomy/agent/async_event_api.rb +3 -3
- data/lib/phronomy/agent/base.rb +116 -55
- data/lib/phronomy/agent/context/capability/base.rb +13 -3
- data/lib/phronomy/agent/context_assembler.rb +13 -3
- data/lib/phronomy/agent/execution_coordinator.rb +423 -252
- data/lib/phronomy/agent/journal_projection.rb +5 -1
- data/lib/phronomy/agent/shared_state.rb +2 -0
- data/lib/phronomy/agent/tool_executor.rb +29 -71
- data/lib/phronomy/agent/tool_invocation.rb +97 -47
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
- data/lib/phronomy/configuration.rb +7 -30
- data/lib/phronomy/diagnostics.rb +12 -41
- data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
- data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +48 -3
- data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
- data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
- data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
- data/lib/phronomy/engine/event_loop.rb +170 -193
- data/lib/phronomy/engine/fsm_session.rb +6 -4
- data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
- data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
- data/lib/phronomy/engine/runtime.rb +52 -158
- data/lib/phronomy/engine/task.rb +136 -277
- data/lib/phronomy/llm_adapter/base.rb +14 -14
- data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
- data/lib/phronomy/llm_adapter.rb +2 -2
- data/lib/phronomy/metrics.rb +15 -30
- data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
- data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
- data/lib/phronomy/multi_agent/handoff.rb +1 -0
- data/lib/phronomy/multi_agent/orchestrator.rb +147 -99
- data/lib/phronomy/multi_agent/team_coordinator.rb +2 -0
- data/lib/phronomy/persistence/in_memory.rb +113 -8
- data/lib/phronomy/persistence.rb +12 -3
- data/lib/phronomy/testing/eval/comparison.rb +23 -0
- data/lib/phronomy/testing/eval/dataset.rb +27 -0
- data/lib/phronomy/testing/eval/eval_case.rb +13 -0
- data/lib/phronomy/testing/eval/eval_result.rb +16 -0
- data/lib/phronomy/testing/eval/metrics.rb +43 -0
- data/lib/phronomy/testing/eval/runner.rb +52 -0
- data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
- data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
- data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
- data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
- data/lib/phronomy/testing/eval/scorer.rb +10 -0
- data/lib/phronomy/testing/eval.rb +9 -0
- data/lib/phronomy/testing/fake_clock.rb +6 -53
- data/lib/phronomy/testing.rb +2 -6
- data/lib/phronomy/tools/agent.rb +141 -6
- data/lib/phronomy/vector_store/async_backend.rb +21 -17
- data/lib/phronomy/vector_store/base.rb +2 -2
- data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +10 -9
- data/lib/phronomy/workflow_runner.rb +363 -99
- data/lib/phronomy.rb +10 -4
- data/scripts/api_snapshot.rb +4 -4
- metadata +25 -33
- data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
- data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
- data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
- data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
- data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
- data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
- data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
- data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
- data/lib/phronomy/engine/task/backend.rb +0 -80
- data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
- data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
- data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
- data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
- data/lib/phronomy/engine/task/thread_backend.rb +0 -84
- data/lib/phronomy/engine/task_group.rb +0 -193
- data/lib/phronomy/eval/comparison.rb +0 -47
- data/lib/phronomy/eval/dataset.rb +0 -45
- data/lib/phronomy/eval/eval_case.rb +0 -17
- data/lib/phronomy/eval/eval_result.rb +0 -29
- data/lib/phronomy/eval/metrics.rb +0 -66
- data/lib/phronomy/eval/runner.rb +0 -94
- data/lib/phronomy/eval/scorer/base.rb +0 -22
- data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
- data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
- data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
- data/lib/phronomy/eval/scorer.rb +0 -9
- data/lib/phronomy/eval.rb +0 -7
- data/lib/phronomy/state_store/base.rb +0 -48
- data/lib/phronomy/state_store/in_memory.rb +0 -62
- data/lib/phronomy/testing/fake_scheduler.rb +0 -104
- data/lib/phronomy/testing/scheduler_helpers.rb +0 -68
|
@@ -25,9 +25,11 @@ module Phronomy
|
|
|
25
25
|
event_loop:,
|
|
26
26
|
resume_event: nil,
|
|
27
27
|
resume_phase: nil,
|
|
28
|
-
stable_observer: nil
|
|
28
|
+
stable_observer: nil,
|
|
29
|
+
graph_thread_id: nil
|
|
29
30
|
)
|
|
30
31
|
@id = id
|
|
32
|
+
@graph_thread_id = graph_thread_id || id
|
|
31
33
|
@ctx = context
|
|
32
34
|
@context = context
|
|
33
35
|
@entry_point = entry_point
|
|
@@ -217,7 +219,7 @@ module Phronomy
|
|
|
217
219
|
return if @done
|
|
218
220
|
|
|
219
221
|
@done = true
|
|
220
|
-
@ctx.set_graph_metadata(thread_id: @
|
|
222
|
+
@ctx.set_graph_metadata(thread_id: @graph_thread_id, phase: :__end__)
|
|
221
223
|
post_terminal_event(:finished, @ctx)
|
|
222
224
|
end
|
|
223
225
|
|
|
@@ -225,7 +227,7 @@ module Phronomy
|
|
|
225
227
|
return if @done
|
|
226
228
|
|
|
227
229
|
@done = true
|
|
228
|
-
@ctx.set_graph_metadata(thread_id: @
|
|
230
|
+
@ctx.set_graph_metadata(thread_id: @graph_thread_id, phase: @current_state)
|
|
229
231
|
post_terminal_event(:halted, @ctx)
|
|
230
232
|
end
|
|
231
233
|
|
|
@@ -248,7 +250,7 @@ module Phronomy
|
|
|
248
250
|
|
|
249
251
|
Phronomy.configuration.logger&.warn(
|
|
250
252
|
"[Phronomy::FSMSession] EventLoop rejected terminal event " \
|
|
251
|
-
|
|
253
|
+
"#{type.inspect} for #{@id}"
|
|
252
254
|
)
|
|
253
255
|
end
|
|
254
256
|
|
|
@@ -2,104 +2,81 @@
|
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
4
|
class Runtime
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
# Replaces the pattern of spawning one +Thread.new { sleep(t); callback }+
|
|
8
|
-
# per deadline. Any number of timers share a single background thread that
|
|
9
|
-
# sleeps until the earliest pending deadline.
|
|
10
|
-
#
|
|
11
|
-
# Use {#schedule} to register a one-shot callback; call {#shutdown} when the
|
|
12
|
-
# queue is no longer needed (e.g. on process exit) to stop the background
|
|
13
|
-
# thread cleanly.
|
|
5
|
+
# Threadless monotonic timer heap driven by EventLoop.
|
|
14
6
|
class TimerQueue
|
|
15
|
-
# @param clock [#call] zero-argument callable that returns the current
|
|
16
|
-
# monotonic time in seconds (defaults to +Process::CLOCK_MONOTONIC+).
|
|
17
|
-
# Override in tests to inject a fake clock.
|
|
18
|
-
# @api private
|
|
19
7
|
def initialize(clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
|
|
20
8
|
@clock = clock
|
|
21
|
-
@heap = []
|
|
9
|
+
@heap = []
|
|
22
10
|
@mutex = Mutex.new
|
|
23
|
-
@cond = ConditionVariable.new
|
|
24
11
|
@stopped = false
|
|
25
|
-
@
|
|
26
|
-
|
|
12
|
+
@wake = nil
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Installs a lightweight wake callback used when a newly scheduled timer
|
|
16
|
+
# may change EventLoop's current wait deadline.
|
|
17
|
+
def wake_with(&block)
|
|
18
|
+
@mutex.synchronize { @wake = block }
|
|
19
|
+
self
|
|
27
20
|
end
|
|
28
21
|
|
|
29
|
-
# Schedule a one-shot callback to fire after +seconds+ from now.
|
|
30
|
-
#
|
|
31
|
-
# @param seconds [Numeric] delay before the callback fires
|
|
32
|
-
# @yield called (in the timer thread) when the deadline is reached
|
|
33
|
-
# @return [self]
|
|
34
|
-
# @api private
|
|
35
22
|
def schedule(seconds:, &callback)
|
|
23
|
+
raise ArgumentError, "schedule requires a block" unless callback
|
|
24
|
+
|
|
36
25
|
fire_at = @clock.call + seconds.to_f
|
|
26
|
+
wake = nil
|
|
37
27
|
@mutex.synchronize do
|
|
38
28
|
raise Phronomy::PoolShutdownError, "TimerQueue has been shut down" if @stopped
|
|
39
|
-
|
|
40
|
-
@
|
|
29
|
+
|
|
30
|
+
previous_first = @heap.first&.first
|
|
31
|
+
@heap << [fire_at, callback]
|
|
32
|
+
@heap.sort_by!(&:first)
|
|
33
|
+
wake = @wake if previous_first.nil? || fire_at < previous_first
|
|
41
34
|
end
|
|
35
|
+
wake&.call
|
|
42
36
|
self
|
|
43
37
|
end
|
|
44
38
|
|
|
45
|
-
#
|
|
46
|
-
|
|
47
|
-
# @return [self]
|
|
48
|
-
# @api private
|
|
49
|
-
def shutdown
|
|
39
|
+
# Seconds until the next timer is due, nil when no timers are pending.
|
|
40
|
+
def seconds_until_next
|
|
50
41
|
@mutex.synchronize do
|
|
51
|
-
@stopped
|
|
52
|
-
@
|
|
42
|
+
return nil if @stopped || @heap.empty?
|
|
43
|
+
[@heap.first.first - @clock.call, 0.0].max
|
|
53
44
|
end
|
|
54
|
-
@thread.join
|
|
55
|
-
self
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
# Number of pending (not yet fired) callbacks. Primarily for testing.
|
|
59
|
-
# @return [Integer]
|
|
60
|
-
# @api private
|
|
61
|
-
def pending_count
|
|
62
|
-
@mutex.synchronize { @heap.size }
|
|
63
45
|
end
|
|
64
46
|
|
|
65
|
-
|
|
47
|
+
# Executes all callbacks whose deadline is due. Must be called by EventLoop.
|
|
48
|
+
def fire_due
|
|
49
|
+
callbacks = @mutex.synchronize do
|
|
50
|
+
return 0 if @stopped
|
|
66
51
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
52
|
+
now = @clock.call
|
|
53
|
+
due_count = @heap.bsearch_index { |(fire_at, _)| fire_at > now } || @heap.length
|
|
54
|
+
@heap.shift(due_count).map(&:last)
|
|
55
|
+
end
|
|
71
56
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
callback&.call
|
|
78
|
-
rescue => e
|
|
79
|
-
Phronomy.configuration.logger&.error { "[TimerQueue] callback raised #{e.class}: #{e.message}" }
|
|
57
|
+
callbacks.each do |callback|
|
|
58
|
+
callback.call
|
|
59
|
+
rescue => error
|
|
60
|
+
Phronomy.configuration.logger&.error do
|
|
61
|
+
"[TimerQueue] callback raised #{error.class}: #{error.message}"
|
|
80
62
|
end
|
|
81
63
|
end
|
|
64
|
+
callbacks.length
|
|
82
65
|
end
|
|
83
66
|
|
|
84
|
-
def
|
|
85
|
-
@mutex.synchronize
|
|
86
|
-
|
|
87
|
-
return :stopped if @stopped
|
|
67
|
+
def pending_count
|
|
68
|
+
@mutex.synchronize { @heap.size }
|
|
69
|
+
end
|
|
88
70
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
return @heap.shift[1]
|
|
96
|
-
else
|
|
97
|
-
remaining = fire_at - now
|
|
98
|
-
@cond.wait(@mutex, remaining)
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
end
|
|
71
|
+
def shutdown
|
|
72
|
+
wake = @mutex.synchronize do
|
|
73
|
+
return self if @stopped
|
|
74
|
+
@stopped = true
|
|
75
|
+
@heap.clear
|
|
76
|
+
@wake
|
|
102
77
|
end
|
|
78
|
+
wake&.call
|
|
79
|
+
self
|
|
103
80
|
end
|
|
104
81
|
end
|
|
105
82
|
end
|
|
@@ -2,38 +2,30 @@
|
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
4
|
class Runtime
|
|
5
|
-
# Lazy
|
|
6
|
-
#
|
|
7
|
-
# Returns a {SchedulerTimerAdapter} when the backing scheduler is a
|
|
8
|
-
# {DeterministicScheduler} (enabling virtual-time integration for the
|
|
9
|
-
# `:fiber` backend), or a standard {TimerQueue} (OS-thread backed) for all
|
|
10
|
-
# other schedulers.
|
|
11
|
-
# @api private
|
|
5
|
+
# Lazy owner of the Runtime's threadless timer queue.
|
|
12
6
|
class TimerService
|
|
13
|
-
|
|
14
|
-
# @api private
|
|
15
|
-
def initialize(scheduler)
|
|
16
|
-
@scheduler = scheduler
|
|
7
|
+
def initialize
|
|
17
8
|
@mutex = Mutex.new
|
|
18
9
|
@timer = nil
|
|
10
|
+
@waker = nil
|
|
19
11
|
end
|
|
20
12
|
|
|
21
|
-
# Returns (or lazily creates) the timer queue for this runtime.
|
|
22
|
-
# @return [TimerQueue, SchedulerTimerAdapter]
|
|
23
|
-
# @api private
|
|
24
13
|
def timer_queue
|
|
25
14
|
@mutex.synchronize do
|
|
26
|
-
@timer ||=
|
|
27
|
-
|
|
28
|
-
else
|
|
29
|
-
TimerQueue.new
|
|
15
|
+
@timer ||= TimerQueue.new.tap do |timer|
|
|
16
|
+
timer.wake_with(&@waker) if @waker
|
|
30
17
|
end
|
|
31
18
|
end
|
|
32
19
|
end
|
|
33
20
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
21
|
+
def wake_with(&block)
|
|
22
|
+
@mutex.synchronize do
|
|
23
|
+
@waker = block
|
|
24
|
+
@timer&.wake_with(&block)
|
|
25
|
+
end
|
|
26
|
+
self
|
|
27
|
+
end
|
|
28
|
+
|
|
37
29
|
def shutdown
|
|
38
30
|
@mutex.synchronize { @timer&.shutdown }
|
|
39
31
|
end
|
|
@@ -1,25 +1,22 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require_relative "runtime/scheduler"
|
|
4
|
-
require_relative "runtime/thread_scheduler"
|
|
5
|
-
require_relative "runtime/fake_scheduler"
|
|
6
|
-
require_relative "runtime/deterministic_scheduler"
|
|
7
3
|
require_relative "runtime/timer_queue"
|
|
8
|
-
require_relative "runtime/scheduler_timer_adapter"
|
|
9
|
-
require_relative "runtime/task_registry"
|
|
10
|
-
require_relative "runtime/runtime_metrics"
|
|
11
4
|
require_relative "runtime/shutdown_result"
|
|
12
5
|
require_relative "runtime/timer_service"
|
|
13
6
|
|
|
14
7
|
module Phronomy
|
|
15
|
-
#
|
|
8
|
+
# Owns the EventLoop, offloaded synchronous work, timers and shutdown lifecycle.
|
|
9
|
+
#
|
|
10
|
+
# Runtime no longer schedules arbitrary Tasks. Framework control flow belongs
|
|
11
|
+
# to EventLoop/FSMSession; synchronous work that must not run on the EventLoop
|
|
12
|
+
# belongs to OffloadPool.
|
|
16
13
|
class Runtime
|
|
17
14
|
@instance_mutex = Mutex.new
|
|
18
15
|
|
|
19
16
|
class << self
|
|
20
17
|
def instance
|
|
21
18
|
instance_mutex.synchronize do
|
|
22
|
-
@instance ||=
|
|
19
|
+
@instance ||= new
|
|
23
20
|
end
|
|
24
21
|
end
|
|
25
22
|
|
|
@@ -65,55 +62,14 @@ module Phronomy
|
|
|
65
62
|
def instance_mutex
|
|
66
63
|
@instance_mutex ||= Mutex.new
|
|
67
64
|
end
|
|
68
|
-
|
|
69
|
-
def build_default_runtime
|
|
70
|
-
scheduler = case Phronomy.configuration.runtime_backend
|
|
71
|
-
when :thread
|
|
72
|
-
ThreadScheduler.new
|
|
73
|
-
when :immediate
|
|
74
|
-
FakeScheduler.new
|
|
75
|
-
when :fiber
|
|
76
|
-
Phronomy.configuration.logger&.warn(
|
|
77
|
-
"[phronomy] runtime_backend: :fiber uses DeterministicScheduler in autorun mode. " \
|
|
78
|
-
"This is an EXPERIMENTAL Fiber-based cooperative scheduler. " \
|
|
79
|
-
"Wall-clock timer integration is available via SchedulerTimerAdapter (Issues #331, #337). " \
|
|
80
|
-
"Not recommended for production use."
|
|
81
|
-
)
|
|
82
|
-
DeterministicScheduler.new(autorun: true)
|
|
83
|
-
else
|
|
84
|
-
raise Phronomy::ConfigurationError,
|
|
85
|
-
"unknown runtime_backend: #{Phronomy.configuration.runtime_backend.inspect}"
|
|
86
|
-
end
|
|
87
|
-
new(scheduler: scheduler)
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def self.in_scheduler_context?
|
|
92
|
-
!Task.current.nil?
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def self.measure_ms
|
|
96
|
-
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
97
|
-
result = yield
|
|
98
|
-
elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - t0) * 1000).round
|
|
99
|
-
[result, elapsed_ms]
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
attr_reader :scheduler
|
|
103
|
-
|
|
104
|
-
def state
|
|
105
|
-
@lifecycle_mutex.synchronize { @state }
|
|
106
65
|
end
|
|
107
66
|
|
|
108
|
-
def initialize
|
|
109
|
-
@
|
|
110
|
-
@event_loop_scheduler = ThreadScheduler.new
|
|
111
|
-
@task_registry = TaskRegistry.new
|
|
112
|
-
@metrics = RuntimeMetrics.new
|
|
113
|
-
@timer_service = TimerService.new(scheduler)
|
|
67
|
+
def initialize
|
|
68
|
+
@timer_service = TimerService.new
|
|
114
69
|
@pool_registry = Phronomy::Concurrency::PoolRegistry.new(
|
|
115
|
-
timer_queue_provider: -> {
|
|
70
|
+
timer_queue_provider: -> { timer_queue }
|
|
116
71
|
)
|
|
72
|
+
@agent_activations = Phronomy::Agent::ActivationRegistry.new
|
|
117
73
|
@lifecycle_mutex = Mutex.new
|
|
118
74
|
@shutdown_mutex = Mutex.new
|
|
119
75
|
@state = :running
|
|
@@ -122,73 +78,13 @@ module Phronomy
|
|
|
122
78
|
@shutdown_result = nil
|
|
123
79
|
end
|
|
124
80
|
|
|
125
|
-
def
|
|
126
|
-
|
|
127
|
-
slice_start = Task.current_cpu_slice_start_ms
|
|
128
|
-
if slice_start
|
|
129
|
-
elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - slice_start
|
|
130
|
-
if elapsed > threshold
|
|
131
|
-
name = Task.current&.name || "unknown"
|
|
132
|
-
Phronomy.configuration.logger&.warn(
|
|
133
|
-
"[Phronomy] CPU-bound task detected: '#{name}' ran #{elapsed.round}ms " \
|
|
134
|
-
"without yielding (threshold: #{threshold}ms)"
|
|
135
|
-
)
|
|
136
|
-
@metrics.increment_starvation
|
|
137
|
-
end
|
|
138
|
-
end
|
|
139
|
-
end
|
|
140
|
-
Task.record_yield!
|
|
141
|
-
@scheduler.yield
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
def non_yield_threshold_violation_count
|
|
145
|
-
@metrics.starvation_count
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
def yield_if_needed(every: 1000)
|
|
149
|
-
self.yield if (Task.increment_yield_counter! % every).zero?
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
def task_group(limit: Float::INFINITY, failure_policy: :fail_fast)
|
|
153
|
-
ensure_accepting_work!
|
|
154
|
-
TaskGroup.new(limit: limit, failure_policy: failure_policy, runtime: self)
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
def spawn(name: nil, &block)
|
|
158
|
-
ensure_accepting_work!
|
|
159
|
-
type = _task_type(name)
|
|
160
|
-
spawn_at = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
161
|
-
@metrics.record_start(type)
|
|
162
|
-
|
|
163
|
-
task = @scheduler.spawn(name: name, parent: Task.current) do
|
|
164
|
-
run_start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
165
|
-
@metrics.record_wait(run_start - spawn_at)
|
|
166
|
-
begin
|
|
167
|
-
result = block.call
|
|
168
|
-
@metrics.record_end(type, :completed, run_start)
|
|
169
|
-
result
|
|
170
|
-
rescue CancellationError
|
|
171
|
-
@metrics.record_end(type, :cancelled, run_start)
|
|
172
|
-
raise
|
|
173
|
-
rescue => error
|
|
174
|
-
@metrics.record_end(type, :failed, run_start)
|
|
175
|
-
raise error
|
|
176
|
-
ensure
|
|
177
|
-
current = Task.current
|
|
178
|
-
@task_registry.deregister(current) if current
|
|
179
|
-
end
|
|
180
|
-
end
|
|
181
|
-
@task_registry.register(task)
|
|
182
|
-
task
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
def task_snapshot
|
|
186
|
-
@metrics.snapshot
|
|
81
|
+
def state
|
|
82
|
+
@lifecycle_mutex.synchronize { @state }
|
|
187
83
|
end
|
|
188
84
|
|
|
189
|
-
def
|
|
190
|
-
pool_size: Phronomy.configuration.
|
|
191
|
-
queue_size: Phronomy.configuration.
|
|
85
|
+
def offload(
|
|
86
|
+
pool_size: Phronomy.configuration.offload_pool_size,
|
|
87
|
+
queue_size: Phronomy.configuration.offload_queue_size
|
|
192
88
|
)
|
|
193
89
|
ensure_accepting_work!
|
|
194
90
|
@pool_registry.default_pool(pool_size: pool_size, queue_size: queue_size)
|
|
@@ -199,16 +95,34 @@ module Phronomy
|
|
|
199
95
|
@pool_registry.named_pool(name, size: size, queue_size: queue_size)
|
|
200
96
|
end
|
|
201
97
|
|
|
98
|
+
# Public timer access also ensures that the EventLoop that drives timers is alive.
|
|
202
99
|
def timer_queue
|
|
203
100
|
ensure_accepting_work!
|
|
101
|
+
timer = @timer_service.timer_queue
|
|
102
|
+
event_loop
|
|
103
|
+
timer
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Internal EventLoop access that does not recursively initialise EventLoop.
|
|
107
|
+
def __timer_queue
|
|
204
108
|
@timer_service.timer_queue
|
|
205
109
|
end
|
|
206
110
|
|
|
111
|
+
# Process-local live Agent executions. Activations are transient runtime
|
|
112
|
+
# state and deliberately do not belong to Persistence.
|
|
113
|
+
def __agent_activations
|
|
114
|
+
@agent_activations
|
|
115
|
+
end
|
|
116
|
+
|
|
207
117
|
def event_loop
|
|
208
118
|
@lifecycle_mutex.synchronize do
|
|
209
119
|
case @state
|
|
210
120
|
when :running
|
|
211
|
-
@event_loop
|
|
121
|
+
unless @event_loop
|
|
122
|
+
@event_loop = EventLoop.new(runtime: self)
|
|
123
|
+
@timer_service.wake_with { @event_loop&.wake }
|
|
124
|
+
end
|
|
125
|
+
@event_loop
|
|
212
126
|
when :draining
|
|
213
127
|
return @event_loop if @event_loop
|
|
214
128
|
|
|
@@ -222,12 +136,8 @@ module Phronomy
|
|
|
222
136
|
end
|
|
223
137
|
|
|
224
138
|
def event_loop_current?
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
end
|
|
228
|
-
|
|
229
|
-
def __spawn_event_loop_service(&block)
|
|
230
|
-
@event_loop_scheduler.spawn(name: "event-loop", parent: nil, &block)
|
|
139
|
+
loop_instance = @lifecycle_mutex.synchronize { @event_loop }
|
|
140
|
+
loop_instance&.current? || false
|
|
231
141
|
end
|
|
232
142
|
|
|
233
143
|
def __event_loop_failed(error)
|
|
@@ -243,7 +153,7 @@ module Phronomy
|
|
|
243
153
|
timeout: Phronomy.configuration.event_loop_stop_grace_seconds,
|
|
244
154
|
cancel_grace: timeout
|
|
245
155
|
)
|
|
246
|
-
if
|
|
156
|
+
if event_loop_current?
|
|
247
157
|
raise Phronomy::RuntimeShutdownReentrancyError,
|
|
248
158
|
"Runtime#shutdown must be called from an external management thread"
|
|
249
159
|
end
|
|
@@ -253,29 +163,33 @@ module Phronomy
|
|
|
253
163
|
@shutdown_mutex.synchronize do
|
|
254
164
|
return @shutdown_result if @shutdown_result
|
|
255
165
|
|
|
256
|
-
|
|
257
|
-
|
|
166
|
+
# Phase 1 — drain sessions with the full configured grace.
|
|
167
|
+
drain_deadline = monotonic_now + timeout
|
|
168
|
+
loop_instance = @lifecycle_mutex.synchronize do
|
|
258
169
|
@state = :draining unless @state == :failed
|
|
259
170
|
@event_loop
|
|
260
171
|
end
|
|
261
|
-
|
|
172
|
+
loop_instance&.begin_draining
|
|
262
173
|
|
|
263
|
-
|
|
174
|
+
loop_idle = !loop_instance || loop_instance.wait_until_idle(drain_deadline)
|
|
264
175
|
|
|
265
176
|
@lifecycle_mutex.synchronize do
|
|
266
177
|
@state = :stopping unless @state == :failed
|
|
267
178
|
end
|
|
268
179
|
|
|
269
|
-
|
|
270
|
-
|
|
180
|
+
# Phase 2 — stop the EventLoop thread with a short independent budget.
|
|
181
|
+
# An idle EventLoop processes STOP and exits in < 1ms normally; the 0.2s
|
|
182
|
+
# budget here is only a safety net for OS scheduling jitter.
|
|
183
|
+
stop_deadline = monotonic_now + [cancel_grace.to_f, 0.2].max
|
|
184
|
+
event_loop_status = if loop_instance
|
|
185
|
+
loop_instance.stop_and_join(deadline: stop_deadline)
|
|
271
186
|
else
|
|
272
187
|
:not_started
|
|
273
188
|
end
|
|
274
189
|
|
|
275
190
|
subsystem_error = shutdown_pools_and_timer
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
(!event_loop || !event_loop.task_alive?) &&
|
|
191
|
+
cleanup_complete = loop_idle &&
|
|
192
|
+
(!loop_instance || !loop_instance.thread_alive?) &&
|
|
279
193
|
event_loop_status != :cancel_timeout &&
|
|
280
194
|
subsystem_error.nil?
|
|
281
195
|
|
|
@@ -285,11 +199,12 @@ module Phronomy
|
|
|
285
199
|
else
|
|
286
200
|
:terminated
|
|
287
201
|
end
|
|
202
|
+
|
|
288
203
|
result = ShutdownResult.new(
|
|
289
204
|
runtime_outcome: runtime_outcome,
|
|
290
205
|
cleanup_status: cleanup_complete ? :complete : :incomplete,
|
|
291
206
|
event_loop_status: event_loop_status,
|
|
292
|
-
task_registry_status:
|
|
207
|
+
task_registry_status: :empty,
|
|
293
208
|
error: failure
|
|
294
209
|
)
|
|
295
210
|
|
|
@@ -311,17 +226,6 @@ module Phronomy
|
|
|
311
226
|
"Runtime is #{current_state}; new work is not accepted"
|
|
312
227
|
end
|
|
313
228
|
|
|
314
|
-
def drain_runtime_work(event_loop, deadline)
|
|
315
|
-
loop do
|
|
316
|
-
task_status = @task_registry.drain_until(deadline)
|
|
317
|
-
return :timeout if task_status == :timeout
|
|
318
|
-
|
|
319
|
-
event_loop_idle = !event_loop || event_loop.wait_until_idle(deadline)
|
|
320
|
-
return :timeout unless event_loop_idle
|
|
321
|
-
return :empty if @task_registry.empty?
|
|
322
|
-
end
|
|
323
|
-
end
|
|
324
|
-
|
|
325
229
|
def shutdown_pools_and_timer
|
|
326
230
|
error = nil
|
|
327
231
|
begin
|
|
@@ -347,15 +251,5 @@ module Phronomy
|
|
|
347
251
|
def monotonic_now
|
|
348
252
|
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
349
253
|
end
|
|
350
|
-
|
|
351
|
-
TASK_TYPE_PREFIXES = %w[agent tool workflow rag llm vector].freeze
|
|
352
|
-
private_constant :TASK_TYPE_PREFIXES
|
|
353
|
-
|
|
354
|
-
def _task_type(name)
|
|
355
|
-
return :other if name.nil?
|
|
356
|
-
|
|
357
|
-
prefix = TASK_TYPE_PREFIXES.find { |candidate| name.to_s.start_with?("#{candidate}-") }
|
|
358
|
-
prefix ? prefix.to_sym : :other
|
|
359
|
-
end
|
|
360
254
|
end
|
|
361
255
|
end
|