phronomy 0.7.0 → 0.7.1

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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -7
  3. data/CHANGELOG.md +151 -1
  4. data/README.md +155 -32
  5. data/Rakefile +33 -0
  6. data/benchmark/baseline.json +1 -1
  7. data/benchmark/bench_regression.rb +1 -0
  8. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
  9. data/docs/decisions/006-no-built-in-guardrails.md +20 -2
  10. data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
  11. data/lib/phronomy/agent/base.rb +250 -65
  12. data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
  13. data/lib/phronomy/agent/fsm.rb +41 -64
  14. data/lib/phronomy/agent/orchestrator.rb +146 -121
  15. data/lib/phronomy/agent/parallel_tool_chat.rb +79 -22
  16. data/lib/phronomy/agent/react_agent.rb +8 -0
  17. data/lib/phronomy/async_queue.rb +155 -0
  18. data/lib/phronomy/blocking_adapter_pool.rb +435 -0
  19. data/lib/phronomy/cancellation_scope.rb +123 -0
  20. data/lib/phronomy/cancellation_token.rb +43 -2
  21. data/lib/phronomy/concurrency_gate.rb +155 -0
  22. data/lib/phronomy/configuration.rb +142 -0
  23. data/lib/phronomy/deadline.rb +63 -0
  24. data/lib/phronomy/diagnostics.rb +62 -0
  25. data/lib/phronomy/embeddings/base.rb +17 -0
  26. data/lib/phronomy/eval/runner.rb +9 -9
  27. data/lib/phronomy/event_loop.rb +181 -43
  28. data/lib/phronomy/fsm_session.rb +50 -4
  29. data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
  30. data/lib/phronomy/invocation_context.rb +152 -0
  31. data/lib/phronomy/knowledge_source/base.rb +18 -0
  32. data/lib/phronomy/llm_adapter/base.rb +104 -0
  33. data/lib/phronomy/llm_adapter/ruby_llm.rb +41 -0
  34. data/lib/phronomy/llm_adapter.rb +20 -0
  35. data/lib/phronomy/metrics.rb +38 -0
  36. data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
  37. data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
  38. data/lib/phronomy/runtime/gate_registry.rb +52 -0
  39. data/lib/phronomy/runtime/pool_registry.rb +57 -0
  40. data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
  41. data/lib/phronomy/runtime/scheduler.rb +98 -0
  42. data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
  43. data/lib/phronomy/runtime/task_registry.rb +48 -0
  44. data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
  45. data/lib/phronomy/runtime/timer_queue.rb +106 -0
  46. data/lib/phronomy/runtime/timer_service.rb +42 -0
  47. data/lib/phronomy/runtime.rb +374 -0
  48. data/lib/phronomy/task/backend.rb +80 -0
  49. data/lib/phronomy/task/fiber_backend.rb +157 -0
  50. data/lib/phronomy/task/immediate_backend.rb +89 -0
  51. data/lib/phronomy/task/thread_backend.rb +84 -0
  52. data/lib/phronomy/task.rb +275 -0
  53. data/lib/phronomy/task_group.rb +265 -0
  54. data/lib/phronomy/testing/fake_clock.rb +109 -0
  55. data/lib/phronomy/testing/fake_scheduler.rb +104 -0
  56. data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
  57. data/lib/phronomy/testing.rb +12 -0
  58. data/lib/phronomy/tool/base.rb +110 -2
  59. data/lib/phronomy/tool/mcp_tool.rb +47 -16
  60. data/lib/phronomy/tool/scope_policy.rb +50 -0
  61. data/lib/phronomy/tool_executor.rb +106 -0
  62. data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
  63. data/lib/phronomy/vector_store/async_backend.rb +110 -0
  64. data/lib/phronomy/vector_store/base.rb +7 -0
  65. data/lib/phronomy/version.rb +1 -1
  66. data/lib/phronomy/workflow.rb +52 -5
  67. data/lib/phronomy/workflow_context.rb +29 -2
  68. data/lib/phronomy/workflow_runner.rb +74 -3
  69. data/lib/phronomy.rb +42 -0
  70. metadata +40 -2
@@ -5,7 +5,6 @@ require "net/http"
5
5
  require "open3"
6
6
  require "securerandom"
7
7
  require "shellwords"
8
- require "timeout"
9
8
  require "uri"
10
9
 
11
10
  module Phronomy
@@ -67,7 +66,7 @@ module Phronomy
67
66
 
