taski 0.5.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +50 -0
  3. data/README.md +168 -21
  4. data/docs/GUIDE.md +394 -0
  5. data/examples/README.md +65 -17
  6. data/examples/{context_demo.rb → args_demo.rb} +27 -27
  7. data/examples/clean_demo.rb +204 -0
  8. data/examples/data_pipeline_demo.rb +1 -1
  9. data/examples/group_demo.rb +113 -0
  10. data/examples/large_tree_demo.rb +519 -0
  11. data/examples/reexecution_demo.rb +93 -80
  12. data/examples/simple_progress_demo.rb +80 -0
  13. data/examples/system_call_demo.rb +56 -0
  14. data/lib/taski/{context.rb → args.rb} +3 -3
  15. data/lib/taski/execution/base_progress_display.rb +348 -0
  16. data/lib/taski/execution/execution_context.rb +383 -0
  17. data/lib/taski/execution/executor.rb +405 -134
  18. data/lib/taski/execution/plain_progress_display.rb +76 -0
  19. data/lib/taski/execution/registry.rb +17 -1
  20. data/lib/taski/execution/scheduler.rb +308 -0
  21. data/lib/taski/execution/simple_progress_display.rb +173 -0
  22. data/lib/taski/execution/task_output_pipe.rb +42 -0
  23. data/lib/taski/execution/task_output_router.rb +287 -0
  24. data/lib/taski/execution/task_wrapper.rb +215 -52
  25. data/lib/taski/execution/tree_progress_display.rb +349 -212
  26. data/lib/taski/execution/worker_pool.rb +104 -0
  27. data/lib/taski/section.rb +16 -3
  28. data/lib/taski/static_analysis/visitor.rb +3 -0
  29. data/lib/taski/task.rb +218 -37
  30. data/lib/taski/test_helper/errors.rb +13 -0
  31. data/lib/taski/test_helper/minitest.rb +38 -0
  32. data/lib/taski/test_helper/mock_registry.rb +51 -0
  33. data/lib/taski/test_helper/mock_wrapper.rb +46 -0
  34. data/lib/taski/test_helper/rspec.rb +38 -0
  35. data/lib/taski/test_helper.rb +214 -0
  36. data/lib/taski/version.rb +1 -1
  37. data/lib/taski.rb +211 -23
  38. data/sig/taski.rbs +207 -27
  39. metadata +25 -8
  40. data/docs/advanced-features.md +0 -625
  41. data/docs/api-guide.md +0 -509
  42. data/docs/error-handling.md +0 -684
  43. data/examples/section_progress_demo.rb +0 -78
