phronomy 0.11.0 → 0.12.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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +17 -8
  3. data/benchmark/bench_agent_invoke.rb +1 -0
  4. data/lib/phronomy/agent/base.rb +197 -83
  5. data/lib/phronomy/agent/concerns/error_translation.rb +1 -1
  6. data/lib/phronomy/agent/concerns/retryable.rb +6 -2
  7. data/lib/phronomy/agent/invocation_context.rb +171 -0
  8. data/lib/phronomy/agent/invocation_session.rb +346 -0
  9. data/lib/phronomy/agent/phase_machine_builder.rb +189 -0
  10. data/lib/phronomy/agent/suspended_session_registry.rb +54 -0
  11. data/lib/phronomy/agent/tool_call_intercepted.rb +26 -0
  12. data/lib/phronomy/configuration.rb +0 -6
  13. data/lib/phronomy/{concurrency → engine/concurrency}/async_queue.rb +68 -5
  14. data/lib/phronomy/{concurrency → engine/concurrency}/blocking_adapter_pool.rb +9 -3
  15. data/lib/phronomy/{event_loop.rb → engine/event_loop.rb} +71 -37
  16. data/lib/phronomy/engine/fsm_session.rb +248 -0
  17. data/lib/phronomy/{runtime → engine/runtime}/deterministic_scheduler.rb +28 -1
  18. data/lib/phronomy/{runtime → engine/runtime}/fake_scheduler.rb +1 -1
  19. data/lib/phronomy/{runtime.rb → engine/runtime.rb} +2 -4
  20. data/lib/phronomy/{task → engine/task}/backend.rb +2 -2
  21. data/lib/phronomy/engine/task/deferred_backend.rb +73 -0
  22. data/lib/phronomy/{task → engine/task}/fiber_backend.rb +1 -1
  23. data/lib/phronomy/{task → engine/task}/immediate_backend.rb +1 -1
  24. data/lib/phronomy/{task → engine/task}/mapped_backend.rb +15 -3
  25. data/lib/phronomy/{task → engine/task}/thread_backend.rb +1 -1
  26. data/lib/phronomy/{task.rb → engine/task.rb} +19 -7
  27. data/lib/phronomy/eval/scorer/llm_judge.rb +1 -1
  28. data/lib/phronomy/generator_verifier.rb +20 -11
  29. data/lib/phronomy/multi_agent/orchestrator.rb +6 -6
  30. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +3 -3
  31. data/lib/phronomy/tools/mcp.rb +17 -10
  32. data/lib/phronomy/version.rb +1 -1
  33. data/lib/phronomy/workflow/phase_machine_builder.rb +24 -13
  34. data/lib/phronomy/workflow.rb +6 -3
  35. data/lib/phronomy/workflow_context.rb +7 -5
  36. data/lib/phronomy/workflow_runner.rb +77 -27
  37. data/lib/phronomy.rb +17 -9
  38. metadata +35 -35
  39. data/lib/phronomy/agent/checkpoint.rb +0 -208
  40. data/lib/phronomy/agent/checkpoint_store.rb +0 -97
  41. data/lib/phronomy/agent/concerns/suspendable.rb +0 -190
  42. data/lib/phronomy/agent/suspend_signal.rb +0 -37
  43. data/lib/phronomy/context.rb +0 -12
  44. data/lib/phronomy/tool.rb +0 -9
  45. data/lib/phronomy/workflow/fsm_session.rb +0 -249
  46. /data/lib/phronomy/{concurrency → engine/concurrency}/cancellation_scope.rb +0 -0
  47. /data/lib/phronomy/{concurrency → engine/concurrency}/cancellation_token.rb +0 -0
  48. /data/lib/phronomy/{concurrency → engine/concurrency}/concurrency_gate.rb +0 -0
  49. /data/lib/phronomy/{concurrency → engine/concurrency}/deadline.rb +0 -0
  50. /data/lib/phronomy/{concurrency → engine/concurrency}/gate_registry.rb +0 -0
  51. /data/lib/phronomy/{concurrency → engine/concurrency}/pool_registry.rb +0 -0
  52. /data/lib/phronomy/{runtime → engine/runtime}/runtime_metrics.rb +0 -0
  53. /data/lib/phronomy/{runtime → engine/runtime}/scheduler.rb +0 -0
  54. /data/lib/phronomy/{runtime → engine/runtime}/scheduler_timer_adapter.rb +0 -0
  55. /data/lib/phronomy/{runtime → engine/runtime}/task_registry.rb +0 -0
  56. /data/lib/phronomy/{runtime → engine/runtime}/thread_scheduler.rb +0 -0
  57. /data/lib/phronomy/{runtime → engine/runtime}/timer_queue.rb +0 -0
  58. /data/lib/phronomy/{runtime → engine/runtime}/timer_service.rb +0 -0
  59. /data/lib/phronomy/{task_group.rb → engine/task_group.rb} +0 -0
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module Phronomy
6
+ class Task
7
+ # Backend for externally-completed Tasks.
8
+ #
9
+ # DeferredBackend never starts a thread. The owner transitions it to a
10
+ # terminal state by calling Task#transition! and then #unblock.
11
+ # @api private
12
+ class DeferredBackend < Backend
13
+ def initialize(task:, &)
14
+ super
15
+ @done_queue = Queue.new
16
+ task.transition!(:running)
17
+ end
18
+
19
+ # Unblocks await/join after the task reaches a terminal state.
20
+ # @api private
21
+ def unblock(value, error)
22
+ @done_queue.push([value, error])
23
+ end
24
+
25
+ # Blocks until externally completed.
26
+ # @api private
27
+ def wait_result
28
+ scheduler = Thread.current.thread_variable_get(Task::SCHEDULER_KEY)
29
+ in_managed_fiber = !Fiber.respond_to?(:main) || Fiber.current != Fiber.main
30
+ if scheduler && in_managed_fiber && !@task.done?
31
+ scheduler.track_blocking_await
32
+ waiting_fiber = Fiber.current
33
+ @task.on_complete do |_value, _error|
34
+ scheduler.complete_blocking_await
35
+ scheduler.enqueue_fiber(-> { waiting_fiber.resume })
36
+ end
37
+ Fiber.yield(:cooperative_suspend)
38
+ end
39
+
40
+ value, error = @done_queue.pop
41
+ raise error if error
42
+
43
+ value
44
+ end
45
+
46
+ # Deferred tasks have no execution thread of their own.
47
+ # @api private
48
+ def alive?
49
+ !@task.done?
50
+ end
51
+
52
+ # Marks the task cancelled without interrupting external work.
53
+ # @api private
54
+ def cancel!
55
+ self
56
+ end
57
+
58
+ # Blocks until externally completed, with optional timeout.
59
+ # @api private
60
+ def join(limit = nil)
61
+ if limit.nil?
62
+ wait_result
63
+ else
64
+ begin
65
+ Timeout.timeout(limit) { wait_result }
66
+ rescue Timeout::Error
67
+ nil
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -100,7 +100,7 @@ module Phronomy
100
100
  # @return [Object]