68
67
  def build_tool_class(tool_name, server_uri, tool_def)
69
68
  klass = Class.new(McpTool)
70
- klass.instance_variable_set(:@mcp_tool_name, tool_name)
69
+ klass.tool_name(tool_name)
71
70
  klass.instance_variable_set(:@mcp_server_uri, server_uri)
72
71
 
73
72
  # Register description and params from the MCP tool definition.
@@ -118,7 +117,9 @@ module Phronomy
118
117
  # {HttpTransport}. Defaults to 30 seconds.
119
118
  # @param env [Hash, nil] environment variable overrides for the subprocess.
120
119
  # When provided, only these variables are added/overridden; the parent environment
121
- # is still inherited unless explicitly cleared via an empty string value.
120
+ # is still inherited. Use +nil+ as a value to unset a variable in the child process
121
+ # (e.g. +{ "SECRET" => nil }+). An empty string value (+""+ ) sets the variable to
122
+ # an empty string — it does NOT unset it.
122
123
  # @param cwd [String, nil] working directory for the subprocess.
123
124
  # Defaults to the current process's working directory.
124
125
  # @param startup_timeout [Numeric, nil] seconds to wait for the server to
@@ -138,6 +139,7 @@ module Phronomy
138
139
  @stderr = nil
139
140
  @wait_thr = nil
140
141
  @stderr_thread = nil
142
+ @stderr_op = nil
141
143
  end
142
144
 
143
145
  # Shut down the child process and close its IO streams.
@@ -149,10 +151,17 @@ module Phronomy
149
151
  @stdout = nil
150
152
  @stderr = nil
151
153
  stderr_thread = @stderr_thread
154
+ stderr_op = @stderr_op
152
155
  wait_thr = @wait_thr
153
156
  @stderr_thread = nil
157
+ @stderr_op = nil
154
158
  @wait_thr = nil
155
159
  stderr_thread&.join(1)
160
+ begin
161
+ stderr_op&.await(timeout: 1.0)
162
+ rescue
163
+ nil
164
+ end
156
165
  wait_thr&.join(5)
157
166
  end
158
167
 
@@ -209,31 +218,53 @@ module Phronomy
209
218
  # Drain stderr asynchronously to prevent the pipe buffer from filling
210
219
  # and deadlocking the child process. Errors inside the drain thread are
211
220
  # silently ignored since stderr content is diagnostics-only.
212
- @stderr_thread = Thread.new do
213
- @stderr.read
214
- rescue
215
- nil
221
+ #
222
+ # Prefer BlockingAdapterPool when a Runtime is configured so that this
223
+ # file eventually needs no direct Thread.new (Issue #360). Fall back to
224
+ # Thread.new when no pool is available (no EventLoop / bare invocation).
225
+ pool = begin; Phronomy::Runtime.instance&.blocking_io; rescue; nil; end
226
+ if pool
227
+ @stderr_op = pool.submit {
228
+ begin
229
+ @stderr.read
230
+ rescue
231
+ nil
232
+ end
233
+ }
234
+ @stderr_thread = nil
235
+ else
236
+ @stderr_thread = Thread.new {
237
+ begin
238
+ @stderr.read
239
+ rescue
240
+ nil
241
+ end
242
+ }
243
+ @stderr_op = nil
216
244
  end
217
245
 
218
246
  if @startup_timeout
219
- Timeout.timeout(@startup_timeout) { @stdout.gets.tap { |line| @stdout.ungetbyte(line) if line } }
247
+ unless IO.select([@stdout], nil, nil, @startup_timeout)
248
+ close
249
+ raise Phronomy::ToolError,
250
+ "MCP stdio server did not start within #{@startup_timeout} seconds"
251
+ end
252
+ line = @stdout.gets
253
+ @stdout.ungetbyte(line) if line
220
254
  end
221
- rescue Timeout::Error
222
- close
223
- raise Phronomy::ToolError,
224
- "MCP stdio server did not start within #{@startup_timeout} seconds"
225
255
  end
226
256
 
227
257
  def rpc_call(method, params)
228
258
  ensure_started!
229
259
  payload = JSON.generate(jsonrpc: "2.0", id: SecureRandom.uuid, method: method, params: params)
230
260
  @stdin.puts(payload)
