Repository: CWorthy-ocean/C-Star

Issue:
Create equivalent to `SchedulerJob` for local execution (`LocalProcess`?)
Following #175 `ROMSComponent.run()` will return a `SchedulerJob` instance if the system uses a scheduler, or executes "live" while the user waits if not. For consistent behaviour we should return an instance of a class like `LocalProcess` that has a shared base class with `SchedulerJob` (something generic like `ExecutionHandler`). On the shared base class, common (abstract) methods like `updates` could be defined.

This way, no matter which system the user calls `.run()` on, they get the same behaviour - an object is returned on which certain queries can be made about the state of their ROMS run.

Steps:

- [x] Move scheduler/job.py -> execution/scheduler_job.py
- [x] Define ExecutionHandler base class in execution/handler.py
- [x] Create LocalProcess subclass in execution/local_process.py
- [x] Update ROMSComponent.run to create and return a LocalProcess instance on a non-scheduler machine
- [x] Add ExecutionHandler returned by ROMSComponent.run() to a private attribute to prevent premature post_run call

Discussion:

Patch:
diff --git a/cstar/base/component.py b/cstar/base/component.py
index 1128546..191cc43 100644
--- a/cstar/base/component.py
+++ b/cstar/base/component.py
@@ -6,7 +6,7 @@ from cstar.base.base_model import BaseModel
 if TYPE_CHECKING:
     from cstar.base.additional_code import AdditionalCode
     from cstar.base.discretization import Discretization
-    from cstar.scheduler.job import SchedulerJob
+    from cstar.execution.handler import ExecutionHandler
 
 
 class Component(ABC):
@@ -179,7 +179,7 @@ class Component(ABC):
         """
 
     @abstractmethod
-    def run(self) -> Optional["SchedulerJob"]:
+    def run(self) -> "ExecutionHandler":
         """Run this component.
 
         This abstract method will be implemented differently by different Component
diff --git a/cstar/case.py b/cstar/case.py
index cc271ec..bd9f429 100644
--- a/cstar/case.py
+++ b/cstar/case.py
@@ -14,7 +14,7 @@ from cstar.roms.component import ROMSComponent
 from cstar.marbl.component import MARBLComponent
 
 if TYPE_CHECKING:
-    from cstar.scheduler.job import SchedulerJob
+    from cstar.execution.handler import ExecutionHandler
 
 
 class Case:
@@ -535,7 +535,7 @@ class Case:
         walltime: Optional[str] = None,
         queue_name: Optional[str] = None,
         job_name: Optional[str] = None,
-    ) -> Optional["SchedulerJob"]:
+    ) -> "ExecutionHandler":
         """Run the case by calling `component.run(caseroot)` on the primary component
         (to which others are coupled)."""
 
diff --git a/cstar/execution/handler.py b/cstar/execution/handler.py
new file mode 100644
index 0000000..46ea117
--- /dev/null
+++ b/cstar/execution/handler.py
@@ -0,0 +1,164 @@
+import time
+from abc import ABC, abstractmethod
+from enum import Enum, auto
+from pathlib import Path
+
+
+class ExecutionStatus(Enum):
+    """Enum representing possible states of a process to be executed.
+
+    Attributes
+    ----------
+    UNSUBMITTED : ExecutionStatus
+        The task is to be handled by a scheduler, but has not yet been submitted
+    PENDING : ExecutionStatus
+        The task is to be handled by a scheduler, but is waiting to start
+    RUNNING : ExecutionStatus
+        The task is currently executing.
+    COMPLETED : ExecutionStatus
+        The task finished successfully.
+    CANCELLED : ExecutionStatus
+        The task was cancelled before completion.
+    FAILED : ExecutionStatus
+        The task finished unsuccessfully.
+    HELD : ExecutionStatus
+        The task is to be handled by a scheduler, but is currently on hold pending release
+    ENDING : ExecutionStatus
+        The task is in the process of ending but not fully completed.
+    UNKNOWN : ExecutionStatus
+        The task state is unknown or not recognized.
+    """
+
+    UNSUBMITTED = auto()
+    PENDING = auto()
+    RUNNING = auto()
+    COMPLETED = auto()
+    CANCELLED = auto()
+    FAILED = auto()
+    HELD = auto()
+    ENDING = auto()
+    UNKNOWN = auto()
+
+    def __str__(self) -> str:
+        return self.name.lower()  # Convert enum name to lowercase for display
+
+
+class ExecutionHandler(ABC):
+    """Abstract base class for managing the execution of a task or process.
+
+    This class defines the interface and common behavior for handling task
+    execution, such as monitoring the status and providing live updates
+    from an output file.
+
+    Attributes
+    ----------
+    status : ExecutionStatus
+        Represents the current status of the task (e.g., RUNNING, COMPLETED).
+        This is an abstract property that must be implemented by subclasses.
+
+    Methods
+    -------
+    updates(seconds=10)
+        Stream live updates from the task's output file for a specified duration.
+    """
+
+    @property
+    @abstractmethod
+    def status(self) -> ExecutionStatus:
+        """Abstract property representing the current status of the task.
+
+        Subclasses must implement this property to query the underlying
+        process and return the appropriate `ExecutionStatus` enum value.
+
+        Returns
+        -------
+        ExecutionStatus
+            The current status of the task, such as `ExecutionStatus.RUNNING`,
+            `ExecutionStatus.COMPLETED`, or other valid states defined in
+            the `ExecutionStatus` enum.
+
+        Notes
+        -----
+        The specific implementation should query the underlying process or
+        system to determine the task's status.
+        """
+
+        pass
+
+    @property
+    @abstractmethod
+    def output_file(self) -> Path:
+        """Abstract property representing the output file.
+
+        Returns
+        -------
+        output_file (Path):
+            Path to the file in which stdout and stderr will be written.
+        """
+
+        pass
+
+    def updates(self, seconds: int = 10, confirm_indefinite: bool = True):
+        """Stream live updates from the task's output file.
+
+        This method streams updates from the task's output file for the
+        specified duration. If the task is not running, a message will
+        indicate the inability to provide updates. If the output file
+        exists and the task has already finished, the method provides a
+        reference to the output file for review.
+
+        Parameters
+        ----------
+        seconds : int, optional, default = 10
+            The duration (in seconds) for which updates should be streamed.
+            If set to 0, updates will be streamed indefinitely until
+            interrupted by the user.
+        confirm_indefinite: bool, optional, default = True
+            If 'seconds' is set to 0, the user will be prompted to confirm
+            whether they want to continue with an indefinite update stream
+            if confirm_indefinite is set to True
+        Notes
+        -----
+        - This method moves to the end of the output file and streams only
+          new lines appended during the specified duration.
+        - When streaming indefinitely (`seconds=0`), user confirmation is
+          required before proceeding.
+        """
+
+        if self.status != ExecutionStatus.RUNNING:
+            print(
+                f"This job is currently not running ({self.status}). Live updates cannot be provided."
+            )
+            if (self.status in {ExecutionStatus.FAILED, ExecutionStatus.COMPLETED}) or (
+                self.status == ExecutionStatus.CANCELLED and self.output_file.exists()
+            ):
+                print(f"See {self.output_file.resolve()} for job output")
+            return
+
+        if (seconds == 0) and (confirm_indefinite):
+            # Confirm indefinite tailing
+            confirmation = (
+                input(
+                    "This will provide indefinite updates to your job. You can stop it anytime using Ctrl+C. "
+                    "Do you want to continue? (y/n): "
+                )
+                .strip()
+                .lower()
+            )
+            if confirmation not in {"y", "yes"}:
+                return
+
+        try:
+            with open(self.output_file, "r") as f:
+                f.seek(0, 2)  # Move to the end of the file
+                start_time = time.time()
+                while seconds == 0 or (time.time() - start_time < seconds):
+                    line = f.readline()
+                    if self.status != ExecutionStatus.RUNNING:
+                        return
+                    elif line:
+                        print(line, end="")
+                    else:
+                        time.sleep(0.1)  # 100ms delay between updates
+        except KeyboardInterrupt:
+            print("\nLive status updates stopped by user.")
diff --git a/cstar/execution/local_process.py b/cstar/execution/local_process.py
new file mode 100644
index 0000000..deb5749
--- /dev/null
+++ b/cstar/execution/local_process.py
@@ -0,0 +1,196 @@
+import subprocess
+from pathlib import Path
+from typing import Optional
+from datetime import datetime
+from cstar.execution.handler import ExecutionHandler, ExecutionStatus
+
+
+class LocalProcess(ExecutionHandler):
+    """Execution handler for managing and monitoring local subprocesses.
+
+    This class handles the execution of commands as local subprocesses,
+    allowing users to monitor their status, stream output, and manage
+    lifecycle events such as cancellation.
+
+    Attributes
+    ----------
+    commands : str
+        The shell command(s) to be executed as a subprocess.
+    run_path : Path
+        The directory from which the subprocess will be executed.
+    output_file : Path
+        The file where the subprocess's standard output and error will
+        be written.
+    status : ExecutionStatus
+        The current status of the subprocess, represented as an
+        `ExecutionStatus` enum value.
+
+    Methods
+    -------
+    start()
+        Start the subprocess using the specified command.
+    cancel()
+        Cancel the running subprocess.
+    updates(seconds=10)
+        Stream live updates from the subprocess's output file for a
+        specified duration.
+    """
+
+    def __init__(
+        self,
+        commands: str,
+        output_file: Optional[str | Path] = None,
+        run_path: Optional[str | Path] = None,
+    ):
+        """Initialize a `LocalProcess` instance.
+
+        Parameters
+        ----------
+        commands : str
+            The shell command(s) to execute as a subprocess.
+        output_file : str or Path, optional
+            The file path where the subprocess's standard output and error
+            will be written. Defaults to an auto-generated file in the
+            `run_path` directory.
+        run_path : str or Path, optional
+            The directory from which the subprocess will be executed.
+            Defaults to the current working directory.
+        """
+
+        self.commands = commands
+        self.run_path = Path(run_path) if run_path is not None else Path.cwd()
+        self._default_name = (
+            f"cstar_process_{datetime.strftime(datetime.now(), '%Y%m%d_%H%M%S')}"
+        )
+        self._output_file = (
+            Path(output_file) if output_file is not None else output_file
+        )
+
+        self._output_file_handle = None
+        self._process = None
+        self._cancelled = False
+
+    def start(self):
+        """Start the local process.
+
+        This method initiates the execution of the command specified during
+        initialization. The command runs in a subprocess, with its standard
+        output and error directed to the output file.
+
+        Notes
+        -----
+        - The output file is opened and actively written to during execution.
+        - Use the `status` property to monitor the current state of the
+          subprocess.
+
+        See Also
+        --------
+        cancel : Terminates the running subprocess.
+        """
+
+        # Open the output file to write to
+        self._output_file_handle = open(self.output_file, "w")
+        local_process = subprocess.Popen(
+            self.commands.split(),
+            # shell=True,
+            cwd=self.run_path,
+            stdin=subprocess.PIPE,
+            stdout=self._output_file_handle,
+            stderr=subprocess.STDOUT,
+        )
+        self._process = local_process
+
+    @property
+    def output_file(self) -> Path:
+        """The file in which to write this task's STDOUT and STDERR."""
+        return (
+            self.run_path / f"{self._default_name}.out"
+            if self._output_file is None
+            else self._output_file
+        )
+
+    @property
+    def status(self) -> ExecutionStatus:
+        """Retrieve the current status of the local process.
+
+        This property determines the status of the process based on its
+        lifecycle and return code. The possible statuses are represented by
+        the `ExecutionStatus` enum.
+
+        Returns
+        -------
+        ExecutionStatus
+            The current status of the process. Possible values include:
+            - `ExecutionStatus.UNSUBMITTED`: The task has not been started.
+            - `ExecutionStatus.RUNNING`: The task is currently executing.
+            - `ExecutionStatus.COMPLETED`: The task finished successfully.
+            - `ExecutionStatus.FAILED`: The task finished unsuccessfully.
+            - `ExecutionStatus.CANCELLED`: The task was cancelled using LocalProcess.cancel()
+            - `ExecutionStatus.UNKNOWN`: The task status could not be determined.
+        """
+
+        if self._cancelled:
+            return ExecutionStatus.CANCELLED
+        if self._process is None:
+            return ExecutionStatus.UNSUBMITTED
+        if self._process.poll() is None:
+            return ExecutionStatus.RUNNING
+        if self._process.returncode == 0:
+            if self._output_file_handle:
+                self._output_file_handle.close()
+                self._output_file_handle = None
+            return ExecutionStatus.COMPLETED
+        elif self._process.returncode is not None:
+            if self._output_file_handle:
+                self._output_file_handle.close()
+                self._output_file_handle = None
+            return ExecutionStatus.FAILED
+        return ExecutionStatus.UNKNOWN
+
+    def cancel(self):
+        """Cancel the local process.
+
+        This method terminates the subprocess if it is currently running.
+        A graceful shutdown is attempted using `terminate` (SIGTERM). If the
+        subprocess does not terminate within a timeout period, it is forcefully
+        killed using `kill` (SIGKILL).
+
+        Notes
+        -----
+        - The status of the subprocess is updated to `ExecutionStatus.CANCELLED`
+          after termination or killing.
+        - The method ensures that the output file handle is closed after
+          cancelling the process.
+
+        See Also
+        --------
+        wait : Wait for the local process to finish
+        """
+
+        if self._process and self.status == ExecutionStatus.RUNNING:
+            try:
+                self._process.terminate()  # Send SIGTERM to allow graceful shutdown
+                self._process.wait(timeout=5)  # Wait for it to terminate
+            except subprocess.TimeoutExpired:
+                self._process.kill()  # Forcefully kill if it doesn't terminate
+            finally:
+                if self._output_file_handle:
+                    self._output_file_handle.close()
+                    self._output_file_handle = None
+                self._cancelled = True
+        else:
+            print(f"Cannot cancel job with status '{self.status}'")
+            return
+
+    def wait(self):
+        """Wait for the local process to finish.
+
+        See Also
+        --------
+        cancel : end the current process
+        """
+
+        if self._process and self.status == ExecutionStatus.RUNNING:
+            self._process.wait()
+        else:
+            print(f"cannot wait for process with execution status '{self.status}'")
diff --git a/cstar/scheduler/job.py b/cstar/execution/scheduler_job.py
similarity index 85%
rename from cstar/scheduler/job.py
rename to cstar/execution/scheduler_job.py
index f6621d1..bd0454c 100644
--- a/cstar/scheduler/job.py
+++ b/cstar/execution/scheduler_job.py
@@ -1,15 +1,14 @@
 import os
 import re
-import time
 import json
 import warnings
 import subprocess
 from math import ceil
-from enum import Enum, auto
 from abc import ABC, abstractmethod
 from pathlib import Path
 from datetime import datetime, timedelta
 from typing import Optional, Tuple
+from cstar.execution.handler import ExecutionStatus, ExecutionHandler
 from cstar.system.manager import cstar_sysmgr
 from cstar.system.scheduler import (
     Queue,
@@ -110,49 +109,7 @@ def create_scheduler_job(
     )
 
 
-class JobStatus(Enum):
-    """Enum representing possible states of a job in the scheduler.
-
-    Each state corresponds to a stage in the lifecycle of a job, from submission
-    to completion or failure.
-
-    Attributes
-    ----------
-    UNSUBMITTED : JobStatus
-        The job has not been submitted to the scheduler yet.
-    PENDING : JobStatus
-        The job has been submitted but is waiting to start.
-    RUNNING : JobStatus
-        The job is currently executing.
-    COMPLETED : JobStatus
-        The job finished successfully.
-    CANCELLED : JobStatus
-        The job was cancelled before completion.
-    FAILED : JobStatus
-        The job finished unsuccessfully.
-    HELD : JobStatus
-        The job is on hold and will not run until released.
-    ENDING : JobStatus
-        The job is in the process of ending but not fully completed.
-    UNKNOWN : JobStatus
-        The job state is unknown or not recognized.
-    """
-
-    UNSUBMITTED = auto()
-    PENDING = auto()
-    RUNNING = auto()
-    COMPLETED = auto()
-    CANCELLED = auto()
-    FAILED = auto()
-    HELD = auto()
-    ENDING = auto()
-    UNKNOWN = auto()
-
-    def __str__(self) -> str:
-        return self.name.lower()  # Convert enum name to lowercase for display
-
-
-class SchedulerJob(ABC):
+class SchedulerJob(ExecutionHandler, ABC):
     """Abstract base class for representing a job submitted to a scheduler.
 
     This class defines the structure and common behavior for jobs managed by
@@ -193,7 +150,7 @@ class SchedulerJob(ABC):
         The maximum walltime for the job, in the format "HH:MM:SS".
     id : int or None
         The unique job ID assigned by the scheduler. None if the job has not been submitted.
-    status: JobStatus
+    status: ExecutionStatus
         A representation of the current status of the job, e.g. RUNNING or CANCELLED
     script: str
         The job script to be submitted to the scheduler.
@@ -277,19 +234,19 @@ class SchedulerJob(ABC):
         self._commands = commands
         self._cpus = cpus
 
-        default_name = f"cstar_job_{datetime.strftime(datetime.now(), '%Y%m%d_%H%M%S')}"
+        self._default_name = (
+            f"cstar_job_{datetime.strftime(datetime.now(), '%Y%m%d_%H%M%S')}"
+        )
 
         self.script_path = (
-            Path.cwd() / f"{default_name}.sh"
+            Path.cwd() / f"{self._default_name}.sh"
             if script_path is None
             else Path(script_path)
         )
         self.run_path = self.script_path.parent if run_path is None else Path(run_path)
-        self._job_name = default_name if job_name is None else job_name
-        self.output_file = (
-            self.run_path / f"{default_name}.out"
-            if output_file is None
-            else output_file
+        self._job_name = self._default_name if job_name is None else job_name
+        self._output_file = (
+            Path(output_file) if output_file is not None else output_file
         )
         self._queue_name = (
             scheduler.primary_queue_name if queue_name is None else queue_name
@@ -385,6 +342,15 @@ class SchedulerJob(ABC):
         self._account_key = account_key
         self._id: Optional[int] = None
 
+    @property
+    def output_file(self) -> Path:
+        """The file in which to write this job's STDOUT and STDERR."""
+        return (
+            self.run_path / f"{self._default_name}.out"
+            if self._output_file is None
+            else self._output_file
+        )
+
     @property
     def account_key(self) -> str:
         """The account key associated with the job for resource tracking."""
@@ -498,12 +464,12 @@ class SchedulerJob(ABC):
 
     @property
     @abstractmethod
-    def status(self) -> JobStatus:
+    def status(self) -> ExecutionStatus:
         """Retrieve the current status of the job.
 
         This method queries the underlying scheduler to determine the current status of
         the job (e.g., PENDING, RUNNING, COMPLETED). Subclasses must implement this method
-        to interact with the specific scheduler and map its state to a `JobStatus` enum.
+        to interact with the specific scheduler and map its state to a `ExecutionStatus` enum.
 
         Parameters
         ----------
@@ -511,55 +477,11 @@ class SchedulerJob(ABC):
 
         Returns
         -------
-        status: JobStatus
+        status: ExecutionStatus
             An enumeration value representing the current status of the job.
         """
         pass
 
-    def updates(self, seconds=10):
-        """Provides updates from the job's output file as a live stream for `seconds`
-        seconds (default 10).
-
-        If `seconds` is 0, updates are provided indefinitely until the user interrupts the stream.
-        """
-
-        if self.status != JobStatus.RUNNING:
-            print(
-                f"This job is currently not running ({self.status}). Live updates cannot be provided."
-            )
-            if (self.status in {JobStatus.FAILED, JobStatus.COMPLETED}) or (
-                self.status == JobStatus.CANCELLED and self.output_file.exists()
-            ):
-                print(f"See {self.output_file.resolve()} for job output")
-            return
-
-        if seconds == 0:
-            # Confirm indefinite tailing
-            confirmation = (
-                input(
-                    "This will provide indefinite updates to your job. You can stop it anytime using Ctrl+C. "
-                    "Do you want to continue? (y/n): "
-                )
-                .strip()
-                .lower()
-            )
-            if confirmation not in {"y", "yes"}:
-                return
-
-        try:
-            with open(self.output_file, "r") as f:
-                f.seek(0, 2)  # Move to the end of the file
-                start_time = time.time()
-
-                while seconds == 0 or (time.time() - start_time < seconds):
-                    line = f.readline()
-                    if line:
-                        print(line, end="")
-                    else:
-                        time.sleep(0.1)  # 100ms delay between updates
-        except KeyboardInterrupt:
-            print("\nLive status updates stopped by user.")
-
     def _calculate_node_distribution(
         self, n_cores_required: int, tot_cores_per_node: int
     ) -> Tuple[int, int]:
@@ -631,7 +553,7 @@ class SlurmJob(SchedulerJob):
         The maximum walltime for the job, in the format "HH:MM:SS".
     id : int or None
         The unique job ID assigned by the SLURM scheduler. None if the job has not been submitted.
-    status : JobStatus
+    status : ExecutionStatus
         The current status of the job, retrieved from SLURM.
     script : str
         The SLURM-specific job script, including directives and commands.
@@ -645,22 +567,22 @@ class SlurmJob(SchedulerJob):
     """
 
     @property
-    def status(self) -> JobStatus:
+    def status(self) -> ExecutionStatus:
         """Retrieve the current status of the job from the SLURM scheduler.
 
         This property queries SLURM using the `sacct` command to determine the job's
-        state and maps it to a corresponding `JobStatus` enumeration.
+        state and maps it to a corresponding `ExecutionStatus` enumeration.
 
         Returns
         -------
-        status: JobStatus
+        status: ExecutionStatus
             The current status of the job. Possible values include:
-            - `JobStatus.PENDING`: The job has been submitted but is waiting to start.
-            - `JobStatus.RUNNING`: The job is currently executing.
-            - `JobStatus.COMPLETED`: The job finished successfully.
-            - `JobStatus.CANCELLED`: The job was cancelled before completion.
-            - `JobStatus.FAILED`: The job finished unsuccessfully.
-            - `JobStatus.UNKNOWN`: The job state could not be determined.
+            - `ExecutionStatus.PENDING`: The job has been submitted but is waiting to start.
+            - `ExecutionStatus.RUNNING`: The job is currently executing.
+            - `ExecutionStatus.COMPLETED`: The job finished successfully.
+            - `ExecutionStatus.CANCELLED`: The job was cancelled before completion.
+            - `ExecutionStatus.FAILED`: The job finished unsuccessfully.
+            - `ExecutionStatus.UNKNOWN`: The job state could not be determined.
 
         Raises
         ------
@@ -669,7 +591,7 @@ class SlurmJob(SchedulerJob):
         """
 
         if self.id is None:
-            return JobStatus.UNSUBMITTED
+            return ExecutionStatus.UNSUBMITTED
         else:
             sacct_cmd = f"sacct -j {self.id} --format=State%20 --noheader"
             result = subprocess.run(
@@ -681,20 +603,20 @@ class SlurmJob(SchedulerJob):
                     f"STDOUT: {result.stdout}, STDERR: {result.stderr}"
                 )
 
-        # Map sacct states to JobStatus enum
+        # Map sacct states to ExecutionStatus enum
         sacct_status_map = {
-            "PENDING": JobStatus.PENDING,
-            "RUNNING": JobStatus.RUNNING,
-            "COMPLETED": JobStatus.COMPLETED,
-            "CANCELLED": JobStatus.CANCELLED,
-            "FAILED": JobStatus.FAILED,
+            "PENDING": ExecutionStatus.PENDING,
+            "RUNNING": ExecutionStatus.RUNNING,
+            "COMPLETED": ExecutionStatus.COMPLETED,
+            "CANCELLED": ExecutionStatus.CANCELLED,
+            "FAILED": ExecutionStatus.FAILED,
         }
         for state, status in sacct_status_map.items():
             if state in result.stdout:
                 return status
 
         # Fallback if no known state is found
-        return JobStatus.UNKNOWN
+        return ExecutionStatus.UNKNOWN
 
     @property
     def script(self) -> str:
@@ -803,8 +725,8 @@ class SlurmJob(SchedulerJob):
             If the `scancel` command fails or returns a non-zero exit code.
         """
 
-        if self.status not in {JobStatus.RUNNING, JobStatus.PENDING}:
-            print(f"Cannot cancel job with status {self.status}")
+        if self.status not in {ExecutionStatus.RUNNING, ExecutionStatus.PENDING}:
+            print(f"Cannot cancel job with status '{self.status}'")
             return
 
         result = subprocess.run(
@@ -862,7 +784,7 @@ class PBSJob(SchedulerJob):
         The maximum walltime for the job, in the format "HH:MM:SS".
     id : int or None
         The unique job ID assigned by the PBS scheduler. None if the job has not been submitted.
-    status : JobStatus
+    status : ExecutionStatus
         The current status of the job, retrieved from PBS.
     script : str
         The PBS-specific job script, including directives and commands.
@@ -876,7 +798,7 @@ class PBSJob(SchedulerJob):
     """
 
     @property
-    def script(self):
+    def script(self) -> str:
         """Generate the PBS-specific job script to be submitted to the scheduler.
         Includes standard Slurm scheduler directives as well as scheduler-specific
         directives specified by the scheduler.other_scheduler_directives attribute.
@@ -901,7 +823,7 @@ class PBSJob(SchedulerJob):
         for (
             key,
             value,
-        ) in cstar_sysmgr.scheduler.other_scheduler_directives.items():
+        ) in self.scheduler.other_scheduler_directives.items():
             scheduler_script += f"\n#PBS {key} {value}"
         scheduler_script += "\ncd ${PBS_O_WORKDIR}"
 
@@ -909,24 +831,24 @@ class PBSJob(SchedulerJob):
         return scheduler_script
 
     @property
-    def status(self) -> JobStatus:
+    def status(self) -> ExecutionStatus:
         """Retrieve the current status of the job from the PBS scheduler.
 
         This property queries PBS using the `qstat` command to determine the job's
-        state and maps it to a corresponding `JobStatus` enumeration.
+        state and maps it to a corresponding `ExecutionStatus` enumeration.
 
         Returns
         -------
-        status : JobStatus
+        status : ExecutionStatus
             The current status of the job. Possible values include:
-            - `JobStatus.PENDING`: The job has been submitted but is waiting to start.
-            - `JobStatus.RUNNING`: The job is currently executing.
-            - `JobStatus.COMPLETED`: The job finished successfully.
-            - `JobStatus.CANCELLED`: The job was cancelled before completion.
-            - `JobStatus.FAILED`: The job finished unsuccessfully.
-            - `JobStatus.HELD`: The job is on hold.
-            - `JobStatus.ENDING`: The job is in the process of ending.
-            - `JobStatus.UNKNOWN`: The job state could not be determined.
+            - `ExecutionStatus.PENDING`: The job has been submitted but is waiting to start.
+            - `ExecutionStatus.RUNNING`: The job is currently executing.
+            - `ExecutionStatus.COMPLETED`: The job finished successfully.
+            - `ExecutionStatus.CANCELLED`: The job was cancelled before completion.
+            - `ExecutionStatus.FAILED`: The job finished unsuccessfully.
+            - `ExecutionStatus.HELD`: The job is on hold.
+            - `ExecutionStatus.ENDING`: The job is in the process of ending.
+            - `ExecutionStatus.UNKNOWN`: The job state could not be determined.
 
         Raises
         ------
@@ -935,7 +857,7 @@ class PBSJob(SchedulerJob):
         """
 
         if self.id is None:
-            return JobStatus.UNSUBMITTED
+            return ExecutionStatus.UNSUBMITTED
 
         qstat_cmd = f"qstat -x -f -F json {self.id}"
         result = subprocess.run(qstat_cmd, capture_output=True, text=True, shell=True)
@@ -957,20 +879,24 @@ class PBSJob(SchedulerJob):
             # Extract the job state
             job_state = job_info["job_state"]
             pbs_status_map = {
-                "Q": JobStatus.PENDING,
-                "R": JobStatus.RUNNING,
-                "C": JobStatus.COMPLETED,
-                "H": JobStatus.HELD,
-                "E": JobStatus.ENDING,
+                "Q": ExecutionStatus.PENDING,
+                "R": ExecutionStatus.RUNNING,
+                "C": ExecutionStatus.COMPLETED,
+                "H": ExecutionStatus.HELD,
+                "E": ExecutionStatus.ENDING,
             }
 
             # Handle specific cases for "F" (Finished)
             if job_state == "F":
                 exit_status = job_info.get("Exit_status", 1)
-                return JobStatus.COMPLETED if exit_status == 0 else JobStatus.FAILED
+                return (
+                    ExecutionStatus.COMPLETED
+                    if exit_status == 0
+                    else ExecutionStatus.FAILED
+                )
             else:
                 # Default to UNKNOWN for unmapped states
-                return pbs_status_map.get(job_state, JobStatus.UNKNOWN)
+                return pbs_status_map.get(job_state, ExecutionStatus.UNKNOWN)
 
         except json.JSONDecodeError as e:
             raise RuntimeError(f"Failed to parse JSON from qstat output: {e}")
@@ -1032,7 +958,11 @@ class PBSJob(SchedulerJob):
             If the `qdel` command fails or returns a non-zero exit code.
         """
 
-        if self.status not in {JobStatus.RUNNING, JobStatus.PENDING, JobStatus.HELD}:
+        if self.status not in {
+            ExecutionStatus.RUNNING,
+            ExecutionStatus.PENDING,
+            ExecutionStatus.HELD,
+        }:
             print(f"Cannot cancel job with status {self.status}")
             return
 
diff --git a/cstar/marbl/component.py b/cstar/marbl/component.py
index 803c26c..aeffb5e 100644
--- a/cstar/marbl/component.py
+++ b/cstar/marbl/component.py
@@ -61,8 +61,8 @@ class MARBLComponent(Component):
         print("No pre-processing steps to be completed for MARBLComponent")
         pass
 
-    def run(self) -> None:
-        print("MARBL must be run in the context of a parent model")
+    def run(self):
+        raise ValueError("MARBL must be run in the context of a parent model")
 
     def post_run(self) -> None:
         print("No post-processing steps to be completed for MARBLComponent")
diff --git a/cstar/roms/component.py b/cstar/roms/component.py
index d8db87a..2a9e634 100644
--- a/cstar/roms/component.py
+++ b/cstar/roms/component.py
@@ -5,7 +5,9 @@ from pathlib import Path
 from datetime import datetime
 from typing import Optional, TYPE_CHECKING, List
 
-from cstar.scheduler.job import create_scheduler_job
+from cstar.execution.scheduler_job import create_scheduler_job
+from cstar.execution.local_process import LocalProcess
+from cstar.execution.handler import ExecutionStatus
 from cstar.base.utils import _replace_text_in_file
 from cstar.base.component import Component
 from cstar.roms.base_model import ROMSBaseModel
@@ -24,7 +26,7 @@ from cstar.system.manager import cstar_sysmgr
 
 if TYPE_CHECKING:
     from cstar.roms import ROMSBaseModel
-    from cstar.scheduler.job import SchedulerJob
+    from cstar.execution.handler import ExecutionHandler
 
 
 class ROMSComponent(Component):
@@ -151,6 +153,8 @@ class ROMSComponent(Component):
         self.exe_path: Optional[Path] = None
         self.partitioned_files: List[Path] | None = None
 
+        self._execution_handler: Optional["ExecutionHandler"] = None
+
     @property
     def in_file(self) -> Path:
         """Find the .in file associated with this ROMSComponent in
@@ -853,7 +857,7 @@ class ROMSComponent(Component):
         walltime: Optional[str] = None,
         queue_name: Optional[str] = None,
         job_name: Optional[str] = None,
-    ) -> Optional["SchedulerJob"]:
+    ) -> "ExecutionHandler":
         """Runs the executable created by `build()`
 
         This method creates a temporary file to be submitted to the job scheduler (if any)
@@ -923,7 +927,7 @@ class ROMSComponent(Component):
 
         output_dir.mkdir(parents=True, exist_ok=True)
 
-        ## 2: RUN ON THIS MACHINE
+        ## 2: RUN ROMS
 
         roms_exec_cmd = (
             f"{cstar_sysmgr.environment.mpi_exec_prefix} -n {self.discretization.n_procs_tot} {self.exe_path} "
@@ -952,85 +956,14 @@ class ROMSComponent(Component):
             )
 
             job_instance.submit()
+            self._execution_handler = job_instance
             return job_instance
 
         else:  # cstar_sysmgr.scheduler is None
-            import time
-
-            romsprocess = subprocess.Popen(
-                roms_exec_cmd,
-                shell=True,
-                cwd=run_path,
-                stdout=subprocess.PIPE,
-                stderr=subprocess.PIPE,
-                text=True,
-            )
-
-            # Process stdout line-by-line
-            tstep0 = 0
-            roms_init_string = ""
-            if romsprocess.stdout is None:
-                raise RuntimeError("ROMS is not producing stdout")
-
-            # 2024-09-21 : the following is included as in some instances ROMS
-            # will exit with code 0 even if a fatal error occurs, see:
-            # https://github.com/CESR-lab/ucla-roms/issues/42
-
-            debugging = False  # Print raw output if true
-            if debugging:
-                while True:
-                    output = romsprocess.stdout.readline()
-                    if output == "" and romsprocess.poll() is not None:
-                        break
-                    if output:
-                        print(output.strip())
-            else:
-                for line in romsprocess.stdout:
-                    # Split the line by whitespace
-                    parts = line.split()
-
-                    # Check if there are exactly 9 parts and the first one is an integer
-                    if len(parts) == 9:
-                        try:
-                            # Try to convert the first part to an integer
-                            tstep = int(parts[0])
-                            if tstep0 == 0:
-                                tstep0 = tstep
-                                T0 = time.time()
-                                # Capture the first integer and print it
-                            else:
-                                ETA = (n_time_steps - (tstep - tstep0)) * (
-                                    (time.time() - T0) / (tstep - tstep0)
-                                )
-                                total_print_statements = 50
-                                print_frq = n_time_steps // total_print_statements + 1
-                                if ((tstep - tstep0) % print_frq) == 0:
-                                    print(
-                                        f"Running ROMS: time-step {tstep-tstep0} of {n_time_steps} ({time.time()-T0:.1f}s elapsed; ETA {ETA:.1f}s)"
-                                    )
-                        except ValueError:
-                            pass
-                    elif tstep0 == 0 and len(roms_init_string) == 0:
-                        roms_init_string = "Running ROMS: Initializing run..."
-                        print(roms_init_string)
-
-            romsprocess.wait()
-            if romsprocess.returncode != 0:
-                import datetime as dt
-
-                errlog = (
-                    output_dir
-                    / f"ROMS_STDERR_{dt.datetime.now().strftime('%Y%m%d_%H%M%S')}.log"
-                )
-                if romsprocess.stderr is not None:
-                    with open(errlog, "w") as F:
-                        F.write(romsprocess.stderr.read())
-                raise RuntimeError(
-                    f"ROMS terminated with errors. See {errlog} for further information."
-                )
-            return None
-
-        return None
+            romsprocess = LocalProcess(commands=roms_exec_cmd, run_path=run_path)
+            self._execution_handler = romsprocess
+            romsprocess.start()
+            return romsprocess
 
     def post_run(self, output_dir: Optional[str | Path] = None) -> None:
         """Performs post-processing steps associated with this ROMSComponent object.
@@ -1043,6 +976,17 @@ class ROMSComponent(Component):
         output_dir: str | Path
             The directory in which output was produced by the run
         """
+
+        if self._execution_handler is None:
+            raise RuntimeError(
+                "Cannot call 'ROMSComponent.post_run()' before calling 'ROMSComponent.run()'"
+            )
+        elif self._execution_handler.status != ExecutionStatus.COMPLETED:
+            raise RuntimeError(
+                "Cannot call 'ROMSComponent.post_run()' until the ROMS run is completed, "
+                + f"but current execution status is '{self._execution_handler.status}'"
+            )
+
         if output_dir is None:
             # This should not be necessary, it allows output_dir to appear
             # optional for signature compatibility in linting, see
diff --git a/docs/5_handling_jobs_on_hpc_systems.ipynb b/docs/5_tracking_runs_executed_as_jobs_on_hpc_systems.ipynb
similarity index 99%
rename from docs/5_handling_jobs_on_hpc_systems.ipynb
rename to docs/5_tracking_runs_executed_as_jobs_on_hpc_systems.ipynb
index 9c947e2..cf01145 100644
--- a/docs/5_handling_jobs_on_hpc_systems.ipynb
+++ b/docs/5_tracking_runs_executed_as_jobs_on_hpc_systems.ipynb
@@ -20,7 +20,7 @@
    "id": "53ab3d32-9292-44b6-a1f5-fa331b654307",
    "metadata": {},
    "source": [
-    "# Handling jobs on HPC systems\n",
+    "# Tracking runs executed as jobs on HPC systems\n",
     "On this page, we will look at how to use C-Star on supported HPC systems with job schedulers, including:\n",
     "\n",
     "- Submitting a job to a scheduler queue\n",
@@ -689,7 +689,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.10.13"
+   "version": "3.13.1"
   }
  },
  "nbformat": 4,
diff --git a/docs/6_tracking_runs_executed_locally.ipynb b/docs/6_tracking_runs_executed_locally.ipynb
new file mode 100644
index 0000000..b7c89e0
--- /dev/null
+++ b/docs/6_tracking_runs_executed_locally.ipynb
@@ -0,0 +1,409 @@
+{
+ "cells": [
+  {
+   "cell_type": "markdown",
+   "id": "970997a3-caeb-42b8-923d-9b86f8c4d94c",
+   "metadata": {},
+   "source": [
+    "> [!Warning] \n",
+    "> **This project is still in an early phase of development.**\n",
+    ">\n",
+    "> The [python API](https://c-star.readthedocs.io/en/latest/api.html) is not yet stable, and some aspects of the schema for the [blueprint](https://c-star.readthedocs.io/en/latest/terminology.html#term-blueprint) will likely evolve. \n",
+    "> Therefore whilst you are welcome to try out using the package, we cannot yet guarantee backwards compatibility. \n",
+    "We expect to reach a more stable version in Q1 2025.\n",
+    ">\n",
+    "> To see which systems C-Star has been tested on so far, see [Supported Systems](https://c-star.readthedocs.io/en/latest/machines.html)."
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "53ab3d32-9292-44b6-a1f5-fa331b654307",
+   "metadata": {},
+   "source": [
+    "# Tracking runs executed locally\n",
+    "On this page, we will look at how to monitor processes created by C-Star where execution is handled locally in more detail.\n",
+    "If you are running C-Star on a supported HPC system with a job scheduler, see [the previous page](5_handling_jobs_on_hpc_systems.html). There are many features in common between jobs run locally and those submitted to a job scheduler, but the former is more simple.\n",
+    "\n",
+    "- Checking the status of a task created by C-Star\n",
+    "- Receiving live updates from a task created by C-Star\n",
+    "- Cancelling a task created by C-Star"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "6f1d2be9-accc-4af6-849d-37d7f0257603",
+   "metadata": {
+    "tags": []
+   },
+   "source": [
+    "## Importing an example Case and running it locally:\n",
+    "We will import and set up an example case similarly to the [previous example](2_importing_and_running_a_case_from_a_blueprint.html)"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 1,
+   "id": "a301fe04-638c-4312-8068-cd4b26249c5f",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "import cstar\n",
+    "\n",
+    "example_case_1 = cstar.Case.from_blueprint(blueprint  = \"../examples/cstar_blueprint_roms_marbl_example.yaml\",\n",
+    "                                           caseroot   = \"../examples/example_case\", \n",
+    "                                           start_date = \"2012-01-03 12:00:00\", \n",
+    "                                           end_date   = \"2012-01-06 12:00:00\")"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "36a4e106-d605-4494-909a-b1c2cbc17eca",
+   "metadata": {},
+   "source": [
+    "## Starting a run locally\n",
+    "We can now set up and run the Case as in the [previous example](2_importing_and_running_a_case_from_a_blueprint.html), assigning the `LocalProcess` instance returned by `Case.run()` to a variable we can keep track of."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "id": "82cb820a-0353-4146-9e01-2e425a615fa2",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "example_case_1.setup()\n",
+    "example_case_1.build()\n",
+    "example_case_1.pre_run()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 3,
+   "id": "071b0e23-1f7f-402d-a2b5-1ae2fae90317",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "\n",
+      "Running ROMS... \n"
+     ]
+    }
+   ],
+   "source": [
+    "cstar_task = example_case_1.run()"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "c0aa814e-d5ef-4f0b-834b-c67d0f47a574",
+   "metadata": {},
+   "source": [
+    "## Tracking the local run\n"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "30c673bb-9490-4feb-aeda-b434dcf1c687",
+   "metadata": {},
+   "source": [
+    "### Viewing the output file path\n",
+    "The output file contains the standard output and error streams returned by the run"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 4,
+   "id": "216bbc79-f578-44d2-867b-7da38b252778",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "PosixPath('/Users/dafyddstephenson/Code/my_c_star/examples/example_case/output/cstar_process_20241224_143121.out')"
+      ]
+     },
+     "execution_count": 4,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "cstar_task.output_file"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "68cf73c3-916f-4007-a9cb-c2e6761fb67c",
+   "metadata": {},
+   "source": [
+    "### Checking the status\n",
+    "We can check the run status using the `status` property. Possible values for a local run are:\n",
+    "\n",
+    "- `UNSUBMITTED`: the run has not yet started\n",
+    "- `RUNNING`: the run is underway\n",
+    "- `COMPLETED`: the run finished successfully\n",
+    "- `CANCELLED`: the run was cancelled by the user\n",
+    "- `FAILED`: the run finished unsuccessfully\n",
+    "- `UNKNOWN`: the status cannot be determined"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 5,
+   "id": "41630183-ecde-4dc2-bfba-41a1457ede1a",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "<ExecutionStatus.RUNNING: 3>"
+      ]
+     },
+     "execution_count": 5,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "cstar_task.status"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "b05c1c44-2d6f-45f3-84fc-4ac071456d6e",
+   "metadata": {
+    "tags": []
+   },
+   "source": [
+    "### Receiving live updates from a local run\n",
+    "While the process is running, we can stream any new lines written to the output file using the `updates()` method. This method receives a `seconds` parameter, and will provide live updates for the number of seconds provided by the user (default 10). If the user specifies `seconds=0`, updates will be provided indefinitely until either the updates are stopped with a keyboard interruption (typically via `Ctrl-c`) or the process ends."
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 6,
+   "id": "0a79a7cc-9281-4bd8-ad4e-ecb32e17a1c0",
+   "metadata": {},
+   "outputs": [
+    {
+     "name": "stdout",
+     "output_type": "stream",
+     "text": [
+      "   2485 4385.8583 1.25835920793-02 1.0622096536-02  0.136952686070  0.136247868683      8     18   61\n",
+      " doing BGC with MARBL\n",
+      "   2486 4385.8625 1.26418727730-02 1.0677227692-02  0.138613564557  0.137943291663      8     18   61\n",
+      " doing BGC with MARBL\n",
+      "   2487 4385.8666 1.27018558957-02 1.0734782520-02  0.140313602622  0.139684982328      8     18   61\n",
+      " doing BGC with MARBL\n",
+      "   2488 4385.8708 1.27644276510-02 1.0795296451-02  0.142053378702  0.141473963532      8     18   61\n",
+      " doing BGC with MARBL\n",
+      "   2489 4385.8750 1.28303755595-02 1.0859155735-02  0.143837624076  0.143314536573      8     18   61\n",
+      "         set_frc :: swrad            input time (days) =   4385.92     rec =  71\n",
+      "         set_frc :: lwrad            input time (days) =   4385.92     rec =  71\n",
+      "         set_frc :: uwnd             input time (days) =   4385.92     rec =  71\n",
+      "         set_frc :: vwnd             input time (days) =   4385.92     rec =  71\n",
+      "         set_frc :: Tair             input time (days) =   4385.92     rec =  71\n",
+      "         set_frc :: qair             input time (days) =   4385.92     rec =  71\n",
+      "         set_frc :: rain             input time (days) =   4385.92     rec =  71\n",
+      " doing BGC with MARBL\n",
+      "   2490 4385.8791 1.29004414329-02 1.0926810649-02  0.145608490301  0.145146964406      8     18   61\n",
+      " doing BGC with MARBL\n",
+      "   2491 4385.8833 1.29708365192-02 1.0995773324-02  0.147131668685  0.146735361928      8     18   61\n",
+      " doing BGC with MARBL\n",
+      "   2492 4385.8875 1.30377943412-02 1.1063338488-02  0.148424019270  0.148095085576      8     18   61\n",
+      " doing BGC with MARBL\n",
+      "   2493 4385.8916 1.31023753611-02 1.1129874381-02  0.149523096643  0.149264579908      8     18   61\n",
+      " doing BGC with MARBL\n",
+      "   2494 4385.8958 1.31652198586-02 1.1195599618-02  0.150478728824  0.150294630798      8     18   61\n",
+      " doing BGC with MARBL\n",
+      "   2495 4385.9000 1.32268445879-02 1.1260610230-02  0.151333910835  0.151257155828      8     18   62\n",
+      " doing BGC with MARBL\n",
+      "   2496 4385.9041 1.32876049836-02 1.1325071888-02  0.152128252273  0.152128252273      8     18   62\n",
+      " doing BGC with MARBL\n",
+      "   2497 4385.9083 1.33478670990-02 1.1389121584-02  0.152959268208  0.152959268208      8     18   62\n",
+      " doing BGC with MARBL\n",
+      "   2498 4385.9125 1.34078950450-02 1.1452897611-02  0.153768111972  0.153768111972      8     18   62\n",
+      " doing BGC with MARBL\n",
+      "   2499 4385.9166 1.34680167142-02 1.1516533838-02  0.154567678966  0.154567678966      8     18   62\n",
+      "         set_frc :: swrad            input time (days) =   4385.96     rec =  72\n",
+      "         set_frc :: lwrad            input time (days) =   4385.96     rec =  72\n",
+      "         set_frc :: uwnd             input time (days) =   4385.96     rec =  72\n",
+      "         set_frc :: vwnd             input time (days) =   4385.96     rec =  72\n",
+      "         set_frc :: Tair             input time (days) =   4385.96     rec =  72\n",
+      "         set_frc :: qair             input time (days) =   4385.96     rec =  72\n",
+      "         set_frc :: rain             input time (days) =   4385.96     rec =  72\n",
+      " doing BGC with MARBL\n",
+      "   2500 4385.9208 1.35283165709-02 1.1580129351-02  0.155325624258  0.155221612013      8     18   61\n",
+      " doing BGC with MARBL\n",
+      "   2501 4385.9250 1.35828230769-02 1.1640319995-02  0.155700653053  0.155481340173      8     18   61\n",
+      " doing BGC with MARBL\n",
+      "   2502 4385.9291 1.36263388869-02 1.1693975898-02  0.155662438206  0.155322944376      8     18   61\n",
+      " doing BGC with MARBL\n",
+      "   2503 4385.9333 1.36600374443-02 1.1741283660-02  0.155263267688  0.154803506067      8     18   61\n",
+      " doing BGC with MARBL\n",
+      "   2504 4385.9375 1.36849625828-02 1.1782576915-02  0.154577909123  0.154000079067      8     18   61\n",
+      " doing BGC with MARBL\n",
+      "   2505 4385.9416 1.37020578756-02 1.1818261666-02  0.153670271592  0.152976916292      8     18   61\n",
+      " doing BGC with MARBL\n",
+      "   2506 4385.9458 1.37121832607-02 1.1848783946-02  0.152590160501  0.151783861959      8     18   61\n",
+      " doing BGC with MARBL\n",
+      "   2507 4385.9500 1.37161351454-02 1.1874569011-02  0.151374986137  0.150458733231      8     18   61\n",
+      " doing BGC with MARBL\n",
+      "   2508 4385.9541 1.37144734308-02 1.1896005050-02  0.150053002374  0.149030644412      8     18   61\n",
+      " doing BGC with MARBL\n",
+      "   2509 4385.9583 1.37078436551-02 1.1913471360-02  0.148646075415  0.147522693372      8     18   61\n",
+      "         set_frc :: swrad            input time (days) =   4386.00     rec =  73\n",
+      "         set_frc :: lwrad            input time (days) =   4386.00     rec =  73\n",
+      "         set_frc :: uwnd             input time (days) =   4386.00     rec =  73\n",
+      "         set_frc :: vwnd             input time (days) =   4386.00     rec =  73\n",
+      "         set_frc :: Tair             input time (days) =   4386.00     rec =  73\n",
+      "         set_frc :: qair             input time (days) =   4386.00     rec =  73\n",
+      "         set_frc :: rain             input time (days) =   4386.00     rec =  73\n",
+      " doing BGC with MARBL\n",
+      "   2510 4385.9625 1.36966772006-02 1.1927277505-02  0.147160964026  0.145942604721      8     18   61\n",
+      " doing BGC with MARBL\n",
+      "   2511 4385.9666 1.36809635410-02 1.1937393429-02  0.145575738835  0.144268441761      8     18   61\n",
+      " doing BGC with MARBL\n",
+      "   2512 4385.9708 1.36607130946-02 1.1943812910-02  0.143911766375  0.142521457819      8     18   61\n",
+      " doing BGC with MARBL\n",
+      "   2513 4385.9750 1.36363893415-02 1.1946799844-02  0.142189803708  0.140721996579      8     18   61\n",
+      " doing BGC with MARBL\n",
+      "   2514 4385.9791 1.36084937336-02 1.1946561437-02  0.140429539964  0.138961591747      8     18   62\n",
+      " doing BGC with MARBL\n",
+      "   2515 4385.9833 1.35771853114-02 1.1943254440-02  0.138650105839  0.137111897393      8     18   62\n",
+      " doing BGC with MARBL\n",
+      "   2516 4385.9875 1.35429113517-02 1.1937106844-02  0.136856769268  0.135251514662      8     18   62\n",
+      " doing BGC with MARBL\n",
+      "   2517 4385.9916 1.35060264336-02 1.1928413681-02  0.135055284864  0.133386889470      8     18   62\n",
+      " doing BGC with MARBL\n",
+      "   2518 4385.9958 1.34667019800-02 1.1917333838-02  0.133250058213  0.131523540883      8     18   62\n",
+      " doing BGC with MARBL\n",
+      "   2519 4386.0000 1.34251380663-02 1.1903898010-02  0.131446412425  0.129667318138      8     18   62\n",
+      "       ocean_vars :: wrote history, tdays =   4386.0000  step =   2519 rec =   3\n",
+      "       wrt_bgc_tracers :: history , trc =  PO4                                       \n",
+      "       wrt_bgc_tracers :: history , trc =  NO3                                       \n",
+      "       wrt_bgc_tracers :: history , trc =  SiO3                                      \n",
+      "       wrt_bgc_tracers :: history , trc =  NH4                                       \n",
+      "       wrt_bgc_tracers :: history , trc =  Fe                                        \n",
+      "       wrt_bgc_tracers :: history , trc =  Lig                                       \n",
+      "       wrt_bgc_tracers :: history , trc =  O2                                        \n",
+      "       wrt_bgc_tracers :: history , trc =  DIC                                       \n",
+      "       wrt_bgc_tracers :: history , trc =  DIC_ALT_CO2                               \n",
+      "       wrt_bgc_tracers :: history , trc =  ALK                                       \n",
+      "       wrt_bgc_tracers :: history , trc =  ALK_ALT_CO2                               \n",
+      "       wrt_bgc_tracers :: history , trc =  DOC                                       \n",
+      "       wrt_bgc_tracers :: history , trc =  DON                                       \n",
+      "       wrt_bgc_tracers :: history , trc =  DOP                                       \n",
+      "       wrt_bgc_tracers :: history , trc =  DOPr                                      \n",
+      "       wrt_bgc_tracers :: history , trc =  DONr                                      \n",
+      "       wrt_bgc_tracers :: history , trc =  DOCr                                      \n",
+      "       wrt_bgc_tracers :: history , trc =  zooC                                      \n",
+      "       wrt_bgc_tracers :: history , trc =  spChl                                     \n",
+      "       wrt_bgc_tracers :: history , trc =  spC                                       \n",
+      "       wrt_bgc_tracers :: history , trc =  spP                                       \n",
+      "       wrt_bgc_tracers :: history , trc =  spFe                                      \n",
+      "       wrt_bgc_tracers :: history , trc =  spCaCO3                                   \n",
+      "       wrt_bgc_tracers :: history , trc =  diatChl                                   \n",
+      "       wrt_bgc_tracers :: history , trc =  diatC                                     \n",
+      "       wrt_bgc_tracers :: history , trc =  diatP                                     \n",
+      "       wrt_bgc_tracers :: history , trc =  diatFe                                    \n",
+      "       wrt_bgc_tracers :: history , trc =  diatSi                                    \n",
+      "       wrt_bgc_tracers :: history , trc =  diazChl                                   \n",
+      "       wrt_bgc_tracers :: history , trc =  diazC                                     \n",
+      "       wrt_bgc_tracers :: history , trc =  diazP                                     \n",
+      "       wrt_bgc_tracers :: history , trc =  diazFe                                    \n",
+      "       bgc :: wrote history, tdays =   4386.0000  step =   2518 rec =   3\n",
+      "       bgc diag :: wrote history, tdays =   4386.0000  step =   2519 rec =   3\n",
+      "         set_frc :: zeta_east        input time (days) =   4387.00     rec =   5\n",
+      "         set_frc :: zeta_west        input time (days) =   4387.00     rec =   5\n",
+      "         set_frc :: zeta_south       input time (days) =   4387.00     rec =   5\n",
+      "         set_frc :: zeta_north       input time (days) =   4387.00     rec =   5\n",
+      "         set_frc :: swrad            input time (days) =   4386.04     rec =  74\n",
+      "         set_frc :: lwrad            input time (days) =   4386.04     rec =  74\n",
+      "         set_frc :: uwnd             input time (days) =   4386.04     rec =  74\n",
+      "         set_frc :: vwnd             input time (days) =   4386.04     rec =  74\n",
+      "         set_frc :: Tair             input time (days) =   4386.04     rec =  74\n",
+      "         set_frc :: qair             input time (days) =   4386.04     rec =  74\n",
+      "         set_frc :: rain             input time (days) =   4386.04     rec =  74\n",
+      " doing BGC with MARBL\n",
+      "   2520 4386.0041 1.33813167950-02 1.1888112769-02  0.129656274071  0.127829968916      8     18   62\n",
+      " doing BGC with MARBL\n",
+      "   2521 4386.0083 1.33372723425-02 1.1871329181-02  0.127909737805  0.126040534783      8     18   62\n",
+      " doing BGC with MARBL\n",
+      "   2522 4386.0125 1.32937687605-02 1.1853843466-02  0.126215169511  0.124305538389      8     18   62\n",
+      " doing BGC with MARBL\n",
+      "   2523 4386.0166 1.32504548141-02 1.1835566947-02  0.124575269680  0.122625412446      8     18   62\n",
+      " doing BGC with MARBL\n",
+      "   2524 4386.0208 1.32071760708-02 1.1816443813-02  0.122986966618  0.120995584727      8     18   62\n"
+     ]
+    }
+   ],
+   "source": [
+    "cstar_task.updates(seconds=2)"
+   ]
+  },
+  {
+   "cell_type": "markdown",
+   "id": "86c42fba-256a-4950-b324-b58413c598e1",
+   "metadata": {},
+   "source": [
+    "### Cancelling a job\n",
+    "We can cancel the job using the `cancel` method:"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 7,
+   "id": "428f25ea-43d8-4462-9b80-d912552aa403",
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "cstar_task.cancel()"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": 8,
+   "id": "10926495-2da3-4a20-b663-d238d6d96a43",
+   "metadata": {},
+   "outputs": [
+    {
+     "data": {
+      "text/plain": [
+       "<ExecutionStatus.CANCELLED: 5>"
+      ]
+     },
+     "execution_count": 8,
+     "metadata": {},
+     "output_type": "execute_result"
+    }
+   ],
+   "source": [
+    "cstar_task.status"
+   ]
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Python 3 (ipykernel)",
+   "language": "python",
+   "name": "python3"
+  },
+  "language_info": {
+   "codemirror_mode": {
+    "name": "ipython",
+    "version": 3
+   },
+   "file_extension": ".py",
+   "mimetype": "text/x-python",
+   "name": "python",
+   "nbconvert_exporter": "python",
+   "pygments_lexer": "ipython3",
+   "version": "3.13.1"
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/docs/api.rst b/docs/api.rst
index f0127b5..f6a5670 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -67,9 +67,10 @@ Scheduler Job
 .. autosummary::
    :toctree: generated/
 
-   cstar.scheduler.job.SchedulerJob
-   cstar.scheduler.job.SlurmJob
-   cstar.scheduler.job.PBSJob
+   cstar.execution.scheduler_job.SchedulerJob
+   cstar.execution.scheduler_job.SlurmJob
+   cstar.execution.scheduler_job.PBSJob
+   cstar.execution.local_process.LocalProcess
    
 System
 ------
diff --git a/docs/index.rst b/docs/index.rst
index aeddd08..de6b2b9 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -33,7 +33,8 @@ A key strength of C-Star lies in its ability to run regional simulations using a
    Importing and running a Case from a blueprint <2_importing_and_running_a_case_from_a_blueprint>
    Restarting and continuing a Case <3_restarting_and_continuing_a_case>
    Preparing input datasets for a Case with ROMS using roms-tools <4_preparing_roms_input_datasets>
-   Handling jobs on HPC systems <5_handling_jobs_on_hpc_systems>
+   Tracking runs executed as jobs on HPC systems <5_tracking_runs_executed_as_jobs_on_hpc_systems>
+   Tracking runs executed locally <6_tracking_runs_executed_locally>
 
 .. toctree::
    :maxdepth: 1
diff --git a/docs/releases.rst b/docs/releases.rst
index f76a0f7..cc4863f 100644
--- a/docs/releases.rst
+++ b/docs/releases.rst
@@ -1,5 +1,15 @@
 Release notes
 =============
+.. _v0.0.6-alpha:
+
+v0.0.6-alpha ()
+---------------------------
+
+New features:
+~~~~~~~~~~~~~
+- Add ExecutionHandler class to track tasks run locally (LocalProcess subclass) or submitted to a job scheduler (SchedulerJob subclass)
+
+
 
 .. _v0.0.3-alpha:
 
diff --git a/examples/cstar_example.py b/examples/cstar_example.py
index ab2ef57..180fc0c 100644
--- a/examples/cstar_example.py
+++ b/examples/cstar_example.py
@@ -10,7 +10,7 @@ roms_marbl_case = cstar.Case.from_blueprint(
     blueprint="cstar_blueprint_roms_marbl_example.yaml",
     caseroot="roms_marbl_example_case/",
     start_date="20120103 12:00:00",
-    end_date="20120104 12:00:00",
+    end_date="20120103 18:00:00",
 )
 
 ## In a python session, execute:


Test patch:
diff --git a/cstar/tests/integration_tests/test_cstar_test_blueprints.py b/cstar/tests/integration_tests/test_cstar_test_blueprints.py
index f99db1b..3591b00 100644
--- a/cstar/tests/integration_tests/test_cstar_test_blueprints.py
+++ b/cstar/tests/integration_tests/test_cstar_test_blueprints.py
@@ -78,5 +78,6 @@ class TestCStar:
         cstar_test_case.to_blueprint(tmpdir / "test_blueprint_persistence.yaml")
         cstar_test_case.build()
         cstar_test_case.pre_run()
-        cstar_test_case.run()
+        test_process = cstar_test_case.run()
+        test_process.updates(seconds=0, confirm_indefinite=False)
         cstar_test_case.post_run()
diff --git a/cstar/tests/unit_tests/execution/test_handler.py b/cstar/tests/unit_tests/execution/test_handler.py
new file mode 100644
index 0000000..40c0b4a
--- /dev/null
+++ b/cstar/tests/unit_tests/execution/test_handler.py
@@ -0,0 +1,251 @@
+import time
+import threading
+from unittest.mock import patch, PropertyMock
+from pathlib import Path
+from cstar.execution.handler import ExecutionHandler, ExecutionStatus
+
+
+class MockExecutionHandler(ExecutionHandler):
+    """Mock implementation of `ExecutionHandler` for testing purposes."""
+
+    def __init__(self, status, output_file):
+        self._status = status
+        self._output_file = Path(output_file)
+
+    @property
+    def status(self):
+        return self._status
+
+    @property
+    def output_file(self):
+        return self._output_file
+
+
+class TestExecutionHandlerUpdates:
+    """Tests for the `updates` method in the `ExecutionHandler` class.
+
+    Tests
+    -----
+    test_updates_non_running_job
+        Validates that `updates()` provides appropriate feedback when the job is not running.
+    test_updates_running_job_with_tmp_file
+        Verifies that `updates()` streams live updates from the output file when the job is running.
+    test_updates_indefinite_with_seconds_param_0
+        Confirms that `updates()` runs indefinitely when `seconds=0` and allows termination via user interruption.
+    """
+
+    def test_updates_non_running_job(self, tmp_path):
+        """Validates that `updates()` provides appropriate feedback when the job is not
+        running.
+
+        This test ensures that if the job status is not `RUNNING`, the method informs
+        the user and provides instructions for viewing the job output if applicable.
+
+        Mocks
+        -----
+        MockExecutionHandler.status
+            Mocked to return `ExecutionStatus.COMPLETED`, simulating a non-running job.
+        builtins.print
+            Mocked to capture printed messages for validation.
+
+        Asserts
+        -------
+        - That the user is informed the job is not running.
+        - That instructions for viewing the job output are provided if the job status
+          is `COMPLETED` or similar.
+        """
+        handler = MockExecutionHandler(
+            ExecutionStatus.COMPLETED, tmp_path / "mock_output.log"
+        )
+
+        with patch("builtins.print") as mock_print:
+            handler.updates(seconds=10)
+            mock_print.assert_any_call(
+                "This job is currently not running (completed). Live updates cannot be provided."
+            )
+            mock_print.assert_any_call(
+                f"See {handler.output_file.resolve()} for job output"
+            )
+
+    def test_updates_running_job_with_tmp_file(self, tmp_path):
+        """Verifies that `updates()` streams live updates from the job's output file
+        when the job is running.
+
+        This test creates a temporary output file and pre-populates it with initial
+        content. Live updates are then appended to the file in real-time using a
+        background thread, simulating a running job. The `updates()` method is tested
+        to ensure it reads and prints both the pre-existing content and new updates.
+
+        Mocks
+        -----
+        MockExecutionHandler.status
+            Mocked to return `ExecutionStatus.RUNNING`, simulating a running job.
+        builtins.print
+            Mocked to capture printed messages for validation.
+
+        Asserts
+        -------
+        - That all live updates appended to the output file are printed correctly.
+        - That previously existing content in the file is also printed.
+        - That the method properly interacts with the output file in real-time.
+        """
+        # Create a temporary output file
+        output_file = tmp_path / "output.log"
+        initial_content = ["First line\n"]
+        live_updates = ["Second line\n", "Third line\n", "Fourth line\n"]
+
+        # Write initial content to the file
+        with output_file.open("w") as f:
+            f.writelines(initial_content)
+
+        handler = MockExecutionHandler(ExecutionStatus.RUNNING, output_file)
+
+        # Function to simulate appending live updates to the file
+        def append_live_updates():
+            with output_file.open("a") as f:
+                for line in live_updates:
+                    time.sleep(0.01)  # Ensure `updates()` is actively reading
+                    f.write(line)
+                    f.flush()  # Immediately write to disk
+
+        # Start the live update simulation in a background thread
+        updater_thread = threading.Thread(target=append_live_updates, daemon=True)
+        updater_thread.start()
+
+        # Run the `updates` method
+        with patch("builtins.print") as mock_print:
+            handler.updates(seconds=0.25)
+
+            # Ensure both initial and live update lines are printed
+            for line in live_updates:
+                mock_print.assert_any_call(line, end="")
+
+        # Ensure the thread finishes before the test ends
+        updater_thread.join()
+
+    def test_updates_indefinite_with_seconds_param_0(self, tmp_path):
+        """Confirms that `updates()` runs indefinitely when `seconds=0` and allows
+        termination via user interruption.
+
+        This test creates a temporary output file and pre-populates it with content.
+        Unlike `test_updates_running_job_with_tmp_file`, this test does not continue
+        to update the temporary file, so no updates are actually provided. The
+        stream is then killed via a simulated `KeyboardInterrupt` and the test
+        ensures a graceful exit.
+
+        Mocks
+        -----
+        MockExecutionHandler.status
+            Mocked to return `ExecutionStatus.RUNNING`, simulating a running job.
+        builtins.input
+            Mocked to simulate user responses to the confirmation prompt.
+        builtins.print
+            Mocked to capture printed messages for validation.
+        time.sleep
+            Mocked to simulate a `KeyboardInterrupt` during indefinite updates.
+
+        Asserts
+        -------
+        - That the user is prompted to confirm running the updates indefinitely.
+        - That the method handles user interruptions and prints the appropriate message.
+        - That no additional file operations occur if the user denies the confirmation.
+        """
+        # Create a temporary output file
+        output_file = tmp_path / "output.log"
+        content = ["Line 1\n", "Line 2\n", "Line 3\n"]
+
+        # Write initial content to the file
+        with output_file.open("w") as f:
+            f.writelines(content)
+
+        handler = MockExecutionHandler(ExecutionStatus.RUNNING, output_file)
+
+        # Mock the `status` property to return "running"
+        with patch.object(
+            MockExecutionHandler, "status", new_callable=PropertyMock
+        ) as mock_status:
+            mock_status.return_value = ExecutionStatus.RUNNING
+
+            # Patch input to simulate the confirmation prompt
+            with patch("builtins.input", side_effect=["y"]) as mock_input:
+                # Replace `time.sleep` with a side effect that raises KeyboardInterrupt
+                with patch("builtins.print") as mock_print:
+                    # Simulate a KeyboardInterrupt during the updates call
+                    with patch("time.sleep", side_effect=KeyboardInterrupt):
+                        handler.updates(seconds=0)  # Run updates indefinitely
+
+                        # Assert that the "stopped by user" message was printed
+                        mock_print.assert_any_call(
+                            "\nLive status updates stopped by user."
+                        )
+
+                # Verify that the prompt was displayed to the user
+                mock_input.assert_called_once_with(
+                    "This will provide indefinite updates to your job. You can stop it anytime using Ctrl+C. "
+                    "Do you want to continue? (y/n): "
+                )
+            # Patch input to simulate the confirmation prompt
+            with patch("builtins.input", side_effect=["n"]) as mock_input:
+                with patch("builtins.open", create=True) as mock_open:
+                    handler.updates(seconds=0)
+                    mock_open.assert_not_called()
+
+    def test_updates_stops_when_status_changes(self, tmp_path):
+        """Verifies that `updates()` stops execution when `status` changes to non-
+        RUNNING.
+
+        This test ensures:
+        - The conditional block exits `updates` when `status` is not `RUNNING`.
+        - Only lines added while the job is `RUNNING` are streamed.
+        """
+        # Create a temporary output file
+        output_file = tmp_path / "output.log"
+        initial_content = ["First line\n"]
+        running_updates = ["Second line\n", "Third line\n"]
+        completed_updates = ["Fourth line\n", "Fifth line\n"]
+        # Write initial content to the file
+        with output_file.open("w") as f:
+            f.writelines(initial_content)
+
+        # Initialize the handler with status `RUNNING`
+        handler = MockExecutionHandler(ExecutionStatus.RUNNING, output_file)
+
+        # Function to simulate appending live updates and changing status
+        def append_updates_and_change_status():
+            with output_file.open("a") as f:
+                for line in running_updates:
+                    time.sleep(0.1)  # Ensure updates() is actively reading
+                    f.write(line)
+                    f.flush()
+
+                # Change the status to `COMPLETED` after writing running updates
+                time.sleep(0.2)
+                handler._status = ExecutionStatus.COMPLETED
+                for line in completed_updates:
+                    time.sleep(0.1)
+                    f.write(line)
+                    f.flush()
+
+        # Start the background thread to append updates and change status
+        updater_thread = threading.Thread(
+            target=append_updates_and_change_status, daemon=True
+        )
+        updater_thread.start()
+
+        # Run the `updates` method
+        with patch("builtins.print") as mock_print:
+            handler.updates(seconds=0, confirm_indefinite=False)
+
+            # Verify that only lines from `running_updates` were printed
+            printed_calls = [call[0][0] for call in mock_print.call_args_list]
+            # print(printed_calls)
+
+            for line in running_updates:
+                assert line in printed_calls
+
+            # Verify that lines from `completed_updates` were not printed
+            for line in completed_updates:
+                assert line not in printed_calls
+
+        # Ensure the thread finishes before the test ends
+        updater_thread.join()
diff --git a/cstar/tests/unit_tests/execution/test_local_process.py b/cstar/tests/unit_tests/execution/test_local_process.py
new file mode 100644
index 0000000..ad50f05
--- /dev/null
+++ b/cstar/tests/unit_tests/execution/test_local_process.py
@@ -0,0 +1,392 @@
+from unittest.mock import MagicMock, patch
+from pathlib import Path
+from cstar.execution.local_process import LocalProcess, ExecutionStatus
+import subprocess
+
+
+class TestLocalProcess:
+    """Tests for the `LocalProcess` class.
+
+    This test class covers the functionality of the `LocalProcess` class, which is a subclass
+    of `ExecutionHandler` designed to manage task execution in a local subprocess. It validates the
+    class's behavior across its lifecycle, including initialization, starting processes,
+    monitoring status, and cancellation.
+
+    Tests
+    -----
+    test_initialization_defaults
+        Ensures that default attributes are correctly assigned during initialization.
+    test_start_success
+        Verifies that the subprocess starts successfully with valid commands and updates the status.
+    test_status
+        Confirms the `status` property reflects the process's lifecycle states (e.g., RUNNING, COMPLETED, FAILED).
+    test_status_failed_closes_file_handle
+        Ensures that a failed process closes the output file handle and updates the status to FAILED.
+    test_status_unknown_no_returncode
+        Validates that the status is `UNKNOWN` when the subprocess state cannot be determined.
+    test_cancel_graceful_termination
+        Ensures that `cancel` gracefully terminates a running process using `terminate` without requiring `kill`.
+    test_cancel_forceful_termination
+        Confirms that `cancel` forcefully terminates a process using `kill` after `terminate` times out.
+    test_cancel_non_running_process
+        Validates that `cancel` does not attempt to terminate or kill a non-running process and provides feedback.
+    """
+
+    def setup_method(self, method):
+        """Sets up reusable parameters and paths for tests in `TestLocalProcess`.
+
+        This method initializes common attributes for creating a `LocalProcess`
+        instance, including the commands to execute, the output file path, and
+        the run directory.
+
+        Parameters
+        ----------
+        method : Callable
+            The test method being executed. This parameter is part of the pytest
+            `setup_method` signature but is not used in this setup.
+
+        Attributes
+        ----------
+        - commands : str
+            The shell command(s) to execute.
+        """
+        self.commands = "echo Hello, World"
+
+    def test_initialization_defaults(self, tmp_path):
+        """Ensures that default attributes are correctly applied during initialization.
+
+        This test verifies that if `run_path` and `output_file` are not explicitly
+        provided, `LocalProcess` assigns default values based on the current
+        working directory.
+
+        Asserts
+        -------
+        - That `run_path` defaults to `Path.cwd()`.
+        - That `output_file` is auto-generated with a timestamped name.
+        """
+        process = LocalProcess(commands=self.commands, run_path=None, output_file=None)
+        assert process.run_path == Path.cwd()
+        assert process.output_file.name.startswith("cstar_process_")
+        assert process.status == ExecutionStatus.UNSUBMITTED
+
+    @patch("subprocess.Popen")
+    def test_start_success(self, mock_popen, tmp_path):
+        """Verifies that the subprocess starts successfully with valid commands.
+
+        This test ensures that the `start` method:
+        - Initializes a subprocess with the correct parameters (commands, working directory).
+        - Redirects the standard output and error to the specified output file.
+        - Updates the `status` property to `RUNNING`.
+
+        Mocks
+        -----
+        subprocess.Popen
+            Mocked to simulate a successful subprocess startup.
+
+        Asserts
+        -------
+        - That the subprocess is called with the correct arguments.
+        - That the `status` property reflects the `RUNNING` state after startup.
+        """
+        mock_popen.return_value.poll.return_value = None  # Simulate running process
+        process = LocalProcess(
+            commands=self.commands,
+            run_path=tmp_path,
+            output_file=tmp_path / "output.log",
+        )
+
+        with patch("builtins.open", MagicMock()) as mock_open:
+            process.start()
+            mock_open.assert_called_once_with(process.output_file, "w")
+
+        mock_popen.assert_called_once_with(
+            self.commands.split(),
+            cwd=tmp_path,
+            stdin=subprocess.PIPE,
+            stdout=mock_open.return_value,
+            stderr=subprocess.STDOUT,
+        )
+        assert process.status == ExecutionStatus.RUNNING
+
+    @patch("subprocess.Popen")
+    def test_status(self, mock_popen, tmp_path):
+        """Tests that the `status` property reflects the subprocess lifecycle.
+
+        This test covers the following states:
+        - `UNSUBMITTED`: Before `start()` is called.
+        - `RUNNING`: After `start()` is called and the process is active.
+        - `COMPLETED`: After the process terminates successfully.
+        - `FAILED`: After the process terminates with a non-zero return code.
+        - `UNKNOWN`: When the process state is indeterminate.
+
+        Mocks
+        -----
+        subprocess.Popen
+            Mocked to simulate the subprocess lifecycle.
+
+        Asserts
+        -------
+        - That the `status` property reflects the correct `ExecutionStatus` for
+          each stage of the process lifecycle.
+        """
+        # Mock subprocess behavior
+        mock_process = MagicMock()
+        mock_popen.return_value = mock_process
+
+        # Initial state: UNSUBMITTED
+        process = LocalProcess(
+            commands=self.commands,
+            run_path=tmp_path,
+            output_file=tmp_path / "output.log",
+        )
+        assert process.status == ExecutionStatus.UNSUBMITTED
+
+        # After starting: RUNNING
+        mock_process.poll.return_value = None  # Simulate running process
+        process.start()
+        assert process.status == ExecutionStatus.RUNNING
+
+        # After completion: COMPLETED
+        mock_process.poll.return_value = 0  # Simulate successful termination
+        mock_process.returncode = 0
+        assert process.status == ExecutionStatus.COMPLETED
+
+        # After failure: FAILED
+        mock_process.poll.return_value = 1  # Simulate unsuccessful termination
+        mock_process.returncode = 1
+        assert process.status == ExecutionStatus.FAILED
+
+        # Indeterminate state: UNKNOWN
+        mock_process.returncode = None  # Indeterminate state
+        process._process = mock_process  # Ensure process is assigned
+        assert process.status == ExecutionStatus.UNKNOWN
+
+    @patch("subprocess.Popen")
+    def test_status_failed_closes_file_handle(self, mock_popen, tmp_path):
+        """Verifies that a failed process closes the output file handle.
+
+        This test ensures:
+        - The `_output_file_handle` is closed and set to `None` after failure.
+        - The status is set to `ExecutionStatus.FAILED`.
+
+        Mocks
+        -----
+        subprocess.Popen
+            Mocked to simulate a failed subprocess.
+
+        Asserts
+        -------
+        - That `_output_file_handle.close()` is called.
+        - That `_output_file_handle` is set to `None`.
+        - That the status is `FAILED`.
+        """
+        # Mock subprocess behavior
+        mock_process = MagicMock()
+        mock_popen.return_value = mock_process
+        mock_process.returncode = 1  # Process failed
+
+        # Create the LocalProcess instance
+        process = LocalProcess(
+            commands=self.commands,
+            run_path=tmp_path,
+            output_file=tmp_path / "output.log",
+        )
+        process._process = mock_process
+
+        # Mock the output file handle
+        mock_file_handle = MagicMock()
+        process._output_file_handle = mock_file_handle
+
+        # Check the status
+        assert process.status == ExecutionStatus.FAILED
+        mock_file_handle.close.assert_called_once()  # Ensure the file handle is closed
+        assert process._output_file_handle is None  # Ensure the file handle is cleared
+
+    @patch("subprocess.Popen")
+    def test_cancel_graceful_termination(self, mock_popen, tmp_path):
+        """Ensures that the `cancel` method gracefully terminates a running process.
+
+        This test verifies:
+        - `terminate()` is called to stop the process.
+        - `kill()` is not called when `terminate()` succeeds.
+        - The `status` property is updated to `CANCELLED`.
+
+        Mocks
+        -----
+        subprocess.Popen
+            Mocked to simulate the subprocess lifecycle.
+
+        Asserts
+        -------
+        - That `terminate()` is called to stop the process.
+        - That `kill()` is not called if `terminate()` succeeds.
+        - That the `status` property reflects the `CANCELLED` state after cancellation.
+        """
+        # Mock subprocess behavior
+        mock_process = MagicMock()
+        mock_popen.return_value = mock_process
+
+        # Simulate a running process
+        mock_process.poll.return_value = None  # Process is active
+        process = LocalProcess(
+            commands=self.commands,
+            run_path=tmp_path,
+            output_file=tmp_path / "output.log",
+        )
+        process.start()
+
+        # Verify the process has started
+        assert process.status == ExecutionStatus.RUNNING
+        assert process._process is mock_process  # Ensure the process was assigned
+
+        # Test graceful termination
+        process.cancel()
+        mock_process.terminate.assert_called_once()  # Ensure terminate() was called
+        mock_process.kill.assert_not_called()  # kill() should not be called if terminate succeeds
+        assert process.status == ExecutionStatus.CANCELLED
+
+    @patch("subprocess.Popen")
+    def test_cancel_forceful_termination(self, mock_popen, tmp_path):
+        """Ensures that the `cancel` method forcefully terminates a process if needed.
+
+        This test verifies:
+        - `terminate()` is called but fails with a `TimeoutExpired` exception.
+        - `kill()` is called as a fallback to stop the process.
+        - The `status` property is updated to `CANCELLED`.
+
+        Mocks
+        -----
+        subprocess.Popen
+            Mocked to simulate the subprocess lifecycle.
+
+        Asserts
+        -------
+        - That `terminate()` is called to stop the process.
+        - That `kill()` is called if `terminate()` fails.
+        - That the `status` property reflects the `CANCELLED` state after cancellation.
+        """
+        # Mock subprocess behavior
+        mock_process = MagicMock()
+        mock_popen.return_value = mock_process
+
+        # Simulate a running process
+        mock_process.poll.return_value = None  # Process is active
+        mock_process.terminate.side_effect = subprocess.TimeoutExpired(
+            cmd="mock", timeout=5
+        )  # Simulate timeout
+        process = LocalProcess(
+            commands=self.commands,
+            run_path=tmp_path,
+            output_file=tmp_path / "output.log",
+        )
+        process.start()
+
+        # Verify the process has started
+        assert process.status == ExecutionStatus.RUNNING
+        assert process._process is mock_process  # Ensure the process was assigned
+
+        # Test forceful termination
+        process.cancel()
+        mock_process.terminate.assert_called_once()  # Ensure terminate() was called
+        mock_process.kill.assert_called_once()  # Ensure kill() was called after terminate timeout
+        assert process.status == ExecutionStatus.CANCELLED
+
+    @patch("subprocess.Popen")
+    @patch("builtins.print")
+    def test_cancel_non_running_process(self, mock_print, mock_popen, tmp_path):
+        """Ensures that `cancel` does not attempt to terminate a non-running process.
+
+        This test verifies:
+        - The `print` statement is invoked with the correct message.
+        - Neither `terminate` nor `kill` is called.
+
+        Mocks
+        -----
+        subprocess.Popen
+            Mocked to simulate the subprocess lifecycle.
+        builtins.print
+            Mocked to capture printed messages for validation.
+
+        Asserts
+        -------
+        - That the `print` statement outputs the correct message.
+        - That neither `terminate` nor `kill` is called.
+        """
+        # Mock subprocess behavior
+        mock_process = MagicMock()
+        mock_popen.return_value = mock_process
+
+        # Simulate a completed process
+        mock_process.poll.return_value = 0  # Process has completed
+        process = LocalProcess(
+            commands=self.commands,
+            run_path=tmp_path,
+            output_file=tmp_path / "output.log",
+        )
+        process._process = mock_process  # Directly assign the mock process
+
+        # Test cancel on a completed process
+        process.cancel()
+        mock_print.assert_called_once_with(
+            f"Cannot cancel job with status '{process.status}'"
+        )
+        mock_process.terminate.assert_not_called()
+        mock_process.kill.assert_not_called()
+
+    @patch("subprocess.Popen")
+    @patch("builtins.print")
+    def test_wait_running_process(self, mock_print, mock_popen, tmp_path):
+        """Ensures that `wait` correctly waits for a running process to complete.
+
+        This test verifies:
+        - `wait()` is called on the process when it is running.
+        - `print` is not called for valid process states.
+        - The process's final status is updated correctly.
+        """
+        # Mock subprocess behavior
+        mock_process = MagicMock()
+        mock_popen.return_value = mock_process
+
+        # Simulate a running process
+        mock_process.poll.return_value = None  # Process is active
+        process = LocalProcess(
+            commands="sleep 1",
+            run_path=tmp_path,
+            output_file=tmp_path / "output.log",
+        )
+        process._process = mock_process  # Assign mock process
+        process._cancelled = False
+
+        # Test wait on a running process
+        process.wait()
+        mock_process.wait.assert_called_once()  # Ensure `wait` was called on the process
+        assert not mock_print.called  # Confirm `print` was not called
+
+    @patch("subprocess.Popen")
+    @patch("builtins.print")
+    def test_wait_non_running_process(self, mock_print, mock_popen, tmp_path):
+        """Ensures that `wait` does not perform actions for non-running processes.
+
+        This test verifies:
+        - `wait()` prints an appropriate message for non-running processes.
+        - `wait()` does not attempt to wait on processes that are not running.
+        """
+        # Mock subprocess behavior
+        mock_process = MagicMock()
+        mock_popen.return_value = mock_process
+
+        # Test wait on a cancelled process
+        mock_process.poll.return_value = None  # Process is active
+        process = LocalProcess(
+            commands="sleep 1",
+            run_path=tmp_path,
+            output_file=tmp_path / "output.log",
+        )
+        process._process = mock_process  # Assign mock process
+        process._cancelled = True
+
+        process.wait()
+        mock_print.assert_called_once_with(
+            f"cannot wait for process with execution status '{ExecutionStatus.CANCELLED}'"
+        )
+        mock_process.wait.assert_not_called()
diff --git a/cstar/tests/unit_tests/scheduler/test_pbs_job.py b/cstar/tests/unit_tests/execution/test_pbs_job.py
similarity index 89%
rename from cstar/tests/unit_tests/scheduler/test_pbs_job.py
rename to cstar/tests/unit_tests/execution/test_pbs_job.py
index b6335c4..52267ec 100644
--- a/cstar/tests/unit_tests/scheduler/test_pbs_job.py
+++ b/cstar/tests/unit_tests/execution/test_pbs_job.py
@@ -2,8 +2,8 @@ import json
 import pytest
 from unittest.mock import MagicMock, patch, PropertyMock
 from cstar.system.scheduler import PBSScheduler, PBSQueue
-from cstar.scheduler.job import (
-    JobStatus,
+from cstar.execution.scheduler_job import (
+    ExecutionStatus,
     PBSJob,
 )
 
@@ -62,6 +62,7 @@ class TestPBSJob:
         self.scheduler = PBSScheduler(
             queues=[self.mock_queue],
             primary_queue_name="test_queue",
+            other_scheduler_directives={"mock_directive": "mock_value"},
         )
 
         # Define common job parameters
@@ -77,20 +78,12 @@ class TestPBSJob:
             "queue_name": "test_queue",
         }
 
-    @patch(
-        "cstar.system.manager.CStarSystemManager.scheduler", new_callable=PropertyMock
-    )
-    def test_script(self, mock_scheduler):
+    def test_script(self):  # , mock_scheduler):
         """Verifies that the PBS job script is correctly generated with all directives.
 
         This test checks that the `script` property of `PBSJob` produces the expected
         job script, including user-provided parameters and any scheduler directives.
 
-        Mocks
-        -----
-        CStarSystemManager.scheduler
-            Mocked to simulate a scheduler with specific directives (`mock_directive`).
-
         Asserts
         -------
         - That the generated job script matches the expected content, including:
@@ -99,12 +92,6 @@ class TestPBSJob:
           - Commands to execute in the job script (`echo Hello, World`).
         """
 
-        # Mock the scheduler and its attributes
-        mock_scheduler.return_value = MagicMock()
-        mock_scheduler.return_value.other_scheduler_directives = {
-            "mock_directive": "mock_value"
-        }
-
         # Initialize a PBSJob
         job = PBSJob(**self.common_job_params)
 
@@ -129,10 +116,7 @@ class TestPBSJob:
         ), f"Script mismatch!\nExpected:\n{expected_script}\n\nGot:\n{job.script}"
 
     @patch("subprocess.run")
-    @patch(
-        "cstar.system.manager.CStarSystemManager.scheduler", new_callable=PropertyMock
-    )
-    def test_submit(self, mock_scheduler, mock_subprocess, tmp_path):
+    def test_submit(self, mock_subprocess, tmp_path):
         """Ensures that the `submit` method properly submits a PBS job and extracts the
         job ID.
 
@@ -145,8 +129,6 @@ class TestPBSJob:
         -----
         subprocess.run
             Mocked to simulate the execution of the `qsub` command and its output.
-        CStarSystemManager.scheduler
-            Mocked to simulate a scheduler with no additional directives.
 
         Asserts
         -------
@@ -155,10 +137,6 @@ class TestPBSJob:
         - That the `qsub` command is executed with the correct arguments.
         """
 
-        # Mock scheduler attributes
-        mock_scheduler.return_value = MagicMock()
-        mock_scheduler.return_value.other_scheduler_directives = {}
-
         # Mock subprocess.run for qsub
         mock_subprocess.return_value = MagicMock(
             returncode=0, stdout="12345.mockserver\n", stderr=""
@@ -186,12 +164,8 @@ class TestPBSJob:
         ],
     )
     @patch("subprocess.run")
-    @patch(
-        "cstar.system.manager.CStarSystemManager.scheduler", new_callable=PropertyMock
-    )
     def test_submit_raises(
         self,
-        mock_scheduler,
         mock_subprocess,
         returncode,
         stdout,
@@ -214,18 +188,12 @@ class TestPBSJob:
         -----
         subprocess.run
             Mocked to simulate the execution of the `qsub` command.
-        CStarSystemManager.scheduler
-            Mocked to simulate a scheduler with no additional directives.
 
         Asserts
         -------
         - That a `RuntimeError` is raised with the expected error message when submission fails.
         """
 
-        # Mock scheduler attributes
-        mock_scheduler.return_value = MagicMock()
-        mock_scheduler.return_value.other_scheduler_directives = {}
-
         # Mock subprocess.run for qsub
         mock_subprocess.return_value = MagicMock(
             returncode=returncode, stdout=stdout, stderr=stderr
@@ -247,7 +215,7 @@ class TestPBSJob:
             job.submit()
 
     @patch("subprocess.run")
-    @patch("cstar.scheduler.job.PBSJob.status", new_callable=PropertyMock)
+    @patch("cstar.execution.scheduler_job.PBSJob.status", new_callable=PropertyMock)
     def test_cancel_running_job(self, mock_status, mock_subprocess, tmp_path):
         """Tests that the `cancel` method successfully cancels a running PBS job.
 
@@ -257,7 +225,7 @@ class TestPBSJob:
         Mocks
         -----
         PBSJob.status
-            Mocked to return `JobStatus.RUNNING`, simulating a running job.
+            Mocked to return `ExecutionStatus.RUNNING`, simulating a running job.
         subprocess.run
             Mocked to simulate successful execution of the `qdel` command.
 
@@ -268,7 +236,7 @@ class TestPBSJob:
         """
 
         # Mock the status to "running"
-        mock_status.return_value = JobStatus.RUNNING
+        mock_status.return_value = ExecutionStatus.RUNNING
 
         # Mock subprocess.run for successful cancellation
         mock_subprocess.return_value = MagicMock(returncode=0, stdout="", stderr="")
@@ -293,7 +261,7 @@ class TestPBSJob:
         )
 
     @patch("subprocess.run")
-    @patch("cstar.scheduler.job.PBSJob.status", new_callable=PropertyMock)
+    @patch("cstar.execution.scheduler_job.PBSJob.status", new_callable=PropertyMock)
     @patch("builtins.print")
     def test_cancel_completed_job(
         self, mock_print, mock_status, mock_subprocess, tmp_path
@@ -339,7 +307,7 @@ class TestPBSJob:
 
     ##
     @patch("subprocess.run")
-    @patch("cstar.scheduler.job.PBSJob.status", new_callable=PropertyMock)
+    @patch("cstar.execution.scheduler_job.PBSJob.status", new_callable=PropertyMock)
     def test_cancel_failure(self, mock_status, mock_subprocess, tmp_path):
         """Ensures that a `RuntimeError` is raised if the `qdel` command fails to cancel
         a job.
@@ -351,7 +319,7 @@ class TestPBSJob:
         Mocks
         -----
         PBSJob.status
-            Mocked to return `JobStatus.RUNNING`, simulating a running job.
+            Mocked to return `ExecutionStatus.RUNNING`, simulating a running job.
         subprocess.run
             Mocked to simulate a failed execution of the `qdel` command, returning a non-zero exit code.
 
@@ -361,7 +329,7 @@ class TestPBSJob:
         - That the `qdel` command is called with the correct job ID and parameters.
         """
         # Mock the status to "running"
-        mock_status.return_value = JobStatus.RUNNING
+        mock_status.return_value = ExecutionStatus.RUNNING
 
         # Mock subprocess.run for qdel failure
         mock_subprocess.return_value = MagicMock(
@@ -388,7 +356,7 @@ class TestPBSJob:
             (
                 {"Jobs": {"12345": {"job_state": "Q"}}},
                 None,
-                JobStatus.PENDING,
+                ExecutionStatus.PENDING,
                 False,
                 None,
                 None,
@@ -396,7 +364,7 @@ class TestPBSJob:
             (
                 {"Jobs": {"12345": {"job_state": "R"}}},
                 None,
-                JobStatus.RUNNING,
+                ExecutionStatus.RUNNING,
                 False,
                 None,
                 None,
@@ -404,7 +372,7 @@ class TestPBSJob:
             (
                 {"Jobs": {"12345": {"job_state": "C"}}},
                 None,
-                JobStatus.COMPLETED,
+                ExecutionStatus.COMPLETED,
                 False,
                 None,
                 None,
@@ -412,7 +380,7 @@ class TestPBSJob:
             (
                 {"Jobs": {"12345": {"job_state": "H"}}},
                 None,
-                JobStatus.HELD,
+                ExecutionStatus.HELD,
                 False,
                 None,
                 None,
@@ -420,7 +388,7 @@ class TestPBSJob:
             (
                 {"Jobs": {"12345": {"job_state": "F", "Exit_status": 1}}},
                 None,
-                JobStatus.FAILED,
+                ExecutionStatus.FAILED,
                 False,
                 None,
                 None,
@@ -428,7 +396,7 @@ class TestPBSJob:
             (
                 {"Jobs": {"12345": {"job_state": "F", "Exit_status": 0}}},
                 None,
-                JobStatus.COMPLETED,
+                ExecutionStatus.COMPLETED,
                 False,
                 None,
                 None,
@@ -452,7 +420,7 @@ class TestPBSJob:
             (
                 {"Jobs": {"12345": {"job_state": "E"}}},
                 None,
-                JobStatus.ENDING,
+                ExecutionStatus.ENDING,
                 False,
                 None,
                 None,
@@ -460,7 +428,7 @@ class TestPBSJob:
             (
                 {"Jobs": {"12345": {"job_state": "X"}}},
                 None,
-                JobStatus.UNKNOWN,
+                ExecutionStatus.UNKNOWN,
                 False,
                 None,
                 None,
diff --git a/cstar/tests/unit_tests/scheduler/test_scheduler_job.py b/cstar/tests/unit_tests/execution/test_scheduler_job.py
similarity index 76%
rename from cstar/tests/unit_tests/scheduler/test_scheduler_job.py
rename to cstar/tests/unit_tests/execution/test_scheduler_job.py
index 5deb475..72799a5 100644
--- a/cstar/tests/unit_tests/scheduler/test_scheduler_job.py
+++ b/cstar/tests/unit_tests/execution/test_scheduler_job.py
@@ -1,6 +1,4 @@
-import time
 import pytest
-import threading
 from unittest.mock import MagicMock, patch, PropertyMock
 from cstar.system.scheduler import (
     Scheduler,
@@ -9,8 +7,7 @@ from cstar.system.scheduler import (
     SlurmScheduler,
     SlurmQOS,
 )
-from cstar.scheduler.job import (
-    JobStatus,
+from cstar.execution.scheduler_job import (
     SchedulerJob,
     PBSJob,
     SlurmJob,
@@ -111,12 +108,6 @@ class TestSchedulerJobBase:
         system defaults.
     test_init_cpus_without_distribution_requirement
         Confirms that task distribution is skipped if the scheduler does not require it.
-    test_updates_non_running_job
-        Validates that `updates()` provides appropriate feedback when the job is not running.
-    test_updates_running_job_with_tmp_file
-        Verifies that `updates()` streams live updates from the output file when the job is running.
-    test_updates_indefinite_with_seconds_param_0
-        Confirms that `updates()` runs indefinitely when `seconds=0` and allows termination via user interruption.
     """
 
     def setup_method(self, method):
@@ -417,173 +408,6 @@ class TestSchedulerJobBase:
         assert job.nodes == expected_nodes
         assert job.cpus_per_node == expected_cpus_per_node
 
-    def test_updates_non_running_job(self):
-        """Validates that `updates()` provides appropriate feedback when the job is not
-        running.
-
-        This test ensures that if the job status is not `RUNNING`, the method informs
-        the user and provides instructions for viewing the job output if applicable.
-
-        Mocks
-        -----
-        MockSchedulerJob.status
-            Mocked to return `JobStatus.COMPLETED`, simulating a non-running job.
-        builtins.print
-            Mocked to capture printed messages for validation.
-
-        Asserts
-        -------
-        - That the user is informed the job is not running.
-        - That instructions for viewing the job output are provided if the job status
-          is `COMPLETED` or similar.
-        """
-
-        job = MockSchedulerJob(**self.common_job_params)
-
-        with patch.object(
-            MockSchedulerJob, "status", new_callable=PropertyMock
-        ) as mock_status:
-            mock_status.return_value = JobStatus.COMPLETED
-            with patch("builtins.print") as mock_print:
-                job.updates(seconds=10)
-                mock_print.assert_any_call(
-                    "This job is currently not running (completed). Live updates cannot be provided."
-                )
-                mock_print.assert_any_call(
-                    f"See {job.output_file.resolve()} for job output"
-                )
-
-    def test_updates_running_job_with_tmp_file(self, tmp_path):
-        """Verifies that `updates()` streams live updates from the job's output file
-        when the job is running.
-
-        This test creates a temporary output file and pre-populates it with initial
-        content. Live updates are then appended to the file in real-time using a
-        background thread, simulating a running job. The `updates()` method is tested
-        to ensure it reads and prints both the pre-existing content and new updates.
-
-        Mocks
-        -----
-        MockSchedulerJob.status
-            Mocked to return `JobStatus.RUNNING`, simulating a running job.
-        builtins.print
-            Mocked to capture printed messages for validation.
-
-        Asserts
-        -------
-        - That all live updates appended to the output file are printed correctly.
-        - That previously existing content in the file is also printed.
-        - That the method properly interacts with the output file in real-time.
-        """
-
-        # Create a temporary output file
-        output_file = tmp_path / "output.log"
-        initial_content = ["First line\n"]
-        live_updates = ["Second line\n", "Third line\n", "Fourth line\n"]
-        print(output_file)
-        # Write initial content to the file
-        with output_file.open("w") as f:
-            f.writelines(initial_content)
-
-        job = MockSchedulerJob(**self.common_job_params, output_file=output_file)
-
-        # Mock the `status` property to return "running"
-        with patch.object(
-            MockSchedulerJob, "status", new_callable=PropertyMock
-        ) as mock_status:
-            mock_status.return_value = JobStatus.RUNNING
-
-            # Function to simulate appending live updates to the file
-            def append_live_updates():
-                with output_file.open("a") as f:
-                    for line in live_updates:
-                        time.sleep(0.01)  # Ensure `updates()` is actively reading
-                        f.write(line)
-                        f.flush()  # Immediately write to disk
-
-            # Start the live update simulation in a background thread
-            updater_thread = threading.Thread(target=append_live_updates, daemon=True)
-            updater_thread.start()
-
-            # Run the `updates` method
-            with patch("builtins.print") as mock_print:
-                job.updates(seconds=0.25)
-
-                # Ensure both initial and live update lines are printed
-                for line in live_updates:
-                    mock_print.assert_any_call(line, end="")
-
-            # Ensure the thread finishes before the test ends
-            updater_thread.join()
-
-    ##
-
-    def test_updates_indefinite_with_seconds_param_0(self, tmp_path):
-        """Confirms that `updates()` runs indefinitely when `seconds=0` and allows
-        termination via user interruption.
-
-        This test creates a temporary output file and pre-populates it with content.
-        Unlike `test_updates_running_job_with_tmp_file`, this test does not continue
-        to update the temporary file, so no updates are actually provided. The
-        stream is then killed via a simulated `KeyboardInterrupt` and the test
-        ensures a graceful exit
-
-        Mocks
-        -----
-        MockSchedulerJob.status
-            Mocked to return `JobStatus.RUNNING`, simulating a running job.
-        builtins.input
-            Mocked to simulate user responses to the confirmation prompt.
-        builtins.print
-            Mocked to capture printed messages for validation.
-        time.sleep
-            Mocked to simulate a `KeyboardInterrupt` during indefinite updates.
-
-        Asserts
-        -------
-        - That the user is prompted to confirm running the updates indefinitely.
-        - That the method handles user interruptions and prints the appropriate message.
-        - That no additional file operations occur if the user denies the confirmation.
-        """
-        # Create a temporary output file
-        output_file = tmp_path / "output.log"
-        content = ["Line 1\n", "Line 2\n", "Line 3\n"]
-
-        # Write initial content to the file
-        with output_file.open("w") as f:
-            f.writelines(content)
-
-        job = MockSchedulerJob(**self.common_job_params, output_file=output_file)
-
-        # Mock the `status` property to return "running"
-        with patch.object(
-            MockSchedulerJob, "status", new_callable=PropertyMock
-        ) as mock_status:
-            mock_status.return_value = JobStatus.RUNNING
-
-            # Patch input to simulate the confirmation prompt
-            with patch("builtins.input", side_effect=["y"]) as mock_input:
-                # Replace `time.sleep` with a side effect that raises KeyboardInterrupt
-                with patch("builtins.print") as mock_print:
-                    # Simulate a KeyboardInterrupt during the updates call
-                    with patch("time.sleep", side_effect=KeyboardInterrupt):
-                        job.updates(seconds=0)  # Run updates indefinitely
-
-                        # Assert that the "stopped by user" message was printed
-                        mock_print.assert_any_call(
-                            "\nLive status updates stopped by user."
-                        )
-                # Verify that the prompt was displayed to the user
-                mock_input.assert_called_once_with(
-                    "This will provide indefinite updates to your job. You can stop it anytime using Ctrl+C. "
-                    "Do you want to continue? (y/n): "
-                )
-            # Patch input to simulate the confirmation prompt
-            with patch("builtins.input", side_effect=["n"]) as mock_input:
-                with patch("builtins.open", create=True) as mock_open:
-                    job.updates(seconds=0)
-                    mock_open.assert_not_called()
-
 
 ##
 @pytest.mark.filterwarnings(
diff --git a/cstar/tests/unit_tests/scheduler/test_slurm_job.py b/cstar/tests/unit_tests/execution/test_slurm_job.py
similarity index 95%
rename from cstar/tests/unit_tests/scheduler/test_slurm_job.py
rename to cstar/tests/unit_tests/execution/test_slurm_job.py
index de68b17..55d0285 100644
--- a/cstar/tests/unit_tests/scheduler/test_slurm_job.py
+++ b/cstar/tests/unit_tests/execution/test_slurm_job.py
@@ -1,7 +1,7 @@
 import pytest
 from unittest.mock import MagicMock, patch, PropertyMock
 from cstar.system.scheduler import SlurmScheduler, SlurmQOS, SlurmPartition
-from cstar.scheduler.job import SlurmJob, JobStatus
+from cstar.execution.scheduler_job import SlurmJob, ExecutionStatus
 
 
 class TestSlurmJob:
@@ -336,7 +336,7 @@ class TestSlurmJob:
             job.submit()
 
     @patch("subprocess.run")
-    @patch("cstar.scheduler.job.SlurmJob.status", new_callable=PropertyMock)
+    @patch("cstar.execution.scheduler_job.SlurmJob.status", new_callable=PropertyMock)
     def test_cancel(self, mock_status, mock_subprocess, tmp_path):
         """Verifies that the `cancel` method cancels a SLURM job and raises an exception
         if it fails.
@@ -365,7 +365,7 @@ class TestSlurmJob:
         run_path = tmp_path
 
         # Mock the status to simulate a running job
-        mock_status.return_value = JobStatus.RUNNING
+        mock_status.return_value = ExecutionStatus.RUNNING
 
         # Mock the subprocess.run behavior for successful cancellation
         mock_subprocess.return_value = MagicMock(returncode=0, stdout="", stderr="")
@@ -459,12 +459,24 @@ class TestSlurmJob:
     @pytest.mark.parametrize(
         "job_id, sacct_output, return_code, expected_status, should_raise",
         [
-            (None, "", 0, JobStatus.UNSUBMITTED, False),  # Unsubmitted job
-            (12345, "PENDING\n", 0, JobStatus.PENDING, False),  # Pending job
-            (12345, "RUNNING\n", 0, JobStatus.RUNNING, False),  # Running job
-            (12345, "COMPLETED\n", 0, JobStatus.COMPLETED, False),  # Completed job
-            (12345, "CANCELLED\n", 0, JobStatus.CANCELLED, False),  # Cancelled job
-            (12345, "FAILED\n", 0, JobStatus.FAILED, False),  # Failed job
+            (None, "", 0, ExecutionStatus.UNSUBMITTED, False),  # Unsubmitted job
+            (12345, "PENDING\n", 0, ExecutionStatus.PENDING, False),  # Pending job
+            (12345, "RUNNING\n", 0, ExecutionStatus.RUNNING, False),  # Running job
+            (
+                12345,
+                "COMPLETED\n",
+                0,
+                ExecutionStatus.COMPLETED,
+                False,
+            ),  # Completed job
+            (
+                12345,
+                "CANCELLED\n",
+                0,
+                ExecutionStatus.CANCELLED,
+                False,
+            ),  # Cancelled job
+            (12345, "FAILED\n", 0, ExecutionStatus.FAILED, False),  # Failed job
             (12345, "", 1, None, True),  # sacct command failure
         ],
     )