101
101
  # @raise [Exception]
102
102
  # @api private
103
- def await
103
+ def wait_result
104
104
  unless @fiber.alive?
105
105
  raise @error if @error
106
106
  return @value
@@ -44,7 +44,7 @@ module Phronomy
44
44
  # @return [Object]
45
45
  # @raise [Exception]
46
46
  # @api private
47
- def await
47
+ def wait_result
48
48
  raise @error if @error
49
49
 
50
50
  @value
@@ -37,7 +37,19 @@ module Phronomy
37
37
  # @return [Object] the mapped value
38
38
  # @raise [Exception] if the source task or the map block raised an error
39
39
  # @api private
40
- def await
40
+ def wait_result
41
+ scheduler = Thread.current.thread_variable_get(Task::SCHEDULER_KEY)
42
+ in_managed_fiber = !Fiber.respond_to?(:main) || Fiber.current != Fiber.main
43
+ if scheduler && in_managed_fiber && !@task.done?
44
+ scheduler.track_blocking_await
45
+ waiting_fiber = Fiber.current
46
+ @task.on_complete do |_value, _error|
47
+ scheduler.complete_blocking_await
48
+ scheduler.enqueue_fiber(-> { waiting_fiber.resume })
49
+ end
50
+ Fiber.yield(:cooperative_suspend)
51
+ end
52
+
41
53
  value, error = @done_queue.pop
