phronomy 0.13.0 → 0.14.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.
@@ -46,12 +46,21 @@ module Phronomy
46
46
  scheduler = Phronomy::Runtime::FakeScheduler.new
47
47
  scheduler.clock = clock if clock
48
48
  runtime = Phronomy::Runtime.new(scheduler: scheduler)
49
- original = Phronomy::Runtime.instance
50
- Phronomy::Runtime.instance = runtime
49
+ original = Phronomy::Runtime.default_if_initialized_for_test
50
+ Phronomy::Runtime.replace_default_for_test(runtime)
51
51
  begin
52
52
  yield scheduler, clock
53
53
  ensure
54
- Phronomy::Runtime.instance = original
54
+ result = nil
55
+ begin
56
+ result = runtime.shutdown
57
+ ensure
58
+ Phronomy::Runtime.restore_default_for_test(original)
59
+ end
60
+ unless result&.cleanup_complete?
61
+ raise Phronomy::RuntimeShutdownError,
62
+ "Temporary test Runtime did not shut down completely"
63
+ end
55
64
  end
56
65
  end
57
66
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
3
4
  require "mcp"
4
5
  require "shellwords"
5
6
  require "uri"
@@ -9,51 +10,73 @@ module Phronomy
9
10
  # A Phronomy::Agent::Context::Capability::Base subclass that wraps a tool exposed by an external
10
11
  # MCP (Model Context Protocol) server.
11
12
  #
12
- # Uses the official MCP Ruby SDK (mcp gem) for transport handling, which provides
13
- # built-in support for the MCP initialize handshake, size limits, and SSE.
13
+ # Uses the official MCP Ruby SDK v1.x for transport handling, which provides
14
+ # the MCP initialize handshake, HTTP/SSE parsing, and request cancellation.
14
15
  #
15
16
  # Supports two transport schemes:
16
17
  # - <b>"stdio://\<command\>"</b> — spawns a child process via MCP::Client::Stdio.
17
18
  # - <b>"http://\<url\>"</b> / <b>"https://\<url\>"</b> — connects via MCP::Client::HTTP.
18
19
  #
20
+ # Each generated tool instance owns one MCP client. Calls, reconnects, and
21
+ # explicit close operations are serialized because the SDK's stdio transport
22
+ # does not support concurrent response readers.
23
+ #
19
24
  # @example
20
25
  # web_search = Phronomy::Tools::Mcp.from_server(
21
26
  # "stdio://./mcp-server",
22
27
  # tool_name: "search_web"
23
28
  # )
24
- # agent = MyAgent.new
25
29
  # agent_class.tools(web_search)
26
30
  class Mcp < Phronomy::Agent::Context::Capability::Base
31
+ SUPPORTED_SCHEMA_DIALECTS = [
32
+ "https://json-schema.org/draft/2020-12/schema",
33
+ "https://json-schema.org/draft/2020-12/schema#"
34
+ ].freeze
35
+ SUPPORTED_ROOT_KEYS = %w[$schema type properties required title description additionalProperties].freeze
36
+ SUPPORTED_PROPERTY_KEYS = %w[type description enum title].freeze
37
+ IGNORED_PROPERTY_KEYS = %w[
38
+ minimum maximum exclusiveMinimum exclusiveMaximum multipleOf
39
+ minLength maxLength pattern format default examples
40
+ ].freeze
41
+ SUPPORTED_TYPES = %w[string integer number boolean].freeze
42
+ MCP_CLEANUP_POOL_SIZE = 2
43
+ MCP_CLEANUP_QUEUE_SIZE = 100
44
+
45
+ private_constant :SUPPORTED_SCHEMA_DIALECTS,
46
+ :SUPPORTED_ROOT_KEYS,
47
+ :SUPPORTED_PROPERTY_KEYS,
48
+ :IGNORED_PROPERTY_KEYS,
49
+ :SUPPORTED_TYPES,
50
+ :MCP_CLEANUP_POOL_SIZE,
51
+ :MCP_CLEANUP_QUEUE_SIZE
52
+
27
53
  class << self
28
54
  # Build a Mcp instance by querying a running MCP server for the
