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.
@@ -1,61 +1,123 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "monitor"
4
- require "etc"
5
4
 
6
5
  module Taski
7
6
  module Execution
8
7
  # Producer-Consumer pattern executor for parallel task execution.
9
8
  #
10
- # Architecture:
11
- # - Main Thread: Manages all state, coordinates execution, handles events
12
- # - Worker Threads: Execute tasks and send completion events
9
+ # Executor is the orchestrator that coordinates all execution components.
10
+ #
11
+ # == Architecture
12
+ #
13
+ # Executor
14
+ # ├── Scheduler: Dependency management and execution order
15
+ # ├── WorkerPool: Thread management and task distribution
16
+ # └── ExecutionContext: Observer notifications and output capture
17
+ # └── Observers (e.g., TreeProgressDisplay)
18
+ #
19
+ # == Execution Flow
13
20
  #
14
- # Communication Queues:
15
- # - Execution Queue (Main -> Worker): Tasks ready to execute
21
+ # 1. Build dependency graph via Scheduler
22
+ # 2. Set up progress display via ExecutionContext
23
+ # 3. Start WorkerPool threads
24
+ # 4. Enqueue ready tasks (no dependencies) to WorkerPool
25
+ # 5. Run event loop:
26
+ # - Pop completion events from workers
27
+ # - Mark completed in Scheduler
28
+ # - Enqueue newly ready tasks to WorkerPool
29
+ # 6. Shutdown WorkerPool when root task completes
30
+ # 7. Teardown progress display
31
+ #
32
+ # == Communication Queues
33
+ #
34
+ # - Execution Queue (Main -> Worker): Tasks ready to execute (via WorkerPool)
16
35
  # - Completion Queue (Worker -> Main): Events from workers
36
+ #
37
+ # == Thread Safety
38
+ #
39
+ # - Main Thread: Manages all state, coordinates execution, handles events
40
+ # - Worker Threads: Execute tasks and send completion events (via WorkerPool)
17
41
  class Executor
18
- # Task execution states for the executor's internal tracking
19
- STATE_PENDING = :pending
20
- STATE_ENQUEUED = :enqueued
21
- STATE_COMPLETED = :completed
22
-
23
42
  class << self
24
43
  # Execute a task and all its dependencies
25
44
  # @param root_task_class [Class] The root task class to execute
26
45
  # @param registry [Registry] The task registry
27
- def execute(root_task_class, registry:)
28
- new(registry: registry).execute(root_task_class)
46
+ ##
47
+ # Create a new Executor and run execution for the specified root task class.
48
+ # @param root_task_class [Class] The top-level task class to execute.
49
+ # @param registry [Taski::Registry] Registry providing task definitions and state.
50
+ # @param execution_context [ExecutionContext, nil] Optional execution context to use; when nil a default context is created.
51
+ # @return [Object] The result returned by the execution of the root task.
52
+ def execute(root_task_class, registry:, execution_context: nil)
53
+ new(registry: registry, execution_context: execution_context).execute(root_task_class)
54
+ end
55
+
56
+ # Execute clean for a task and all its dependencies (in reverse order)
57
+ # @param root_task_class [Class] The root task class to clean
58
+ # @param registry [Registry] The task registry
59
+ ##
60
+ # Runs reverse-order clean execution beginning at the given root task class.
61
+ # @param [Class] root_task_class - The root task class whose dependency graph will drive the clean run.
62
+ # @param [Object] registry - Task registry used to resolve and track tasks during execution.
63
+ # @param [ExecutionContext, nil] execution_context - Optional execution context for observers and output capture; if `nil`, a default context is created.
64
+ def execute_clean(root_task_class, registry:, execution_context: nil)
65
+ new(registry: registry, execution_context: execution_context).execute_clean(root_task_class)
29
66
  end
30
67
  end
31
68
 
32
- def initialize(registry:, worker_count: nil)
69
+ ##
70
+ # Initialize an Executor and its internal coordination components.
71
+ # @param [Object] registry - Task registry used to look up task definitions and state.
72
+ # @param [Integer, nil] worker_count - Optional number of worker threads to use; when `nil`,
73
+ # uses Taski.args_worker_count which retrieves the worker count from the runtime args.
74
+ # @param [Taski::Execution::ExecutionContext, nil] execution_context - Optional execution context for observers and output capture; when `nil` a default context (with progress observer and execution trigger) is created.
75
+ def initialize(registry:, worker_count: nil, execution_context: nil)
33
76
  @registry = registry
