This guide provides detailed documentation beyond the basics covered in the README.
Taski provides comprehensive error handling for parallel task execution.
| Exception | Purpose |
|---|---|
Taski::AggregateError |
Multiple tasks failed during parallel execution |
Taski::TaskError |
Base class for task-specific errors |
Taski::TaskAbortException |
Intentional abort (stops all tasks immediately) |
Taski::CircularDependencyError |
Circular dependency detected |
TaskClass::Error |
Auto-generated error class for each Task subclass |
When multiple tasks fail during parallel execution, errors are collected into an AggregateError:
class DatabaseTask < Taski::Task
exports :connection
def run
raise "Database connection failed"
end
end
class CacheTask < Taski::Task
exports :redis_client
def run
raise "Cache connection failed"
end
end
class AppTask < Taski::Task
def run
db = DatabaseTask.connection
cache = CacheTask.redis_client
end
end
begin
AppTask.run
rescue Taski::AggregateError => e
puts "#{e.errors.size} tasks failed:"
e.errors.each do |failure|
puts " - #{failure.task_class.name}: #{failure.error.message}"
end
end
# Output:
# 2 tasks failed:
# - DatabaseTask: Database connection failed
# - CacheTask: Cache connection failedEach Task subclass automatically gets an ::Error class for targeted rescue:
class DatabaseTask < Taski::Task
exports :connection
def run
raise "Connection failed"
end
end
# Rescue errors from a specific task
begin
AppTask.run
rescue DatabaseTask::Error => e
puts "Database task failed: #{e.message}"
# e is the AggregateError that contained the DatabaseTask::Error;
# e.cause returns that DatabaseTask::Error (whose #task_class is
# DatabaseTask, and whose #cause is the original exception)
endThis works transparently with AggregateError — when you rescue DatabaseTask::Error, it matches an AggregateError that contains a DatabaseTask::Error.
How this works: Ruby's rescue uses the === operator to match exceptions. Taski's AggregateAware module (extended by TaskError and all TaskClass::Error classes) overrides === to check whether an AggregateError contains an error of that type. This means rescue Taski::TaskError will match an AggregateError wrapping TaskError instances, even though AggregateError does not inherit from TaskError.
Use TaskAbortException to immediately stop all task execution:
class CriticalTask < Taski::Task
def run
if critical_condition_met?
raise Taski::TaskAbortException, "Critical error - aborting"
end
end
endTaskAbortException takes priority over regular errors. Already running tasks will complete, but no new tasks will start.
# 1. Handle errors within the task when recovery is possible
class ResilientTask < Taski::Task
exports :data
def run
@data = fetch_from_primary
rescue Timeout::Error
@data = fetch_from_fallback
end
end
# 2. Use task-specific errors for clarity
begin
AppTask.run
rescue DatabaseTask::Error => e
handle_database_failure(e)
rescue CacheTask::Error => e
handle_cache_failure(e)
end
# 3. Fail fast with clear messages
class ValidatingTask < Taski::Task
def run
missing = %w[DATABASE_URL API_KEY].select { |v| ENV[v].nil? }
raise "Missing: #{missing.join(', ')}" if missing.any?
end
endTaski supports resource cleanup with run_and_clean, which executes the run phase followed by the clean phase in a single operation.
class DatabaseSetup < Taski::Task
exports :connection
def run
@connection = "postgresql://localhost:5432/myapp"
puts "Database connected"
end
def clean
puts "Database disconnected"
end
end
class WebServer < Taski::Task
def run
puts "Server started with #{DatabaseSetup.connection}"
end
def clean
puts "Server stopped"
end
end
# Run then clean in one call
WebServer.run_and_clean
# => Database connected
# => Server started
# => Server stopped
# => Database disconnectedBy default, clean is skipped when the run phase fails — the failure propagates immediately. Pass clean_on_failure: true to release resources (temp files, connections) even when run fails:
WebServer.run_and_clean(clean_on_failure: true)If clean itself fails after a successful run, the clean error propagates; if both phases fail, the original run error takes precedence and the clean failure is logged.
Use a block to execute code between run and clean phases. This is useful when you need to use exported values before cleanup:
DatabaseSetup.run_and_clean do
# Exported values are accessible here
deploy(DatabaseSetup.connection)
end
# Clean runs automatically after the blockClean methods should be safe to call multiple times:
class SafeFileTask < Taski::Task
exports :data_file
def run
@data_file = '/tmp/data.txt'
File.write(@data_file, 'data')
end
def clean
# Check before delete
if @data_file && File.exist?(@data_file)
File.delete(@data_file)
end
end
endTaski provides real-time progress visualization during task execution.
- Spinner Animation: Animated spinner during execution
- Output Capture: Real-time display of task output (last line)
- Status Indicators: Success/failure icons with execution time
- Group Blocks: Organize output messages into logical phases
- TTY Detection: Clean output when redirected to files
Use group blocks to organize output within a task into logical phases. The current group name is displayed alongside the task's output in the progress display.
class DeployTask < Taski::Task
def run
group("Preparing environment") do
puts "Checking dependencies..."
puts "Validating config..."
end
group("Building application") do
puts "Compiling source..."
puts "Running tests..."
end
group("Deploying") do
puts "Uploading files..."
puts "Restarting server..."
end
end
endProgress display output:
During execution:
⠋ DeployTask (Task) | Deploying: Uploading files...
After completion:
✓ DeployTask (Task) 520ms
The group name appears as a prefix to the output message: | GroupName: output...
Groups are useful for:
- Logical organization: Group related operations together
- Progress visibility: See which phase is currently executing
- Error context: Know which phase failed when errors occur
During execution:
WebServer (Task)
├── Config (Task) ...
│ ├── Database (Task) 45.2ms
│ └── Cache (Task) ...
└── Server (Task)
After completion:
WebServer (Task) 120.5ms
├── Config (Task) 50.3ms
│ ├── Database (Task) 45.2ms
│ └── Cache (Task) 48.1ms
└── Server (Task) 70.2ms
Taski supports three progress display modes:
Compact single-line display showing current progress:
⠹ [3/5] DeployTask | Uploading files...
✓ [5/5] All tasks completed (1234ms)
Format: [spinner] [completed/total] TaskName | last output...
When multiple tasks run in parallel:
⠹ [2/5] DownloadLayer1, DownloadLayer2 | Downloading...
On failure:
✗ [3/5] DeployTask failed: Connection refused
Full dependency tree visualization with status for each task:
WebServer (Task)
├── ⠋ Config (Task) | Reading config.yml...
│ ├── ✅ Database (Task) 45.2ms
│ └── ⠙ Cache (Task) | Connecting...
└── ◻ Server (Task)
Plain text output without escape codes, designed for CI/logs:
[START] DatabaseSetup
[DONE] DatabaseSetup (45.2ms)
[START] WebServer
[DONE] WebServer (120.5ms)
[TASKI] Completed: 2/2 tasks (165ms)
# Choose the layout — assign the kind; the right variant and output are built for you:
Taski.progress.layout = Taski::Progress::Layout::Simple # Simple display (default)
Taski.progress.layout = Taski::Progress::Layout::Tree # Tree display (Live on a TTY, Event in logs/CI)
Taski.progress.layout = Taski::Progress::Layout::Log # Log output (CI/logs)
Taski.progress_display = nil # Disable progress output entirelyThe layout decides what gets rendered when; the theme decides how each line looks. There are two ways to customize a theme: a Ruby class (full power) or a YAML data theme (no code — safe to share and use without reading).
Subclass Taski::Progress::Theme::Base (or a shipped theme: Default,
Detail, Compact, Plain) and override any of the 15 template methods.
Each receives immutable render data and returns the final display string —
plain Ruby, so conditionals, loops, and arbitrary formatting are all
available:
class MyTheme < Taski::Progress::Theme::Detail
def spinner_frames = %w[🌑 🌒 🌓 🌔 🌕 🌖 🌗 🌘]
def icon_success = "🎉"
# Highlight slow tasks in red
def task_success(task:, execution: nil)
part = (task.duration && task.duration >= 1000) ? red(" (#{format_duration(task.duration)})") : duration_part(task.duration)
"#{icon_for(task.state)} #{short_name(task.name)}#{part}"
end
end
Taski.progress.theme = MyThemeTask-level methods (task_pending/start/success/fail/skip,
clean_start/success/fail, group_start/success/fail) have the signature
def m(task:, execution: nil); execution-level methods (execution_start/ running/complete/fail) have def m(execution:, task: nil). The layout
always passes both keywords.
task is a TaskInfo (name, state, duration, error_message,
group_name, stdout); execution is an ExecutionInfo (state, count
tallies, total_duration, root_task_name, task_names, spinner_index).
Helpers available in every theme: short_name, icon_for(state),
spinner_frame(index), colorize/red/green/yellow/dim,
truncate_text/truncate_list, format_count/format_duration, and the
fragment helpers duration_part/error_part/stdout_part/task_names_part
(render " (1.2s)"-style suffixes only when the value is present).
A buggy theme cannot break a run: any exception from a theme method is
contained by the renderer — the line renders empty and a
template.render_error warning is logged. Themes must be stateless (one
instance is shared across threads).
A data theme is an inert YAML file — no code runs, so you can use themes from others without auditing them:
# my-theme.yml
schema: 1
extends: detail # base | default | plain | detail | compact
colors:
green: "\e[38;2;166;227;161m" # truecolor works
reset: "\e[0m"
icons:
success: "✔"
templates:
task_success: "%{icon} %{name}%{duration_part}"
execution_running: "%{spinner} [%{done_count}/%{total_count}]%{task_names_part}"Taski.progress.theme = "my-theme.yml" # validated here, at assignmentThe loaded theme is a real subclass of the extends theme: anything not
declared falls back to that theme's rendering, with your declared colors,
icons, spinner frames, formats and truncation still applied — the same
semantics as overriding methods in a Ruby subclass. A colors-only theme
therefore restyles every line.
The file is validated fully at load (Taski::Progress::Theme::LoadError
lists the offending key): unknown keys, unknown placeholders, type errors,
and stray % all fail at assignment, never mid-run. Write a literal %
as %%. Note the trust boundary precisely: a data theme cannot execute
code, but color values are raw escape strings, so a theme does control
what your terminal is told to display.
Available placeholders (the names match the Ruby helper vocabulary):
| Placeholder | Meaning |
|---|---|
%{name} / %{full_name} |
task class name, short / fully qualified |
%{state} |
task state (running, completed, ...) |
%{duration} / %{duration_part} |
formatted duration / " (1.2s)" only when present |
%{error_message} / %{error_part} |
error text / ": msg" only when present |
%{group_name} |
current group name |
%{stdout} / %{stdout_part} |
last output line (truncated) / " | line" only when present |
%{root_task_name} |
root task short name |
%{done_count} %{total_count} %{failed_count} %{completed_count} %{skipped_count} %{pending_count} |
tallies (through formats.count) |
%{total_duration} |
execution duration, formatted |
%{task_names} / %{task_names_part} |
active task list (limited) / leading-space variant |
%{spinner} / %{icon} |
current spinner frame / state-colored icon |
%{green} %{red} %{yellow} %{dim} %{reset} |
raw color codes |
Other sections: spinner: (frames:, interval:), render_interval:,
formats: (count:, duration_ms:, duration_s:), truncation:
(list_limit:, list_separator:, list_suffix:, text_max:,
text_suffix:), and fragments: to restyle the *_part wrappers (e.g.
duration_part: " %{dim}(%{duration})%{reset}" — the only-when-present
logic stays in the engine and is not overridable).
Data themes are deliberately logic-free. If a theme needs real control flow, write a Ruby theme — that's the upgrade path, with the same vocabulary.
See examples/themes/catppuccin-mocha.yml for a complete truecolor example.
When output is redirected, interactive spinners are automatically disabled:
ruby build.rb > build.log 2>&1When a task accesses a dependency's exported value (e.g., DepTask.value), Taski may return a lightweight proxy object instead of the actual value. This proxy defers dependency resolution until you call a method on it, at which point it transparently resolves the real value and forwards the method call.
class FetchData < Taski::Task
exports :data
def run
@data = expensive_api_call
end
end
class ProcessData < Taski::Task
exports :result
def run
raw = FetchData.data # May return a proxy (no blocking yet)
setup_environment # Task continues while FetchData runs
@result = raw.transform # Proxy resolves here — blocks if needed
end
endFrom the user's perspective, the proxy is completely transparent — it behaves exactly like the real value.
Proxy-based resolution enables better parallelism. A task can continue executing setup logic while its dependencies are still running, only blocking when the dependency value is actually used. This can significantly reduce total execution time when tasks have independent setup work before they need their dependencies.
Taski uses static analysis (Prism AST parsing) to determine when proxy resolution is safe. Dependencies used in positions where the proxy could cause issues — such as conditions (if dep_value), method arguments, or other contexts where truthiness or identity matters — are automatically resolved synchronously instead of returning a proxy.
You do not need to think about this in normal usage. The static analyzer examines your task's run method and only enables proxy resolution for dependency accesses that are confirmed safe (e.g., simple assignments like x = Dep.value followed by method calls on x).
require "logger"
Taski.logger = Logger.new($stdout, level: Logger::DEBUG)Set Taski.logger to a Ruby Logger instance to enable structured logging of execution events.
puts MyTask.tree
# MyTask (Task)
# ├── DatabaseTask (Task)
# └── CacheTask (Task)
# └── ConfigTask (Task)Circular Dependencies
# Detected before execution
begin
TaskA.run
rescue Taski::CircularDependencyError => e
puts e.cyclic_tasks # [[TaskA, TaskB]]
endStatic Analysis Requirements
Tasks must be defined in source files (not dynamically with Class.new) because static analysis uses Prism AST parsing which requires actual source files. Static analysis is used for dependency tree visualization, circular dependency detection, and optimizing dependency resolution (determining when lazy proxy resolution is safe vs. when synchronous resolution is required).