29
55
  # tool definition identified by +tool_name+.
30
56
  #
57
+ # +additionalProperties+ omitted from the remote schema is accepted, but
58
+ # Phronomy still exposes and accepts only parameters declared in +properties+.
59
+ #
31
60
  # @param server_uri [String] URI of the MCP server.
32
- # Supported schemes:
33
- # - "stdio://<command>" — spawn a child process
34
- # - "http://<url>" / "https://<url>" — connect to an HTTP/SSE server
35
61
  # @param tool_name [String] the tool name as registered in the MCP server
36
- # @param headers [Hash] additional HTTP request headers forwarded to every
37
- # request (tool discovery and tool execution). Ignored for stdio transports.
38
- # Typical use: <tt>headers: { "Authorization" => "Bearer #{ENV['API_KEY']}" }</tt>
39
- # @return [Mcp] a configured subclass instance ready for use with an Agent
62
+ # @param headers [Hash] additional HTTP headers forwarded to discovery and execution
63
+ # @return [Mcp] configured tool instance
40
64
  # @api public
41
65
  def from_server(server_uri, tool_name:, headers: {})
42
- # Use a short-lived client only to discover the tool definition, then close.
43
- # Each Mcp instance creates its own client so concurrent agent threads
44
- # never share IO streams, eliminating the need for synchronisation.
45
- transport = build_transport(server_uri, headers: headers)
46
- client = MCP::Client.new(transport: transport)
66
+ transport = nil
47
67
  begin
68
+ transport = build_transport(server_uri, headers: headers)
69
+ client = MCP::Client.new(transport: transport)
48
70
  client.connect
49
71
  tool_def = extract_tool_def(client, tool_name.to_s, server_uri)
50
- rescue ArgumentError
72
+ rescue ArgumentError, Phronomy::ToolError
51
73
  raise
52
74
  rescue => e
53
75
  raise Phronomy::ToolError, "MCP connection failed: #{e.message}"
54
76
  ensure
55
- transport.close
77
+ close_transport_safely(transport)
56
78
  end
79
+
57
80
  build_tool_class(tool_name, server_uri, tool_def, headers: headers).new
58
81
  end
59
82
 
@@ -63,21 +86,38 @@ module Phronomy
63
86
  scheme, path = uri.split("://", 2)
64
87
  case scheme
65
88
  when "stdio"
66
- argv = Shellwords.split(path)
89
+ argv = Shellwords.split(path.to_s)
90
+ if argv.empty? || argv[0].to_s.empty?
91
+ raise ArgumentError, "MCP stdio URI must include a command"
92
+ end
93
+
67
94
  MCP::Client::Stdio.new(command: argv[0], args: argv[1..])
68
95
  when "http", "https"
69
96
  MCP::Client::HTTP.new(url: uri, headers: headers)
70
97
  else
71
- raise ArgumentError, "Unsupported MCP transport scheme: #{scheme.inspect}. Supported: 'stdio://', 'http://', 'https://'."
98
+ raise ArgumentError,
99
+ "Unsupported MCP transport scheme: #{scheme.inspect}. " \
100
+ "Supported: 'stdio://', 'http://', 'https://'."
72
101
  end
73
102
  end
74
103
 
75
104
  def extract_tool_def(client, tool_name, server_uri)
76
- mcp_tool = client.tools.find { |t| t.name == tool_name }
77
- raise ArgumentError, "Tool #{tool_name.inspect} not found on MCP server #{server_uri.inspect}" unless mcp_tool
105
+ mcp_tool = client.tools.find { |tool| tool.name == tool_name }
106
+ unless mcp_tool
107
+ raise ArgumentError,
108
+ "Tool #{tool_name.inspect} not found on MCP server #{server_uri.inspect}"
109
+ end
110
+
111
+ validate_supported_schema!(
112
+ mcp_tool.input_schema,
113
+ output_schema: mcp_tool.output_schema,
114
+ tool_name: mcp_tool.name
115
+ )
116
+
117
+ input_schema = mcp_tool.input_schema
118
+ properties = input_schema.fetch("properties", {})
119
+ required_names = input_schema.fetch("required", [])
78
120
 