34
- @worker_count = worker_count || default_worker_count
35
- @execution_queue = Queue.new
36
77
  @completion_queue = Queue.new
37
- @workers = []
38
78
 
39
- # State managed by main thread only
40
- @dependencies = {}
41
- @task_states = {}
42
- @completed_tasks = Set.new
79
+ # ExecutionContext for observer pattern and output capture
80
+ @execution_context = execution_context || create_default_execution_context
81
+
82
+ # Scheduler for dependency management
83
+ @scheduler = Scheduler.new
84
+
85
+ # Determine effective worker count: explicit param > args > default
86
+ # Store as instance variable for consistent use in both run and clean phases
87
+ @effective_worker_count = worker_count || Taski.args_worker_count
88
+
89
+ # WorkerPool for thread management
90
+ @worker_pool = WorkerPool.new(
91
+ registry: @registry,
92
+ worker_count: @effective_worker_count
93
+ ) { |task_class, wrapper| execute_task(task_class, wrapper) }
43
94
  end
44
95
 
45
96
  # Execute root task and all dependencies
46
- # @param root_task_class [Class] The root task class to execute
97
+ ##
98
+ # Execute the task graph rooted at the given task class.
99
+ #
100
+ # Builds the dependency graph, starts progress reporting and worker threads,
101
+ # enqueues tasks that are ready (no unmet dependencies), and processes worker
102
+ # completion events until the root task finishes. After completion or abort,
103
+ # shuts down workers, stops progress reporting, and restores stdout capture if
104
+ # this executor configured it.
105
+ # @param root_task_class [Class] The root task class to execute.
47
106
  def execute(root_task_class)
48
107
  # Build dependency graph from static analysis
49
- build_dependency_graph(root_task_class)
108
+ @scheduler.build_dependency_graph(root_task_class)
109
+
110
+ # Set up progress display with root task
111
+ setup_progress_display(root_task_class)
50
112
 
51
- # Set up tree progress display with root task (before start)
52
- setup_tree_progress(root_task_class)
113
+ # Set up output capture (returns true if this executor set it up)
114
+ should_teardown_capture = setup_output_capture_if_needed
53
115
 
54
- # Start progress display automatically for tree progress
116
+ # Start progress display
55
117
  start_progress_display
56
118
 
57
119
  # Start worker threads
58
- start_workers
120
+ @worker_pool.start
59
121
 
60
122
  # Enqueue tasks with no dependencies
61
123
  enqueue_ready_tasks
@@ -64,66 +126,96 @@ module Taski
64
126
  run_main_loop(root_task_class)
65
127
 
66
128
  # Shutdown workers
67
- shutdown_workers
129
+ @worker_pool.shutdown
68
130
 
69
131
  # Stop progress display
70
132
  stop_progress_display
71
- end
72
133
 
73
- private
134
+ # Restore original stdout (only if this executor set it up)
135
+ teardown_output_capture if should_teardown_capture
74
136
 
75
- def default_worker_count
76
- Etc.nprocessors.clamp(2, 8)
137
+ # Raise aggregated errors if any tasks failed
138
+ raise_if_any_failures
77
139
  end
78
140
 