231
- raw = Timeout.timeout(@read_timeout) { @stdout.gets }
261
+ unless IO.select([@stdout], nil, nil, @read_timeout)
262
+ raise Phronomy::ToolError,
263
+ "MCP stdio server did not respond within #{@read_timeout} seconds"
264
+ end
265
+ raw = @stdout.gets
232
266
  raise Phronomy::ToolError, "MCP server closed the connection unexpectedly" if raw.nil?
233
267
  JSON.parse(raw)
234
- rescue Timeout::Error
235
- raise Phronomy::ToolError,
236
- "MCP stdio server did not respond within #{@read_timeout} seconds"
237
268
  end
238
269
 
239
270
  def parse_schema_params(properties, required_names: [])
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Tool
5
+ # Evaluates whether a tool with a given scope may execute.
6
+ #
7
+ # A ScopePolicy is a callable that receives +(tool_class, scope, agent)+ and
8
+ # returns one of:
9
+ # +:allow+ — proceed immediately without an approval gate.
10
+ # +:reject+ — block execution; the tool returns a denial message.
11
+ # +:approve+ — delegate to the agent's approval handler (if registered);
12
+ # when no handler is registered the call is rejected.
13
+ #
14
+ # The {Default} instance is used automatically when no custom policy is
15
+ # configured on an agent.
16
+ #
17
+ # @example Custom policy that allows everything
18
+ # agent.scope_policy = ->(_tool_class, _scope, _agent) { :allow }
19
+ #
20
+ # @example Strict policy that rejects all write scopes
21
+ # agent.scope_policy = ->(_tc, scope, _agent) {
22
+ # scope == :write ? :reject : :allow
23
+ # }
24
+ class ScopePolicy
25
+ # Scopes that must go through an approval gate before execution.
26
+ APPROVAL_REQUIRED_SCOPES = %i[write admin external_network filesystem process external_process].freeze
27
+
28
+ # Scopes that are always permitted without approval.
29
+ ALWAYS_ALLOWED_SCOPES = %i[read_only].freeze
30
+
31
+ # Returns +:allow+ for always-allowed scopes, +:approve+ for high-risk
32
+ # scopes, and +:allow+ for anything else (including +nil+).
33
+ #
34
+ # @param _tool_class [Class]
35
+ # @param scope [Symbol, nil]
36
+ # @param _agent [Object]
37
+ # @return [:allow, :approve, :reject]
38
+ # @api private
39
+ def call(_tool_class, scope, _agent)
40
+ return :allow if scope.nil? || ALWAYS_ALLOWED_SCOPES.include?(scope)
41
+ return :approve if APPROVAL_REQUIRED_SCOPES.include?(scope)
42
+
43
+ :allow
44
+ end
45
+
46
+ # Shared singleton used when no custom policy is configured.
47
+ DEFAULT = new.freeze
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # Centralises tool execution routing based on {Tool::Base.execution_mode}.
5
+ #
6
+ # This is the single place in the framework that decides *how* a tool call is
7
+ # dispatched:
8
+ #
9
+ # - +:cooperative+ — dispatched via +Runtime#spawn+ through the configured
10
+ # scheduler. Under the +:fiber+ backend this avoids an
11
+ # extra OS thread; under the +:thread+ backend it is
12
+ # backed by +ThreadScheduler+ (one thread per task).
13
+ # - +:blocking_io+ — submitted to +BlockingAdapterPool+ when the runtime
14
+ # provides a pool; falls back to +Runtime#spawn+ otherwise.
15
+ # - +:cpu_bound+ — emits a deprecation-style warning then falls back to
16
+ # +:blocking_io+ routing (no process pool available yet).
17
+ # - +:external_process+ — falls back to +:blocking_io+ routing (no process
18
+ # manager available yet).
19
+ #
20
+ # All paths return an object that responds to +#await+ (+Phronomy::Task+ or
21
+ # +BlockingAdapterPool::PendingOperation+), so callers can collect results
22
+ # uniformly.
23
+ #
24
+ # @note Non-goals
25
+ # ToolExecutor deliberately does NOT provide:
26
+ # - A CPU-bound process pool. CPU-intensive tool work must be handled at the
27
+ # application layer (e.g., fork, Sidekiq, separate OS processes). The
28
+ # framework will not add a +ProcessPoolExecutor+ equivalent.
29
+ # - An external process manager. Spawning or supervising subprocesses is
30
+ # out of scope for this module.
31
+ # - Additional core execution routes beyond scheduler-backed cooperative
32
+ # execution and BlockingAdapterPool-backed blocking I/O isolation.
33
+ # The +:cpu_bound+ and +:external_process+ modes are accepted for
34
+ # compatibility but both fall back to +:blocking_io+ routing with a
35
+ # one-time warning. If a genuinely new core execution route is needed,
36
+ # a new ADR is required.
37
+ # These non-goals follow from the cooperative-first, non-preemptive
38
+ # concurrency model (ADR-010): framework components must not assume the
39
+ # caller's concurrency model, and CPU/process management belongs to the
40
+ # application layer.
41
+ #
42
+ # @api private
43
+ module ToolExecutor
44
+ # Tracks tool classes that have already emitted an execution_mode warning so
45
+ # that the same warning is only logged once per process lifetime.
46
+ WARNED_MODES = Set.new
47
+ WARNED_MODES_MUTEX = Mutex.new
48
+ private_constant :WARNED_MODES, :WARNED_MODES_MUTEX
49
+
50
+ # Dispatches a single tool call asynchronously according to its
51
+ # +execution_mode+ and returns an awaitable.
52
+ #
53
+ # @param tool [Phronomy::Tool::Base] the tool instance to invoke
54
+ # @param args [Hash] argument hash to pass to {Tool::Base#call}
55
+ # @param cancellation_token [Phronomy::CancellationToken, nil]
56
+ # @param runtime [Phronomy::Runtime] runtime to use for spawning
57
+ # (defaults to {Runtime.instance}; injectable for tests)
58
+ # @return [#await] a {Phronomy::Task} or {BlockingAdapterPool::PendingOperation}
59
+ # @api private
60
+ def self.call_async(tool:, args:, cancellation_token: nil, runtime: Phronomy::Runtime.instance)
61
+ ct = cancellation_token
62
+ mode = tool.class.execution_mode
63
+
64
+ # Warn and normalise unsupported modes to :blocking_io.
65
+ # Each (tool class, mode) pair emits the warning at most once per process
66
+ # lifetime to avoid log flooding in high-throughput scenarios.
67
+ if mode == :cpu_bound || mode == :external_process
68
+ warn_key = [tool.class.name, mode]
69
+ newly_warned = WARNED_MODES_MUTEX.synchronize { WARNED_MODES.add?(warn_key) }
70
+ if newly_warned
71
+ msg = if mode == :cpu_bound
72
+ "[Phronomy] Tool #{tool.class.name} declares execution_mode :cpu_bound, " \
73
+ "which has no dedicated executor. " \
74
+ "Falling back to blocking_io (BlockingAdapterPool). " \
75
+ "Use :blocking_io explicitly to suppress this warning."
76
+ else
77
+ "[Phronomy] Tool #{tool.class.name} declares execution_mode :external_process, " \
78
+ "which has no dedicated process manager. " \
79
+ "Falling back to blocking_io (BlockingAdapterPool)."
80
+ end
81
+ if Phronomy.configuration.logger
82
+ Phronomy.configuration.logger.warn(msg)
83
+ else
84
+ warn msg
85
+ end
86
+ end
87
+ mode = :blocking_io
88
+ end
89
+
90
+ pool = begin
91
+ runtime&.blocking_io
92
+ rescue
93
+ nil
94
+ end
95
+
96
+ if mode == :cooperative || pool.nil?
97
+ runtime.spawn(name: "tool-#{tool.class.name.to_s.split("::").last}") do
98
+ tool.call(args, cancellation_token: ct)
99
+ end
100
+ else
101
+ # Submit directly to pool — no wrapping Task thread required.
102
+ pool.submit(cancellation_token: ct) { tool.call(args, cancellation_token: ct) }
103
+ end
104
+ end
105
+ end
106
+ end
@@ -52,6 +52,40 @@ module Phronomy
52
52
  end
53
53
  span.finish
54
54
  end
55
+
56
+ # Overrides Base#trace to use OTel +in_span+, which pushes the span onto
57
+ # the OTel Context stack while the block runs. Nested +trace+ calls
58
+ # automatically inherit the current span as their parent, establishing the
59
+ # correct parent/child hierarchy in the trace tree.
60
+ #
61
+ # @param name [String] span name
62
+ # @param input [Object, nil] input to record as a span attribute
63
+ # @param meta [Hash] additional metadata (e.g. task_id, user_id)
64
+ # @yield [span] the active OTel span
65
+ # @return [Object] the block's return value
66
+ # @api private
67
+ def trace(name, input: nil, **meta)
68
+ attrs = {}
69
+ attrs["phronomy.input"] = input.to_s if input
70
+ meta.each { |k, v| attrs["phronomy.#{k}"] = v.to_s unless v.nil? }
71
+
72
+ result = nil
73
+ @otel_tracer.in_span(name, attributes: attrs) do |span|
74
+ result, usage = yield span
75
+ span.set_attribute("phronomy.output", result.to_s) if result
76
+ if usage
77
+ span.set_attribute("llm.usage.input_tokens", usage.input)
78
+ span.set_attribute("llm.usage.output_tokens", usage.output)
79
+ total = (usage.input || 0) + (usage.output || 0)
80
+ span.set_attribute("llm.usage.total_tokens", total)
81
+ end
82
+ rescue => e
83
+ span.record_exception(e)
84
+ span.status = OpenTelemetry::Trace::Status.error(e.message)
85
+ raise
86
+ end
87
+ result
88
+ end
55
89
  end
