phronomy 0.22.0 → 0.23.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 (156) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +3 -4
  3. data/CHANGELOG.md +200 -10
  4. data/CONTRIBUTING.md +81 -9
  5. data/README.md +15 -6
  6. data/VERIFY.sh +587 -0
  7. data/benchmark/bench_agent_invoke.rb +2 -2
  8. data/benchmark/bench_context_assembler.rb +39 -68
  9. data/benchmark/bench_regression.rb +2 -2
  10. data/docs/architecture/agent-context.md +174 -0
  11. data/docs/architecture/before-llm-input.md +78 -0
  12. data/docs/architecture/context-management.md +232 -0
  13. data/docs/architecture/knowledge-and-rag.md +130 -0
  14. data/docs/architecture/multi-agent-handoff.md +152 -0
  15. data/docs/architecture/persistence.md +175 -0
  16. data/docs/architecture/removed/agent-context.md +72 -0
  17. data/docs/architecture/security-boundaries.md +173 -0
  18. data/docs/architecture/tracing.md +194 -0
  19. data/docs/architecture.md +82 -0
  20. data/docs/archive/design/archived/04_api_design.md +507 -0
  21. data/docs/archive/design/archived/09_guardrails.md +186 -0
  22. data/docs/archive/design/archived/17_rails_integration.md +175 -0
  23. data/docs/archive/design/historical/00_design_philosophy.md +122 -0
  24. data/docs/archive/design/historical/01_rubyllm_evaluation.md +178 -0
  25. data/docs/archive/design/historical/06_design_decisions.md +143 -0
  26. data/docs/changelog/0.14-and-earlier.md +1 -1
  27. data/docs/decisions/001-rubyllm-as-provider-layer.md +6 -1
  28. data/docs/decisions/002-workflow-context-immutability.md +26 -1
  29. data/docs/decisions/006-no-built-in-guardrails.md +2 -1
  30. data/docs/decisions/012-canonical-execution-log-and-context-policy.md +120 -38
  31. data/docs/decisions/014-unified-persistence-durable-state.md +9 -2
  32. data/docs/decisions/016-semantic-multi-agent-handoff.md +112 -0
  33. data/docs/decisions/017-design-authority-and-adr-governance.md +200 -0
  34. data/docs/decisions/018-durability-guarantees-and-failure-model.md +488 -0
  35. data/docs/decisions/019-filter-contract-and-security-boundaries.md +229 -0
  36. data/docs/decisions/020-canonical-workflow-instance-identity.md +177 -0
  37. data/docs/decisions/021-generic-agent-invocation-identity-removal.md +119 -0
  38. data/docs/decisions/022-agent-execution-parent-identity-and-runtime-routing-boundary.md +193 -0
  39. data/docs/decisions/023-fsm-session-incarnation-identity-and-routing.md +139 -0
  40. data/docs/decisions/024-event-loop-single-writer-agent-runtime.md +188 -0
  41. data/docs/decisions/025-process-local-agent-ownership-and-runtime-admission.md +249 -0
  42. data/docs/decisions/026-workflow-runtime-admission-and-durable-terminal-barrier.md +257 -0
  43. data/docs/decisions/027-llm-adapter-provider-boundary.md +93 -0
  44. data/docs/decisions/README.md +172 -0
  45. data/docs/features.md +31 -11
  46. data/docs/getting-started.md +77 -45
  47. data/docs/migrations/0.19.md +14 -7
  48. data/docs/migrations/0.22.md +390 -0
  49. data/docs/persistence-backends.md +88 -38
  50. data/docs/runtime-and-concurrency.md +227 -33
  51. data/examples/README.md +13 -0
  52. data/lib/phronomy/agent/agent_execution.rb +19 -15
  53. data/lib/phronomy/agent/agent_invocation.rb +288 -93
  54. data/lib/phronomy/agent/agent_invocation_session_builder.rb +236 -202
  55. data/lib/phronomy/agent/agent_root.rb +3 -3
  56. data/lib/phronomy/agent/approval_evaluation_request.rb +37 -19
  57. data/lib/phronomy/agent/async_event_api.rb +145 -72
  58. data/lib/phronomy/agent/base.rb +388 -181
  59. data/lib/phronomy/agent/concerns/before_llm_input.rb +1 -1
  60. data/lib/phronomy/agent/context_assembler.rb +437 -178
  61. data/lib/phronomy/agent/context_candidate_resolver.rb +2 -2
  62. data/lib/phronomy/agent/context_plan.rb +18 -13
  63. data/lib/phronomy/agent/context_plan_validator.rb +246 -88
  64. data/lib/phronomy/agent/context_policies/default.rb +123 -34
  65. data/lib/phronomy/agent/context_policy.rb +109 -3
  66. data/lib/phronomy/agent/context_policy_input.rb +244 -0
  67. data/lib/phronomy/agent/context_policy_input_builder.rb +241 -0
  68. data/lib/phronomy/agent/execution_coordinator.rb +1975 -587
  69. data/lib/phronomy/agent/journal_record.rb +17 -4
  70. data/lib/phronomy/agent/llm_input_build_context.rb +1 -1
  71. data/lib/phronomy/agent/llm_input_manifest.rb +277 -2
  72. data/lib/phronomy/agent/llm_operation_result.rb +12 -7
  73. data/lib/phronomy/agent/phase_machine_builder.rb +19 -7
  74. data/lib/phronomy/agent/provider_call_outcome.rb +23 -7
  75. data/lib/phronomy/agent/recovery_coordinator/continuation.rb +271 -0
  76. data/lib/phronomy/agent/recovery_coordinator/installation.rb +427 -0
  77. data/lib/phronomy/agent/recovery_coordinator/resolution.rb +635 -0
  78. data/lib/phronomy/agent/recovery_coordinator.rb +211 -0
  79. data/lib/phronomy/agent/recovery_support.rb +512 -0
  80. data/lib/phronomy/agent/ruby_llm_materializer.rb +22 -13
  81. data/lib/phronomy/agent/selection/candidate.rb +53 -0
  82. data/lib/phronomy/agent/selection/constraint.rb +49 -0
  83. data/lib/phronomy/agent/shared_state.rb +38 -1
  84. data/lib/phronomy/agent/tool_approval_request.rb +33 -5
  85. data/lib/phronomy/agent/tool_definition_set.rb +49 -3
  86. data/lib/phronomy/agent/tool_invocation.rb +336 -102
  87. data/lib/phronomy/agent/tool_invocation_session_builder.rb +49 -45
  88. data/lib/phronomy/agent.rb +20 -2
  89. data/lib/phronomy/agent_already_exists_error.rb +5 -0
  90. data/lib/phronomy/agent_purged_error.rb +5 -0
  91. data/lib/phronomy/engine/concurrency/offload_pool.rb +17 -3
  92. data/lib/phronomy/engine/concurrency/physical_completion_task.rb +135 -0
  93. data/lib/phronomy/engine/event_loop.rb +622 -63
  94. data/lib/phronomy/engine/fsm_session.rb +194 -21
  95. data/lib/phronomy/engine/runtime/agent_ownership_registry.rb +352 -0
  96. data/lib/phronomy/engine/runtime.rb +77 -20
  97. data/lib/phronomy/generator_verifier.rb +12 -14
  98. data/lib/phronomy/invocation_context.rb +9 -29
  99. data/lib/phronomy/multi_agent/admission_registry.rb +51 -0
  100. data/lib/phronomy/multi_agent/coordination_state.rb +18 -0
  101. data/lib/phronomy/multi_agent/coordinator.rb +154 -0
  102. data/lib/phronomy/multi_agent/execution_coordinator.rb +116 -0
  103. data/lib/phronomy/multi_agent/fan_out_invocation.rb +24 -33
  104. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +12 -19
  105. data/lib/phronomy/multi_agent/handoff.rb +24 -45
  106. data/lib/phronomy/multi_agent/handoff_capability_factory.rb +87 -0
  107. data/lib/phronomy/multi_agent/handoff_context.rb +95 -0
  108. data/lib/phronomy/multi_agent/handoff_policy.rb +137 -0
  109. data/lib/phronomy/multi_agent/handoff_projection.rb +191 -0
  110. data/lib/phronomy/multi_agent/handoff_request.rb +45 -0
  111. data/lib/phronomy/multi_agent/orchestrator.rb +12 -15
  112. data/lib/phronomy/multi_agent/runner.rb +98 -0
  113. data/lib/phronomy/persistence/durable_codec.rb +646 -0
  114. data/lib/phronomy/persistence/durable_record.rb +117 -0
  115. data/lib/phronomy/persistence/in_memory.rb +210 -134
  116. data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
  117. data/lib/phronomy/persistence/repository_facades.rb +316 -0
  118. data/lib/phronomy/persistence.rb +81 -41
  119. data/lib/phronomy/recovery.rb +186 -0
  120. data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +2 -2
  121. data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +1 -1
  122. data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +19 -19
  123. data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +3 -3
  124. data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +5 -5
  125. data/lib/phronomy/tracing/automatic.rb +176 -0
  126. data/lib/phronomy/tracing/base.rb +11 -2
  127. data/lib/phronomy/tracing/langfuse_tracer.rb +20 -12
  128. data/lib/phronomy/version.rb +1 -1
  129. data/lib/phronomy/workflow.rb +3 -6
  130. data/lib/phronomy/workflow_context.rb +14 -5
  131. data/lib/phronomy/workflow_recovery.rb +123 -0
  132. data/lib/phronomy/workflow_runner.rb +468 -256
  133. data/lib/phronomy.rb +6 -0
  134. data/scripts/api_snapshot.rb +12 -0
  135. data/sig/phronomy/agent.rbs +209 -7
  136. data/sig/phronomy/multi_agent.rbs +39 -0
  137. data/sig/phronomy/persistence.rbs +62 -4
  138. data/sig/phronomy/runtime.rbs +1 -4
  139. data/sig/phronomy/workflow.rbs +2 -2
  140. data/sig/phronomy.rbs +10 -0
  141. metadata +65 -17
  142. data/examples/workflows/agent_event_mapping.rb +0 -101
  143. data/examples/workflows/generic_task_event_mapping.rb +0 -66
  144. data/lib/phronomy/agent/activation_registry.rb +0 -28
  145. data/lib/phronomy/agent/agent_execution_activation.rb +0 -172
  146. data/lib/phronomy/agent/context_candidate.rb +0 -47
  147. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +0 -53
  148. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +0 -56
  149. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +0 -30
  150. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +0 -118
  151. data/lib/phronomy/agent/context_policy_descriptor.rb +0 -49
  152. data/lib/phronomy/agent/context_policy_registry.rb +0 -46
  153. data/lib/phronomy/agent/context_request.rb +0 -35
  154. data/lib/phronomy/agent/context_selection_unit.rb +0 -38
  155. data/lib/phronomy/agent/derived_content_spec.rb +0 -34
  156. data/lib/phronomy/agent/runner.rb +0 -97
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  module Phronomy
4
6
  # Event-driven execution wrapper for a single FSM session.