@@ -0,0 +1,287 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+ require_relative "task_output_pipe"
5
+
6
+ module Taski
7
+ module Execution
8
+ # Central coordinator that manages all task pipes and polling.
9
+ # Also acts as an IO proxy for $stdout, routing writes to the appropriate pipe
10
+ # based on the current thread.
11
+ #
12
+ # Architecture:
13
+ # - Each task gets a dedicated IO pipe for output capture
14
+ # - Writes are routed to the appropriate pipe based on Thread.current
15
+ # - A reader thread polls all pipes using IO.select for efficiency
16
+ # - When no pipe is registered for a thread, output goes to original stdout
17
+ class TaskOutputRouter
18
+ include MonitorMixin
19
+
20
+ POLL_TIMEOUT = 0.05 # 50ms timeout for IO.select
21
+ POLL_INTERVAL = 0.1 # 100ms between polls (matches TreeProgressDisplay)
22
+ READ_BUFFER_SIZE = 4096
23
+ MAX_RECENT_LINES = 30 # Maximum number of recent lines to keep per task
24
+
25
+ def initialize(original_stdout)
26
+ super()
27
+ @original = original_stdout
28
+ @pipes = {} # task_class => TaskOutputPipe
29
+ @thread_map = {} # Thread => task_class
30
+ @recent_lines = {} # task_class => Array<String>
31
+ @poll_thread = nil
32
+ @polling = false
33
+ end
34
+
35
+ # Start the background polling thread
36
+ # This ensures pipes are drained even when display doesn't poll
37
+ def start_polling
38
+ synchronize do
39
+ return if @polling
40
+ @polling = true
41
+ end
42
+
43
+ @poll_thread = Thread.new do
44
+ loop do
45
+ break unless @polling
46
+ poll
47
+ sleep POLL_INTERVAL
48
+ end
49
+ end
50
+ end
51
+
52
+ # Stop the background polling thread
53
+ def stop_polling
54
+ synchronize { @polling = false }
55
+ @poll_thread&.join(0.5)
56
+ @poll_thread = nil
57
+ end
58
+
59
+ # Start capturing output for the current thread
60
+ # Creates a new pipe for the task and registers the thread mapping
61
+ # @param task_class [Class] The task class being executed
62
+ def start_capture(task_class)
63
+ synchronize do
64
+ pipe = TaskOutputPipe.new(task_class)
65
+ @pipes[task_class] = pipe
66
+ @thread_map[Thread.current] = task_class
67
+ debug_log("Started capture for #{task_class} on thread #{Thread.current.object_id}")
68
+ end
69
+ end
70
+
71
+ # Stop capturing output for the current thread
72
+ # Closes the write end of the pipe and drains remaining data
73
+ def stop_capture
74
+ task_class = nil
75
+ pipe = nil
76
+
77
+ synchronize do
78
+ task_class = @thread_map.delete(Thread.current)
79
+ unless task_class
80
+ debug_log("Warning: stop_capture called for unregistered thread #{Thread.current.object_id}")
81
+ return
82
+ end
83
+
84
+ pipe = @pipes[task_class]
85
+ pipe&.close_write
86
+ debug_log("Stopped capture for #{task_class} on thread #{Thread.current.object_id}")
87
+ end
88
+
89
+ # Drain any remaining data from the pipe after closing write end
90
+ drain_pipe(pipe) if pipe
91
+ end
92
+
93
+ # Drain all remaining data from a pipe
94
+ # Called after close_write to ensure all output is captured
95
+ def drain_pipe(pipe)
96
+ return if pipe.read_closed?
97
+
98
+ loop do
99
+ data = pipe.read_io.read_nonblock(READ_BUFFER_SIZE)
100
+ debug_log("drain_pipe read #{data.bytesize} bytes for #{pipe.task_class}")
101
+ store_output_lines(pipe.task_class, data)
102
+ rescue IO::WaitReadable
103
+ # Check if there's more data with a very short timeout
104
+ ready, = IO.select([pipe.read_io], nil, nil, 0.001)
105
+ break unless ready
106
+ rescue IOError
107
+ # All data has been read (EOFError) or pipe was closed by another thread
108
+ synchronize { pipe.close_read }
109
+ break
110
+ end
111
+ end
112
+
113
+ # Poll all open pipes for available data
114
+ # Should be called periodically from the display thread
115
+ def poll
116
+ readable_pipes = synchronize do
117
+ @pipes.values.reject { |p| p.read_closed? }.map(&:read_io)
118
+ end
119
+ return if readable_pipes.empty?
120
+
121
+ # Handle race condition: pipe may be closed between check and select
122
+ ready, = IO.select(readable_pipes, nil, nil, POLL_TIMEOUT)
123
+ return unless ready
124
+
125
+ ready.each do |read_io|
126
+ pipe = synchronize { @pipes.values.find { |p| p.read_io == read_io } }
127
+ next unless pipe
128
+
129
+ read_from_pipe(pipe)
130
+ end
131
+ rescue IOError
132
+ # Pipe was closed by another thread (drain_pipe), ignore
133
+ end
134
+
135
+ # Get the last output line for a task
136
+ # @param task_class [Class] The task class
137
+ # @return [String, nil] The last output line
138
+ def last_line_for(task_class)
139
+ synchronize { @recent_lines[task_class]&.last }
140
+ end
141
+
142
+ # Get recent output lines for a task (up to MAX_RECENT_LINES)
143
+ # @param task_class [Class] The task class
144
+ # @return [Array<String>] Recent output lines
145
+ def recent_lines_for(task_class)
146
+ synchronize { (@recent_lines[task_class] || []).dup }
147
+ end
148
+
149
+ # Close all pipes and clean up
150
+ def close_all
151
+ synchronize do
152
+ @pipes.each_value(&:close)
153
+ @pipes.clear
154
+ @thread_map.clear
155
+ end
156
+ end
157
+
158
+ # Check if there are any active (not fully closed) pipes
159
+ # @return [Boolean] true if there are active pipes
160
+ def active?
161
+ synchronize do
162
+ @pipes.values.any? { |p| !p.read_closed? }
163
+ end
164
+ end
165
+
166
+ # IO interface methods - route to pipe when capturing, otherwise pass through
167
+
168
+ def write(str)
169
+ pipe = current_thread_pipe
170
+ if pipe && !pipe.write_closed?
171
+ begin
172
+ pipe.write_io.write(str)
173
+ rescue IOError
174
+ # Pipe was closed by another thread (e.g., stop_capture), fall back to original
175
+ @original.write(str)
176
+ end
177
+ else
178
+ @original.write(str)
179
+ end
180
+ end
181
+
182
+ def puts(*args)
183
+ if args.empty?
184
+ write("\n")
185
+ else
186
+ args.each do |arg|
187
+ str = arg.to_s
188
+ write(str)
189
+ write("\n") unless str.end_with?("\n")
190
+ end
191
+ end
192
+ nil
193
+ end
194
+
195
+ def print(*args)
196
+ args.each { |arg| write(arg.to_s) }
197
+ nil
198
+ end
199
+
200
+ def <<(str)
201
+ write(str.to_s)
202
+ self
203
+ end
204
+
205
+ def flush
206
+ @original.flush
207
+ end
208
+
209
+ def tty?
210
+ @original.tty?
211
+ end
212
+
213
+ def isatty
214
+ @original.isatty
215
+ end
216
+
217
+ def winsize
218
+ @original.winsize
219
+ end
220
+
221
+ # Get the write IO for the current thread's pipe
222
+ # Used by Task#system to redirect subprocess output directly to the pipe
223
+ # @return [IO, nil] The write IO or nil if not capturing
224
+ def current_write_io
225
+ synchronize do
226
+ task_class = @thread_map[Thread.current]
227
+ return nil unless task_class
228
+ pipe = @pipes[task_class]
229
+ return nil if pipe.nil? || pipe.write_closed?
230
+ pipe.write_io
231
+ end
232
+ end
233
+
234
+ # Delegate unknown methods to original stdout
235
+ def method_missing(method, ...)
236
+ @original.send(method, ...)
237
+ end
238
+
239
+ def respond_to_missing?(method, include_private = false)
240
+ @original.respond_to?(method, include_private)
241
+ end
242
+
243
+ private
244
+
245
+ def current_thread_pipe
246
+ synchronize do
247
+ task_class = @thread_map[Thread.current]
248
+ return nil unless task_class
249
+ @pipes[task_class]
250
+ end
251
+ end
252
+
253
+ def read_from_pipe(pipe)
254
+ data = pipe.read_io.read_nonblock(READ_BUFFER_SIZE)
255
+ store_output_lines(pipe.task_class, data)
256
+ rescue IO::WaitReadable
257
+ # No data available yet
258
+ rescue IOError
259
+ # Pipe closed by writer (EOFError) or by another thread, close read end
260
+ synchronize { pipe.close_read }
261
+ end
262
+
263
+ def store_output_lines(task_class, data)
264
+ return if data.nil? || data.empty?
265
+
266
+ lines = data.lines
267
+ synchronize do
268
+ @recent_lines[task_class] ||= []
269
+ lines.each do |line|
270
+ stripped = line.chomp
271
+ @recent_lines[task_class] << stripped unless stripped.strip.empty?
272
+ end
273
+ # Keep only the last MAX_RECENT_LINES
274
+ if @recent_lines[task_class].size > MAX_RECENT_LINES
275
+ @recent_lines[task_class] = @recent_lines[task_class].last(MAX_RECENT_LINES)
276
+ end
277
+ debug_log("store_output_lines: #{task_class} now has #{@recent_lines[task_class].size} lines")
278
+ end
279
+ end
280
+
281
+ def debug_log(message)
282
+ return unless ENV["TASKI_DEBUG"]
283
+ warn "[TaskOutputRouter] #{message}"
284
+ end
285
+ end
286
+ end
287
+ end
@@ -26,24 +26,33 @@ module Taski
26
26
  # In the Producer-Consumer pattern, TaskWrapper does NOT start threads.