56
90
  end
57
91
  end
@@ -0,0 +1,110 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module VectorStore
5
+ # Mixin that defines the async interface for VectorStore backends.
6
+ #
7
+ # Mixing this module into a VectorStore class provides three choices:
8
+ #
9
+ # 1. **Do nothing** — inherits default implementations from {VectorStore::Base}
10
+ # that route through {BlockingAdapterPool} (the previous behaviour).
11
+ #
12
+ # 2. **Override selectively** — override only the async methods where the
13
+ # backend has a native async driver, while the remaining methods fall back
14
+ # to the pool.
15
+ #
16
+ # 3. **Implement all natively** — override all async methods to avoid pool
17
+ # allocation entirely.
18
+ #
19
+ # @example Native async search (no pool worker thread allocated)
20
+ # class MyFastStore < Phronomy::VectorStore::Base
21
+ # include Phronomy::VectorStore::AsyncBackend
22
+ #
23
+ # def search_async(query_embedding:, k: 5, cancellation_token: nil, timeout: nil)
24
+ # # Returns a PendingOperation backed by a native async driver.
25
+ # native_async_search(query_embedding, k)
26
+ # end
27
+ # end
28
+ #
29
+ # @api public
30
+ module AsyncBackend
31
+ # Async variant of {VectorStore::Base#add}.
32
+ #
33
+ # Submits the add call to {BlockingAdapterPool} by default.
34
+ # Override to use a native async driver.
35
+ #
36
+ # @param id [String]
37
+ # @param embedding [Array<Float>]
38
+ # @param metadata [Hash]
39
+ # @param cancellation_token [Phronomy::CancellationToken, nil]
40
+ # @param timeout [Numeric, nil]
41
+ # @return [BlockingAdapterPool::PendingOperation]
42
+ # @api public
43
+ def add_async(id:, embedding:, metadata: {}, cancellation_token: nil, timeout: nil)
44
+ Phronomy::Runtime.instance.blocking_io.submit(
45
+ timeout: timeout,
46
+ cancellation_token: cancellation_token
47
+ ) do
48
+ add(id: id, embedding: embedding, metadata: metadata, cancellation_token: cancellation_token)
49
+ end
50
+ end
51
+
52
+ # Async variant of {VectorStore::Base#search}.
53
+ #
54
+ # Submits the search call to {BlockingAdapterPool} by default.
55
+ # Override to use a native async driver.
56
+ #
57
+ # @param query_embedding [Array<Float>]
58
+ # @param k [Integer]
59
+ # @param cancellation_token [Phronomy::CancellationToken, nil]
60
+ # @param timeout [Numeric, nil]
61
+ # @return [BlockingAdapterPool::PendingOperation]
62
+ # @api public
63
+ def search_async(query_embedding:, k: 5, cancellation_token: nil, timeout: nil)
64
+ Phronomy::Runtime.instance.blocking_io.submit(
65
+ timeout: timeout,
66
+ cancellation_token: cancellation_token
67
+ ) do
68
+ search(query_embedding: query_embedding, k: k, cancellation_token: cancellation_token)
69
+ end
70
+ end
71
+
72
+ # Async variant of {VectorStore::Base#remove}.
73
+ #
74
+ # Submits the remove call to {BlockingAdapterPool} by default.
75
+ # Override to use a native async driver.
76
+ #
77
+ # @param id [String]
78
+ # @param cancellation_token [Phronomy::CancellationToken, nil]
79
+ # @param timeout [Numeric, nil]
80
+ # @return [BlockingAdapterPool::PendingOperation]
81
+ # @api public
82
+ def remove_async(id:, cancellation_token: nil, timeout: nil)
83
+ Phronomy::Runtime.instance.blocking_io.submit(
84
+ timeout: timeout,
85
+ cancellation_token: cancellation_token
86
+ ) do
87
+ remove(id: id)
88
+ end
89
+ end
90
+
91
+ # Async variant of {VectorStore::Base#clear}.
92
+ #
93
+ # Submits the clear call to {BlockingAdapterPool} by default.
94
+ # Override to use a native async driver.
95
+ #
96
+ # @param cancellation_token [Phronomy::CancellationToken, nil]
97
+ # @param timeout [Numeric, nil]
98
+ # @return [BlockingAdapterPool::PendingOperation]
99
+ # @api public
100
+ def clear_async(cancellation_token: nil, timeout: nil)
101
+ Phronomy::Runtime.instance.blocking_io.submit(
102
+ timeout: timeout,
103
+ cancellation_token: cancellation_token
104
+ ) do
105
+ clear
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
@@ -6,7 +6,14 @@ module Phronomy
6
6
  #
