phronomy 0.28.0 → 0.29.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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +20 -5
  3. data/docs/application-recipes.md +229 -0
  4. data/docs/architecture/dx05-agent-scope.md +119 -0
  5. data/docs/architecture/dx05-workflow-task-state.md +134 -0
  6. data/docs/architecture/error-api-review.md +245 -0
  7. data/docs/failure-reason-codes.md +45 -0
  8. data/docs/features.md +4 -1
  9. data/docs/getting-started.md +44 -43
  10. data/docs/ordered-event-delivery.md +54 -0
  11. data/docs/workflow-completion.md +92 -0
  12. data/gemfiles/http_minimum.gemfile +5 -0
  13. data/lib/phronomy/agent/agent_not_found_error.rb +16 -0
  14. data/lib/phronomy/agent/agent_unloaded_error.rb +14 -0
  15. data/lib/phronomy/agent/api/agent.rb +13 -0
  16. data/lib/phronomy/agent/async_event_api.rb +111 -87
  17. data/lib/phronomy/agent/base.rb +74 -43
  18. data/lib/phronomy/agent/before_llm_input.rb +7 -1
  19. data/lib/phronomy/agent/execution/exact_execution.rb +11 -9
  20. data/lib/phronomy/agent/execution/execution_coordinator.rb +16 -13
  21. data/lib/phronomy/agent/execution/execution_outcome_committer.rb +1 -1
  22. data/lib/phronomy/agent/execution/initial_preparation.rb +1 -4
  23. data/lib/phronomy/agent/execution/runtime_record_encoder.rb +1 -4
  24. data/lib/phronomy/agent/filterable.rb +21 -15
  25. data/lib/phronomy/agent/lifecycle/lifetime_api.rb +76 -0
  26. data/lib/phronomy/agent/lifecycle/operation_lease.rb +33 -0
  27. data/lib/phronomy/agent/lifecycle/operation_result.rb +53 -0
  28. data/lib/phronomy/agent/lifecycle/ownership_registry.rb +99 -8
  29. data/lib/phronomy/agent/lifecycle/scope.rb +198 -0
  30. data/lib/phronomy/agent/lifecycle/scope_cleanup_error.rb +19 -0
  31. data/lib/phronomy/agent/lifecycle/unload_transition.rb +78 -0
  32. data/lib/phronomy/agent/recovery/recovery_coordinator/continuation.rb +3 -3
  33. data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +7 -7
  34. data/lib/phronomy/agent/recovery/recovery_coordinator/resolution.rb +14 -18
  35. data/lib/phronomy/agent/recovery/recovery_coordinator.rb +5 -9
  36. data/lib/phronomy/agent/recovery/recovery_support.rb +2 -8
  37. data/lib/phronomy/agent/stream_callback_error.rb +2 -2
  38. data/lib/phronomy/common/error.rb +65 -1
  39. data/lib/phronomy/content_store/base.rb +7 -2
  40. data/lib/phronomy/context/assembly.rb +11 -18
  41. data/lib/phronomy/embeddings/authentication_error.rb +11 -0
  42. data/lib/phronomy/embeddings/backends/ruby_llm_embeddings.rb +5 -1
  43. data/lib/phronomy/embeddings/rate_limit_error.rb +11 -0
  44. data/lib/phronomy/execution/execution_cancellation_error.rb +2 -2
  45. data/lib/phronomy/execution/execution_timeout_error.rb +2 -2
  46. data/lib/phronomy/execution/task_result.rb +7 -2
  47. data/lib/phronomy/filter/filter_block_error.rb +2 -2
  48. data/lib/phronomy/generation/low_confidence_error.rb +2 -2
  49. data/lib/phronomy/integrations/ordered_event_delivery.rb +208 -0
  50. data/lib/phronomy/multi_agent/persistence/team_execution_repository.rb +5 -5
  51. data/lib/phronomy/multi_agent/team_busy_error.rb +18 -0
  52. data/lib/phronomy/multi_agent/team_coordinator.rb +20 -7
  53. data/lib/phronomy/multi_agent/team_not_found_error.rb +18 -0
  54. data/lib/phronomy/storage/condition_failed_error.rb +2 -2
  55. data/lib/phronomy/storage/unique_constraint_error.rb +2 -2
  56. data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +1 -1
  57. data/lib/phronomy/tool/base.rb +25 -23
  58. data/lib/phronomy/tracing/base.rb +35 -6
  59. data/lib/phronomy/tracing/open_telemetry_tracer.rb +4 -29
  60. data/lib/phronomy/version.rb +1 -1
  61. data/lib/phronomy/workflow/execution/workflow.rb +30 -2
  62. data/lib/phronomy/workflow/execution/workflow_execution_environment.rb +1 -0
  63. data/lib/phronomy/workflow/execution/workflow_execution_registry.rb +74 -3
  64. data/lib/phronomy/workflow/execution/workflow_runner.rb +48 -14
  65. data/lib/phronomy/workflow/execution/workflow_task_execution.rb +153 -0
  66. data/lib/phronomy/workflow/execution/workflow_task_reception.rb +72 -0
  67. data/lib/phronomy/workflow/execution/workflow_task_resume_error.rb +14 -0
  68. data/lib/phronomy/workflow/runtime_binding/workflow_engine_environment.rb +26 -3
  69. data/lib/phronomy/workflow/runtime_binding/workflow_phase_machine_builder.rb +3 -1
  70. data/lib/phronomy/workflow/runtime_binding/workflow_task_session.rb +76 -0
  71. data/lib/phronomy.rb +6 -0
  72. data/scripts/api_snapshot.rb +12 -0
  73. data/scripts/check_readme_ruby.rb +24 -29
  74. data/scripts/check_readme_runnable.rb +92 -81
  75. data/sig/phronomy/agent/agent_not_found_error.rbs +6 -0
  76. data/sig/phronomy/agent/agent_unloaded_error.rbs +6 -0
  77. data/sig/phronomy/agent/stream_callback_error.rbs +8 -0
  78. data/sig/phronomy/agent.rbs +18 -0
  79. data/sig/phronomy/embeddings/base.rbs +6 -0
  80. data/sig/phronomy/execution/execution.rbs +2 -2
  81. data/sig/phronomy/extensions.rbs +1 -0
  82. data/sig/phronomy/filter/filter_block_error.rbs +6 -0
  83. data/sig/phronomy/generation/low_confidence_error.rbs +6 -0
  84. data/sig/phronomy/integrations.rbs +14 -0
  85. data/sig/phronomy/multi_agent/team_busy_error.rbs +8 -0
  86. data/sig/phronomy/multi_agent/team_not_found_error.rbs +8 -0
  87. data/sig/phronomy/storage/contracts.rbs +2 -2
  88. data/sig/phronomy/workflow/execution/workflow_execution_environment.rbs +1 -0
  89. data/sig/phronomy/workflow.rbs +4 -0
  90. data/sig/phronomy.rbs +4 -0
  91. metadata +49 -2
