taski 0.7.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.
@@ -0,0 +1,348 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "monitor"
4
+
5
+ module Taski
6
+ module Execution
7
+ # Base class for progress display implementations.
8
+ # Provides common task tracking and lifecycle management.
9
+ # Subclasses override template methods for custom rendering.
10
+ class BaseProgressDisplay
11
+ # Shared task progress tracking
12
+ class TaskProgress
13
+ # Run lifecycle tracking
14
+ attr_accessor :run_state, :run_start_time, :run_end_time, :run_error, :run_duration
15
+ # Clean lifecycle tracking
16
+ attr_accessor :clean_state, :clean_start_time, :clean_end_time, :clean_error, :clean_duration
17
+ # Display properties
18
+ attr_accessor :is_impl_candidate
19
+ # Group tracking
20
+ attr_accessor :groups, :current_group_index
21
+
22
+ def initialize
23
+ # Run lifecycle
24
+ @run_state = :pending
25
+ @run_start_time = nil
26
+ @run_end_time = nil
27
+ @run_error = nil
28
+ @run_duration = nil
29
+ # Clean lifecycle
30
+ @clean_state = nil # nil means clean hasn't started
31
+ @clean_start_time = nil
32
+ @clean_end_time = nil
33
+ @clean_error = nil
34
+ @clean_duration = nil
35
+ # Display
36
+ @is_impl_candidate = false
37
+ # Groups
38
+ @groups = []
39
+ @current_group_index = nil
40
+ end
41
+
42
+ # Returns the most relevant state for display
43
+ def state
44
+ @clean_state || @run_state
45
+ end
46
+
47
+ # Legacy accessors for backward compatibility
48
+ def start_time
49
+ @clean_start_time || @run_start_time
50
+ end
51
+
52
+ def end_time
53
+ @clean_end_time || @run_end_time
54
+ end
55
+
56
+ def error
57
+ @clean_error || @run_error
58
+ end
59
+
60
+ def duration
61
+ @clean_duration || @run_duration
62
+ end
63
+ end
64
+
65
+ # Tracks the progress of a group within a task
66
+ class GroupProgress
67
+ attr_accessor :name, :state, :start_time, :end_time, :duration, :error, :last_message
68
+
69
+ def initialize(name)
70
+ @name = name
71
+ @state = :pending
72
+ @start_time = nil
73
+ @end_time = nil
74
+ @duration = nil
75
+ @error = nil
76
+ @last_message = nil
77
+ end
78
+ end
79
+
80
+ def initialize(output: $stdout)
81
+ @output = output
82
+ @tasks = {}
83
+ @monitor = Monitor.new
84
+ @nest_level = 0
85
+ @root_task_class = nil
86
+ @output_capture = nil
87
+ @start_time = nil
88
+ end
89
+
90
+ # Set the output capture for getting task output
91
+ # @param capture [ThreadOutputCapture] The output capture instance
92
+ def set_output_capture(capture)
93
+ @monitor.synchronize do
94
+ @output_capture = capture
95
+ end
96
+ end
97
+
98
+ # Set the root task to build tree structure
99
+ # Only sets root task if not already set (prevents nested executor overwrite)
100
+ # @param root_task_class [Class] The root task class
101
+ def set_root_task(root_task_class)
102
+ @monitor.synchronize do
103
+ return if @root_task_class # Don't overwrite existing root task
104
+ @root_task_class = root_task_class
105
+ on_root_task_set
106
+ end
107
+ end
108
+
109
+ # Register which impl was selected for a section
110
+ # @param section_class [Class] The section class
111
+ # @param impl_class [Class] The selected implementation class
112
+ def register_section_impl(section_class, impl_class)
113
+ @monitor.synchronize do
114
+ on_section_impl_registered(section_class, impl_class)
115
+ end
116
+ end
117
+
118
+ # @param task_class [Class] The task class to register
119
+ def register_task(task_class)
120
+ @monitor.synchronize do
121
+ return if @tasks.key?(task_class)
122
+ @tasks[task_class] = TaskProgress.new
123
+ on_task_registered(task_class)
124
+ end
125
+ end
126
+
127
+ # @param task_class [Class] The task class to check
128
+ # @return [Boolean] true if the task is registered
129
+ def task_registered?(task_class)
130
+ @monitor.synchronize do
131
+ @tasks.key?(task_class)
132
+ end
133
+ end
134
+
135
+ # @param task_class [Class] The task class to update
136
+ # @param state [Symbol] The new state
137
+ # @param duration [Float] Duration in milliseconds (for completed tasks)
138
+ # @param error [Exception] Error object (for failed tasks)
139
+ def update_task(task_class, state:, duration: nil, error: nil)
140
+ @monitor.synchronize do
141
+ progress = @tasks[task_class]
142
+ # Register task if not already registered (for late-registered tasks)
143
+ progress ||= @tasks[task_class] = TaskProgress.new
144
+
145
+ apply_state_transition(progress, state, duration, error)
146
+ on_task_updated(task_class, state, duration, error)
147
+ end
148
+ end
149
+
150
+ # @param task_class [Class] The task class
151
+ # @return [Symbol] The task state
152
+ def task_state(task_class)
153
+ @monitor.synchronize do
154
+ @tasks[task_class]&.state
155
+ end
156
+ end
157
+
158
+ # Update group state for a task.
159
+ # @param task_class [Class] The task class containing the group
160
+ # @param group_name [String] The name of the group
161
+ # @param state [Symbol] The new state (:running, :completed, :failed)
162
+ # @param duration [Float, nil] Duration in milliseconds (for completed groups)
163
+ # @param error [Exception, nil] Error object (for failed groups)
164
+ def update_group(task_class, group_name, state:, duration: nil, error: nil)
165
+ @monitor.synchronize do
166
+ progress = @tasks[task_class]
167
+ return unless progress
168
+
169
+ apply_group_state_transition(progress, group_name, state, duration, error)
170
+ on_group_updated(task_class, group_name, state, duration, error)
171
+ end
172
+ end
173
+
174
+ def start
175
+ should_start = false
176
+ @monitor.synchronize do
177
+ @nest_level += 1
178
+ return if @nest_level > 1 # Already running from outer executor
179
+ return unless should_activate?
180
+
181
+ @start_time = Time.now
182
+ should_start = true
183
+ end
184
+
185
+ return unless should_start
186
+
187
+ on_start
188
+ end
189
+
190
+ def stop
191
+ should_stop = false
192
+ @monitor.synchronize do
193
+ @nest_level -= 1 if @nest_level > 0
194
+ return unless @nest_level == 0
195
+
196
+ should_stop = true
197
+ end
198
+
199
+ return unless should_stop
200
+
201
+ on_stop
202
+ end
203
+
204
+ protected
205
+
206
+ # Template methods - override in subclasses
207
+
208
+ # Called when root task is set. Override to build tree structure.
209
+ def on_root_task_set
210
+ # Default: no-op
211
+ end
212
+
213
+ # Called when a section impl is registered.
214
+ def on_section_impl_registered(section_class, impl_class)
215
+ # Default: no-op
216
+ end
217
+
218
+ # Called when a task is registered.
219
+ def on_task_registered(task_class)
220
+ # Default: no-op
221
+ end
222
+
223
+ # Called when a task state is updated.
224
+ def on_task_updated(task_class, state, duration, error)
225
+ # Default: no-op
226
+ end
227
+
228
+ # Called when a group state is updated.
229
+ def on_group_updated(task_class, group_name, state, duration, error)
230
+ # Default: no-op
231
+ end
232
+
233
+ # Called to determine if display should activate.
234
+ # @return [Boolean] true if display should start
235
+ def should_activate?
236
+ true
237
+ end
238
+
239
+ # Called when display starts.
240
+ def on_start
241
+ # Default: no-op
242
+ end
243
+
244
+ # Called when display stops.
245
+ def on_stop
246
+ # Default: no-op
247
+ end
248
+
249
+ # Shared tree traversal for subclasses
250
+
251
+ # Register all tasks from a tree structure recursively
252
+ def register_tasks_from_tree(node)
253
+ return unless node
254
+
255
+ task_class = node[:task_class]
256
+ @tasks[task_class] ||= TaskProgress.new
257
+ @tasks[task_class].is_impl_candidate = true if node[:is_impl_candidate]
258
+
259
+ node[:children].each { |child| register_tasks_from_tree(child) }
260
+ end
261
+
262
+ # Utility methods for subclasses
263
+
264
+ # Get short name of a task class
265
+ def short_name(task_class)
266
+ return "Unknown" unless task_class
267
+ task_class.name&.split("::")&.last || task_class.to_s
268
+ end
269
+
270
+ # Format duration for display
271
+ def format_duration(ms)
272
+ if ms >= 1000
273
+ "#{(ms / 1000.0).round(1)}s"
274
+ else
275
+ "#{ms.round(1)}ms"
276
+ end
277
+ end
278
+
279
+ # Check if output is a TTY
280
+ def tty?
281
+ @output.tty?
282
+ end
283
+
284
+ private
285
+
286
+ # Apply state transition to TaskProgress
287
+ def apply_state_transition(progress, state, duration, error)
288
+ case state
289
+ # Run lifecycle states
290
+ when :pending
291
+ progress.run_state = :pending
292
+ when :running
293
+ progress.run_state = :running
294
+ progress.run_start_time = Time.now
295
+ when :completed
296
+ progress.run_state = :completed
297
+ progress.run_end_time = Time.now
298
+ progress.run_duration = duration if duration
299
+ when :failed
300
+ progress.run_state = :failed
301
+ progress.run_end_time = Time.now
302
+ progress.run_error = error if error
303
+ # Clean lifecycle states
304
+ when :cleaning
305
+ progress.clean_state = :cleaning
306
+ progress.clean_start_time = Time.now
307
+ when :clean_completed
308
+ progress.clean_state = :clean_completed
309
+ progress.clean_end_time = Time.now
310
+ progress.clean_duration = duration if duration
311
+ when :clean_failed
312
+ progress.clean_state = :clean_failed
313
+ progress.clean_end_time = Time.now
314
+ progress.clean_error = error if error
315
+ end
316
+ end
317
+
318
+ # Apply state transition to GroupProgress
319
+ def apply_group_state_transition(progress, group_name, state, duration, error)
320
+ case state
321
+ when :running
322
+ group = GroupProgress.new(group_name)
323
+ group.state = :running
324
+ group.start_time = Time.now
325
+ progress.groups << group
326
+ progress.current_group_index = progress.groups.size - 1
327
+ when :completed
328
+ group = progress.groups.find { |g| g.name == group_name && g.state == :running }
329
+ if group
330
+ group.state = :completed
331
+ group.end_time = Time.now
332
+ group.duration = duration
333
+ end
334
+ progress.current_group_index = nil
335
+ when :failed
336
+ group = progress.groups.find { |g| g.name == group_name && g.state == :running }
337
+ if group
338
+ group.state = :failed
339
+ group.end_time = Time.now
340
+ group.duration = duration
341
+ group.error = error
342
+ end
343
+ progress.current_group_index = nil
344
+ end
345
+ end
346
+ end
347
+ end
348
+ end
@@ -98,6 +98,7 @@ module Taski
98
98
  @monitor.synchronize do