79
- # Build dependency graph by traversing from root task
80
- # Populates @dependencies and @task_states
81
- def build_dependency_graph(root_task_class)
82
- # @type var queue: Array[singleton(Taski::Task)]
83
- queue = [root_task_class]
141
+ # Execute clean for root task and all dependencies (in reverse dependency order)
142
+ # Clean operations run in reverse: root task cleans first, then dependencies
143
+ ##
144
+ # Executes the clean workflow for the given root task in reverse dependency order.
145
+ # Sets up progress display and optional output capture, starts a dedicated clean worker pool,
146
+ # enqueues ready-to-clean tasks, processes completion events until all tasks are cleaned,
147
+ # then shuts down workers and tears down progress and output capture as needed.
148
+ # @param [Class] root_task_class - The root task class to clean
149
+ def execute_clean(root_task_class)
150
+ # Build reverse dependency graph for clean order
151
+ # This must happen first to ensure root task and all static dependencies are included
152
+ @scheduler.build_reverse_dependency_graph(root_task_class)
153
+
154
+ # Merge runtime dependencies (e.g., Section's dynamically selected implementations)
155
+ # This allows clean to include tasks that were selected at runtime during run phase
156
+ runtime_deps = @execution_context.runtime_dependencies
157
+ @scheduler.merge_runtime_dependencies(runtime_deps)
158
+
159
+ # Set up progress display with root task (if not already set)
160
+ setup_progress_display(root_task_class)
161
+
162
+ # Set up output capture (returns true if this executor set it up)
163
+ should_teardown_capture = setup_output_capture_if_needed
164
+
165
+ # Start progress display
166
+ start_progress_display
167
+
168
+ # Create a new worker pool for clean operations
169
+ # Uses the same worker count as the run phase
170
+ @clean_worker_pool = WorkerPool.new(
171
+ registry: @registry,
172
+ worker_count: @effective_worker_count
173
+ ) { |task_class, wrapper| execute_clean_task(task_class, wrapper) }
84
174
 
85
- while (task_class = queue.shift)
86
- next if @task_states.key?(task_class)
175
+ # Start worker threads
176
+ @clean_worker_pool.start
87
177
 
88
- deps = task_class.cached_dependencies
89
- @dependencies[task_class] = deps.dup
90
- @task_states[task_class] = STATE_PENDING
178
+ # Enqueue tasks ready for clean (no reverse dependencies)
179
+ enqueue_ready_clean_tasks
91
180
 
92
- deps.each { |dep| queue << dep }
93
- end
181
+ # Main event loop - continues until all tasks are cleaned
182
+ run_clean_main_loop(root_task_class)
183
+
184
+ # Shutdown workers
185
+ @clean_worker_pool.shutdown
186
+
187
+ # Stop progress display
188
+ stop_progress_display
189
+
190
+ # Restore original stdout (only if this executor set it up)
191
+ teardown_output_capture if should_teardown_capture
192
+
193
+ # Raise aggregated errors if any clean tasks failed
194
+ raise_if_any_clean_failures
94
195
  end
95
196
 
96
- # Enqueue tasks that have all dependencies completed
97
- def enqueue_ready_tasks
98
- @task_states.each_key do |task_class|
99
- next unless @task_states[task_class] == STATE_PENDING
100
- next unless ready_to_execute?(task_class)
197
+ private
101
198
 
199
+ # Enqueue all tasks that are ready to execute
200
+ def enqueue_ready_tasks
201
+ @scheduler.next_ready_tasks.each do |task_class|
102
202
  enqueue_task(task_class)
103
203
  end
104
204
  end
105
205
 
106
- # Check if a task is ready to execute
107
- def ready_to_execute?(task_class)
108
- task_deps = @dependencies[task_class] || Set.new
109
- task_deps.subset?(@completed_tasks)
110
- end
111
-
112
206
  # Enqueue a single task for execution
113
207
  def enqueue_task(task_class)
114
208
  return if @registry.abort_requested?
115
209
 
116
- @task_states[task_class] = STATE_ENQUEUED
210
+ @scheduler.mark_enqueued(task_class)
117
211
 
118
212
  wrapper = get_or_create_wrapper(task_class)
119
213
  return unless wrapper.mark_running
120
214
 
121
- Taski.progress_display&.register_task(task_class)
122
- Taski.progress_display&.update_task(task_class, state: :running)
123
-
124
- @execution_queue.push({task_class: task_class, wrapper: wrapper})
215
+ @execution_context.notify_task_registered(task_class)
216
+ @execution_context.notify_task_started(task_class)
125
217
 
126
- debug_log("Enqueued: #{task_class}")
218
+ @worker_pool.enqueue(task_class, wrapper)
127
219
  end
128
220
 
129
221
  # Get or create a task wrapper via Registry
@@ -131,40 +223,26 @@ module Taski
131
223
  @registry.get_or_create(task_class) do
