taski 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,308 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Taski
4
+ module Execution
5
+ # Scheduler manages task dependency state and determines execution order.
6
+ # It tracks which tasks are pending, enqueued, or completed, and provides
7
+ # methods to determine which tasks are ready to execute.
8
+ #
9
+ # == Responsibilities
10
+ #
11
+ # - Build dependency graph from root task via static analysis
12
+ # - Track task states: pending, enqueued, completed
13
+ # - Determine which tasks are ready to execute (all dependencies completed)
14
+ # - Provide next_ready_tasks for the Executor's event loop
15
+ # - Build reverse dependency graph for clean operations
16
+ # - Track clean states independently from run states
17
+ # - Provide next_ready_clean_tasks for reverse dependency order execution
18
+ #
19
+ # == API
20
+ #
21
+ # Run operations:
22
+ # - {#build_dependency_graph} - Initialize dependency graph from root task
23
+ # - {#next_ready_tasks} - Get tasks ready for execution
24
+ # - {#mark_enqueued} - Mark task as sent to worker pool
25
+ # - {#mark_completed} - Mark task as finished
26
+ # - {#completed?} - Check if task is completed
27
+ # - {#running_tasks?} - Check if any tasks are currently executing
28
+ #
29
+ # Clean operations:
30
+ # - {#build_reverse_dependency_graph} - Build reverse graph for clean order
31
+ # - {#next_ready_clean_tasks} - Get tasks ready for clean (reverse order)
32
+ # - {#mark_clean_enqueued} - Mark task as sent for clean
33
+ # - {#mark_clean_completed} - Mark task as clean finished
34
+ # - {#clean_completed?} - Check if task clean is completed
35
+ # - {#running_clean_tasks?} - Check if any clean tasks are currently executing
36
+ #
37
+ # == Thread Safety
38
+ #
39
+ # Scheduler is only accessed from the main thread in Executor,
40
+ # so no synchronization is needed. The Executor serializes all
41
+ # access to the Scheduler through its event loop.
42
+ class Scheduler
43
+ # Task execution states
44
+ STATE_PENDING = :pending
45
+ STATE_ENQUEUED = :enqueued
46
+ STATE_COMPLETED = :completed
47
+
48
+ # Clean execution states (independent from run states)
49
+ CLEAN_STATE_PENDING = :clean_pending
50
+ CLEAN_STATE_ENQUEUED = :clean_enqueued
51
+ CLEAN_STATE_COMPLETED = :clean_completed
52
+
53
+ ##
54
+ # Initializes internal data structures used to track normal and clean task execution.
55
+ #
56
+ # Sets up:
57
+ # - @dependencies: map from task class to its dependency task classes.
58
+ # - @task_states: map from task class to its normal execution state.
59
+ # - @completed_tasks: set of task classes that have completed normal execution.
60
+ # - @reverse_dependencies: map from task class to dependent task classes (used for clean ordering).
61
+ # - @clean_task_states: map from task class to its clean execution state.
62
+ # - @clean_completed_tasks: set of task classes that have completed clean execution.
63
+ def initialize
64
+ # Run execution state
65
+ @dependencies = {}
66
+ @task_states = {}
67
+ @completed_tasks = Set.new
68
+
69
+ # Clean execution state (independent tracking)
70
+ @reverse_dependencies = {}
71
+ @clean_task_states = {}
72
+ @clean_completed_tasks = Set.new
73
+ end
74
+
75
+ # Build dependency graph by traversing from root task.
76
+ # Populates internal state with all tasks and their dependencies.
77
+ #
78
+ # @param root_task_class [Class] The root task class to start from
79
+ def build_dependency_graph(root_task_class)
80
+ # @type var queue: Array[singleton(Taski::Task)]
81
+ queue = [root_task_class]
82
+
83
+ while (task_class = queue.shift)
84
+ next if @task_states.key?(task_class)
85
+
86
+ deps = task_class.cached_dependencies
87
+ @dependencies[task_class] = deps.dup
88
+ @task_states[task_class] = STATE_PENDING
89
+
90
+ deps.each { |dep| queue << dep }
91
+ end
92
+ end
93
+
94
+ # Get all tasks that are ready to execute.
95
+ # A task is ready when all its dependencies are completed.
96
+ #
97
+ # @return [Array<Class>] Array of task classes ready for execution
98
+ def next_ready_tasks
99
+ ready = []
100
+ @task_states.each_key do |task_class|
101
+ next unless @task_states[task_class] == STATE_PENDING
102
+ next unless ready_to_execute?(task_class)
103
+ ready << task_class
104
+ end
105
+ ready
106
+ end
107
+
108
+ # Mark a task as enqueued for execution.
109
+ #
110
+ # @param task_class [Class] The task class to mark
111
+ def mark_enqueued(task_class)
112
+ @task_states[task_class] = STATE_ENQUEUED
113
+ end
114
+
115
+ # Mark a task as completed.
116
+ #
117
+ # @param task_class [Class] The task class to mark
118
+ def mark_completed(task_class)
119
+ @task_states[task_class] = STATE_COMPLETED
120
+ @completed_tasks.add(task_class)
121
+ end
122
+
123
+ # Check if a task is completed.
124
+ #
125
+ # @param task_class [Class] The task class to check
126
+ # @return [Boolean] true if the task is completed
127
+ def completed?(task_class)
128
+ @completed_tasks.include?(task_class)
129
+ end
130
+
131
+ # Check if there are any running (enqueued) tasks.
132
+ #
133
+ ##
134
+ # Indicates whether any tasks are currently enqueued for execution.
135
+ # @return [Boolean] `true` if any task is enqueued, `false` otherwise.
136
+ def running_tasks?
137
+ @task_states.values.any? { |state| state == STATE_ENQUEUED }
138
+ end
139
+
140
+ # ========================================
141
+ # Runtime Dependency Merging
142
+ # ========================================
143
+
144
+ # Merge runtime dependencies into the dependency graph.
145
+ # Used to incorporate dynamically selected dependencies (e.g., Section implementations)
146
+ # that were determined during the run phase.
147
+ #
148
+ # This method also updates reverse dependencies if they exist (for clean operations).
149
+ # This method is idempotent - calling it multiple times with the same data is safe.
150
+ #
151
+ # @param runtime_deps [Hash{Class => Set<Class>}] Runtime dependencies from ExecutionContext
152
+ def merge_runtime_dependencies(runtime_deps)
153
+ runtime_deps.each do |from_class, to_classes|
154
+ # Ensure the from_class exists in the graph
155
+ @dependencies[from_class] ||= Set.new
156
+ @task_states[from_class] ||= STATE_PENDING
157
+
158
+ to_classes.each do |to_class|
159
+ # Add the dependency relationship
160
+ @dependencies[from_class].add(to_class)
161
+
162
+ # Add the to_class to the graph if not present
163
+ unless @dependencies.key?(to_class)
164
+ @dependencies[to_class] = to_class.cached_dependencies
165
+ @task_states[to_class] = STATE_PENDING
166
+ end
167
+
168
+ # Update reverse dependencies if they exist (for clean operations)
169
+ # If A depends on B (from_class→to_class), then B→[A] in reverse graph
170
+ if @reverse_dependencies.any?
171
+ @reverse_dependencies[to_class] ||= Set.new
172
+ @reverse_dependencies[to_class].add(from_class)
173
+
174
+ # Ensure to_class has clean state initialized
175
+ @clean_task_states[to_class] ||= CLEAN_STATE_PENDING
176
+ end
177
+ end
178
+ end
179
+ end
180
+
181
+ # ========================================
182
+ # Clean Operations (Reverse Dependency Order)
183
+ # ========================================
184
+
185
+ # Build reverse dependency graph for clean operations.
186
+ # Clean operations run in reverse order: if A depends on B, then B must
187
+ # be cleaned after A (so A→[B] in reverse graph means B depends on A's clean).
188
+ #
189
+ # Also initializes clean states for all tasks to CLEAN_STATE_PENDING.
190
+ #
191
+ ##
192
+ # Builds the reverse dependency graph and initializes per-task clean execution state starting from the given root task.
193
+ #
194
+ # Ensures the forward dependency graph is present, clears prior clean-state data, initializes each discovered task with
195
+ # an empty reverse-dependency set and `CLEAN_STATE_PENDING`, and populates reverse mappings so a task's clean run
196
+ # depends on the clean completion of tasks that depend on it.
197
+ # @param [Class] root_task_class The root task class from which to discover tasks and their dependencies.
198
+ def build_reverse_dependency_graph(root_task_class)
199
+ # First, ensure we have the forward dependency graph
200
+ build_dependency_graph(root_task_class) if @dependencies.empty?
201
+
202
+ # Clear previous clean state
203
+ @reverse_dependencies.clear
204
+ @clean_task_states.clear
205
+ @clean_completed_tasks.clear
206
+
207
+ # Initialize all tasks with empty reverse dependency sets
208
+ @dependencies.each_key do |task_class|
209
+ @reverse_dependencies[task_class] = Set.new
210
+ @clean_task_states[task_class] = CLEAN_STATE_PENDING
211
+ end
212
+
213
+ # Build reverse mappings: if A depends on B, then B→[A] in reverse graph
214
+ # This means B's clean depends on A's clean completing first
215
+ @dependencies.each do |task_class, deps|
216
+ deps.each do |dep_class|
217
+ @reverse_dependencies[dep_class].add(task_class)
218
+ end
219
+ end
220
+ end
221
+
222
+ # Get all tasks that are ready to clean.
223
+ # A task is ready to clean when all its reverse dependencies (dependents)
224
+ # have completed their clean operation.
225
+ #
226
+ ##
227
+ # Lists task classes that are ready for clean execution.
228
+ # A task is considered ready when its clean state is `CLEAN_STATE_PENDING` and all of its reverse dependencies have completed their clean execution.
229
+ # @return [Array<Class>] Array of task classes ready for clean execution.
230
+ def next_ready_clean_tasks
231
+ ready = []
232
+ @clean_task_states.each_key do |task_class|
233
+ next unless @clean_task_states[task_class] == CLEAN_STATE_PENDING
234
+ next unless ready_to_clean?(task_class)
235
+ ready << task_class
236
+ end
237
+ ready
238
+ end
239
+
240
+ # Mark a task as enqueued for clean execution.
241
+ #
242
+ ##
243
+ # Marks the given task class as enqueued for clean execution.
244
+ # @param [Class] task_class The task class to mark as enqueued for clean execution.
245
+ def mark_clean_enqueued(task_class)
246
+ @clean_task_states[task_class] = CLEAN_STATE_ENQUEUED
247
+ end
248
+
249
+ # Mark a task as clean completed.
250
+ #
251
+ ##
252
+ # Marks the clean execution of the given task class as completed and records it.
253
+ # @param [Class] task_class - The task class to mark as clean completed.
254
+ def mark_clean_completed(task_class)
255
+ @clean_task_states[task_class] = CLEAN_STATE_COMPLETED
256
+ @clean_completed_tasks.add(task_class)
257
+ end
258
+
259
+ # Check if a task's clean is completed.
260
+ #
261
+ # @param task_class [Class] The task class to check
262
+ ##
263
+ # Checks whether a task class has completed its clean execution.
264
+ # @param [Class] task_class - The task class to check.
265
+ # @return [Boolean] `true` if the task's clean is completed, `false` otherwise.
266
+ def clean_completed?(task_class)
267
+ @clean_completed_tasks.include?(task_class)
268
+ end
269
+
270
+ # Check if there are any running (enqueued) clean tasks.
271
+ #
272
+ ##
273
+ # Indicates whether any clean tasks are currently enqueued for execution.
274
+ # @return [Boolean] `true` if at least one clean task is enqueued, `false` otherwise.
275
+ def running_clean_tasks?
276
+ @clean_task_states.values.any? { |state| state == CLEAN_STATE_ENQUEUED }
277
+ end
278
+
279
+ private
280
+
281
+ # Check if a task is ready to execute (all dependencies completed).
282
+ #
283
+ # @param task_class [Class] The task class to check
284
+ ##
285
+ # Determines whether a task's dependencies have all completed.
286
+ # @param [Class] task_class - The task class to check.
287
+ # @return [Boolean] `true` if every dependency of `task_class` is in the set of completed tasks, `false` otherwise.
288
+ def ready_to_execute?(task_class)
289
+ task_deps = @dependencies[task_class] || Set.new
290
+ task_deps.subset?(@completed_tasks)
291
+ end
292
+
293
+ # Check if a task is ready to clean (all reverse dependencies completed).
294
+ # Reverse dependencies are tasks that depend on this task, which must
295
+ # be cleaned before this task can be cleaned.
296
+ #
297
+ # @param task_class [Class] The task class to check
298
+ ##
299
+ # Determines whether a task is ready for clean execution.
300
+ # @param [Class] task_class - The task class to check.
301
+ # @return [Boolean] `true` if all tasks that depend on `task_class` have completed their clean execution, `false` otherwise.
302
+ def ready_to_clean?(task_class)
303
+ reverse_deps = @reverse_dependencies[task_class] || Set.new
304
+ reverse_deps.subset?(@clean_completed_tasks)
305
+ end
306
+ end
307
+ end
308
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Taski
4
+ module Execution
5
+ # Manages a single IO pipe for capturing task output
6
+ # Each task gets its own dedicated pipe for stdout capture
7
+ class TaskOutputPipe
8
+ attr_reader :read_io, :write_io, :task_class
9
+
10
+ def initialize(task_class)
11
+ @task_class = task_class
12
+ @read_io, @write_io = IO.pipe
13
+ @write_io.sync = true
14
+ end
15
+
16
+ def close_write
17
+ @write_io.close unless @write_io.closed?
18
+ end
19
+
20
+ def close_read
21
+ @read_io.close unless @read_io.closed?
22
+ end
23
+
24
+ def close
25
+ close_write
26
+ close_read
27
+ end
28
+
29
+ def write_closed?
30
+ @write_io.closed?
31
+ end
32
+
33
+ def read_closed?
34
+ @read_io.closed?
35
+ end
36
+
37
+ def closed?
38
+ write_closed? && read_closed?
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,216 @@
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
+ READ_BUFFER_SIZE = 4096
22
+
23
+ def initialize(original_stdout)
24
+ super()
25
+ @original = original_stdout
26
+ @pipes = {} # task_class => TaskOutputPipe
27
+ @thread_map = {} # Thread => task_class
28
+ @last_lines = {} # task_class => String
29
+ end
30
+
31
+ # Start capturing output for the current thread
32
+ # Creates a new pipe for the task and registers the thread mapping
33
+ # @param task_class [Class] The task class being executed
34
+ def start_capture(task_class)
35
+ synchronize do
36
+ pipe = TaskOutputPipe.new(task_class)
37
+ @pipes[task_class] = pipe
38
+ @thread_map[Thread.current] = task_class
39
+ debug_log("Started capture for #{task_class} on thread #{Thread.current.object_id}")
40
+ end
41
+ end
42
+
43
+ # Stop capturing output for the current thread
44
+ # Closes the write end of the pipe
45
+ def stop_capture
46
+ synchronize do
47
+ task_class = @thread_map.delete(Thread.current)
48
+ unless task_class
49
+ debug_log("Warning: stop_capture called for unregistered thread #{Thread.current.object_id}")
50
+ return
51
+ end
52
+
53
+ pipe = @pipes[task_class]
54
+ pipe&.close_write
55
+ debug_log("Stopped capture for #{task_class} on thread #{Thread.current.object_id}")
56
+ end
57
+ end
58
+
59
+ # Poll all open pipes for available data
60
+ # Should be called periodically from the display thread
61
+ def poll
62
+ readable_pipes = synchronize do
63
+ @pipes.values.reject { |p| p.read_closed? }.map(&:read_io)
64
+ end
65
+ return if readable_pipes.empty?
66
+
67
+ ready, _, _ = IO.select(readable_pipes, nil, nil, POLL_TIMEOUT)
68
+ return unless ready
69
+
70
+ ready.each do |read_io|
71
+ pipe = synchronize { @pipes.values.find { |p| p.read_io == read_io } }
72
+ next unless pipe
73
+
74
+ read_from_pipe(pipe)
75
+ end
76
+ end
77
+
78
+ # Get the last output line for a task
79
+ # @param task_class [Class] The task class
80
+ # @return [String, nil] The last output line
81
+ def last_line_for(task_class)
82
+ synchronize { @last_lines[task_class] }
83
+ end
84
+
85
+ # Close all pipes and clean up
86
+ def close_all
87
+ synchronize do
88
+ @pipes.each_value(&:close)
89
+ @pipes.clear
90
+ @thread_map.clear
91
+ end
92
+ end
93
+
94
+ # Check if there are any active (not fully closed) pipes
95
+ # @return [Boolean] true if there are active pipes
96
+ def active?
97
+ synchronize do
98
+ @pipes.values.any? { |p| !p.read_closed? }
99
+ end
100
+ end
101
+
102
+ # IO interface methods - route to pipe when capturing, otherwise pass through
103
+
104
+ def write(str)
105
+ pipe = current_thread_pipe
106
+ if pipe && !pipe.write_closed?
107
+ pipe.write_io.write(str)
108
+ else
109
+ # Fallback to original stdout - log why capture failed
110
+ if @thread_map.empty?
111
+ debug_log("Output not captured: no threads registered")
112
+ else
113
+ debug_log("Output not captured: thread #{Thread.current.object_id} not mapped")
114
+ end
115
+ @original.write(str)
116
+ end
117
+ end
118
+
119
+ def puts(*args)
120
+ if args.empty?
121
+ write("\n")
122
+ else
123
+ args.each do |arg|
124
+ str = arg.to_s
125
+ write(str)
126
+ write("\n") unless str.end_with?("\n")
127
+ end
128
+ end
129
+ nil
130
+ end
131
+
132
+ def print(*args)
133
+ args.each { |arg| write(arg.to_s) }
134
+ nil
135
+ end
136
+
137
+ def <<(str)
138
+ write(str.to_s)
139
+ self
140
+ end
141
+
142
+ def flush
143
+ @original.flush
144
+ end
145
+
146
+ def tty?
147
+ @original.tty?
148
+ end
149
+
150
+ def isatty
151
+ @original.isatty
152
+ end
153
+
154
+ def winsize
155
+ @original.winsize
156
+ end
157
+
158
+ # Get the write IO for the current thread's pipe
159
+ # Used by Task#system to redirect subprocess output directly to the pipe
160
+ # @return [IO, nil] The write IO or nil if not capturing
161
+ def current_write_io
162
+ synchronize do
163
+ task_class = @thread_map[Thread.current]
164
+ return nil unless task_class
165
+ pipe = @pipes[task_class]
166
+ return nil if pipe.nil? || pipe.write_closed?
167
+ pipe.write_io
168
+ end
169
+ end
170
+
171
+ # Delegate unknown methods to original stdout
172
+ def method_missing(method, ...)
173
+ @original.send(method, ...)
174
+ end
175
+
176
+ def respond_to_missing?(method, include_private = false)
177
+ @original.respond_to?(method, include_private)
178
+ end
179
+
180
+ private
181
+
182
+ def current_thread_pipe
183
+ synchronize do
184
+ task_class = @thread_map[Thread.current]
185
+ return nil unless task_class
186
+ @pipes[task_class]
187
+ end
188
+ end
189
+
190
+ def read_from_pipe(pipe)
191
+ data = pipe.read_io.read_nonblock(READ_BUFFER_SIZE)
192
+ update_last_line(pipe.task_class, data)
193
+ rescue IO::WaitReadable
194
+ # No data available yet
195
+ rescue EOFError
196
+ # Pipe closed by writer, close read end
197
+ synchronize { pipe.close_read }
198
+ end
199
+
200
+ def update_last_line(task_class, data)
201
+ return if data.nil? || data.empty?
202
+
203
+ lines = data.lines
204
+ last_non_empty = lines.reverse.find { |l| !l.strip.empty? }
205
+ synchronize do
206
+ @last_lines[task_class] = last_non_empty&.strip if last_non_empty
207
+ end
208
+ end
209
+
210
+ def debug_log(message)
211
+ return unless ENV["TASKI_DEBUG"]
212
+ @original.puts "[TaskOutputRouter] #{message}"
213
+ end
214
+ end
215
+ end
216
+ end