99
99
  @original_stdout = output_io
100
100
  @output_capture = TaskOutputRouter.new(@original_stdout)
101
+ @output_capture.start_polling
101
102
  $stdout = @output_capture
102
103
  end
103
104
 
@@ -106,13 +107,16 @@ module Taski
106
107
 
107
108
  # Tear down output capture and restore original $stdout.
108
109
  def teardown_output_capture
110
+ capture = nil
109
111
  @monitor.synchronize do
110
112
  return unless @original_stdout
111
113
 
114
+ capture = @output_capture
112
115
  $stdout = @original_stdout
113
116
  @output_capture = nil
114
117
  @original_stdout = nil
115
118
  end
119
+ capture&.stop_polling
116
120
  end
117
121
 
118
122
  # Get the current output capture instance.
@@ -107,32 +107,19 @@ module Taski
107
107
  # Build dependency graph from static analysis
108
108
  @scheduler.build_dependency_graph(root_task_class)
109
109
 
110
- # Set up progress display with root task
111
- setup_progress_display(root_task_class)
112
-
113
- # Set up output capture (returns true if this executor set it up)
114
- should_teardown_capture = setup_output_capture_if_needed
115
-
116
- # Start progress display
117
- start_progress_display
118
-
119
- # Start worker threads
120
- @worker_pool.start
110
+ with_display_lifecycle(root_task_class) do
111
+ # Start worker threads
112
+ @worker_pool.start
121
113
 
