Skip to content

Design: Task lifecycle events, states, and observer notification system #147

Description

@ahogappa

Summary

Define Taski's event and state model clearly to provide a consistent observer notification system for progress displays, logging, and user hooks.

Prerequisites: This issue assumes the following are already completed:


State Diagrams

Run Phase

stateDiagram-v2
    [*] --> pending

    pending --> running : task starts
    pending --> skipped : not executed

    running --> completed : success
    running --> failed : error

    completed --> [*]
    failed --> [*]
    skipped --> [*]
Loading

Clean Phase

stateDiagram-v2
    [*] --> pending

    pending --> running : clean starts

    running --> completed : success
    running --> failed : error

    completed --> [*]
    failed --> [*]
Loading

Unified State Set

Both Run and Clean phases use the same state names:

State Description
pending Initial state, waiting to be executed
running Task is currently executing
completed Task finished successfully
failed Task finished with error
skipped Task was not executed (Run phase only)

skipped Reasons

The skipped state means "this task was not executed." The reason can be determined by observers via Pull (checking dependency graph and dependency states):

  • Not accessed at runtime: Static analysis detected the task as a possible dependency, but the runtime code path never accessed it (e.g., if branch not taken, rescue block not entered). This is a natural result of Fiber-based lazy resolution — only actually accessed dependencies are executed.
  • Dependency failed: A task this depends on failed, so this task could not run

This keeps skipped as a simple factual statement ("not executed") rather than encoding the reason in the state.

Removed States

  • enqueued / clean_enqueued: Removed. The current Scheduler uses enqueued as an intermediate state, but running is sufficient. Once a task begins execution, it is considered running.

Clean State Unification

The current clean states (:cleaning, :clean_completed, :clean_failed) are unified to use the same names as run states (:running, :completed, :failed). The phase context (:run or :clean) distinguishes which phase the state belongs to.

Clean State Special Case

  • nil: Tasks that were skipped or failed in Run phase don't have a Clean phase

Push/Pull Pattern

Push (Notification)

Notify identifiers, state transition, phase, and timestamp:

notify_task_updated(task_class, previous_state:, current_state:, phase:, timestamp:)

All transient/mutable information is pushed. The facade itself holds zero mutable domain state.

Pull (Query)

Any component that needs execution-related information pulls immutable data from ExecutionFacade:

facade.root_task_class         # Root task (immutable)
facade.dependency_graph        # DependencyGraph (immutable, single source for all dependency/tree data)
facade.output_stream.read(task_class, limit: nil)  # Captured output (accumulating)

ExecutionFacade is the single pull target — observers, Executor, and any other component that needs execution data should pull from here.

Design Principle: Stateless Facade

The facade holds no mutable domain state. The split is:

Type Where Examples
Mutable/transient Push (in notifications) phase, previous_state, current_state, timestamp
Immutable Pull (from facade) dependency_graph, root_task_class
Accumulating Pull (from facade) output_stream

Key Decisions

  1. previous_state in notifications: Allows observers to determine state transitions directly

    • pending→running: Task started
    • pending→skipped: Task not executed (not accessed at runtime, or dependency failed)
    • running→completed: Success
    • running→failed: Error
  2. phase in notifications: Included in every task_updated so observers always know which phase a state transition belongs to, without needing to track phase state themselves. Eliminates facade.current_phase as mutable state.

  3. state_for is unnecessary: State is pushed via notification

  4. No error in notifications: Error details (message, backtrace) are logged via a direct path from Executor → Taski::Logging, not through the observer notification system. Ruby's standard exception propagation handles user-facing error display at the top level. Including error in the notification would create inconsistent signatures — pending→running and pending→skipped transitions have no error, only running→failed does.

  5. No duration in notifications: Duration is not a fundamental piece of data — it's derived from timestamps. Observers calculate duration themselves:

    • Record timestamp on pending→running transition
    • Compute duration = timestamp - start_time on running→completed/failed transition

    This keeps the notification signature clean and uniform across all transitions. TaskTiming in TaskWrapper (which existed solely to calculate and pass duration_ms to observers) is removed.

  6. All task candidates known before execution: Static analysis detects all possible task candidates including their dependency trees. The only thing determined at runtime is which tasks are NOT executed (skipped) — this is determined by Fiber-based lazy resolution (only accessed dependencies execute).

  7. Single DependencyGraph: The dependency graph and tree display metadata are derived from the same static analysis data. Instead of maintaining two separate structures (dependency_graph + task_tree), a single DependencyGraph serves as the sole source for all dependency and tree-related queries. Layout observers derive display metadata (depth, position, tree prefixes) from the graph — the graph itself does not carry display concerns.


