phronomy 0.28.0 → 0.30.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 (157) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +96 -1
  3. data/README.md +32 -6
  4. data/docs/application-recipes.md +229 -0
  5. data/docs/architecture/agent-configuration-and-tool-binding.md +8 -0
  6. data/docs/architecture/agent-iteration-budget.md +81 -0
  7. data/docs/architecture/dx05-agent-scope.md +126 -0
  8. data/docs/architecture/dx05-workflow-task-state.md +134 -0
  9. data/docs/architecture/error-api-review.md +245 -0
  10. data/docs/architecture/generator-verifier-lifecycle-result.md +126 -0
  11. data/docs/architecture/generator-verifier-ownership.md +18 -109
  12. data/docs/architecture/llm-continuation-metadata.md +88 -0
  13. data/docs/architecture/lm-studio-output-failures.md +111 -0
  14. data/docs/architecture/r8-unit15.md +3 -0
  15. data/docs/architecture/r8-unit7.md +2 -1
  16. data/docs/architecture/structured-output-and-scope.md +183 -0
  17. data/docs/architecture/tracing.md +22 -4
  18. data/docs/architecture/transport-error-boundaries.md +103 -0
  19. data/docs/architecture/trust-acceptance-followup.md +87 -0
  20. data/docs/failure-reason-codes.md +45 -0
  21. data/docs/features.md +14 -2
  22. data/docs/getting-started.md +44 -43
  23. data/docs/migrations/r8-unit15.md +3 -0
  24. data/docs/ordered-event-delivery.md +54 -0
  25. data/docs/workflow-completion.md +92 -0
  26. data/gemfiles/http_minimum.gemfile +10 -0
  27. data/lib/phronomy/agent/admission.rb +1 -1
  28. data/lib/phronomy/agent/agent_not_found_error.rb +16 -0
  29. data/lib/phronomy/agent/agent_unloaded_error.rb +14 -0
  30. data/lib/phronomy/agent/api/agent.rb +13 -0
  31. data/lib/phronomy/agent/async_event_api.rb +121 -88
  32. data/lib/phronomy/agent/base.rb +82 -44
  33. data/lib/phronomy/agent/before_llm_input.rb +7 -1
  34. data/lib/phronomy/agent/context_importer.rb +4 -0
  35. data/lib/phronomy/agent/execution/agent_invocation.rb +6 -2
  36. data/lib/phronomy/agent/execution/dispatch_preparation.rb +6 -1
  37. data/lib/phronomy/agent/execution/exact_execution.rb +20 -9
  38. data/lib/phronomy/agent/execution/execution_cancellation.rb +1 -1
  39. data/lib/phronomy/agent/execution/execution_coordinator.rb +79 -18
  40. data/lib/phronomy/agent/execution/execution_outcome_committer.rb +24 -4
  41. data/lib/phronomy/agent/execution/execution_registry.rb +5 -1
  42. data/lib/phronomy/agent/execution/initial_preparation.rb +11 -5
  43. data/lib/phronomy/agent/execution/invocation_actions.rb +1 -1
  44. data/lib/phronomy/agent/execution/invocation_transitions.rb +4 -4
  45. data/lib/phronomy/agent/execution/iteration_budget.rb +35 -0
  46. data/lib/phronomy/agent/execution/runtime_record_encoder.rb +4 -4
  47. data/lib/phronomy/agent/execution_observation.rb +5 -5
  48. data/lib/phronomy/agent/filterable.rb +21 -15
  49. data/lib/phronomy/agent/lifecycle/lifetime_api.rb +76 -0
  50. data/lib/phronomy/agent/lifecycle/operation_lease.rb +33 -0
  51. data/lib/phronomy/agent/lifecycle/operation_result.rb +53 -0
  52. data/lib/phronomy/agent/lifecycle/ownership_registry.rb +99 -8
  53. data/lib/phronomy/agent/lifecycle/scope.rb +208 -0
  54. data/lib/phronomy/agent/lifecycle/scope_cleanup_error.rb +21 -0
  55. data/lib/phronomy/agent/lifecycle/unload_transition.rb +78 -0
  56. data/lib/phronomy/agent/output_contract.rb +64 -0
  57. data/lib/phronomy/agent/output_error.rb +53 -0
  58. data/lib/phronomy/agent/recovery/recovery_coordinator/continuation.rb +6 -6
  59. data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +8 -7
  60. data/lib/phronomy/agent/recovery/recovery_coordinator/resolution.rb +14 -18
  61. data/lib/phronomy/agent/recovery/recovery_coordinator.rb +6 -10
  62. data/lib/phronomy/agent/recovery/recovery_support.rb +6 -8
  63. data/lib/phronomy/agent/runtime_binding/engine_session_builder.rb +2 -1
  64. data/lib/phronomy/agent/runtime_input.rb +15 -4
  65. data/lib/phronomy/agent/saved_context_reader.rb +2 -1
  66. data/lib/phronomy/agent/store.rb +10 -2
  67. data/lib/phronomy/agent/stream_callback_error.rb +2 -2
  68. data/lib/phronomy/common/error.rb +65 -1
  69. data/lib/phronomy/common/values/immutable.rb +33 -0
  70. data/lib/phronomy/content_store/base.rb +7 -2
  71. data/lib/phronomy/context/assembly.rb +18 -22
  72. data/lib/phronomy/embeddings/authentication_error.rb +11 -0
  73. data/lib/phronomy/embeddings/backends/ruby_llm_embeddings.rb +8 -2
  74. data/lib/phronomy/embeddings/rate_limit_error.rb +11 -0
  75. data/lib/phronomy/engine/fsm_session.rb +16 -1
  76. data/lib/phronomy/execution/execution_cancellation_error.rb +2 -2
  77. data/lib/phronomy/execution/execution_timeout_error.rb +2 -2
  78. data/lib/phronomy/execution/task_result.rb +7 -2
  79. data/lib/phronomy/filter/filter_block_error.rb +2 -2
  80. data/lib/phronomy/generation/generator_verifier/agent_operation.rb +28 -5
  81. data/lib/phronomy/generation/generator_verifier/agent_result_receiver.rb +14 -27
  82. data/lib/phronomy/generation/generator_verifier/approval.rb +30 -0
  83. data/lib/phronomy/generation/generator_verifier/cleanup_error.rb +15 -0
  84. data/lib/phronomy/generation/generator_verifier/invocation.rb +147 -0
  85. data/lib/phronomy/generation/generator_verifier/pipeline_state.rb +16 -0
  86. data/lib/phronomy/generation/generator_verifier/protocol.rb +55 -0
  87. data/lib/phronomy/generation/generator_verifier/result.rb +121 -0
  88. data/lib/phronomy/generation/generator_verifier/unaccepted_result_error.rb +14 -0
  89. data/lib/phronomy/generation/generator_verifier/workflow_builder.rb +13 -11
  90. data/lib/phronomy/generation/generator_verifier.rb +170 -85
  91. data/lib/phronomy/integrations/ordered_event_delivery.rb +208 -0
  92. data/lib/phronomy/llm_adapter/backends/lm_studio.rb +139 -0
  93. data/lib/phronomy/llm_adapter/backends/ruby_llm.rb +134 -12
  94. data/lib/phronomy/llm_adapter/backends/structured_response_capture.rb +41 -0
  95. data/lib/phronomy/llm_adapter/message.rb +4 -0
  96. data/lib/phronomy/llm_adapter/request.rb +17 -2
  97. data/lib/phronomy/llm_adapter/response.rb +11 -4
  98. data/lib/phronomy/llm_adapter/structured_output_unsupported_error.rb +14 -0
  99. data/lib/phronomy/llm_adapter/transport_error.rb +4 -0
  100. data/lib/phronomy/multi_agent/persistence/team_execution_repository.rb +5 -5
  101. data/lib/phronomy/multi_agent/shared_state.rb +1 -0
  102. data/lib/phronomy/multi_agent/team_busy_error.rb +18 -0
  103. data/lib/phronomy/multi_agent/team_coordinator.rb +20 -7
  104. data/lib/phronomy/multi_agent/team_not_found_error.rb +18 -0
  105. data/lib/phronomy/output_parser/json_schema_parser.rb +113 -0
  106. data/lib/phronomy/output_parser/parse_error.rb +10 -1
  107. data/lib/phronomy/runtime_composition/agent_defaults.rb +4 -0
  108. data/lib/phronomy/storage/condition_failed_error.rb +2 -2
  109. data/lib/phronomy/storage/unique_constraint_error.rb +2 -2
  110. data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +1 -1
  111. data/lib/phronomy/tool/base.rb +25 -23
  112. data/lib/phronomy/tracing/automatic.rb +14 -8
  113. data/lib/phronomy/tracing/base.rb +37 -7
  114. data/lib/phronomy/tracing/langfuse_tracer.rb +3 -3
  115. data/lib/phronomy/tracing/observation.rb +2 -1
  116. data/lib/phronomy/tracing/open_telemetry_tracer.rb +7 -32
  117. data/lib/phronomy/version.rb +1 -1
  118. data/lib/phronomy/workflow/execution/workflow.rb +30 -2
  119. data/lib/phronomy/workflow/execution/workflow_execution_environment.rb +1 -0
  120. data/lib/phronomy/workflow/execution/workflow_execution_registry.rb +74 -3
  121. data/lib/phronomy/workflow/execution/workflow_runner.rb +48 -14
  122. data/lib/phronomy/workflow/execution/workflow_task_execution.rb +153 -0
  123. data/lib/phronomy/workflow/execution/workflow_task_reception.rb +72 -0
  124. data/lib/phronomy/workflow/execution/workflow_task_resume_error.rb +14 -0
  125. data/lib/phronomy/workflow/runtime_binding/workflow_engine_environment.rb +26 -3
  126. data/lib/phronomy/workflow/runtime_binding/workflow_phase_machine_builder.rb +3 -1
  127. data/lib/phronomy/workflow/runtime_binding/workflow_task_session.rb +76 -0
  128. data/lib/phronomy.rb +7 -2
  129. data/scripts/api_snapshot.rb +15 -0
  130. data/scripts/check_readme_ruby.rb +24 -29
  131. data/scripts/check_readme_runnable.rb +92 -81
  132. data/scripts/migrate_generator_verifier_result.rb +18 -0
  133. data/sig/phronomy/agent/agent_not_found_error.rbs +6 -0
  134. data/sig/phronomy/agent/agent_unloaded_error.rbs +6 -0
  135. data/sig/phronomy/agent/execution_contract.rbs +4 -3
  136. data/sig/phronomy/agent/stream_callback_error.rbs +8 -0
  137. data/sig/phronomy/agent.rbs +36 -5
  138. data/sig/phronomy/embeddings/base.rbs +6 -0
  139. data/sig/phronomy/execution/execution.rbs +2 -2
  140. data/sig/phronomy/extensions.rbs +8 -2
  141. data/sig/phronomy/filter/filter_block_error.rbs +6 -0
  142. data/sig/phronomy/generation/generator_verifier.rbs +70 -0
  143. data/sig/phronomy/integrations.rbs +14 -0
  144. data/sig/phronomy/llm_adapter/backends/lm_studio.rbs +8 -0
  145. data/sig/phronomy/llm_adapter/backends/ruby_llm.rbs +1 -0
  146. data/sig/phronomy/llm_adapter/values.rbs +8 -2
  147. data/sig/phronomy/multi_agent/team_busy_error.rbs +8 -0
  148. data/sig/phronomy/multi_agent/team_not_found_error.rbs +8 -0
  149. data/sig/phronomy/storage/contracts.rbs +2 -2
  150. data/sig/phronomy/tracing/observation.rbs +1 -1
  151. data/sig/phronomy/workflow/execution/workflow_execution_environment.rbs +1 -0
  152. data/sig/phronomy/workflow.rbs +4 -0
  153. data/sig/phronomy.rbs +6 -0
  154. metadata +103 -5
  155. data/lib/phronomy/generation/generator_verifier/default_parser.rb +0 -27
  156. data/lib/phronomy/generation/low_confidence_error.rb +0 -14
  157. data/lib/phronomy/runtime_composition/generation_defaults.rb +0 -6