122
- # Enqueue tasks with no dependencies
123
- enqueue_ready_tasks
124
-
125
- # Main event loop - continues until root task completes
126
- run_main_loop(root_task_class)
127
-
128
- # Shutdown workers
129
- @worker_pool.shutdown
114
+ # Enqueue tasks with no dependencies
115
+ enqueue_ready_tasks
130
116
 
131
- # Stop progress display
132
- stop_progress_display
117
+ # Main event loop - continues until root task completes
118
+ run_main_loop(root_task_class)
133
119
 
134
- # Restore original stdout (only if this executor set it up)
135
- teardown_output_capture if should_teardown_capture
120
+ # Shutdown workers
121
+ @worker_pool.shutdown
122
+ end
136
123
 
137
124
  # Raise aggregated errors if any tasks failed
138
125
  raise_if_any_failures
@@ -156,39 +143,26 @@ module Taski
156
143
  runtime_deps = @execution_context.runtime_dependencies
157
144
  @scheduler.merge_runtime_dependencies(runtime_deps)
158
145
 
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
146
+ with_display_lifecycle(root_task_class) do
147
+ # Create a new worker pool for clean operations
148
+ # Uses the same worker count as the run phase
149
+ @clean_worker_pool = WorkerPool.new(
150
+ registry: @registry,
151
+ worker_count: @effective_worker_count
152
+ ) { |task_class, wrapper| execute_clean_task(task_class, wrapper) }
164
153
 
