phronomy 0.25.0 → 0.26.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 +31 -0
- data/CONTRIBUTING.md +4 -4
- data/README.md +10 -7
- data/benchmark/bench_regression.rb +1 -1
- data/docs/architecture/agent-context.md +1 -1
- data/docs/architecture/persistence.md +2 -2
- data/docs/architecture/tracing.md +3 -3
- data/docs/async-composition.md +205 -0
- data/docs/decisions/010-cooperative-first-concurrency.md +23 -23
- data/docs/features.md +12 -9
- data/docs/getting-started.md +6 -6
- data/docs/migrations/durable-semantic-coordination-v2.md +1 -1
- data/docs/persistence-backends.md +1 -1
- data/docs/runtime-and-concurrency.md +89 -29
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +1 -1
- data/lib/phronomy/agent/base.rb +4 -10
- data/lib/phronomy/agent/exact_execution.rb +3 -3
- data/lib/phronomy/agent/execution_coordinator.rb +20 -7
- data/lib/phronomy/agent/phase_machine_builder.rb +2 -2
- data/lib/phronomy/agent/recovery_coordinator/continuation.rb +1 -1
- data/lib/phronomy/agent/recovery_coordinator/installation.rb +4 -4
- data/lib/phronomy/agent/recovery_coordinator.rb +3 -3
- data/lib/phronomy/agent/tool_executor.rb +4 -4
- data/lib/phronomy/agent/tool_invocation.rb +1 -1
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +2 -2
- data/lib/phronomy/blocking.rb +15 -8
- data/lib/phronomy/engine/concurrency/offload_pool.rb +25 -16
- data/lib/phronomy/engine/concurrency/operation_binding.rb +43 -0
- data/lib/phronomy/engine/concurrency/physical_completion_task.rb +4 -62
- data/lib/phronomy/engine/concurrency/result_collector.rb +99 -0
- data/lib/phronomy/engine/concurrency/result_composition.rb +145 -0
- data/lib/phronomy/engine/concurrency/subscriptions.rb +68 -0
- data/lib/phronomy/engine/event_loop.rb +7 -7
- data/lib/phronomy/engine/fsm_session.rb +3 -3
- data/lib/phronomy/engine/runtime/timer_queue.rb +11 -0
- data/lib/phronomy/engine/{task.rb → task_result.rb} +97 -42
- data/lib/phronomy/execution.rb +236 -0
- data/lib/phronomy/execution_cancellation_error.rb +12 -0
- data/lib/phronomy/execution_timeout_error.rb +12 -0
- data/lib/phronomy/invalid_async_entry_action_error.rb +1 -1
- data/lib/phronomy/invalid_async_transition_action_error.rb +1 -1
- data/lib/phronomy/invalid_async_workflow_action_error.rb +1 -1
- data/lib/phronomy/invocation_context.rb +13 -1
- data/lib/phronomy/llm_adapter/base.rb +2 -2
- data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +3 -3
- data/lib/phronomy/multi_agent/orchestrator.rb +35 -67
- data/lib/phronomy/multi_agent/team_coordinator.rb +3 -3
- data/lib/phronomy/testing/eval/scorer/llm_judge.rb +5 -3
- data/lib/phronomy/testing/fake_clock.rb +13 -9
- data/lib/phronomy/tools/agent.rb +5 -5
- data/lib/phronomy/vector_store/async_backend.rb +5 -5
- data/lib/phronomy/vector_store/embeddings/base.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/phase_machine_builder.rb +6 -6
- data/lib/phronomy/workflow.rb +2 -2
- data/lib/phronomy/workflow_runner.rb +4 -4
- data/scripts/api_snapshot.rb +3 -1
- data/sig/phronomy/agent.rbs +4 -4
- data/sig/phronomy/extensions.rbs +5 -5
- data/sig/phronomy/multi_agent.rbs +1 -3
- data/sig/phronomy/runtime.rbs +34 -7
- data/sig/phronomy/tool.rbs +1 -1
- data/sig/phronomy/workflow.rbs +1 -1
- metadata +11 -5
- data/lib/phronomy/multi_agent/fan_out_invocation.rb +0 -137
- data/lib/phronomy/multi_agent/fan_out_session_builder.rb +0 -118
|
@@ -3,20 +3,54 @@
|
|
|
3
3
|
module Phronomy
|
|
4
4
|
# A thread-free asynchronous completion handle.
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
# OffloadPool.
|
|
6
|
+
# TaskResult does not execute work. Execution belongs to EventLoop/FSMSession or
|
|
7
|
+
# OffloadPool. TaskResult represents completion, failure, cancellation, callbacks,
|
|
8
8
|
# and a blocking wait for callers outside EventLoop.
|
|
9
9
|
#
|
|
10
|
-
# Framework components own
|
|
11
|
-
#
|
|
10
|
+
# Framework components own TaskResult settlement. Application code should observe a
|
|
11
|
+
# TaskResult through {#wait_result}, {#on_complete}, {#map}, and state readers rather
|
|
12
12
|
# than calling {#complete}, {#fail}, or {#cancel!}. Operation-wide cancellation
|
|
13
13
|
# is supplied through the CancellationToken accepted by the API that created
|
|
14
|
-
# the
|
|
15
|
-
class
|
|
14
|
+
# the TaskResult.
|
|
15
|
+
class TaskResult
|
|
16
16
|
STATES = %i[pending completed failed cancelled].freeze
|
|
17
17
|
TERMINAL_STATES = %i[completed failed cancelled].freeze
|
|
18
18
|
private_constant :TERMINAL_STATES
|
|
19
19
|
|
|
20
|
+
# A shallow immutable input-position record. Application values are retained
|
|
21
|
+
# by reference; they are neither copied nor frozen by this record.
|
|
22
|
+
# @api public
|
|
23
|
+
class Outcome < Struct.new(:index, :status, :value, :error)
|
|
24
|
+
def initialize(**fields)
|
|
25
|
+
super
|
|
26
|
+
freeze
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Observe already-created results without taking ownership of their work.
|
|
31
|
+
# Invalid inputs are rejected before any completion subscription is added.
|
|
32
|
+
# @param results [Array<TaskResult>]
|
|
33
|
+
# @return [TaskResult<Array<Outcome>>]
|
|
34
|
+
# @raise [TypeError] for a non-Array list or non-TaskResult element
|
|
35
|
+
# @api public
|
|
36
|
+
def self.all_settled(results)
|
|
37
|
+
raise TypeError, "results must be an Array" unless results.is_a?(Array)
|
|
38
|
+
|
|
39
|
+
inputs = results.dup
|
|
40
|
+
inputs.each_with_index do |result, index|
|
|
41
|
+
unless result.is_a?(Phronomy::TaskResult)
|
|
42
|
+
raise TypeError, "results[#{index}] must be a Phronomy::TaskResult"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
combined = Phronomy::TaskResult.deferred(name: "all-settled")
|
|
46
|
+
collector = Concurrency::ResultCollector.new(inputs.length) do |_, outcomes|
|
|
47
|
+
combined.complete(outcomes)
|
|
48
|
+
end
|
|
49
|
+
inputs.each_with_index { |result, index| collector.watch(index, result) }
|
|
50
|
+
collector.finish(:completed)
|
|
51
|
+
combined
|
|
52
|
+
end
|
|
53
|
+
|
|
20
54
|
# Creates an unsettled completion handle for framework-owned execution.
|
|
21
55
|
# @api private
|
|
22
56
|
def self.deferred(name: nil, parent: nil)
|
|
@@ -24,26 +58,26 @@ module Phronomy
|
|
|
24
58
|
end
|
|
25
59
|
|
|
26
60
|
# Public factories for already-settled values. No execution is started.
|
|
27
|
-
# Always create a base
|
|
61
|
+
# Always create a base TaskResult: there is no physical worker to supervise.
|
|
28
62
|
# @param value [Object] already available result
|
|
29
63
|
# @param name [String, nil] optional diagnostic name
|
|
30
|
-
# @return [Phronomy::
|
|
64
|
+
# @return [Phronomy::TaskResult] a completed base TaskResult
|
|
31
65
|
# @api public
|
|
32
66
|
def self.completed(value = nil, name: nil)
|
|
33
|
-
Phronomy::
|
|
67
|
+
Phronomy::TaskResult.deferred(name: name).tap { |task| task.complete(value) }
|
|
34
68
|
end
|
|
35
69
|
|
|
36
70
|
# Represents an already known failure without raising the stored error.
|
|
37
71
|
# Observation through wait_result raises it; on_complete receives it.
|
|
38
72
|
# @param error [Exception] original failure object
|
|
39
73
|
# @param name [String, nil] optional diagnostic name
|
|
40
|
-
# @return [Phronomy::
|
|
74
|
+
# @return [Phronomy::TaskResult] a failed base TaskResult
|
|
41
75
|
# @raise [ArgumentError] if error is not an Exception
|
|
42
76
|
# @api public
|
|
43
77
|
def self.failed(error, name: nil)
|
|
44
78
|
raise ArgumentError, "error must be an Exception" unless error.is_a?(Exception)
|
|
45
79
|
|
|
46
|
-
Phronomy::
|
|
80
|
+
Phronomy::TaskResult.deferred(name: name).tap { |task| task.fail(error) }
|
|
47
81
|
end
|
|
48
82
|
|
|
49
83
|
attr_reader :name, :parent
|
|
@@ -68,13 +102,13 @@ module Phronomy
|
|
|
68
102
|
@mutex.synchronize { @status }
|
|
69
103
|
end
|
|
70
104
|
|
|
71
|
-
# @return [Boolean] whether the
|
|
105
|
+
# @return [Boolean] whether the TaskResult has reached a terminal state
|
|
72
106
|
# @api public
|
|
73
107
|
def done?
|
|
74
108
|
@mutex.synchronize { TERMINAL_STATES.include?(@status) }
|
|
75
109
|
end
|
|
76
110
|
|
|
77
|
-
# @return [Boolean] whether the
|
|
111
|
+
# @return [Boolean] whether the TaskResult has not yet reached a terminal state
|
|
78
112
|
# @api public
|
|
79
113
|
def alive?
|
|
80
114
|
!done?
|
|
@@ -82,19 +116,19 @@ module Phronomy
|
|
|
82
116
|
|
|
83
117
|
# Blocks the calling thread until settlement.
|
|
84
118
|
#
|
|
85
|
-
# EventLoop is never allowed to wait for a
|
|
119
|
+
# EventLoop is never allowed to wait for a TaskResult; framework continuation must
|
|
86
120
|
# proceed through explicit events. The optional timeout is waiter-local: it
|
|
87
|
-
# does not settle or cancel the
|
|
121
|
+
# does not settle or cancel the TaskResult.
|
|
88
122
|
#
|
|
89
123
|
# @param timeout [Numeric, nil] maximum seconds this caller will block
|
|
90
124
|
# @return [Object] the completed value
|
|
91
125
|
# @raise [Phronomy::TimeoutError] when the waiter-local timeout expires
|
|
92
|
-
# @raise [Exception] the error that settled the
|
|
126
|
+
# @raise [Exception] the error that settled the TaskResult
|
|
93
127
|
# @api public
|
|
94
128
|
def wait_result(timeout: nil)
|
|
95
129
|
if Phronomy::Runtime.in_event_loop_context? && !done?
|
|
96
130
|
raise Phronomy::EventLoopReentrancyError,
|
|
97
|
-
"
|
|
131
|
+
"TaskResult#wait_result cannot block the EventLoop thread; continue via an event"
|
|
98
132
|
end
|
|
99
133
|
|
|
100
134
|
deadline = timeout && monotonic_now + timeout.to_f
|
|
@@ -104,7 +138,7 @@ module Phronomy
|
|
|
104
138
|
remaining = deadline - monotonic_now
|
|
105
139
|
if remaining <= 0
|
|
106
140
|
raise Phronomy::TimeoutError,
|
|
107
|
-
"timed out waiting for
|
|
141
|
+
"timed out waiting for TaskResult #{@name || "(unnamed)"}"
|
|
108
142
|
end
|
|
109
143
|
@cond.wait(@mutex, remaining)
|
|
110
144
|
else
|
|
@@ -118,13 +152,13 @@ module Phronomy
|
|
|
118
152
|
value
|
|
119
153
|
end
|
|
120
154
|
|
|
121
|
-
# Compatibility wait that does not re-raise the
|
|
155
|
+
# Compatibility wait that does not re-raise the TaskResult error.
|
|
122
156
|
# Returns self when settled, nil on timeout.
|
|
123
157
|
# @api private
|
|
124
158
|
def join(limit = nil)
|
|
125
159
|
if Phronomy::Runtime.in_event_loop_context? && !done?
|
|
126
160
|
raise Phronomy::EventLoopReentrancyError,
|
|
127
|
-
"
|
|
161
|
+
"TaskResult#join cannot block the EventLoop thread; continue via an event"
|
|
128
162
|
end
|
|
129
163
|
|
|
130
164
|
deadline = limit && monotonic_now + limit.to_f
|
|
@@ -148,7 +182,7 @@ module Phronomy
|
|
|
148
182
|
# registers after settlement, an OffloadPool worker, or a framework control
|
|
149
183
|
# thread. Callbacks must therefore be thread-safe and should complete quickly.
|
|
150
184
|
# A callback failure is logged and does not suppress delivery to other
|
|
151
|
-
# completion callbacks or change the
|
|
185
|
+
# completion callbacks or change the TaskResult's already-settled result.
|
|
152
186
|
#
|
|
153
187
|
# @yield [value, error]
|
|
154
188
|
# @return [self]
|
|
@@ -168,53 +202,74 @@ module Phronomy
|
|
|
168
202
|
self
|
|
169
203
|
end
|
|
170
204
|
|
|
171
|
-
# Settles this
|
|
205
|
+
# Settles this TaskResult successfully. Framework-owned settlement API.
|
|
172
206
|
# @api private
|
|
173
207
|
def complete(value = nil)
|
|
174
208
|
settle!(:completed, value: value)
|
|
175
209
|
end
|
|
176
210
|
|
|
177
|
-
# Settles this
|
|
211
|
+
# Settles this TaskResult with a failure. Framework-owned settlement API.
|
|
178
212
|
# @api private
|
|
179
213
|
def fail(error)
|
|
180
214
|
raise ArgumentError, "error is required" unless error
|
|
181
215
|
settle!(:failed, error: error)
|
|
182
216
|
end
|
|
183
217
|
|
|
184
|
-
# Settles this
|
|
218
|
+
# Settles this TaskResult as cancelled. Framework-owned settlement API.
|
|
185
219
|
#
|
|
186
220
|
# This method does not propagate backwards into a CancellationToken that may
|
|
187
|
-
# have been used to create the
|
|
221
|
+
# have been used to create the TaskResult. Tokens can be shared across operations;
|
|
188
222
|
# operation-wide cancellation is owned by the creating API.
|
|
189
223
|
# @api private
|
|
190
|
-
def cancel!(error = Phronomy::CancellationError.new("
|
|
224
|
+
def cancel!(error = Phronomy::CancellationError.new("TaskResult cancelled"))
|
|
191
225
|
changed = settle!(:cancelled, error: error)
|
|
192
226
|
if changed
|
|
193
227
|
children = @mutex.synchronize { @children.dup }
|
|
194
|
-
children.each
|
|
228
|
+
children.each { |child| child.cancel!(error) }
|
|
195
229
|
end
|
|
196
230
|
self
|
|
197
231
|
end
|
|
198
232
|
|
|
199
|
-
# Creates a derived
|
|
233
|
+
# Creates a derived TaskResult by transforming this TaskResult's successful result.
|
|
200
234
|
# @api public
|
|
201
235
|
def map(&block)
|
|
202
236
|
raise ArgumentError, "map requires a block" unless block
|
|
203
237
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
if error
|
|
207
|
-
mapped.fail(error)
|
|
208
|
-
next
|
|
209
|
-
end
|
|
238
|
+
Concurrency::ResultComposition.new(self, flatten: false, &block).start
|
|
239
|
+
end
|
|
210
240
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
241
|
+
# Transform a successful value into the result of another asynchronous step.
|
|
242
|
+
# The block must return a TaskResult. No implicit wrapping or nested value
|
|
243
|
+
# flattening is performed by map.
|
|
244
|
+
# @return [TaskResult]
|
|
245
|
+
# @api public
|
|
246
|
+
def flat_map(&block)
|
|
247
|
+
raise ArgumentError, "flat_map requires a block" unless block
|
|
248
|
+
|
|
249
|
+
Concurrency::ResultComposition.new(self, flatten: true, &block).start
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
# Atomic state observation and removable framework subscriptions.
|
|
253
|
+
# @api private
|
|
254
|
+
def __snapshot
|
|
255
|
+
@mutex.synchronize { [@status, @value, @error] }
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# @api private
|
|
259
|
+
def __unsubscribe(callback)
|
|
260
|
+
@mutex.synchronize { @on_complete_callbacks.delete(callback) }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Bound at admission, before application code can attach continuations.
|
|
264
|
+
# @api private
|
|
265
|
+
def __bind_execution(execution)
|
|
266
|
+
@execution_scope = execution
|
|
267
|
+
self
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# @api private
|
|
271
|
+
def __execution_scope
|
|
272
|
+
@execution_scope
|
|
218
273
|
end
|
|
219
274
|
|
|
220
275
|
protected
|
|
@@ -250,7 +305,7 @@ module Phronomy
|
|
|
250
305
|
callback.call(value, error)
|
|
251
306
|
rescue => callback_error
|
|
252
307
|
Phronomy.configuration.logger&.error do
|
|
253
|
-
"[
|
|
308
|
+
"[TaskResult] on_complete callback raised #{callback_error.class}: #{callback_error.message}"
|
|
254
309
|
end
|
|
255
310
|
end
|
|
256
311
|
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
# Thread-free fan-out/fan-in. Each input block returns the final TaskResult for
|
|
5
|
+
# that JOB; its application-defined intermediate work is not discovered here.
|
|
6
|
+
# @api public
|
|
7
|
+
class Execution
|
|
8
|
+
# @return [InvocationContext] this execution's explicit operation context
|
|
9
|
+
# @api public
|
|
10
|
+
attr_reader :invocation_context
|
|
11
|
+
|
|
12
|
+
# @param inputs [Array] shallowly snapshotted JOB inputs
|
|
13
|
+
# @param timeout [Numeric, nil] whole-execution deadline in seconds
|
|
14
|
+
# @param cancellation_token [Concurrency::CancellationToken, nil]
|
|
15
|
+
# @param invocation_context [InvocationContext, nil] existing information/controls
|
|
16
|
+
# @yield [input, execution] starts one JOB and returns its final TaskResult
|
|
17
|
+
# @return [TaskResult<Array<TaskResult::Outcome>>]
|
|
18
|
+
# @api public
|
|
19
|
+
def self.run_async(inputs, timeout: nil, cancellation_token: nil,
|
|
20
|
+
invocation_context: nil, &block)
|
|
21
|
+
__run_async(inputs, timeout: timeout, cancellation_token: cancellation_token,
|
|
22
|
+
invocation_context: invocation_context, &block)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Starts the same execution once and waits outside EventLoop.
|
|
26
|
+
# @return [Array<TaskResult::Outcome>]
|
|
27
|
+
# @see run_async
|
|
28
|
+
# @api public
|
|
29
|
+
def self.run(inputs, timeout: nil, cancellation_token: nil,
|
|
30
|
+
invocation_context: nil, &block)
|
|
31
|
+
if Runtime.in_event_loop_context?
|
|
32
|
+
raise EventLoopReentrancyError, "Execution.run cannot run on EventLoop; use run_async"
|
|
33
|
+
end
|
|
34
|
+
run_async(inputs, timeout: timeout, cancellation_token: cancellation_token,
|
|
35
|
+
invocation_context: invocation_context, &block).wait_result
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Existing Orchestrator concurrency policy uses the same execution engine.
|
|
39
|
+
# This is not an additional public execution entrance or scheduling mode.
|
|
40
|
+
# @api private
|
|
41
|
+
def self.__run_async(inputs, timeout: nil, cancellation_token: nil,
|
|
42
|
+
invocation_context: nil, concurrency_limit: nil, &block)
|
|
43
|
+
raise TypeError, "inputs must be an Array" unless inputs.is_a?(Array)
|
|
44
|
+
raise ArgumentError, "Execution.run_async requires a block" unless block
|
|
45
|
+
if !cancellation_token.nil? && !cancellation_token.is_a?(Concurrency::CancellationToken)
|
|
46
|
+
raise TypeError, "cancellation_token must be a Phronomy::Concurrency::CancellationToken or nil"
|
|
47
|
+
end
|
|
48
|
+
if !invocation_context.nil? && !invocation_context.is_a?(InvocationContext)
|
|
49
|
+
raise TypeError, "invocation_context must be a Phronomy::InvocationContext or nil"
|
|
50
|
+
end
|
|
51
|
+
unless timeout.nil?
|
|
52
|
+
raise TypeError, "timeout must be Numeric or nil" unless timeout.is_a?(Numeric)
|
|
53
|
+
if timeout.is_a?(Complex) || !timeout.finite? || !timeout.to_f.finite?
|
|
54
|
+
raise ArgumentError, "timeout must be a finite real number"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
new(inputs.dup, timeout: timeout, cancellation_token: cancellation_token,
|
|
58
|
+
invocation_context: invocation_context, concurrency_limit: concurrency_limit,
|
|
59
|
+
&block).send(:start)
|
|
60
|
+
end
|
|
61
|
+
private_class_method :__run_async
|
|
62
|
+
|
|
63
|
+
# Common admission binding for Agent and synchronous-work adapters.
|
|
64
|
+
# @api private
|
|
65
|
+
def self.__operation_binding(invocation_context:, cancellation_token:)
|
|
66
|
+
Concurrency::OperationBinding.new(invocation_context: invocation_context,
|
|
67
|
+
cancellation_token: cancellation_token)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# A scoped observation copy. The source, its controls and its physical work
|
|
71
|
+
# keep their original ownership. Bind before adding this run's map/flat_map.
|
|
72
|
+
# @param source_result [TaskResult]
|
|
73
|
+
# @return [TaskResult] a distinct scoped observation result
|
|
74
|
+
# @raise [TypeError] for a non-TaskResult source
|
|
75
|
+
# @api public
|
|
76
|
+
def observe(source_result)
|
|
77
|
+
unless source_result.is_a?(TaskResult)
|
|
78
|
+
raise TypeError, "source_result must be a Phronomy::TaskResult"
|
|
79
|
+
end
|
|
80
|
+
observed = TaskResult.deferred(name: "execution-observation").__bind_execution(self)
|
|
81
|
+
subscriptions = Concurrency::Subscriptions.new
|
|
82
|
+
subscriptions.result(observed) { subscriptions.close }
|
|
83
|
+
unless __open?
|
|
84
|
+
observed.cancel!(__cancellation_error)
|
|
85
|
+
return observed
|
|
86
|
+
end
|
|
87
|
+
subscriptions.result(source_result) do
|
|
88
|
+
status, value, error = source_result.__snapshot
|
|
89
|
+
if !__open? && status == :completed
|
|
90
|
+
observed.cancel!(__cancellation_error)
|
|
91
|
+
elsif status == :completed
|
|
92
|
+
observed.complete(value)
|
|
93
|
+
elsif status == :cancelled
|
|
94
|
+
observed.cancel!(error)
|
|
95
|
+
else
|
|
96
|
+
observed.fail(error)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
subscriptions.cancellation(@token) { observed.cancel!(__cancellation_error) }
|
|
100
|
+
observed
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Scope/admission hooks shared with framework-owned operations.
|
|
104
|
+
# @api private
|
|
105
|
+
def __while_open(&block)
|
|
106
|
+
@collector.while_open(&block)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @api private
|
|
110
|
+
def __open?
|
|
111
|
+
@collector.open?
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# @api private
|
|
115
|
+
def __cancellation_token
|
|
116
|
+
@token
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @api private
|
|
120
|
+
def __cancellation_error
|
|
121
|
+
@cancellation_error
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def initialize(inputs, timeout:, cancellation_token:, invocation_context:,
|
|
127
|
+
concurrency_limit:, &block)
|
|
128
|
+
@started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
129
|
+
@inputs = inputs
|
|
130
|
+
@block = block
|
|
131
|
+
@timeout = timeout
|
|
132
|
+
@parent_context = invocation_context
|
|
133
|
+
@parent_token = cancellation_token
|
|
134
|
+
@token = Concurrency::CancellationToken.new
|
|
135
|
+
@subscriptions = Concurrency::Subscriptions.new
|
|
136
|
+
@cancellation_error = CancellationError.new("Execution scope is closed")
|
|
137
|
+
@invocation_context = (invocation_context || InvocationContext.new)
|
|
138
|
+
.merge(cancellation_token: @token).__bind_execution(self)
|
|
139
|
+
@result = TaskResult.deferred(name: "execution")
|
|
140
|
+
@pump_mutex = Mutex.new
|
|
141
|
+
@next_index = 0
|
|
142
|
+
@active = 0
|
|
143
|
+
@draining = false
|
|
144
|
+
@limit = concurrency_limit || [inputs.length, 1].max
|
|
145
|
+
@collector = Concurrency::ResultCollector.new(inputs.length) do |kind, outcomes|
|
|
146
|
+
finish(kind, outcomes)
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def start
|
|
151
|
+
# All slots and control state exist before any registration can fire.
|
|
152
|
+
[@parent_token, @parent_context&.cancellation_token].compact.uniq.each do |token|
|
|
153
|
+
@subscriptions.cancellation(token) { @collector.finish(:cancelled) }
|
|
154
|
+
end
|
|
155
|
+
if @parent_context&.__execution_scope && !@parent_context.__execution_scope.__open?
|
|
156
|
+
@collector.finish(:cancelled)
|
|
157
|
+
end
|
|
158
|
+
if @parent_context&.deadline
|
|
159
|
+
@subscriptions.after(@parent_context.deadline.remaining_seconds) { @collector.finish(:cancelled) }
|
|
160
|
+
end
|
|
161
|
+
if @timeout
|
|
162
|
+
remaining = @timeout - (Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started_at)
|
|
163
|
+
@subscriptions.after(remaining) { @collector.finish(:timeout) }
|
|
164
|
+
end
|
|
165
|
+
@collector.finish(:completed) if @inputs.empty?
|
|
166
|
+
drain
|
|
167
|
+
@result
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def drain
|
|
171
|
+
claimed = @pump_mutex.synchronize do
|
|
172
|
+
next false if @draining
|
|
173
|
+
@draining = true
|
|
174
|
+
end
|
|
175
|
+
return unless claimed
|
|
176
|
+
|
|
177
|
+
loop do
|
|
178
|
+
work = @collector.while_open do
|
|
179
|
+
@pump_mutex.synchronize do
|
|
180
|
+
if @active < @limit && @next_index < @inputs.length
|
|
181
|
+
index = @next_index
|
|
182
|
+
@next_index += 1
|
|
183
|
+
@active += 1
|
|
184
|
+
[index, @inputs[index], @block]
|
|
185
|
+
else
|
|
186
|
+
@draining = false
|
|
187
|
+
nil
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
unless work
|
|
192
|
+
@pump_mutex.synchronize { @draining = false } unless __open?
|
|
193
|
+
break
|
|
194
|
+
end
|
|
195
|
+
index, input, start_block = work
|
|
196
|
+
begin
|
|
197
|
+
result = start_block.call(input, self)
|
|
198
|
+
unless result.is_a?(TaskResult)
|
|
199
|
+
raise TypeError, "JOB at inputs[#{index}] must return a Phronomy::TaskResult"
|
|
200
|
+
end
|
|
201
|
+
rescue => error
|
|
202
|
+
result = TaskResult.failed(error, name: "execution-job-#{index}")
|
|
203
|
+
end
|
|
204
|
+
@collector.watch(index, result) do
|
|
205
|
+
@pump_mutex.synchronize { @active -= 1 }
|
|
206
|
+
drain
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def finish(kind, outcomes)
|
|
212
|
+
@subscriptions.close
|
|
213
|
+
# A retained scoped result needs the closed-scope gate, not all original
|
|
214
|
+
# inputs and the application start closure. A claimed start keeps its own
|
|
215
|
+
# local copy so cancellation during admission does not invalidate it.
|
|
216
|
+
@pump_mutex.synchronize do
|
|
217
|
+
@inputs = []
|
|
218
|
+
@block = nil
|
|
219
|
+
end
|
|
220
|
+
case kind
|
|
221
|
+
when :completed
|
|
222
|
+
@result.complete(outcomes)
|
|
223
|
+
when :timeout
|
|
224
|
+
@cancellation_error = CancellationError.new("Execution deadline exceeded")
|
|
225
|
+
error = ExecutionTimeoutError.new(outcomes: outcomes)
|
|
226
|
+
@token.cancel!
|
|
227
|
+
@result.fail(error)
|
|
228
|
+
when :cancelled
|
|
229
|
+
@cancellation_error = CancellationError.new("Execution cancelled")
|
|
230
|
+
error = ExecutionCancellationError.new(outcomes: outcomes)
|
|
231
|
+
@token.cancel!
|
|
232
|
+
@result.cancel!(error)
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
|
-
# Raised when a synchronous FSM entry action returns Phronomy::
|
|
4
|
+
# Raised when a synchronous FSM entry action returns Phronomy::TaskResult.
|
|
5
5
|
#
|
|
6
6
|
# Entry actions are Run-to-Completion callbacks. They may start asynchronous
|
|
7
7
|
# work, but completion must return through a later explicit event.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
|
-
# Raised when a synchronous Workflow transition action returns Phronomy::
|
|
4
|
+
# Raised when a synchronous Workflow transition action returns Phronomy::TaskResult.
|
|
5
5
|
#
|
|
6
6
|
# Transition actions are Run-to-Completion callbacks. They may start
|
|
7
7
|
# asynchronous work, but completion must return through a later explicit event.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
|
-
# Base error for synchronous Workflow callbacks that return Phronomy::
|
|
4
|
+
# Base error for synchronous Workflow callbacks that return Phronomy::TaskResult.
|
|
5
5
|
#
|
|
6
6
|
# Workflow callbacks may start asynchronous work, but they must return
|
|
7
7
|
# synchronously and deliver completion through a later explicit event.
|
|
@@ -56,7 +56,19 @@ module Phronomy
|
|
|
56
56
|
redaction_policy: overrides.fetch(:redaction_policy, @redaction_policy),
|
|
57
57
|
task_id: overrides.fetch(:task_id, @task_id),
|
|
58
58
|
parent_task_id: overrides.fetch(:parent_task_id, @parent_task_id)
|
|
59
|
-
)
|
|
59
|
+
).__bind_execution(@execution_scope)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Framework-owned metadata; not inferred from the executing Ruby thread.
|
|
63
|
+
# @api private
|
|
64
|
+
def __bind_execution(execution)
|
|
65
|
+
@execution_scope = execution
|
|
66
|
+
self
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @api private
|
|
70
|
+
def __execution_scope
|
|
71
|
+
@execution_scope
|
|
60
72
|
end
|
|
61
73
|
|
|
62
74
|
# @api private
|
|
@@ -52,7 +52,7 @@ module Phronomy
|
|
|
52
52
|
# Transport timeout and retry remain the responsibility of the adapter or
|
|
53
53
|
# provider client; Phronomy does not attach an additional operation timeout.
|
|
54
54
|
#
|
|
55
|
-
# @return [Phronomy::
|
|
55
|
+
# @return [Phronomy::TaskResult] caller-facing completion handle
|
|
56
56
|
# @api private
|
|
57
57
|
def complete_async(chat, message, config: {}, pool: default_pool)
|
|
58
58
|
token = config[:cancellation_token]
|
|
@@ -68,7 +68,7 @@ module Phronomy
|
|
|
68
68
|
# application callbacks must never be passed directly to this method.
|
|
69
69
|
#
|
|
70
70
|
# @yield [chunk] streaming chunk on the worker thread
|
|
71
|
-
# @return [Phronomy::
|
|
71
|
+
# @return [Phronomy::TaskResult] caller-facing completion handle
|
|
72
72
|
# @api private
|
|
73
73
|
def stream_async(chat, message, config: {}, pool: default_pool, &block)
|
|
74
74
|
raise ArgumentError, "stream_async requires a block" unless block
|
|
@@ -4,7 +4,7 @@ require "securerandom"
|
|
|
4
4
|
|
|
5
5
|
module Phronomy
|
|
6
6
|
module MultiAgent
|
|
7
|
-
# Agent-owned transactions reserve child work;
|
|
7
|
+
# Agent-owned transactions reserve child work; TaskResult callbacks only wake observers.
|
|
8
8
|
# @api private
|
|
9
9
|
class DurableSubagentCoordinator
|
|
10
10
|
KEY = "multi_agent_coordination_ref"
|
|
@@ -47,7 +47,7 @@ module Phronomy
|
|
|
47
47
|
|
|
48
48
|
def self.start(parent:, tool_invocation_id:, parent_execution_id:, config:)
|
|
49
49
|
runtime = Phronomy::Runtime.instance
|
|
50
|
-
completion = Phronomy::
|
|
50
|
+
completion = Phronomy::TaskResult.deferred(name: "durable-subagent:#{tool_invocation_id}")
|
|
51
51
|
preparation = runtime.offload.submit(on_full: :raise) do
|
|
52
52
|
current = parent.persistence.executions.load(parent_execution_id)
|
|
53
53
|
raise Phronomy::Persistence::ConflictError, "Parent owner mismatch" unless current.agent_id == parent.agent_id
|
|
@@ -125,7 +125,7 @@ module Phronomy
|
|
|
125
125
|
end
|
|
126
126
|
completion
|
|
127
127
|
rescue => error
|
|
128
|
-
completion ||= Phronomy::
|
|
128
|
+
completion ||= Phronomy::TaskResult.deferred(name: "durable-subagent")
|
|
129
129
|
completion.fail(error)
|
|
130
130
|
completion
|
|
131
131
|
end
|