Event Catalog (8 Events)

Category Events Count
Lifecycle ready, start, stop 3
Phase phase_started(phase), phase_completed(phase) 2
Task task_updated 1
Group group_started, group_completed 2

Event Signatures

# Lifecycle (3)
notify_ready()                # Preparation complete (Pull for root_task, graph, all tasks)
notify_start()
notify_stop()

# Phase (2)
notify_phase_started(phase)   # phase = :run or :clean
notify_phase_completed(phase)

# Task (1)
notify_task_updated(task_class, previous_state:, current_state:, phase:, timestamp:)

# Group (2)
notify_group_started(task_class, group_name, phase:, timestamp:)
notify_group_completed(task_class, group_name, phase:, timestamp:)

Execution Order

ready → start → phase_started(:run) → task_updated... → phase_completed(:run) → phase_started(:clean) → ... → phase_completed(:clean) → stop

Removed Events (vs current ExecutionContext)

  • notify_task_registered → Removed (all task candidates known before execution via Pull at ready)
  • notify_task_started / notify_task_completed / notify_task_skipped → Merged into notify_task_updated
  • notify_clean_started / notify_clean_completed → Merged into notify_task_updated with phase context
  • notify_set_root_task / notify_set_output_capture → Merged into notify_ready

Observer Design

Base Class

class TaskObserver
  attr_accessor :context  # Pull window: ExecutionFacade provides immutable data

  # Methods to implement (8 types)
  def on_ready; end
  def on_start; end
  def on_stop; end
  def on_phase_started(phase); end
  def on_phase_completed(phase); end
  def on_task_updated(task_class, previous_state:, current_state:, phase:, timestamp:); end
  def on_group_started(task_class, group_name, phase:, timestamp:); end
  def on_group_completed(task_class, group_name, phase:, timestamp:); end
end

Usage Example

class MyLayout < TaskObserver
  def on_ready
    @graph = @context.dependency_graph       # Single source for all dependency/tree data
    @root = @context.root_task_class
    @start_times = {}
  end

  def on_task_updated(task_class, previous_state:, current_state:, phase:, timestamp:)
    case [previous_state, current_state]
    when [:pending, :running]
      @start_times[task_class] = timestamp
    when [:pending, :skipped]
      mark_skipped(task_class)
    when [:running, :completed], [:running, :failed]
      duration = timestamp - @start_times[task_class]
      render_completion(task_class, current_state, duration)
    end
  end
end

# ExecutionFacade auto-injects at registration
facade.add_observer(my_layout)
# → my_layout.context = self

ExecutionFacade

Architectural Intent

The core motivation of this refactoring is to consolidate scattered execution-related information into a single object. Currently, the dependency tree is built independently in multiple places (Scheduler, Layout::Base, etc.), and runtime information is spread across ExecutionContext, Scheduler, TaskWrapper, and Layout.

ExecutionFacade absorbs the current ExecutionContext and becomes the single source of truth for all execution-related information. It is created once per execution and referenced by any component that needs execution data.

Key insight: The dependency graph is immutable — it is built once from static analysis results and never changes during execution. What changes is each task's state, which is pushed via notifications. The facade itself holds no mutable domain state — all transient information (phase, state transitions, timestamps) flows through notifications.

Single DependencyGraph

Currently, dependency data is computed independently in multiple places:

  • DependencyGraph — builds graph via Analyzer.analyze() recursively
  • Layout::Base#build_tree_node — re-runs Analyzer.analyze() to build a tree
  • Scheduler#build_dependency_graph — builds its own graph via BFS

