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
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
4
|
module Concurrency
|
|
5
|
-
# Registry and lifecycle manager for {
|
|
5
|
+
# Registry and lifecycle manager for {OffloadPool} instances.
|
|
6
6
|
#
|
|
7
7
|
# Maintains one unnamed "default" pool (accessed via {#default_pool}) and
|
|
8
8
|
# an arbitrary number of named pools (accessed via {#named_pool}).
|
|
@@ -21,10 +21,10 @@ module Phronomy
|
|
|
21
21
|
# Returns (or lazily creates) the unnamed default pool.
|
|
22
22
|
# @param pool_size [Integer]
|
|
23
23
|
# @param queue_size [Integer]
|
|
24
|
-
# @return [
|
|
24
|
+
# @return [OffloadPool]
|
|
25
25
|
# @api private
|
|
26
26
|
def default_pool(pool_size: 10, queue_size: 100)
|
|
27
|
-
@default ||=
|
|
27
|
+
@default ||= OffloadPool.new(
|
|
28
28
|
name: :default,
|
|
29
29
|
pool_size: pool_size,
|
|
30
30
|
queue_size: queue_size,
|
|
@@ -36,11 +36,11 @@ module Phronomy
|
|
|
36
36
|
# @param name [Symbol, String]
|
|
37
37
|
# @param size [Integer]
|
|
38
38
|
# @param queue_size [Integer]
|
|
39
|
-
# @return [
|
|
39
|
+
# @return [OffloadPool]
|
|
40
40
|
# @api private
|
|
41
41
|
def named_pool(name, size: 10, queue_size: 100)
|
|
42
42
|
@mutex.synchronize do
|
|
43
|
-
@pools[name.to_sym] ||=
|
|
43
|
+
@pools[name.to_sym] ||= OffloadPool.new(
|
|
44
44
|
name: name,
|
|
45
45
|
pool_size: size,
|
|
46
46
|
queue_size: queue_size,
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
4
|
# Runtime-owned FIFO event loop for FSMSession instances.
|
|
5
|
+
#
|
|
6
|
+
# EventLoop owns the framework's sole control-plane OS thread. All session
|
|
7
|
+
# lifecycle progression happens by short event dispatches on this thread.
|
|
5
8
|
class EventLoop
|
|
6
9
|
SYSTEM_CHANNEL_ID = "__event_loop__"
|
|
7
10
|
|
|
@@ -12,7 +15,8 @@ module Phronomy
|
|
|
12
15
|
private_constant :TERMINAL_MANAGEMENT_EVENTS
|
|
13
16
|
|
|
14
17
|
STOP = Object.new.freeze
|
|
15
|
-
|
|
18
|
+
WAKE = Object.new.freeze
|
|
19
|
+
private_constant :STOP, :WAKE
|
|
16
20
|
|
|
17
21
|
def initialize(runtime:)
|
|
18
22
|
@runtime = runtime
|
|
@@ -22,21 +26,16 @@ module Phronomy
|
|
|
22
26
|
@max_queue_depth = 0
|
|
23
27
|
@last_queue_backlog_warning_at = nil
|
|
24
28
|
|
|
25
|
-
# @fsms and @waiting are dispatcher-thread-owned.
|
|
26
29
|
@fsms = {}
|
|
27
30
|
@waiting = {}
|
|
28
|
-
|
|
29
|
-
# Admission is shared by caller threads and the dispatcher. A session ID
|
|
30
|
-
# enters this set before its :start event is queued and leaves when its
|
|
31
|
-
# terminal management event is queued.
|
|
32
31
|
@admitted_session_ids = Set.new
|
|
32
|
+
@workflow_admissions = {}
|
|
33
33
|
|
|
34
34
|
@lifecycle_mutex = Mutex.new
|
|
35
35
|
@idle_cond = ConditionVariable.new
|
|
36
36
|
@shutdown_mutex = Mutex.new
|
|
37
37
|
@state = :running
|
|
38
38
|
@outstanding_sessions = 0
|
|
39
|
-
@cancel_requested = false
|
|
40
39
|
@shutdown_status = nil
|
|
41
40
|
|
|
42
41
|
@lag_mutex = Mutex.new
|
|
@@ -45,7 +44,8 @@ module Phronomy
|
|
|
45
44
|
@dispatch_count = 0
|
|
46
45
|
@total_lag_ns = 0
|
|
47
46
|
|
|
48
|
-
@
|
|
47
|
+
@thread = Thread.new { run_loop }
|
|
48
|
+
@thread.name = "phronomy-event-loop"
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def last_lag_seconds
|
|
@@ -59,7 +59,6 @@ module Phronomy
|
|
|
59
59
|
def average_lag_seconds
|
|
60
60
|
@lag_mutex.synchronize do
|
|
61
61
|
return 0.0 if @dispatch_count.zero?
|
|
62
|
-
|
|
63
62
|
@total_lag_ns.to_f / @dispatch_count / 1_000_000_000.0
|
|
64
63
|
end
|
|
65
64
|
end
|
|
@@ -79,21 +78,11 @@ module Phronomy
|
|
|
79
78
|
"Schedule work asynchronously instead."
|
|
80
79
|
end
|
|
81
80
|
|
|
82
|
-
|
|
83
|
-
completion || Phronomy::Concurrency::AsyncQueue.new
|
|
84
|
-
scheduler = Phronomy::Runtime::Scheduler.current
|
|
85
|
-
if scheduler &&
|
|
86
|
-
completion_queue.respond_to?(:expect_cross_thread_push)
|
|
87
|
-
completion_queue.expect_cross_thread_push(scheduler)
|
|
88
|
-
end
|
|
89
|
-
|
|
81
|
+
completion_handle = completion || Phronomy::Concurrency::AsyncQueue.new
|
|
90
82
|
event = Phronomy::Event.new(
|
|
91
83
|
type: :start,
|
|
92
84
|
target_id: SYSTEM_CHANNEL_ID,
|
|
93
|
-
payload: {
|
|
94
|
-
session: fsm_session,
|
|
95
|
-
completion: completion_queue
|
|
96
|
-
}
|
|
85
|
+
payload: {session: fsm_session, completion: completion_handle}
|
|
97
86
|
)
|
|
98
87
|
queued_depth = nil
|
|
99
88
|
|
|
@@ -107,23 +96,19 @@ module Phronomy
|
|
|
107
96
|
@admitted_session_ids.add(fsm_session.id)
|
|
108
97
|
@outstanding_sessions += 1
|
|
109
98
|
begin
|
|
110
|
-
queued_depth = enqueue(
|
|
111
|
-
[event, monotonic_nanoseconds]
|
|
112
|
-
)
|
|
99
|
+
queued_depth = enqueue([event, monotonic_nanoseconds])
|
|
113
100
|
rescue
|
|
114
101
|
@admitted_session_ids.delete(fsm_session.id)
|
|
115
102
|
@outstanding_sessions -= 1
|
|
116
|
-
@idle_cond.broadcast if
|
|
103
|
+
@idle_cond.broadcast if runtime_idle_locked?
|
|
117
104
|
raise
|
|
118
105
|
end
|
|
119
106
|
end
|
|
120
107
|
|
|
121
108
|
check_queue_backlog(queued_depth, event)
|
|
122
|
-
|
|
109
|
+
completion_handle
|
|
123
110
|
end
|
|
124
111
|
|
|
125
|
-
# Internal post operation. Management terminal events close admission for
|
|
126
|
-
# their session before the terminal event is enqueued.
|
|
127
112
|
def post(event)
|
|
128
113
|
queued_depth = nil
|
|
129
114
|
accepted = @lifecycle_mutex.synchronize do
|
|
@@ -136,9 +121,7 @@ module Phronomy
|
|
|
136
121
|
end
|
|
137
122
|
|
|
138
123
|
begin
|
|
139
|
-
queued_depth = enqueue(
|
|
140
|
-
[event, monotonic_nanoseconds]
|
|
141
|
-
)
|
|
124
|
+
queued_depth = enqueue([event, monotonic_nanoseconds])
|
|
142
125
|
rescue
|
|
143
126
|
@admitted_session_ids.add(terminal_session_id) if terminal_session_id
|
|
144
127
|
raise
|
|
@@ -151,15 +134,9 @@ module Phronomy
|
|
|
151
134
|
true
|
|
152
135
|
end
|
|
153
136
|
|
|
154
|
-
# Posts an event only when the target session has been admitted and has not
|
|
155
|
-
# queued a terminal management event. The event remains FIFO with all other
|
|
156
|
-
# EventLoop work.
|
|
157
|
-
#
|
|
158
|
-
# A true result reports admission, not transition success.
|
|
159
137
|
def post_to_session(event)
|
|
160
138
|
if event.target_id == SYSTEM_CHANNEL_ID
|
|
161
|
-
raise ArgumentError,
|
|
162
|
-
"post_to_session cannot target the system channel"
|
|
139
|
+
raise ArgumentError, "post_to_session cannot target the system channel"
|
|
163
140
|
end
|
|
164
141
|
|
|
165
142
|
queued_depth = nil
|
|
@@ -167,9 +144,7 @@ module Phronomy
|
|
|
167
144
|
next false unless accepting_events?
|
|
168
145
|
next false unless @admitted_session_ids.include?(event.target_id)
|
|
169
146
|
|
|
170
|
-
queued_depth = enqueue(
|
|
171
|
-
[event, monotonic_nanoseconds]
|
|
172
|
-
)
|
|
147
|
+
queued_depth = enqueue([event, monotonic_nanoseconds])
|
|
173
148
|
true
|
|
174
149
|
end
|
|
175
150
|
return false unless accepted
|
|
@@ -178,14 +153,87 @@ module Phronomy
|
|
|
178
153
|
true
|
|
179
154
|
end
|
|
180
155
|
|
|
181
|
-
|
|
156
|
+
# Reserves one logical Workflow thread for one concrete FSMSession execution.
|
|
157
|
+
# thread_id is durable Workflow identity; owner_fsm_session_id is the
|
|
158
|
+
# Runtime-only identity of the invocation/resume that currently owns it.
|
|
159
|
+
def admit_workflow(thread_id, owner_fsm_session_id:)
|
|
160
|
+
key = thread_id.to_s
|
|
161
|
+
owner = owner_fsm_session_id.to_s
|
|
162
|
+
raise ArgumentError, "thread_id must not be empty" if key.empty?
|
|
163
|
+
raise ArgumentError, "owner_fsm_session_id must not be empty" if owner.empty?
|
|
164
|
+
|
|
182
165
|
@lifecycle_mutex.synchronize do
|
|
183
|
-
|
|
166
|
+
ensure_accepting_registrations!
|
|
167
|
+
current_owner = @workflow_admissions[key]
|
|
168
|
+
if current_owner
|
|
169
|
+
raise Phronomy::Error,
|
|
170
|
+
"Workflow thread #{key.inspect} is already owned by " \
|
|
171
|
+
"FSMSession #{current_owner.inspect}"
|
|
172
|
+
end
|
|
173
|
+
@workflow_admissions[key] = owner
|
|
184
174
|
end
|
|
175
|
+
true
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Releases a Workflow reservation only when the caller is its current owner.
|
|
179
|
+
# A failed competing admission can therefore never release another session's
|
|
180
|
+
# reservation during cleanup.
|
|
181
|
+
def release_workflow(thread_id, owner_fsm_session_id:)
|
|
182
|
+
key = thread_id.to_s
|
|
183
|
+
owner = owner_fsm_session_id.to_s
|
|
184
|
+
@lifecycle_mutex.synchronize do
|
|
185
|
+
next false unless @workflow_admissions[key] == owner
|
|
186
|
+
|
|
187
|
+
@workflow_admissions.delete(key)
|
|
188
|
+
@idle_cond.broadcast if runtime_idle_locked?
|
|
189
|
+
true
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def workflow_admission_owner(thread_id)
|
|
194
|
+
@lifecycle_mutex.synchronize { @workflow_admissions[thread_id.to_s] }
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Resolves durable Workflow identity to the currently owning FSMSession and
|
|
198
|
+
# enqueues the event atomically with that ownership check.
|
|
199
|
+
def post_to_workflow(thread_id:, event:, payload: nil)
|
|
200
|
+
queued_depth = nil
|
|
201
|
+
posted_event = nil
|
|
202
|
+
accepted = @lifecycle_mutex.synchronize do
|
|
203
|
+
next false unless accepting_events?
|
|
204
|
+
|
|
205
|
+
owner = @workflow_admissions[thread_id.to_s]
|
|
206
|
+
next false unless owner
|
|
207
|
+
next false unless @admitted_session_ids.include?(owner)
|
|
208
|
+
|
|
209
|
+
posted_event = Phronomy::Event.new(
|
|
210
|
+
type: event.to_sym,
|
|
211
|
+
target_id: owner,
|
|
212
|
+
payload: payload
|
|
213
|
+
)
|
|
214
|
+
queued_depth = enqueue([posted_event, monotonic_nanoseconds])
|
|
215
|
+
true
|
|
216
|
+
end
|
|
217
|
+
return false unless accepted
|
|
218
|
+
|
|
219
|
+
check_queue_backlog(queued_depth, posted_event)
|
|
220
|
+
true
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Interrupts the queue wait so EventLoop can recompute the next timer deadline.
|
|
224
|
+
def wake
|
|
225
|
+
@queue.push(WAKE)
|
|
226
|
+
true
|
|
227
|
+
rescue ClosedQueueError
|
|
228
|
+
false
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def admitted_session?(session_id)
|
|
232
|
+
@lifecycle_mutex.synchronize { @admitted_session_ids.include?(session_id) }
|
|
185
233
|
end
|
|
186
234
|
|
|
187
235
|
def current?
|
|
188
|
-
|
|
236
|
+
Thread.current.equal?(@thread)
|
|
189
237
|
end
|
|
190
238
|
|
|
191
239
|
def state
|
|
@@ -200,24 +248,23 @@ module Phronomy
|
|
|
200
248
|
end
|
|
201
249
|
|
|
202
250
|
def idle?
|
|
203
|
-
@lifecycle_mutex.synchronize
|
|
204
|
-
@outstanding_sessions.zero?
|
|
205
|
-
end
|
|
251
|
+
@lifecycle_mutex.synchronize { runtime_idle_locked? }
|
|
206
252
|
end
|
|
207
253
|
|
|
208
254
|
def wait_until_idle(deadline)
|
|
209
255
|
@lifecycle_mutex.synchronize do
|
|
210
|
-
until
|
|
256
|
+
until runtime_idle_locked?
|
|
211
257
|
remaining = deadline - monotonic_now
|
|
212
258
|
return false if remaining <= 0
|
|
213
|
-
|
|
214
259
|
@idle_cond.wait(@lifecycle_mutex, remaining)
|
|
215
260
|
end
|
|
216
261
|
true
|
|
217
262
|
end
|
|
218
263
|
end
|
|
219
264
|
|
|
220
|
-
|
|
265
|
+
# Sends STOP to the queue and joins the EventLoop thread.
|
|
266
|
+
# Assumes sessions have already been drained before this call.
|
|
267
|
+
def stop_and_join(deadline:)
|
|
221
268
|
@shutdown_mutex.synchronize do
|
|
222
269
|
return @shutdown_status if @shutdown_status
|
|
223
270
|
|
|
@@ -227,32 +274,40 @@ module Phronomy
|
|
|
227
274
|
return @shutdown_status
|
|
228
275
|
end
|
|
229
276
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
begin_stopping_if_idle
|
|
233
|
-
join_until(deadline)
|
|
234
|
-
end
|
|
277
|
+
begin_stopping_if_idle
|
|
278
|
+
join_until(deadline)
|
|
235
279
|
|
|
236
|
-
@shutdown_status =
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
280
|
+
@shutdown_status = if thread_alive?
|
|
281
|
+
@lifecycle_mutex.synchronize { @state = :failed }
|
|
282
|
+
:cancel_timeout
|
|
283
|
+
elsif state == :failed
|
|
284
|
+
:failed
|
|
285
|
+
else
|
|
286
|
+
finalize_terminated(:terminated)
|
|
287
|
+
end
|
|
244
288
|
end
|
|
245
289
|
end
|
|
246
290
|
|
|
247
|
-
|
|
248
|
-
|
|
291
|
+
# Legacy entry point kept for any callers that pass deadline:/cancel_grace:.
|
|
292
|
+
def shutdown(deadline:, cancel_grace: deadline)
|
|
293
|
+
stop_and_join(deadline: deadline)
|
|
249
294
|
end
|
|
250
295
|
|
|
296
|
+
def thread_alive?
|
|
297
|
+
@thread&.alive? || false
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# Compatibility for existing runtime/shutdown observers during migration.
|
|
301
|
+
alias_method :task_alive?, :thread_alive?
|
|
302
|
+
|
|
251
303
|
private
|
|
252
304
|
|
|
253
305
|
def run_loop
|
|
254
306
|
loop do
|
|
255
|
-
|
|
307
|
+
fire_due_timers
|
|
308
|
+
timeout = @runtime.__timer_queue.seconds_until_next
|
|
309
|
+
item = dequeue(timeout: timeout)
|
|
310
|
+
next if item.nil? || item.equal?(WAKE)
|
|
256
311
|
break if item.equal?(STOP)
|
|
257
312
|
|
|
258
313
|
event, posted_at_ns = item
|
|
@@ -265,24 +320,16 @@ module Phronomy
|
|
|
265
320
|
dispatch(event)
|
|
266
321
|
check_dispatch_time(dispatch_start_ns, event)
|
|
267
322
|
end
|
|
268
|
-
|
|
269
|
-
if shutdown_cancel_requested?
|
|
270
|
-
cleanup_abandoned_work(
|
|
271
|
-
Phronomy::CancellationError.new(
|
|
272
|
-
"Runtime shutdown timed out"
|
|
273
|
-
)
|
|
274
|
-
)
|
|
275
|
-
else
|
|
276
|
-
notify_unexpected_dispatcher_failure(error)
|
|
277
|
-
raise
|
|
278
|
-
end
|
|
323
|
+
fire_due_timers
|
|
279
324
|
rescue => error
|
|
280
325
|
notify_unexpected_dispatcher_failure(error)
|
|
281
326
|
raise
|
|
282
327
|
ensure
|
|
283
|
-
@lifecycle_mutex.synchronize
|
|
284
|
-
|
|
285
|
-
|
|
328
|
+
@lifecycle_mutex.synchronize { @idle_cond.broadcast }
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def fire_due_timers
|
|
332
|
+
@runtime.__timer_queue.fire_due
|
|
286
333
|
end
|
|
287
334
|
|
|
288
335
|
def dispatch(event)
|
|
@@ -308,11 +355,11 @@ module Phronomy
|
|
|
308
355
|
session_id = event.payload.fetch(:session_id)
|
|
309
356
|
session = @fsms.delete(session_id)
|
|
310
357
|
waiter = @waiting.delete(session_id)
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
)
|
|
358
|
+
# decrement before waking caller so wait_until_idle sees the control-plane
|
|
359
|
+
# session count immediately; Workflow durable admission may intentionally
|
|
360
|
+
# keep Runtime non-idle until its terminal save completes.
|
|
315
361
|
decrement_outstanding if session
|
|
362
|
+
complete_waiter(waiter, event.payload.fetch(:result))
|
|
316
363
|
when :start
|
|
317
364
|
session = event.payload.fetch(:session)
|
|
318
365
|
waiter = event.payload[:completion]
|
|
@@ -322,6 +369,9 @@ module Phronomy
|
|
|
322
369
|
when :agent_terminal_ready
|
|
323
370
|
cmd = event.payload.fetch(:command)
|
|
324
371
|
cmd.coordinator.deliver_on_event_loop(cmd)
|
|
372
|
+
when :workflow_persistence_ready
|
|
373
|
+
cmd = event.payload.fetch(:command)
|
|
374
|
+
cmd.runner.deliver_persistence_on_event_loop(cmd)
|
|
325
375
|
end
|
|
326
376
|
end
|
|
327
377
|
|
|
@@ -335,66 +385,30 @@ module Phronomy
|
|
|
335
385
|
def begin_stopping_if_idle
|
|
336
386
|
@lifecycle_mutex.synchronize do
|
|
337
387
|
return false unless @state == :draining
|
|
338
|
-
return false unless
|
|
388
|
+
return false unless runtime_idle_locked?
|
|
339
389
|
|
|
340
390
|
@state = :stopping
|
|
341
|
-
|
|
391
|
+
@queue.push(STOP)
|
|
342
392
|
true
|
|
343
393
|
end
|
|
344
394
|
end
|
|
345
395
|
|
|
346
|
-
def cancel_and_cleanup(cancel_grace)
|
|
347
|
-
task = @task
|
|
348
|
-
@lifecycle_mutex.synchronize do
|
|
349
|
-
@state = :stopping unless @state == :failed
|
|
350
|
-
@cancel_requested = true
|
|
351
|
-
end
|
|
352
|
-
|
|
353
|
-
task&.cancel!
|
|
354
|
-
begin
|
|
355
|
-
task&.join(cancel_grace)
|
|
356
|
-
rescue
|
|
357
|
-
nil
|
|
358
|
-
end
|
|
359
|
-
|
|
360
|
-
if task&.alive?
|
|
361
|
-
@lifecycle_mutex.synchronize do
|
|
362
|
-
@state = :failed
|
|
363
|
-
end
|
|
364
|
-
return :cancel_timeout
|
|
365
|
-
end
|
|
366
|
-
|
|
367
|
-
return :failed if state == :failed
|
|
368
|
-
|
|
369
|
-
cleanup_abandoned_work(
|
|
370
|
-
Phronomy::CancellationError.new(
|
|
371
|
-
"Runtime shutdown timed out"
|
|
372
|
-
)
|
|
373
|
-
)
|
|
374
|
-
finalize_terminated(:cancelled)
|
|
375
|
-
end
|
|
376
|
-
|
|
377
396
|
def cleanup_abandoned_work(error)
|
|
378
397
|
drain_queued_items.each do |item|
|
|
379
|
-
next if item.equal?(STOP)
|
|
398
|
+
next if item.equal?(STOP) || item.equal?(WAKE)
|
|
380
399
|
|
|
381
400
|
event, = item
|
|
382
401
|
next unless event.target_id == SYSTEM_CHANNEL_ID
|
|
383
402
|
next unless event.type == :start
|
|
384
|
-
|
|
385
|
-
complete_waiter(
|
|
386
|
-
event.payload[:completion],
|
|
387
|
-
error
|
|
388
|
-
)
|
|
403
|
+
complete_waiter(event.payload[:completion], error)
|
|
389
404
|
end
|
|
390
405
|
|
|
391
|
-
@waiting.values.each
|
|
392
|
-
complete_waiter(waiter, error)
|
|
393
|
-
end
|
|
406
|
+
@waiting.values.each { |waiter| complete_waiter(waiter, error) }
|
|
394
407
|
@waiting.clear
|
|
395
408
|
@fsms.clear
|
|
396
409
|
@lifecycle_mutex.synchronize do
|
|
397
410
|
@admitted_session_ids.clear
|
|
411
|
+
@workflow_admissions.clear
|
|
398
412
|
@outstanding_sessions = 0
|
|
399
413
|
@idle_cond.broadcast
|
|
400
414
|
end
|
|
@@ -405,7 +419,6 @@ module Phronomy
|
|
|
405
419
|
loop do
|
|
406
420
|
item = dequeue(timeout: 0)
|
|
407
421
|
break unless item
|
|
408
|
-
|
|
409
422
|
items << item
|
|
410
423
|
end
|
|
411
424
|
items
|
|
@@ -415,56 +428,48 @@ module Phronomy
|
|
|
415
428
|
@lifecycle_mutex.synchronize do
|
|
416
429
|
@state = :failed
|
|
417
430
|
@admitted_session_ids.clear
|
|
431
|
+
@workflow_admissions.clear
|
|
418
432
|
@idle_cond.broadcast
|
|
419
433
|
end
|
|
420
|
-
|
|
421
|
-
complete_waiter(waiter, error)
|
|
422
|
-
end
|
|
434
|
+
cleanup_abandoned_work(error)
|
|
423
435
|
@runtime.__event_loop_failed(error)
|
|
424
436
|
end
|
|
425
437
|
|
|
426
|
-
def shutdown_cancel_requested?
|
|
427
|
-
@lifecycle_mutex.synchronize do
|
|
428
|
-
@cancel_requested && @state == :stopping
|
|
429
|
-
end
|
|
430
|
-
end
|
|
431
|
-
|
|
432
438
|
def accepting_events?
|
|
433
439
|
%i[running draining].include?(@state)
|
|
434
440
|
end
|
|
435
441
|
|
|
436
442
|
def ensure_accepting_registrations!
|
|
437
443
|
return if accepting_events?
|
|
438
|
-
|
|
439
444
|
raise Phronomy::RuntimeShutdownError,
|
|
440
445
|
"EventLoop is #{@state}; new sessions are not accepted"
|
|
441
446
|
end
|
|
442
447
|
|
|
443
448
|
def decrement_outstanding
|
|
444
449
|
@lifecycle_mutex.synchronize do
|
|
445
|
-
if @outstanding_sessions.positive?
|
|
446
|
-
|
|
447
|
-
end
|
|
448
|
-
@idle_cond.broadcast if @outstanding_sessions.zero?
|
|
450
|
+
@outstanding_sessions -= 1 if @outstanding_sessions.positive?
|
|
451
|
+
@idle_cond.broadcast if runtime_idle_locked?
|
|
449
452
|
end
|
|
450
453
|
end
|
|
451
454
|
|
|
455
|
+
def runtime_idle_locked?
|
|
456
|
+
@outstanding_sessions.zero? && @workflow_admissions.empty?
|
|
457
|
+
end
|
|
458
|
+
|
|
452
459
|
def join_until(deadline)
|
|
453
460
|
remaining = deadline - monotonic_now
|
|
454
461
|
return if remaining <= 0
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
rescue
|
|
459
|
-
nil
|
|
460
|
-
end
|
|
462
|
+
@thread&.join(remaining)
|
|
463
|
+
rescue
|
|
464
|
+
nil
|
|
461
465
|
end
|
|
462
466
|
|
|
463
467
|
def finalize_terminated(status)
|
|
464
468
|
@lifecycle_mutex.synchronize do
|
|
465
469
|
@state = :terminated
|
|
466
470
|
@admitted_session_ids.clear
|
|
467
|
-
@
|
|
471
|
+
@workflow_admissions.clear
|
|
472
|
+
@thread = nil unless @thread&.alive?
|
|
468
473
|
@idle_cond.broadcast
|
|
469
474
|
end
|
|
470
475
|
status
|
|
@@ -474,16 +479,7 @@ module Phronomy
|
|
|
474
479
|
return unless waiter
|
|
475
480
|
|
|
476
481
|
if waiter.is_a?(Phronomy::Task)
|
|
477
|
-
|
|
478
|
-
waiter.backend.unblock(nil, payload)
|
|
479
|
-
waiter.transition!(:failed, error: payload)
|
|
480
|
-
else
|
|
481
|
-
waiter.backend.unblock(payload, nil)
|
|
482
|
-
waiter.transition!(
|
|
483
|
-
:completed,
|
|
484
|
-
value: payload
|
|
485
|
-
)
|
|
486
|
-
end
|
|
482
|
+
payload.is_a?(Exception) ? waiter.fail(payload) : waiter.complete(payload)
|
|
487
483
|
else
|
|
488
484
|
waiter.push(payload)
|
|
489
485
|
end
|
|
@@ -492,9 +488,7 @@ module Phronomy
|
|
|
492
488
|
def enqueue(item)
|
|
493
489
|
depth = @queue_metrics_mutex.synchronize do
|
|
494
490
|
@queue_depth += 1
|
|
495
|
-
if @queue_depth > @max_queue_depth
|
|
496
|
-
@max_queue_depth = @queue_depth
|
|
497
|
-
end
|
|
491
|
+
@max_queue_depth = @queue_depth if @queue_depth > @max_queue_depth
|
|
498
492
|
@queue_depth
|
|
499
493
|
end
|
|
500
494
|
@queue.push(item)
|
|
@@ -511,7 +505,7 @@ module Phronomy
|
|
|
511
505
|
begin
|
|
512
506
|
item = @queue.pop(timeout: timeout)
|
|
513
507
|
ensure
|
|
514
|
-
if item
|
|
508
|
+
if item && !item.equal?(WAKE) && !item.equal?(STOP)
|
|
515
509
|
@queue_metrics_mutex.synchronize do
|
|
516
510
|
@queue_depth -= 1 if @queue_depth.positive?
|
|
517
511
|
end
|
|
@@ -527,12 +521,9 @@ module Phronomy
|
|
|
527
521
|
max_depth = nil
|
|
528
522
|
should_warn = @queue_metrics_mutex.synchronize do
|
|
529
523
|
last = @last_queue_backlog_warning_at
|
|
530
|
-
if last &&
|
|
531
|
-
(now - last) <
|
|
532
|
-
QUEUE_BACKLOG_WARNING_INTERVAL_SECONDS
|
|
524
|
+
if last && (now - last) < QUEUE_BACKLOG_WARNING_INTERVAL_SECONDS
|
|
533
525
|
next false
|
|
534
526
|
end
|
|
535
|
-
|
|
536
527
|
@last_queue_backlog_warning_at = now
|
|
537
528
|
max_depth = @max_queue_depth
|
|
538
529
|
true
|
|
@@ -541,12 +532,9 @@ module Phronomy
|
|
|
541
532
|
|
|
542
533
|
warn_queue_backlog(
|
|
543
534
|
"[Phronomy::EventLoop] Queue backlog is high: " \
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
"target_id=#{event.target_id.inspect}. " \
|
|
548
|
-
"Events are not dropped; inspect slow callbacks or " \
|
|
549
|
-
"high streaming concurrency."
|
|
535
|
+
"depth=#{depth} max_depth=#{max_depth} " \
|
|
536
|
+
"threshold=#{QUEUE_BACKLOG_WARNING_THRESHOLD} " \
|
|
537
|
+
"event=#{event.type.inspect} target_id=#{event.target_id.inspect}."
|
|
550
538
|
)
|
|
551
539
|
end
|
|
552
540
|
|
|
@@ -567,38 +555,30 @@ module Phronomy
|
|
|
567
555
|
end
|
|
568
556
|
|
|
569
557
|
def check_starvation_lag(lag_ns, event)
|
|
570
|
-
threshold =
|
|
571
|
-
Phronomy.configuration
|
|
572
|
-
.event_loop_starvation_threshold_seconds
|
|
558
|
+
threshold = Phronomy.configuration.event_loop_starvation_threshold_seconds
|
|
573
559
|
return unless threshold
|
|
574
560
|
return unless lag_ns > (threshold * 1_000_000_000)
|
|
575
561
|
|
|
576
562
|
Phronomy.configuration.logger&.warn do
|
|
577
|
-
"[Phronomy::EventLoop] Starvation detected: " \
|
|
578
|
-
"event #{event.type.inspect} " \
|
|
563
|
+
"[Phronomy::EventLoop] Starvation detected: event #{event.type.inspect} " \
|
|
579
564
|
"for target #{event.target_id.inspect} waited " \
|
|
580
|
-
"#{format("%.3f", lag_ns / 1_000_000_000.0)}s " \
|
|
581
|
-
"
|
|
565
|
+
"#{format("%.3f", lag_ns / 1_000_000_000.0)}s in queue " \
|
|
566
|
+
"(threshold: #{threshold}s)"
|
|
582
567
|
end
|
|
583
568
|
end
|
|
584
569
|
|
|
585
570
|
def check_dispatch_time(dispatch_start_ns, event)
|
|
586
|
-
threshold =
|
|
587
|
-
Phronomy.configuration
|
|
588
|
-
.event_loop_dispatch_threshold_seconds
|
|
571
|
+
threshold = Phronomy.configuration.event_loop_dispatch_threshold_seconds
|
|
589
572
|
return unless threshold
|
|
590
573
|
|
|
591
574
|
elapsed_ns = monotonic_nanoseconds - dispatch_start_ns
|
|
592
|
-
return unless elapsed_ns >
|
|
593
|
-
(threshold * 1_000_000_000)
|
|
575
|
+
return unless elapsed_ns > (threshold * 1_000_000_000)
|
|
594
576
|
|
|
595
577
|
Phronomy.configuration.logger&.warn do
|
|
596
|
-
"[Phronomy::EventLoop] Long dispatch: " \
|
|
597
|
-
"event #{event.type.inspect} " \
|
|
578
|
+
"[Phronomy::EventLoop] Long dispatch: event #{event.type.inspect} " \
|
|
598
579
|
"for target #{event.target_id.inspect} took " \
|
|
599
|
-
"#{format("%.3f", elapsed_ns / 1_000_000_000.0)}s " \
|
|
600
|
-
"
|
|
601
|
-
"Consider moving blocking work to BlockingAdapterPool."
|
|
580
|
+
"#{format("%.3f", elapsed_ns / 1_000_000_000.0)}s on the EventLoop thread " \
|
|
581
|
+
"(threshold: #{threshold}s). Move blocking I/O to OffloadPool."
|
|
602
582
|
end
|
|
603
583
|
end
|
|
604
584
|
|
|
@@ -607,10 +587,7 @@ module Phronomy
|
|
|
607
587
|
end
|
|
608
588
|
|
|
609
589
|
def monotonic_nanoseconds
|
|
610
|
-
Process.clock_gettime(
|
|
611
|
-
Process::CLOCK_MONOTONIC,
|
|
612
|
-
:nanosecond
|
|
613
|
-
)
|
|
590
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
|
|
614
591
|
end
|
|
615
592
|
end
|
|
616
593
|
end
|