@@ -92,9 +92,11 @@ module Phronomy
92
92
  # @return [self]
93
93
  # @api public
94
94
  def add_input_filter(filter)
95
- @_instance_input_filters ||= []
96
- @_instance_input_filters << _resolve_filter(filter)
97
- self
95
+ __with_operation do
96
+ @_instance_input_filters ||= []
97
+ @_instance_input_filters << _resolve_filter(filter)
98
+ self
99
+ end
98
100
  end
99
101
 
100
102
  # Registers an output filter on this instance.
@@ -105,9 +107,11 @@ module Phronomy
105
107
  # @return [self]
106
108
  # @api public
107
109
  def add_output_filter(filter)
108
- @_instance_output_filters ||= []
109
- @_instance_output_filters << _resolve_filter(filter)
110
- self
110
+ __with_operation do
111
+ @_instance_output_filters ||= []
112
+ @_instance_output_filters << _resolve_filter(filter)
113
+ self
114
+ end
111
115
  end
112
116
 
113
117
  # Registers a tool result filter on this instance.
@@ -125,16 +129,18 @@ module Phronomy
125
129
  # @return [self]
126
130
  # @api public
127
131
  def add_tool_result_filter(tool_class_or_filter, filter = nil)
128
- if filter.nil?
129
- # Single-argument form: apply to all tools.
130
- @_instance_tool_result_filters ||= []
131
- @_instance_tool_result_filters << _resolve_filter(tool_class_or_filter)
132
- else
133
- # Two-argument form: scoped to one tool class.
134
- @_scoped_tool_result_filters ||= {}
135
- (@_scoped_tool_result_filters[tool_class_or_filter] ||= []) << _resolve_filter(filter)
132
+ __with_operation do
133
+ if filter.nil?
134
+ # Single-argument form: apply to all tools.
135
+ @_instance_tool_result_filters ||= []
136
+ @_instance_tool_result_filters << _resolve_filter(tool_class_or_filter)
137
+ else
138
+ # Two-argument form: scoped to one tool class.
139
+ @_scoped_tool_result_filters ||= {}
140
+ (@_scoped_tool_result_filters[tool_class_or_filter] ||= []) << _resolve_filter(filter)
141
+ end
142
+ self
136
143
  end