42
54
  raise error if error
43
55
 
@@ -64,10 +76,10 @@ module Phronomy
64
76
  # @api private
65
77
  def join(limit = nil)
66
78
  if limit.nil?
67
- await
79
+ wait_result
68
80
  else
69
81
  begin
70
- Timeout.timeout(limit) { await }
82
+ Timeout.timeout(limit) { wait_result }
71
83
  rescue Timeout::Error
72
84
  nil
73
85
  end
@@ -41,7 +41,7 @@ module Phronomy
41
41
  # @return [Object]
42
42
  # @raise [Exception]
43
43
  # @api private
44
- def await
44
+ def wait_result
45
45
  @thread.join
46
46
  raise @error if @error
47
47
 
@@ -5,6 +5,7 @@ require_relative "task/thread_backend"
5
5
  require_relative "task/immediate_backend"
6
6
  require_relative "task/fiber_backend"
7
7
  require_relative "task/mapped_backend"
8
+ require_relative "task/deferred_backend"
8
9
 
9
10
  module Phronomy
10
11
  # A single unit of concurrent work.
@@ -21,7 +22,7 @@ module Phronomy
21
22
  #
22
23
  # @example Basic usage (framework/test code only — prefer Runtime.instance.spawn in app code)
23
24
  # task = Phronomy::Task.spawn { expensive_io() }
24
- # result = task.await # blocks until done, re-raises errors
25
+ # result = task.wait_result # blocks until done, re-raises errors
25
26
  #
26
27
  # @example Cancel a running task
27
28
  # task = Phronomy::Task.spawn { loop { Phronomy::Task.checkpoint! } }
@@ -121,6 +122,15 @@ module Phronomy
121
122
  new(name: name, parent: parent, backend_class: backend_class, &block)
122
123
  end
123
124
 
125
+ # Creates a task whose lifecycle is completed externally.
126
+ # @param name [String, nil]
127
+ # @param parent [Task, nil]
128
+ # @return [Task]
129
+ # @api private
130
+ def self.deferred(name: nil, parent: current)
131
+ new(name: name, parent: parent, backend_class: DeferredBackend) {}
132
+ end
133
+
124
134
  # @return [String, nil] optional human-readable label
125
135
  attr_reader :name
126
136
 
@@ -161,8 +171,8 @@ module Phronomy
161
171
  # @return [Object] the result produced by the block
162
172
  # @raise [Exception] if the block raised an error
163
173
  # @api private
164
- def await
165
- @backend.await
174
+ def wait_result
175
+ @backend.wait_result
166
176
  end
167
177
 
168
178
  # Registers a callback to be invoked when the task reaches a terminal state
@@ -170,7 +180,7 @@ module Phronomy
170
180
  #
171
181
  # The callback receives two arguments: +value+ (the task's return value,
172
182
  # or +nil+) and +error+ (the exception, or +nil+). These are provided
173
- # directly so the callback does not need to call +task.await+, which would
183
+ # directly so the callback does not need to call +task.wait_result+, which would
174
184
  # risk a self-join error when the callback runs inside the task's own thread.
175
185
  #
176
186
  # If the task is already done when this method is called, the callback is
@@ -204,7 +214,7 @@ module Phronomy
204
214
  #
205
215
  # The primary use-case is transforming an agent result into a
206
216
  # {WorkflowContext} so that a Workflow entry action can return a Task
207
- # whose value is picked up by {Workflow::FSMSession} via the existing
217
+ # whose value is picked up by {FSMSession} via the existing
208
218
  # +:action_completed+ path:
209
219
  #
210
220
  # @example Returning agent output into a Workflow state field
@@ -237,13 +247,15 @@ module Phronomy
237
247
  mapped_error = e
238
248
  end
239
249
  end