79
- properties = mcp_tool.input_schema&.dig("properties") || {}
80
- required_names = mcp_tool.input_schema&.dig("required") || []
81
121
  {
82
122
  description: mcp_tool.description || tool_name,
83
123
  parameters: parse_schema_params(properties, required_names: required_names)
@@ -88,62 +128,17 @@ module Phronomy
88
128
  klass = Class.new(Mcp)
89
129
  klass.tool_name(tool_name)
90
130
  klass.instance_variable_set(:@mcp_server_uri, server_uri)
91
- klass.instance_variable_set(:@mcp_headers, headers)
131
+ klass.instance_variable_set(:@mcp_headers, headers.dup.freeze)
92
132
 
93
- # Register description and params from the MCP tool definition.
94
133
  klass.description(tool_def[:description] || tool_name)
95
- (tool_def[:parameters] || []).each do |p|
96
- opts = {type: p[:type]&.to_sym || :string, desc: p[:description].to_s}
97
- opts[:required] = p[:required] if p.key?(:required)
98
- opts[:enum] = p[:enum] if p.key?(:enum)
99
- klass.param(p[:name].to_sym, **opts)
100
- end
101
-
102
- # Each instance creates its own MCP client so concurrent agent threads
103
- # never share IO streams.
104
- klass.define_method(:initialize) do
105
- uri = self.class.instance_variable_get(:@mcp_server_uri)
106
- hdrs = self.class.instance_variable_get(:@mcp_headers) || {}
107
- transport = self.class.send(:build_transport, uri, headers: hdrs)
108
- @mcp_client = MCP::Client.new(transport: transport)
109
- @mcp_client.connect
110
- end
111
-
112
- klass.define_method(:execute) do |cancellation_token: nil, **args|
113
- # Bridge Phronomy::CancellationToken to MCP::Cancellation so that
114
- # explicit cancel! calls propagate into the in-flight MCP request.
115
- # Deadline-based expiry is handled cooperatively by BlockingAdapterPool
116
- # (the worker slot is marked abandoned); no extra handling is needed here.
117
- mcp_cancel = nil
118
- if cancellation_token
119
- mcp_cancel = MCP::Cancellation.new
120
- cancellation_token.on_cancel { mcp_cancel.cancel(reason: "phronomy_cancelled") }
121
- end
122
- begin
123
- response = @mcp_client.call_tool(
124
- name: tool_name,
125
- arguments: args.transform_keys(&:to_s),
126
- cancellation: mcp_cancel
127
- )
128
- rescue => e
129
- raise Phronomy::ToolError, "MCP call failed: #{e.message}"
130
- end
131
- if response["error"]
132
- err_msg = response.dig("error", "message") || response["error"].to_s
133
- raise Phronomy::ToolError, "MCP server returned error: #{err_msg}"
134
- end
135
- content = response.dig("result", "content")
136
- if content.is_a?(Array)
137
- texts = content.select { |c| c["type"] == "text" }.map { |c| c["text"] }
138
- (texts.length == 1) ? texts.first : texts
139
- else
140
- content
141
- end
142
- end
143
-
144
- # Allow callers to deterministically shut down the underlying transport.
145
- klass.define_method(:close) do
146
- @mcp_client.transport.close
134
+ (tool_def[:parameters] || []).each do |parameter|
135
+ options = {
136
+ type: parameter.fetch(:type).to_sym,
137
+ desc: parameter[:description].to_s,
138
+ required: parameter.fetch(:required, false)
139
+ }
140
+ options[:enum] = parameter[:enum] if parameter.key?(:enum)
141
+ klass.param(parameter.fetch(:name).to_sym, **options)
147
142
  end
148
143
 
149
144
  klass
@@ -151,16 +146,325 @@ module Phronomy
151
146
 
152
147
  def parse_schema_params(properties, required_names: [])
153
148
  properties.map do |name, schema|
154
- param = {
149
+ parameter = {
155
150
  name: name.to_s,
156
- type: schema["type"] || "string",
151
+ type: schema.fetch("type"),
157
152
  description: schema["description"].to_s,
158
153
  required: required_names.include?(name.to_s)
159
154
  }
160
- param[:enum] = schema["enum"] if schema["enum"]
161
- param
155
+ parameter[:enum] = schema["enum"] if schema.key?("enum")
156
+ parameter
157
+ end
158
+ end
159
+
160
+ def validate_supported_schema!(input_schema, output_schema:, tool_name:)
161
+ unless input_schema.is_a?(Hash) && input_schema["type"] == "object"
162
+ raise Phronomy::ToolError,
163
+ "MCP tool #{tool_name.inspect} must use an object input schema"
164
+ end
165
+
166
+ dialect = input_schema["$schema"]
167
+ if dialect && !SUPPORTED_SCHEMA_DIALECTS.include?(dialect)
168
+ raise Phronomy::ToolError,
169
+ "MCP tool #{tool_name.inspect} uses unsupported JSON Schema dialect #{dialect.inspect}"
170
+ end
171
+
172
+ unknown_root = input_schema.keys - SUPPORTED_ROOT_KEYS
173
+ if unknown_root.any?
174
+ raise Phronomy::ToolError,
175
+ "MCP tool #{tool_name.inspect} uses unsupported root schema keywords: " \
176
+ "#{unknown_root.join(", ")}"
177
+ end
178
+
179
+ additional_properties = input_schema["additionalProperties"]
180
+ unless additional_properties.nil? || additional_properties == false
181
+ raise Phronomy::ToolError,
182
+ "MCP tool #{tool_name.inspect} uses additionalProperties: " \
183
+ "#{additional_properties.inspect} (only false or omission is supported)"
184
+ end
185
+
186
+ properties = input_schema["properties"] || {}
187
+ unless properties.is_a?(Hash)
188
+ raise Phronomy::ToolError,
189
+ "MCP tool #{tool_name.inspect} has an invalid properties schema"
190
+ end
191
+
192
+ required_names = input_schema["required"] || []
193
+ unless required_names.is_a?(Array) && required_names.all? { |name| name.is_a?(String) }
194
+ raise Phronomy::ToolError,
195
+ "MCP tool #{tool_name.inspect} has an invalid required list"
196
+ end
197
+
198
+ unknown_required = required_names - properties.keys
199
+ if unknown_required.any?
200
+ raise Phronomy::ToolError,
201
+ "MCP tool #{tool_name.inspect} requires undefined parameters: " \
202
+ "#{unknown_required.inspect}"
203
+ end
204
+
205
+ properties.each do |name, schema|
206
+ validate_property_schema!(tool_name, name, schema)
207
+ end
208
+
209
+ if output_schema
210
+ warn_mcp(
211
+ "[Phronomy] MCP tool '#{tool_name}' has an output schema; " \
212
+ "Phronomy does not yet use it for validation"
213
+ )
214
+ end
215
+ end
216
+
217
+ def validate_property_schema!(tool_name, name, schema)
218
+ unless name.is_a?(String)
219
+ raise Phronomy::ToolError,
220
+ "MCP tool #{tool_name.inspect} has a non-string property key: #{name.inspect}"
221
+ end
222
+ unless schema.is_a?(Hash)
223
+ raise Phronomy::ToolError,
224
+ "MCP parameter #{name.inspect} must have an object schema"
225
+ end
226
+
227
+ type = schema["type"]
228
+ unless type.is_a?(String) && SUPPORTED_TYPES.include?(type)
229
+ raise Phronomy::ToolError,
230
+ "MCP parameter #{name.inspect} uses unsupported type #{type.inspect}"
231
+ end
232
+
233
+ if schema.key?("enum")
234
+ enum = schema["enum"]
235
+ unless enum.is_a?(Array)
236
+ raise Phronomy::ToolError,
237
+ "MCP parameter #{name.inspect} has an invalid enum (must be an Array)"
238
+ end
239
+ validate_enum_values!(type, enum, tool_name: tool_name, parameter_name: name)
240
+ end
241
+
242
+ ignored = IGNORED_PROPERTY_KEYS.select { |key| schema.key?(key) }
243
+ if ignored.any?
244
+ warn_mcp(
245
+ "[Phronomy] MCP tool '#{tool_name}' parameter '#{name}' has " \
246
+ "constraint keywords #{ignored.inspect}; they will be ignored"
247
+ )
248
+ end
249
+
250
+ unknown_property = schema.keys - SUPPORTED_PROPERTY_KEYS - IGNORED_PROPERTY_KEYS
251
+ if unknown_property.any?
252
+ raise Phronomy::ToolError,
253
+ "MCP parameter #{name.inspect} uses unsupported schema keywords: " \
254
+ "#{unknown_property.join(", ")}"
255
+ end
256
+ end
257
+
258
+ def validate_enum_values!(type, values, tool_name:, parameter_name:)
259
+ valid = values.all? do |value|
260
+ case type
261
+ when "string" then value.is_a?(String)
262
+ when "integer" then value.is_a?(Integer)
263
+ when "number" then value.is_a?(Numeric)
264
+ when "boolean" then value == true || value == false
265
+ end
266
+ end
267
+ return if valid
268
+
269
+ raise Phronomy::ToolError,
270
+ "MCP tool #{tool_name.inspect} parameter #{parameter_name.inspect} " \
271
+ "has enum values incompatible with #{type}"
272
+ end
273
+
274
+ def warn_mcp(message)
275
+ if Phronomy.configuration.logger
276
+ Phronomy.configuration.logger.warn(message)
277
+ else
278
+ Kernel.warn(message)
162
279
  end
163
280
  end
281
+
282
+ def close_transport_safely(transport)
283
+ transport&.close
284
+ rescue
285
+ nil
286
+ end
287
+ end
288
+
289
+ # @api private
290
+ def initialize
291
+ @mcp_call_mutex = Mutex.new
292
+ @mcp_client = nil
293
+ build_and_connect_client!
294
+ end
295
+
296
+ # Executes the remote MCP tool.
297
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
298
+ # @return [String, Array, Hash]
299
+ # @api public
300
+ def execute(cancellation_token: nil, **args)
301
+ @mcp_call_mutex.synchronize do
302
+ ensure_mcp_client!
303
+ perform_mcp_call(cancellation_token: cancellation_token, args: args)
304
+ end
305
+ end
306
+
307
+ # Closes the currently connected client synchronously. A transport already
308
+ # detached after cancellation is owned by the Runtime cleanup pool and is
309
+ # drained during Runtime shutdown; this method does not wait for that older
310
+ # cleanup operation.
311
+ #
312
+ # The instance can be used again after close; the next call reconnects.
313
+ # @return [void]
314
+ # @api public
315
+ def close
316
+ @mcp_call_mutex.synchronize { invalidate_mcp_client! }
317
+ end
318
+
319
+ private
320
+
321
+ def perform_mcp_call(cancellation_token:, args:)
322
+ mcp_cancellation = build_mcp_cancellation(cancellation_token)
323
+ response = begin
324
+ @mcp_client.call_tool(
325
+ name: self.class.tool_name,
326
+ arguments: args.transform_keys(&:to_s),
327
+ cancellation: mcp_cancellation
328
+ )
329
+ rescue MCP::CancelledError => e
330
+ invalidate_mcp_client_after_cancellation!
331
+ message = "MCP tool call was cancelled"
332
+ message += ": #{e.reason}" if e.respond_to?(:reason) && e.reason
333
+ raise Phronomy::CancellationError, message
334
+ rescue MCP::Client::SessionExpiredError
335
+ recover_expired_session!
336
+ rescue MCP::Client::ServerError => e
337
+ raise Phronomy::ToolError,
338
+ "MCP server returned error (#{e.code}): #{e.message}"
339
+ rescue MCP::Client::InputRequiredError => e
340
+ raise Phronomy::ToolError,
341
+ "MCP tool requires unsupported multi-round-trip input: #{e.message}"
342
+ rescue MCP::Client::ValidationError => e
343
+ raise Phronomy::ToolError,
344
+ "MCP response validation failed: #{e.message}"
345
+ rescue MCP::Client::RequestHandlerError => e
346
+ raise Phronomy::ToolError,
347
+ "MCP request handler failed: #{e.message}"
348
+ rescue => e
349
+ raise Phronomy::ToolError, "MCP call failed: #{e.message}"
350
+ end
351
+
352
+ result = validate_call_tool_response!(response)
353
+ format_tool_result(result)
354
+ end
355
+
356
+ def build_mcp_cancellation(cancellation_token)
357
+ return nil unless cancellation_token
358
+
359
+ mcp_cancellation = MCP::Cancellation.new
360
+ cancellation_token.on_cancel do
361
+ mcp_cancellation.cancel(reason: "phronomy_cancelled")
362
+ end
363
+ mcp_cancellation
364
+ end
365
+
366
+ def recover_expired_session!
367
+ begin
368
+ @mcp_client.connect
369
+ rescue => reconnect_error
370
+ invalidate_mcp_client!
371
+ raise Phronomy::ToolError,
372
+ "MCP session expired and reconnection failed: #{reconnect_error.message}"
373
+ end
374
+
375
+ raise Phronomy::ToolError,
376
+ "MCP session expired; the connection was restored, but the tool call was not replayed"
377
+ end
378
+
379
+ def validate_call_tool_response!(response)
380
+ unless response.is_a?(Hash)
381
+ raise Phronomy::ToolError, "MCP tool returned a non-object response"
382
+ end
383
+
384
+ result = response["result"]
385
+ unless result.is_a?(Hash)
386
+ raise Phronomy::ToolError, "MCP tool response is missing a valid result"
387
+ end
388
+
389
+ content = result["content"]
390
+ unless content.is_a?(Array)
391
+ raise Phronomy::ToolError, "MCP tool result is missing valid content"
392
+ end
393
+ unless content.all? { |item| item.is_a?(Hash) }
394
+ raise Phronomy::ToolError,
395
+ "MCP tool result contains an invalid content item"
396
+ end
397
+ if result.key?("isError") && result["isError"] != true && result["isError"] != false
398
+ raise Phronomy::ToolError, "MCP tool result has an invalid isError value"
399
+ end
400
+
401
+ result
402
+ end
403
+
404
+ def format_tool_result(result)
405
+ content = result.fetch("content")
406
+ texts = content.filter_map do |item|
407
+ item["text"] if item["type"] == "text" && item["text"].is_a?(String)
408
+ end
409
+
410
+ if result["isError"] == true
411
+ message = texts.join("\n")
412
+ message = JSON.generate(result["structuredContent"] || content) if message.empty?
413
+ return "MCP tool execution error: #{message}"
414
+ end
415
+
416
+ return texts.first if texts.length == 1
417
+ return texts if texts.any?
418
+
419
+ result["structuredContent"] || content
420
+ end
421
+
422
+ def ensure_mcp_client!
423
+ build_and_connect_client! unless @mcp_client
424
+ end
425
+
426
+ def build_and_connect_client!
427
+ transport = nil
428
+ begin
429
+ uri = self.class.instance_variable_get(:@mcp_server_uri)
430
+ headers = self.class.instance_variable_get(:@mcp_headers) || {}
431
+ transport = self.class.send(:build_transport, uri, headers: headers)
432
+ client = MCP::Client.new(transport: transport)
433
+ client.connect
434
+ @mcp_client = client
435
+ rescue => e
436
+ self.class.send(:close_transport_safely, transport)
437
+ raise Phronomy::ToolError, "MCP connection failed: #{e.message}"
438
+ end
439
+ end
440
+
441
+ def invalidate_mcp_client!
442
+ old_client = @mcp_client
443
+ @mcp_client = nil
444
+ self.class.send(:close_transport_safely, old_client&.transport)
445
+ end
446
+
447
+ def invalidate_mcp_client_after_cancellation!
448
+ old_client = @mcp_client
449
+ @mcp_client = nil
450
+ return unless old_client
451
+
452
+ schedule_transport_cleanup(old_client.transport)
453
+ end
454
+
455
+ def schedule_transport_cleanup(transport)
456
+ cleanup_pool = Phronomy::Runtime.instance.pool(
457
+ :mcp_cleanup,
458
+ size: MCP_CLEANUP_POOL_SIZE,
459
+ queue_size: MCP_CLEANUP_QUEUE_SIZE
460
+ )
461
+ cleanup_pool.submit(on_full: :raise) do
462
+ self.class.send(:close_transport_safely, transport)
463
+ end
464
+ rescue Phronomy::BackpressureError, Phronomy::PoolShutdownError
465
+ # During shutdown or an exceptional cleanup burst, prefer a bounded
466
+ # synchronous fallback over leaking the child process/socket.
467
+ self.class.send(:close_transport_safely, transport)
164
468
  end
165
469
  end
166
470
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.13.0"
4
+ VERSION = "0.14.0"
5
5
  end
@@ -76,6 +76,10 @@ module Phronomy
76
76
  # waits for the async worker thread to post a state_completed event back.
77
77
  attr_accessor :async_pending
78
78
 
79
+ # Invocation-local routing. FSMSession sets these on each machine
80
+ # instance; the compiled Class captures no Runtime-specific object.
81
+ attr_accessor :event_loop, :timer_queue_provider, :session_id
82
+
79
83
  state_machine :phase, initial: entry do
80
84
  all_states.each { |s| state s }
81
85
 
@@ -190,16 +194,16 @@ module Phronomy
190
194
  # @api private
191
195
  def dispatch_task_in_event_loop(machine, result, state_name, timeout_secs)
192
196
  machine.async_pending = true
193
- thread_id = machine.context.thread_id
197
+ session_id = machine.session_id || machine.context.thread_id
194
198
  if timeout_secs
195
- Phronomy::Runtime.instance.timer_queue.schedule(seconds: timeout_secs) do
199
+ machine.timer_queue_provider.call.schedule(seconds: timeout_secs) do
196
200
  next if result.done?
197
201
 
198
- Phronomy::EventLoop.instance.post(
202
+ machine.event_loop.post(
199
203
  Phronomy::Event.new(
200
204
  type: :error,
201
205
  target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID,
202
- payload: {session_id: thread_id, result: Phronomy::ActionTimeoutError.new(
206
+ payload: {session_id: session_id, result: Phronomy::ActionTimeoutError.new(
203
207
  "Action in state #{state_name.inspect} timed out after #{timeout_secs}s"
204
208
  )}
205
209
  )
@@ -208,17 +212,17 @@ module Phronomy
208
212
  end
209
213
  result.on_complete do |task_result, error|
210
214
  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})
215
+ machine.event_loop.post(
216
+ Phronomy::Event.new(type: :error, target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID, payload: {session_id: session_id, result: error})
213
217
  )
214
218
  next
215
219
  end
216
220
  ev = if task_result.is_a?(Phronomy::WorkflowContext)
217
- Phronomy::Event.new(type: :action_completed, target_id: thread_id, payload: task_result)
221
+ Phronomy::Event.new(type: :action_completed, target_id: session_id, payload: task_result)
218
222
  else
219
- Phronomy::Event.new(type: :state_completed, target_id: thread_id, payload: nil)
223
+ Phronomy::Event.new(type: :state_completed, target_id: session_id, payload: nil)
220
224
  end
221
- Phronomy::EventLoop.instance.post(ev)
225
+ machine.event_loop.post(ev)
222
226
  end
223
227
  end
224
228
 
@@ -167,10 +167,9 @@ module Phronomy
167
167
  # @api private
168
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)
169
169
  def _assert_write_permitted!
170
- return unless defined?(Phronomy::EventLoop)
171
170
  # Allow mutations when executing synchronously (e.g. Workflow#stream via run_workflow).
172
171
  return if Thread.current[:phronomy_sync_execution]
173
- return if Phronomy::EventLoop.current?
172
+ return if Phronomy::Runtime.in_event_loop_context?
174
173
 
175
174
  raise Phronomy::WorkflowContextOwnershipError,
176
175
  "WorkflowContext fields may only be mutated from the EventLoop dispatch " \