137
- self
138
144
  end
139
145
 
140
146
  private
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # Public admission boundaries. The explicit operation capability belongs to
6
+ # C04, including queued work and domain completion after logical cancellation.
7
+ # @api private
8
+ module LifetimeApi
9
+ # Releases only the Runtime incarnation; durable state and Store stay intact.
10
+ # @return [TaskResult<Boolean>]
11
+ # @api public
12
+ def unload_async
13
+ return Phronomy::TaskResult.completed(false) if @_phronomy_runtime_owner_state == :unloaded
14
+
15
+ __assert_agent_accessible!
16
+ transition, start = __execution_environment.ownership.reserve_unload(self)
17
+ start ? transition.start : transition.result
18
+ rescue => error
19
+ Phronomy::TaskResult.failed(error)
20
+ end
21
+
22
+ # A timeout stops this wait, never the accepted release.
23
+ # @return [Boolean]
24
+ # @api public
25
+ def unload!(timeout: nil)
26
+ _check_event_loop_reentrancy(:unload!, :unload_async)
27
+ unload_async.wait_result(timeout: timeout)
28
+ end
29
+
30
+ private
31
+
32
+ def __with_operation(async: false, read_only: false)
33
+ lease = __acquire_operation!(read_only: read_only)
34
+ followed = false
35
+ value = yield
36
+ if async && lease
37
+ lease.follow(value)
38
+ followed = true
39
+ end
40
+ value
41
+ rescue Phronomy::AgentUnloadedError, Phronomy::AgentBusyError, Phronomy::RuntimeShutdownError => error
42
+ raise unless async
43
+ Phronomy::TaskResult.failed(error)
44
+ ensure
45
+ lease&.release unless followed
46
+ end
47
+
48
+ # Accepted C04 commands carry their admission/operation capability. Their
49
+ # continuations read the snapshot without entering the public admission gate.
50
+ def __root_for_operation
51
+ __assert_agent_accessible!
52
+ @root
53
+ end
54
+
55
+ def __acquire_operation!(read_only: false)
56
+ __assert_agent_accessible!
57
+ return nil if @_phronomy_runtime_owner_state == :constructing
58
+
59
+ __execution_environment.ownership.acquire_operation(self, read_only: read_only)
60
+ end
61
+
62
+ def __mark_unloaded!
63
+ @_phronomy_runtime_owner_state = :unloaded
64
+ @root = nil
65
+ @_phronomy_journal_records = [].freeze
66
+ @_phronomy_event_listener = nil
67
+ @_phronomy_execution_wiring = {}.freeze
68
+ @execution_coordinator = nil
69
+ @tool_approval_policy = @before_llm_input = nil
70
+ @_instance_input_filters = @_instance_output_filters = nil
71
+ @_instance_tool_result_filters = @_scoped_tool_result_filters = nil
72
+ @initial_retention = nil
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # C04 capability for accepted work. Never inferred from the current thread.
6
+ # @api private
7
+ class OperationLease
8
+ def initialize(registry, agent, token)
9
+ @registry, @agent, @token = registry, agent, token
10
+ @mutex = Mutex.new
11
+ end
12
+
13
+ def release
14
+ ownership = @mutex.synchronize do
15
+ next unless @registry
16
+ current = [@registry, @agent, @token]
17
+ @registry = @agent = @token = nil
18
+ current
19
+ end
20
+ ownership&.first&.release_operation(ownership[1], ownership[2])
21
+ end
22
+
23
+ def follow(task)
24
+ if task.respond_to?(:on_physical_complete)
25
+ task.on_physical_complete { release }
26
+ else
27
+ task.on_complete { |_value, _error| release }
28
+ end
29
+ task
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # Logical cancellation of a public handle does not release Agent ownership.
6
+ # Only the domain runner acknowledges the end of its accepted operation.
7
+ # @api private
8
+ class OperationResult < Phronomy::Concurrency::PhysicalCompletionTask
9
+ def initialize(agent:, name:)
10
+ super(name: name)
11
+ @operation_lease = agent.send(:__acquire_operation!)
12
+ @lifetime_mutex = Mutex.new
13
+ @work = 0
14
+ @finished = false
15
+ @released = false
16
+ end
17
+
18
+ def finish_operation!
19
+ @lifetime_mutex.synchronize { @finished = true }
20
+ release_if_quiet
21
+ end
22
+
23
+ def track(task)
24
+ @lifetime_mutex.synchronize { @work += 1 }
25
+ task.on_physical_complete do
26
+ @lifetime_mutex.synchronize { @work -= 1 }
27
+ release_if_quiet
28
+ end
29
+ task
30
+ end
31
+
32
+ def settle(value = nil, error: nil)
33
+ finish_operation!
34
+ error ? fail(error) : complete(value)
35
+ end
36
+
37
+ private
38
+
39
+ def release_if_quiet
40
+ released = @lifetime_mutex.synchronize do
41
+ next unless @finished && @work.zero? && !@released
42
+ @released = true
43
+ current = @operation_lease
44
+ @operation_lease = nil
45
+ [current]
46
+ end
47
+ return unless released
48
+ released.first&.release
49
+ mark_physical_complete!
50
+ end
51
+ end
52
+ end
53
+ end
@@ -25,12 +25,14 @@ module Phronomy
25
25
  @mutex = Mutex.new