132
224
  task_instance = task_class.allocate
133
225
  task_instance.send(:initialize)
134
- TaskWrapper.new(task_instance, registry: @registry)
226
+ TaskWrapper.new(task_instance, registry: @registry, execution_context: @execution_context)
135
227
  end
136
228
  end
137
229
 
138
- # Start worker threads
139
- def start_workers
140
- @worker_count.times do
141
- worker = Thread.new { worker_loop }
142
- @workers << worker
143
- @registry.register_thread(worker)
144
- end
145
- end
230
+ # Execute a task and send completion event (called by WorkerPool)
231
+ def execute_task(task_class, wrapper)
232
+ return if @registry.abort_requested?
146
233
 
147
- # Worker thread main loop
148
- def worker_loop
149
- loop do
150
- work_item = @execution_queue.pop
151
- break if work_item == :shutdown
234
+ output_capture = @execution_context.output_capture
152
235
 
153
- task_class = work_item[:task_class]
154
- wrapper = work_item[:wrapper]
236
+ # Start capturing output for this task
237
+ output_capture&.start_capture(task_class)
155
238
 
156
- debug_log("Worker executing: #{task_class}")
157
-
158
- execute_task(task_class, wrapper)
159
- end
160
- end
161
-
162
- # Execute a task and send completion event
163
- def execute_task(task_class, wrapper)
164
- return if @registry.abort_requested?
239
+ # Set thread-local execution context for task access (e.g., Section)
240
+ ExecutionContext.current = @execution_context
241
+ # Set thread-local registry for dependency resolution
242
+ Taski.set_current_registry(@registry)
165
243
 
166
244
  begin
167
- result = execute_task_run(wrapper)
245
+ result = wrapper.task.run
168
246
  wrapper.mark_completed(result)
169
247
  @completion_queue.push({task_class: task_class, wrapper: wrapper})
170
248
  rescue Taski::TaskAbortException => e
@@ -174,74 +252,287 @@ module Taski
174
252
  rescue => e
175
253
  wrapper.mark_failed(e)
176
254
  @completion_queue.push({task_class: task_class, wrapper: wrapper, error: e})
255
+ ensure
256
+ # Stop capturing output for this task
257
+ output_capture&.stop_capture
258
+ # Clear thread-local execution context
259
+ ExecutionContext.current = nil
260
+ # Clear thread-local registry
261
+ Taski.clear_current_registry
177
262
  end
178
263
  end
179
264
 
180
- # Execute task run method
181
- # Note: Previously captured stdout for progress display, but this was removed
182
- # due to thread-safety concerns with global $stdout mutation.
183
- def execute_task_run(wrapper)
184
- wrapper.task.run
185
- end
186
-
187
265
  # Main thread event loop - continues until root task completes
188
266
  def run_main_loop(root_task_class)
189
- until @completed_tasks.include?(root_task_class)
190
- break if @registry.abort_requested? && no_running_tasks?
267
+ until @scheduler.completed?(root_task_class)
268
+ break if @registry.abort_requested? && !@scheduler.running_tasks?
191
269
 
192
270
  event = @completion_queue.pop
193
271
  handle_completion(event)
194
272
  end
195
273
  end
196
274
 
197
- def no_running_tasks?
198
- @task_states.values.none? { |state| state == STATE_ENQUEUED }
199
- end
200
-
201
- # Handle task completion event
275
+ ##
276
+ # Marks the given task as completed in the scheduler and enqueues any tasks that become ready as a result.
277
+ # @param [Hash] event - Completion event containing information about the finished task.
278
+ # @param [Class] event[:task_class] - The task class that completed.
202
279
  def handle_completion(event)
203
280
  task_class = event[:task_class]
204
281
 
205
282
  debug_log("Completed: #{task_class}")
206
283
 
207
- @task_states[task_class] = STATE_COMPLETED
208
- @completed_tasks.add(task_class)
284
+ @scheduler.mark_completed(task_class)
209
285
 
210
286
  # Enqueue newly ready tasks
211
287
  enqueue_ready_tasks
212
288
  end
213
289
 