7
7
  # Implementations manage a collection of (embedding, metadata) pairs and
8
8
  # support similarity search.
9
+ #
10
+ # Async methods (`search_async`, `add_async`, `remove_async`, `clear_async`)
11
+ # are provided by the {AsyncBackend} mixin which defaults to routing calls
12
+ # through {BlockingAdapterPool}. Backends with native async drivers may
13
+ # override individual async methods without touching the pool at all.
9
14
  class Base
15
+ include AsyncBackend
16
+
10
17
  # Add a document with its vector embedding.
11
18
  #
12
19
  # @param id [String] unique document identifier
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.7.0"
4
+ VERSION = "0.7.1"
5
5
  end
@@ -81,12 +81,35 @@ module Phronomy
81
81
  # Executes the workflow from the initial state.
82
82
  # @param input [Hash] initial context field values
83
83
  # @param config [Hash] { thread_id:, recursion_limit:, user_id:, session_id: }
84
+ # @param invocation_context [Phronomy::InvocationContext, nil] optional first-class context
85
+ # object. When present, +thread_id+, +cancellation_token+, and +deadline+ are
86
+ # derived from it (existing +config:+ keys take precedence). The object is also
87
+ # stored in +config[:invocation_context]+ for downstream tracing.
84
88
  # @return [Object] final context
85
89
  # @api public
86
- def invoke(input, config: {})
90
+ def invoke(input, config: {}, invocation_context: nil)
91
+ if invocation_context
92
+ config = _apply_invocation_context(config, invocation_context)
93
+ end
87
94
  @runner.invoke(input, config: config)
88
95
  end
89
96
 
97
+ # Invokes this workflow asynchronously and returns a {Phronomy::Task}.
98
+ #
99
+ # @param input [Hash]
100
+ # @param config [Hash]
101
+ # @param invocation_context [Phronomy::InvocationContext, nil]
102
+ # @return [Phronomy::Task]
103
+ # @api public
104
+ def invoke_async(input, config: {}, invocation_context: nil)
105
+ if invocation_context
106
+ config = _apply_invocation_context(config, invocation_context)
107
+ end
108
+ Phronomy::Runtime.instance.spawn(name: "workflow-invoke-async") do
109
+ invoke(input, config: config)
110
+ end
111
+ end
112
+
90
113
  # Resumes a halted workflow. Generic resume that works for all halt types.
91
114
  # @param state [Object] halted context
92
115
  # @param input [Hash, nil] optional field updates to merge before resuming
@@ -116,6 +139,23 @@ module Phronomy
116
139
  @runner.stream(input, config: config, &block)
117
140
  end
118
141
 
142
+ private
143
+
144
+ # Merges an {InvocationContext} into the config hash.
145
+ # Existing +config+ keys take precedence (backward-compat).
146
+ def _apply_invocation_context(config, ic)
147
+ effective = config.merge(invocation_context: ic)
148
+ effective = effective.merge(thread_id: ic.thread_id) if effective[:thread_id].nil? && ic.thread_id
149
+ if effective[:cancellation_token].nil?
150
+ if (tok = ic.effective_timeout_token)
151
+ effective = effective.merge(cancellation_token: tok)
152
+ end
153
+ end
154
+ effective
155
+ end
156
+
157
+ public
158
+
119
159
  # ---------------------------------------------------------------------------
