phronomy 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +3 -4
  3. data/CHANGELOG.md +200 -10
  4. data/CONTRIBUTING.md +81 -9
  5. data/README.md +15 -6
  6. data/VERIFY.sh +587 -0
  7. data/benchmark/bench_agent_invoke.rb +2 -2
  8. data/benchmark/bench_context_assembler.rb +39 -68
  9. data/benchmark/bench_regression.rb +2 -2
  10. data/docs/architecture/agent-context.md +174 -0
  11. data/docs/architecture/before-llm-input.md +78 -0
  12. data/docs/architecture/context-management.md +232 -0
  13. data/docs/architecture/knowledge-and-rag.md +130 -0
  14. data/docs/architecture/multi-agent-handoff.md +152 -0
  15. data/docs/architecture/persistence.md +175 -0
  16. data/docs/architecture/removed/agent-context.md +72 -0
  17. data/docs/architecture/security-boundaries.md +173 -0
  18. data/docs/architecture/tracing.md +194 -0
  19. data/docs/architecture.md +82 -0
  20. data/docs/archive/design/archived/04_api_design.md +507 -0
  21. data/docs/archive/design/archived/09_guardrails.md +186 -0
  22. data/docs/archive/design/archived/17_rails_integration.md +175 -0
  23. data/docs/archive/design/historical/00_design_philosophy.md +122 -0
  24. data/docs/archive/design/historical/01_rubyllm_evaluation.md +178 -0
  25. data/docs/archive/design/historical/06_design_decisions.md +143 -0
  26. data/docs/changelog/0.14-and-earlier.md +1 -1
  27. data/docs/decisions/001-rubyllm-as-provider-layer.md +6 -1
  28. data/docs/decisions/002-workflow-context-immutability.md +26 -1
  29. data/docs/decisions/006-no-built-in-guardrails.md +2 -1
  30. data/docs/decisions/012-canonical-execution-log-and-context-policy.md +120 -38
  31. data/docs/decisions/014-unified-persistence-durable-state.md +9 -2
  32. data/docs/decisions/016-semantic-multi-agent-handoff.md +112 -0
  33. data/docs/decisions/017-design-authority-and-adr-governance.md +200 -0
  34. data/docs/decisions/018-durability-guarantees-and-failure-model.md +488 -0
  35. data/docs/decisions/019-filter-contract-and-security-boundaries.md +229 -0
  36. data/docs/decisions/020-canonical-workflow-instance-identity.md +177 -0
  37. data/docs/decisions/021-generic-agent-invocation-identity-removal.md +119 -0
  38. data/docs/decisions/022-agent-execution-parent-identity-and-runtime-routing-boundary.md +193 -0
  39. data/docs/decisions/023-fsm-session-incarnation-identity-and-routing.md +139 -0
  40. data/docs/decisions/024-event-loop-single-writer-agent-runtime.md +188 -0
  41. data/docs/decisions/025-process-local-agent-ownership-and-runtime-admission.md +249 -0
  42. data/docs/decisions/026-workflow-runtime-admission-and-durable-terminal-barrier.md +257 -0
  43. data/docs/decisions/027-llm-adapter-provider-boundary.md +93 -0
  44. data/docs/decisions/README.md +172 -0
  45. data/docs/features.md +31 -11
  46. data/docs/getting-started.md +77 -45
  47. data/docs/migrations/0.19.md +14 -7
  48. data/docs/migrations/0.22.md +390 -0
  49. data/docs/persistence-backends.md +88 -38
  50. data/docs/runtime-and-concurrency.md +227 -33
  51. data/examples/README.md +13 -0
  52. data/lib/phronomy/agent/agent_execution.rb +19 -15
  53. data/lib/phronomy/agent/agent_invocation.rb +288 -93
  54. data/lib/phronomy/agent/agent_invocation_session_builder.rb +236 -202
  55. data/lib/phronomy/agent/agent_root.rb +3 -3
  56. data/lib/phronomy/agent/approval_evaluation_request.rb +37 -19
  57. data/lib/phronomy/agent/async_event_api.rb +145 -72
  58. data/lib/phronomy/agent/base.rb +388 -181
  59. data/lib/phronomy/agent/concerns/before_llm_input.rb +1 -1
  60. data/lib/phronomy/agent/context_assembler.rb +437 -178
  61. data/lib/phronomy/agent/context_candidate_resolver.rb +2 -2
  62. data/lib/phronomy/agent/context_plan.rb +18 -13
  63. data/lib/phronomy/agent/context_plan_validator.rb +246 -88
  64. data/lib/phronomy/agent/context_policies/default.rb +123 -34
  65. data/lib/phronomy/agent/context_policy.rb +109 -3
  66. data/lib/phronomy/agent/context_policy_input.rb +244 -0
  67. data/lib/phronomy/agent/context_policy_input_builder.rb +241 -0
  68. data/lib/phronomy/agent/execution_coordinator.rb +1975 -587
  69. data/lib/phronomy/agent/journal_record.rb +17 -4
  70. data/lib/phronomy/agent/llm_input_build_context.rb +1 -1
  71. data/lib/phronomy/agent/llm_input_manifest.rb +277 -2
  72. data/lib/phronomy/agent/llm_operation_result.rb +12 -7
  73. data/lib/phronomy/agent/phase_machine_builder.rb +19 -7
  74. data/lib/phronomy/agent/provider_call_outcome.rb +23 -7
  75. data/lib/phronomy/agent/recovery_coordinator/continuation.rb +271 -0
  76. data/lib/phronomy/agent/recovery_coordinator/installation.rb +427 -0
  77. data/lib/phronomy/agent/recovery_coordinator/resolution.rb +635 -0
  78. data/lib/phronomy/agent/recovery_coordinator.rb +211 -0
  79. data/lib/phronomy/agent/recovery_support.rb +512 -0
  80. data/lib/phronomy/agent/ruby_llm_materializer.rb +22 -13
  81. data/lib/phronomy/agent/selection/candidate.rb +53 -0
  82. data/lib/phronomy/agent/selection/constraint.rb +49 -0
  83. data/lib/phronomy/agent/shared_state.rb +38 -1
  84. data/lib/phronomy/agent/tool_approval_request.rb +33 -5
  85. data/lib/phronomy/agent/tool_definition_set.rb +49 -3
  86. data/lib/phronomy/agent/tool_invocation.rb +336 -102
  87. data/lib/phronomy/agent/tool_invocation_session_builder.rb +49 -45
  88. data/lib/phronomy/agent.rb +20 -2
  89. data/lib/phronomy/agent_already_exists_error.rb +5 -0
  90. data/lib/phronomy/agent_purged_error.rb +5 -0
  91. data/lib/phronomy/engine/concurrency/offload_pool.rb +17 -3
  92. data/lib/phronomy/engine/concurrency/physical_completion_task.rb +135 -0
  93. data/lib/phronomy/engine/event_loop.rb +622 -63
  94. data/lib/phronomy/engine/fsm_session.rb +194 -21
  95. data/lib/phronomy/engine/runtime/agent_ownership_registry.rb +352 -0
  96. data/lib/phronomy/engine/runtime.rb +77 -20
  97. data/lib/phronomy/generator_verifier.rb +12 -14
  98. data/lib/phronomy/invocation_context.rb +9 -29
  99. data/lib/phronomy/multi_agent/admission_registry.rb +51 -0
  100. data/lib/phronomy/multi_agent/coordination_state.rb +18 -0
  101. data/lib/phronomy/multi_agent/coordinator.rb +154 -0
  102. data/lib/phronomy/multi_agent/execution_coordinator.rb +116 -0
  103. data/lib/phronomy/multi_agent/fan_out_invocation.rb +24 -33
  104. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +12 -19
  105. data/lib/phronomy/multi_agent/handoff.rb +24 -45
  106. data/lib/phronomy/multi_agent/handoff_capability_factory.rb +87 -0
  107. data/lib/phronomy/multi_agent/handoff_context.rb +95 -0
  108. data/lib/phronomy/multi_agent/handoff_policy.rb +137 -0
  109. data/lib/phronomy/multi_agent/handoff_projection.rb +191 -0
  110. data/lib/phronomy/multi_agent/handoff_request.rb +45 -0
  111. data/lib/phronomy/multi_agent/orchestrator.rb +12 -15
  112. data/lib/phronomy/multi_agent/runner.rb +98 -0
  113. data/lib/phronomy/persistence/durable_codec.rb +646 -0
  114. data/lib/phronomy/persistence/durable_record.rb +117 -0
  115. data/lib/phronomy/persistence/in_memory.rb +210 -134
  116. data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
  117. data/lib/phronomy/persistence/repository_facades.rb +316 -0
  118. data/lib/phronomy/persistence.rb +81 -41
  119. data/lib/phronomy/recovery.rb +186 -0
  120. data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +2 -2
  121. data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +1 -1
  122. data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +19 -19
  123. data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +3 -3
  124. data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +5 -5
  125. data/lib/phronomy/tracing/automatic.rb +176 -0
  126. data/lib/phronomy/tracing/base.rb +11 -2
  127. data/lib/phronomy/tracing/langfuse_tracer.rb +20 -12
  128. data/lib/phronomy/version.rb +1 -1
  129. data/lib/phronomy/workflow.rb +3 -6
  130. data/lib/phronomy/workflow_context.rb +14 -5
  131. data/lib/phronomy/workflow_recovery.rb +123 -0
  132. data/lib/phronomy/workflow_runner.rb +468 -256
  133. data/lib/phronomy.rb +6 -0
  134. data/scripts/api_snapshot.rb +12 -0
  135. data/sig/phronomy/agent.rbs +209 -7
  136. data/sig/phronomy/multi_agent.rbs +39 -0
  137. data/sig/phronomy/persistence.rbs +62 -4
  138. data/sig/phronomy/runtime.rbs +1 -4
  139. data/sig/phronomy/workflow.rbs +2 -2
  140. data/sig/phronomy.rbs +10 -0
  141. metadata +65 -17
  142. data/examples/workflows/agent_event_mapping.rb +0 -101
  143. data/examples/workflows/generic_task_event_mapping.rb +0 -66
  144. data/lib/phronomy/agent/activation_registry.rb +0 -28
  145. data/lib/phronomy/agent/agent_execution_activation.rb +0 -172
  146. data/lib/phronomy/agent/context_candidate.rb +0 -47
  147. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +0 -53
  148. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +0 -56
  149. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +0 -30
  150. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +0 -118
  151. data/lib/phronomy/agent/context_policy_descriptor.rb +0 -49
  152. data/lib/phronomy/agent/context_policy_registry.rb +0 -46
  153. data/lib/phronomy/agent/context_request.rb +0 -35
  154. data/lib/phronomy/agent/context_selection_unit.rb +0 -38
  155. data/lib/phronomy/agent/derived_content_spec.rb +0 -34
  156. data/lib/phronomy/agent/runner.rb +0 -97