214
- # Shutdown worker threads
215
- def shutdown_workers
216
- @worker_count.times { @execution_queue.push(:shutdown) }
217
- @workers.each(&:join)
290
+ # ========================================
291
+ # Clean Execution Methods
292
+ # ========================================
293
+
294
+ ##
295
+ # Enqueues all tasks that are currently ready to be cleaned.
296
+ def enqueue_ready_clean_tasks
297
+ @scheduler.next_ready_clean_tasks.each do |task_class|
298
+ enqueue_clean_task(task_class)
299
+ end
218
300
  end
219
301
 
220
- def setup_tree_progress(root_task_class)
221
- progress = Taski.progress_display
222
- return unless progress.is_a?(TreeProgressDisplay)
302
+ ##
303
+ # Enqueues a single task for reverse-order (clean) execution.
304
+ # If execution has been aborted, does nothing. Marks the task as clean-enqueued,
305
+ # skips if the task is not registered or not eligible to run, notifies the
306
+ # execution context that cleaning has started, and schedules the task on the
307
+ # clean worker pool.
308
+ # @param [Class] task_class - The task class to enqueue for clean execution.
309
+ def enqueue_clean_task(task_class)
310
+ return if @registry.abort_requested?
223
311
 
224
- progress.set_root_task(root_task_class)
312
+ @scheduler.mark_clean_enqueued(task_class)
313
+
314
+ wrapper = get_or_create_wrapper(task_class)
315
+ return unless wrapper.mark_clean_running
316
+
317
+ @execution_context.notify_clean_started(task_class)
318
+
319
+ @clean_worker_pool.enqueue(task_class, wrapper)
225
320
  end
226
321
 
227
- def start_progress_display
228
- progress = Taski.progress_display
229
- return unless progress.is_a?(TreeProgressDisplay)
322
+ ##
323
+ # Executes the clean lifecycle for a task and emits a completion event.
324
+ #
325
+ # Runs the task's `clean` method, updates the provided wrapper with success or failure
326
+ # (which handles timing and observer notification), and pushes a completion event onto
327
+ # the executor's completion queue.
328
+ # This method respects an abort requested state from the registry (no-op if abort already requested)
329
+ # and triggers a registry abort when a `Taski::TaskAbortException` is raised.
330
+ # It also starts and stops per-task output capture when available and sets the thread-local
331
+ # `ExecutionContext.current` for the duration of the clean.
332
+ # @param [Class] task_class - The task class being cleaned.
333
+ # @param [Taski::Execution::TaskWrapper] wrapper - The wrapper instance for the task, used to record clean success or failure.
334
+ def execute_clean_task(task_class, wrapper)
335
+ return if @registry.abort_requested?
336
+
337
+ output_capture = @execution_context.output_capture
338
+
339
+ # Start capturing output for this task
340
+ output_capture&.start_capture(task_class)
341
+
342
+ # Set thread-local execution context for task access
343
+ ExecutionContext.current = @execution_context
344
+ # Set thread-local registry for dependency resolution
345
+ Taski.set_current_registry(@registry)
346
+
347
+ begin
348
+ result = wrapper.task.clean
349
+ wrapper.mark_clean_completed(result)
350
+ @completion_queue.push({task_class: task_class, wrapper: wrapper, clean: true})
351
+ rescue Taski::TaskAbortException => e
352
+ @registry.request_abort!
353
+ wrapper.mark_clean_failed(e)
354
+ @completion_queue.push({task_class: task_class, wrapper: wrapper, error: e, clean: true})
355
+ rescue => e
356
+ wrapper.mark_clean_failed(e)
357
+ @completion_queue.push({task_class: task_class, wrapper: wrapper, error: e, clean: true})
358
+ ensure
359
+ # Stop capturing output for this task
360
+ output_capture&.stop_capture
361
+ # Clear thread-local execution context
362
+ ExecutionContext.current = nil
363
+ # Clear thread-local registry
364
+ Taski.clear_current_registry
365
+ end
366
+ end
367
+
368
+ ##
369
+ # Runs the main event loop that processes clean completion events until all tasks have been cleaned.
370
+ # Continuously pops events from the internal completion queue and delegates them to the clean completion handler,
371
+ # stopping early if an abort is requested and no clean tasks are running.
372
+ # @param [Class] root_task_class - The root task class that defines the overall clean lifecycle.
373
+ def run_clean_main_loop(root_task_class)
374
+ # Find all tasks in the dependency graph
375
+ # Continue until all tasks have been cleaned
376
+ until all_tasks_cleaned?
377
+ break if @registry.abort_requested? && !@scheduler.running_clean_tasks?
378
+
379
+ event = @completion_queue.pop
380
+ handle_clean_completion(event)
381
+ end
382
+ end
383
+
384
+ ##
385
+ # Processes a clean completion event and advances the cleaning workflow.
386
+ # Marks the completed task in the scheduler and enqueues any tasks that become ready to clean.
387
+ # @param [Hash] event - A completion event hash containing the `:task_class` key for the task that finished cleaning.
388
+ def handle_clean_completion(event)
389
+ task_class = event[:task_class]
390
+
391
+ debug_log("Clean completed: #{task_class}")
392
+
393
+ @scheduler.mark_clean_completed(task_class)
394
+
395
+ # Enqueue newly ready clean tasks
396
+ enqueue_ready_clean_tasks
397
+ end
398
+
399
+ ##
400
+ # Determines whether all tasks have finished their clean phase.
401
+ # @return [Boolean] `true` if there are no ready-to-clean tasks and no running clean tasks, `false` otherwise.
402
+ def all_tasks_cleaned?
403
+ @scheduler.next_ready_clean_tasks.empty? && !@scheduler.running_clean_tasks?
404
+ end
405
+
406
+ # Notify observers about the root task
407
+ # @param root_task_class [Class] The root task class
408
+ # @return [void]
409
+ def setup_progress_display(root_task_class)
410
+ @execution_context.notify_set_root_task(root_task_class)
411
+ end
412
+
413
+ # Set up output capture if progress display is active and not already set up
414
+ # @return [Boolean] true if this executor set up the capture
415
+ def setup_output_capture_if_needed
416
+ return false unless Taski.progress_display
417
+ return false if @execution_context.output_capture_active?
418
+
419
+ @execution_context.setup_output_capture($stdout)
420
+ true
421
+ end
422
+
423
+ # Tear down output capture and restore original $stdout
424
+ # @return [void]
425
+ def teardown_output_capture
426
+ @execution_context.teardown_output_capture
427
+ end
230
428
 