120
160
  # Internal DSL builder
121
161
  # ---------------------------------------------------------------------------
@@ -139,6 +179,8 @@ module Phronomy
139
179
  @transitions = []
140
180
  # Set of wait state names
141
181
  @wait_state_names = []
182
+ # { state_name => Numeric } — per-state action timeout in seconds
183
+ @action_timeouts = {}
142
184
  end
143
185
 
144
186
  # Declares the initial (entry) state.
@@ -151,13 +193,17 @@ module Phronomy
151
193
  # rubocop:enable Style/TrivialAccessors
152
194
 
153
195
  # Declares an action state.
154
- # @param name [Symbol] state name
155
- # @param action [#call, nil] optional entry action shorthand.
196
+ # @param name [Symbol] state name
197
+ # @param action [#call, nil] optional entry action shorthand.
156
198
  # +state :generate, action: MY_PROC+ is equivalent to
157
199
  # +state :generate; entry :generate, MY_PROC+.
200
+ # @param action_timeout [Numeric, nil] seconds before an async (Task-returning)
201
+ # entry action is cancelled and {Phronomy::ActionTimeoutError} is raised.
202
+ # Only applies when the action returns a {Task} or {PendingOperation}.
158
203
  # @api public
159
- def state(name, action: nil)
204
+ def state(name, action: nil, action_timeout: nil)
160
205
  @declared_states << name
206
+ @action_timeouts[name] = action_timeout if action_timeout
161
207
  entry(name, action) if action
162
208
  end
163
209
 
@@ -309,7 +355,8 @@ module Phronomy
309
355
  external_events: external_events,
310
356
  entry_point: @initial || @declared_states.first,
311
357
  wait_state_names: @wait_state_names,
312
- state_store: @state_store
358
+ state_store: @state_store,
359
+ action_timeouts: @action_timeouts.dup
313
360
  )
314
361
 
315
362
  Workflow.new(runner)
@@ -42,7 +42,16 @@ module Phronomy
42
42
  end
43
43
 
44
44
  @fields[name] = {type: type, default: default}
45
- attr_accessor name
45
+
46
+ # Define getter.
47
+ attr_reader name
48
+
49
+ # Define write-guarded setter. Mutation from outside the EventLoop
50
+ # dispatch thread raises WorkflowContextOwnershipError in EventLoop mode.
51
+ define_method(:"#{name}=") do |value|
52
+ _assert_write_permitted!
53
+ instance_variable_set(:"@#{name}", value)
54
+ end
46
55
  end
47
56
 
48
57
  def fields
@@ -88,7 +97,9 @@ module Phronomy
88
97
 
89
98
  self.class.fields.each do |name, config|
90
99
  default = config[:default].is_a?(Proc) ? config[:default].call : config[:default]
91
- send(:"#{name}=", attrs.fetch(name, default))
100
+ # Bypass the write guard in initialize — ownership enforcement begins
101
+ # after construction is complete.
102
+ instance_variable_set(:"@#{name}", attrs.fetch(name, default))
92
103
  end
93
104
  @thread_id = nil
94
105
  @phase = :__end__
@@ -142,6 +153,22 @@ module Phronomy
142
153
 
143
154
  private
144
155
 
156
+ # Asserts that the calling thread is allowed to mutate this context.
157
+ # No-op when EventLoop mode is disabled.
158
+ # @raise [Phronomy::WorkflowContextOwnershipError] when called from a
159
+ # non-EventLoop thread in EventLoop mode.
160
+ # @api private
161
+ def _assert_write_permitted!
162
+ return unless defined?(Phronomy::EventLoop) &&
163
+ Phronomy.configuration.event_loop
164
+ return if Phronomy::EventLoop.current?
165
+
166
+ raise Phronomy::WorkflowContextOwnershipError,
167
+ "WorkflowContext fields may only be mutated from the EventLoop dispatch " \
168
+ "thread. Use context.merge(...) to produce a new context, or deliver " \
169
+ "updates as event payloads."
170
+ end
171
+
145
172
  # Performs a deep copy of a value for immutable context propagation.
146
173
  # Arrays and Hashes are deep-duplicated recursively.
147
174
  # Immutable values (nil, Symbol, Integer, Float, true/false, frozen String) are returned as-is.