taski 0.5.0 → 0.7.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.
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+
5
+ module Taski
6
+ module Execution
7
+ # WorkerPool manages a pool of worker threads that execute tasks.
8
+ # It provides methods to start, stop, and enqueue tasks for execution.
9
+ #
10
+ # == Responsibilities
11
+ #
12
+ # - Manage worker thread lifecycle (start, shutdown)
13
+ # - Distribute tasks to worker threads via Queue
14
+ # - Execute tasks via callback provided by Executor
15
+ #
16
+ # == API
17
+ #
18
+ # - {#start} - Start all worker threads
19
+ # - {#enqueue} - Add a task to the execution queue
20
+ # - {#shutdown} - Gracefully shutdown all worker threads
21
+ # - {#execution_queue} - Access the underlying Queue (for testing)
22
+ #
23
+ # == Thread Safety
24
+ #
25
+ # WorkerPool uses Queue for thread-safe task distribution.
26
+ # The Queue handles synchronization between the main thread
27
+ # (which enqueues tasks) and worker threads (which pop tasks).
28
+ class WorkerPool
29
+ attr_reader :execution_queue
30
+
31
+ # @param registry [Registry] The task registry for thread tracking
32
+ # @param worker_count [Integer, nil] Number of worker threads (defaults to CPU count)
33
+ # @param on_execute [Proc] Callback to execute a task, receives (task_class, wrapper)
34
+ def initialize(registry:, worker_count: nil, &on_execute)
35
+ @worker_count = worker_count || default_worker_count
36
+ @registry = registry
37
+ @on_execute = on_execute
38
+ @execution_queue = Queue.new
39
+ @workers = []
40
+ end
41
+
42
+ # Start all worker threads.
43
+ def start
44
+ @worker_count.times do
45
+ worker = Thread.new { worker_loop }
46
+ @workers << worker
47
+ @registry.register_thread(worker)
48
+ end
49
+ end
50
+
51
+ # Enqueue a task for execution.
52
+ #
53
+ # @param task_class [Class] The task class to execute
54
+ # @param wrapper [TaskWrapper] The task wrapper
55
+ def enqueue(task_class, wrapper)
56
+ @execution_queue.push({task_class: task_class, wrapper: wrapper})
57
+ debug_log("Enqueued: #{task_class}")
58
+ end
59
+
60
+ # Shutdown all worker threads gracefully.
61
+ def shutdown
62
+ enqueue_shutdown_signals
63
+ @workers.each(&:join)
64
+ end
65
+
66
+ # Enqueue shutdown signals for all workers.
67
+ def enqueue_shutdown_signals
68
+ @worker_count.times { @execution_queue.push(:shutdown) }
69
+ end
70
+
71
+ private
72
+
73
+ def default_worker_count
74
+ Etc.nprocessors.clamp(2, 8)
75
+ end
76
+
77
+ def worker_loop
78
+ loop do
79
+ work_item = @execution_queue.pop
80
+ break if work_item == :shutdown
81
+
82
+ task_class = work_item[:task_class]
83
+ wrapper = work_item[:wrapper]
84
+
85
+ debug_log("Worker executing: #{task_class}")
86
+
87
+ begin
88
+ @on_execute.call(task_class, wrapper)
89
+ rescue => e
90
+ # Log error but don't crash the worker thread.
91
+ # Task-level errors are handled in the execute callback.
92
+ # This catches unexpected errors in the callback itself.
93
+ warn "[WorkerPool] Unexpected error executing #{task_class}: #{e.message}"
94
+ end
95
+ end
96
+ end
97
+
98
+ def debug_log(message)
99
+ return unless ENV["TASKI_DEBUG"]
100
+ puts "[WorkerPool] #{message}"
101
+ end
102
+ end
103
+ end
104
+ end
data/lib/taski/section.rb CHANGED
@@ -24,6 +24,9 @@ module Taski
24
24
  raise "Section #{self.class} does not have an implementation. Override 'impl' method."
25
25
  end
26
26
 
27
+ # Register runtime dependency for clean phase (before register_impl_selection)
28
+ register_runtime_dependency(implementation_class)
29
+
27
30
  # Register selected impl for progress display
28
31
  register_impl_selection(implementation_class)
29
32
 
@@ -43,11 +46,21 @@ module Taski
43
46
 
44
47
  private
45
48
 