26
26
  @condition = ConditionVariable.new
27
27
  @entries = {}
28
+ @scope_owners = {}
29
+ @operations = {}
28
30
  @state = :running
29
31
  end
30
32
 
31
- def create(agent_id, expected_class:)
33
+ def create(agent_id, expected_class:, scope_owner: nil)
32
34
  key = normalize_agent_id(agent_id)
33
- token = reserve_create!(key)
35
+ token = reserve_create!(key, scope_owner: scope_owner)
34
36
 
35
37
  begin
36
38
  agent = yield(@environment)
@@ -59,7 +61,7 @@ module Phronomy
59
61
  )
60
62
  end
61
63
 
62
- def load(agent_id, expected_class:)
64
+ def load(agent_id, expected_class:, scope_owner: nil)
63
65
  key = normalize_agent_id(agent_id)
64
66
  token = nil
65
67
 
@@ -67,9 +69,14 @@ module Phronomy
67
69
  existing = @mutex.synchronize do
68
70
  ensure_running!
69
71
  entry = @entries[key]
72
+ if scope_owner && entry && !@scope_owners[key].equal?(scope_owner)
73
+ raise Phronomy::AgentBusyError, "Agent #{key.inspect} belongs to another live owner"
74
+ end
70
75
  case entry&.state
