phronomy 0.16.0 → 0.18.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 +8 -9
- data/CHANGELOG.md +151 -1134
- data/CONTRIBUTING.md +28 -16
- data/README.md +69 -1249
- data/benchmark/baseline.json +2 -3
- data/benchmark/bench_agent_invoke.rb +4 -4
- data/benchmark/bench_context_assembler.rb +134 -34
- data/benchmark/bench_regression.rb +26 -6
- data/benchmark/bench_tool_schema.rb +2 -35
- data/docs/changelog/0.14-and-earlier.md +1137 -0
- data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
- data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
- data/docs/decisions/010-cooperative-first-concurrency.md +155 -228
- data/docs/decisions/011-build-context-as-single-llm-input-authority.md +2 -2
- data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
- data/docs/features.md +87 -0
- data/docs/getting-started.md +351 -0
- data/docs/migrations/0.15.md +35 -0
- data/docs/migrations/0.16.md +43 -0
- data/docs/runtime-and-concurrency.md +258 -0
- data/examples/workflows/generic_task_event_mapping.rb +14 -6
- data/lib/phronomy/agent/agent_invocation.rb +2 -36
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +157 -94
- data/lib/phronomy/agent/agent_root.rb +1 -2
- data/lib/phronomy/agent/async_event_api.rb +3 -3
- data/lib/phronomy/agent/base.rb +170 -333
- data/lib/phronomy/agent/context/capability/base.rb +177 -298
- data/lib/phronomy/agent/context_assembler.rb +65 -29
- data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +19 -89
- data/lib/phronomy/agent/context_plan_validator.rb +0 -33
- data/lib/phronomy/agent/execution_coordinator.rb +6 -7
- data/lib/phronomy/agent/journal_projection.rb +28 -2
- data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -111
- data/lib/phronomy/agent/shared_state.rb +48 -138
- data/lib/phronomy/agent/token_budget_resolver.rb +5 -4
- data/lib/phronomy/agent/tool_executor.rb +29 -71
- data/lib/phronomy/agent/tool_invocation.rb +180 -336
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
- data/lib/phronomy/agent.rb +6 -10
- data/lib/phronomy/configuration.rb +4 -171
- data/lib/phronomy/diagnostics.rb +12 -41
- data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
- data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +51 -79
- data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
- data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
- data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
- data/lib/phronomy/engine/event_loop.rb +89 -190
- data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
- data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
- data/lib/phronomy/engine/runtime.rb +49 -377
- data/lib/phronomy/engine/task.rb +136 -277
- data/lib/phronomy/llm_adapter/base.rb +14 -14
- data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
- data/lib/phronomy/llm_adapter.rb +2 -2
- data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
- data/lib/phronomy/metrics.rb +15 -30
- data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
- data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
- data/lib/phronomy/multi_agent/handoff.rb +1 -0
- data/lib/phronomy/multi_agent/orchestrator.rb +252 -256
- data/lib/phronomy/multi_agent/team_coordinator.rb +44 -133
- data/lib/phronomy/testing/eval/comparison.rb +23 -0
- data/lib/phronomy/testing/eval/dataset.rb +27 -0
- data/lib/phronomy/testing/eval/eval_case.rb +13 -0
- data/lib/phronomy/testing/eval/eval_result.rb +16 -0
- data/lib/phronomy/testing/eval/metrics.rb +43 -0
- data/lib/phronomy/testing/eval/runner.rb +52 -0
- data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
- data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
- data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
- data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
- data/lib/phronomy/testing/eval/scorer.rb +10 -0
- data/lib/phronomy/testing/eval.rb +9 -0
- data/lib/phronomy/testing/fake_clock.rb +6 -53
- data/lib/phronomy/testing.rb +2 -6
- data/lib/phronomy/tools/agent.rb +141 -6
- data/lib/phronomy/vector_store/async_backend.rb +21 -17
- data/lib/phronomy/vector_store/base.rb +2 -2
- data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
- data/lib/phronomy/vector_store/in_memory.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow_runner.rb +2 -4
- data/lib/phronomy.rb +7 -121
- data/scripts/api_snapshot.rb +4 -15
- metadata +24 -38
- data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
- data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
- data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
- data/lib/phronomy/agent/fsm_runtime_adapter.rb +0 -210
- data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
- data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
- data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
- data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
- data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
- data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
- data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
- data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
- data/lib/phronomy/engine/task/backend.rb +0 -80
- data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
- data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
- data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
- data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
- data/lib/phronomy/engine/task/thread_backend.rb +0 -84
- data/lib/phronomy/engine/task_group.rb +0 -265
- data/lib/phronomy/eval/comparison.rb +0 -47
- data/lib/phronomy/eval/dataset.rb +0 -45
- data/lib/phronomy/eval/eval_case.rb +0 -17
- data/lib/phronomy/eval/eval_result.rb +0 -29
- data/lib/phronomy/eval/metrics.rb +0 -66
- data/lib/phronomy/eval/runner.rb +0 -94
- data/lib/phronomy/eval/scorer/base.rb +0 -22
- data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
- data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
- data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
- data/lib/phronomy/eval/scorer.rb +0 -9
- data/lib/phronomy/eval.rb +0 -7
- data/lib/phronomy/knowledge_source.rb +0 -12
- data/lib/phronomy/llm_context_window/assembler.rb +0 -191
- data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
- data/lib/phronomy/testing/fake_scheduler.rb +0 -104
- data/lib/phronomy/testing/scheduler_helpers.rb +0 -68
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
class Task
|
|
5
|
-
# Cooperative task backend using Ruby Fibers.
|
|
6
|
-
#
|
|
7
|
-
# Unlike {ImmediateBackend} (which runs the block to completion on the
|
|
8
|
-
# calling thread) or {ThreadBackend} (which runs the block on a new OS
|
|
9
|
-
# thread), +FiberBackend+ wraps the block in a +Fiber+ that is NOT started
|
|
10
|
-
# immediately. The owning scheduler calls {#step} to advance execution one
|
|
11
|
-
# cooperative step at a time.
|
|
12
|
-
#
|
|
13
|
-
# This backend is used exclusively by {Runtime::DeterministicScheduler} to
|
|
14
|
-
# enable deterministic, wall-clock-free testing of concurrent logic.
|
|
15
|
-
#
|
|
16
|
-
# Thread-local key under which the currently active {DeterministicScheduler}
|
|
17
|
-
# is stored so that {#await} can suspend cooperatively.
|
|
18
|
-
SCHEDULER_KEY = :phronomy_deterministic_scheduler
|
|
19
|
-
|
|
20
|
-
# @api private
|
|
21
|
-
class FiberBackend < Backend
|
|
22
|
-
def initialize(task:, &block)
|
|
23
|
-
super
|
|
24
|
-
@value = nil
|
|
25
|
-
@error = nil
|
|
26
|
-
@cancel_error = nil
|
|
27
|
-
@cancel_requested = false
|
|
28
|
-
@started = false
|
|
29
|
-
@cooperative_suspend = false
|
|
30
|
-
|
|
31
|
-
# Capture `self` (the FiberBackend instance) in the closure so that
|
|
32
|
-
# instance-variable writes from inside the Fiber update this object.
|
|
33
|
-
@fiber = Fiber.new do
|
|
34
|
-
task.transition!(:running)
|
|
35
|
-
begin
|
|
36
|
-
# If cancel! was called before the first step, raise immediately.
|
|
37
|
-
raise @cancel_error if @cancel_error
|
|
38
|
-
|
|
39
|
-
@value = block.call
|
|
40
|
-
task.transition!(:completed, value: @value)
|
|
41
|
-
rescue CancellationError => e
|
|
42
|
-
task.transition!(:cancelled, error: e)
|
|
43
|
-
@error = e
|
|
44
|
-
rescue => e
|
|
45
|
-
task.transition!(:failed, error: e)
|
|
46
|
-
@error = e
|
|
47
|
-
ensure
|
|
48
|
-
task.transition!(:cancelled) unless task.done?
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
# Advances execution by one scheduler step.
|
|
54
|
-
# Resumes the Fiber until it yields (via +Fiber.yield+) or finishes.
|
|
55
|
-
# Cooperative cancellation is checked at the start of each step: if
|
|
56
|
-
# +cancel!+ has been called, +CancellationError+ is raised inside the
|
|
57
|
-
# Fiber at this controlled checkpoint rather than injected at an
|
|
58
|
-
# arbitrary suspension point via +Fiber#raise+.
|
|
59
|
-
# @return [self]
|
|
60
|
-
# @api private
|
|
61
|
-
def step
|
|
62
|
-
return self unless @fiber.alive?
|
|
63
|
-
|
|
64
|
-
@started = true
|
|
65
|
-
# Deliver pending cancellation at this scheduler checkpoint rather than
|
|
66
|
-
# injecting it mid-Fiber via Fiber#raise (which would be preemptive).
|
|
67
|
-
if @cancel_requested && @cancel_error
|
|
68
|
-
begin
|
|
69
|
-
@fiber.raise(@cancel_error)
|
|
70
|
-
rescue FiberError
|
|
71
|
-
nil # Fiber completed between the check and raise — safe to ignore.
|
|
72
|
-
end
|
|
73
|
-
@cancel_requested = false
|
|
74
|
-
return self
|
|
75
|
-
end
|
|
76
|
-
yield_value = @fiber.resume
|
|
77
|
-
# A yield value of :cooperative_suspend signals that the Fiber deliberately
|
|
78
|
-
# suspended itself (e.g. inside CoopSignal#wait) and must NOT be
|
|
79
|
-
# re-enqueued by step_callable — it will be resumed by an explicit signal.
|
|
80
|
-
@cooperative_suspend = (yield_value == :cooperative_suspend)
|
|
81
|
-
self
|
|
82
|
-
end
|
|
83
|
-
|
|
84
|
-
# Returns +true+ if the Fiber yielded cooperatively (via a signal wait)
|
|
85
|
-
# and should not be automatically re-enqueued by the scheduler.
|
|
86
|
-
# @return [Boolean]
|
|
87
|
-
# @api private
|
|
88
|
-
def cooperative_suspend?
|
|
89
|
-
@cooperative_suspend
|
|
90
|
-
end
|
|
91
|
-
|
|
92
|
-
# Blocks until the task completes.
|
|
93
|
-
#
|
|
94
|
-
# When called from within a {DeterministicScheduler}-managed Fiber,
|
|
95
|
-
# suspends the current Fiber cooperatively and schedules it to resume
|
|
96
|
-
# when this task completes. When called from outside a managed Fiber
|
|
97
|
-
# (e.g. the main fiber or a regular thread), drives execution by calling
|
|
98
|
-
# {#step} in a loop.
|
|
99
|
-
#
|
|
100
|
-
# @return [Object]
|
|
101
|
-
# @raise [Exception]
|
|
102
|
-
# @api private
|
|
103
|
-
def wait_result
|
|
104
|
-
unless @fiber.alive?
|
|
105
|
-
raise @error if @error
|
|
106
|
-
return @value
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
scheduler = Thread.current.thread_variable_get(SCHEDULER_KEY)
|
|
110
|
-
# Fiber.main was added in Ruby 3.2.4+; fall back to true (assume we are
|
|
111
|
-
# inside a managed Fiber whenever a scheduler is active).
|
|
112
|
-
in_managed_fiber = !Fiber.respond_to?(:main) || Fiber.current != Fiber.main
|
|
113
|
-
if scheduler && in_managed_fiber
|
|
114
|
-
# Cooperative context: suspend current Fiber until task is done.
|
|
115
|
-
waiting_fiber = Fiber.current
|
|
116
|
-
@task.on_complete { scheduler.enqueue_fiber(-> { waiting_fiber.resume }) }
|
|
117
|
-
Fiber.yield(:cooperative_suspend)
|
|
118
|
-
else
|
|
119
|
-
# Non-cooperative context: drive the fiber to completion.
|
|
120
|
-
step while @fiber.alive?
|
|
121
|
-
end
|
|
122
|
-
|
|
123
|
-
raise @error if @error
|
|
124
|
-
@value
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
# @return [Boolean] +true+ while the Fiber has not yet finished
|
|
128
|
-
# @api private
|
|
129
|
-
def alive?
|
|
130
|
-
@fiber.alive?
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
# Requests cancellation using a cooperative checkpoint mechanism.
|
|
134
|
-
# Sets a cancellation flag; the error is raised inside the Fiber at the
|
|
135
|
-
# next +step+ call (i.e. when the scheduler next dispatches this task),
|
|
136
|
-
# not injected at an arbitrary suspension point via +Fiber#raise+.
|
|
137
|
-
# If the Fiber has not yet started, the error is recorded so it is raised
|
|
138
|
-
# on the first {#step}.
|
|
139
|
-
# @return [self]
|
|
140
|
-
# @api private
|
|
141
|
-
def cancel!
|
|
142
|
-
@cancel_error = CancellationError.new("Task cancelled")
|
|
143
|
-
@cancel_requested = true
|
|
144
|
-
self
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
# Joins execution by stepping until the Fiber is no longer alive.
|
|
148
|
-
# @param limit [Numeric, nil] ignored
|
|
149
|
-
# @return [self]
|
|
150
|
-
# @api private
|
|
151
|
-
def join(_limit = nil)
|
|
152
|
-
step while @fiber.alive?
|
|
153
|
-
self
|
|
154
|
-
end
|
|
155
|
-
end
|
|
156
|
-
end
|
|
157
|
-
end
|
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
class Task
|
|
5
|
-
# Synchronous task backend that executes the block on the calling thread.
|
|
6
|
-
#
|
|
7
|
-
# Used by {Runtime::FakeScheduler} to allow tests to verify cooperative
|
|
8
|
-
# scheduling behaviour without spawning additional Threads. The block
|
|
9
|
-
# runs to completion before {#initialize} returns, so {#await} and {#join}
|
|
10
|
-
# always return immediately.
|
|
11
|
-
#
|
|
12
|
-
# Thread count invariant: +ImmediateBackend+ never creates a new Thread.
|
|
13
|
-
class ImmediateBackend < Backend
|
|
14
|
-
# Executes +block+ synchronously on the calling thread.
|
|
15
|
-
# Saves and restores +Task.current+ so nested ImmediateBackend tasks
|
|
16
|
-
# compose correctly.
|
|
17
|
-
#
|
|
18
|
-
# @param task [Task]
|
|
19
|
-
# @yieldreturn [Object]
|
|
20
|
-
# @api private
|
|
21
|
-
def initialize(task:, &block)
|
|
22
|
-
super
|
|
23
|
-
@value = nil
|
|
24
|
-
@error = nil
|
|
25
|
-
previous_task = Thread.current[:phronomy_current_task]
|
|
26
|
-
Thread.current[:phronomy_current_task] = task
|
|
27
|
-
task.transition!(:running)
|
|
28
|
-
begin
|
|
29
|
-
@value = block.call
|
|
30
|
-
task.transition!(:completed, value: @value)
|
|
31
|
-
rescue CancellationError => e
|
|
32
|
-
task.transition!(:cancelled, error: e)
|
|
33
|
-
@error = e
|
|
34
|
-
rescue => e
|
|
35
|
-
task.transition!(:failed, error: e)
|
|
36
|
-
@error = e
|
|
37
|
-
ensure
|
|
38
|
-
task.transition!(:cancelled) unless task.done?
|
|
39
|
-
Thread.current[:phronomy_current_task] = previous_task
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
# Returns the block's return value, or re-raises its exception.
|
|
44
|
-
# @return [Object]
|
|
45
|
-
# @raise [Exception]
|
|
46
|
-
# @api private
|
|
47
|
-
def wait_result
|
|
48
|
-
raise @error if @error
|
|
49
|
-
|
|
50
|
-
@value
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
# @return [Object, nil]
|
|
54
|
-
# @api private
|
|
55
|
-
def completed_value
|
|
56
|
-
@value
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
# @return [Exception, nil]
|
|
60
|
-
# @api private
|
|
61
|
-
def completed_error
|
|
62
|
-
@error
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
# Always +false+ — block has already completed by the time the task
|
|
66
|
-
# is visible to callers.
|
|
67
|
-
# @return [Boolean]
|
|
68
|
-
# @api private
|
|
69
|
-
def alive?
|
|
70
|
-
false
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
# No-op: the block has already completed.
|
|
74
|
-
# @return [self]
|
|
75
|
-
# @api private
|
|
76
|
-
def cancel!
|
|
77
|
-
self
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
# Returns immediately — nothing to wait for.
|
|
81
|
-
# @param limit [Numeric, nil] ignored
|
|
82
|
-
# @return [self]
|
|
83
|
-
# @api private
|
|
84
|
-
def join(_limit = nil)
|
|
85
|
-
self
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
end
|
|
89
|
-
end
|
|
@@ -1,90 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "timeout"
|
|
4
|
-
|
|
5
|
-
module Phronomy
|
|
6
|
-
class Task
|
|
7
|
-
# Backend for Tasks created by {Task#map}.
|
|
8
|
-
#
|
|
9
|
-
# A mapped task's lifecycle is driven entirely by the +on_complete+
|
|
10
|
-
# callback of its source task — it never spawns a thread of its own.
|
|
11
|
-
# +MappedBackend+ transitions the owning task to +:running+ immediately
|
|
12
|
-
# on initialization so that +FSMSession+ treats it as an in-progress
|
|
13
|
-
# async action. Completion (or failure) is triggered externally via
|
|
14
|
-
# {Task#transition!} from the +on_complete+ callback registered by
|
|
15
|
-
# {Task#map}.
|
|
16
|
-
#
|
|
17
|
-
# +await+ and +join+ block until {#unblock} is called, which {Task#map}
|
|
18
|
-
# arranges by registering a second +on_complete+ callback on the *mapped*
|
|
19
|
-
# task itself after the transform callback has been registered.
|
|
20
|
-
#
|
|
21
|
-
# @api private
|
|
22
|
-
class MappedBackend < Backend
|
|
23
|
-
def initialize(task:, &)
|
|
24
|
-
super
|
|
25
|
-
@done_queue = Queue.new
|
|
26
|
-
task.transition!(:running)
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
# Unblocks +await+ / +join+. Called by {Task#map} after the mapped task
|
|
30
|
-
# reaches a terminal state.
|
|
31
|
-
# @api private
|
|
32
|
-
def unblock(value, error)
|
|
33
|
-
@done_queue.push([value, error])
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
# Blocks until the mapped task reaches a terminal state.
|
|
37
|
-
# @return [Object] the mapped value
|
|
38
|
-
# @raise [Exception] if the source task or the map block raised an error
|
|
39
|
-
# @api private
|
|
40
|
-
def wait_result
|
|
41
|
-
scheduler = Thread.current.thread_variable_get(Task::SCHEDULER_KEY)
|
|
42
|
-
in_managed_fiber = !Fiber.respond_to?(:main) || Fiber.current != Fiber.main
|
|
43
|
-
if scheduler && in_managed_fiber && !@task.done?
|
|
44
|
-
scheduler.track_blocking_await
|
|
45
|
-
waiting_fiber = Fiber.current
|
|
46
|
-
@task.on_complete do |_value, _error|
|
|
47
|
-
scheduler.complete_blocking_await
|
|
48
|
-
scheduler.enqueue_fiber(-> { waiting_fiber.resume })
|
|
49
|
-
end
|
|
50
|
-
Fiber.yield(:cooperative_suspend)
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
value, error = @done_queue.pop
|
|
54
|
-
raise error if error
|
|
55
|
-
|
|
56
|
-
value
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
# Returns +false+ — a mapped task has no independent thread to kill.
|
|
60
|
-
# @return [Boolean]
|
|
61
|
-
# @api private
|
|
62
|
-
def alive?
|
|
63
|
-
false
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
# No-op — mapped tasks carry no independent thread to cancel.
|
|
67
|
-
# @return [self]
|
|
68
|
-
# @api private
|
|
69
|
-
def cancel!
|
|
70
|
-
self
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
# Blocks until the mapped task completes, with an optional timeout.
|
|
74
|
-
# @param limit [Numeric, nil]
|
|
75
|
-
# @return [Object, nil] +nil+ on timeout
|
|
76
|
-
# @api private
|
|
77
|
-
def join(limit = nil)
|
|
78
|
-
if limit.nil?
|
|
79
|
-
wait_result
|
|
80
|
-
else
|
|
81
|
-
begin
|
|
82
|
-
Timeout.timeout(limit) { wait_result }
|
|
83
|
-
rescue Timeout::Error
|
|
84
|
-
nil
|
|
85
|
-
end
|
|
86
|
-
end
|
|
87
|
-
end
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
|
-
end
|
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
class Task
|
|
5
|
-
# Thread-based Task backend (default).
|
|
6
|
-
#
|
|
7
|
-
# Each task runs on its own OS thread. Cancellation is delivered via
|
|
8
|
-
# +Thread#raise(CancellationError)+, which cooperates with +rescue+ clauses
|
|
9
|
-
# inside the block. This backend is always available and requires no
|
|
10
|
-
# external dependencies.
|
|
11
|
-
#
|
|
12
|
-
# When the cooperative scheduler backend is introduced, this backend will
|
|
13
|
-
# remain available as the fallback for blocking I/O operations that must
|
|
14
|
-
# run outside the scheduler (e.g. inside {BlockingAdapterPool}).
|
|
15
|
-
class ThreadBackend < Backend
|
|
16
|
-
def initialize(task:, &block)
|
|
17
|
-
super
|
|
18
|
-
@value = nil
|
|
19
|
-
@error = nil
|
|
20
|
-
@thread = Thread.new do
|
|
21
|
-
Thread.current.name = task.name if task.name
|
|
22
|
-
Thread.current[:phronomy_current_task] = task
|
|
23
|
-
Thread.current[:phronomy_task_cpu_slice_start_ms] =
|
|
24
|
-
Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
25
|
-
task.transition!(:running)
|
|
26
|
-
@value = block.call
|
|
27
|
-
task.transition!(:completed, value: @value)
|
|
28
|
-
rescue CancellationError => e
|
|
29
|
-
task.transition!(:cancelled, error: e)
|
|
30
|
-
@error = e
|
|
31
|
-
rescue => e
|
|
32
|
-
task.transition!(:failed, error: e)
|
|
33
|
-
@error = e
|
|
34
|
-
ensure
|
|
35
|
-
# Guard against Thread#raise firing before the rescue handler has a
|
|
36
|
-
# chance to run (e.g. when cancel! is called immediately after spawn).
|
|
37
|
-
task.transition!(:cancelled) unless task.done?
|
|
38
|
-
end
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
# @return [Object]
|
|
42
|
-
# @raise [Exception]
|
|
43
|
-
# @api private
|
|
44
|
-
def wait_result
|
|
45
|
-
@thread.join
|
|
46
|
-
raise @error if @error
|
|
47
|
-
|
|
48
|
-
@value
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
# @return [Boolean]
|
|
52
|
-
# @api private
|
|
53
|
-
def alive?
|
|
54
|
-
@thread.alive?
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
# @return [self]
|
|
58
|
-
# @api private
|
|
59
|
-
def cancel!
|
|
60
|
-
@thread.raise(CancellationError, "Task cancelled") if @thread.alive?
|
|
61
|
-
self
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
# @param limit [Numeric, nil]
|
|
65
|
-
# @return [Thread, nil]
|
|
66
|
-
# @api private
|
|
67
|
-
def join(limit = nil)
|
|
68
|
-
@thread.join(limit)
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
# @return [Object, nil]
|
|
72
|
-
# @api private
|
|
73
|
-
def completed_value
|
|
74
|
-
@value
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
# @return [Exception, nil]
|
|
78
|
-
# @api private
|
|
79
|
-
def completed_error
|
|
80
|
-
@error
|
|
81
|
-
end
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
end
|
|
@@ -1,265 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
# Manages a bounded set of concurrent {Task}s with structured concurrency.
|
|
5
|
-
#
|
|
6
|
-
# Enforces an upper bound on simultaneously running tasks (+limit+).
|
|
7
|
-
# When the limit is reached, {#spawn} blocks the caller until a slot
|
|
8
|
-
# becomes available. Results are always returned in the order tasks
|
|
9
|
-
# were spawned, regardless of completion order.
|
|
10
|
-
#
|
|
11
|
-
# A configurable +failure_policy+ controls how errors propagate:
|
|
12
|
-
# - +:fail_fast+ (default) — cancels all remaining tasks on the first error
|
|
13
|
-
# - +:collect_all+ — waits for every task to complete, then raises the first error
|
|
14
|
-
# - +:skip_failed+ — ignores failed tasks and returns only successful results
|
|
15
|
-
#
|
|
16
|
-
# {#cancel_all!} cancels every task in the group and joins them, guaranteeing
|
|
17
|
-
# that the active child task count reaches zero before returning.
|
|
18
|
-
#
|
|
19
|
-
# @example Parallel tool calls with a concurrency cap
|
|
20
|
-
# group = Phronomy::TaskGroup.new(limit: 5)
|
|
21
|
-
# tasks = items.map { |item| group.spawn { process(item) } }
|
|
22
|
-
# results = group.await_all # Array in spawn order
|
|
23
|
-
#
|
|
24
|
-
# @example Collect-all failure policy
|
|
25
|
-
# group = Phronomy::TaskGroup.new(failure_policy: :collect_all)
|
|
26
|
-
# …
|
|
27
|
-
class TaskGroup
|
|
28
|
-
# Valid failure policies.
|
|
29
|
-
FAILURE_POLICIES = %i[fail_fast collect_all skip_failed].freeze
|
|
30
|
-
|
|
31
|
-
# @param limit [Integer, Float::INFINITY] maximum simultaneous active tasks
|
|
32
|
-
# @param failure_policy [Symbol] one of {FAILURE_POLICIES} (default +:fail_fast+)
|
|
33
|
-
# @param runtime [Runtime, nil] runtime used to spawn tasks via {Runtime#spawn};
|
|
34
|
-
# when +nil+, tasks are created directly via +Task.new+ (backward-compatible mode).
|
|
35
|
-
# Pass +runtime: self+ from {Runtime#task_group} to keep task execution consistent
|
|
36
|
-
# with the configured scheduler backend.
|
|
37
|
-
# @api private
|
|
38
|
-
def initialize(limit: Float::INFINITY, failure_policy: :fail_fast, runtime: nil)
|
|
39
|
-
raise ArgumentError, "unknown failure_policy: #{failure_policy}" unless FAILURE_POLICIES.include?(failure_policy)
|
|
40
|
-
|
|
41
|
-
@limit = limit
|
|
42
|
-
@failure_policy = failure_policy
|
|
43
|
-
@runtime = runtime
|
|
44
|
-
@tasks = []
|
|
45
|
-
@mutex = Mutex.new
|
|
46
|
-
@cond = ConditionVariable.new
|
|
47
|
-
@active = 0
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
# Spawns a new task within the group.
|
|
51
|
-
# Blocks if the number of currently active tasks equals +limit+.
|
|
52
|
-
#
|
|
53
|
-
# @yield block to execute concurrently
|
|
54
|
-
# @return [Task] the spawned task
|
|
55
|
-
# @api private
|
|
56
|
-
def spawn(&block)
|
|
57
|
-
wait_for_slot!
|
|
58
|
-
|
|
59
|
-
task = if @runtime
|
|
60
|
-
@runtime.spawn(name: "task-group-worker") do
|
|
61
|
-
block.call
|
|
62
|
-
ensure
|
|
63
|
-
release_slot!
|
|
64
|
-
end
|
|
65
|
-
else
|
|
66
|
-
Task.new do
|
|
67
|
-
block.call
|
|
68
|
-
ensure
|
|
69
|
-
release_slot!
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
@mutex.synchronize { @tasks << task }
|
|
74
|
-
task
|
|
75
|
-
end
|
|
76
|
-
|
|
77
|
-
# Waits for all spawned tasks to complete.
|
|
78
|
-
# Returns results in spawn order.
|
|
79
|
-
#
|
|
80
|
-
# Failure behaviour is controlled by the +failure_policy+ set at
|
|
81
|
-
# construction time:
|
|
82
|
-
# - +:fail_fast+ — raises the first error after cancelling unfinished tasks
|
|
83
|
-
# - +:collect_all+ — waits for all tasks, then raises the first error
|
|
84
|
-
# - +:skip_failed+ — returns only the values of successful tasks
|
|
85
|
-
#
|
|
86
|
-
# @return [Array] results in spawn order (or successful-only for :skip_failed)
|
|
87
|
-
# @raise [Exception] when any task failed (except :skip_failed)
|
|
88
|
-
# @api private
|
|
89
|
-
def await_all
|
|
90
|
-
tasks = @mutex.synchronize { @tasks.dup }
|
|
91
|
-
return [] if tasks.empty?
|
|
92
|
-
|
|
93
|
-
if Phronomy::Runtime::Scheduler.current
|
|
94
|
-
_await_all_cooperative(tasks)
|
|
95
|
-
else
|
|
96
|
-
_await_all_threaded(tasks)
|
|
97
|
-
end
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
private
|
|
101
|
-
|
|
102
|
-
# Cooperative await_all for DeterministicScheduler context.
|
|
103
|
-
# Uses on_complete callbacks + AsyncQueue to observe task completions in
|
|
104
|
-
# arrival order (not spawn order), matching the fail-fast semantics of the
|
|
105
|
-
# threaded path. AsyncQueue#pop suspends the current Fiber cooperatively
|
|
106
|
-
# rather than blocking the OS thread.
|
|
107
|
-
# @api private
|
|
108
|
-
# @param tasks [Array<Task>]
|
|
109
|
-
# @return [Array]
|
|
110
|
-
def _await_all_cooperative(tasks)
|
|
111
|
-
completion_q = Phronomy::Concurrency::AsyncQueue.new
|
|
112
|
-
tasks.each_with_index do |task, idx|
|
|
113
|
-
task.on_complete do |value, error|
|
|
114
|
-
completion_q.push({index: idx, value: value, error: error})
|
|
115
|
-
end
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
entries = Array.new(tasks.length)
|
|
119
|
-
cancelled = false
|
|
120
|
-
fail_fast_error = nil
|
|
121
|
-
|
|
122
|
-
tasks.length.times do
|
|
123
|
-
entry = completion_q.pop # cooperative suspend via scheduler signal
|
|
124
|
-
entries[entry[:index]] = entry
|
|
125
|
-
|
|
126
|
-
if entry[:error] && @failure_policy == :fail_fast && !cancelled
|
|
127
|
-
cancelled = true
|
|
128
|
-
fail_fast_error = entry[:error]
|
|
129
|
-
tasks.each { |t| t.cancel! unless t.done? }
|
|
130
|
-
end
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
case @failure_policy
|
|
134
|
-
when :fail_fast
|
|
135
|
-
raise fail_fast_error if fail_fast_error
|
|
136
|
-
entries.map { |r| r[:value] }
|
|
137
|
-
when :skip_failed
|
|
138
|
-
entries.filter_map { |r| r[:value] unless r[:error] }
|
|
139
|
-
else # :collect_all
|
|
140
|
-
errors = entries.filter_map { |r| r[:error] }
|
|
141
|
-
raise errors.first if errors.any?
|
|
142
|
-
entries.map { |r| r[:value] }
|
|
143
|
-
end
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
# Thread-blocking await_all for ThreadBackend / ImmediateBackend context.
|
|
147
|
-
# Uses Task#on_complete callbacks instead of spawning N additional watcher
|
|
148
|
-
# tasks (Issue #328). on_complete receives the task's value and error
|
|
149
|
-
# directly — no await call is needed, eliminating the risk of a self-join
|
|
150
|
-
# when the callback fires inside the task's own execution thread.
|
|
151
|
-
def _await_all_threaded(tasks)
|
|
152
|
-
completion_q = Queue.new
|
|
153
|
-
tasks.each_with_index do |task, idx|
|
|
154
|
-
task.on_complete do |value, error|
|
|
155
|
-
completion_q.push({index: idx, value: value, error: error})
|
|
156
|
-
end
|
|
157
|
-
end
|
|
158
|
-
|
|
159
|
-
entries = Array.new(tasks.length)
|
|
160
|
-
cancelled = false
|
|
161
|
-
# The error that triggered fail_fast cancellation (tracked separately so
|
|
162
|
-
# we raise it rather than a secondary CancellationError from cancelled tasks).
|
|
163
|
-
fail_fast_error = nil
|
|
164
|
-
|
|
165
|
-
tasks.length.times do
|
|
166
|
-
entry = completion_q.pop
|
|
167
|
-
entries[entry[:index]] = entry
|
|
168
|
-
|
|
169
|
-
if entry[:error] && @failure_policy == :fail_fast && !cancelled
|
|
170
|
-
cancelled = true
|
|
171
|
-
fail_fast_error = entry[:error]
|
|
172
|
-
tasks.each { |t| t.cancel! unless t.done? }
|
|
173
|
-
end
|
|
174
|
-
end
|
|
175
|
-
|
|
176
|
-
case @failure_policy
|
|
177
|
-
when :fail_fast
|
|
178
|
-
raise fail_fast_error if fail_fast_error
|
|
179
|
-
entries.map { |r| r[:value] }
|
|
180
|
-
when :skip_failed
|
|
181
|
-
entries.filter_map { |r| r[:value] unless r[:error] }
|
|
182
|
-
else # :collect_all
|
|
183
|
-
errors = entries.filter_map { |r| r[:error] }
|
|
184
|
-
raise errors.first if errors.any?
|
|
185
|
-
entries.map { |r| r[:value] }
|
|
186
|
-
end
|
|
187
|
-
end
|
|
188
|
-
|
|
189
|
-
public
|
|
190
|
-
|
|
191
|
-
# Cancels all tasks currently in the group and waits for each to finish.
|
|
192
|
-
# After this method returns, the active child task count is guaranteed to
|
|
193
|
-
# be zero.
|
|
194
|
-
#
|
|
195
|
-
# Note: if a task is cancelled before its block has started executing, the
|
|
196
|
-
# internal +ensure+ clause inside the block may not run, so @active is
|
|
197
|
-
# reset explicitly after all tasks are joined.
|
|
198
|
-
#
|
|
199
|
-
# @return [self]
|
|
200
|
-
# @api private
|
|
201
|
-
def cancel_all!
|
|
202
|
-
tasks = @mutex.synchronize { @tasks.dup }
|
|
203
|
-
tasks.each(&:cancel!)
|
|
204
|
-
tasks.each do |t|
|
|
205
|
-
t.join
|
|
206
|
-
rescue
|
|
207
|
-
nil
|
|
208
|
-
end
|
|
209
|
-
# Force @active to zero: tasks cancelled before block execution starts
|
|
210
|
-
# may not decrement @active via their ensure clause.
|
|
211
|
-
scheduler = Phronomy::Runtime::Scheduler.current
|
|
212
|
-
if scheduler && @coop_signal
|
|
213
|
-
@active = 0
|
|
214
|
-
scheduler.raise_signal_all(@coop_signal)
|
|
215
|
-
else
|
|
216
|
-
@mutex.synchronize do
|
|
217
|
-
@active = 0
|
|
218
|
-
@cond.broadcast
|
|
219
|
-
end
|
|
220
|
-
end
|
|
221
|
-
self
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
# Returns the number of currently executing child tasks.
|
|
225
|
-
# @return [Integer]
|
|
226
|
-
# @api private
|
|
227
|
-
def active_task_count
|
|
228
|
-
@mutex.synchronize { @active }
|
|
229
|
-
end
|
|
230
|
-
|
|
231
|
-
private
|
|
232
|
-
|
|
233
|
-
def wait_for_slot!
|
|
234
|
-
scheduler = Phronomy::Runtime::Scheduler.current
|
|
235
|
-
if scheduler
|
|
236
|
-
@coop_signal ||= scheduler.new_signal
|
|
237
|
-
loop do
|
|
238
|
-
if @active < @limit
|
|
239
|
-
@active += 1
|
|
240
|
-
return
|
|
241
|
-
end
|
|
242
|
-
scheduler.wait_for_signal(@coop_signal)
|
|
243
|
-
end
|
|
244
|
-
else
|
|
245
|
-
@mutex.synchronize do
|
|
246
|
-
@cond.wait(@mutex) while @active >= @limit
|
|
247
|
-
@active += 1
|
|
248
|
-
end
|
|
249
|
-
end
|
|
250
|
-
end
|
|
251
|
-
|
|
252
|
-
def release_slot!
|
|
253
|
-
scheduler = Phronomy::Runtime::Scheduler.current
|
|
254
|
-
if scheduler && @coop_signal
|
|
255
|
-
@active -= 1
|
|
256
|
-
scheduler.raise_signal(@coop_signal)
|
|
257
|
-
else
|
|
258
|
-
@mutex.synchronize do
|
|
259
|
-
@active -= 1
|
|
260
|
-
@cond.signal
|
|
261
|
-
end
|
|
262
|
-
end
|
|
263
|
-
end
|
|
264
|
-
end
|
|
265
|
-
end
|