231
- progress.start
429
+ def start_progress_display
430
+ @execution_context.notify_start
232
431
  end
233
432
 
234
433
  def stop_progress_display
434
+ @execution_context.notify_stop
435
+ end
436
+
437
+ def create_default_execution_context
438
+ context = ExecutionContext.new
235
439
  progress = Taski.progress_display
236
- return unless progress.is_a?(TreeProgressDisplay)
440
+ context.add_observer(progress) if progress
441
+
442
+ # Set execution trigger to break circular dependency with TaskWrapper
443
+ context.execution_trigger = ->(task_class, registry) do
444
+ Executor.execute(task_class, registry: registry, execution_context: context)
445
+ end
237
446
 
238
- progress.stop
447
+ context
239
448
  end
240
449
 
241
450
  def debug_log(message)
242
451
  return unless ENV["TASKI_DEBUG"]
243
452
  puts "[Executor] #{message}"
244
453
  end
454
+
455
+ # Raise error(s) if any tasks failed during execution
456
+ # TaskAbortException: raised directly (abort takes priority)
457
+ # All other errors: raises AggregateError containing all failures
458
+ def raise_if_any_failures
459
+ failed = @registry.failed_wrappers
460
+ return if failed.empty?
461
+
462
+ # TaskAbortException takes priority - raise the first one directly
463
+ abort_wrapper = failed.find { |w| w.error.is_a?(TaskAbortException) }
464
+ raise abort_wrapper.error if abort_wrapper
465
+
466
+ # Flatten nested AggregateErrors and deduplicate by original error object_id
467
+ failures = flatten_failures(failed)
468
+ unique_failures = failures.uniq { |f| error_identity(f.error) }
469
+
470
+ raise AggregateError.new(unique_failures)
471
+ end
472
+
473
+ # Flatten AggregateErrors into individual TaskFailure objects
474
+ # Wraps original errors with task-specific Error class for rescue matching
475
+ def flatten_failures(failed_wrappers)
476
+ failed_wrappers.flat_map do |wrapper|
477
+ case wrapper.error
478
+ when AggregateError
479
+ wrapper.error.errors
480
+ else
481
+ wrapped_error = wrap_with_task_error(wrapper.task.class, wrapper.error)
482
+ [TaskFailure.new(task_class: wrapper.task.class, error: wrapped_error)]
483
+ end
484
+ end
485
+ end
486
+
487
+ # Wraps an error with the task-specific Error class
488
+ # @param task_class [Class] The task class
489
+ # @param error [Exception] The original error
490
+ # @return [TaskError] The wrapped error
491
+ def wrap_with_task_error(task_class, error)
492
+ # Don't double-wrap if already a TaskError
493
+ return error if error.is_a?(TaskError)
494
+
495
+ error_class = task_class.const_get(:Error)
496
+ error_class.new(error, task_class: task_class)
497
+ end
498
+
499
+ # Raise error(s) if any tasks failed during clean execution
500
+ # TaskAbortException: raised directly (abort takes priority)
501
+ # All other errors: raises AggregateError containing all failures
502
+ def raise_if_any_clean_failures
503
+ failed = @registry.failed_clean_wrappers
504
+ return if failed.empty?
505
+
506
+ # TaskAbortException takes priority - raise the first one directly
507
+ abort_wrapper = failed.find { |w| w.clean_error.is_a?(TaskAbortException) }
508
+ raise abort_wrapper.clean_error if abort_wrapper
509
+
510
+ # Flatten nested AggregateErrors and deduplicate by original error object_id
511
+ failures = flatten_clean_failures(failed)
512
+ unique_failures = failures.uniq { |f| error_identity(f.error) }
513
+
514
+ raise AggregateError.new(unique_failures)
515
+ end
516
+
517
+ # Flatten AggregateErrors into individual TaskFailure objects for clean errors
518
+ # Wraps original errors with task-specific Error class for rescue matching
519
+ def flatten_clean_failures(failed_wrappers)
520
+ failed_wrappers.flat_map do |wrapper|
521
+ case wrapper.clean_error
522
+ when AggregateError
523
+ wrapper.clean_error.errors
524
+ else
525
+ wrapped_error = wrap_with_task_error(wrapper.task.class, wrapper.clean_error)
526
+ [TaskFailure.new(task_class: wrapper.task.class, error: wrapped_error)]
527
+ end
528
+ end
529
+ end
530
+
531
+ # Returns a unique identifier for an error, used for deduplication
532
+ # For TaskError, uses the wrapped cause's object_id
533
+ def error_identity(error)
534
+ error.is_a?(TaskError) ? error.cause&.object_id || error.object_id : error.object_id
535
+ end
245
536
  end