5
7
  #
@@ -7,12 +9,76 @@ module Phronomy
7
9
  # FSMSession owns FSM execution only; it does not own external Task handles,
8
10
  # activity tokens, callback correlation, or domain-specific stale-event policy.
9
11
  class FSMSession
12
+ class IdentityReservation
13
+ attr_reader :fsm_session_id
14
+
15
+ def initialize
16
+ @fsm_session_id = SecureRandom.uuid.to_s.freeze
17
+ @claimed = false
18
+ @mutex = Mutex.new
19
+ end
20
+
21
+ def claim!
22
+ @mutex.synchronize do
23
+ raise Phronomy::Error, "FSMSession identity reservation already claimed" if @claimed
24
+
25
+ @claimed = true
26
+ @fsm_session_id
27
+ end
28
+ end
29
+ end
30
+ private_constant :IdentityReservation
31
+
32
+ class EventSink
33
+ attr_reader :fsm_session_id
34
+
35
+ def initialize(event_loop:)
36
+ @event_loop = event_loop
37
+ @fsm_session_id = nil
38
+ end
39
+
40
+ def bind!(fsm_session_id)
41
+ raise Phronomy::Error, "FSMSession EventSink is already bound" if @fsm_session_id
42
+
43
+ @fsm_session_id = fsm_session_id.to_s.freeze
44
+ self
45
+ end
46
+
47
+ def post(type, payload = nil)
48
+ raise Phronomy::Error, "FSMSession EventSink is not bound" unless @fsm_session_id
49
+
50
+ event = Phronomy::Event.new(
51
+ type: type,
52
+ target_id: @fsm_session_id,
53
+ payload: payload
54
+ )
55
+ if @event_loop.respond_to?(:post_to_session)
56
+ @event_loop.post_to_session(event)
57
+ else
58
+ @event_loop.post(event)
59
+ end
60
+ end
61
+ end
62
+
10
63
  FINISH = WorkflowRunner::FINISH
11
64
 
12
- attr_reader :id, :context
65
+ attr_reader :id, :context, :event_sink
66
+
67
+ # Returns the live current Workflow phase. During an active FSM transition
68
+ # the tracker phase is authoritative; @current_state lags until
69
+ # fire_and_advance! returns. Terminal persistence lifecycle is deliberately
70
+ # separate from this logical Workflow phase.
71
+ def current_state
72
+ return @tracker.phase.to_sym if @tracker
73
+ @current_state
74
+ end
75
+
76
+ # @api private
77
+ def self.reserve_identity
78
+ IdentityReservation.new
79
+ end
13
80
 
14
81
  def initialize(
15
- id:,
16
82
  context:,
17
83
  entry_point:,
18
84
  entry_actions:,
@@ -26,10 +92,23 @@ module Phronomy
26
92
  resume_event: nil,
27
93
  resume_phase: nil,
28
94
  stable_observer: nil,
29
- graph_thread_id: nil
95
+ context_metadata: {},
96
+ event_sink: nil,
97
+ identity_reservation: nil,
98
+ terminal_barrier: nil
30
99
  )
31
- @id = id
32
- @graph_thread_id = graph_thread_id || id
100
+ @id = if identity_reservation
101
+ unless identity_reservation.is_a?(IdentityReservation)
102
+ raise ArgumentError,
103
+ "identity_reservation must come from FSMSession.reserve_identity"
104
+ end
105
+ identity_reservation.send(:claim!).to_s.freeze
106
+ else
107
+ SecureRandom.uuid.to_s.freeze
108
+ end
109
+ @event_sink = event_sink || EventSink.new(event_loop: event_loop)
110
+ @event_sink.bind!(@id)
111
+ @context_metadata = context_metadata.dup.freeze
33
112
  @ctx = context
34
113
  @context = context
35
114
  @entry_point = entry_point
@@ -44,6 +123,10 @@ module Phronomy
44
123
  @resume_event = resume_event
45
124
  @resume_phase = resume_phase
46
125
  @stable_observer = stable_observer
126
+ @terminal_barrier = terminal_barrier
127
+ @terminal_lifecycle_state = :running
128
+ @pending_terminal_type = nil
129
+ @pending_terminal_notify_stable = false
47
130
  @step = 0
48
131
  @done = false
49
132
  @current_state = nil
@@ -77,6 +160,16 @@ module Phronomy
77
160
  def handle(event)
78
161
  return if @done
79
162
 
163
+ if event.type == :workflow_terminal_persistence_result
164
+ handle_terminal_persistence_result(event.payload)
165
+ return
166
+ end
167
+
168
+ # Once terminal persistence begins, ordinary Workflow events no longer
169
+ # have result authority. Only the persistence completion for this concrete
170
+ # FSMSession can advance its terminal lifecycle.
171
+ return unless @terminal_lifecycle_state == :running
172
+
80
173
  context_disposition = apply_context_event(event)
81
174
  return if context_disposition == :consume
82
175
 
@@ -92,6 +185,8 @@ module Phronomy
92
185
 
93
186
  private
94
187
 
188
+ attr_reader :terminal_lifecycle_state
189
+
95
190
  def run_initial_entry_actions!
96
191
  Array(@entry_actions[@current_state]).each do |callable|
97
192
  result = callable.call(@ctx)
@@ -110,6 +205,7 @@ module Phronomy
110
205
  if _fsm_context?(result)
111
206
  @ctx = result
112
207
  @context = result
208
+ apply_context_metadata!
113
209
  end
114
210
  end
115
211
 
@@ -122,6 +218,7 @@ module Phronomy
122
218
  if _fsm_context?(result)
123
219
  @ctx = result
124
220
  @context = result
221
+ apply_context_metadata!
125
222
  @tracker.context = @ctx
126
223
  true
127
224
  else
@@ -143,6 +240,7 @@ module Phronomy
143
240
 
144
241
  @ctx = @tracker.context
145
242
  @context = @ctx
243
+ apply_context_metadata!
146
244
  @current_state = @tracker.phase.to_sym
147
245
  @step += 1
148
246
  advance_or_halt
@@ -165,25 +263,33 @@ module Phronomy
165
263
  def advance_or_halt
166
264
  return finish! if @current_state == FINISH
167
265
 
168
- notify_stable_state!
169
-
266
+ # A wait state is the logical end of this Workflow execution segment. Its
267
+ # public stable-state notification is therefore part of terminalization
268
+ # and, for durable Workflows, must not escape before the durable barrier.
170
269
  if @wait_state_names.include?(@current_state)
171
270
  halt!
172
271
  return
173
272
  end
174
273
 
175
274
  if @auto_state_set.key?(@current_state)
275
+ notify_stable_state!
176
276
  post_session_event(:state_completed)
