phronomy 0.25.0 → 0.26.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +31 -0
  3. data/CONTRIBUTING.md +4 -4
  4. data/README.md +10 -7
  5. data/benchmark/bench_regression.rb +1 -1
  6. data/docs/architecture/agent-context.md +1 -1
  7. data/docs/architecture/persistence.md +2 -2
  8. data/docs/architecture/tracing.md +3 -3
  9. data/docs/async-composition.md +205 -0
  10. data/docs/decisions/010-cooperative-first-concurrency.md +23 -23
  11. data/docs/features.md +12 -9
  12. data/docs/getting-started.md +6 -6
  13. data/docs/migrations/durable-semantic-coordination-v2.md +1 -1
  14. data/docs/persistence-backends.md +1 -1
  15. data/docs/runtime-and-concurrency.md +89 -29
  16. data/lib/phronomy/agent/agent_invocation_session_builder.rb +1 -1
  17. data/lib/phronomy/agent/base.rb +4 -10
  18. data/lib/phronomy/agent/exact_execution.rb +3 -3
  19. data/lib/phronomy/agent/execution_coordinator.rb +20 -7
  20. data/lib/phronomy/agent/phase_machine_builder.rb +2 -2
  21. data/lib/phronomy/agent/recovery_coordinator/continuation.rb +1 -1
  22. data/lib/phronomy/agent/recovery_coordinator/installation.rb +4 -4
  23. data/lib/phronomy/agent/recovery_coordinator.rb +3 -3
  24. data/lib/phronomy/agent/tool_executor.rb +4 -4
  25. data/lib/phronomy/agent/tool_invocation.rb +1 -1
  26. data/lib/phronomy/agent/tool_invocation_session_builder.rb +2 -2
  27. data/lib/phronomy/blocking.rb +15 -8
  28. data/lib/phronomy/engine/concurrency/offload_pool.rb +25 -16
  29. data/lib/phronomy/engine/concurrency/operation_binding.rb +43 -0
  30. data/lib/phronomy/engine/concurrency/physical_completion_task.rb +4 -62
  31. data/lib/phronomy/engine/concurrency/result_collector.rb +99 -0
  32. data/lib/phronomy/engine/concurrency/result_composition.rb +145 -0
  33. data/lib/phronomy/engine/concurrency/subscriptions.rb +68 -0
  34. data/lib/phronomy/engine/event_loop.rb +7 -7
  35. data/lib/phronomy/engine/fsm_session.rb +3 -3
  36. data/lib/phronomy/engine/runtime/timer_queue.rb +11 -0
  37. data/lib/phronomy/engine/{task.rb → task_result.rb} +97 -42
  38. data/lib/phronomy/execution.rb +236 -0
  39. data/lib/phronomy/execution_cancellation_error.rb +12 -0
  40. data/lib/phronomy/execution_timeout_error.rb +12 -0
  41. data/lib/phronomy/invalid_async_entry_action_error.rb +1 -1
  42. data/lib/phronomy/invalid_async_transition_action_error.rb +1 -1
  43. data/lib/phronomy/invalid_async_workflow_action_error.rb +1 -1
  44. data/lib/phronomy/invocation_context.rb +13 -1
  45. data/lib/phronomy/llm_adapter/base.rb +2 -2
  46. data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +3 -3
  47. data/lib/phronomy/multi_agent/orchestrator.rb +35 -67
  48. data/lib/phronomy/multi_agent/team_coordinator.rb +3 -3
  49. data/lib/phronomy/testing/eval/scorer/llm_judge.rb +5 -3
  50. data/lib/phronomy/testing/fake_clock.rb +13 -9
  51. data/lib/phronomy/tools/agent.rb +5 -5
  52. data/lib/phronomy/vector_store/async_backend.rb +5 -5
  53. data/lib/phronomy/vector_store/embeddings/base.rb +2 -2
  54. data/lib/phronomy/version.rb +1 -1
  55. data/lib/phronomy/workflow/phase_machine_builder.rb +6 -6
  56. data/lib/phronomy/workflow.rb +2 -2
  57. data/lib/phronomy/workflow_runner.rb +4 -4
  58. data/scripts/api_snapshot.rb +3 -1
  59. data/sig/phronomy/agent.rbs +4 -4
  60. data/sig/phronomy/extensions.rbs +5 -5
  61. data/sig/phronomy/multi_agent.rbs +1 -3
  62. data/sig/phronomy/runtime.rbs +34 -7
  63. data/sig/phronomy/tool.rbs +1 -1
  64. data/sig/phronomy/workflow.rbs +1 -1
  65. metadata +11 -5
  66. data/lib/phronomy/multi_agent/fan_out_invocation.rb +0 -137
  67. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +0 -118