49
+ # Register the selected implementation as a runtime dependency.
50
+ # This allows the clean phase to include the dynamically selected impl.
51
+ # Handles nil ExecutionContext gracefully.
52
+ #
53
+ # @param impl_class [Class] The selected implementation class
54
+ def register_runtime_dependency(impl_class)
55
+ context = Execution::ExecutionContext.current
56
+ context&.register_runtime_dependency(self.class, impl_class)
57
+ end
58
+
46
59
  def register_impl_selection(implementation_class)
47
- progress = Taski.progress_display
48
- return unless progress.is_a?(Execution::TreeProgressDisplay)
60
+ context = Execution::ExecutionContext.current
61
+ return unless context
49
62
 
50
- progress.register_section_impl(self.class, implementation_class)
63
+ context.notify_section_impl_selected(self.class, implementation_class)
51
64
  end
52
65
 
53
66
  # @param implementation_class [Class] The implementation task class
data/lib/taski/task.rb CHANGED
@@ -5,8 +5,34 @@ require_relative "execution/registry"
5
5
  require_relative "execution/task_wrapper"
6
6
 
7
7
  module Taski
8
+ # Base class for all tasks in the Taski framework.
9
+ # Tasks define units of work with dependencies and exported values.
10
+ #
11
+ # @example Defining a simple task
12
+ # class MyTask < Taski::Task
13
+ # exports :result
14
+ #
15
+ # def run
16
+ # @result = "completed"
17
+ # end
18
+ # end
8
19
  class Task
9
20
  class << self
21
+ ##
22
+ # Callback invoked when a subclass is created.
23
+ # Automatically creates a task-specific Error class for each subclass.
24
+ # @param subclass [Class] The newly created subclass.
25
+ def inherited(subclass)
26
+ super
27
+ # Create TaskClass::Error that inherits from Taski::TaskError
28
+ error_class = Class.new(Taski::TaskError)
29
+ subclass.const_set(:Error, error_class)
30
+ end
31
+
32
+ ##
33
+ # Declares exported methods that will be accessible after task execution.
34
+ # Creates instance reader and class accessor methods for each export.
35
+ # @param export_methods [Array<Symbol>] The method names to export.
10
36
  def exports(*export_methods)
11
37
  @exported_methods = export_methods
12
38
 
@@ -16,75 +42,128 @@ module Taski
16
42
  end
17
43
  end
18
44
 
45
+ ##
46
+ # Returns the list of exported method names.
47
+ # @return [Array<Symbol>] The exported method names.
19
48
  def exported_methods
20
49
  @exported_methods ||= []
21
50
  end
22
51
 
23
- # Each call creates a fresh TaskWrapper instance for re-execution support.
52
+ ##
53
+ # Creates a fresh TaskWrapper instance for re-execution support.
24
54
  # Use class methods (e.g., MyTask.result) for cached single execution.
55
+ # @return [Execution::TaskWrapper] A new wrapper for this task.
25
56
  def new
26
- fresh_registry = Execution::Registry.new
27
- task_instance = allocate
28
- task_instance.send(:initialize)
29
- wrapper = Execution::TaskWrapper.new(
30
- task_instance,
31
- registry: fresh_registry
32
- )
33
- # Pre-register to prevent Executor from creating a duplicate wrapper
34
- fresh_registry.register(self, wrapper)
35
- wrapper
57
+ fresh_wrapper
36
58
  end
37
59
 
60
+ ##
61
+ # Returns cached static dependencies for this task class.
62
+ # Dependencies are analyzed from the run method body using static analysis.
63
+ # @return [Set<Class>] The set of task classes this task depends on.
38
64
  def cached_dependencies
39
65
  @dependencies_cache ||= StaticAnalysis::Analyzer.analyze(self)
40
66
  end
41
67
 
68
+ ##
69
+ # Clears the cached dependency analysis.
70
+ # Useful when task code has changed and dependencies need to be re-analyzed.
42
71
  def clear_dependency_cache
43
72
  @dependencies_cache = nil
44
73
  end
45
74
 
46
- def run(context: {})
47
- Taski.start_context(options: context, root_task: self)
75
+ ##
76
+ # Executes the task and all its dependencies.
77
+ # Creates a fresh registry each time for independent execution.
78
+ # @param args [Hash] User-defined arguments accessible via Taski.args.
79
+ # @param workers [Integer, nil] Number of worker threads for parallel execution.
80
+ # Must be a positive integer or nil.
81
+ # Use workers: 1 for sequential execution (useful for debugging).
82
+ # @raise [ArgumentError] If workers is not a positive integer or nil.
83
+ # @return [Object] The result of task execution.
84
+ def run(args: {}, workers: nil)
85
+ validate_workers!(workers)
86
+ Taski.start_args(options: args.merge(_workers: workers), root_task: self)
48
87
  validate_no_circular_dependencies!