250
+ # Unblock mapped.wait_result / mapped.join before the terminal transition.
251
+ # on_complete callbacks may resume a waiting Fiber immediately; the
252
+ # queue must already contain the result at that point.
253
+ mapped.backend.unblock(mapped_value, mapped_error)
240
254
  if mapped_error
241
255
  mapped.transition!(:failed, error: mapped_error)
242
256
  else
243
257
  mapped.transition!(:completed, value: mapped_value)
244
258
  end
245
- # Unblock mapped.await / mapped.join after the terminal transition.
246
- mapped.backend.unblock(mapped_value, mapped_error)
247
259
  end
248
260
  mapped
249
261
  end
@@ -58,7 +58,7 @@ module Phronomy
58
58
  # still returns 0.0 — warn is a side-effect not tested by value assertions
59
59
  def score(actual:, expected:, input: nil)
60
60
  prompt = format(@prompt_template, input: input.to_s, expected: expected.to_s, actual: actual.to_s)
61
- response = Phronomy::Runtime.instance.blocking_io.submit { RubyLLM.chat(model: @model).ask(prompt) }.await
61
+ response = Phronomy::Runtime.instance.blocking_io.submit { RubyLLM.chat(model: @model).ask(prompt) }.blocking_wait
62
62
  response.content.to_s.strip.scan(/-?\d+\.?\d*/).first.to_f.clamp(0.0, 1.0)
63
63
  rescue => e
64
64
  raise if @raise_on_error
@@ -192,21 +192,30 @@ module Phronomy
192
192
  entry :draft, ->(state) {
193
193
  feedback = state.review_notes.last
194
194
  prompt = dpb.call(state.input, feedback)
195
- result = draft_agent.invoke(prompt)
196
- parsed = drp.call(result[:output])
197
- state.draft = parsed[:answer].to_s
198
- state.self_score = pipeline.__send__(:clamp, parsed[:confidence])
199
- state.citations = pipeline.__send__(:normalize_citations, parsed[:citations])
200
- state.iteration = state.iteration + 1
195
+ # Return a Task so WorkflowRunner awaits it off the EventLoop thread,
196
+ # then posts :action_completed with the new context back to EventLoop.
197
+ draft_agent.invoke_async(prompt).map do |result|
198
+ parsed = drp.call(result[:output])
199
+ state.merge(
200
+ draft: parsed[:answer].to_s,
201
+ self_score: pipeline.__send__(:clamp, parsed[:confidence]),
202
+ citations: pipeline.__send__(:normalize_citations, parsed[:citations]),
203
+ iteration: state.iteration + 1
204
+ )
205
+ end
201
206
  }
202
207
 
203
208
  entry :review, ->(state) {
204
209
  prompt = rpb.call(state.input, state.draft, state.citations)
205
- result = review_agent.invoke(prompt)
206
- parsed = rrp.call(result[:output])
207
- state.review_score = pipeline.__send__(:clamp, parsed[:score])
208
- state.approved = parsed[:approved] == true
209
- state.review_notes << parsed[:feedback].to_s
210
+ # Return a Task so WorkflowRunner awaits it off the EventLoop thread.
211
+ review_agent.invoke_async(prompt).map do |result|
212
+ parsed = rrp.call(result[:output])
213
+ state.merge(
214
+ review_score: pipeline.__send__(:clamp, parsed[:score]),
215
+ approved: parsed[:approved] == true,
216
+ review_notes: [parsed[:feedback].to_s]
217
+ )
218
+ end
210
219
  }
211
220
 
212
221
  entry :finalize, ->(state) { state.output = state.draft }
@@ -84,7 +84,7 @@ module Phronomy
84
84
  input,
85
85
  thread_id: ctx[:thread_id] || parent_ic&.thread_id,
86
86
  config: task_config
87
- ).await
87
+ ).wait_result
88
88
  result[:output]
89
89
  rescue
90
90
  raise if on_error == :raise
@@ -217,7 +217,7 @@ module Phronomy
217
217
  input,
218
218
  config: effective_config,
219
219
  thread_id: thread_id || ctx[:thread_id] || parent_ic&.thread_id