177
277
  return
178
278
  end
179
279
 
180
- return if has_external_event_from?(@current_state)
280
+ if has_external_event_from?(@current_state)
281
+ notify_stable_state!
282
+ return
283
+ end
181
284
 
182
285
  unless @declared_states.include?(@current_state)
183
286
  raise ArgumentError, "State #{@current_state.inspect} is not defined"
184
287
  end
185
288
 
186
- finish!
289
+ # A declared state with no outgoing transition is also a logical terminal
290
+ # boundary. Preserve its stable-state notification, but for durable
291
+ # Workflows publish it only after the terminal save succeeds.
292
+ finish!(notify_stable: true)
187
293
  end
188
294
 
189
295
  def notify_stable_state!
@@ -215,35 +321,96 @@ module Phronomy
215
321
  "EventLoop rejected #{type.inspect} for FSMSession #{@id}"
216
322
  end
217
323
 
218
- def finish!
219
- return if @done
220
-
221
- @done = true
222
- @ctx.set_graph_metadata(thread_id: @graph_thread_id, phase: :__end__)
223
- post_terminal_event(:finished, @ctx)
324
+ def finish!(notify_stable: false)
325
+ request_terminal!(
326
+ :finished,
327
+ phase: :__end__,
328
+ notify_stable: notify_stable
329
+ )
224
330
  end
225
331
 
226
332
  def halt!
227
- return if @done
333
+ request_terminal!(:halted, phase: @current_state, notify_stable: true)
334
+ end
335
+
336
+ def request_terminal!(terminal_type, phase:, notify_stable:)
337
+ return if @done || @terminal_lifecycle_state != :running
338
+
339
+ apply_context_metadata!(phase: phase)
340
+ @pending_terminal_type = terminal_type
341
+ @pending_terminal_notify_stable = notify_stable
342
+ unless @terminal_barrier
343
+ complete_terminal!(terminal_type)
344
+ return
345
+ end
346
+
347
+ @terminal_lifecycle_state = :persisting_terminal
348
+ @terminal_barrier.call(
349
+ terminal_type: terminal_type,
350
+ context: @ctx,
351
+ event_sink: @event_sink
352
+ )
353
+ rescue => error
354
+ finish_with_error(error)
355
+ end
356
+
357
+ def handle_terminal_persistence_result(result)
358
+ return unless @terminal_lifecycle_state == :persisting_terminal
228
359
 
360
+ case result.outcome
361
+ when :success
362
+ complete_terminal!(@pending_terminal_type)
363
+ when :known_failure
364
+ finish_with_error(
365
+ result.error || Phronomy::Error.new("Workflow terminal persistence failed")
366
+ )
367
+ when :outcome_unknown
368
+ @done = true
369
+ @terminal_lifecycle_state = :recovery_required
370
+ post_recovery_required_event(result.error)
371
+ else
372
+ raise Phronomy::Error,
373
+ "unknown Workflow terminal persistence outcome: #{result.outcome.inspect}"
374
+ end
375
+ end
376
+
377
+ def complete_terminal!(terminal_type)
229
378
  @done = true
230
- @ctx.set_graph_metadata(thread_id: @graph_thread_id, phase: @current_state)
231
- post_terminal_event(:halted, @ctx)
379
+ @terminal_lifecycle_state =
380
+ (terminal_type == :halted) ? :halted : :completed
381
+ notify_stable_state! if @pending_terminal_notify_stable
382
+ post_terminal_event(terminal_type, @ctx)
232
383
  end
233
384
 
234
385
  def finish_with_error(error)
235
386
  return if @done
236
387
 
237
388
  @done = true
389
+ @terminal_lifecycle_state = :error
238
390
  post_terminal_event(:error, error)
239
391
  end
240
392
 
393
+ def post_recovery_required_event(error)
394
+ accepted = @event_loop.post(
395
+ Phronomy::Event.new(
396
+ type: :recovery_required,
397
+ target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID,
398
+ payload: {fsm_session_id: @id, error: error}
399
+ )
400
+ )
401
+ return if accepted
402
+
403
+ Phronomy.configuration.logger&.warn(
404
+ "[Phronomy::FSMSession] EventLoop rejected recovery-required event for #{@id}"
405
+ )
406
+ end
407
+
241
408
  def post_terminal_event(type, result)