49
- cached_wrapper.run
88
+ fresh_wrapper.run
89
+ ensure
90
+ Taski.reset_args!
50
91
  end
51
92
 
52
- def clean(context: {})
53
- Taski.start_context(options: context, root_task: self)
93
+ ##
94
+ # Executes the clean phase for the task and all its dependencies.
95
+ # Clean is executed in reverse dependency order.
96
+ # Creates a fresh registry each time for independent execution.
97
+ # @param args [Hash] User-defined arguments accessible via Taski.args.
98
+ # @param workers [Integer, nil] Number of worker threads for parallel execution.
99
+ # Must be a positive integer or nil.
100
+ # @raise [ArgumentError] If workers is not a positive integer or nil.
101
+ def clean(args: {}, workers: nil)
102
+ validate_workers!(workers)
103
+ Taski.start_args(options: args.merge(_workers: workers), root_task: self)
54
104
  validate_no_circular_dependencies!
55
- cached_wrapper.clean
105
+ fresh_wrapper.clean
106
+ ensure
107
+ Taski.reset_args!
56
108
  end
57
109
 
58
- def registry
59
- Taski.global_registry
110
+ ##
111
+ # Execute run followed by clean in a single operation.
112
+ # If run fails, clean is still executed for resource release.
113
+ # Creates a fresh registry for both operations to share.
114
+ #
115
+ # @param args [Hash] User-defined arguments accessible via Taski.args.
116
+ # @param workers [Integer, nil] Number of worker threads for parallel execution.
117
+ # Must be a positive integer or nil.
118
+ # @raise [ArgumentError] If workers is not a positive integer or nil.
119
+ # @return [Object] The result of task execution
120
+ def run_and_clean(args: {}, workers: nil)
121
+ validate_workers!(workers)
122
+ Taski.start_args(options: args.merge(_workers: workers), root_task: self)
123
+ validate_no_circular_dependencies!
124
+ fresh_wrapper.run_and_clean
125
+ ensure
126
+ Taski.reset_args!
60
127
  end
61
128
 
129
+ ##
130
+ # Resets the task state and progress display.
131
+ # Useful for testing or re-running tasks from scratch.
62
132
  def reset!
63
- registry.reset!
64
- Taski.reset_global_registry!
65
- Taski.reset_context!
133
+ Taski.reset_args!
134
+ Taski.reset_progress_display!
66
135
  @circular_dependency_checked = false
67
136
  end
68
137
 
138
+ ##
139
+ # Renders a static tree representation of the task dependencies.
140
+ # @return [String] The rendered tree string.
69
141
  def tree
70
142
  Execution::TreeProgressDisplay.render_static_tree(self)
71
143
  end
72
144
 
73
145
  private
74
146
 
75
- # Use allocate + initialize instead of new to avoid infinite loop
76
- # since new is overridden to return TaskWrapper
77
- def cached_wrapper
78
- registry.get_or_create(self) do
79
- task_instance = allocate
80
- task_instance.send(:initialize)
81
- Execution::TaskWrapper.new(
82
- task_instance,
83
- registry: registry
84
- )
85
- end
147
+ ##
148
+ # Creates a fresh TaskWrapper with its own registry.
149
+ # Used for class method execution (Task.run) where each call is independent.
150
+ # @return [Execution::TaskWrapper] A new wrapper with fresh registry.
151
+ def fresh_wrapper
152
+ fresh_registry = Execution::Registry.new
153
+ task_instance = allocate
154
+ task_instance.__send__(:initialize)
155
+ wrapper = Execution::TaskWrapper.new(
156
+ task_instance,
157
+ registry: fresh_registry,
158
+ execution_context: Execution::ExecutionContext.current
159
+ )
160
+ fresh_registry.register(self, wrapper)
161
+ wrapper
86
162
  end
87
163
 
164
+ ##
165
+ # Defines an instance reader method for an exported value.
166
+ # @param method [Symbol] The method name to define.
88
167
  def define_instance_reader(method)
89
168
  undef_method(method) if method_defined?(method)
90
169
 
@@ -94,16 +173,48 @@ module Taski
94
173
  end
95
174
  end
96
175
 