27
27
  # The Executor controls all scheduling and execution.
28
28
  class TaskWrapper
29
- attr_reader :task, :result, :error, :timing
29
+ attr_reader :task, :result, :error, :timing, :clean_error
30
30
 
31
31
  STATE_PENDING = :pending
32
32
  STATE_RUNNING = :running
33
33
  STATE_COMPLETED = :completed
34
34
 
35
- def initialize(task, registry:)
35
+ ##
36
+ # Create a new TaskWrapper for the given task and registry.
37
+ # Initializes synchronization primitives, state tracking for execution and cleanup, and timing/result/error holders.
38
+ # @param [Object] task - The task instance being wrapped.
39
+ # @param [Object] registry - The registry used to query abort status and coordinate execution.
40
+ # @param [Object, nil] execution_context - Optional execution context used to trigger and report execution and cleanup.
41
+ def initialize(task, registry:, execution_context: nil)
36
42
  @task = task
37
43
  @registry = registry
44
+ @execution_context = execution_context
38
45
  @result = nil
39
46
  @clean_result = nil
40
47
  @error = nil
48
+ @clean_error = nil
41
49
  @monitor = Monitor.new
42
50
  @condition = @monitor.new_cond
43
51
  @clean_condition = @monitor.new_cond
44
52
  @state = STATE_PENDING