165
- # Start progress display
166
- start_progress_display
154
+ # Start worker threads
155
+ @clean_worker_pool.start
167
156
 
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) }
157
+ # Enqueue tasks ready for clean (no reverse dependencies)
158
+ enqueue_ready_clean_tasks
174
159
 
175
- # Start worker threads
176
- @clean_worker_pool.start
160
+ # Main event loop - continues until all tasks are cleaned
161
+ run_clean_main_loop(root_task_class)
177
162
 
178
- # Enqueue tasks ready for clean (no reverse dependencies)
179
- enqueue_ready_clean_tasks
180
-
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
163
+ # Shutdown workers
164
+ @clean_worker_pool.shutdown
165
+ end
192
166
 
193
167
  # Raise aggregated errors if any clean tasks failed
194
168
  raise_if_any_clean_failures
@@ -210,7 +184,16 @@ module Taski
210
184
  @scheduler.mark_enqueued(task_class)
211
185
 
212
186
  wrapper = get_or_create_wrapper(task_class)
213
- return unless wrapper.mark_running
187
+ unless wrapper.mark_running
188
+ # Task is either already running or completed in another context (e.g., parent Executor)
189
+ # Wait for the task to complete if it's running elsewhere
190
+ wrapper.wait_for_completion
191
+
192
+ # Now mark it as completed in the scheduler and enqueue newly ready tasks
193
+ @scheduler.mark_completed(task_class)
194
+ enqueue_ready_tasks
195
+ return
196
+ end
214
197
 
215
198
  @execution_context.notify_task_registered(task_class)
216
199
  @execution_context.notify_task_started(task_class)
@@ -231,17 +214,7 @@ module Taski
231
214
  def execute_task(task_class, wrapper)
232
215
  return if @registry.abort_requested?
233
216
 
234
- output_capture = @execution_context.output_capture
235
-
236
- # Start capturing output for this task
237
- output_capture&.start_capture(task_class)
238
-
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)
243
-
244
- begin
217
+ with_task_context(task_class) do
245
218
  result = wrapper.task.run
246
219
  wrapper.mark_completed(result)
247
220
  @completion_queue.push({task_class: task_class, wrapper: wrapper})
@@ -252,13 +225,6 @@ module Taski
252
225
  rescue => e
253
226
  wrapper.mark_failed(e)
254
227
  @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
262
228
  end
263
229
  end
264
230
 
@@ -334,17 +300,7 @@ module Taski
334
300
  def execute_clean_task(task_class, wrapper)
335
301
  return if @registry.abort_requested?
336
302
 
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
303
+ with_task_context(task_class) do
348
304
  result = wrapper.task.clean
349
305
  wrapper.mark_clean_completed(result)
350
306
  @completion_queue.push({task_class: task_class, wrapper: wrapper, clean: true})
@@ -355,13 +311,6 @@ module Taski
355
311
  rescue => e
356
312
  wrapper.mark_clean_failed(e)
357
313
  @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
314
  end
366
315
  end
367
316
 
@@ -434,6 +383,44 @@ module Taski
434
383
  @execution_context.notify_stop
435
384
  end
436
385
 