242
409
  accepted = @event_loop.post(
243
410
  Phronomy::Event.new(
244
411
  type: type,
245
412
  target_id: Phronomy::EventLoop::SYSTEM_CHANNEL_ID,
246
- payload: {session_id: @id, result: result}
413
+ payload: {fsm_session_id: @id, result: result}
247
414
  )
248
415
  )
249
416
  return if accepted
@@ -254,6 +421,14 @@ module Phronomy
254
421
  )
255
422
  end
256
423
 
424
+ def apply_context_metadata!(phase: nil)
425
+ return unless @ctx.respond_to?(:set_graph_metadata)
426
+
427
+ metadata = @context_metadata
428
+ metadata = metadata.merge(phase: phase) unless phase.nil?
429
+ @ctx.set_graph_metadata(**metadata)
430
+ end
431
+
257
432
  def fire_event!(tracker, event_name, from_state)
258
433
  unless tracker.respond_to?(event_name)
259
434
  raise ArgumentError,
@@ -262,8 +437,6 @@ module Phronomy
262
437
 
263
438
  return true if tracker.public_send(event_name)
264
439
 
265
- # A declared external event whose guards all reject is a valid no-op.
266
- # Applications use this to reject stale or unrelated correlated events.
267
440
  return false if has_external_event_from?(from_state, event_name)
268
441
 
269
442
  raise ArgumentError,