45
53
  @clean_state = STATE_PENDING
46
54
  @timing = nil
55
+ @clean_timing = nil
47
56
  end
48
57
 
49
58
  # @return [Symbol] Current state
@@ -61,28 +70,67 @@ module Taski
61
70
  state == STATE_COMPLETED
62
71
  end
63
72
 
73
+ # Resets the wrapper state to allow re-execution.
74
+ # Clears all cached results and returns state to pending.
75
+ def reset!
76
+ @monitor.synchronize do
77
+ @state = STATE_PENDING
78
+ @clean_state = STATE_PENDING
79
+ @result = nil
80
+ @clean_result = nil
81
+ @error = nil
82
+ @clean_error = nil
83
+ @timing = nil
84
+ @clean_timing = nil
85
+ end
86
+ @task.reset! if @task.respond_to?(:reset!)
87
+ @registry.reset!
88
+ end
89
+
64
90
  # Called by user code to get result. Triggers execution if needed.
91
+ # Sets up args if not already set (for Task.new.run usage).
65
92
  # @return [Object] The result of task execution
66
93
  def run
67
- trigger_execution_and_wait
68
- raise @error if @error # steep:ignore
69
- @result
94
+ with_args_lifecycle do
95
+ trigger_execution_and_wait
96
+ raise @error if @error # steep:ignore
97
+ @result
98
+ end
70
99
  end
71
100
 
72
101
  # Called by user code to clean. Triggers clean execution if needed.
102
+ # Sets up args if not already set (for Task.new.clean usage).
73
103
  # @return [Object] The result of cleanup
74
104
  def clean
75
- trigger_clean_and_wait
76
- @clean_result
105
+ with_args_lifecycle do
106
+ trigger_clean_and_wait
107
+ @clean_result
108
+ end
109
+ end
110
+
111
+ # Called by user code to run and clean. Runs execution followed by cleanup.
112
+ # If run fails, clean is still executed for resource release.
113
+ # Pre-increments progress display nest_level to prevent double rendering.
114
+ # @return [Object] The result of task execution
115
+ def run_and_clean
116
+ context = ensure_execution_context
117
+ context.notify_start # Pre-increment nest_level to prevent double rendering
118
+ run
119
+ ensure
120
+ clean
121
+ context&.notify_stop # Final decrement and render
77
122
  end
78
123
 
79
124
  # Called by user code to get exported value. Triggers execution if needed.
125
+ # Sets up args if not already set (for Task.new usage).
80
126
  # @param method_name [Symbol] The name of the exported method
81
127
  # @return [Object] The exported value
82
128
  def get_exported_value(method_name)
83
- trigger_execution_and_wait
84
- raise @error if @error # steep:ignore
85
- @task.public_send(method_name)
129
+ with_args_lifecycle do
130
+ trigger_execution_and_wait
131
+ raise @error if @error # steep:ignore
132
+ @task.public_send(method_name)
133
+ end
86
134
  end