71
76
  when :live
72
77
  entry.agent
78
+ when :unloading
79
+ raise Phronomy::AgentBusyError, "Agent #{key.inspect} is unloading"
73
80
  when :constructing, :purging
74
81
  @condition.wait(@mutex)
75
82
  :retry
@@ -78,6 +85,7 @@ module Phronomy
78
85
  when nil
79
86
  token = Object.new.freeze
80
87
  @entries[key] = Entry.new(state: :constructing, agent: nil, token: token)
88
+ @scope_owners[key] = scope_owner if scope_owner
81
89
  nil
82
90
  # :nocov:
83
91
  else
@@ -119,8 +127,8 @@ module Phronomy
119
127
  when :recovery_required
120
128
  raise ownership_recovery_error(key)
121
129
  # :nocov:
122
- when :purging
123
- raise Phronomy::Error, "Agent #{key.inspect} is being purged"
130
+ when :purging, :unloading
131
+ raise Phronomy::AgentBusyError, "Agent #{key.inspect} is #{entry.state}"
124
132
  # :cover:
125
133
  end
126
134
  end
@@ -129,13 +137,77 @@ module Phronomy
129
137
  validate_expected_class!(agent, expected_class, key)
130
138
  end
131
139
 
140
+ # Admission and lifetime transitions share this mutex. No Runtime or Store
141
+ # operation is called while it is held.
142
+ def acquire_operation(agent, read_only: false)
143
+ token = Object.new.freeze
144
+ @mutex.synchronize do
145
+ ensure_running! unless read_only && @state == :draining
146
+ entry = @entries[agent.agent_id]
147
+ if entry&.agent&.equal?(agent) && entry.state == :unloading
148
+ raise Phronomy::AgentBusyError, "Agent #{agent.agent_id.inspect} is unloading"
149
+ end
150
+ unless entry&.state == :live && entry.agent.equal?(agent)
151
+ raise Phronomy::RuntimeShutdownError, "Agent is no longer the live Runtime owner"
152
+ end
153
+ (@operations[agent] ||= {})[token] = true
154
+ end
155
+ OperationLease.new(self, agent, token)
156
+ end
157
+
158
+ def release_operation(agent, token)
159
+ @mutex.synchronize do
160
+ operations = @operations[agent]
161
+ operations&.delete(token)
162
+ @operations.delete(agent) if operations && operations.empty?
163
+ @condition.broadcast
164
+ end
165
+ true
166
+ end
167
+
168
+ # Returns the already accepted transition for duplicate callers.
169
+ def reserve_unload(agent)
170
+ @mutex.synchronize do
171
+ ensure_running!
172
+ entry = @entries[agent.agent_id]
173
+ if entry&.state == :unloading && entry.agent.equal?(agent)
174
+ return [entry.token, false]
175
+ end
176
+ unless entry&.state == :live && entry.agent.equal?(agent)
177
+ raise Phronomy::RuntimeShutdownError, "Agent is no longer the live Runtime owner"
178
+ end
179
+ assert_no_operations!(agent)
180
+ transition = UnloadTransition.new(agent: agent, environment: @environment, ownership: self)
181
+ @entries[agent.agent_id] = Entry.new(state: :unloading, agent: agent, token: transition)
182
+ [transition, true]
183
+ end
184
+ end
185
+
186
+ def finish_unload(agent, transition, success:)
187
+ @mutex.synchronize do
188
+ entry = @entries[agent.agent_id]
189
+ unless entry&.state == :unloading && entry.agent.equal?(agent) && entry.token.equal?(transition)
190
+ raise Phronomy::RuntimeShutdownError, "Agent unload reservation is no longer authoritative"
191
+ end
192
+ if success
193
+ agent.send(:__mark_unloaded!)
194
+ @entries.delete(agent.agent_id)
195
+ @scope_owners.delete(agent.agent_id)
196
+ else
197
+ @entries[agent.agent_id] = Entry.new(state: :live, agent: agent, token: nil)
198
+ end
199
+ @condition.broadcast
200
+ end
201
+ true
202
+ end
203
+
132
204
  def owned?(agent)
133
205
  return false unless agent
134
206
 
135
207
  key = normalize_agent_id(agent.agent_id)
136
208
  @mutex.synchronize do
137
209
  entry = @entries[key]
138
- entry&.state == :live && entry.agent.equal?(agent)
210
+ %i[live unloading].include?(entry&.state) && entry.agent.equal?(agent)
139
211
  end
140
212
  rescue ArgumentError
141
213
  false
@@ -151,9 +223,13 @@ module Phronomy
151
223
  ensure_running!
152
224
  entry = @entries[key]
153
225
  unless entry&.state == :live && entry.agent.equal?(agent)
226
+ if entry&.state == :unloading && entry.agent.equal?(agent)
227
+ raise Phronomy::AgentBusyError, "Agent #{key.inspect} is unloading"
228
+ end
154
229
  raise Phronomy::RuntimeShutdownError,
155
230
  "Agent #{key.inspect} is not the live owner in this Runtime"
156
231
  end
232
+ assert_no_operations!(agent)
157
233
  @entries[key] = Entry.new(state: :purging, agent: agent, token: token)
158
234
  end
159
235
  agent.send(:__mark_purging!, @environment)
@@ -167,6 +243,7 @@ module Phronomy
167
243
  validate_purge_entry!(entry, agent, token, key)
168
244
  agent.send(:__mark_purged!, @environment)
169
245
  @entries.delete(key)
246
+ @scope_owners.delete(key)
170
247
  @condition.broadcast
171
248
  end
172
249
  true
@@ -215,7 +292,7 @@ module Phronomy
215
292
  # ownership transition that is still actively changing state must settle.
216
293
  def wait_until_idle(deadline)
217
294
  @mutex.synchronize do
218
- while @entries.values.any? { |entry| %i[constructing purging].include?(entry.state) }
295
+ while @entries.values.any? { |entry| %i[constructing purging unloading].include?(entry.state) }
219
296
  remaining = deadline - monotonic_now
220
297
  # :nocov:
221
298
  return false if remaining <= 0
@@ -231,6 +308,8 @@ module Phronomy
231
308
  @state = :terminated
232
309
  owned = @entries.values.filter_map(&:agent).uniq
233
310
  @entries.clear
311
+ @scope_owners.clear
312
+ @operations.clear
234
313
  @condition.broadcast
235
314
  owned
236
315
  end
@@ -240,17 +319,22 @@ module Phronomy
240
319
 
241
320
  private
242
321
 
243
- def reserve_create!(key)
322
+ def reserve_create!(key, scope_owner: nil)
244
323
  token = Object.new.freeze
245
324
  @mutex.synchronize do
246
325
  ensure_running!
247
326
  if (entry = @entries[key])
248
327
  raise ownership_recovery_error(key) if entry.state == :recovery_required
249
328
 
329
+ if scope_owner
330
+ raise Phronomy::AgentBusyError, "Agent #{key.inspect} already has a Runtime owner"
331
+ end
332
+
250
333
  raise Phronomy::AgentAlreadyExistsError,
251
334
  "Agent #{key.inspect} already exists in this Runtime (#{entry.state})"
252
335
  end
253
336
  @entries[key] = Entry.new(state: :constructing, agent: nil, token: token)
337
+ @scope_owners[key] = scope_owner if scope_owner
254
338
  end
255
339
  token
256
340
  end
@@ -291,6 +375,7 @@ module Phronomy
291
375
  # :nocov:
292
376
  if entry&.state == :constructing && entry.token.equal?(token)
293
377
  @entries.delete(key)
378
+ @scope_owners.delete(key)
294
379
  @condition.broadcast
295
380
  end
296
381
  # :cover:
@@ -353,6 +438,12 @@ module Phronomy
353
438
  # :cover:
354
439
  end
355
440
 
441
+ def assert_no_operations!(agent)
442
+ return unless @operations[agent]&.any?
443
+
444
+ raise Phronomy::AgentBusyError, "Agent #{agent.agent_id.inspect} still has accepted operations"
445
+ end
446
+
356
447
  def monotonic_now
357
448
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
358
449
  end
@@ -0,0 +1,208 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # Owns the Agents materialized within one application operation. A scope is
6
+ # Runtime-bound, process-local, and must not be persisted as Workflow state.
7
+ # @api public
8
+ class Scope
9
+ # @api private
10
+ def self.open
11
+ raise ArgumentError, "open_scope requires a block" unless block_given?
12
+ if Phronomy::WaitPolicy.blocking_forbidden?
13
+ raise Phronomy::EventLoopReentrancyError, "Agent.open_scope cannot block EventLoop"
14
+ end
15
+
16
+ scope = new(environment: ExecutionEnvironment.current)
17
+ result = nil
18
+ primary = nil
19
+ begin
20
+ returned = yield(scope)
21
+ result = returned.is_a?(Phronomy::TaskResult) ? returned.wait_result : returned
22
+ rescue Exception => error # standard:disable Lint/RescueException
23
+ primary = error
24
+ raise
25
+ ensure
26
+ begin
27
+ scope.release!
28
+ rescue => cleanup
29
+ failures = cleanup.is_a?(ScopeCleanupError) ? cleanup.failures : [{agent_id: nil, error: cleanup}]
30
+ raise ScopeCleanupError.new(scope: scope, failures: failures, result: result,
31
+ primary_error: primary), cause: primary || failures.first.fetch(:error)
32
+ end
33
+ end
34
+ result
35
+ end
36
+ private_class_method :open
37
+
38
+ def initialize(environment:)
39
+ @environment = environment
40
+ @ownership = environment.ownership
41
+ @owner_token = Object.new.freeze
42
+ @mutex = Mutex.new
43
+ @condition = ConditionVariable.new
44
+ @state = :open
45
+ @receipts = {}
46
+ @agents = {}
47
+ end
48
+ private_class_method :new
49
+
50
+ # @return [Agent::Base]
51
+ # @api public
52
+ def create(agent_class, **options, &listener)
53
+ check_blocking!
54
+ materialize(:create, agent_class, options, listener, async: false)
55
+ end
56
+
57
+ # @return [TaskResult<Agent::Base>]
58
+ # @api public
59
+ def create_async(agent_class, **options, &listener)
60
+ materialize(:create, agent_class, options, listener, async: true)
61
+ rescue => error
62
+ Phronomy::TaskResult.failed(error)
63
+ end
64
+
65
+ # Repeated load in this scope returns the same live owner. Supplying a new
66
+ # listener or different Store for an already live instance is rejected.
67
+ # @return [Agent::Base]
68
+ # @api public
69
+ def load(agent_class, agent_id, persistence:, **options, &listener)
70
+ check_blocking!
71
+ materialize(:load, agent_class, options.merge(agent_id: agent_id, persistence: persistence), listener, async: false)
72
+ end
73
+
74
+ # @return [TaskResult<Agent::Base>]
75
+ # @api public
76
+ def load_async(agent_class, agent_id, persistence:, **options, &listener)
77
+ materialize(:load, agent_class, options.merge(agent_id: agent_id, persistence: persistence), listener, async: true)
78
+ rescue => error
79
+ Phronomy::TaskResult.failed(error)
80
+ end
81
+
82
+ # Release only safe owners; retain failed owners so the caller can retry.
83
+ # @api public
84
+ def release!
85
+ owns_release = false
86
+ check_blocking!
87
+ agents = @mutex.synchronize do
88
+ return false if @state == :released
89
+ raise Phronomy::Error.new("Agent scope is releasing", code: "agent.scope_busy") if @state == :releasing
90
+ @state = :releasing
91
+ owns_release = true
92
+ @condition.wait(@mutex) until @receipts.empty?
93
+ @agents.values.dup
94
+ end
95
+ failures = []
96
+ agents.each do |agent|
97
+ agent.unload!
98
+ @mutex.synchronize { @agents.delete(agent.object_id) }
99
+ rescue => error
100
+ failures << {agent_id: agent.agent_id, error: error}.freeze
101
+ end
102
+ @mutex.synchronize do
103
+ @state = failures.empty? ? :released : :release_failed
104
+ @environment = @ownership = @owner_token = nil if failures.empty?
105
+ end
106
+ unless failures.empty?
107
+ raise ScopeCleanupError.new(scope: self, failures: failures), cause: failures.first.fetch(:error)
108
+ end
109
+ true
110
+ ensure
111
+ # An interrupted waiter/unloader must remain retryable, without changing
112
+ # another thread's release state when this call was rejected as busy.
113
+ if owns_release
114
+ @mutex.synchronize { @state = :release_failed if @state == :releasing }
115
+ end
116
+ end
117
+
118
+ private
119
+
120
+ def check_blocking!
121
+ return unless Phronomy::WaitPolicy.blocking_forbidden?
122
+
123
+ raise Phronomy::EventLoopReentrancyError, "Agent scope materialization cannot block EventLoop; use create_async/load_async"
124
+ end
125
+
126
+ def materialize(kind, agent_class, options, listener, async:)
127
+ unless agent_class.is_a?(Class) && agent_class <= Base
128
+ raise ArgumentError, "agent_class must be an Agent::Base subclass"
129
+ end
130
+ if options[:on_event] && listener
131
+ raise ArgumentError, "Provide either on_event: or a block, not both"
132
+ end
133
+ if kind == :load && !options[:persistence]
134
+ raise ArgumentError, "persistence is required"
135
+ end
136
+ key = (options[:agent_id] || (SecureRandom.uuid if kind == :create)).to_s.dup.freeze
137
+ raise ArgumentError, "agent_id must not be empty" if key.empty?
138
+ options = options.merge(agent_id: key)
139
+ receipt = Object.new.freeze
140
+ @mutex.synchronize do
141
+ raise Phronomy::Error, "Agent scope is #{@state}" unless @state == :open
142
+ @receipts[receipt] = key
143
+ end
144
+ operation = -> { acquire(kind, agent_class, key, options, listener) }
145
+ if async
146
+ begin
147
+ task = @environment.submit(on_full: :raise, &operation)
148
+ task.on_physical_complete { finish_receipt(receipt) }
149
+ task
150
+ rescue
151
+ finish_receipt(receipt)
152
+ raise
153
+ end
154
+ else
155
+ begin
156
+ operation.call
157
+ ensure
158
+ finish_receipt(receipt)
159
+ end
160
+ end
161
+ end
162
+
163
+ def acquire(kind, agent_class, key, options, listener)
164
+ materialized = false
165
+ agent = @ownership.public_send(kind, key, expected_class: agent_class, scope_owner: @owner_token) do |environment|
166
+ materialized = true
167
+ instance = agent_class.send(:__construct_owned_agent, environment, key,
168
+ **options, load_existing: kind == :load, &listener)
169
+ RecoveryCoordinator.new(instance).recover_on_load! if kind == :load
170
+ instance
171
+ end
172
+ # Register before any result or post-materialization check reaches the
173
+ # caller, even when scope closure began while the worker was running.
174
+ @mutex.synchronize { @agents[agent.object_id] = agent }
175
+ if kind == :load
176
+ unless agent.persistence.equal?(options[:persistence])
177
+ raise Phronomy::ConfigurationError, "Agent #{key.inspect} is already live with a different Persistence instance"
178
+ end
179
+ if !materialized && (options[:on_event] || listener)
180
+ raise Phronomy::ConfigurationError, "Agent #{key.inspect} is already live; load cannot re-bind on_event"
181
+ end
182
+ end
183
+ agent
184
+ rescue Phronomy::Persistence::ConflictError => error
185
+ raise unless kind == :create
186
+ raise Phronomy::AgentAlreadyExistsError, "Agent #{key.inspect} already exists durably: #{error.message}"
187
+ rescue => error
188
+ # A fail-closed construction remains reserved in C04. Do not purge or
189
+ # release that reservation just because no instance reached this scope.
190
+ report_materialization_error(key, error)
191
+ raise
192
+ end
193
+
194
+ def finish_receipt(receipt)
195
+ @mutex.synchronize do
196
+ @receipts.delete(receipt)
197
+ @condition.broadcast
198
+ end
199
+ end
200
+
201
+ def report_materialization_error(key, error)
202
+ Phronomy::RuntimeSettings.current.logger&.warn("[Agent scope] materialization failed agent_id=#{key.inspect}: #{error.class}: #{error.message}")
203
+ rescue
204
+ nil
205
+ end
206
+ end
207
+ end
208
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # Successful application work with one or more failed Agent releases.
6
+ # Original exceptions and the successful result remain available to the caller.
7
+ # @api public
8
+ class ScopeCleanupError < Phronomy::Error
9
+ attr_reader :failures, :result, :primary_error, :scope
10
+
11
+ def initialize(scope:, failures:, result: nil, primary_error: nil)
12
+ @failures = failures.map { |failure| failure.dup.freeze }.freeze
13
+ @result = result
14
+ @primary_error = primary_error
15
+ @scope = scope
16
+ super("Agent scope cleanup failed for: #{@failures.map { |f| f.fetch(:agent_id) }.join(", ")}",
17
+ code: "agent.scope_cleanup_failed")
18
+ end
19
+ end
20
+ end
21
+ end