176
+ ##
177
+ # Defines a class accessor method for an exported value.
178
+ # When called inside an execution, returns cached value from registry.
179
+ # When called outside execution, creates fresh execution.
180
+ # @param method [Symbol] The method name to define.
97
181
  def define_class_accessor(method)
98
182
  singleton_class.undef_method(method) if singleton_class.method_defined?(method)
99
183
 
100
184
  define_singleton_method(method) do
101
- Taski.start_context(options: {}, root_task: self)
102
- validate_no_circular_dependencies!
103
- cached_wrapper.get_exported_value(method)
185
+ # Check if running inside an execution with a registry
186
+ registry = Taski.current_registry
187
+ if registry
188
+ # Inside execution - get or create wrapper in current registry
189
+ # This handles both pre-registered dependencies and dynamic ones (like Section impl)
190
+ wrapper = registry.get_or_create(self) do
191
+ task_instance = allocate
192
+ task_instance.__send__(:initialize)
193
+ Execution::TaskWrapper.new(
194
+ task_instance,
195
+ registry: registry,
196
+ execution_context: Execution::ExecutionContext.current
197
+ )
198
+ end
199
+ wrapper.get_exported_value(method)
200
+ else
201
+ # Outside execution - fresh execution (top-level call)
202
+ args_was_nil = Taski.args.nil?
203
+ begin
204
+ Taski.start_args(options: {}, root_task: self) if args_was_nil
205
+ validate_no_circular_dependencies!
206
+ fresh_wrapper.get_exported_value(method)
207
+ ensure
208
+ # Only reset if we set args
209
+ Taski.reset_args! if args_was_nil
210
+ end
211
+ end
104
212
  end
105
213
  end
106
214
 
215
+ ##
216
+ # Validates that no circular dependencies exist in the task graph.
217
+ # @raise [Taski::CircularDependencyError] If circular dependencies are detected.
107
218
  def validate_no_circular_dependencies!
108
219
  return if @circular_dependency_checked
109
220
 
@@ -116,15 +227,90 @@ module Taski
116
227
 
117
228
  @circular_dependency_checked = true
118
229
  end
230
+
231
+ ##
232
+ # Validates the workers parameter.
233
+ # @param workers [Object] The workers parameter to validate.
234
+ # @raise [ArgumentError] If workers is not a positive integer or nil.
235
+ def validate_workers!(workers)
236
+ return if workers.nil?
237
+
238
+ unless workers.is_a?(Integer) && workers >= 1
239
+ raise ArgumentError, "workers must be a positive integer or nil, got: #{workers.inspect}"
240
+ end
241
+ end
119
242
  end
120
243
 
244
+ ##
245
+ # Executes the task's main logic.
246
+ # Subclasses must override this method to implement task behavior.
247
+ # @raise [NotImplementedError] If not overridden in a subclass.
121
248
  def run
122
249
  raise NotImplementedError, "Subclasses must implement the run method"
123
250
  end
124
251
 
252
+ ##
253
+ # Cleans up resources after task execution.
254
+ # Override in subclasses to implement cleanup logic.
125
255
  def clean
126
256
  end
127
257
 
258
+ # Override system() to capture subprocess output through the pipe-based architecture.
259
+ # Uses Kernel.system with :out option to redirect output to the task's pipe.
260
+ # If user provides :out or :err options, they are respected (no automatic redirection).
261
+ # @param args [Array] Command arguments (shell mode if single string, exec mode if array)
262
+ # @param opts [Hash] Options passed to Kernel.system
263
+ # @return [Boolean, nil] true if command succeeded, false if failed, nil if command not found
264
+ def system(*args, **opts)
265
+ write_io = $stdout.respond_to?(:current_write_io) ? $stdout.current_write_io : nil
266
+
267
+ if write_io && !opts.key?(:out)
268
+ # Redirect subprocess output to the task's pipe (stderr merged into stdout)
269
+ Kernel.system(*args, out: write_io, err: [:child, :out], **opts)
270
+ else
271
+ # No capture active or user provided custom :out, use normal system
272
+ Kernel.system(*args, **opts)
273
+ end
274
+ end
275
+
276
+ # Groups related output within a task for organized progress display.
277
+ # The group name is shown in the progress tree as a child of the task.
278
+ # Groups cannot be nested.
279
+ #
280
+ # @param name [String] The group name to display
281
+ # @yield The block to execute within the group
282
+ # @return [Object] The result of the block
283
+ # @raise Re-raises any exception from the block after marking group as failed
284
+ #
285
+ # @example
286
+ # def run
287
+ # group("Preparing") do
288
+ # puts "Checking dependencies..."
289
+ # puts "Validating config..."
290
+ # end
291
+ # group("Deploying") do
292
+ # puts "Uploading files..."
293
+ # end
294
+ # end
295
+ def group(name)
296
+ context = Execution::ExecutionContext.current
297
+ context&.notify_group_started(self.class, name)
298
+ start_time = Time.now
299
+
300
+ begin
301
+ result = yield
302
+ duration_ms = ((Time.now - start_time) * 1000).round(0)
303
+ context&.notify_group_completed(self.class, name, duration: duration_ms)
304
+ result
305
+ rescue => e
306
+ duration_ms = ((Time.now - start_time) * 1000).round(0)
307
+ context&.notify_group_completed(self.class, name, duration: duration_ms, error: e)
308
+ raise
309
+ end
310
+ end
311
+
312
+ ##
313
+ # Resets the instance's exported values to nil.
128
314
  def reset!