These all derive from the same static analysis data. In the new design, DependencyGraph is the single data structure built once by ExecutionFacade. It provides:

  • Graph queries: all_tasks, dependencies_for(task_class), sorted (topological order), cyclic?
  • Tree traversal: parent-child relationships derived from the graph structure

Layout observers derive their own display metadata (depth, is_last flags, tree prefixes) from the graph in on_ready. The graph itself does not carry display concerns.

Initialization: Static Analysis → ExecutionFacade

ExecutionFacade receives static analysis results and builds the dependency graph at initialization:

# Static analysis is performed once
analysis_result = StaticAnalysis::Analyzer.analyze(root_task_class)

# ExecutionFacade builds the dependency graph from the analysis result
facade = ExecutionFacade.new(
  root_task_class: root_task_class,
  analysis_result: analysis_result,
  output_hub: output_hub
)
# Internally builds:
#   - dependency_graph  (single source: circular detection, execution order, tree traversal)

The graph is built once and frozen. Consumers pull what they need:

  • Layout pulls dependency_graph and derives display metadata (depth, tree prefixes) in on_ready
  • Any component pulls what it needs without rebuilding
Before:  Information scattered, static analysis re-run in multiple places
  Scheduler       → builds dependency graph (BFS from cached_dependencies)
  Layout::Base    → re-runs static analysis to build tree
                    TaskState class tracks per-task state (run_state, clean_state, duration, error)
  ExecutionContext → observer dispatch + output capture
  TaskWrapper     → state, timing, error

After:  Static analysis → ExecutionFacade builds DependencyGraph once
  StaticAnalysis::Analyzer.analyze(root)
        │
        ▼
  ExecutionFacade  (single source of truth, created once per execution)
  ├── dependency_graph    (immutable — single source for graph & tree queries)
  ├── root_task_class     (immutable)
  ├── output_stream       (OutputHub, accumulating)
  ├── observer notification dispatch (stateless — passes data through)
  └── NO mutable domain state
  
  Layout derives display metadata from dependency_graph in on_ready:
  └── depth, is_last, tree prefixes (computed once, stored in Layout)

  Referenced by:
  ├── Executor    → triggers notifications (phase, state transitions)
  ├── Layout      → receives notifications + pulls dependency_graph, output_stream
  └── any component that needs execution data

Note: This intentionally creates a fat model. The goal is to first gather all scattered information into one place, understand the full picture, and then decompose into well-separated components in a future refactoring.

API

class ExecutionFacade
  def initialize(root_task_class:, analysis_result:, output_hub:)
    @root_task_class = root_task_class
    @output_hub = output_hub

    # Build single immutable structure from static analysis
    @dependency_graph = build_dependency_graph(analysis_result)

    @observers = []
  end

  # --- Pull API (immutable data only) ---

  attr_reader :root_task_class      # Immutable
  attr_reader :dependency_graph     # Immutable — single source for all dependency/tree data

  def output_stream                 # Accumulating
    @output_hub
  end

  # --- Push API (stateless dispatch — no domain state stored) ---

  def add_observer(observer)
    observer.context = self
    @observers << observer
  end

  def notify_task_updated(task_class, previous_state:, current_state:, phase:, timestamp:)
    dispatch(:on_task_updated, task_class,
             previous_state:, current_state:, phase:, timestamp:)
  end

  # ... other notify_* methods

  private

  def dispatch(method, *args, **kwargs)
    @observers.each do |observer|
      observer.send(method, *args, **kwargs) if observer.respond_to?(method)
    end
  end
end

Output Handling

Design