@@ -13,7 +13,7 @@ module Phronomy
13
13
  # synchronous call. Phronomy deliberately does not classify the workload by
14
14
  # cause; the application decides whether a unit of work is EventLoop-safe.
15
15
  #
16
- # Logical waits are different. Waiting for another Phronomy Task, Agent,
16
+ # Logical waits are different. Waiting for another Phronomy TaskResult, Agent,
17
17
  # Workflow, ToolInvocation, timer, or FSMSession must remain an explicit
18
18
  # EventLoop/FSMSession continuation and must not consume an OffloadPool worker.
19
19
  # See ADR-010.
@@ -22,7 +22,7 @@ module Phronomy
22
22
  #
23
23
  # 1. The total number of worker OS threads is capped.
24
24
  # 2. Queue depth is bounded (backpressure when the pool is saturated).
25
- # 3. Per-operation timeouts and cancellation settle the caller-facing Task.
25
+ # 3. Per-operation timeouts and cancellation settle the caller-facing TaskResult.
26
26
  # 4. Operations that settle after worker execution has started are tracked as
27
27
  # abandoned until that worker returns.
28
28
  # 5. Metrics expose active work, queue depth, cumulative abandonment,
@@ -43,7 +43,7 @@ module Phronomy
43
43
  class OffloadPool
44
44
  # Private execution record for one submitted synchronous operation.
45
45
  #
46
- # Caller-facing completion is represented exclusively by {Phronomy::Task}.
46
+ # Caller-facing completion is represented exclusively by {Phronomy::TaskResult}.
47
47
  # This object owns only OffloadPool-specific execution state: queue timing,
48
48
  # worker-start linearization, submit timeout/cancellation, abandonment, and
49
49
  # the submitted block itself.
@@ -77,6 +77,7 @@ module Phronomy
77
77
  @submitted_at = submitted_at ||
78
78
  Process.clock_gettime(Process::CLOCK_MONOTONIC)
79
79
  @mutex = Mutex.new
80
+ @timer_subscriptions = Subscriptions.new
80
81
 
81
82
  # Explicit submit cancellation is operation-wide. Deadline-only tokens are
82
83
  # promoted to cancel! by OffloadPool#submit using the Runtime timer queue.
@@ -92,19 +93,26 @@ module Phronomy
92
93
  @mutex.synchronize { @settled }
93
94
  end
94
95
 
95
- # @return [Boolean] true when the submit-time deadline settled the Task
96
+ # Registration can race logical completion. Dispose the timer even when
97
+ # settlement occurred while schedule was returning.
98
+ def schedule(timer_queue, seconds:, &callback)
99
+ timer_queue.schedule(seconds: seconds, &callback)
100
+ @timer_subscriptions.add { timer_queue.cancel(callback) }
101
+ end
102
+
103
+ # @return [Boolean] true when the submit-time deadline settled the TaskResult
96
104
  # @api private
97
105
  def timed_out?
98
106
  @mutex.synchronize { @timed_out }
99
107
  end
100
108
 
101
- # @return [Boolean] true when submit cancellation settled the Task
109
+ # @return [Boolean] true when submit cancellation settled the TaskResult
102
110
  # @api private
103
111
  def cancelled?
104
112
  @mutex.synchronize { @cancelled }
105
113
  end
106
114
 
107
- # @return [Boolean] true when timeout/cancellation settled the Task after
115
+ # @return [Boolean] true when timeout/cancellation settled the TaskResult after
108
116
  # worker execution had started. The worker is not forcibly interrupted.
109
117
  # @api private
110
118
  def abandoned?
@@ -117,7 +125,7 @@ module Phronomy
117
125
  @wait_time || 0.0
118
126
  end
119
127
 
120
- # Settles the caller-facing Task with a submit-time timeout.
128
+ # Settles the caller-facing TaskResult with a submit-time timeout.
121
129
  #
122
130
  # The worker is not interrupted. If execution has already started, the
123
131
  # operation is marked abandoned and the worker's eventual result is discarded.
@@ -132,7 +140,7 @@ module Phronomy
132
140
  end
133
141
  end
134
142
 
135
- # Settles the caller-facing Task because its submit cancellation token was
143
+ # Settles the caller-facing TaskResult because its submit cancellation token was
136
144
  # cancelled. Cancellation never injects Thread#raise into the worker.
137
145
  #
138
146
  # @return [Boolean] true when this call won settlement
@@ -201,7 +209,7 @@ module Phronomy
201
209
  complete_with_value!(@block.call)
202
210
  rescue Exception => e # rubocop:disable Lint/RescueException
203
211
  # Rescue all Exception subclasses so non-StandardError raises still
204
- # settle the Task and unblock waiters.
212
+ # settle the TaskResult and unblock waiters.
205
213
  complete_with_error!(e)
206
214
  raise if e.is_a?(SignalException) || e.is_a?(SystemExit)
207
215
  end
@@ -280,6 +288,7 @@ module Phronomy
280
288
  end
281
289
 
282
290
  def detach_submit_cancellation
291
+ @timer_subscriptions.close
283
292
  return unless @cancellation_token && @cancellation_callback
284
293
 
285
294
  @cancellation_token.send(
@@ -325,20 +334,20 @@ module Phronomy
325
334
 
326
335
  # Submits synchronous off-EventLoop work to the pool.
327
336
  #
328
- # Returns a {Phronomy::Task} immediately after queue admission; the block
337
+ # Returns a {Phronomy::TaskResult} immediately after queue admission; the block
329
338
  # runs on a worker thread. Do not submit logical waits (for example waiting
330
- # for a child Agent Task) merely to make them asynchronous; those belong to
339
+ # for a child Agent TaskResult) merely to make them asynchronous; those belong to
331
340
  # FSMSession/EventLoop completion events.
332
341
  #
333
342
  # A submit-time +timeout+ is an operation-wide deadline measured from the
334
- # start of this method, including queue wait. The timer settles the Task and
343
+ # start of this method, including queue wait. The timer settles the TaskResult and
335
344
  # notifies +on_complete+ without forcibly interrupting a running worker. If
336
345
  # the deadline fires before worker execution starts, the block is skipped.
337
346
  # If it fires after execution starts, the private Operation is marked
338
347
  # abandoned and the eventual worker result is discarded.
339
348
  #
340
349
  # The submit +cancellation_token+ is also operation-wide. Explicit
341
- # cancellation settles the Task immediately. A token with a monotonic
350
+ # cancellation settles the TaskResult immediately. A token with a monotonic
342
351
  # deadline is attached to the Runtime timer queue so deadline expiry becomes
343
352
  # explicit cancellation without adding a polling Thread. Cancellation before
344
353
  # execution skips the block; cancellation after execution starts abandons
@@ -356,7 +365,7 @@ module Phronomy
356
365
  # @param on_full [Symbol] +:wait+, +:raise+, or +:timeout+
357
366
  # @param full_timeout [Numeric, nil] queue-admission timeout for +on_full: :timeout+
358
367
  # @yield block containing synchronous work
359
- # @return [Phronomy::Task]
368
+ # @return [Phronomy::TaskResult]
360
369
  # @raise [Phronomy::ConfigurationError] when a timer is required but no
361
370
  # timer queue provider is configured
362
371
  # @raise [Phronomy::PoolShutdownError] when the pool has been shut down
@@ -421,7 +430,7 @@ module Phronomy
421
430
 
422
431
  # Arm before queue admission so the deadline includes time spent waiting
423
432
  # for a queue slot.
424
- timer_queue.schedule(seconds: remaining) { operation.fire_timeout! }
433
+ operation.schedule(timer_queue, seconds: remaining) { operation.fire_timeout! }
425
434
  end
426
435
 
427
436
  if cancellation_remaining
@@ -431,7 +440,7 @@ module Phronomy
431
440
  cancellation_token.cancel!
432
441
  return task
433
442
  end
434
- timer_queue.schedule(seconds: remaining) { cancellation_token.cancel! }
443
+ operation.schedule(timer_queue, seconds: remaining) { cancellation_token.cancel! }
435
444
  end
436
445
 
437
446
  # Cancellation/timeout can race with timer registration. Do not enqueue
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Concurrency
5
+ # Connects explicit invocation controls before operation admission. Its token
6
+ # is private: cancelling it never cancels the application's shared tokens.
7
+ # Used by Execution's Agent and Blocking adapters; it owns no worker.
8
+ # @api private
9
+ class OperationBinding
10
+ attr_reader :token, :scope
11
+
12
+ def initialize(invocation_context:, cancellation_token:)
13
+ if !invocation_context.nil? && !invocation_context.is_a?(InvocationContext)
14
+ raise TypeError, "invocation_context must be a Phronomy::InvocationContext or nil"
15
+ end
16
+ @scope = invocation_context&.__execution_scope
17
+ @token = CancellationToken.new
18
+ @subscriptions = Subscriptions.new
19
+ [invocation_context&.cancellation_token, cancellation_token].compact.uniq.each do |source|
20
+ @subscriptions.cancellation(source) { @token.cancel! }
21
+ end
22
+ if !@scope && invocation_context&.deadline
23
+ @subscriptions.after(invocation_context.deadline.remaining_seconds) { @token.cancel! }
24
+ end
25
+ @token.cancel! if @scope && !@scope.__open?
26
+ end
27
+
28
+ def bind(result)
29
+ result.__bind_execution(@scope)
30
+ track(result)
31
+ end
32
+
33
+ def track(result)
34
+ @subscriptions.result(result) { @subscriptions.close }
35
+ result
36
+ end
37
+
38
+ def close
39
+ @subscriptions.close
40
+ end
41
+ end
42
+ end
43
+ end
@@ -2,16 +2,16 @@
2
2
 
3
3
  module Phronomy
4
4
  module Concurrency
5
- # Private Task specialization used when logical completion and physical
5
+ # Private TaskResult specialization used when logical completion and physical
6
6
  # completion are intentionally different boundaries.
7
7
  #
8
- # Offload cancellation/timeout may settle the caller-facing Task while a
8
+ # Offload cancellation/timeout may settle the caller-facing TaskResult while a
9
9
  # worker is still executing. EventLoop supervision must observe the later
10
10
  # physical-completion boundary without adding domain-specific state to the
11
- # public Phronomy::Task contract.
11
+ # public Phronomy::TaskResult contract.
12
12
  #
13
13
  # @api private
14
- class PhysicalCompletionTask < Phronomy::Task
14
+ class PhysicalCompletionTask < Phronomy::TaskResult
15
15
  # @api private
16
16
  def initialize(name: nil, parent: nil)
17
17
  super
@@ -63,64 +63,6 @@ module Phronomy
63
63
  self
64
64
  end
65
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
66
  private
125
67
 
126
68
  def deliver_physical_callback(callback)
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Concurrency
5
+ # Shared input-order fan-in and terminal arbitration. No application code or
6
+ # completion callback runs while this collector's lock is held.
7
+ # @api private
8
+ class ResultCollector
9
+ def initialize(size, &finished)
10
+ @mutex = Mutex.new
11
+ @outcomes = Array.new(size)
12
+ @remaining = size
13
+ @closed = false
14
+ @sources = {}.compare_by_identity
15
+ @subscriptions = Subscriptions.new
16
+ @finished = finished
17
+ end
18
+
19
+ def while_open
20
+ @mutex.synchronize { yield unless @closed }
21
+ end
22
+
23
+ def open?
24
+ @mutex.synchronize { !@closed }
25
+ end
26
+
27
+ def watch(index, result, &recorded)
28
+ subscribe = @mutex.synchronize do
29
+ return if @closed
30
+
31
+ first = !@sources.key?(result)
32
+ (@sources[result] ||= {})[index] = recorded
33
+ first
34
+ end
35
+ if subscribe
36
+ @subscriptions.result(result) { receive(result) }
37
+ elsif result.done?
38
+ receive(result)
39
+ end
40
+ end
41
+
42
+ def finish(kind)
43
+ snapshot = @mutex.synchronize do
44
+ return false if @closed
45
+ return false if kind == :completed && @remaining > 0
46
+
47
+ @closed = true
48
+ snapshot_locked
49
+ end
50
+ deliver(kind, snapshot)
51
+ true
52
+ end
53
+
54
+ private
55
+
56
+ def receive(result)
57
+ status, value, error = result.__snapshot
58
+ callbacks = []
59
+ snapshot = @mutex.synchronize do
60
+ return if @closed
61
+
62
+ @sources.fetch(result).each do |index, callback|
63
+ next if @outcomes[index]
64
+
65
+ @outcomes[index] = TaskResult::Outcome.new(index: index,
66
+ status: status, value: value, error: error)
67
+ @remaining -= 1
68
+ callbacks << callback if callback
69
+ end
70
+ if @remaining.zero?
71
+ @closed = true
72
+ snapshot_locked
73
+ end
74
+ end
75
+ deliver(:completed, snapshot) if snapshot
76
+ callbacks.each(&:call)
77
+ end
78
+
79
+ def snapshot_locked
80
+ @outcomes.each_with_index.map do |outcome, index|
81
+ outcome || TaskResult::Outcome.new(index: index,
82
+ status: :unfinished, value: nil, error: nil)
83
+ end.freeze
84
+ end
85
+
86
+ def deliver(kind, snapshot)
87
+ finished = @mutex.synchronize do
88
+ @sources.clear
89
+ @outcomes.clear
90
+ callback = @finished
91
+ @finished = nil
92
+ callback
93
+ end
94
+ @subscriptions.close
95
+ finished.call(kind, snapshot)
96
+ end
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Concurrency
5
+ # Shared map/flat_map implementation, including the private physical-work
6
+ # boundary. Observing a result never mutates that result or its cancellation.
7
+ # @api private
8
+ class ResultComposition
9
+ def initialize(source, flatten:, &block)
10
+ @source = source
11
+ @scope = source.__execution_scope
12
+ @flatten = flatten
13
+ @block = block
14
+ physical = flatten || source.respond_to?(:on_physical_complete)
15
+ klass = physical ? PhysicalCompletionTask : TaskResult
16
+ @result = klass.deferred(name: "#{source.name}-#{flatten ? "flat-mapped" : "mapped"}",
17
+ parent: source.parent).__bind_execution(@scope)
18
+ @mutex = Mutex.new
19
+ @phase = :waiting
20
+ @source_physical = !source.respond_to?(:on_physical_complete)
21
+ @work_done = false
22
+ @inner_physical = true
23
+ @subscriptions = Subscriptions.new
24
+ end
25
+
26
+ def start
27
+ unless @source_physical
28
+ @source.on_physical_complete do
29
+ @mutex.synchronize { @source_physical = true }
30
+ finish_physical
31
+ end
32
+ end
33
+ @subscriptions.result(@source) { receive_source }
34
+ if @scope
35
+ @subscriptions.cancellation(@scope.__cancellation_token) { suppress }
36
+ suppress unless @scope.__open?
37
+ end
38
+ @result
39
+ end
40
+
41
+ private
42
+
43
+ def receive_source
44
+ status, value, error = @source.__snapshot
45
+ if status != :completed
46
+ started = @mutex.synchronize do
47
+ next false unless @phase == :waiting
48
+ @phase = :finished
49
+ @work_done = true
50
+ true
51
+ end
52
+ if started
53
+ finish_physical
54
+ propagate(status, value, error)
55
+ @subscriptions.close
56
+ end
57
+ return
58
+ end
59
+
60
+ claim = lambda do
61
+ @mutex.synchronize do
62
+ next false unless @phase == :waiting
63
+ @phase = :running
64
+ true
65
+ end
66
+ end
67
+ allowed = @scope ? @scope.__while_open(&claim) : claim.call
68
+ unless allowed
69
+ suppress
70
+ return
71
+ end
72
+
73
+ begin
74
+ transformed = @block.call(value)
75
+ if @flatten && !transformed.is_a?(TaskResult)
76
+ raise TypeError, "flat_map block must return a Phronomy::TaskResult"
77
+ end
78
+ track_inner_physical(transformed) if @flatten
79
+ @mutex.synchronize do
80
+ @work_done = true
81
+ @phase = :inner unless @phase == :suppressed
82
+ end
83
+ finish_physical
84
+ if @flatten
85
+ unless @result.done?
86
+ @subscriptions.result(transformed) do
87
+ propagate(*transformed.__snapshot)
88
+ @subscriptions.close
89
+ end
90
+ end
91
+ else
92
+ @result.complete(transformed)
93
+ @subscriptions.close
94
+ end
95
+ rescue => error
96
+ @mutex.synchronize { @work_done = true }
97
+ finish_physical
98
+ @result.fail(error)
99
+ @subscriptions.close
100
+ ensure
101
+ # Even an exception deliberately outside StandardError cannot leave
102
+ # already-finished Ruby work looking physically active.
103
+ @mutex.synchronize { @work_done = true }
104
+ finish_physical
105
+ end
106
+ end
107
+
108
+ def suppress
109
+ @mutex.synchronize do
110
+ @work_done = true if @phase == :waiting
111
+ @phase = :suppressed
112
+ end
113
+ finish_physical
114
+ @result.cancel!(@scope.__cancellation_error)
115
+ @subscriptions.close
116
+ end
117
+
118
+ def propagate(status, value, error)
119
+ case status
120
+ when :completed then @result.complete(value)
121
+ when :cancelled then @result.cancel!(error)
122
+ when :failed then @result.fail(error)
123
+ end
124
+ end
125
+
126
+ def track_inner_physical(inner)
127
+ return unless inner.respond_to?(:on_physical_complete)
128
+ return unless inner.__execution_scope.equal?(@scope)
129
+
130
+ @mutex.synchronize { @inner_physical = false }
131
+ inner.on_physical_complete do
132
+ @mutex.synchronize { @inner_physical = true }
133
+ finish_physical
134
+ end
135
+ end
136
+
137
+ def finish_physical
138
+ return unless @result.respond_to?(:mark_physical_complete!)
139
+
140
+ done = @mutex.synchronize { @source_physical && @work_done && @inner_physical }
141
+ @result.mark_physical_complete! if done
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Concurrency
5
+ # Disposable registrations. add also handles synchronous notification during
6
+ # registration: cleanup added after close is performed immediately.
7
+ # @api private
8
+ class Subscriptions
9
+ def initialize
10
+ @mutex = Mutex.new
11
+ @closed = false
12
+ @cleanup = []
13
+ end
14
+
15
+ def add(&cleanup)
16
+ dispose = @mutex.synchronize do
17
+ if @closed
18
+ true
19
+ else
20
+ @cleanup << cleanup
21
+ false
22
+ end
23
+ end
24
+ cleanup.call if dispose
25
+ end
26
+
27
+ def result(result, &callback)
28
+ result.on_complete(&callback)
29
+ add { result.__unsubscribe(callback) }
30
+ end
31
+
32
+ def cancellation(token, &callback)
33
+ return unless token
34
+
35
+ token.on_cancel(&callback)
36
+ add { token.send(:unregister_cancel_callback, callback) }
37
+ remaining = token.remaining_monotonic_seconds
38
+ if token.cancelled?
39
+ callback.call
40
+ elsif remaining
41
+ after(remaining, &callback)
42
+ end
43
+ end
44
+
45
+ def after(seconds, &callback)
46
+ if seconds <= 0
47
+ callback.call
48
+ return
49
+ end
50
+ timer = Phronomy::Runtime.instance.timer_queue
51
+ timer.schedule(seconds: seconds, &callback)
52
+ add { timer.cancel(callback) }
53
+ end
54
+
55
+ def close
56
+ cleanup = @mutex.synchronize do
57
+ return if @closed
58
+
59
+ @closed = true
60
+ current = @cleanup
61
+ @cleanup = []
62
+ current
63
+ end
64
+ cleanup.each(&:call)
65
+ end
66
+ end
67
+ end
68
+ end
@@ -114,7 +114,7 @@ module Phronomy
114
114
  end
115
115
 
116
116
  def register(fsm_session, completion: nil)
117
- if current? && !completion.is_a?(Phronomy::Task)
117
+ if current? && !completion.is_a?(Phronomy::TaskResult)
118
118
  raise Phronomy::Error,
119
119
  "Cannot call a synchronous invocation API from an EventLoop action. " \
120
120
  "Schedule work asynchronously instead."
@@ -413,14 +413,14 @@ module Phronomy
413
413
  end
414
414
  end
415
415
 
416
- # Registers a caller-facing Task that observes the authoritative terminal
416
+ # Registers a caller-facing TaskResult that observes the authoritative terminal
417
417
  # outcome of one logical Agent execution. Waiters are Runtime-only and are
418
418
  # never persisted or rehydrated.
419
419
  # @api private
420
420
  def register_agent_completion_waiter(execution_id, task)
421
421
  assert_event_loop_thread!
422
- unless task.is_a?(Phronomy::Task)
423
- raise ArgumentError, "Agent completion waiter must be a Phronomy::Task"
422
+ unless task.is_a?(Phronomy::TaskResult)
423
+ raise ArgumentError, "Agent completion waiter must be a Phronomy::TaskResult"
424
424
  end
425
425
 
426
426
  key = execution_id.to_s
@@ -432,7 +432,7 @@ module Phronomy
432
432
  end
433
433
 
434
434
  # Atomically detaches all process-local completion waiters at authoritative
435
- # terminal delivery. A fallback Task is included for pre-install terminal
435
+ # terminal delivery. A fallback TaskResult is included for pre-install terminal
436
436
  # paths that never acquired a live execution directory entry.
437
437
  # @api private
438
438
  def take_agent_completion_waiters(execution_id, fallback: nil)
@@ -1027,7 +1027,7 @@ module Phronomy
1027
1027
  if pending_waiters.any?
1028
1028
  error = Phronomy::ExecutionRehydrationRequiredError.new(
1029
1029
  "Runtime terminated while Agent execution remained nonterminal; " \
1030
- "process-local Task handles are not rehydrated"
1030
+ "process-local TaskResult handles are not rehydrated"
1031
1031
  )
1032
1032
  pending_waiters.each { |waiter| complete_waiter(waiter, error) }
1033
1033
  end
@@ -1037,7 +1037,7 @@ module Phronomy
1037
1037
  def complete_waiter(waiter, payload)
1038
1038
  return unless waiter
1039
1039
 
1040
- if waiter.is_a?(Phronomy::Task)
1040
+ if waiter.is_a?(Phronomy::TaskResult)
1041
1041
  payload.is_a?(Exception) ? waiter.fail(payload) : waiter.complete(payload)
1042
1042
  else
1043
1043
  waiter.push(payload)
@@ -6,7 +6,7 @@ module Phronomy
6
6
  # Event-driven execution wrapper for a single FSM session.
7
7
  #
8
8
  # All public methods are called from the Runtime-owned EventLoop thread.
9
- # FSMSession owns FSM execution only; it does not own external Task handles,
9
+ # FSMSession owns FSM execution only; it does not own external TaskResult handles,
10
10
  # activity tokens, callback correlation, or domain-specific stale-event policy.
11
11
  class FSMSession
12
12
  class IdentityReservation
@@ -195,9 +195,9 @@ module Phronomy
195
195
  end
196
196
 
197
197
  def apply_synchronous_action_result!(result, state_name)
198
- if result.is_a?(Phronomy::Task)
198
+ if result.is_a?(Phronomy::TaskResult)
199
199
  raise Phronomy::InvalidAsyncEntryActionError,
200
- "Entry action for state #{state_name.inspect} returned Phronomy::Task. " \
200
+ "Entry action for state #{state_name.inspect} returned Phronomy::TaskResult. " \
201
201
  "Start the asynchronous operation, register its callback/listener, " \
202
202
  "and return the WorkflowContext or nil."
203
203
  end
@@ -44,6 +44,17 @@ module Phronomy
44
44
  end
45
45
  end
46
46
 
47
+ # Remove this registration without changing another user's timers. A
48
+ # callback already taken by fire_due may still run; owners arbitrate once.
49
+ # @api private
50
+ def cancel(callback)
51
+ @mutex.synchronize do
52
+ before = @heap.length
53
+ @heap.delete_if { |(_, registered)| registered == callback }
54
+ before != @heap.length
55
+ end
56
+ end
57
+
47
58
  # Executes all callbacks whose deadline is due. Must be called by EventLoop.
48
59
  def fire_due
49
60
  callbacks = @mutex.synchronize do