220
- ).await
220
+ ).wait_result
221
221
  end
222
222
 
223
223
  private
@@ -252,9 +252,9 @@ module Phronomy
252
252
  effective_name = prepared.new.name
253
253
  Class.new(prepared) do
254
254
  tool_name effective_name
255
- define_method(:call) do |args|
255
+ define_method(:call) do |args, **kwargs|
256
256
  self._orchestrator_context = orch.instance_variable_get(:@_orchestrator_context)
257
- super(args)
257
+ super(args, **kwargs)
258
258
  end
259
259
  end
260
260
  end
@@ -306,7 +306,7 @@ module Phronomy
306
306
  task[:input],
307
307
  config: task_config,
308
308
  thread_id: task[:thread_id] || invocation_context&.thread_id
309
- ).await
309
+ ).wait_result
310
310
  rescue => e
311
311
  errors[i] = e unless on_error == :skip
312
312
  end
@@ -324,7 +324,7 @@ module Phronomy
324
324
  "(#{alive.length} of #{spawned.length} tasks still running)"
325
325
  end
326
326
  else
327
- spawned.each(&:await)
327
+ spawned.each(&:wait_result)
328
328
  end
329
329
 
330
330
  first_error = errors.compact.first
@@ -77,7 +77,7 @@ module Phronomy
77
77
  # Thread that previously wrapped each pool operation.
78
78
  #
79
79
  # Both Phronomy::Task and BlockingAdapterPool::PendingOperation support
80
- # #await, so results are collected uniformly below.
80
+ # #wait_result, so results are collected uniformly below.
81
81
  ct = @cancellation_token
82
82
  max = @max_parallel_tools
83
83
  tool_results = tool_calls.each_slice(max).flat_map do |batch|
@@ -105,7 +105,7 @@ module Phronomy
105
105
 
106
106
  # Await all dispatched operations in original order.
107
107
  dispatched.map do |item|
108
- result = item[:awaitable] ? item[:awaitable].await : item[:result]
108
+ result = item[:awaitable] ? item[:awaitable].wait_result : item[:result]
109
109
  {tool_call: item[:tool_call], result: result}
110
110
  end
111
111
  end
@@ -142,7 +142,7 @@ module Phronomy
142
142
  tool: tool,
143
143
  args: tool_call.arguments,
144
144
  cancellation_token: @cancellation_token
145
- ).await
145
+ ).wait_result
146
146
  end
147
147
  end
148
148
  end
@@ -35,39 +35,43 @@ module Phronomy
35
35
  # - "stdio://<command>" — spawn a child process
36
36
  # - "http://<url>" / "https://<url>" — connect to an HTTP/SSE server
37
37
  # @param tool_name [String] the tool name as registered in the MCP server
38
+ # @param headers [Hash] additional HTTP request headers forwarded to every
39
+ # request (tool discovery and tool execution). Ignored for stdio transports.
40
+ # Typical use: <tt>headers: { "Authorization" => "Bearer #{ENV['API_KEY']}" }</tt>
38
41
  # @return [Mcp] a configured subclass instance ready for use with an Agent
39
42
  # @api public
40
- def from_server(server_uri, tool_name:)
43
+ def from_server(server_uri, tool_name:, headers: {})
41
44
  # Use a short-lived transport only to query the tool definition,
42
45
  # then close it. Each Mcp instance creates its own transport
43
46
  # so that concurrent callers never share IO streams.
44
- transport = build_transport(server_uri)
47
+ transport = build_transport(server_uri, headers: headers)
45
48
  begin
46
49
  tool_def = transport.fetch_tool(tool_name)
47
50
  ensure
48
51
  transport.close
49
52
  end
50
- build_tool_class(tool_name, server_uri, tool_def).new
53
+ build_tool_class(tool_name, server_uri, tool_def, headers: headers).new
51
54
  end
52
55
 
53
56
  private
54
57
 
55
- def build_transport(uri)
58
+ def build_transport(uri, headers: {})
56
59
  scheme, path = uri.split("://", 2)
57
60
  case scheme
58
61
  when "stdio"
59
62
  StdioTransport.new(path)
60
63
  when "http", "https"
