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.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/CONTRIBUTING.md +4 -4
  4. data/README.md +10 -7
  5. data/benchmark/bench_regression.rb +1 -1
  6. data/docs/architecture/agent-context.md +1 -1
  7. data/docs/architecture/persistence.md +2 -2
  8. data/docs/architecture/tracing.md +3 -3
  9. data/docs/async-composition.md +205 -0
  10. data/docs/decisions/010-cooperative-first-concurrency.md +23 -23
  11. data/docs/features.md +12 -9
  12. data/docs/getting-started.md +6 -6
  13. data/docs/migrations/durable-semantic-coordination-v2.md +1 -1
  14. data/docs/persistence-backends.md +1 -1
  15. data/docs/runtime-and-concurrency.md +89 -29
  16. data/lib/phronomy/agent/agent_invocation_session_builder.rb +1 -1
  17. data/lib/phronomy/agent/base.rb +4 -10
  18. data/lib/phronomy/agent/exact_execution.rb +3 -3
  19. data/lib/phronomy/agent/execution_coordinator.rb +20 -7
  20. data/lib/phronomy/agent/phase_machine_builder.rb +2 -2
  21. data/lib/phronomy/agent/recovery_coordinator/continuation.rb +1 -1
  22. data/lib/phronomy/agent/recovery_coordinator/installation.rb +4 -4
  23. data/lib/phronomy/agent/recovery_coordinator.rb +3 -3
  24. data/lib/phronomy/agent/tool_executor.rb +4 -4
  25. data/lib/phronomy/agent/tool_invocation.rb +1 -1
  26. data/lib/phronomy/agent/tool_invocation_session_builder.rb +2 -2
  27. data/lib/phronomy/blocking.rb +15 -8
  28. data/lib/phronomy/engine/concurrency/offload_pool.rb +25 -16
  29. data/lib/phronomy/engine/concurrency/operation_binding.rb +43 -0
  30. data/lib/phronomy/engine/concurrency/physical_completion_task.rb +4 -62
  31. data/lib/phronomy/engine/concurrency/result_collector.rb +99 -0
  32. data/lib/phronomy/engine/concurrency/result_composition.rb +145 -0
  33. data/lib/phronomy/engine/concurrency/subscriptions.rb +68 -0
  34. data/lib/phronomy/engine/event_loop.rb +7 -7
  35. data/lib/phronomy/engine/fsm_session.rb +3 -3
  36. data/lib/phronomy/engine/runtime/timer_queue.rb +11 -0
  37. data/lib/phronomy/engine/{task.rb → task_result.rb} +97 -42
  38. data/lib/phronomy/execution.rb +236 -0
  39. data/lib/phronomy/execution_cancellation_error.rb +12 -0
  40. data/lib/phronomy/execution_timeout_error.rb +12 -0
  41. data/lib/phronomy/invalid_async_entry_action_error.rb +1 -1
  42. data/lib/phronomy/invalid_async_transition_action_error.rb +1 -1
  43. data/lib/phronomy/invalid_async_workflow_action_error.rb +1 -1
  44. data/lib/phronomy/invocation_context.rb +13 -1
  45. data/lib/phronomy/llm_adapter/base.rb +2 -2
  46. data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +3 -3
  47. data/lib/phronomy/multi_agent/orchestrator.rb +35 -67
  48. data/lib/phronomy/multi_agent/team_coordinator.rb +3 -3
  49. data/lib/phronomy/testing/eval/scorer/llm_judge.rb +5 -3
  50. data/lib/phronomy/testing/fake_clock.rb +13 -9
  51. data/lib/phronomy/tools/agent.rb +5 -5
  52. data/lib/phronomy/vector_store/async_backend.rb +5 -5
  53. data/lib/phronomy/vector_store/embeddings/base.rb +2 -2
  54. data/lib/phronomy/version.rb +1 -1
  55. data/lib/phronomy/workflow/phase_machine_builder.rb +6 -6
  56. data/lib/phronomy/workflow.rb +2 -2
  57. data/lib/phronomy/workflow_runner.rb +4 -4
  58. data/scripts/api_snapshot.rb +3 -1
  59. data/sig/phronomy/agent.rbs +4 -4
  60. data/sig/phronomy/extensions.rbs +5 -5
  61. data/sig/phronomy/multi_agent.rbs +1 -3
  62. data/sig/phronomy/runtime.rbs +34 -7
  63. data/sig/phronomy/tool.rbs +1 -1
  64. data/sig/phronomy/workflow.rbs +1 -1
  65. metadata +11 -5
  66. data/lib/phronomy/multi_agent/fan_out_invocation.rb +0 -137
  67. 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
- # Task does not execute work. Execution belongs to EventLoop/FSMSession or
7
- # OffloadPool. Task represents completion, failure, cancellation, callbacks,
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 Task settlement. Application code should observe a
11
- # Task through {#wait_result}, {#on_complete}, {#map}, and state readers rather
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 Task.
15
- class Task
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 Task: there is no physical worker to supervise.
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::Task] a completed base Task
64
+ # @return [Phronomy::TaskResult] a completed base TaskResult
31
65
  # @api public
32
66
  def self.completed(value = nil, name: nil)
33
- Phronomy::Task.deferred(name: name).tap { |task| task.complete(value) }
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::Task] a failed base Task
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::Task.deferred(name: name).tap { |task| task.fail(error) }
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 Task has reached a terminal state
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 Task has not yet reached a terminal state
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 Task; framework continuation must
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 Task.
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 Task
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
- "Task#wait_result cannot block the EventLoop thread; continue via an event"
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 Task #{@name || "(unnamed)"}"
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 Task error.
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
- "Task#join cannot block the EventLoop thread; continue via an event"
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 Task's already-settled result.
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 Task successfully. Framework-owned settlement API.
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 Task with a failure. Framework-owned settlement API.
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 Task as cancelled. Framework-owned settlement API.
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 Task. Tokens can be shared across operations;
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("Task cancelled"))
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(&:cancel!)
228
+ children.each { |child| child.cancel!(error) }
195
229
  end
196
230
  self
197
231
  end
198
232
 
199
- # Creates a derived Task by transforming this Task's successful result.
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
- mapped = self.class.deferred(name: "#{@name}-mapped", parent: @parent)
205
- on_complete do |value, error|
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
- begin
212
- mapped.complete(block.call(value))
213
- rescue => mapped_error
214
- mapped.fail(mapped_error)
215
- end
216
- end
217
- mapped
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
- "[Task] on_complete callback raised #{callback_error.class}: #{callback_error.message}"
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
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class ExecutionCancellationError < CancellationError
5
+ attr_reader :outcomes
6
+
7
+ def initialize(message = "Execution cancelled", outcomes:)
8
+ @outcomes = outcomes
9
+ super(message)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class ExecutionTimeoutError < TimeoutError
5
+ attr_reader :outcomes
6
+
7
+ def initialize(message = "Execution timed out", outcomes:)
8
+ @outcomes = outcomes
9
+ super(message)
10
+ end
11
+ end
12
+ 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::Task.
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::Task.
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::Task.
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::Task] caller-facing completion handle
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::Task] caller-facing completion handle
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; Task callbacks only wake observers.
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::Task.deferred(name: "durable-subagent:#{tool_invocation_id}")
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::Task.deferred(name: "durable-subagent")
128
+ completion ||= Phronomy::TaskResult.deferred(name: "durable-subagent")
129
129
  completion.fail(error)
130
130
  completion
131
131
  end