@@ -0,0 +1,352 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class Runtime
5
+ # Process-local authoritative owner registry for mutable Agent instances.
6
+ #
7
+ # This is not a cache. While a Runtime is alive, one agent_id maps to at most
8
+ # one mutable live Agent object. Construction is reserved before durable
9
+ # create/load so concurrent callers cannot materialize independent objects.
10
+ class AgentOwnershipRegistry
11
+ Entry = Data.define(:state, :agent, :token)
12
+ private_constant :Entry
13
+
14
+ def initialize(runtime:)
15
+ @runtime = runtime
16
+ @mutex = Mutex.new
17
+ @condition = ConditionVariable.new
18
+ @entries = {}
19
+ @state = :running
20
+ end
21
+
22
+ def create(agent_id, expected_class:)
23
+ key = normalize_agent_id(agent_id)
24
+ token = reserve_create!(key)
25
+
26
+ begin
27
+ agent = yield(@runtime)
28
+ rescue Phronomy::AgentAlreadyExistsError,
29
+ Phronomy::Persistence::ConflictError,
30
+ Phronomy::Persistence::NotFoundError,
31
+ Phronomy::Persistence::SerializationError,
32
+ ArgumentError,
33
+ Phronomy::ConfigurationError
34
+ release_construction!(key, token)
35
+ raise
36
+ rescue
37
+ # A generic backend/transport failure can be an indeterminate commit.
38
+ # Do not make the same identity creatable again until recovery proves
39
+ # which durable lineage exists.
40
+ fail_construction_closed!(key, token)
41
+ raise
42
+ end
43
+
44
+ publish_constructed!(
45
+ key,
46
+ token,
47
+ agent,
48
+ expected_class,
49
+ fail_closed: true
50
+ )
51
+ end
52
+
53
+ def load(agent_id, expected_class:)
54
+ key = normalize_agent_id(agent_id)
55
+ token = nil
56
+
57
+ loop do
58
+ existing = @mutex.synchronize do
59
+ ensure_running!
60
+ entry = @entries[key]
61
+ case entry&.state
62
+ when :live
63
+ entry.agent
64
+ when :constructing, :purging
65
+ @condition.wait(@mutex)
66
+ :retry
67
+ when :recovery_required
68
+ raise ownership_recovery_error(key)
69
+ when nil
70
+ token = Object.new.freeze
71
+ @entries[key] = Entry.new(state: :constructing, agent: nil, token: token)
72
+ nil
73
+ # :nocov:
74
+ else
75
+ raise Phronomy::Error,
76
+ "unknown Agent ownership state for #{key.inspect}: #{entry.state.inspect}"
77
+ # :cover:
78
+ end
79
+ end
80
+
81
+ next if existing == :retry
82
+ return validate_expected_class!(existing, expected_class, key) if existing
83
+ break
84
+ end
85
+
86
+ begin
87
+ agent = yield(@runtime)
88
+ publish_constructed!(
89
+ key,
90
+ token,
91
+ agent,
92
+ expected_class,
93
+ fail_closed: false
94
+ )
95
+ rescue
96
+ # Hydration is read-only. A failed load does not establish a new
97
+ # durable Agent lineage, so the reservation can safely be released.
98
+ release_construction!(key, token)
99
+ raise
100
+ end
101
+ end
102
+
103
+ def get(agent_id, expected_class:)
104
+ key = normalize_agent_id(agent_id)
105
+ agent = @mutex.synchronize do
106
+ entry = @entries[key]
107
+ case entry&.state
108
+ when :live
109
+ entry.agent
110
+ when :recovery_required
111
+ raise ownership_recovery_error(key)
112
+ # :nocov:
113
+ when :purging
114
+ raise Phronomy::Error, "Agent #{key.inspect} is being purged"
115
+ # :cover:
116
+ end
117
+ end
118
+ return nil unless agent
119
+
120
+ validate_expected_class!(agent, expected_class, key)
121
+ end
122
+
123
+ def owned?(agent)
124
+ return false unless agent
125
+
126
+ key = normalize_agent_id(agent.agent_id)
127
+ @mutex.synchronize do
128
+ entry = @entries[key]
129
+ entry&.state == :live && entry.agent.equal?(agent)
130
+ end
131
+ rescue ArgumentError
132
+ false
133
+ end
134
+
135
+ # Moves an exact live owner into :purging before any durable deletion.
136
+ # The returned opaque token must be supplied to complete/abort.
137
+ def begin_purge(agent)
138
+ key = normalize_agent_id(agent.agent_id)
139
+ token = Object.new.freeze
140
+
141
+ @mutex.synchronize do
142
+ ensure_running!
143
+ entry = @entries[key]
144
+ unless entry&.state == :live && entry.agent.equal?(agent)
145
+ raise Phronomy::RuntimeShutdownError,
146
+ "Agent #{key.inspect} is not the live owner in this Runtime"
147
+ end
148
+ @entries[key] = Entry.new(state: :purging, agent: agent, token: token)
149
+ end
150
+ agent.send(:__mark_purging!, @runtime)
151
+ token
152
+ end
153
+
154
+ def complete_purge(agent, token)
155
+ key = normalize_agent_id(agent.agent_id)
156
+ @mutex.synchronize do
157
+ entry = @entries[key]
158
+ validate_purge_entry!(entry, agent, token, key)
159
+ @entries.delete(key)
160
+ @condition.broadcast
161
+ end
162
+ agent.send(:__mark_purged!, @runtime)
163
+ true
164
+ end
165
+
166
+ # Use only when the durable purge is known not to have committed.
167
+ def abort_purge(agent, token)
168
+ key = normalize_agent_id(agent.agent_id)
169
+ @mutex.synchronize do
170
+ entry = @entries[key]
171
+ validate_purge_entry!(entry, agent, token, key)
172
+ @entries[key] = Entry.new(state: :live, agent: agent, token: nil)
173
+ @condition.broadcast
174
+ end
175
+ agent.send(:__restore_live_after_purge_abort!, @runtime)
176
+ true
177
+ end
178
+
179
+ # Unknown durable outcome is fail-closed, but it must not leave load/shutdown
180
+ # waiters blocked forever. Convert the transition into an explicit stable
181
+ # recovery-required state and wake all waiters.
182
+ def leave_purge_uncertain(agent, token)
183
+ key = normalize_agent_id(agent.agent_id)
184
+ @mutex.synchronize do
185
+ validate_purge_entry!(@entries[key], agent, token, key)
186
+ @entries[key] = Entry.new(
187
+ state: :recovery_required,
188
+ agent: agent,
189
+ token: nil
190
+ )
191
+ @condition.broadcast
192
+ end
193
+ agent.send(:__mark_ownership_recovery_required!, @runtime)
194
+ true
195
+ end
196
+
197
+ def begin_draining
198
+ @mutex.synchronize do
199
+ @state = :draining if @state == :running
200
+ @condition.broadcast
201
+ end
202
+ self
203
+ end
204
+
205
+ # Live/recovery-required entries do not prevent Runtime shutdown. Only an
206
+ # ownership transition that is still actively changing state must settle.
207
+ def wait_until_stable(deadline)
208
+ @mutex.synchronize do
209
+ while @entries.values.any? { |entry| %i[constructing purging].include?(entry.state) }
210
+ remaining = deadline - monotonic_now
211
+ # :nocov:
212
+ return false if remaining <= 0
213
+ # :cover:
214
+ @condition.wait(@mutex, remaining)
215
+ end
216
+ true
217
+ end
218
+ end
219
+
220
+ def shutdown!
221
+ agents = @mutex.synchronize do
222
+ @state = :terminated
223
+ owned = @entries.values.filter_map(&:agent).uniq
224
+ @entries.clear
225
+ @condition.broadcast
226
+ owned
227
+ end
228
+ agents.each { |agent| agent.send(:__release_runtime_owner!, @runtime) }
229
+ true
230
+ end
231
+
232
+ private
233
+
234
+ def reserve_create!(key)
235
+ token = Object.new.freeze
236
+ @mutex.synchronize do
237
+ ensure_running!
238
+ if (entry = @entries[key])
239
+ raise ownership_recovery_error(key) if entry.state == :recovery_required
240
+
241
+ raise Phronomy::AgentAlreadyExistsError,
242
+ "Agent #{key.inspect} already exists in this Runtime (#{entry.state})"
243
+ end
244
+ @entries[key] = Entry.new(state: :constructing, agent: nil, token: token)
245
+ end
246
+ token
247
+ end
248
+
249
+ def publish_constructed!(key, token, agent, expected_class, fail_closed:)
250
+ validate_expected_class!(agent, expected_class, key)
251
+ unless agent.agent_id.to_s == key
252
+ raise Phronomy::Error,
253
+ "constructed Agent identity mismatch: reserved #{key.inspect}, got #{agent.agent_id.inspect}"
254
+ end
255
+
256
+ agent.send(:__bind_runtime_owner!, @runtime)
257
+ @mutex.synchronize do
258
+ entry = @entries[key]
259
+ unless entry&.state == :constructing && entry.token.equal?(token)
260
+ raise Phronomy::Error,
261
+ "Agent ownership reservation for #{key.inspect} was lost during construction"
262
+ end
263
+ @entries[key] = Entry.new(state: :live, agent: agent, token: nil)
264
+ @condition.broadcast
265
+ end
266
+ agent
267
+ rescue
268
+ # :nocov:
269
+ if fail_closed
270
+ fail_construction_closed!(key, token, agent: agent)
271
+ agent&.send(:__mark_ownership_recovery_required!, @runtime)
272
+ else
273
+ release_construction!(key, token)
274
+ end
275
+ # :cover:
276
+ raise
277
+ end
278
+
279
+ def release_construction!(key, token)
280
+ @mutex.synchronize do
281
+ entry = @entries[key]
282
+ # :nocov:
283
+ if entry&.state == :constructing && entry.token.equal?(token)
284
+ @entries.delete(key)
285
+ @condition.broadcast
286
+ end
287
+ # :cover:
288
+ end
289
+ end
290
+
291
+ def fail_construction_closed!(key, token, agent: nil)
292
+ @mutex.synchronize do
293
+ entry = @entries[key]
294
+ # :nocov:
295
+ return unless entry&.state == :constructing && entry.token.equal?(token)
296
+ # :cover:
297
+
298
+ @entries[key] = Entry.new(
299
+ state: :recovery_required,
300
+ agent: agent,
301
+ token: nil
302
+ )
303
+ @condition.broadcast
304
+ end
305
+ end
306
+
307
+ def validate_expected_class!(agent, expected_class, key)
308
+ return agent if agent.is_a?(expected_class)
309
+
310
+ raise Phronomy::ConfigurationError,
311
+ "Agent definition mismatch: #{key.inspect} is already live as #{agent.class}, not #{expected_class}"
312
+ end
313
+
314
+ def validate_purge_entry!(entry, agent, token, key)
315
+ return if entry&.state == :purging &&
316
+ entry.agent.equal?(agent) && entry.token.equal?(token)
317
+
318
+ # :nocov:
319
+ raise Phronomy::Error,
320
+ "Agent purge ownership for #{key.inspect} is no longer authoritative"
321
+ # :cover:
322
+ end
323
+
324
+ def ownership_recovery_error(key)
325
+ Phronomy::Error.new(
326
+ "Agent #{key.inspect} ownership requires durable recovery/reconciliation"
327
+ )
328
+ end
329
+
330
+ def normalize_agent_id(agent_id)
331
+ key = agent_id.to_s
332
+ # :nocov:
333
+ raise ArgumentError, "agent_id must not be empty" if key.empty?
334
+ # :cover:
335
+ key.freeze
336
+ end
337
+
338
+ def ensure_running!
339
+ return if @state == :running
340
+
341
+ # :nocov:
342
+ raise Phronomy::RuntimeShutdownError,
343
+ "Runtime is #{@state}; Agent ownership changes are not accepted"
344
+ # :cover:
345
+ end
346
+
347
+ def monotonic_now
348
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
349
+ end
350
+ end
351
+ end
352
+ end