taski 0.7.0 → 0.8.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +38 -0
- data/README.md +139 -9
- data/docs/GUIDE.md +54 -0
- data/examples/README.md +3 -3
- data/examples/args_demo.rb +21 -20
- data/examples/data_pipeline_demo.rb +1 -1
- data/examples/large_tree_demo.rb +519 -0
- data/examples/simple_progress_demo.rb +80 -0
- data/lib/taski/args.rb +2 -8
- data/lib/taski/env.rb +17 -0
- data/lib/taski/execution/base_progress_display.rb +348 -0
- data/lib/taski/execution/execution_context.rb +4 -0
- data/lib/taski/execution/executor.rb +111 -131
- data/lib/taski/execution/plain_progress_display.rb +76 -0
- data/lib/taski/execution/simple_progress_display.rb +173 -0
- data/lib/taski/execution/task_output_router.rb +91 -20
- data/lib/taski/execution/task_wrapper.rb +34 -31
- data/lib/taski/execution/tree_progress_display.rb +121 -271
- data/lib/taski/static_analysis/visitor.rb +3 -0
- data/lib/taski/task.rb +42 -30
- data/lib/taski/test_helper/errors.rb +13 -0
- data/lib/taski/test_helper/minitest.rb +38 -0
- data/lib/taski/test_helper/mock_registry.rb +53 -0
- data/lib/taski/test_helper/mock_wrapper.rb +46 -0
- data/lib/taski/test_helper/rspec.rb +38 -0
- data/lib/taski/test_helper.rb +246 -0
- data/lib/taski/version.rb +1 -1
- data/lib/taski.rb +119 -8
- metadata +14 -2
|
@@ -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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
-
|
|
132
|
-
|
|
117
|
+
# Main event loop - continues until root task completes
|
|
118
|
+
run_main_loop(root_task_class)
|
|
133
119
|
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
166
|
-
|
|
154
|
+
# Start worker threads
|
|
155
|
+
@clean_worker_pool.start
|
|
167
156
|
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
176
|
-
|
|
160
|
+
# Main event loop - continues until all tasks are cleaned
|
|
161
|
+
run_clean_main_loop(root_task_class)
|
|
177
162
|
|
|
178
|
-
|
|
179
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
460
|
-
|
|
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 =
|
|
464
|
-
raise 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 =
|
|
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
|
-
|
|
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
|
-
|
|
487
|
+
error = error_accessor.call(wrapper)
|
|
488
|
+
case error
|
|
478
489
|
when AggregateError
|
|
479
|
-
|
|
490
|
+
error.errors
|
|
480
491
|
else
|
|
481
|
-
wrapped_error = wrap_with_task_error(wrapper.task.class,
|
|
482
|
-
|
|
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)
|