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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +50 -0
- data/README.md +40 -21
- data/docs/GUIDE.md +340 -0
- data/examples/README.md +65 -17
- data/examples/{context_demo.rb → args_demo.rb} +27 -27
- data/examples/clean_demo.rb +204 -0
- data/examples/group_demo.rb +113 -0
- data/examples/reexecution_demo.rb +93 -80
- data/examples/system_call_demo.rb +56 -0
- data/lib/taski/{context.rb → args.rb} +3 -3
- data/lib/taski/execution/execution_context.rb +379 -0
- data/lib/taski/execution/executor.rb +408 -117
- data/lib/taski/execution/registry.rb +17 -1
- data/lib/taski/execution/scheduler.rb +308 -0
- data/lib/taski/execution/task_output_pipe.rb +42 -0
- data/lib/taski/execution/task_output_router.rb +216 -0
- data/lib/taski/execution/task_wrapper.rb +210 -40
- data/lib/taski/execution/tree_progress_display.rb +385 -98
- data/lib/taski/execution/worker_pool.rb +104 -0
- data/lib/taski/section.rb +16 -3
- data/lib/taski/task.rb +222 -36
- data/lib/taski/version.rb +1 -1
- data/lib/taski.rb +138 -23
- data/sig/taski.rbs +207 -27
- metadata +13 -7
- data/docs/advanced-features.md +0 -625
- data/docs/api-guide.md +0 -509
- data/docs/error-handling.md +0 -684
- data/examples/section_progress_demo.rb +0 -78
|
@@ -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
|