@@ -93,7 +93,7 @@ module Phronomy
93
93
  )
94
94
  @agent.send(:__assert_live_agent!)
95
95
  @agent.send(:_reject_removed_generic_identity_keys!, config)
96
- result_task = Phronomy::TaskResult.deferred(name: "agent-#{@agent.agent_id}-#{mode}")
96
+ result_task = OperationResult.new(agent: @agent, name: "agent-#{@agent.agent_id}-#{mode}")
97
97
  controls = Phronomy::InvocationControls.attach(result_task,
98
98
  invocation_context: config[:invocation_context],
99
99
  cancellation_token: config[:cancellation_token])
@@ -127,9 +127,8 @@ module Phronomy
127
127
  )
128
128
  @agent.send(:__assert_live_agent!)
129
129
  @agent.send(:_reject_removed_generic_identity_keys!, config)
130
- result_task = Phronomy::TaskResult.deferred(
131
- name: "agent-approval-resume:#{execution_id}"
132
- )
130
+ result_task = OperationResult.new(agent: @agent,
131
+ name: "agent-approval-resume:#{execution_id}")
133
132
  command = ResumeCommand.new(
134
133
  coordinator: self,
135
134
  execution_id: execution_id.to_s.freeze,
@@ -269,7 +268,7 @@ module Phronomy
269
268
  def begin_start_on_event_loop(request)
270
269
  environment = @agent.__execution_environment
271
270
  request.controls.check_start!
272
- root = @agent.agent_root
271
+ root = @agent.send(:__root_for_operation)
273
272
  if root.lifecycle_status == :closed
274
273
  deliver_start_failure_on_event_loop(
275
274
  request,
@@ -298,6 +297,7 @@ module Phronomy
298
297
  @initial_preparation.prepare(operation)
299
298
  end
300
299
  submitted = true
300
+ request.result_task.track(task) if request.result_task.respond_to?(:track)
301
301
  task.on_complete do |result, error|
302
302
  ready = InitialPreparationReady.new(
303
303
  coordinator: self,
@@ -519,7 +519,7 @@ module Phronomy
519
519
 
520
520
  operation = InitialPreparation::RecoveryCommand.new(
521
521
  execution: execution,
522
- root: @agent.agent_root,
522
+ root: @agent.send(:__root_for_operation),
523
523
  journal_records: @agent.send(:_journal_records_snapshot)
524
524
  )
525
525
  task = environment.submit(on_full: :raise) do
@@ -717,7 +717,7 @@ module Phronomy
717
717
  execution_id: state.execution_id,
718
718
  fsm_session_id: fsm_session_id.to_s.freeze,
719
719
  expected_execution_revision: state.execution.execution_revision,
720
- root: @agent.agent_root,
720
+ root: @agent.send(:__root_for_operation),
721
721
  journal_records: @agent.send(:_journal_records_snapshot),
722
722
  execution: state.execution,
723
723
  base_manifest: state.base_manifest,
@@ -739,7 +739,7 @@ module Phronomy
739
739
  execution_id: state.execution_id,
740
740
  fsm_session_id: fsm_session_id.to_s.freeze,
741
741
  expected_execution_revision: state.execution.execution_revision,
742
- root: @agent.agent_root,
742
+ root: @agent.send(:__root_for_operation),
743
743
  execution: state.execution,
744
744
  runtime_snapshot: invocation.runtime_snapshot,
745
745
  tool_batch_snapshot: tool_batch_snapshot,
@@ -921,6 +921,7 @@ module Phronomy
921
921
  @approval_resume_commit.commit(operation)
922
922
  end
923
923
  submitted = true
924
+ request.result_task.track(task) if request.result_task.respond_to?(:track)
924
925
  task.on_complete do |result, error|
925
926
  ready = ResumeCommitReady.new(
926
927
  coordinator: self,
@@ -953,7 +954,7 @@ module Phronomy
953
954
  ResumeCommitCommand.new(
954
955
  execution_id: state.execution_id,
955
956
  expected_execution_revision: state.execution.execution_revision,
956
- root: @agent.agent_root,
957
+ root: @agent.send(:__root_for_operation),
957
958
  execution: state.execution,
958
959
  approval_request_id: request.approval_request_id,
959
960
  approved: request.approved,
@@ -1254,7 +1255,7 @@ module Phronomy
1254
1255
 
1255
1256
  operation = build_terminal_operation(
1256
1257
  execution: state.execution,
1257
- root: @agent.agent_root,
1258
+ root: @agent.send(:__root_for_operation),
1258
1259
  journal_records: @agent.send(:_journal_records_snapshot),
1259
1260
  invocation: invocation,
1260
1261
  source_error: source_error,
@@ -1338,6 +1339,7 @@ module Phronomy
1338
1339
  # worker boundary. TaskResult/listener delivery state stays outside it.
1339
1340
  @outcome_committer.commit_outcome(operation)
1340
1341
  end
1342
+ delivery.result_task.track(task) if delivery.result_task.respond_to?(:track)
1341
1343
  task.on_complete do |outcome, error|
1342
1344
  ready = TerminalCommitReady.new(
1343
1345
  coordinator: self,
@@ -1440,7 +1442,7 @@ module Phronomy
1440
1442
  if operation.execution.metadata["reservation"] || operation.execution.metadata["execution_extension"]
1441
1443
  release_terminal_ownership(environment, operation.execution_id, state)
1442
1444
  environment.registry.take_agent_completion_waiters(operation.execution_id, fallback: delivery.result_task).each do |task|
1443
- task.fail(ready.error)
1445
+ fail_task(task, ready.error)
1444
1446
  end
1445
1447
  return
1446
1448
  end
@@ -1477,7 +1479,7 @@ module Phronomy
1477
1479
  delivery = ready.delivery
1478
1480
  outcome = ready.outcome
1479
1481
  release_terminal_ownership(environment, operation.execution_id, state)
1480
- environment.registry.take_agent_completion_waiters(operation.execution_id, fallback: delivery.result_task).each { |task| task.fail(outcome.error) }
1482
+ environment.registry.take_agent_completion_waiters(operation.execution_id, fallback: delivery.result_task).each { |task| fail_task(task, outcome.error) }
1481
1483
  end
1482
1484
 
1483
1485
  def suspend_execution_on_event_loop(ready, state, environment)
@@ -1501,7 +1503,7 @@ module Phronomy
1501
1503
  outcome.result
1502
1504
  )
1503
1505
  Array(state&.invocation&.config&.fetch(:phronomy_exact_observers, [])).each do |task|
1504
- task.fail(Phronomy::ExecutionRehydrationRequiredError.new("Execution #{operation.execution_id} requires approval"))
1506
+ fail_task(task, Phronomy::ExecutionRehydrationRequiredError.new("Execution #{operation.execution_id} requires approval"))
1505
1507
  end
1506
1508
  end
1507
1509
 
@@ -1695,6 +1697,7 @@ module Phronomy
1695
1697
  end
1696
1698
  if execution_error
1697
1699
  if execution_error.is_a?(Phronomy::CancellationError)
1700
+ result_task.finish_operation! if result_task.respond_to?(:finish_operation!)
1698
1701
  result_task.cancel!(execution_error)
1699
1702
  else
1700
1703
  fail_task(result_task, execution_error)
@@ -187,7 +187,7 @@ module Phronomy
187
187
  def encode_failure(tx, operation, error)
188
188
  current = operation.execution
189
189
  encoded_records, call_records = encode_runtime_records(tx, operation, context_candidate: false)
190
- error_ref = tx.contents.put_json("class" => error.class.name, "message" => error.message)
190
+ error_ref = tx.contents.put_json(Phronomy::Error.diagnostic(error))
191
191
  terminal_status = ExecutionFailure.status_for(error)
192
192
  records = failure_records(operation, encoded_records, terminal_status, error_ref)
193
193
  failed = current.with(
@@ -262,10 +262,7 @@ module Phronomy
262
262
  translated_error = error
263
263
  failed = next_root = appended = nil
264
264
  @persistence.transaction do |tx|
265
- error_ref = tx.contents.put_json(
266
- "class" => translated_error.class.name,
267
- "message" => translated_error.message
268
- )
265
+ error_ref = tx.contents.put_json(Phronomy::Error.diagnostic(translated_error))
269
266
  status = ExecutionFailure.status_for(translated_error)
270
267
  records = preparation_failure_records(execution, root, status, error_ref)
271
268
  appended = tx.journals.append(
@@ -29,10 +29,7 @@ module Phronomy
29
29
  output_ref = assistant_output_ref(tx, outcome)
30
30
  assistant_ref = assistant_message_ref(tx, outcome)
31
31
  error_ref = if call_error
32
- tx.contents.put_json(
33
- "class" => call_error.class.name,
34
- "message" => call_error.message
35
- )
32
+ tx.contents.put_json(Phronomy::Error.diagnostic(call_error))
36
33
  end
37
34
  usage_ref = if outcome && outcome.usage.to_h.values.any? { |value| !value.nil? }
38
35
  tx.contents.put_json(json_value(outcome.usage.to_h))
@@ -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