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.
- checksums.yaml +4 -4
- data/.mutant.yml +3 -4
- data/CHANGELOG.md +200 -10
- data/CONTRIBUTING.md +81 -9
- data/README.md +15 -6
- data/VERIFY.sh +587 -0
- data/benchmark/bench_agent_invoke.rb +2 -2
- data/benchmark/bench_context_assembler.rb +39 -68
- data/benchmark/bench_regression.rb +2 -2
- data/docs/architecture/agent-context.md +174 -0
- data/docs/architecture/before-llm-input.md +78 -0
- data/docs/architecture/context-management.md +232 -0
- data/docs/architecture/knowledge-and-rag.md +130 -0
- data/docs/architecture/multi-agent-handoff.md +152 -0
- data/docs/architecture/persistence.md +175 -0
- data/docs/architecture/removed/agent-context.md +72 -0
- data/docs/architecture/security-boundaries.md +173 -0
- data/docs/architecture/tracing.md +194 -0
- data/docs/architecture.md +82 -0
- data/docs/archive/design/archived/04_api_design.md +507 -0
- data/docs/archive/design/archived/09_guardrails.md +186 -0
- data/docs/archive/design/archived/17_rails_integration.md +175 -0
- data/docs/archive/design/historical/00_design_philosophy.md +122 -0
- data/docs/archive/design/historical/01_rubyllm_evaluation.md +178 -0
- data/docs/archive/design/historical/06_design_decisions.md +143 -0
- data/docs/changelog/0.14-and-earlier.md +1 -1
- data/docs/decisions/001-rubyllm-as-provider-layer.md +6 -1
- data/docs/decisions/002-workflow-context-immutability.md +26 -1
- data/docs/decisions/006-no-built-in-guardrails.md +2 -1
- data/docs/decisions/012-canonical-execution-log-and-context-policy.md +120 -38
- data/docs/decisions/014-unified-persistence-durable-state.md +9 -2
- data/docs/decisions/016-semantic-multi-agent-handoff.md +112 -0
- data/docs/decisions/017-design-authority-and-adr-governance.md +200 -0
- data/docs/decisions/018-durability-guarantees-and-failure-model.md +488 -0
- data/docs/decisions/019-filter-contract-and-security-boundaries.md +229 -0
- data/docs/decisions/020-canonical-workflow-instance-identity.md +177 -0
- data/docs/decisions/021-generic-agent-invocation-identity-removal.md +119 -0
- data/docs/decisions/022-agent-execution-parent-identity-and-runtime-routing-boundary.md +193 -0
- data/docs/decisions/023-fsm-session-incarnation-identity-and-routing.md +139 -0
- data/docs/decisions/024-event-loop-single-writer-agent-runtime.md +188 -0
- data/docs/decisions/025-process-local-agent-ownership-and-runtime-admission.md +249 -0
- data/docs/decisions/026-workflow-runtime-admission-and-durable-terminal-barrier.md +257 -0
- data/docs/decisions/027-llm-adapter-provider-boundary.md +93 -0
- data/docs/decisions/README.md +172 -0
- data/docs/features.md +31 -11
- data/docs/getting-started.md +77 -45
- data/docs/migrations/0.19.md +14 -7
- data/docs/migrations/0.22.md +390 -0
- data/docs/persistence-backends.md +88 -38
- data/docs/runtime-and-concurrency.md +227 -33
- data/examples/README.md +13 -0
- data/lib/phronomy/agent/agent_execution.rb +19 -15
- data/lib/phronomy/agent/agent_invocation.rb +288 -93
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +236 -202
- data/lib/phronomy/agent/agent_root.rb +3 -3
- data/lib/phronomy/agent/approval_evaluation_request.rb +37 -19
- data/lib/phronomy/agent/async_event_api.rb +145 -72
- data/lib/phronomy/agent/base.rb +388 -181
- data/lib/phronomy/agent/concerns/before_llm_input.rb +1 -1
- data/lib/phronomy/agent/context_assembler.rb +437 -178
- data/lib/phronomy/agent/context_candidate_resolver.rb +2 -2
- data/lib/phronomy/agent/context_plan.rb +18 -13
- data/lib/phronomy/agent/context_plan_validator.rb +246 -88
- data/lib/phronomy/agent/context_policies/default.rb +123 -34
- data/lib/phronomy/agent/context_policy.rb +109 -3
- data/lib/phronomy/agent/context_policy_input.rb +244 -0
- data/lib/phronomy/agent/context_policy_input_builder.rb +241 -0
- data/lib/phronomy/agent/execution_coordinator.rb +1975 -587
- data/lib/phronomy/agent/journal_record.rb +17 -4
- data/lib/phronomy/agent/llm_input_build_context.rb +1 -1
- data/lib/phronomy/agent/llm_input_manifest.rb +277 -2
- data/lib/phronomy/agent/llm_operation_result.rb +12 -7
- data/lib/phronomy/agent/phase_machine_builder.rb +19 -7
- data/lib/phronomy/agent/provider_call_outcome.rb +23 -7
- data/lib/phronomy/agent/recovery_coordinator/continuation.rb +271 -0
- data/lib/phronomy/agent/recovery_coordinator/installation.rb +427 -0
- data/lib/phronomy/agent/recovery_coordinator/resolution.rb +635 -0
- data/lib/phronomy/agent/recovery_coordinator.rb +211 -0
- data/lib/phronomy/agent/recovery_support.rb +512 -0
- data/lib/phronomy/agent/ruby_llm_materializer.rb +22 -13
- data/lib/phronomy/agent/selection/candidate.rb +53 -0
- data/lib/phronomy/agent/selection/constraint.rb +49 -0
- data/lib/phronomy/agent/shared_state.rb +38 -1
- data/lib/phronomy/agent/tool_approval_request.rb +33 -5
- data/lib/phronomy/agent/tool_definition_set.rb +49 -3
- data/lib/phronomy/agent/tool_invocation.rb +336 -102
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +49 -45
- data/lib/phronomy/agent.rb +20 -2
- data/lib/phronomy/agent_already_exists_error.rb +5 -0
- data/lib/phronomy/agent_purged_error.rb +5 -0
- data/lib/phronomy/engine/concurrency/offload_pool.rb +17 -3
- data/lib/phronomy/engine/concurrency/physical_completion_task.rb +135 -0
- data/lib/phronomy/engine/event_loop.rb +622 -63
- data/lib/phronomy/engine/fsm_session.rb +194 -21
- data/lib/phronomy/engine/runtime/agent_ownership_registry.rb +352 -0
- data/lib/phronomy/engine/runtime.rb +77 -20
- data/lib/phronomy/generator_verifier.rb +12 -14
- data/lib/phronomy/invocation_context.rb +9 -29
- data/lib/phronomy/multi_agent/admission_registry.rb +51 -0
- data/lib/phronomy/multi_agent/coordination_state.rb +18 -0
- data/lib/phronomy/multi_agent/coordinator.rb +154 -0
- data/lib/phronomy/multi_agent/execution_coordinator.rb +116 -0
- data/lib/phronomy/multi_agent/fan_out_invocation.rb +24 -33
- data/lib/phronomy/multi_agent/fan_out_session_builder.rb +12 -19
- data/lib/phronomy/multi_agent/handoff.rb +24 -45
- data/lib/phronomy/multi_agent/handoff_capability_factory.rb +87 -0
- data/lib/phronomy/multi_agent/handoff_context.rb +95 -0
- data/lib/phronomy/multi_agent/handoff_policy.rb +137 -0
- data/lib/phronomy/multi_agent/handoff_projection.rb +191 -0
- data/lib/phronomy/multi_agent/handoff_request.rb +45 -0
- data/lib/phronomy/multi_agent/orchestrator.rb +12 -15
- data/lib/phronomy/multi_agent/runner.rb +98 -0
- data/lib/phronomy/persistence/durable_codec.rb +646 -0
- data/lib/phronomy/persistence/durable_record.rb +117 -0
- data/lib/phronomy/persistence/in_memory.rb +210 -134
- data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
- data/lib/phronomy/persistence/repository_facades.rb +316 -0
- data/lib/phronomy/persistence.rb +81 -41
- data/lib/phronomy/recovery.rb +186 -0
- data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +2 -2
- data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +1 -1
- data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +19 -19
- data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +3 -3
- data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +5 -5
- data/lib/phronomy/tracing/automatic.rb +176 -0
- data/lib/phronomy/tracing/base.rb +11 -2
- data/lib/phronomy/tracing/langfuse_tracer.rb +20 -12
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +3 -6
- data/lib/phronomy/workflow_context.rb +14 -5
- data/lib/phronomy/workflow_recovery.rb +123 -0
- data/lib/phronomy/workflow_runner.rb +468 -256
- data/lib/phronomy.rb +6 -0
- data/scripts/api_snapshot.rb +12 -0
- data/sig/phronomy/agent.rbs +209 -7
- data/sig/phronomy/multi_agent.rbs +39 -0
- data/sig/phronomy/persistence.rbs +62 -4
- data/sig/phronomy/runtime.rbs +1 -4
- data/sig/phronomy/workflow.rbs +2 -2
- data/sig/phronomy.rbs +10 -0
- metadata +65 -17
- data/examples/workflows/agent_event_mapping.rb +0 -101
- data/examples/workflows/generic_task_event_mapping.rb +0 -66
- data/lib/phronomy/agent/activation_registry.rb +0 -28
- data/lib/phronomy/agent/agent_execution_activation.rb +0 -172
- data/lib/phronomy/agent/context_candidate.rb +0 -47
- data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +0 -53
- data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +0 -56
- data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +0 -30
- data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +0 -118
- data/lib/phronomy/agent/context_policy_descriptor.rb +0 -49
- data/lib/phronomy/agent/context_policy_registry.rb +0 -46
- data/lib/phronomy/agent/context_request.rb +0 -35
- data/lib/phronomy/agent/context_selection_unit.rb +0 -38
- data/lib/phronomy/agent/derived_content_spec.rb +0 -34
- 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
|
-
|
|
95
|
+
context_metadata: {},
|
|
96
|
+
event_sink: nil,
|
|
97
|
+
identity_reservation: nil,
|
|
98
|
+
terminal_barrier: nil
|
|
30
99
|
)
|
|
31
|
-
@id =
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
231
|
-
|
|
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: {
|
|
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
|