129
315
  self.class.exported_methods.each do |method|
130
316
  instance_variable_set("@#{method}", nil)
data/lib/taski/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Taski
4
- VERSION = "0.5.0"
4
+ VERSION = "0.7.0"
5
5
  end
data/lib/taski.rb CHANGED
@@ -5,12 +5,13 @@ require_relative "taski/static_analysis/analyzer"
5
5
  require_relative "taski/static_analysis/visitor"
6
6
  require_relative "taski/static_analysis/dependency_graph"
7
7
  require_relative "taski/execution/registry"
8
+ require_relative "taski/execution/execution_context"
8
9
  require_relative "taski/execution/task_wrapper"
10
+ require_relative "taski/execution/scheduler"
11
+ require_relative "taski/execution/worker_pool"
9
12
  require_relative "taski/execution/executor"
10
13
  require_relative "taski/execution/tree_progress_display"
11
- require_relative "taski/context"
12
- require_relative "taski/task"
13
- require_relative "taski/section"
14
+ require_relative "taski/args"
14
15
 
15
16
  module Taski
16
17
  class TaskAbortException < StandardError
@@ -28,41 +29,125 @@ module Taski
28
29
  end
29
30
  end
30
31
 
31
- @context_monitor = Monitor.new
32
+ # Represents a single task failure with its context
33
+ class TaskFailure
34
+ attr_reader :task_class, :error
32
35
 
33
- # Get the current execution context
34
- # @return [Context, nil] The current context or nil if no task is running
35
- def self.context
36
- @context_monitor.synchronize { @context }
36
+ # @param task_class [Class] The task class that failed
37
+ # @param error [Exception] The exception that was raised
38
+ def initialize(task_class:, error:)
39
+ @task_class = task_class
40
+ @error = error
41
+ end
37
42
  end
38
43
 
39
- # Start a new execution context (internal use only)
40
- # @api private
41
- def self.start_context(options:, root_task:)
42
- @context_monitor.synchronize do
43
- return if @context
44
- @context = Context.new(options: options, root_task: root_task)
44
+ # Mixin for exception classes to enable transparent rescue matching with AggregateError.
45
+ # When extended by an exception class, `rescue ThatError` will also match
46
+ # an AggregateError that contains ThatError.
47
+ #
48
+ # @note TaskError and all TaskClass::Error classes already extend this module.
49
+ #
50
+ # @example
51
+ # begin
52
+ # MyTask.value # raises AggregateError containing MyTask::Error
53
+ # rescue MyTask::Error => e
54
+ # puts "MyTask failed: #{e.message}"
55
+ # end
56
+ module AggregateAware
57
+ def ===(other)
58
+ return super unless other.is_a?(Taski::AggregateError)
59
+
60
+ other.includes?(self)
45
61
  end
46
62
  end
47
63
 