@@ -40,18 +40,28 @@ module Phronomy
40
40
  ]
41
41
  }.freeze
42
42
 
43
- def self.build(tool_invocation:, runtime: Phronomy::Runtime.instance)
44
- build_session(tool_invocation: tool_invocation, runtime: runtime)
43
+ def self.build(
44
+ tool_invocation:,
45
+ parent_event_sink:,
46
+ runtime: Phronomy::Runtime.instance
47
+ )
48
+ build_session(
49
+ tool_invocation: tool_invocation,
50
+ parent_event_sink: parent_event_sink,
51
+ runtime: runtime
52
+ )
45
53
  end
46
54
 
47
55
  def self.build_for_resume(
48
56
  tool_invocation:,
57
+ parent_event_sink:,
49
58
  resume_event:,
50
59
  resume_phase:,
51
60
  runtime: Phronomy::Runtime.instance
52
61
  )
53
62
  build_session(
54
63
  tool_invocation: tool_invocation,
64
+ parent_event_sink: parent_event_sink,
55
65
  runtime: runtime,
56
66
  resume_event: resume_event,
57
67
  resume_phase: resume_phase
@@ -60,16 +70,18 @@ module Phronomy
60
70
 
61
71
  def self.build_session(
62
72
  tool_invocation:,
73
+ parent_event_sink:,
63
74
  runtime:,
64
75
  resume_event: nil,
65
76
  resume_phase: nil
66
77
  )
67
- actions = build_entry_actions(runtime)
78
+ event_sink = Phronomy::FSMSession::EventSink.new(event_loop: runtime.event_loop)
79
+ actions = build_entry_actions(runtime, event_sink, parent_event_sink)
68
80
  phase_machine = build_phase_machine(actions)
69
81
 
70
82
  Phronomy::FSMSession.new(
71
- id: tool_invocation.id,
72
83
  context: tool_invocation,
84
+ event_sink: event_sink,
73
85
  entry_point: :idle,
74
86
  phase_machine_class: phase_machine,
75
87
  entry_actions: {},
@@ -85,18 +97,18 @@ module Phronomy
85
97
  end
86
98
  private_class_method :build_session
87
99
 
88
- def self.build_entry_actions(runtime)
100
+ def self.build_entry_actions(runtime, event_sink, parent_event_sink)
89
101
  {
90
102
  validating: [method(:validating_action)],
91
- authorizing: [method(:authorizing_action).curry.call(runtime)],
92
- awaiting_approval: [method(:awaiting_approval_action).curry.call(runtime)],
93
- authorized: [method(:authorized_action).curry.call(runtime)],
103
+ authorizing: [method(:authorizing_action).curry.call(runtime, event_sink)],
104
+ awaiting_approval: [method(:awaiting_approval_action).curry.call(parent_event_sink)],
105
+ authorized: [method(:authorized_action).curry.call(parent_event_sink)],
94
106
  queued: [method(:queued_action)],
95
- running: [method(:running_action).curry.call(runtime)],
96
- completed: [method(:completed_action).curry.call(runtime)],
97
- failed: [method(:failed_action).curry.call(runtime)],
98
- rejected: [method(:rejected_action).curry.call(runtime)],
99
- cancelled: [method(:cancelled_action).curry.call(runtime)]
107
+ running: [method(:running_action).curry.call(runtime, event_sink)],
108
+ completed: [method(:completed_action).curry.call(parent_event_sink)],
109
+ failed: [method(:failed_action).curry.call(parent_event_sink)],
110
+ rejected: [method(:rejected_action).curry.call(parent_event_sink)],
111
+ cancelled: [method(:cancelled_action).curry.call(parent_event_sink)]
100
112
  }
101
113
  end
102
114
  private_class_method :build_entry_actions
@@ -183,24 +195,24 @@ module Phronomy
183
195
  end
184
196
  private_class_method :validating_action
185
197
 
186
- def self.authorizing_action(runtime, invocation)
198
+ def self.authorizing_action(runtime, event_sink, invocation)
187
199
  invocation.start_authorization(runtime: runtime) do |outcome|
188
- post_to_invocation(runtime, invocation.id, :authorization_completed, outcome)
200
+ post_to_session(event_sink, :authorization_completed, outcome)
189
201
  end
190
202
  invocation
191
203
  end
192
204
  private_class_method :authorizing_action
193
205
 
194
- def self.awaiting_approval_action(runtime, invocation)
206
+ def self.awaiting_approval_action(parent_event_sink, invocation)
195
207
  invocation.mark_awaiting_approval!
196
- notify_parent(runtime, invocation, :tool_approval_required)
208
+ notify_parent(parent_event_sink, invocation, :tool_approval_required)
197
209
  invocation
198
210
  end
199
211
  private_class_method :awaiting_approval_action
200
212
 
201
- def self.authorized_action(runtime, invocation)
213
+ def self.authorized_action(parent_event_sink, invocation)
202
214
  invocation.mark_authorized!
203
- notify_parent(runtime, invocation, :tool_authorized)
215
+ notify_parent(parent_event_sink, invocation, :tool_authorized)
204
216
  invocation
205
217
  end
206
218
  private_class_method :authorized_action
@@ -210,61 +222,53 @@ module Phronomy
210
222
  end
211
223
  private_class_method :queued_action
212
224
 
213
- def self.running_action(runtime, invocation)
225
+ def self.running_action(runtime, event_sink, invocation)
214
226
  invocation.start_execution(runtime: runtime) do |outcome|
215
- post_to_invocation(runtime, invocation.id, :execution_completed, outcome)
227
+ post_to_session(event_sink, :execution_completed, outcome)
216
228
  end
217
229
  invocation.mark_running!
218
230
  invocation
219
231
  end
220
232
  private_class_method :running_action
221
233
 
222
- def self.completed_action(runtime, invocation)
223
- notify_parent(runtime, invocation, :tool_completed)
234
+ def self.completed_action(parent_event_sink, invocation)
235
+ notify_parent(parent_event_sink, invocation, :tool_completed)
224
236
  invocation
225
237
  end
226
238
  private_class_method :completed_action
227
239
 
228
- def self.failed_action(runtime, invocation)
229
- notify_parent(runtime, invocation, :tool_failed)
240
+ def self.failed_action(parent_event_sink, invocation)
241
+ notify_parent(parent_event_sink, invocation, :tool_failed)
230
242
  invocation
231
243
  end
232
244
  private_class_method :failed_action
233
245
 
234
- def self.rejected_action(runtime, invocation)
246
+ def self.rejected_action(parent_event_sink, invocation)
235
247
  invocation.mark_rejected!
236
- notify_parent(runtime, invocation, :tool_rejected)
248
+ notify_parent(parent_event_sink, invocation, :tool_rejected)
237
249
  invocation
238
250
  end
239
251
  private_class_method :rejected_action
240
252
 
241
- def self.cancelled_action(runtime, invocation)
253
+ def self.cancelled_action(parent_event_sink, invocation)
242
254
  invocation.mark_cancelled!
243
- notify_parent(runtime, invocation, :tool_cancelled)
255
+ notify_parent(parent_event_sink, invocation, :tool_cancelled)
244
256
  invocation
245
257
  end
246
258
  private_class_method :cancelled_action
247
259
 
248
- def self.post_to_invocation(runtime, id, event_type, payload)
249
- accepted = runtime.event_loop.post_to_session(
250
- Phronomy::Event.new(type: event_type, target_id: id, payload: payload)
251
- )
252
- return if accepted
260
+ def self.post_to_session(event_sink, event_type, payload)
261
+ return if event_sink.post(event_type, payload)
253
262
 
254
263
  Phronomy.configuration.logger&.warn(
255
- "[Phronomy] Dropped #{event_type.inspect} for ToolInvocation #{id}"
264
+ "[Phronomy] Dropped #{event_type.inspect} for " \
265
+ "FSMSession #{event_sink.fsm_session_id}"
256
266
  )
257
267
  end
258
- private_class_method :post_to_invocation
259
-
260
- def self.notify_parent(runtime, invocation, event_type)
261
- runtime.event_loop.post_to_session(
262
- Phronomy::Event.new(
263
- type: event_type,
264
- target_id: invocation.parent_agent_invocation_id,
265
- payload: {tool_invocation_id: invocation.id}
266
- )
267
- )
268
+ private_class_method :post_to_session
269
+
270
+ def self.notify_parent(parent_event_sink, invocation, event_type)
271
+ parent_event_sink.post(event_type, {tool_invocation_id: invocation.id})
268
272
  end
269
273
  private_class_method :notify_parent
270
274
  end
@@ -4,12 +4,26 @@ module Phronomy
4
4
  module Agent
5
5
  StreamEvent = Data.define(:type, :payload)
6
6
 
7
- def self.run_once(definition:, input:, context: nil, knowledge: [], **invoke_options)
7
+ def self.run_once(
8
+ definition:,
9
+ input:,
10
+ context: nil,
11
+ knowledge: [],
12
+ on_event: nil,
13
+ **invoke_options,
14
+ &event_block
15
+ )
16
+ if on_event && event_block
17
+ raise ArgumentError, "Provide either on_event: or a block, not both"
18
+ end
19
+
8
20
  persistence = Phronomy::Persistence::InMemory.new
9
21
  agent = definition.create(
10
22
  context: context,
11
23
  knowledge: knowledge,
12
- persistence: persistence
24
+ persistence: persistence,
25
+ on_event: on_event,
26
+ &event_block
13
27
  )
14
28
  agent.invoke(input, **invoke_options)
15
29
  end
@@ -21,3 +35,7 @@ require_relative "agent/async_event_api"
21
35
  unless Phronomy::Agent::Base < Phronomy::Agent::AsyncEventApi
22
36
  Phronomy::Agent::Base.prepend(Phronomy::Agent::AsyncEventApi)
23
37
  end
38
+
39
+ # Durable Agent Recovery and Agent-incarnation event binding.
40
+ require_relative "agent/recovery_support"
41
+ require_relative "agent/recovery_coordinator"
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class AgentAlreadyExistsError < Phronomy::Error; end
5
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class AgentPurgedError < Phronomy::Error; end
5
+ end
@@ -65,7 +65,9 @@ module Phronomy
65
65
  @timeout = timeout
66
66
  @cancellation_token = cancellation_token
67
67
  @on_abandoned = on_abandoned
68
- @task = Phronomy::Task.deferred(name: task_name)
68
+ @task = Phronomy::Concurrency::PhysicalCompletionTask.deferred(
69
+ name: task_name
70
+ )
69
71
  @settled = false
70
72
  @timed_out = false
71
73
  @cancelled = false
@@ -162,6 +164,7 @@ module Phronomy
162
164
  else
163
165
  @task.complete(nil)
164
166
  end
167
+ @task.mark_physical_complete!
165
168
  changed
166
169
  end
167
170
 
@@ -224,6 +227,7 @@ module Phronomy
224
227
 
225
228
  detach_submit_cancellation
226
229
  notify_abandoned if abandoned_now
230
+ @task.mark_physical_complete! unless abandoned_now
227
231
  if cancelled
228
232
  @task.cancel!(error)
229
233
  else
@@ -251,18 +255,26 @@ module Phronomy
251
255
 
252
256
  def complete_with_value!(value)
253
257
  changed = claim_terminal!
254
- return false unless changed
258
+ unless changed
259
+ @task.mark_physical_complete!
260
+ return false
261
+ end
255
262
 
256
263
  detach_submit_cancellation
264
+ @task.mark_physical_complete!
257
265
  @task.complete(value)
258
266
  true
259
267
  end
260
268
 
261
269
  def complete_with_error!(error)
262
270
  changed = claim_terminal!
263
- return false unless changed
271
+ unless changed
272
+ @task.mark_physical_complete!
273
+ return false
274
+ end
264
275
 
265
276
  detach_submit_cancellation
277
+ @task.mark_physical_complete!
266
278
  @task.fail(error)
267
279
  true
268
280
  end
@@ -581,6 +593,8 @@ module Phronomy
581
593
  @completed_count += 1
582
594
  end
583
595
 
596
+ operation.task.mark_physical_complete!
597
+
584
598
  if abandoned
585
599
  @logger&.warn do
586
600
  "OffloadPool: worker finished after caller-facing timeout/cancellation settlement"
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Concurrency
5
+ # Private Task specialization used when logical completion and physical
6
+ # completion are intentionally different boundaries.
7
+ #
8
+ # Offload cancellation/timeout may settle the caller-facing Task while a
9
+ # worker is still executing. EventLoop supervision must observe the later
10
+ # physical-completion boundary without adding domain-specific state to the
11
+ # public Phronomy::Task contract.
12
+ #
13
+ # @api private
14
+ class PhysicalCompletionTask < Phronomy::Task
15
+ # @api private
16
+ def initialize(name: nil, parent: nil)
17
+ super
18
+ @physical_mutex = Mutex.new
19
+ @physical_complete = false
20
+ @physical_callbacks = []
21
+ end
22
+
23
+ # @return [Boolean]
24
+ # @api private
25
+ def physical_complete?
26
+ @physical_mutex.synchronize { @physical_complete }
27
+ end
28
+
29
+ # Registers a callback that fires only when the underlying physical work
30
+ # can no longer affect the owning execution.
31
+ #
32
+ # @return [self]
33
+ # @api private
34
+ def on_physical_complete(&callback)
35
+ raise ArgumentError, "on_physical_complete requires a block" unless callback
36
+
37
+ fire_now = @physical_mutex.synchronize do
38
+ if @physical_complete
39
+ true
40
+ else
41
+ @physical_callbacks << callback
42
+ false
43
+ end
44
+ end
45
+ deliver_physical_callback(callback) if fire_now
46
+ self
47
+ end
48
+
49
+ # Idempotently marks the physical work boundary complete.
50
+ #
51
+ # @return [self]
52
+ # @api private
53
+ def mark_physical_complete!
54
+ callbacks = @physical_mutex.synchronize do
55
+ return self if @physical_complete
56
+
57
+ @physical_complete = true
58
+ current = @physical_callbacks
59
+ @physical_callbacks = []
60
+ current
61
+ end
62
+ callbacks.each { |callback| deliver_physical_callback(callback) }
63
+ self
64
+ end
65
+
66
+ # Preserves the physical-completion boundary across Task#map.
67
+ #
68
+ # A mapped PhysicalCompletionTask is physically complete only after both:
69
+ # 1. the source Task's underlying physical work is complete; and
70
+ # 2. the mapping callback itself has finished running.
71
+ #
72
+ # The second condition is required because Task completion callbacks may run
73
+ # on the OffloadPool worker that settles the source Task. Propagating the
74
+ # source physical signal immediately could otherwise let EventLoop declare
75
+ # the owning Execution quiescent while the mapping callback is still active.
76
+ #
77
+ # Logical success/failure semantics intentionally remain the same as
78
+ # Phronomy::Task#map.
79
+ #
80
+ # @api public
81
+ def map(&block)
82
+ raise ArgumentError, "map requires a block" unless block
83
+
84
+ mapped = self.class.deferred(name: "#{name}-mapped", parent: parent)
85
+ propagation_mutex = Mutex.new
86
+ source_physical_complete = physical_complete?
87
+ mapping_complete = false
88
+
89
+ mark_mapped_physical_if_ready = lambda do
90
+ ready = propagation_mutex.synchronize do
91
+ source_physical_complete && mapping_complete
92
+ end
93
+ mapped.mark_physical_complete! if ready
94
+ end
95
+
96
+ on_physical_complete do
97
+ propagation_mutex.synchronize { source_physical_complete = true }
98
+ mark_mapped_physical_if_ready.call
99
+ end
100
+
101
+ on_complete do |value, error|
102
+ if error
103
+ propagation_mutex.synchronize { mapping_complete = true }
104
+ mark_mapped_physical_if_ready.call
105
+ mapped.fail(error)
106
+ next
107
+ end
108
+
109
+ begin
110
+ transformed = block.call(value)
111
+ propagation_mutex.synchronize { mapping_complete = true }
112
+ mark_mapped_physical_if_ready.call
113
+ mapped.complete(transformed)
114
+ rescue => mapped_error
115
+ propagation_mutex.synchronize { mapping_complete = true }
116
+ mark_mapped_physical_if_ready.call
117
+ mapped.fail(mapped_error)
118
+ end
119
+ end
120
+
121
+ mapped
122
+ end
123
+
124
+ private
125
+
126
+ def deliver_physical_callback(callback)
127
+ callback.call
128
+ rescue => error
129
+ Phronomy.configuration.logger&.error do
130
+ "[PhysicalCompletionTask] callback raised #{error.class}: #{error.message}"
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end