87
135
 
88
136
  # Called by Executor to mark task as running
@@ -108,7 +156,10 @@ module Taski
108
156
  end
109
157
 
110
158
  # Called by Executor when task.run raises an error
111
- # @param error [Exception] The error that occurred
159
+ ##
160
+ # Marks the task as failed and records the error.
161
+ # Records the provided error, sets the task state to completed, updates the timing end time, notifies threads waiting for completion, and reports the failure to the execution context.
162
+ # @param [Exception] error - The exception raised during task execution.
112
163
  def mark_failed(error)
113
164
  @timing = @timing&.with_end_now
114
165
  @monitor.synchronize do
@@ -119,30 +170,75 @@ module Taski
119
170
  update_progress(:failed, error: error)
120
171
  end
121
172
 
173
+ # Called by Executor to mark clean as running
174
+ ##
175
+ # Mark the task's cleanup state as running and start timing.
176
+ # @return [Boolean] `true` if the clean state was changed from pending to running, `false` otherwise.
177
+ def mark_clean_running
178
+ @monitor.synchronize do
179
+ return false unless @clean_state == STATE_PENDING
180
+ @clean_state = STATE_RUNNING
181
+ @clean_timing = TaskTiming.start_now
182
+ true
183
+ end
184
+ end
185
+
122
186
  # Called by Executor after clean completes
123
- # @param result [Object] The result of cleanup
187
+ ##
188
+ # Marks the cleanup run as completed, stores the cleanup result, sets the clean state to COMPLETED,
189
+ # notifies any waiters, and reports completion to observers.
190
+ # @param [Object] result - The result of the cleanup operation.
124
191
  def mark_clean_completed(result)
192
+ @clean_timing = @clean_timing&.with_end_now
125
193
  @monitor.synchronize do
126
194
  @clean_result = result
127
195
  @clean_state = STATE_COMPLETED
128
196
  @clean_condition.broadcast
129
197
  end
198
+ update_clean_progress(:clean_completed, duration: @clean_timing&.duration_ms)
199
+ end
200
+
201
+ # Called by Executor when clean raises an error
202
+ ##
203
+ # Marks the cleanup as failed by storing the cleanup error, transitioning the cleanup state to completed,
204
+ # notifying any waiters, and reports failure to observers.
205
+ # @param [Exception] error - The exception raised during the cleanup run.
206
+ def mark_clean_failed(error)
207
+ @clean_timing = @clean_timing&.with_end_now
208
+ @monitor.synchronize do
209
+ @clean_error = error
210
+ @clean_state = STATE_COMPLETED
211
+ @clean_condition.broadcast
212
+ end
213
+ update_clean_progress(:clean_failed, duration: @clean_timing&.duration_ms, error: error)
130
214
  end
131
215
 
132
- # Wait until task is completed
216
+ ##
217
+ # Blocks the current thread until the task reaches the completed state.
218
+ #
219
+ # The caller will be suspended until the wrapper's state becomes STATE_COMPLETED.
220
+ # This method does not raise on its own; any errors from task execution are surfaced elsewhere.
133
221
  def wait_for_completion
134
222
  @monitor.synchronize do
135
223
  @condition.wait_until { @state == STATE_COMPLETED }
136
224
  end
137
225
  end
138
226
 
139
- # Wait until clean is completed
227
+ ##
228
+ # Blocks the current thread until the task's clean phase reaches the completed state.
229
+ # The caller will be suspended until the wrapper's clean_state becomes STATE_COMPLETED.
140
230
  def wait_for_clean_completion
141
231
  @monitor.synchronize do
142
232
  @clean_condition.wait_until { @clean_state == STATE_COMPLETED }
143
233
  end
144
234
  end
145
235
 
236
+ ##
237
+ # Delegates method calls to get_exported_value for exported task methods.
238
+ # @param method_name [Symbol] The method name being called.
239
+ # @param args [Array] Arguments passed to the method.
240
+ # @param block [Proc] Block passed to the method.
241
+ # @return [Object] The exported value for the method.
146
242
  def method_missing(method_name, *args, &block)
147
243
  if @task.class.method_defined?(method_name)
148
244
  get_exported_value(method_name)
