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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +96 -1
- data/README.md +32 -6
- data/docs/application-recipes.md +229 -0
- data/docs/architecture/agent-configuration-and-tool-binding.md +8 -0
- data/docs/architecture/agent-iteration-budget.md +81 -0
- data/docs/architecture/dx05-agent-scope.md +126 -0
- data/docs/architecture/dx05-workflow-task-state.md +134 -0
- data/docs/architecture/error-api-review.md +245 -0
- data/docs/architecture/generator-verifier-lifecycle-result.md +126 -0
- data/docs/architecture/generator-verifier-ownership.md +18 -109
- data/docs/architecture/llm-continuation-metadata.md +88 -0
- data/docs/architecture/lm-studio-output-failures.md +111 -0
- data/docs/architecture/r8-unit15.md +3 -0
- data/docs/architecture/r8-unit7.md +2 -1
- data/docs/architecture/structured-output-and-scope.md +183 -0
- data/docs/architecture/tracing.md +22 -4
- data/docs/architecture/transport-error-boundaries.md +103 -0
- data/docs/architecture/trust-acceptance-followup.md +87 -0
- data/docs/failure-reason-codes.md +45 -0
- data/docs/features.md +14 -2
- data/docs/getting-started.md +44 -43
- data/docs/migrations/r8-unit15.md +3 -0
- data/docs/ordered-event-delivery.md +54 -0
- data/docs/workflow-completion.md +92 -0
- data/gemfiles/http_minimum.gemfile +10 -0
- data/lib/phronomy/agent/admission.rb +1 -1
- data/lib/phronomy/agent/agent_not_found_error.rb +16 -0
- data/lib/phronomy/agent/agent_unloaded_error.rb +14 -0
- data/lib/phronomy/agent/api/agent.rb +13 -0
- data/lib/phronomy/agent/async_event_api.rb +121 -88
- data/lib/phronomy/agent/base.rb +82 -44
- data/lib/phronomy/agent/before_llm_input.rb +7 -1
- data/lib/phronomy/agent/context_importer.rb +4 -0
- data/lib/phronomy/agent/execution/agent_invocation.rb +6 -2
- data/lib/phronomy/agent/execution/dispatch_preparation.rb +6 -1
- data/lib/phronomy/agent/execution/exact_execution.rb +20 -9
- data/lib/phronomy/agent/execution/execution_cancellation.rb +1 -1
- data/lib/phronomy/agent/execution/execution_coordinator.rb +79 -18
- data/lib/phronomy/agent/execution/execution_outcome_committer.rb +24 -4
- data/lib/phronomy/agent/execution/execution_registry.rb +5 -1
- data/lib/phronomy/agent/execution/initial_preparation.rb +11 -5
- data/lib/phronomy/agent/execution/invocation_actions.rb +1 -1
- data/lib/phronomy/agent/execution/invocation_transitions.rb +4 -4
- data/lib/phronomy/agent/execution/iteration_budget.rb +35 -0
- data/lib/phronomy/agent/execution/runtime_record_encoder.rb +4 -4
- data/lib/phronomy/agent/execution_observation.rb +5 -5
- data/lib/phronomy/agent/filterable.rb +21 -15
- data/lib/phronomy/agent/lifecycle/lifetime_api.rb +76 -0
- data/lib/phronomy/agent/lifecycle/operation_lease.rb +33 -0
- data/lib/phronomy/agent/lifecycle/operation_result.rb +53 -0
- data/lib/phronomy/agent/lifecycle/ownership_registry.rb +99 -8
- data/lib/phronomy/agent/lifecycle/scope.rb +208 -0
- data/lib/phronomy/agent/lifecycle/scope_cleanup_error.rb +21 -0
- data/lib/phronomy/agent/lifecycle/unload_transition.rb +78 -0
- data/lib/phronomy/agent/output_contract.rb +64 -0
- data/lib/phronomy/agent/output_error.rb +53 -0
- data/lib/phronomy/agent/recovery/recovery_coordinator/continuation.rb +6 -6
- data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +8 -7
- data/lib/phronomy/agent/recovery/recovery_coordinator/resolution.rb +14 -18
- data/lib/phronomy/agent/recovery/recovery_coordinator.rb +6 -10
- data/lib/phronomy/agent/recovery/recovery_support.rb +6 -8
- data/lib/phronomy/agent/runtime_binding/engine_session_builder.rb +2 -1
- data/lib/phronomy/agent/runtime_input.rb +15 -4
- data/lib/phronomy/agent/saved_context_reader.rb +2 -1
- data/lib/phronomy/agent/store.rb +10 -2
- data/lib/phronomy/agent/stream_callback_error.rb +2 -2
- data/lib/phronomy/common/error.rb +65 -1
- data/lib/phronomy/common/values/immutable.rb +33 -0
- data/lib/phronomy/content_store/base.rb +7 -2
- data/lib/phronomy/context/assembly.rb +18 -22
- data/lib/phronomy/embeddings/authentication_error.rb +11 -0
- data/lib/phronomy/embeddings/backends/ruby_llm_embeddings.rb +8 -2
- data/lib/phronomy/embeddings/rate_limit_error.rb +11 -0
- data/lib/phronomy/engine/fsm_session.rb +16 -1
- data/lib/phronomy/execution/execution_cancellation_error.rb +2 -2
- data/lib/phronomy/execution/execution_timeout_error.rb +2 -2
- data/lib/phronomy/execution/task_result.rb +7 -2
- data/lib/phronomy/filter/filter_block_error.rb +2 -2
- data/lib/phronomy/generation/generator_verifier/agent_operation.rb +28 -5
- data/lib/phronomy/generation/generator_verifier/agent_result_receiver.rb +14 -27
- data/lib/phronomy/generation/generator_verifier/approval.rb +30 -0
- data/lib/phronomy/generation/generator_verifier/cleanup_error.rb +15 -0
- data/lib/phronomy/generation/generator_verifier/invocation.rb +147 -0
- data/lib/phronomy/generation/generator_verifier/pipeline_state.rb +16 -0
- data/lib/phronomy/generation/generator_verifier/protocol.rb +55 -0
- data/lib/phronomy/generation/generator_verifier/result.rb +121 -0
- data/lib/phronomy/generation/generator_verifier/unaccepted_result_error.rb +14 -0
- data/lib/phronomy/generation/generator_verifier/workflow_builder.rb +13 -11
- data/lib/phronomy/generation/generator_verifier.rb +170 -85
- data/lib/phronomy/integrations/ordered_event_delivery.rb +208 -0
- data/lib/phronomy/llm_adapter/backends/lm_studio.rb +139 -0
- data/lib/phronomy/llm_adapter/backends/ruby_llm.rb +134 -12
- data/lib/phronomy/llm_adapter/backends/structured_response_capture.rb +41 -0
- data/lib/phronomy/llm_adapter/message.rb +4 -0
- data/lib/phronomy/llm_adapter/request.rb +17 -2
- data/lib/phronomy/llm_adapter/response.rb +11 -4
- data/lib/phronomy/llm_adapter/structured_output_unsupported_error.rb +14 -0
- data/lib/phronomy/llm_adapter/transport_error.rb +4 -0
- data/lib/phronomy/multi_agent/persistence/team_execution_repository.rb +5 -5
- data/lib/phronomy/multi_agent/shared_state.rb +1 -0
- data/lib/phronomy/multi_agent/team_busy_error.rb +18 -0
- data/lib/phronomy/multi_agent/team_coordinator.rb +20 -7
- data/lib/phronomy/multi_agent/team_not_found_error.rb +18 -0
- data/lib/phronomy/output_parser/json_schema_parser.rb +113 -0
- data/lib/phronomy/output_parser/parse_error.rb +10 -1
- data/lib/phronomy/runtime_composition/agent_defaults.rb +4 -0
- data/lib/phronomy/storage/condition_failed_error.rb +2 -2
- data/lib/phronomy/storage/unique_constraint_error.rb +2 -2
- data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +1 -1
- data/lib/phronomy/tool/base.rb +25 -23
- data/lib/phronomy/tracing/automatic.rb +14 -8
- data/lib/phronomy/tracing/base.rb +37 -7
- data/lib/phronomy/tracing/langfuse_tracer.rb +3 -3
- data/lib/phronomy/tracing/observation.rb +2 -1
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +7 -32
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/execution/workflow.rb +30 -2
- data/lib/phronomy/workflow/execution/workflow_execution_environment.rb +1 -0
- data/lib/phronomy/workflow/execution/workflow_execution_registry.rb +74 -3
- data/lib/phronomy/workflow/execution/workflow_runner.rb +48 -14
- data/lib/phronomy/workflow/execution/workflow_task_execution.rb +153 -0
- data/lib/phronomy/workflow/execution/workflow_task_reception.rb +72 -0
- data/lib/phronomy/workflow/execution/workflow_task_resume_error.rb +14 -0
- data/lib/phronomy/workflow/runtime_binding/workflow_engine_environment.rb +26 -3
- data/lib/phronomy/workflow/runtime_binding/workflow_phase_machine_builder.rb +3 -1
- data/lib/phronomy/workflow/runtime_binding/workflow_task_session.rb +76 -0
- data/lib/phronomy.rb +7 -2
- data/scripts/api_snapshot.rb +15 -0
- data/scripts/check_readme_ruby.rb +24 -29
- data/scripts/check_readme_runnable.rb +92 -81
- data/scripts/migrate_generator_verifier_result.rb +18 -0
- data/sig/phronomy/agent/agent_not_found_error.rbs +6 -0
- data/sig/phronomy/agent/agent_unloaded_error.rbs +6 -0
- data/sig/phronomy/agent/execution_contract.rbs +4 -3
- data/sig/phronomy/agent/stream_callback_error.rbs +8 -0
- data/sig/phronomy/agent.rbs +36 -5
- data/sig/phronomy/embeddings/base.rbs +6 -0
- data/sig/phronomy/execution/execution.rbs +2 -2
- data/sig/phronomy/extensions.rbs +8 -2
- data/sig/phronomy/filter/filter_block_error.rbs +6 -0
- data/sig/phronomy/generation/generator_verifier.rbs +70 -0
- data/sig/phronomy/integrations.rbs +14 -0
- data/sig/phronomy/llm_adapter/backends/lm_studio.rbs +8 -0
- data/sig/phronomy/llm_adapter/backends/ruby_llm.rbs +1 -0
- data/sig/phronomy/llm_adapter/values.rbs +8 -2
- data/sig/phronomy/multi_agent/team_busy_error.rbs +8 -0
- data/sig/phronomy/multi_agent/team_not_found_error.rbs +8 -0
- data/sig/phronomy/storage/contracts.rbs +2 -2
- data/sig/phronomy/tracing/observation.rbs +1 -1
- data/sig/phronomy/workflow/execution/workflow_execution_environment.rbs +1 -0
- data/sig/phronomy/workflow.rbs +4 -0
- data/sig/phronomy.rbs +6 -0
- metadata +103 -5
- data/lib/phronomy/generation/generator_verifier/default_parser.rb +0 -27
- data/lib/phronomy/generation/low_confidence_error.rb +0 -14
- 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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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::
|
|
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
|
|
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
|