386
+ # Execute a block with task-local context set up.
387
+ # Sets ExecutionContext.current, Taski.current_registry, and output capture.
388
+ # Cleans up all context in ensure block.
389
+ #
390
+ # @param task_class [Class] The task class being executed
391
+ # @yield The block to execute with context set up
392
+ def with_task_context(task_class)
393
+ output_capture = @execution_context.output_capture
394
+ output_capture&.start_capture(task_class)
395
+
396
+ ExecutionContext.current = @execution_context
397
+ Taski.set_current_registry(@registry)
398
+
399
+ yield
400
+ ensure
401
+ output_capture&.stop_capture
402
+ ExecutionContext.current = nil
403
+ Taski.clear_current_registry
404
+ end
405
+
406
+ # Execute a block with progress display and output capture lifecycle.
407
+ # Sets up progress display, output capture, starts display, then yields.
408
+ # Ensures proper cleanup even on interrupt.
409
+ #
410
+ # @param root_task_class [Class] The root task class
411
+ # @yield The block to execute
412
+ def with_display_lifecycle(root_task_class)
413
+ setup_progress_display(root_task_class)
414
+ should_teardown_capture = setup_output_capture_if_needed
415
+ start_progress_display
416
+
417
+ yield
418
+ ensure
419
+ stop_progress_display
420
+ @saved_output_capture = @execution_context.output_capture
421
+ teardown_output_capture if should_teardown_capture
422
+ end
423
+
437
424
  def create_default_execution_context
438
425
  context = ExecutionContext.new
439
426
  progress = Taski.progress_display
@@ -456,15 +443,34 @@ module Taski
456
443
  # TaskAbortException: raised directly (abort takes priority)
457
444
  # All other errors: raises AggregateError containing all failures
458
445
  def raise_if_any_failures
459
- failed = @registry.failed_wrappers
460
- return if failed.empty?
446
+ raise_if_any_failures_from(
447
+ @registry.failed_wrappers,
448
+ error_accessor: ->(w) { w.error }
449
+ )
450
+ end
451
+
452
+ # Raise error(s) if any tasks failed during clean execution
453
+ # TaskAbortException: raised directly (abort takes priority)
454
+ # All other errors: raises AggregateError containing all failures
455
+ def raise_if_any_clean_failures
456
+ raise_if_any_failures_from(
457
+ @registry.failed_clean_wrappers,
458
+ error_accessor: ->(w) { w.clean_error }
459
+ )
460
+ end
461
+
462
+ # Generic method to raise errors from failed wrappers
463
+ # @param failed_wrappers [Array<TaskWrapper>] Failed wrappers
464
+ # @param error_accessor [Proc] Lambda to extract error from wrapper
465
+ def raise_if_any_failures_from(failed_wrappers, error_accessor:)
466
+ return if failed_wrappers.empty?
461
467
 
462
468
  # 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
469
+ abort_wrapper = failed_wrappers.find { |w| error_accessor.call(w).is_a?(TaskAbortException) }
470
+ raise error_accessor.call(abort_wrapper) if abort_wrapper
465
471
 
466
472
  # Flatten nested AggregateErrors and deduplicate by original error object_id
467
- failures = flatten_failures(failed)
473
+ failures = flatten_failures_from(failed_wrappers, error_accessor: error_accessor)
468
474
  unique_failures = failures.uniq { |f| error_identity(f.error) }
469
475
 
470
476
  raise AggregateError.new(unique_failures)
@@ -472,14 +478,20 @@ module Taski
472
478
 
473
479
  # Flatten AggregateErrors into individual TaskFailure objects
474
480
  # Wraps original errors with task-specific Error class for rescue matching
475
- def flatten_failures(failed_wrappers)
481
+ # @param failed_wrappers [Array<TaskWrapper>] Failed wrappers
482
+ # @param error_accessor [Proc] Lambda to extract error from wrapper
483
+ def flatten_failures_from(failed_wrappers, error_accessor:)
484
+ output_capture = @saved_output_capture
485
+
476
486
  failed_wrappers.flat_map do |wrapper|
477
- case wrapper.error
487
+ error = error_accessor.call(wrapper)
488
+ case error
478
489
  when AggregateError
479
- wrapper.error.errors
490
+ error.errors
480
491
  else
481
- wrapped_error = wrap_with_task_error(wrapper.task.class, wrapper.error)
482
- [TaskFailure.new(task_class: wrapper.task.class, error: wrapped_error)]
492
+ wrapped_error = wrap_with_task_error(wrapper.task.class, error)
493
+ output_lines = output_capture&.recent_lines_for(wrapper.task.class) || []
494
+ [TaskFailure.new(task_class: wrapper.task.class, error: wrapped_error, output_lines: output_lines)]
483
495
  end
484
496
  end
485
497
  end
@@ -496,38 +508,6 @@ module Taski
496
508
  error_class.new(error, task_class: task_class)
497
509
  end
498
510
 
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
511
  # Returns a unique identifier for an error, used for deduplication
532
512
  # For TaskError, uses the wrapped cause's object_id
533
513
  def error_identity(error)