61
- HttpTransport.new(uri)
64
+ HttpTransport.new(uri, headers: headers)
62
65
  else
63
66
  raise ArgumentError, "Unsupported MCP transport scheme: #{scheme.inspect}. Supported: 'stdio://', 'http://', 'https://'."
64
67
  end
65
68
  end
66
69
 
67
- def build_tool_class(tool_name, server_uri, tool_def)
70
+ def build_tool_class(tool_name, server_uri, tool_def, headers: {})
68
71
  klass = Class.new(Mcp)
69
72
  klass.tool_name(tool_name)
70
73
  klass.instance_variable_set(:@mcp_server_uri, server_uri)
74
+ klass.instance_variable_set(:@mcp_headers, headers)
71
75
 
72
76
  # Register description and params from the MCP tool definition.
73
77
  klass.description(tool_def[:description] || tool_name)
@@ -82,7 +86,8 @@ module Phronomy
82
86
  # never share IO streams, eliminating the need for synchronisation.
83
87
  klass.define_method(:initialize) do
84
88
  uri = self.class.instance_variable_get(:@mcp_server_uri)
85
- @mcp_transport = self.class.send(:build_transport, uri)
89
+ hdrs = self.class.instance_variable_get(:@mcp_headers) || {}
90
+ @mcp_transport = self.class.send(:build_transport, uri, headers: hdrs)
86
91
  end
87
92
 
88
93
  klass.define_method(:execute) do |**args|
@@ -158,7 +163,7 @@ module Phronomy
158
163
  @wait_thr = nil
159
164
  stderr_thread&.join(1)
160
165
  begin
161
- stderr_op&.await(timeout: 1.0)
166
+ stderr_op&.blocking_wait(timeout: 1.0)
162
167
  rescue
163
168
  nil
164
169
  end
@@ -249,8 +254,10 @@ module Phronomy
249
254
  raise Phronomy::ToolError,
250
255
  "MCP stdio server did not start within #{@startup_timeout} seconds"
251
256
  end
252
- line = @stdout.gets
253
- @stdout.ungetbyte(line) if line
257
+ # Do NOT call @stdout.gets here: gets() blocks until a newline arrives,
258
+ # which hangs indefinitely when the server emits no startup line or a
259
+ # partial line without '\n'. IO.select already confirmed the server is
260
+ # alive and responsive; the first rpc_call will consume actual output.
254
261
  end
255
262
  end
256
263
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.11.0"
4
+ VERSION = "0.12.0"
5
5
  end
@@ -170,11 +170,7 @@ module Phronomy
170
170
  # @api private
171
171
  def handle_entry_action_result(machine, result, state_name, timeout_secs)
172
172
  if result.is_a?(Phronomy::Task)
173
- if Phronomy.configuration.event_loop
174
- dispatch_task_in_event_loop(machine, result, state_name, timeout_secs)
175
- else
176
- await_task_blocking(machine, result, state_name, timeout_secs)
177
- end
173
+ dispatch_task_in_event_loop(machine, result, state_name, timeout_secs)
178
174
  elsif result.is_a?(Phronomy::WorkflowContext)
179
175
  machine.context = result
180
176
  end
@@ -195,19 +191,34 @@ module Phronomy
195
191
  def dispatch_task_in_event_loop(machine, result, state_name, timeout_secs)
196
192
  machine.async_pending = true
197
193
  thread_id = machine.context.thread_id
198
- Phronomy::Runtime.instance.spawn(name: "wf-await-#{thread_id}") do
199
- enforce_timeout!(result, state_name, timeout_secs)
200
- task_result = result.await
194
+ if timeout_secs
195
+ Phronomy::Runtime.instance.timer_queue.schedule(seconds: timeout_secs) do
196
+ next if result.done?
197
+
198
+ Phronomy::EventLoop.instance.post(
199
+ Phronomy::Event.new(
200
+ type: :error,
201
+ target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID,
202
+ payload: {session_id: thread_id, result: Phronomy::ActionTimeoutError.new(
203
+ "Action in state #{state_name.inspect} timed out after #{timeout_secs}s"
204
+ )}
205
+ )
206
+ )
207
+ end
208
+ end
209
+ result.on_complete do |task_result, error|
210
+ if error
211
+ Phronomy::EventLoop.instance.post(
212
+ Phronomy::Event.new(type: :error, target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID, payload: {session_id: thread_id, result: error})
213
+ )
214
+ next
215
+ end
201
216
  ev = if task_result.is_a?(Phronomy::WorkflowContext)