@@ -151,22 +247,65 @@ module Taski
151
247
  end
152
248
  end
153
249
 
250
+ ##
251
+ # Returns true if the task class defines the given method.
252
+ # @param method_name [Symbol] The method name to check.
253
+ # @param include_private [Boolean] Whether to include private methods.
254
+ # @return [Boolean] true if the task responds to the method.
154
255
  def respond_to_missing?(method_name, include_private = false)
155
256
  @task.class.method_defined?(method_name) || super
156
257
  end
157
258
 
158
259
  private
159
260
 
160
- # Trigger execution via Executor and wait for completion
261
+ ##
262
+ # Ensures args are set during block execution, then resets if they weren't set before.
263
+ # This allows Task.new.run usage without requiring explicit args setup.
264
+ # @yield The block to execute with args lifecycle management
265
+ # @return [Object] The result of the block
266
+ def with_args_lifecycle(&block)
267
+ Taski.with_args(options: {}, root_task: @task.class, &block)
268
+ end
269
+
270
+ ##
271
+ # Ensures the task is executed if still pending and waits for completion.
272
+ # If the task is pending, triggers execution (via the configured ExecutionContext when present, otherwise via Executor) outside the monitor; if the task is running, waits until it becomes completed; if already completed, returns immediately.
273
+ # @raise [Taski::TaskAbortException] If the registry requested an abort before execution begins.
161
274
  def trigger_execution_and_wait
275
+ trigger_and_wait(
276
+ state_accessor: -> { @state },
277
+ condition: @condition,
278
+ trigger: ->(ctx) { ctx.trigger_execution(@task.class, registry: @registry) }
279
+ )
280
+ end
281
+
282
+ ##
283
+ # Triggers task cleanup through the configured execution mechanism and waits until the cleanup completes.
284
+ #
285
+ # If an ExecutionContext is configured the cleanup is invoked through it; otherwise a fallback executor is used.
286
+ # @raise [Taski::TaskAbortException] if the registry has requested an abort.
287
+ def trigger_clean_and_wait
288
+ trigger_and_wait(
289
+ state_accessor: -> { @clean_state },
290
+ condition: @clean_condition,
291
+ trigger: ->(ctx) { ctx.trigger_clean(@task.class, registry: @registry) }
292
+ )
293
+ end
294
+
295
+ # Generic trigger-and-wait implementation for both run and clean phases.
296
+ # @param state_accessor [Proc] Lambda returning the current state
297
+ # @param condition [MonitorMixin::ConditionVariable] Condition to wait on
298
+ # @param trigger [Proc] Lambda receiving context to trigger execution
299
+ # @raise [Taski::TaskAbortException] If the registry requested an abort
300
+ def trigger_and_wait(state_accessor:, condition:, trigger:)
162
301
  should_execute = false
163
302
  @monitor.synchronize do
164
- case @state
303
+ case state_accessor.call
165
304
  when STATE_PENDING
166
305
  check_abort!
167
306
  should_execute = true
168
307
  when STATE_RUNNING
169
- @condition.wait_until { @state == STATE_COMPLETED }
308
+ condition.wait_until { state_accessor.call == STATE_COMPLETED }
170
309
  when STATE_COMPLETED
171
310
  # Already done
172
311
  end
@@ -174,57 +313,81 @@ module Taski
174
313
 
175
314
  if should_execute
176
315
  # Execute outside the lock to avoid deadlock
177
- Executor.execute(@task.class, registry: @registry)
178
- # After Executor.execute returns, the task is completed
316
+ context = ensure_execution_context
317
+ trigger.call(context)
318
+ # After execution returns, the task is completed
179
319
  end
180
320
  end
181
321
 
182
- # Trigger clean execution and wait for completion
183
- def trigger_clean_and_wait
184
- @monitor.synchronize do
185
- case @clean_state
186
- when STATE_PENDING
187
- @clean_state = STATE_RUNNING
188
- # Execute clean in a thread (clean doesn't use Producer-Consumer)
189
- thread = Thread.new { execute_clean }
190
- @registry.register_thread(thread)
191
- @clean_condition.wait_until { @clean_state == STATE_COMPLETED }
192
- when STATE_RUNNING
193
- @clean_condition.wait_until { @clean_state == STATE_COMPLETED }
194
- when STATE_COMPLETED
195
- # Already done
196
- end
322
+ ##
323
+ # Checks whether the registry has requested an abort and raises an exception to stop starting new tasks.
324
+ # @raise [Taski::TaskAbortException] if `@registry.abort_requested?` is true — raised with the message "Execution aborted - no new tasks will start".
325
+ def check_abort!
326
+ if @registry.abort_requested?
327
+ raise Taski::TaskAbortException, "Execution aborted - no new tasks will start"
197
328
  end
