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.
Files changed (143) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +22 -0
  3. data/CHANGELOG.md +488 -0
  4. data/CONTRIBUTING.md +102 -0
  5. data/README.md +374 -36
  6. data/RELEASE_CHECKLIST.md +86 -0
  7. data/Rakefile +33 -0
  8. data/SECURITY.md +80 -0
  9. data/benchmark/baseline.json +9 -0
  10. data/benchmark/bench_agent_invoke.rb +105 -0
  11. data/benchmark/bench_context_assembler.rb +46 -0
  12. data/benchmark/bench_regression.rb +172 -0
  13. data/benchmark/bench_token_estimator.rb +44 -0
  14. data/benchmark/bench_tool_schema.rb +69 -0
  15. data/benchmark/bench_vector_store.rb +39 -0
  16. data/benchmark/bench_workflow.rb +55 -0
  17. data/benchmark/run_all.rb +118 -0
  18. data/docs/decisions/001-rubyllm-as-provider-layer.md +42 -0
  19. data/docs/decisions/002-workflow-context-immutability.md +42 -0
  20. data/docs/decisions/003-event-loop-singleton.md +48 -0
  21. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +75 -0
  22. data/docs/decisions/005-static-knowledge-class-level-cache.md +45 -0
  23. data/docs/decisions/006-no-built-in-guardrails.md +66 -0
  24. data/docs/decisions/007-mcp-is-beta-stability.md +51 -0
  25. data/docs/decisions/008-orchestrator-uses-os-threads.md +52 -0
  26. data/docs/decisions/009-state-store-abstraction.md +141 -0
  27. data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
  28. data/lib/phronomy/agent/base.rb +416 -49
  29. data/lib/phronomy/agent/before_completion_context.rb +1 -0
  30. data/lib/phronomy/agent/checkpoint.rb +1 -0
  31. data/lib/phronomy/agent/concerns/before_completion.rb +6 -0
  32. data/lib/phronomy/agent/concerns/error_translation.rb +45 -0
  33. data/lib/phronomy/agent/concerns/guardrailable.rb +3 -0
  34. data/lib/phronomy/agent/concerns/retryable.rb +12 -1
  35. data/lib/phronomy/agent/concerns/suspendable.rb +19 -0
  36. data/lib/phronomy/agent/fsm.rb +44 -52
  37. data/lib/phronomy/agent/handoff.rb +3 -0
  38. data/lib/phronomy/agent/orchestrator.rb +191 -54
  39. data/lib/phronomy/agent/parallel_tool_chat.rb +87 -13
  40. data/lib/phronomy/agent/react_agent.rb +16 -6
  41. data/lib/phronomy/agent/runner.rb +2 -0
  42. data/lib/phronomy/agent/shared_state.rb +11 -0
  43. data/lib/phronomy/agent/suspend_signal.rb +2 -0
  44. data/lib/phronomy/agent/team_coordinator.rb +17 -5
  45. data/lib/phronomy/async_queue.rb +155 -0
  46. data/lib/phronomy/blocking_adapter_pool.rb +435 -0
  47. data/lib/phronomy/cancellation_scope.rb +123 -0
  48. data/lib/phronomy/cancellation_token.rb +133 -0
  49. data/lib/phronomy/concurrency_gate.rb +155 -0
  50. data/lib/phronomy/configuration.rb +168 -2
  51. data/lib/phronomy/context/assembler.rb +6 -0
  52. data/lib/phronomy/context/compaction_context.rb +2 -0
  53. data/lib/phronomy/context/context_version_cache.rb +2 -0
  54. data/lib/phronomy/context/token_budget.rb +3 -0
  55. data/lib/phronomy/context/token_estimator.rb +9 -2
  56. data/lib/phronomy/context/trigger_context.rb +1 -0
  57. data/lib/phronomy/context/trim_context.rb +4 -0
  58. data/lib/phronomy/deadline.rb +63 -0
  59. data/lib/phronomy/diagnostics.rb +62 -0
  60. data/lib/phronomy/embeddings/base.rb +22 -2
  61. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +6 -2
  62. data/lib/phronomy/eval/comparison.rb +2 -0
  63. data/lib/phronomy/eval/dataset.rb +4 -0
  64. data/lib/phronomy/eval/metrics.rb +6 -0
  65. data/lib/phronomy/eval/runner.rb +11 -9
  66. data/lib/phronomy/eval/scorer/base.rb +1 -0
  67. data/lib/phronomy/eval/scorer/exact_match.rb +2 -0
  68. data/lib/phronomy/eval/scorer/includes_scorer.rb +2 -0
  69. data/lib/phronomy/eval/scorer/llm_judge.rb +2 -0
  70. data/lib/phronomy/event_loop.rb +275 -30
  71. data/lib/phronomy/fsm_session.rb +57 -4
  72. data/lib/phronomy/generator_verifier.rb +2 -0
  73. data/lib/phronomy/guardrail/base.rb +3 -0
  74. data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
  75. data/lib/phronomy/invocation_context.rb +152 -0
  76. data/lib/phronomy/knowledge_source/base.rb +24 -2
  77. data/lib/phronomy/knowledge_source/entity_knowledge.rb +7 -2
  78. data/lib/phronomy/knowledge_source/rag_knowledge.rb +8 -4
  79. data/lib/phronomy/knowledge_source/static_knowledge.rb +7 -2
  80. data/lib/phronomy/llm_adapter/base.rb +104 -0
  81. data/lib/phronomy/llm_adapter/ruby_llm.rb +41 -0
  82. data/lib/phronomy/llm_adapter.rb +20 -0
  83. data/lib/phronomy/loader/base.rb +1 -0
  84. data/lib/phronomy/loader/csv_loader.rb +2 -0
  85. data/lib/phronomy/loader/markdown_loader.rb +2 -0
  86. data/lib/phronomy/loader/plain_text_loader.rb +1 -0
  87. data/lib/phronomy/metrics.rb +38 -0
  88. data/lib/phronomy/output_parser/base.rb +1 -0
  89. data/lib/phronomy/output_parser/json_parser.rb +22 -3
  90. data/lib/phronomy/output_parser/structured_parser.rb +2 -0
  91. data/lib/phronomy/prompt_template.rb +5 -0
  92. data/lib/phronomy/runnable.rb +20 -3
  93. data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
  94. data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
  95. data/lib/phronomy/runtime/gate_registry.rb +52 -0
  96. data/lib/phronomy/runtime/pool_registry.rb +57 -0
  97. data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
  98. data/lib/phronomy/runtime/scheduler.rb +98 -0
  99. data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
  100. data/lib/phronomy/runtime/task_registry.rb +48 -0
  101. data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
  102. data/lib/phronomy/runtime/timer_queue.rb +106 -0
  103. data/lib/phronomy/runtime/timer_service.rb +42 -0
  104. data/lib/phronomy/runtime.rb +374 -0
  105. data/lib/phronomy/splitter/base.rb +2 -0
  106. data/lib/phronomy/splitter/fixed_size_splitter.rb +2 -0
  107. data/lib/phronomy/splitter/recursive_splitter.rb +2 -0
  108. data/lib/phronomy/state_store/base.rb +48 -0
  109. data/lib/phronomy/state_store/in_memory.rb +62 -0
  110. data/lib/phronomy/task/backend.rb +80 -0
  111. data/lib/phronomy/task/fiber_backend.rb +157 -0
  112. data/lib/phronomy/task/immediate_backend.rb +89 -0
  113. data/lib/phronomy/task/thread_backend.rb +84 -0
  114. data/lib/phronomy/task.rb +275 -0
  115. data/lib/phronomy/task_group.rb +265 -0
  116. data/lib/phronomy/testing/fake_clock.rb +109 -0
  117. data/lib/phronomy/testing/fake_scheduler.rb +104 -0
  118. data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
  119. data/lib/phronomy/testing.rb +12 -0
  120. data/lib/phronomy/tool/agent_tool.rb +1 -0
  121. data/lib/phronomy/tool/base.rb +298 -28
  122. data/lib/phronomy/tool/mcp_tool.rb +103 -17
  123. data/lib/phronomy/tool/scope_policy.rb +50 -0
  124. data/lib/phronomy/tool_executor.rb +106 -0
  125. data/lib/phronomy/tracing/base.rb +3 -0
  126. data/lib/phronomy/tracing/langfuse_tracer.rb +2 -0
  127. data/lib/phronomy/tracing/open_telemetry_tracer.rb +36 -0
  128. data/lib/phronomy/vector_store/async_backend.rb +110 -0
  129. data/lib/phronomy/vector_store/base.rb +40 -7
  130. data/lib/phronomy/vector_store/in_memory.rb +16 -7
  131. data/lib/phronomy/vector_store/pgvector.rb +40 -9
  132. data/lib/phronomy/vector_store/redis_search.rb +29 -8
  133. data/lib/phronomy/version.rb +1 -1
  134. data/lib/phronomy/workflow.rb +147 -11
  135. data/lib/phronomy/workflow_context.rb +83 -6
  136. data/lib/phronomy/workflow_runner.rb +106 -7
  137. data/lib/phronomy.rb +112 -1
  138. data/scripts/api_snapshot.rb +91 -0
  139. data/scripts/check_api_annotations.rb +68 -0
  140. data/scripts/check_private_enforcement.rb +93 -0
  141. data/scripts/check_readme_runnable.rb +98 -0
  142. data/scripts/run_mutation.sh +46 -0
  143. 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