48
- # Reset the execution context (internal use only)
49
- # @api private
50
- def self.reset_context!
51
- @context_monitor.synchronize { @context = nil }
64
+ # Base class for task-specific error wrappers.
65
+ # Each Task subclass automatically gets a ::Error class that inherits from this.
66
+ # This allows rescuing errors by task class: rescue MyTask::Error => e
67
+ #
68
+ # @example Rescuing task-specific errors
69
+ # begin
70
+ # MyTask.value
71
+ # rescue MyTask::Error => e
72
+ # puts "MyTask failed: #{e.message}"
73
+ # end
74
+ class TaskError < StandardError
75
+ extend AggregateAware
76
+
77
+ # @return [Exception] The original error that occurred in the task
78
+ attr_reader :cause
79
+
80
+ # @return [Class] The task class where the error occurred
81
+ attr_reader :task_class
82
+
83
+ # @param cause [Exception] The original error
84
+ # @param task_class [Class] The task class where the error occurred
85
+ def initialize(cause, task_class:)
86
+ @cause = cause
87
+ @task_class = task_class
88
+ super(cause.message)
89
+ set_backtrace(cause.backtrace)
90
+ end
91
+ end
92
+
93
+ # Raised when multiple tasks fail during parallel execution
94
+ class AggregateError < StandardError
95
+ attr_reader :errors
96
+
97
+ # @param errors [Array<TaskFailure>] List of task failures
98
+ def initialize(errors)
99
+ @errors = errors
100
+ super(build_message)
101
+ end
102
+
103
+ # Returns the first error for compatibility with exception chaining
104
+ # @return [Exception, nil] The first error or nil if no errors
105
+ def cause
106
+ errors.first&.error
107
+ end
108
+
109
+ # Check if this aggregate contains an error of the given type
110
+ # @param exception_class [Class] The exception class to check for
111
+ # @return [Boolean] true if any contained error is of the given type
112
+ def includes?(exception_class)
113
+ errors.any? { |f| f.error.is_a?(exception_class) }
114
+ end
115
+
116
+ private
117
+
118
+ def build_message
119
+ task_word = (errors.size == 1) ? "task" : "tasks"
120
+ "#{errors.size} #{task_word} failed:\n" +
121
+ errors.map { |f| " - #{f.task_class.name}: #{f.error.message}" }.join("\n")
122
+ end
52
123
  end
53
124
 
54
- def self.global_registry
55
- @global_registry ||= Execution::Registry.new
125
+ @args_monitor = Monitor.new
126
+
127
+ # Get the current runtime arguments
128
+ # @return [Args, nil] The current args or nil if no task is running
129
+ def self.args
130
+ @args_monitor.synchronize { @args }
131
+ end
132
+
133
+ # Start new runtime arguments (internal use only)
134
+ # @api private
135
+ def self.start_args(options:, root_task:)
136
+ @args_monitor.synchronize do
137
+ return if @args
138
+ @args = Args.new(options: options, root_task: root_task)
139
+ end
56
140
  end
57
141
 
58
- def self.reset_global_registry!
59
- @global_registry = nil
142
+ # Reset the runtime arguments (internal use only)
143
+ # @api private
144
+ def self.reset_args!
145
+ @args_monitor.synchronize { @args = nil }
60
146
  end
61
147
 
62
148
  # Progress display is enabled by default (tree-style).
63
149
  # Environment variables:
64
150
  # - TASKI_PROGRESS_DISABLE=1: Disable progress display entirely
65
- # - TASKI_FORCE_PROGRESS=1: Force enable even without TTY (for testing)
66
151
  def self.progress_display
67
152
  return nil if progress_disabled?
68
153
  @progress_display ||= Execution::TreeProgressDisplay.new
@@ -76,4 +161,34 @@ module Taski
76
161
  @progress_display&.stop
77
162
  @progress_display = nil
78
163
  end
164
+
165
+ # Get the worker count from the current args (set via Task.run(workers: n))
166
+ # @return [Integer, nil] The worker count or nil to use WorkerPool default
167
+ # @api private
168
+ def self.args_worker_count
169
+ args&.fetch(:_workers, nil)
170
+ end
171
+
172
+ # Get the current registry for this thread (used during dependency resolution)
173
+ # @return [Execution::Registry, nil] The current registry or nil
174
+ # @api private
175
+ def self.current_registry
176
+ Thread.current[:taski_current_registry]
177
+ end
178
+
179
+ # Set the current registry for this thread (internal use only)
180
+ # @api private
181
+ def self.set_current_registry(registry)
182
+ Thread.current[:taski_current_registry] = registry
183
+ end
184
+
185
+ # Clear the current registry for this thread (internal use only)
186
+ # @api private
187
+ def self.clear_current_registry
188
+ Thread.current[:taski_current_registry] = nil
189
+ end
79
190
  end
191
+
192
+ # Load Task and Section after Taski module is defined (they depend on TaskError)
193
+ require_relative "taski/task"
194
+ require_relative "taski/section"