198
329
  end
199
330
 
200
- def execute_clean
201
- debug_log("Cleaning #{@task.class}...")
202
- result = @task.clean
203
- wait_for_clean_dependencies
204
- mark_clean_completed(result)
205
- debug_log("Clean #{@task.class} completed.")
331
+ ##
332
+ # Ensures an execution context exists for this wrapper.
333
+ # Returns the existing context if set, otherwise creates a shared context.
334
+ # This enables run and clean phases to share state like runtime dependencies.
335
+ # @return [ExecutionContext] The execution context for this wrapper
336
+ def ensure_execution_context
337
+ @execution_context ||= create_shared_context
206
338
  end
207
339
 
208
- def wait_for_clean_dependencies
209
- dependencies = @task.class.cached_dependencies
210
- return if dependencies.empty?
340
+ ##
341
+ # Creates a shared execution context with proper triggers for run and clean.
342
+ # The context is configured to reuse itself when triggering nested executions.
343
+ # @return [ExecutionContext] A new execution context
344
+ def create_shared_context
345
+ context = ExecutionContext.new
346
+ progress = Taski.progress_display
347
+ context.add_observer(progress) if progress
211
348
 
212
- wait_threads = dependencies.map do |dep_class|
213
- Thread.new { dep_class.clean }
349
+ # Set triggers to reuse this context for nested executions
350
+ context.execution_trigger = ->(task_class, registry) do
351
+ Executor.execute(task_class, registry: registry, execution_context: context)
214
352
  end
215
- wait_threads.each(&:join)
216
- end
217
-
218
- def check_abort!
219
- if @registry.abort_requested?
220
- raise Taski::TaskAbortException, "Execution aborted - no new tasks will start"
353
+ context.clean_trigger = ->(task_class, registry) do
354
+ Executor.execute_clean(task_class, registry: registry, execution_context: context)
221
355
  end
356
+
357
+ context
222
358
  end
223
359
 
360
+ ##
361
+ # Notifies the execution context of task completion or failure.
362
+ # Falls back to getting the current context if not set during initialization.
363
+ # @param state [Symbol] The completion state (unused, kept for API consistency).
364
+ # @param duration [Numeric, nil] The execution duration in milliseconds.
365
+ # @param error [Exception, nil] The error if the task failed.
224
366
  def update_progress(state, duration: nil, error: nil)
225
- Taski.progress_display&.update_task(@task.class, state: state, duration: duration, error: error)
367
+ # Defensive fallback: try to get current context if not set during initialization
368
+ @execution_context ||= ExecutionContext.current
369
+ return unless @execution_context
370
+
371
+ @execution_context.notify_task_completed(@task.class, duration: duration, error: error)
372
+ end
373
+
374
+ ##
375
+ # Notifies the execution context of clean completion or failure.
376
+ # Falls back to getting the current context if not set during initialization.
377
+ # @param state [Symbol] The clean state (unused, kept for API consistency).
378
+ # @param duration [Numeric, nil] The clean duration in milliseconds.
379
+ # @param error [Exception, nil] The error if the clean failed.
380
+ def update_clean_progress(state, duration: nil, error: nil)
381
+ # Defensive fallback: try to get current context if not set during initialization
382
+ @execution_context ||= ExecutionContext.current
383
+ return unless @execution_context
384
+
385
+ @execution_context.notify_clean_completed(@task.class, duration: duration, error: error)
226
386
  end
227
387
 
388
+ ##
389
+ # Outputs a debug message if TASKI_DEBUG environment variable is set.
390
+ # @param message [String] The debug message to output.
228
391
  def debug_log(message)
229
392
  return unless ENV["TASKI_DEBUG"]
230
393
  puts "[TaskWrapper] #{message}"