phronomy 0.24.1 → 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 +41 -0
- data/CONTRIBUTING.md +4 -4
- data/README.md +11 -6
- 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 -7
- 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 +41 -0
- data/lib/phronomy/engine/concurrency/offload_pool.rb +33 -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} +115 -37
- 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 +39 -3
- data/sig/phronomy/tool.rbs +1 -1
- data/sig/phronomy/workflow.rbs +1 -1
- metadata +12 -5
- data/lib/phronomy/multi_agent/fan_out_invocation.rb +0 -137
- data/lib/phronomy/multi_agent/fan_out_session_builder.rb +0 -118
|
@@ -44,6 +44,17 @@ module Phronomy
|
|
|
44
44
|
end
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
+
# Remove this registration without changing another user's timers. A
|
|
48
|
+
# callback already taken by fire_due may still run; owners arbitrate once.
|
|
49
|
+
# @api private
|
|
50
|
+
def cancel(callback)
|
|
51
|
+
@mutex.synchronize do
|
|
52
|
+
before = @heap.length
|
|
53
|
+
@heap.delete_if { |(_, registered)| registered == callback }
|
|
54
|
+
before != @heap.length
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
47
58
|
# Executes all callbacks whose deadline is due. Must be called by EventLoop.
|
|
48
59
|
def fire_due
|
|
49
60
|
callbacks = @mutex.synchronize do
|
|
@@ -3,26 +3,83 @@
|
|
|
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)
|
|
23
57
|
new(name: name, parent: parent)
|
|
24
58
|
end
|
|
25
59
|
|
|
60
|
+
# Public factories for already-settled values. No execution is started.
|
|
61
|
+
# Always create a base TaskResult: there is no physical worker to supervise.
|
|
62
|
+
# @param value [Object] already available result
|
|
63
|
+
# @param name [String, nil] optional diagnostic name
|
|
64
|
+
# @return [Phronomy::TaskResult] a completed base TaskResult
|
|
65
|
+
# @api public
|
|
66
|
+
def self.completed(value = nil, name: nil)
|
|
67
|
+
Phronomy::TaskResult.deferred(name: name).tap { |task| task.complete(value) }
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Represents an already known failure without raising the stored error.
|
|
71
|
+
# Observation through wait_result raises it; on_complete receives it.
|
|
72
|
+
# @param error [Exception] original failure object
|
|
73
|
+
# @param name [String, nil] optional diagnostic name
|
|
74
|
+
# @return [Phronomy::TaskResult] a failed base TaskResult
|
|
75
|
+
# @raise [ArgumentError] if error is not an Exception
|
|
76
|
+
# @api public
|
|
77
|
+
def self.failed(error, name: nil)
|
|
78
|
+
raise ArgumentError, "error must be an Exception" unless error.is_a?(Exception)
|
|
79
|
+
|
|
80
|
+
Phronomy::TaskResult.deferred(name: name).tap { |task| task.fail(error) }
|
|
81
|
+
end
|
|
82
|
+
|
|
26
83
|
attr_reader :name, :parent
|
|
27
84
|
|
|
28
85
|
# @api private
|
|
@@ -45,13 +102,13 @@ module Phronomy
|
|
|
45
102
|
@mutex.synchronize { @status }
|
|
46
103
|
end
|
|
47
104
|
|
|
48
|
-
# @return [Boolean] whether the
|
|
105
|
+
# @return [Boolean] whether the TaskResult has reached a terminal state
|
|
49
106
|
# @api public
|
|
50
107
|
def done?
|
|
51
108
|
@mutex.synchronize { TERMINAL_STATES.include?(@status) }
|
|
52
109
|
end
|
|
53
110
|
|
|
54
|
-
# @return [Boolean] whether the
|
|
111
|
+
# @return [Boolean] whether the TaskResult has not yet reached a terminal state
|
|
55
112
|
# @api public
|
|
56
113
|
def alive?
|
|
57
114
|
!done?
|
|
@@ -59,19 +116,19 @@ module Phronomy
|
|
|
59
116
|
|
|
60
117
|
# Blocks the calling thread until settlement.
|
|
61
118
|
#
|
|
62
|
-
# EventLoop is never allowed to wait for a
|
|
119
|
+
# EventLoop is never allowed to wait for a TaskResult; framework continuation must
|
|
63
120
|
# proceed through explicit events. The optional timeout is waiter-local: it
|
|
64
|
-
# does not settle or cancel the
|
|
121
|
+
# does not settle or cancel the TaskResult.
|
|
65
122
|
#
|
|
66
123
|
# @param timeout [Numeric, nil] maximum seconds this caller will block
|
|
67
124
|
# @return [Object] the completed value
|
|
68
125
|
# @raise [Phronomy::TimeoutError] when the waiter-local timeout expires
|
|
69
|
-
# @raise [Exception] the error that settled the
|
|
126
|
+
# @raise [Exception] the error that settled the TaskResult
|
|
70
127
|
# @api public
|
|
71
128
|
def wait_result(timeout: nil)
|
|
72
129
|
if Phronomy::Runtime.in_event_loop_context? && !done?
|
|
73
130
|
raise Phronomy::EventLoopReentrancyError,
|
|
74
|
-
"
|
|
131
|
+
"TaskResult#wait_result cannot block the EventLoop thread; continue via an event"
|
|
75
132
|
end
|
|
76
133
|
|
|
77
134
|
deadline = timeout && monotonic_now + timeout.to_f
|
|
@@ -81,7 +138,7 @@ module Phronomy
|
|
|
81
138
|
remaining = deadline - monotonic_now
|
|
82
139
|
if remaining <= 0
|
|
83
140
|
raise Phronomy::TimeoutError,
|
|
84
|
-
"timed out waiting for
|
|
141
|
+
"timed out waiting for TaskResult #{@name || "(unnamed)"}"
|
|
85
142
|
end
|
|
86
143
|
@cond.wait(@mutex, remaining)
|
|
87
144
|
else
|
|
@@ -95,13 +152,13 @@ module Phronomy
|
|
|
95
152
|
value
|
|
96
153
|
end
|
|
97
154
|
|
|
98
|
-
# Compatibility wait that does not re-raise the
|
|
155
|
+
# Compatibility wait that does not re-raise the TaskResult error.
|
|
99
156
|
# Returns self when settled, nil on timeout.
|
|
100
157
|
# @api private
|
|
101
158
|
def join(limit = nil)
|
|
102
159
|
if Phronomy::Runtime.in_event_loop_context? && !done?
|
|
103
160
|
raise Phronomy::EventLoopReentrancyError,
|
|
104
|
-
"
|
|
161
|
+
"TaskResult#join cannot block the EventLoop thread; continue via an event"
|
|
105
162
|
end
|
|
106
163
|
|
|
107
164
|
deadline = limit && monotonic_now + limit.to_f
|
|
@@ -125,7 +182,7 @@ module Phronomy
|
|
|
125
182
|
# registers after settlement, an OffloadPool worker, or a framework control
|
|
126
183
|
# thread. Callbacks must therefore be thread-safe and should complete quickly.
|
|
127
184
|
# A callback failure is logged and does not suppress delivery to other
|
|
128
|
-
# completion callbacks or change the
|
|
185
|
+
# completion callbacks or change the TaskResult's already-settled result.
|
|
129
186
|
#
|
|
130
187
|
# @yield [value, error]
|
|
131
188
|
# @return [self]
|
|
@@ -145,53 +202,74 @@ module Phronomy
|
|
|
145
202
|
self
|
|
146
203
|
end
|
|
147
204
|
|
|
148
|
-
# Settles this
|
|
205
|
+
# Settles this TaskResult successfully. Framework-owned settlement API.
|
|
149
206
|
# @api private
|
|
150
207
|
def complete(value = nil)
|
|
151
208
|
settle!(:completed, value: value)
|
|
152
209
|
end
|
|
153
210
|
|
|
154
|
-
# Settles this
|
|
211
|
+
# Settles this TaskResult with a failure. Framework-owned settlement API.
|
|
155
212
|
# @api private
|
|
156
213
|
def fail(error)
|
|
157
214
|
raise ArgumentError, "error is required" unless error
|
|
158
215
|
settle!(:failed, error: error)
|
|
159
216
|
end
|
|
160
217
|
|
|
161
|
-
# Settles this
|
|
218
|
+
# Settles this TaskResult as cancelled. Framework-owned settlement API.
|
|
162
219
|
#
|
|
163
220
|
# This method does not propagate backwards into a CancellationToken that may
|
|
164
|
-
# have been used to create the
|
|
221
|
+
# have been used to create the TaskResult. Tokens can be shared across operations;
|
|
165
222
|
# operation-wide cancellation is owned by the creating API.
|
|
166
223
|
# @api private
|
|
167
|
-
def cancel!(error = Phronomy::CancellationError.new("
|
|
224
|
+
def cancel!(error = Phronomy::CancellationError.new("TaskResult cancelled"))
|
|
168
225
|
changed = settle!(:cancelled, error: error)
|
|
169
226
|
if changed
|
|
170
227
|
children = @mutex.synchronize { @children.dup }
|
|
171
|
-
children.each
|
|
228
|
+
children.each { |child| child.cancel!(error) }
|
|
172
229
|
end
|
|
173
230
|
self
|
|
174
231
|
end
|
|
175
232
|
|
|
176
|
-
# Creates a derived
|
|
233
|
+
# Creates a derived TaskResult by transforming this TaskResult's successful result.
|
|
177
234
|
# @api public
|
|
178
235
|
def map(&block)
|
|
179
236
|
raise ArgumentError, "map requires a block" unless block
|
|
180
237
|
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
if error
|
|
184
|
-
mapped.fail(error)
|
|
185
|
-
next
|
|
186
|
-
end
|
|
238
|
+
Concurrency::ResultComposition.new(self, flatten: false, &block).start
|
|
239
|
+
end
|
|
187
240
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
|
195
273
|
end
|
|
196
274
|
|
|
197
275
|
protected
|
|
@@ -227,7 +305,7 @@ module Phronomy
|
|
|
227
305
|
callback.call(value, error)
|
|
228
306
|
rescue => callback_error
|
|
229
307
|
Phronomy.configuration.logger&.error do
|
|
230
|
-
"[
|
|
308
|
+
"[TaskResult] on_complete callback raised #{callback_error.class}: #{callback_error.message}"
|
|
231
309
|
end
|
|
232
310
|
end
|
|
233
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
|