phronomy 0.6.0 → 0.7.1
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 +22 -0
- data/CHANGELOG.md +488 -0
- data/CONTRIBUTING.md +102 -0
- data/README.md +374 -36
- data/RELEASE_CHECKLIST.md +86 -0
- data/Rakefile +33 -0
- data/SECURITY.md +80 -0
- data/benchmark/baseline.json +9 -0
- data/benchmark/bench_agent_invoke.rb +105 -0
- data/benchmark/bench_context_assembler.rb +46 -0
- data/benchmark/bench_regression.rb +172 -0
- data/benchmark/bench_token_estimator.rb +44 -0
- data/benchmark/bench_tool_schema.rb +69 -0
- data/benchmark/bench_vector_store.rb +39 -0
- data/benchmark/bench_workflow.rb +55 -0
- data/benchmark/run_all.rb +118 -0
- data/docs/decisions/001-rubyllm-as-provider-layer.md +42 -0
- data/docs/decisions/002-workflow-context-immutability.md +42 -0
- data/docs/decisions/003-event-loop-singleton.md +48 -0
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +75 -0
- data/docs/decisions/005-static-knowledge-class-level-cache.md +45 -0
- data/docs/decisions/006-no-built-in-guardrails.md +66 -0
- data/docs/decisions/007-mcp-is-beta-stability.md +51 -0
- data/docs/decisions/008-orchestrator-uses-os-threads.md +52 -0
- data/docs/decisions/009-state-store-abstraction.md +141 -0
- data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
- data/lib/phronomy/agent/base.rb +416 -49
- data/lib/phronomy/agent/before_completion_context.rb +1 -0
- data/lib/phronomy/agent/checkpoint.rb +1 -0
- data/lib/phronomy/agent/concerns/before_completion.rb +6 -0
- data/lib/phronomy/agent/concerns/error_translation.rb +45 -0
- data/lib/phronomy/agent/concerns/guardrailable.rb +3 -0
- data/lib/phronomy/agent/concerns/retryable.rb +12 -1
- data/lib/phronomy/agent/concerns/suspendable.rb +19 -0
- data/lib/phronomy/agent/fsm.rb +44 -52
- data/lib/phronomy/agent/handoff.rb +3 -0
- data/lib/phronomy/agent/orchestrator.rb +191 -54
- data/lib/phronomy/agent/parallel_tool_chat.rb +87 -13
- data/lib/phronomy/agent/react_agent.rb +16 -6
- data/lib/phronomy/agent/runner.rb +2 -0
- data/lib/phronomy/agent/shared_state.rb +11 -0
- data/lib/phronomy/agent/suspend_signal.rb +2 -0
- data/lib/phronomy/agent/team_coordinator.rb +17 -5
- data/lib/phronomy/async_queue.rb +155 -0
- data/lib/phronomy/blocking_adapter_pool.rb +435 -0
- data/lib/phronomy/cancellation_scope.rb +123 -0
- data/lib/phronomy/cancellation_token.rb +133 -0
- data/lib/phronomy/concurrency_gate.rb +155 -0
- data/lib/phronomy/configuration.rb +168 -2
- data/lib/phronomy/context/assembler.rb +6 -0
- data/lib/phronomy/context/compaction_context.rb +2 -0
- data/lib/phronomy/context/context_version_cache.rb +2 -0
- data/lib/phronomy/context/token_budget.rb +3 -0
- data/lib/phronomy/context/token_estimator.rb +9 -2
- data/lib/phronomy/context/trigger_context.rb +1 -0
- data/lib/phronomy/context/trim_context.rb +4 -0
- data/lib/phronomy/deadline.rb +63 -0
- data/lib/phronomy/diagnostics.rb +62 -0
- data/lib/phronomy/embeddings/base.rb +22 -2
- data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +6 -2
- data/lib/phronomy/eval/comparison.rb +2 -0
- data/lib/phronomy/eval/dataset.rb +4 -0
- data/lib/phronomy/eval/metrics.rb +6 -0
- data/lib/phronomy/eval/runner.rb +11 -9
- data/lib/phronomy/eval/scorer/base.rb +1 -0
- data/lib/phronomy/eval/scorer/exact_match.rb +2 -0
- data/lib/phronomy/eval/scorer/includes_scorer.rb +2 -0
- data/lib/phronomy/eval/scorer/llm_judge.rb +2 -0
- data/lib/phronomy/event_loop.rb +275 -30
- data/lib/phronomy/fsm_session.rb +57 -4
- data/lib/phronomy/generator_verifier.rb +2 -0
- data/lib/phronomy/guardrail/base.rb +3 -0
- data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
- data/lib/phronomy/invocation_context.rb +152 -0
- data/lib/phronomy/knowledge_source/base.rb +24 -2
- data/lib/phronomy/knowledge_source/entity_knowledge.rb +7 -2
- data/lib/phronomy/knowledge_source/rag_knowledge.rb +8 -4
- data/lib/phronomy/knowledge_source/static_knowledge.rb +7 -2
- data/lib/phronomy/llm_adapter/base.rb +104 -0
- data/lib/phronomy/llm_adapter/ruby_llm.rb +41 -0
- data/lib/phronomy/llm_adapter.rb +20 -0
- data/lib/phronomy/loader/base.rb +1 -0
- data/lib/phronomy/loader/csv_loader.rb +2 -0
- data/lib/phronomy/loader/markdown_loader.rb +2 -0
- data/lib/phronomy/loader/plain_text_loader.rb +1 -0
- data/lib/phronomy/metrics.rb +38 -0
- data/lib/phronomy/output_parser/base.rb +1 -0
- data/lib/phronomy/output_parser/json_parser.rb +22 -3
- data/lib/phronomy/output_parser/structured_parser.rb +2 -0
- data/lib/phronomy/prompt_template.rb +5 -0
- data/lib/phronomy/runnable.rb +20 -3
- data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
- data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
- data/lib/phronomy/runtime/gate_registry.rb +52 -0
- data/lib/phronomy/runtime/pool_registry.rb +57 -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 +374 -0
- data/lib/phronomy/splitter/base.rb +2 -0
- data/lib/phronomy/splitter/fixed_size_splitter.rb +2 -0
- data/lib/phronomy/splitter/recursive_splitter.rb +2 -0
- data/lib/phronomy/state_store/base.rb +48 -0
- data/lib/phronomy/state_store/in_memory.rb +62 -0
- 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/agent_tool.rb +1 -0
- data/lib/phronomy/tool/base.rb +298 -28
- data/lib/phronomy/tool/mcp_tool.rb +103 -17
- data/lib/phronomy/tool/scope_policy.rb +50 -0
- data/lib/phronomy/tool_executor.rb +106 -0
- data/lib/phronomy/tracing/base.rb +3 -0
- data/lib/phronomy/tracing/langfuse_tracer.rb +2 -0
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +36 -0
- data/lib/phronomy/vector_store/async_backend.rb +110 -0
- data/lib/phronomy/vector_store/base.rb +40 -7
- data/lib/phronomy/vector_store/in_memory.rb +16 -7
- data/lib/phronomy/vector_store/pgvector.rb +40 -9
- data/lib/phronomy/vector_store/redis_search.rb +29 -8
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +147 -11
- data/lib/phronomy/workflow_context.rb +83 -6
- data/lib/phronomy/workflow_runner.rb +106 -7
- data/lib/phronomy.rb +112 -1
- data/scripts/api_snapshot.rb +91 -0
- data/scripts/check_api_annotations.rb +68 -0
- data/scripts/check_private_enforcement.rb +93 -0
- data/scripts/check_readme_runnable.rb +98 -0
- data/scripts/run_mutation.sh +46 -0
- metadata +83 -2
|
@@ -0,0 +1,157 @@
|
|
|
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 await
|
|
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
|
|
@@ -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
|