246
537
  end
247
538
  end
@@ -16,7 +16,9 @@ module Taski
16
16
  # @yield Block to create the task instance if it doesn't exist
17
17
  # @return [Object] The task instance
18
18
  def get_or_create(task_class)
19
- @tasks[task_class] ||= yield
19
+ @monitor.synchronize do
20
+ @tasks[task_class] ||= yield
21
+ end
20
22
  end
21
23
 
22
24
  # @param task_class [Class] The task class
@@ -61,6 +63,20 @@ module Taski
61
63
  @monitor.synchronize { @abort_requested }
62
64
  end
63
65
 
66
+ # @return [Array<TaskWrapper>] All wrappers that have errors
67
+ def failed_wrappers
68
+ @monitor.synchronize do
69
+ @tasks.values.select { |w| w.error }
70
+ end
71
+ end
72
+
73
+ # @return [Array<TaskWrapper>] All wrappers that have clean errors
74
+ def failed_clean_wrappers
75
+ @monitor.synchronize do
76
+ @tasks.values.select { |w| w.clean_error }
77
+ end
78
+ end
79
+
64
80
  # @param task_class [Class] The task class to run
65
81
  # @param exported_methods [Array<Symbol>] Methods to call to trigger execution
66
82
  # @return [Object] The result of the task execution