Both stdout and stderr are captured (per-fiber scoping already implemented by #155):

Source Destination Captured
Task stdout (puts) OutputHub Yes
Task stderr (warn) OutputHub Yes
Layout/Progress display original_stderr No

OutputHub Implementation

class OutputHub
  MAX_RECENT_LINES = 30

  def store_output_lines(task_class, data)
    lines = data.lines.map(&:chomp)
    lines.each do |line|
      # Immediate log output
      Taski::Logging.output(task_class, line)

      # Store in ring buffer
      @ring_buffer[task_class] ||= []
      @ring_buffer[task_class] << line
      @ring_buffer[task_class] = @ring_buffer[task_class].last(MAX_RECENT_LINES)
    end
  end

  # Public API: read
  # limit: nil for entire buffer, or last N lines
  def read(task_class, limit: nil)
    lines = @ring_buffer[task_class] || []
    limit ? lines.last(limit) : lines
  end
end

Error Logging

Error details (message, backtrace) are logged via a direct path, not through the observer notification system:

# In Executor: when a task fails
rescue => error
  Taski::Logging.error(task_class, error)   # Direct path: log error details immediately
  wrapper.mark_failed(error)                 # Triggers notify_task_updated(state: :failed)
end
Data Flow Path Content
State transition Observer notification (task_updated) task_class, previous_state, current_state, phase, timestamp
Error details Direct: Executor → Taski::Logging Error message, backtrace, task class
Task output Direct: OutputHub → Taski::Logging stdout/stderr lines with task class

Responsibility

Class Responsibility Data Public API
OutputHub Capture + Log + Ring buffer @ring_buffer (per task) read(task_class, limit:)
Layout/Progress Display only (Pull graph + notifications) None -
LoggerObserver Event logging (state transitions only) None -

Two Types of Dependencies

Type Purpose Implementation Characteristics
Static Display, circular detection StaticAnalysis::Analyzer.analyze() Includes all possible dependencies from all code paths
Runtime Actual execution Fiber-based lazy resolution Only actually accessed dependencies are executed

The gap between static and runtime dependencies is the set of skipped tasks. Static analysis detects all possible tasks; at runtime, Fiber lazy resolution only executes the ones actually accessed. The rest are marked skipped.


Removed Classes / Code

TaskTiming — Remove

The TaskTiming class in TaskWrapper (tracks start_time, end_time, calculates duration_ms) is removed. With the new design:

  • Timing: Each task_updated notification includes timestamp:. Observers record the timestamp at pending→running and compute duration at running→completed/failed by calculating the difference.
  • TaskTiming existed solely to calculate and pass duration_ms to observers. With timestamp-based notifications, this is no longer needed.
  • Remove @timing and @clean_timing instance variables from TaskWrapper.

Layout::Base::TaskState — Remove

The current TaskState class in Layout::Base (tracks run_state, clean_state, run_duration, clean_duration, run_error, clean_error per task) is removed. With the new design:

  • State: Pushed via task_updated notifications — each observer tracks what it needs
  • Duration: Calculated by observers from timestamp in notifications
  • Error: Logged via direct path (Executor → Taski::Logging), not stored per-task

Observers are responsible for maintaining their own view of task state based on notifications they receive.

Tree Building Code in Layout — Remove

All tree building and dependency analysis code must be removed from Layout classes. This logic is replaced by a single DependencyGraph built once by ExecutionFacade. Layout observers derive display metadata from the graph in on_ready.

Layout::Base — remove the following methods:

  • build_tree_node — recursive tree node construction (replaced by dependency_graph)
  • get_task_dependencies — calls StaticAnalysis::Analyzer.analyze() (re-runs static analysis)
  • collect_all_dependencies / collect_dependencies_recursive — dependency collection
  • on_root_task_set — no longer needed (replaced by on_ready pulling from Facade)
  • set_root_task — no longer needed (root_task available via Facade Pull)
  • @tasks hash (TaskState tracking) — observers track their own state
  • apply_state_transition — replaced by unified task_updated notification handling

Layout::Simple — remove:

  • build_tree_structure — orchestrates tree building
  • register_tasks_from_tree — registers tasks from tree into @tasks
  • on_root_task_set override

Layout::Tree — remove:

  • build_tree_structure — orchestrates tree building
  • register_tree_nodes — registers tree nodes with depth/position metadata
  • on_root_task_set override
  • Rebuild display metadata from facade.dependency_graph in on_ready

Scheduler#build_dependency_graph — Remove

Scheduler currently builds its own dependency graph via BFS from cached_dependencies. This is replaced by pulling dependency_graph from ExecutionFacade.

Scheduler state constants — Remove

  • STATE_ENQUEUED / CLEAN_STATE_ENQUEUED: No longer needed (unified into running)

ExecutionContext — Remove (absorbed into ExecutionFacade)

All functionality (observer dispatch, output capture management) moves to ExecutionFacade. Individual notify_* methods are replaced by the unified event catalog.

Current methods to remove/merge:

  • notify_task_registered → removed (Pull at ready)
  • notify_task_started / notify_task_completed / notify_task_skipped → merged into notify_task_updated
  • notify_clean_started / notify_clean_completed → merged into notify_task_updated
  • notify_set_root_task / notify_set_output_capture → merged into notify_ready
  • notify_start / notify_stop → kept as notify_start / notify_stop
  • trigger_execution / trigger_clean / execution_trigger= / clean_trigger= → kept (execution coordination)
  • THREAD_LOCAL_KEY / self.current / self.current= → updated (Fiber-local context)

Implementation Plan

Phase 1: Unify State Constants

  • Remove STATE_ENQUEUED / CLEAN_STATE_ENQUEUED from Scheduler — unify into running
  • Change clean state values in Layout::Base#apply_state_transition: :cleaning:running, :clean_completed:completed, :clean_failed:failed
  • Add phase context to distinguish run vs clean states

Phase 2: Introduce ExecutionFacade

  • Create ExecutionFacade class absorbing ExecutionContext
  • Receive static analysis result and build DependencyGraph at initialization (single source for all dependency and tree data)
  • Add Pull API: root_task_class, dependency_graph, output_stream (all immutable/accumulating)
  • Add Push API: stateless observer dispatch (migrated from ExecutionContext)
  • Remove ExecutionContext (replaced by ExecutionFacade)

Phase 3: Unify Events

  • Remove notify_task_registered (available via Pull at ready)
  • Merge task events into notify_task_updated(task_class, previous_state:, current_state:, phase:, timestamp:)
  • Merge notify_set_root_task, notify_set_output_capture into notify_ready
  • Add notify_phase_started(phase) / notify_phase_completed(phase)

Phase 4: Introduce Observer Base Class

  • Create TaskObserver base class
  • Auto-inject context in add_observer

Phase 5: Pull-based Observer

Phase 6: Remove TaskTiming

  • Remove TaskTiming class from TaskWrapper
  • Remove @timing and @clean_timing instance variables
  • Observers use timestamp: from notifications to calculate duration

Phase 7: Add skipped State for Unaccessed Tasks

  • After run phase completes, identify tasks detected by static analysis but never executed at runtime
  • Mark these as :skipped via task_updated notification
  • Mark tasks whose dependencies failed as :skipped

Phase 8: Output Processing

  • Add OutputHub#read(task_class, limit: nil) method
  • Expose facade.output_stream
  • Centralize output routing through OutputHub

Phase 9: Update Tests


Critical Files

File Role
lib/taski/execution/execution_facade.rb ExecutionFacade: single source of truth (new, replaces execution_context.rb)
lib/taski/execution/execution_context.rb To be removed (absorbed into ExecutionFacade)
lib/taski/execution/task_observer.rb Observer base class (new)
lib/taski/execution/scheduler.rb Remove build_dependency_graph, remove enqueued states (pulls from Facade instead)
lib/taski/static_analysis/analyzer.rb Static analysis — results fed into ExecutionFacade
lib/taski/static_analysis/dependency_graph.rb Single DependencyGraph structure (built by ExecutionFacade from analysis, provides graph & tree queries)
lib/taski/progress/layout/base.rb Remove tree building, TaskState (display only, derives metadata from DependencyGraph)
lib/taski/progress/layout/simple.rb Remove build_tree_structure, register_tasks_from_tree
lib/taski/progress/layout/tree.rb Remove build_tree_structure, register_tree_nodes
lib/taski/execution/task_wrapper.rb Remove TaskTiming, @timing, @clean_timing. State holder + state transition trigger
lib/taski/execution/executor.rb Phase event trigger

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions