phronomy 0.7.0 → 0.8.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 -7
- data/CHANGELOG.md +151 -1
- data/README.md +170 -47
- data/Rakefile +33 -0
- data/benchmark/baseline.json +1 -1
- data/benchmark/bench_context_assembler.rb +2 -2
- data/benchmark/bench_regression.rb +6 -5
- data/benchmark/bench_token_estimator.rb +5 -5
- data/benchmark/bench_tool_schema.rb +1 -1
- data/benchmark/bench_vector_store.rb +1 -1
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
- data/docs/decisions/006-no-built-in-guardrails.md +20 -2
- data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
- data/lib/phronomy/agent/base.rb +285 -137
- data/lib/phronomy/agent/checkpoint.rb +118 -0
- data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
- data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
- data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
- data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
- data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
- data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
- data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
- data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
- data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
- data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
- data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
- data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
- data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
- data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
- data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
- data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
- data/lib/phronomy/agent/fsm.rb +42 -65
- data/lib/phronomy/agent/invocation_pipeline.rb +99 -0
- data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
- data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
- data/lib/phronomy/agent/react_agent.rb +27 -14
- data/lib/phronomy/agent/runner.rb +2 -2
- data/lib/phronomy/agent/tool_executor.rb +108 -0
- data/lib/phronomy/concurrency/async_queue.rb +157 -0
- data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
- data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
- data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
- data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
- data/lib/phronomy/concurrency/deadline.rb +65 -0
- data/lib/phronomy/concurrency/gate_registry.rb +52 -0
- data/lib/phronomy/concurrency/pool_registry.rb +57 -0
- data/lib/phronomy/configuration.rb +142 -0
- data/lib/phronomy/context.rb +2 -8
- data/lib/phronomy/diagnostics.rb +62 -0
- data/lib/phronomy/embeddings.rb +2 -2
- data/lib/phronomy/eval/runner.rb +13 -9
- data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
- data/lib/phronomy/event_loop.rb +184 -46
- data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
- data/lib/phronomy/invocation_context.rb +152 -0
- data/lib/phronomy/knowledge_source.rb +0 -5
- data/lib/phronomy/llm_adapter/base.rb +104 -0
- data/lib/phronomy/llm_adapter/ruby_llm.rb +47 -0
- data/lib/phronomy/llm_adapter.rb +20 -0
- data/lib/phronomy/{context → llm_context_window}/assembler.rb +18 -3
- data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
- data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
- data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
- data/lib/phronomy/loader.rb +4 -4
- data/lib/phronomy/metrics.rb +38 -0
- data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
- data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +151 -126
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +149 -0
- data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
- data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
- data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
- data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
- data/lib/phronomy/runtime/scheduler.rb +98 -0
- data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
- data/lib/phronomy/runtime/task_registry.rb +48 -0
- data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
- data/lib/phronomy/runtime/timer_queue.rb +106 -0
- data/lib/phronomy/runtime/timer_service.rb +42 -0
- data/lib/phronomy/runtime.rb +389 -0
- data/lib/phronomy/splitter.rb +3 -3
- data/lib/phronomy/task/backend.rb +80 -0
- data/lib/phronomy/task/fiber_backend.rb +157 -0
- data/lib/phronomy/task/immediate_backend.rb +89 -0
- data/lib/phronomy/task/thread_backend.rb +84 -0
- data/lib/phronomy/task.rb +275 -0
- data/lib/phronomy/task_group.rb +265 -0
- data/lib/phronomy/testing/fake_clock.rb +109 -0
- data/lib/phronomy/testing/fake_scheduler.rb +104 -0
- data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
- data/lib/phronomy/testing.rb +12 -0
- data/lib/phronomy/tool/base.rb +156 -7
- data/lib/phronomy/tool/mcp_tool.rb +47 -16
- data/lib/phronomy/tool/scope_policy.rb +50 -0
- data/lib/phronomy/tracing/null_tracer.rb +3 -1
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
- data/lib/phronomy/vector_store.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +52 -5
- data/lib/phronomy/workflow_context.rb +37 -2
- data/lib/phronomy/workflow_runner.rb +28 -77
- data/lib/phronomy.rb +43 -0
- metadata +73 -33
- data/lib/phronomy/agent/parallel_tool_chat.rb +0 -92
- data/lib/phronomy/cancellation_token.rb +0 -92
- data/lib/phronomy/context/compaction_context.rb +0 -111
- data/lib/phronomy/context/trigger_context.rb +0 -39
- data/lib/phronomy/context/trim_context.rb +0 -75
- data/lib/phronomy/embeddings/base.rb +0 -22
- data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
- data/lib/phronomy/fsm_session.rb +0 -201
- data/lib/phronomy/knowledge_source/base.rb +0 -36
- data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
- data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
- data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
- data/lib/phronomy/loader/base.rb +0 -25
- data/lib/phronomy/loader/csv_loader.rb +0 -56
- data/lib/phronomy/loader/markdown_loader.rb +0 -76
- data/lib/phronomy/loader/plain_text_loader.rb +0 -22
- data/lib/phronomy/prompt_template.rb +0 -96
- data/lib/phronomy/splitter/base.rb +0 -47
- data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
- data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
- data/lib/phronomy/vector_store/base.rb +0 -82
- data/lib/phronomy/vector_store/in_memory.rb +0 -93
- data/lib/phronomy/vector_store/pgvector.rb +0 -127
- data/lib/phronomy/vector_store/redis_search.rb +0 -192
|
@@ -0,0 +1,89 @@
|
|
|
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 await
|
|
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
|
|
@@ -0,0 +1,84 @@
|
|
|
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 await
|
|
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
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "task/backend"
|
|
4
|
+
require_relative "task/thread_backend"
|
|
5
|
+
require_relative "task/immediate_backend"
|
|
6
|
+
require_relative "task/fiber_backend"
|
|
7
|
+
|
|
8
|
+
module Phronomy
|
|
9
|
+
# A single unit of concurrent work.
|
|
10
|
+
#
|
|
11
|
+
# Decouples task semantics from the underlying execution primitive via a
|
|
12
|
+
# pluggable {Backend}. The default backend is {ThreadBackend}; a cooperative
|
|
13
|
+
# or test-double backend can be substituted via {.default_backend_class=} or
|
|
14
|
+
# by passing +backend_class:+ to {.spawn}.
|
|
15
|
+
#
|
|
16
|
+
# +Task.spawn+ is an **internal API** used by schedulers and the framework
|
|
17
|
+
# itself. Application code and framework components should use
|
|
18
|
+
# +Runtime.instance.spawn+ instead, which routes through the configured
|
|
19
|
+
# scheduler and respects the concurrency model.
|
|
20
|
+
#
|
|
21
|
+
# @example Basic usage (framework/test code only — prefer Runtime.instance.spawn in app code)
|
|
22
|
+
# task = Phronomy::Task.spawn { expensive_io() }
|
|
23
|
+
# result = task.await # blocks until done, re-raises errors
|
|
24
|
+
#
|
|
25
|
+
# @example Cancel a running task
|
|
26
|
+
# task = Phronomy::Task.spawn { loop { Phronomy::Task.checkpoint! } }
|
|
27
|
+
# task.cancel!
|
|
28
|
+
#
|
|
29
|
+
# @example Task tree — cancel parent cancels children
|
|
30
|
+
# parent = Phronomy::Task.spawn { sleep 10 }
|
|
31
|
+
# child = Phronomy::Task.spawn(parent: parent) { sleep 10 }
|
|
32
|
+
# parent.cancel! # child is also cancelled
|
|
33
|
+
class Task
|
|
34
|
+
# Valid task lifecycle states.
|
|
35
|
+
STATES = %i[pending running completed failed cancelled].freeze
|
|
36
|
+
|
|
37
|
+
# Returns the process-wide default backend class.
|
|
38
|
+
# Defaults to {ThreadBackend}.
|
|
39
|
+
# Override in tests or to enable a cooperative scheduler backend.
|
|
40
|
+
# @return [Class<Backend>]
|
|
41
|
+
# @api private
|
|
42
|
+
def self.default_backend_class
|
|
43
|
+
@default_backend_class || ThreadBackend
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Sets the process-wide default backend class.
|
|
47
|
+
# @param klass [Class<Backend>]
|
|
48
|
+
# @api private
|
|
49
|
+
def self.default_backend_class=(klass)
|
|
50
|
+
@default_backend_class = klass
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Returns the {Task} currently executing on this thread, or +nil+.
|
|
54
|
+
# Returns +nil+ when called from outside a task-managed execution context.
|
|
55
|
+
# @return [Task, nil]
|
|
56
|
+
# @api private
|
|
57
|
+
def self.current
|
|
58
|
+
Thread.current[:phronomy_current_task]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Returns the monotonic clock value (ms) when the current task last recorded
|
|
62
|
+
# a yield (or when the task started), or +nil+ when not inside a task context.
|
|
63
|
+
# Used by {Runtime#yield} for CPU-bound detection without placing
|
|
64
|
+
# +Thread.current+ in files outside the allowlist.
|
|
65
|
+
# @return [Integer, nil]
|
|
66
|
+
# @api private
|
|
67
|
+
def self.current_cpu_slice_start_ms
|
|
68
|
+
Thread.current[:phronomy_task_cpu_slice_start_ms]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Resets the CPU slice start clock for the current task to +now+.
|
|
72
|
+
# Call this immediately after the cooperative yield has been performed so
|
|
73
|
+
# that the next yield correctly measures only the time since the last yield.
|
|
74
|
+
# @api private
|
|
75
|
+
def self.record_yield!
|
|
76
|
+
Thread.current[:phronomy_task_cpu_slice_start_ms] =
|
|
77
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Returns and increments a per-thread yield-if-needed counter.
|
|
81
|
+
# Used by {Runtime#yield_if_needed} so that the counter is thread-local
|
|
82
|
+
# without putting +Thread.current+ in runtime.rb (which is outside the
|
|
83
|
+
# Thread.current allowlist).
|
|
84
|
+
# @return [Integer] the new counter value
|
|
85
|
+
# @api private
|
|
86
|
+
def self.increment_yield_counter!
|
|
87
|
+
count = (Thread.current[:phronomy_yield_if_needed_counter] || 0) + 1
|
|
88
|
+
Thread.current[:phronomy_yield_if_needed_counter] = count
|
|
89
|
+
count
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Cooperative cancellation checkpoint.
|
|
93
|
+
#
|
|
94
|
+
# Raises {CancellationError} if the current task's status is +:cancelled+.
|
|
95
|
+
# On {ThreadBackend}, cancellation is delivered via +Thread#raise+ so this
|
|
96
|
+
# is a no-op in practice; on future cooperative backends this will be the
|
|
97
|
+
# primary cancellation mechanism.
|
|
98
|
+
#
|
|
99
|
+
# Safe to call from outside a task context (no-op when no current task).
|
|
100
|
+
# @return [void]
|
|
101
|
+
# @raise [CancellationError] if the current task has been cancelled
|
|
102
|
+
# @api private
|
|
103
|
+
def self.checkpoint!
|
|
104
|
+
ct = current
|
|
105
|
+
return unless ct
|
|
106
|
+
|
|
107
|
+
raise CancellationError, "Task cancelled" if ct.status == :cancelled
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Spawns a new task executing +block+ concurrently.
|
|
111
|
+
#
|
|
112
|
+
# @param name [String, nil] optional human-readable label
|
|
113
|
+
# @param parent [Task, nil] parent task; cancelling the parent
|
|
114
|
+
# also cancels this task (default: currently running task)
|
|
115
|
+
# @param backend_class [Class<Backend>] backend to use
|
|
116
|
+
# @yieldreturn [Object] the task result
|
|
117
|
+
# @return [Task]
|
|
118
|
+
# @api private
|
|
119
|
+
def self.spawn(name: nil, parent: current, backend_class: default_backend_class, &block)
|
|
120
|
+
new(name: name, parent: parent, backend_class: backend_class, &block)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# @return [String, nil] optional human-readable label
|
|
124
|
+
attr_reader :name
|
|
125
|
+
|
|
126
|
+
# @return [Task, nil] parent task in the task tree, if any
|
|
127
|
+
attr_reader :parent
|
|
128
|
+
|
|
129
|
+
# @return [Backend] the execution backend for this task
|
|
130
|
+
# @api private
|
|
131
|
+
attr_reader :backend
|
|
132
|
+
|
|
133
|
+
# @param name [String, nil]
|
|
134
|
+
# @param parent [Task, nil]
|
|
135
|
+
# @param backend_class [Class<Backend>]
|
|
136
|
+
# @api private use {.spawn} instead
|
|
137
|
+
def initialize(name: nil, parent: nil, backend_class: self.class.default_backend_class, &block)
|
|
138
|
+
@name = name
|
|
139
|
+
@parent = parent
|
|
140
|
+
@status = :pending
|
|
141
|
+
@mutex = Mutex.new
|
|
142
|
+
@children = []
|
|
143
|
+
@on_complete_callbacks = []
|
|
144
|
+
@completed_value = nil
|
|
145
|
+
@completed_error = nil
|
|
146
|
+
parent&.register_child(self)
|
|
147
|
+
@backend = backend_class.new(task: self, &block)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Returns the current lifecycle state.
|
|
151
|
+
# @return [Symbol] one of {STATES}
|
|
152
|
+
# @api private
|
|
153
|
+
def status
|
|
154
|
+
@mutex.synchronize { @status }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Blocks until the task completes and returns its value.
|
|
158
|
+
# Re-raises any exception raised inside the block.
|
|
159
|
+
#
|
|
160
|
+
# @return [Object] the result produced by the block
|
|
161
|
+
# @raise [Exception] if the block raised an error
|
|
162
|
+
# @api private
|
|
163
|
+
def await
|
|
164
|
+
@backend.await
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Registers a callback to be invoked when the task reaches a terminal state
|
|
168
|
+
# (+:completed+, +:failed+, or +:cancelled+).
|
|
169
|
+
#
|
|
170
|
+
# The callback receives two arguments: +value+ (the task's return value,
|
|
171
|
+
# or +nil+) and +error+ (the exception, or +nil+). These are provided
|
|
172
|
+
# directly so the callback does not need to call +task.await+, which would
|
|
173
|
+
# risk a self-join error when the callback runs inside the task's own thread.
|
|
174
|
+
#
|
|
175
|
+
# If the task is already done when this method is called, the callback is
|
|
176
|
+
# invoked immediately (synchronously, on the calling thread).
|
|
177
|
+
#
|
|
178
|
+
# @yield [value, error] called when the task finishes
|
|
179
|
+
# @return [self]
|
|
180
|
+
# @api private
|
|
181
|
+
def on_complete(&callback)
|
|
182
|
+
fire_now = false
|
|
183
|
+
fire_args = nil
|
|
184
|
+
@mutex.synchronize do
|
|
185
|
+
# Check @status directly to avoid re-entering the mutex (done? calls
|
|
186
|
+
# status, which also takes @mutex).
|
|
187
|
+
if %i[completed failed cancelled].include?(@status)
|
|
188
|
+
fire_now = true
|
|
189
|
+
fire_args = [@completed_value, @completed_error]
|
|
190
|
+
else
|
|
191
|
+
@on_complete_callbacks << callback
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
callback.call(*fire_args) if fire_now
|
|
195
|
+
self
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# Returns +true+ once the task has finished (success, error, or cancellation).
|
|
199
|
+
# @return [Boolean]
|
|
200
|
+
# @api private
|
|
201
|
+
def done?
|
|
202
|
+
%i[completed failed cancelled].include?(status)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Requests cancellation. Propagates to all registered child tasks.
|
|
206
|
+
# Sets status to :cancelled immediately so that even tasks that have not
|
|
207
|
+
# started executing yet are correctly marked as cancelled after join.
|
|
208
|
+
# Passes a CancellationError to on_complete callbacks so callers do not
|
|
209
|
+
# need to call await to discover the error.
|
|
210
|
+
# @return [self]
|
|
211
|
+
# @api private
|
|
212
|
+
def cancel!
|
|
213
|
+
transition!(:cancelled, error: CancellationError.new("Task cancelled"))
|
|
214
|
+
# @backend may be nil if cancel! is called while ImmediateBackend is still
|
|
215
|
+
# initializing (the block runs synchronously inside .new, so register_child
|
|
216
|
+
# fires before @backend is assigned). Safe-navigate to avoid NoMethodError.
|
|
217
|
+
@backend&.cancel!
|
|
218
|
+
children = @mutex.synchronize { @children.dup }
|
|
219
|
+
children.each(&:cancel!)
|
|
220
|
+
self
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Joins the underlying execution context, optionally with a timeout.
|
|
224
|
+
# Returns +nil+ when the timeout expires before completion.
|
|
225
|
+
#
|
|
226
|
+
# @param limit [Numeric, nil] seconds to wait; nil waits indefinitely
|
|
227
|
+
# @return [Object, nil]
|
|
228
|
+
# @api private
|
|
229
|
+
def join(limit = nil)
|
|
230
|
+
@backend.join(limit)
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# Returns +true+ while the task's block is still executing.
|
|
234
|
+
# @return [Boolean]
|
|
235
|
+
# @api private
|
|
236
|
+
def alive?
|
|
237
|
+
@backend.alive?
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# Updates the task lifecycle state.
|
|
241
|
+
# Called by backends during execution transitions.
|
|
242
|
+
# Terminal states (completed/failed/cancelled) are never overwritten.
|
|
243
|
+
# When a terminal state is reached, fires on_complete callbacks (outside
|
|
244
|
+
# the mutex) passing the result value and error directly.
|
|
245
|
+
#
|
|
246
|
+
# @param new_status [Symbol]
|
|
247
|
+
# @param value [Object, nil] task return value (terminal states only)
|
|
248
|
+
# @param error [Exception, nil] exception raised by the block, if any
|
|
249
|
+
# @api private
|
|
250
|
+
def transition!(new_status, value: nil, error: nil)
|
|
251
|
+
callbacks = nil
|
|
252
|
+
@mutex.synchronize do
|
|
253
|
+
# Check @status directly (not via #done?) to avoid re-entering the mutex.
|
|
254
|
+
return if %i[completed failed cancelled].include?(@status)
|
|
255
|
+
|
|
256
|
+
@status = new_status
|
|
257
|
+
if %i[completed failed cancelled].include?(new_status)
|
|
258
|
+
@completed_value = value
|
|
259
|
+
@completed_error = error
|
|
260
|
+
callbacks = @on_complete_callbacks.dup
|
|
261
|
+
@on_complete_callbacks.clear
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
callbacks&.each { |cb| cb.call(value, error) }
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Registers +child+ as a child task for cancellation propagation.
|
|
268
|
+
# Called automatically during child task initialization.
|
|
269
|
+
# @param child [Task]
|
|
270
|
+
# @api private
|
|
271
|
+
def register_child(child)
|
|
272
|
+
@mutex.synchronize { @children << child }
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
end
|
|
@@ -0,0 +1,265 @@
|
|
|
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
|