202
217
  Phronomy::Event.new(type: :action_completed, target_id: thread_id, payload: task_result)
203
218
  else
204
219
  Phronomy::Event.new(type: :state_completed, target_id: thread_id, payload: nil)
205
220
  end
206
221
  Phronomy::EventLoop.instance.post(ev)
207
- rescue => e
208
- Phronomy::EventLoop.instance.post(
209
- Phronomy::Event.new(type: :error, target_id: thread_id, payload: e)
210
- )
211
222
  end
212
223
  end
213
224
 
@@ -223,7 +234,7 @@ module Phronomy
223
234
  # @api private
224
235
  def await_task_blocking(machine, result, state_name, timeout_secs)
225
236
  enforce_timeout!(result, state_name, timeout_secs)
226
- task_result = result.await
237
+ task_result = result.wait_result
227
238
  machine.context = task_result if task_result.is_a?(Phronomy::WorkflowContext)
228
239
  end
229
240
 
@@ -96,6 +96,11 @@ module Phronomy
96
96
 
97
97
  # Invokes this workflow asynchronously and returns a {Phronomy::Task}.
98
98
  #
99
+ # Unlike {#invoke}, this method registers the workflow session with the
100
+ # EventLoop and returns immediately without spawning an extra OS thread.
101
+ # The returned Task resolves with the final context when the workflow
102
+ # finishes.
103
+ #
99
104
  # @param input [Hash]
100
105
  # @param config [Hash]
101
106
  # @param invocation_context [Phronomy::InvocationContext, nil]
@@ -105,9 +110,7 @@ module Phronomy
105
110
  if invocation_context
106
111
  config = _apply_invocation_context(config, invocation_context)
107
112
  end
108
- Phronomy::Runtime.instance.spawn(name: "workflow-invoke-async") do
109
- invoke(input, config: config)
110
- end
113
+ @runner.invoke_deferred(input, config: config)
111
114
  end
112
115
 
113
116
  # Resumes a halted workflow. Generic resume that works for all halt types.
@@ -160,14 +160,16 @@ module Phronomy
160
160
  private
161
161
 
162
162
  # Asserts that the calling thread is allowed to mutate this context.
163
- # No-op when EventLoop mode is disabled.
164
- # @raise [Phronomy::WorkflowContextOwnershipError] when called from a
165
- # non-EventLoop thread in EventLoop mode.
163
+ # Raises WorkflowContextOwnershipError when called from outside the EventLoop
164
+ # dispatch thread, unless we are inside a synchronous execution context
165
+ # (e.g. Workflow#stream using the run_workflow sync path).
166
+ # @raise [Phronomy::WorkflowContextOwnershipError]
166
167
  # @api private
167
168
  # mutant:disable - multiple genuine equivalent mutations: defined?(Phronomy::EventLoop)&& removal is genuine because EventLoop is always loaded in the killfork environment; true&& is genuine (truthy guard); EventLoop.current? resolves to Phronomy::EventLoop.current? within the Phronomy module; WorkflowContextOwnershipError resolves to Phronomy::WorkflowContextOwnershipError within the module; raise without message or with nil message is genuine (spec checks exception class, not message text)
168
169
  def _assert_write_permitted!
169
- return unless defined?(Phronomy::EventLoop) &&
170
- Phronomy.configuration.event_loop
170
+ return unless defined?(Phronomy::EventLoop)
171
+ # Allow mutations when executing synchronously (e.g. Workflow#stream via run_workflow).
172
+ return if Thread.current[:phronomy_sync_execution]
171
173
  return if Phronomy::EventLoop.current?
172
174
 
173
175
  raise Phronomy::WorkflowContextOwnershipError,