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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -0
- data/CONTRIBUTING.md +4 -4
- data/README.md +10 -7
- data/benchmark/bench_regression.rb +1 -1
- data/docs/architecture/agent-context.md +1 -1
- data/docs/architecture/persistence.md +2 -2
- data/docs/architecture/tracing.md +3 -3
- data/docs/async-composition.md +205 -0
- data/docs/decisions/010-cooperative-first-concurrency.md +23 -23
- data/docs/features.md +12 -9
- data/docs/getting-started.md +6 -6
- data/docs/migrations/durable-semantic-coordination-v2.md +1 -1
- data/docs/persistence-backends.md +1 -1
- data/docs/runtime-and-concurrency.md +89 -29
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +1 -1
- data/lib/phronomy/agent/base.rb +4 -10
- data/lib/phronomy/agent/exact_execution.rb +3 -3
- data/lib/phronomy/agent/execution_coordinator.rb +20 -7
- data/lib/phronomy/agent/phase_machine_builder.rb +2 -2
- data/lib/phronomy/agent/recovery_coordinator/continuation.rb +1 -1
- data/lib/phronomy/agent/recovery_coordinator/installation.rb +4 -4
- data/lib/phronomy/agent/recovery_coordinator.rb +3 -3
- data/lib/phronomy/agent/tool_executor.rb +4 -4
- data/lib/phronomy/agent/tool_invocation.rb +1 -1
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +2 -2
- data/lib/phronomy/blocking.rb +15 -8
- data/lib/phronomy/engine/concurrency/offload_pool.rb +25 -16
- data/lib/phronomy/engine/concurrency/operation_binding.rb +43 -0
- data/lib/phronomy/engine/concurrency/physical_completion_task.rb +4 -62
- data/lib/phronomy/engine/concurrency/result_collector.rb +99 -0
- data/lib/phronomy/engine/concurrency/result_composition.rb +145 -0
- data/lib/phronomy/engine/concurrency/subscriptions.rb +68 -0
- data/lib/phronomy/engine/event_loop.rb +7 -7
- data/lib/phronomy/engine/fsm_session.rb +3 -3
- data/lib/phronomy/engine/runtime/timer_queue.rb +11 -0
- data/lib/phronomy/engine/{task.rb → task_result.rb} +97 -42
- data/lib/phronomy/execution.rb +236 -0
- data/lib/phronomy/execution_cancellation_error.rb +12 -0
- data/lib/phronomy/execution_timeout_error.rb +12 -0
- data/lib/phronomy/invalid_async_entry_action_error.rb +1 -1
- data/lib/phronomy/invalid_async_transition_action_error.rb +1 -1
- data/lib/phronomy/invalid_async_workflow_action_error.rb +1 -1
- data/lib/phronomy/invocation_context.rb +13 -1
- data/lib/phronomy/llm_adapter/base.rb +2 -2
- data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +3 -3
- data/lib/phronomy/multi_agent/orchestrator.rb +35 -67
- data/lib/phronomy/multi_agent/team_coordinator.rb +3 -3
- data/lib/phronomy/testing/eval/scorer/llm_judge.rb +5 -3
- data/lib/phronomy/testing/fake_clock.rb +13 -9
- data/lib/phronomy/tools/agent.rb +5 -5
- data/lib/phronomy/vector_store/async_backend.rb +5 -5
- data/lib/phronomy/vector_store/embeddings/base.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/phase_machine_builder.rb +6 -6
- data/lib/phronomy/workflow.rb +2 -2
- data/lib/phronomy/workflow_runner.rb +4 -4
- data/scripts/api_snapshot.rb +3 -1
- data/sig/phronomy/agent.rbs +4 -4
- data/sig/phronomy/extensions.rbs +5 -5
- data/sig/phronomy/multi_agent.rbs +1 -3
- data/sig/phronomy/runtime.rbs +34 -7
- data/sig/phronomy/tool.rbs +1 -1
- data/sig/phronomy/workflow.rbs +1 -1
- metadata +11 -5
- data/lib/phronomy/multi_agent/fan_out_invocation.rb +0 -137
- 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
|
|
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
|
|
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::
|
|
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
|
-
#
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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::
|
|
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
|
|
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
|
|
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
|
|
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::
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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::
|
|
11
|
+
# public Phronomy::TaskResult contract.
|
|
12
12
|
#
|
|
13
13
|
# @api private
|
|
14
|
-
class PhysicalCompletionTask < Phronomy::
|
|
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::
|
|
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
|
|
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::
|
|
423
|
-
raise ArgumentError, "Agent completion waiter must be a Phronomy::
|
|
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
|
|
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
|
|
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::
|
|
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
|
|
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::
|
|
198
|
+
if result.is_a?(Phronomy::TaskResult)
|
|
199
199
|
raise Phronomy::InvalidAsyncEntryActionError,
|
|
200
|
-
"Entry action for state #{state_name.inspect} returned Phronomy::
|
|
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
|