Repository: AgnostiqHQ/covalent (python)

Issue:
Redispatch workflows: SDK
To support iterative workflow development, we want to introduce the ability to *redispatch* previously run workflows. 
This issue tracks the necessary changes to the SDK. The main task is to implement the `LocalDispatcher.redispatch` static method.

## UX

- `ct.redispatch(dispatch_id)()` — retries workflow with the same inputs
- `ct.redispatch(dispatch_id, replace_electrons={"task_1": task_1}, reuse_previous_results=False)(*new_args, **new_kwargs)`: reruns workflow with all instances of `task_1` replaced by the new definition of `task_1` and new lattice inputs
    - `new_args` and `new_kwargs` are the new lattice inputs
    - electron replacements can modify metadata and function bodies but not function signatures
    - If `reuse_previous_results=True`, try to reuse task results from the previous dispatch.
    
    ```python
    @ct.electron
    def task_1(x):
    	pass
    
    @ct.electron
    def task_2(x):
        return x*x
    
    @ct.lattice
    def workflow(x):
    	task_1(task_2(x))
      task_1(x)
    
    dispatch_id = ct.dispatch(workflow)(5)
    # TG:
    # 5 -> task_2 -> task_1
    # 5 -> task_1
    
    ### later, possibly in a new python module
    
    @ct.electron
    def new_task_1():
    	### new definition of task 1
    
    new_dispatch_id = ct.redispatch(
        dispatch_id, 
        replace_electrons={"task_1": new_task_1}
    )
    
    # TG:
    # 5 -> task_2 -> new_task_1
    # 5 -> new_task_1
    ```
    

## Details

- Signature

```python
@staticmethod
def redispatch(
	dispatch_id: str,
	replace_electrons: Dict[str, Callable],
	reuse_previous_results: bool = False,
	dispatcher_addr: str = None
) -> Callable
```

- Parameters:
    - `dispatch_id` is a previous dispatch
    - `replace_electrons` is a mapping from electron names to functions decorated with `ct.electron`. This indicates the electrons in the previous workflow to modify. For each specified electron name, all electrons with that name in the previous workflow will be replaced by the provided electron.
    - `reuse_previous_results`: indicates whether the user wishes to avoid rerunning previously completed electrons that do not depend on any electrons to be replaced.
- Returns:
    - A **callable** `wrapper` such that
        - `wrapper()` requests Covalent to re-run the workflow `dispatch_id` with the specified electron replacements and with the same lattice inputs as before.
        - `wrapper(*args, **kwargs)` downloads the previous workflow function for `dispatch_id` , rebuilds the transport graph with the provided lattice inputs, and submits the rebuilt lattice as a new dispatch with the specified electron replacements.
        
        The details of these web requests are described below.
        
- [Example UX](https://github.com/AgnostiqHQ/covalent/blob/redispatch-exp/tests/functional_tests/workflow_stack_test.py#L841):
    
    ```python
    @ct.electron
    def sum_task(x, y):
        return x + y
    
    @ct.electron
    def mult_task(x, y):
        return x * y
    
    @ct.electron
    def square_task(x):
        return x * x
    
    @ct.electron
    def cube_task(x):
        return x**3
    
    @ct.lattice
    def workflow(x, y):
        res_1 = sum_task(x, y)
        res_2 = square_task(res_1)
        return res_2
    
    dispatch_id = ct.dispatch(workflow)(2, 3)
    res_obj = ct.get_result(dispatch_id, wait=True)
    assert res_obj.result == 25
    
    # Replace all instances of "square_task" with cube_task
    redispatch_id = ct.redispatch(
    	dispatch_id, 
    	replace_electrons={"square_task": cube_task},
    	)()
    res_obj_2 = ct.get_result(redispatch_id, wait=True)
    assert res_obj_2.result == 125
    
    # Replace all instances of "sum_task" with mult_task,
    # and also rebuild the graph with new lattice inputs (2, 5).
    redispatch_id = ct.redispatch(
    	dispatch_id, 
    	replace_electrons={"sum_task": mult_task}
    	)(2, 5)
    res_obj_3 = ct.get_result(redispatch_id, wait=True)
    assert res_obj_3.result == 100
    
    redispatch_id = ct.redispatch(dispatch_id)(2, 5)
    res_obj_4 = ct.get_result(redispatch_id, wait=True)
    assert res_obj_4.result == 49
    
    # Later...
    @ct.electron(executor="awslambda")
    def square_task(x):
        return x * x
    
    # Replace the executor for `square_task` with "awslambda"
    ct.dispatch(dispatch_id, replace_electrons={"square_task": square_task})()

    ### Example involving `reuse_previous_results`

    redispatch_id = ct.redispatch(
        dispatch_id, replace_electrons={"square_task": cube_task}, reuse_previous_results=True
    )()
    res_obj_5 = ct.get_result(redispatch_id, wait=True)
    assert res_obj_5.result == 125

    # Check that node 0 isn't re-run since it is not a descendent
    # of the modified node
    assert res_obj_5.get_node_result(0)["start_time"] == res_obj_5.get_node_result(0)["end_time"]

    
    ```
    

- Calling `wrapper()` or `wrapper(*args, **kwargs)` makes a POST request to the server endpoint `/api/redispatch` with JSON body
    
    
    ```python
    {
    	"json_lattice": json_lattice | "",
    	"dispatch_id": dispatch_id,
    	"electron_updates": {
    		key_1: ElectronUpdate,
    		key_2: ElectronUpdate,
    		...
    	},
    	"reuse_previous_results": False | True,
    }
    ```
    
    - `json_lattice` is an empty string if the workflow is redispatched with the same inputs - the first case in the “Return” spec above. Otherwise, it is the JSON-serialized lattice for dispatch `dispatch_id` whose compute graph has been rebuilt with the new workflow inputs.
    - The keys in the `“electron_updates"` dictionary are the keys of the `replace_electrons` dictionary. Each ElectronUpdate dictionary represents the parts of the existing electron to modify:
        - function
        - function string
        - metadata
    
    Mimic the implementation of `ct.dispatch()`.
    
- **Schemas**: Let `e` be an Electron object.
    - `ElectronUpdate`
        
        ```python
        {
        	"name": e.function.__name__,
        	"function": TransportObject(e.function).to_dict(),
        	"function_string": get_serialized_function_str(e.function),
        	"metadata": MetadataUpdate(e),
        }
        ```
        
    - `MetadataUpdate(e)` is the JSON-encoded metadata explicitly specified in the electron decorator. For example,
        
        ```python
        dep = ct.DepsPip(["numpy"])
        
        @ct.electron(executor="awslambda", deps_pip=dep)
        def foo(*args, **kwargs)
        	pass
        
        @ct.electron
        def bar(*args, **kwargs)
        
        ct.redispatch(dispatch_id, replace_electrons={"foo": foo, "bar": bar})()
        ```
        
        Produces the following MetadataUpdate JSON
        
        ```python
        # MetadataUpdate(foo)
        {
        	"executor": "awslambda",
        	"executor_data": {},
        	"deps": {
        						"pip": dep.to_dict(),
        					}
        }
        
        # MetadataUpdate(bar)
        {}
        ```
        

## Comments

The main work involved is to generate the `ElectronUpdate` dictionaries. This is not entirely straightforward because applying the electron decorator to a callable does not produce an `Electron` object, but rather a wrapper that instantiates an `Electron` when called, and then immediately either

- Adds a node to the transport graph, if there is an “active lattice”, or
- Calls the underlying function, if there is no active lattice.

One would like to just return the instantiated `Electron` without proceeding to the next step of manipulating the transport graph or invoking the underlying function

In other words, given an expression like

```python
@ct.electron
def foo(*args, **kwargs)
```

we want to somehow get a hold of the object `Electron(function=foo)` in order to access its attributes, such as metadata.

Suggestion: repurpose `DispatchInfo` and `active_dispatch_info_manager`. 

- In the `wrapper` function generated by `decorator_electron`: return the `electron_object` if `active_dispatch_info_manager` is holding onto a DispatchInfo object.

To generate the `ElectronUpdate` for an electron:

- Create a `DispatchInfo` object with the previous `dispatch_id`, and claim it using the `active_dispatch_info_manager` .
- Call the wrapper function with empty arguments.
- Query the returned `Electron` object.

See [here](https://github.com/AgnostiqHQ/covalent/blob/redispatch-exp/covalent/_dispatcher_plugins/utils/redispatch_helpers.py#L43) for example.

## Acceptance Criteria

- [ ] `redispatch` is implemented as a static method of `LocalDispatcher` (`covalent/_dispatcher_plugins/local.py`) and exposed in the main `covalent` namespace
- [ ] Unit tests



Pull request description:
add remaining redispatch functionality
Fixes #1475 

<!--
⚠️ Your pull request title should be short detailed and understandable for all.
⚠️ Increment the version number in the /VERSION file. If this is a bugfix, increment the patch version (the rightmost number), for example 0.18.2 becomes 0.18.3. If it's a new feature, increment the minor version (the middle number), for example 0.18.2 becomes 0.19.0.
⚠️ Add a note to /CHANGELOG.md with your version number and the date, summarizing the changes.
⚠️ If your pull request fixes an open issue, please link to the issue.
⚠️ Rebase the latest changes from the develop branch. Assuming origin points to https://github.com/AgnostiqHQ/covalent, you should run git rebase origin/develop.
-->

- [ ] I have added the tests to cover my changes.
- [ ] I have updated the documentation and CHANGELOG accordingly.
- [ ] I have read the CONTRIBUTING document.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7b1e4e31..2d99654d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [UNRELEASED]
 
+### Changed
+
+- Location of function to load result from the database now moved to load module in covalent_dispatcher/_db folde.
+
+### Added
+- API endpoint for redispatching.
+- Unit and functional tests for redispatching.
+
 ## [0.216.0-rc.0] - 2023-02-05
 
 ### Authors
diff --git a/covalent/_results_manager/utils.py b/covalent/_results_manager/utils.py
index 6ae3d9ed..d1910977 100644
--- a/covalent/_results_manager/utils.py
+++ b/covalent/_results_manager/utils.py
@@ -20,10 +20,6 @@
 
 """Utility functions for results manager."""
 
-import requests
-
-from .._shared_files.config import get_config
-
 
 def convert_to_lattice_function_call(
     lattice_function_string: str, lattice_function_name: str, inputs: dict
@@ -51,12 +47,3 @@ def convert_to_lattice_function_call(
     function_call_str = lattice_function_string
     function_call_str += f'if __name__ == "__main__":\n    {lattice_function_name}({inp})\n'
     return function_call_str
-
-
-def _db_path(
-    dispatcher: str = get_config("dispatcher.address") + ":" + str(get_config("dispatcher.port")),
-) -> str:
-    url = "http://" + dispatcher + "/api/db-path"
-    r = requests.get(url)
-    path = r.json()
-    return path
diff --git a/covalent_dispatcher/__init__.py b/covalent_dispatcher/__init__.py
index 8f2d0a02..818dab34 100644
--- a/covalent_dispatcher/__init__.py
+++ b/covalent_dispatcher/__init__.py
@@ -18,4 +18,4 @@
 #
 # Relief from the License may be granted by purchasing a commercial license.
 
-from .entry_point import cancel_running_dispatch, run_dispatcher
+from .entry_point import cancel_running_dispatch, run_dispatcher, run_redispatch
diff --git a/covalent_dispatcher/_core/__init__.py b/covalent_dispatcher/_core/__init__.py
index 810c5a49..59426d08 100644
--- a/covalent_dispatcher/_core/__init__.py
+++ b/covalent_dispatcher/_core/__init__.py
@@ -18,5 +18,5 @@
 #
 # Relief from the License may be granted by purchasing a commercial license.
 
-from .data_manager import make_dispatch
+from .data_manager import make_derived_dispatch, make_dispatch
 from .dispatcher import cancel_workflow, run_dispatch
diff --git a/covalent_dispatcher/_core/data_manager.py b/covalent_dispatcher/_core/data_manager.py
index 9bd69d79..64e66b48 100644
--- a/covalent_dispatcher/_core/data_manager.py
+++ b/covalent_dispatcher/_core/data_manager.py
@@ -186,7 +186,7 @@ def make_derived_dispatch(
     if electron_updates is None:
         electron_updates = {}
 
-    old_result_object = load.get_result_object_from_storage(parent_dispatch_id, wait=False)
+    old_result_object = load.get_result_object_from_storage(parent_dispatch_id)
 
     if json_lattice:
         result_object = _get_result_object_from_new_lattice(
diff --git a/covalent_dispatcher/_core/dispatcher.py b/covalent_dispatcher/_core/dispatcher.py
index ac19b319..53df8ff8 100644
--- a/covalent_dispatcher/_core/dispatcher.py
+++ b/covalent_dispatcher/_core/dispatcher.py
@@ -144,6 +144,7 @@ async def _submit_task(result_object, node_id):
 
     # Get name of the node for the current task
     node_name = result_object.lattice.transport_graph.get_node_value(node_id, "name")
+    node_status = result_object.lattice.transport_graph.get_node_value(node_id, "status")
     app_log.debug(f"7A: Node name: {node_name} (run_planned_workflow).")
 
     # Handle parameter nodes
@@ -153,16 +154,28 @@ async def _submit_task(result_object, node_id):
         app_log.debug(f"7C: Node output: {output} (run_planned_workflow).")
         app_log.debug("8: Starting update node (run_planned_workflow).")
 
+        timestamp = datetime.now(timezone.utc)
         node_result = {
             "node_id": node_id,
-            "start_time": datetime.now(timezone.utc),
-            "end_time": datetime.now(timezone.utc),
+            "start_time": timestamp,
+            "end_time": timestamp,
             "status": Result.COMPLETED,
             "output": output,
         }
         await datasvc.update_node_result(result_object, node_result)
         app_log.debug("8A: Update node success (run_planned_workflow).")
 
+    elif node_status == Result.COMPLETED:
+        timestamp = datetime.now(timezone.utc)
+        node_result = {
+            "node_id": node_id,
+            "start_time": timestamp,
+            "end_time": timestamp,
+            "status": Result.COMPLETED,
+        }
+        await datasvc.update_node_result(result_object, node_result)
+        app_log.debug(f"Skipped completed node {node_id}.")
+
     else:
 
         # Executor for post_processing and dispatching sublattices
diff --git a/covalent_dispatcher/_db/load.py b/covalent_dispatcher/_db/load.py
index 6bf6dd72..41fb8d91 100644
--- a/covalent_dispatcher/_db/load.py
+++ b/covalent_dispatcher/_db/load.py
@@ -20,12 +20,16 @@
 
 """Functions to load results from the database."""
 
-from typing import Optional
 
+from covalent import lattice
 from covalent._results_manager.result import Result
 from covalent._shared_files import logger
+from covalent._shared_files.util_classes import Status
+from covalent._workflow.transport import TransportableObject
 
+from .datastore import workflow_db
 from .models import Lattice
+from .write_result_to_db import load_file
 
 app_log = logger.app_log
 log_stack_info = logger.log_stack_info
@@ -33,11 +37,113 @@ log_stack_info = logger.log_stack_info
 
 def _result_from(lattice_record: Lattice) -> Result:
     """Re-hydrate result object from the lattice record."""
-    pass
+    function = load_file(
+        storage_path=lattice_record.storage_path, filename=lattice_record.function_filename
+    )
+    function_string = load_file(
+        storage_path=lattice_record.storage_path, filename=lattice_record.function_string_filename
+    )
+    function_docstring = load_file(
+        storage_path=lattice_record.storage_path, filename=lattice_record.docstring_filename
+    )
+    executor_data = load_file(
+        storage_path=lattice_record.storage_path, filename=lattice_record.executor_data_filename
+    )
+    workflow_executor_data = load_file(
+        storage_path=lattice_record.storage_path,
+        filename=lattice_record.workflow_executor_data_filename,
+    )
+    inputs = load_file(
+        storage_path=lattice_record.storage_path, filename=lattice_record.inputs_filename
+    )
+    named_args = load_file(
+        storage_path=lattice_record.storage_path, filename=lattice_record.named_args_filename
+    )
+    named_kwargs = load_file(
+        storage_path=lattice_record.storage_path, filename=lattice_record.named_kwargs_filename
+    )
+    error = load_file(
+        storage_path=lattice_record.storage_path, filename=lattice_record.error_filename
+    )
+    transport_graph = load_file(
+        storage_path=lattice_record.storage_path, filename=lattice_record.transport_graph_filename
+    )
+    output = load_file(
+        storage_path=lattice_record.storage_path, filename=lattice_record.results_filename
+    )
+    deps = load_file(
+        storage_path=lattice_record.storage_path, filename=lattice_record.deps_filename
+    )
+    call_before = load_file(
+        storage_path=lattice_record.storage_path, filename=lattice_record.call_before_filename
+    )
+    call_after = load_file(
+        storage_path=lattice_record.storage_path, filename=lattice_record.call_after_filename
+    )
+    cova_imports = load_file(
+        storage_path=lattice_record.storage_path, filename=lattice_record.cova_imports_filename
+    )
+    lattice_imports = load_file(
+        storage_path=lattice_record.storage_path, filename=lattice_record.lattice_imports_filename
+    )
 
+    name = lattice_record.name
+    executor = lattice_record.executor
+    workflow_executor = lattice_record.workflow_executor
+    num_nodes = lattice_record.electron_num
 
-def get_result_object_from_storage(
-    dispatch_id: str, wait: Optional[bool] = False, status_only: Optional[bool] = False
-):
-    """Get result object from Database."""
-    pass
+    attributes = {
+        "workflow_function": function,
+        "workflow_function_string": function_string,
+        "__name__": name,
+        "__doc__": function_docstring,
+        "metadata": {
+            "executor": executor,
+            "executor_data": executor_data,
+            "workflow_executor": workflow_executor,
+            "workflow_executor_data": workflow_executor_data,
+            "deps": deps,
+            "call_before": call_before,
+            "call_after": call_after,
+        },
+        "args": inputs["args"],
+        "kwargs": inputs["kwargs"],
+        "named_args": named_args,
+        "named_kwargs": named_kwargs,
+        "transport_graph": transport_graph,
+        "cova_imports": cova_imports,
+        "lattice_imports": lattice_imports,
+        "post_processing": False,
+        "electron_outputs": {},
+    }
+
+    def dummy_function(x):
+        return x
+
+    lat = lattice(dummy_function)
+    lat.__dict__ = attributes
+
+    result = Result(
+        lat,
+        dispatch_id=lattice_record.dispatch_id,
+    )
+    result._root_dispatch_id = lattice_record.root_dispatch_id
+    result._status = Status(lattice_record.status)
+    result._error = error or None
+    result._inputs = inputs
+    result._start_time = lattice_record.started_at
+    result._end_time = lattice_record.completed_at
+    result._result = output if output is not None else TransportableObject(None)
+    result._num_nodes = num_nodes
+    return result
+
+
+def get_result_object_from_storage(dispatch_id: str):
+    """Get the result object from the database."""
+    with workflow_db.session() as session:
+        lattice_record = session.query(Lattice).where(Lattice.dispatch_id == dispatch_id).first()
+        if not lattice_record:
+            app_log.debug(f"No result object found for dispatch {dispatch_id}")
+            raise RuntimeError(f"No result object found for dispatch {dispatch_id}")
+
+        return _result_from(lattice_record)
diff --git a/covalent_dispatcher/_db/write_result_to_db.py b/covalent_dispatcher/_db/write_result_to_db.py
index bb0bc8de..22fed783 100644
--- a/covalent_dispatcher/_db/write_result_to_db.py
+++ b/covalent_dispatcher/_db/write_result_to_db.py
@@ -107,7 +107,7 @@ def insert_lattices_data(
     started_at: dt,
     completed_at: dt,
 ) -> int:
-    """This funtion writes the lattice data / metadata to the Lattices table in the DB."""
+    """This function writes the lattice data / metadata to the Lattices table in the DB."""
 
     lattice_row = Lattice(
         dispatch_id=dispatch_id,
diff --git a/covalent_dispatcher/_service/app.py b/covalent_dispatcher/_service/app.py
index bc84a263..983ca7a6 100644
--- a/covalent_dispatcher/_service/app.py
+++ b/covalent_dispatcher/_service/app.py
@@ -24,20 +24,16 @@ from typing import Optional
 from uuid import UUID
 
 import cloudpickle as pickle
-from fastapi import APIRouter, Request
+from fastapi import APIRouter, HTTPException, Request
 from fastapi.responses import JSONResponse
 
 import covalent_dispatcher as dispatcher
-from covalent import lattice
 from covalent._results_manager.result import Result
 from covalent._shared_files import logger
-from covalent._shared_files.util_classes import Status
-from covalent._workflow.transport import TransportableObject
 
 from .._db.datastore import workflow_db
-from .._db.dispatchdb import DispatchDB
+from .._db.load import _result_from
 from .._db.models import Lattice
-from .._db.write_result_to_db import load_file
 
 app_log = logger.app_log
 log_stack_info = logger.log_stack_info
@@ -45,110 +41,6 @@ log_stack_info = logger.log_stack_info
 router: APIRouter = APIRouter()
 
 
-def _result_from(lattice_record: Lattice) -> Result:
-    """Re-hydrate result object from the lattice record."""
-
-    function = load_file(
-        storage_path=lattice_record.storage_path, filename=lattice_record.function_filename
-    )
-    function_string = load_file(
-        storage_path=lattice_record.storage_path, filename=lattice_record.function_string_filename
-    )
-    function_docstring = load_file(
-        storage_path=lattice_record.storage_path, filename=lattice_record.docstring_filename
-    )
-    executor_data = load_file(
-        storage_path=lattice_record.storage_path, filename=lattice_record.executor_data_filename
-    )
-    workflow_executor_data = load_file(
-        storage_path=lattice_record.storage_path,
-        filename=lattice_record.workflow_executor_data_filename,
-    )
-    inputs = load_file(
-        storage_path=lattice_record.storage_path, filename=lattice_record.inputs_filename
-    )
-    named_args = load_file(
-        storage_path=lattice_record.storage_path, filename=lattice_record.named_args_filename
-    )
-    named_kwargs = load_file(
-        storage_path=lattice_record.storage_path, filename=lattice_record.named_kwargs_filename
-    )
-    error = load_file(
-        storage_path=lattice_record.storage_path, filename=lattice_record.error_filename
-    )
-    transport_graph = load_file(
-        storage_path=lattice_record.storage_path, filename=lattice_record.transport_graph_filename
-    )
-    output = load_file(
-        storage_path=lattice_record.storage_path, filename=lattice_record.results_filename
-    )
-    deps = load_file(
-        storage_path=lattice_record.storage_path, filename=lattice_record.deps_filename
-    )
-    call_before = load_file(
-        storage_path=lattice_record.storage_path, filename=lattice_record.call_before_filename
-    )
-    call_after = load_file(
-        storage_path=lattice_record.storage_path, filename=lattice_record.call_after_filename
-    )
-    cova_imports = load_file(
-        storage_path=lattice_record.storage_path, filename=lattice_record.cova_imports_filename
-    )
-    lattice_imports = load_file(
-        storage_path=lattice_record.storage_path, filename=lattice_record.lattice_imports_filename
-    )
-
-    name = lattice_record.name
-    executor = lattice_record.executor
-    workflow_executor = lattice_record.workflow_executor
-    num_nodes = lattice_record.electron_num
-
-    attributes = {
-        "workflow_function": function,
-        "workflow_function_string": function_string,
-        "__name__": name,
-        "__doc__": function_docstring,
-        "metadata": {
-            "executor": executor,
-            "executor_data": executor_data,
-            "workflow_executor": workflow_executor,
-            "workflow_executor_data": workflow_executor_data,
-            "deps": deps,
-            "call_before": call_before,
-            "call_after": call_after,
-        },
-        "args": inputs["args"],
-        "kwargs": inputs["kwargs"],
-        "named_args": named_args,
-        "named_kwargs": named_kwargs,
-        "transport_graph": transport_graph,
-        "cova_imports": cova_imports,
-        "lattice_imports": lattice_imports,
-        "post_processing": False,
-        "electron_outputs": {},
-    }
-
-    def dummy_function(x):
-        return x
-
-    lat = lattice(dummy_function)
-    lat.__dict__ = attributes
-
-    result = Result(
-        lat,
-        dispatch_id=lattice_record.dispatch_id,
-    )
-    result._root_dispatch_id = lattice_record.root_dispatch_id
-    result._status = Status(lattice_record.status)
-    result._error = error if error else None
-    result._inputs = inputs
-    result._start_time = lattice_record.started_at
-    result._end_time = lattice_record.completed_at
-    result._result = output if output is not None else TransportableObject(None)
-    result._num_nodes = num_nodes
-    return result
-
-
 @router.post("/submit")
 async def submit(request: Request) -> UUID:
     """
@@ -163,11 +55,39 @@ async def submit(request: Request) -> UUID:
         dispatch_id: The dispatch id in a json format
                      returned as a Fast API Response object.
     """
-    data = await request.json()
-    data = json.dumps(data).encode("utf-8")
+    try:
+        data = await request.json()
+        data = json.dumps(data).encode("utf-8")
+
+        return await dispatcher.run_dispatcher(data)
+    except Exception as e:
+        raise HTTPException(
+            status_code=400,
+            detail=f"Failed to submit workflow: {e}",
+        ) from e
+
+
+@router.post("/redispatch")
+async def redispatch(request: Request) -> str:
+    """Endpoint to redispatch a workflow."""
+    try:
+        data = await request.json()
+        dispatch_id = data["dispatch_id"]
+        json_lattice = data["json_lattice"]
+        electron_updates = data["electron_updates"]
+        reuse_previous_results = data["reuse_previous_results"]
+        return await dispatcher.run_redispatch(
+            dispatch_id,
+            json_lattice,
+            electron_updates,
+            reuse_previous_results,
+        )
 
-    dispatch_id = await dispatcher.run_dispatcher(data)
-    return dispatch_id
+    except Exception as e:
+        raise HTTPException(
+            status_code=400,
+            detail=f"Failed to redispatch workflow: {e}",
+        ) from e
 
 
 @router.post("/cancel")
@@ -183,17 +103,17 @@ async def cancel(request: Request) -> str:
         Fast API Response object confirming that the dispatch
         has been cancelled.
     """
-    data = await request.body()
-    dispatch_id = data.decode("utf-8")
-
-    dispatcher.cancel_running_dispatch(dispatch_id)
-    return f"Dispatch {dispatch_id} cancelled."
-
+    try:
+        data = await request.body()
+        dispatch_id = data.decode("utf-8")
 
-@router.get("/db-path")
-def db_path() -> str:
-    db_path = DispatchDB()._dbpath
-    return json.dumps(db_path)
+        dispatcher.cancel_running_dispatch(dispatch_id)
+        return f"Dispatch {dispatch_id} cancelled."
+    except Exception as e:
+        raise HTTPException(
+            status_code=400,
+            detail=f"Failed to cancel workflow: {e}",
+        ) from e
 
 
 @router.get("/result/{dispatch_id}")
@@ -225,11 +145,10 @@ def get_result(
                 ).decode()
             return output
 
-        response = JSONResponse(
+        return JSONResponse(
             status_code=503,
             content={
                 "message": "Result not ready to read yet. Please wait for a couple of seconds."
             },
             headers={"Retry-After": "2"},
         )
-        return response
diff --git a/covalent_dispatcher/entry_point.py b/covalent_dispatcher/entry_point.py
index 9351603c..e4813b12 100644
--- a/covalent_dispatcher/entry_point.py
+++ b/covalent_dispatcher/entry_point.py
@@ -52,6 +52,21 @@ async def run_dispatcher(json_lattice: str):
     return dispatch_id
 
 
+async def run_redispatch(
+    dispatch_id: str, json_lattice: str, electron_updates: dict, reuse_previous_results: bool
+):
+    from ._core import make_derived_dispatch, run_dispatch
+
+    redispatch_id = make_derived_dispatch(
+        dispatch_id, json_lattice, electron_updates, reuse_previous_results
+    )
+    run_dispatch(redispatch_id)
+
+    app_log.debug(f"Re-dispatching {dispatch_id} as {redispatch_id}")
+
+    return redispatch_id
+
+
 def cancel_running_dispatch(dispatch_id: str) -> None:
     """
     Cancels a running dispatch job.


Test patch:
diff --git a/tests/covalent_dispatcher_tests/_db/load_test.py b/tests/covalent_dispatcher_tests/_db/load_test.py
new file mode 100644
index 00000000..5c36f330
--- /dev/null
+++ b/tests/covalent_dispatcher_tests/_db/load_test.py
@@ -0,0 +1,224 @@
+# Copyright 2023 Agnostiq Inc.
+#
+# This file is part of Covalent.
+#
+# Licensed under the GNU Affero General Public License 3.0 (the "License").
+# A copy of the License may be obtained with this software package or at
+#
+#      https://www.gnu.org/licenses/agpl-3.0.en.html
+#
+# Use of this file is prohibited except in compliance with the License. Any
+# modifications or derivative works of this file must retain this copyright
+# notice, and modified files must contain a notice indicating that they have
+# been altered from the originals.
+#
+# Covalent is distributed in the hope that it will be useful, but WITHOUT
+# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+# FITNESS FOR A PARTICULAR PURPOSE. See the License for more details.
+#
+# Relief from the License may be granted by purchasing a commercial license.
+
+
+"""Unit tests for result loading (from database) module."""
+
+from unittest.mock import MagicMock, call
+
+import pytest
+
+from covalent._shared_files.util_classes import Status
+from covalent_dispatcher._db.load import _result_from, get_result_object_from_storage
+
+
+def test_result_from(mocker):
+    """Test the result from function in the load module."""
+    mock_lattice_record = MagicMock()
+    load_file_mock = mocker.patch("covalent_dispatcher._db.load.load_file")
+    lattice_mock = mocker.patch("covalent_dispatcher._db.load.lattice")
+    result_mock = mocker.patch("covalent_dispatcher._db.load.Result")
+
+    result_object = _result_from(mock_lattice_record)
+
+    assert (
+        call(
+            storage_path=mock_lattice_record.storage_path,
+            filename=mock_lattice_record.function_filename,
+        )
+        in load_file_mock.mock_calls
+    )
+    assert (
+        call(
+            storage_path=mock_lattice_record.storage_path,
+            filename=mock_lattice_record.function_string_filename,
+        )
+        in load_file_mock.mock_calls
+    )
+    assert (
+        call(
+            storage_path=mock_lattice_record.storage_path,
+            filename=mock_lattice_record.docstring_filename,
+        )
+        in load_file_mock.mock_calls
+    )
+    assert (
+        call(
+            storage_path=mock_lattice_record.storage_path,
+            filename=mock_lattice_record.executor_data_filename,
+        )
+        in load_file_mock.mock_calls
+    )
+    assert (
+        call(
+            storage_path=mock_lattice_record.storage_path,
+            filename=mock_lattice_record.workflow_executor_data_filename,
+        )
+        in load_file_mock.mock_calls
+    )
+    assert (
+        call(
+            storage_path=mock_lattice_record.storage_path,
+            filename=mock_lattice_record.inputs_filename,
+        )
+        in load_file_mock.mock_calls
+    )
+    assert (
+        call(
+            storage_path=mock_lattice_record.storage_path,
+            filename=mock_lattice_record.named_args_filename,
+        )
+        in load_file_mock.mock_calls
+    )
+    assert (
+        call(
+            storage_path=mock_lattice_record.storage_path,
+            filename=mock_lattice_record.named_kwargs_filename,
+        )
+        in load_file_mock.mock_calls
+    )
+    assert (
+        call(
+            storage_path=mock_lattice_record.storage_path,
+            filename=mock_lattice_record.error_filename,
+        )
+        in load_file_mock.mock_calls
+    )
+    assert (
+        call(
+            storage_path=mock_lattice_record.storage_path,
+            filename=mock_lattice_record.transport_graph_filename,
+        )
+        in load_file_mock.mock_calls
+    )
+    assert (
+        call(
+            storage_path=mock_lattice_record.storage_path,
+            filename=mock_lattice_record.results_filename,
+        )
+        in load_file_mock.mock_calls
+    )
+    assert (
+        call(
+            storage_path=mock_lattice_record.storage_path,
+            filename=mock_lattice_record.deps_filename,
+        )
+        in load_file_mock.mock_calls
+    )
+    assert (
+        call(
+            storage_path=mock_lattice_record.storage_path,
+            filename=mock_lattice_record.call_before_filename,
+        )
+        in load_file_mock.mock_calls
+    )
+    assert (
+        call(
+            storage_path=mock_lattice_record.storage_path,
+            filename=mock_lattice_record.call_after_filename,
+        )
+        in load_file_mock.mock_calls
+    )
+    assert (
+        call(
+            storage_path=mock_lattice_record.storage_path,
+            filename=mock_lattice_record.cova_imports_filename,
+        )
+        in load_file_mock.mock_calls
+    )
+    assert (
+        call(
+            storage_path=mock_lattice_record.storage_path,
+            filename=mock_lattice_record.lattice_imports_filename,
+        )
+        in load_file_mock.mock_calls
+    )
+
+    lattice_mock.assert_called_once()
+    result_mock.assert_called_once()
+
+    assert result_object._root_dispatch_id == mock_lattice_record.root_dispatch_id
+    assert result_object._status == Status(mock_lattice_record.status)
+    assert result_object._error == load_file_mock.return_value
+    assert result_object._inputs == load_file_mock.return_value
+    assert result_object._start_time == mock_lattice_record.started_at
+    assert result_object._end_time == mock_lattice_record.completed_at
+    assert result_object._result == load_file_mock.return_value
+    assert result_object._num_nodes == mock_lattice_record.electron_num
+
+    lattice_mock_attrs = lattice_mock().__dict__
+    assert set(lattice_mock_attrs.keys()) == {
+        "workflow_function",
+        "workflow_function_string",
+        "__name__",
+        "__doc__",
+        "metadata",
+        "args",
+        "kwargs",
+        "named_args",
+        "named_kwargs",
+        "transport_graph",
+        "cova_imports",
+        "lattice_imports",
+        "post_processing",
+        "electron_outputs",
+    }
+    assert lattice_mock_attrs["post_processing"] is False
+    assert lattice_mock_attrs["electron_outputs"] == {}
+
+    _, args, _ = lattice_mock.mock_calls[0]
+    assert args[0].__name__ == "dummy_function"
+
+
+def test_get_result_object_from_storage(mocker):
+    """Test the get_result_object_from_storage method."""
+    from covalent_dispatcher._db.load import Lattice
+
+    result_from_mock = mocker.patch("covalent_dispatcher._db.load._result_from")
+
+    workflow_db_mock = mocker.patch("covalent_dispatcher._db.load.workflow_db")
+    session_mock = workflow_db_mock.session.return_value.__enter__.return_value
+
+    result_object = get_result_object_from_storage("mock-dispatch-id")
+
+    assert call(Lattice) in session_mock.query.mock_calls
+    session_mock.query().where().first.assert_called_once()
+
+    assert result_object == result_from_mock.return_value
+    result_from_mock.assert_called_once_with(session_mock.query().where().first.return_value)
+
+
+def test_get_result_object_from_storage_exception(mocker):
+    """Test the get_result_object_from_storage method."""
+    from covalent_dispatcher._db.load import Lattice
+
+    result_from_mock = mocker.patch("covalent_dispatcher._db.load._result_from")
+
+    workflow_db_mock = mocker.patch("covalent_dispatcher._db.load.workflow_db")
+    session_mock = workflow_db_mock.session.return_value.__enter__.return_value
+    session_mock.query().where().first.return_value = None
+
+    with pytest.raises(RuntimeError):
+        get_result_object_from_storage("mock-dispatch-id")
+
+    assert call(Lattice) in session_mock.query.mock_calls
+    session_mock.query().where().first.assert_called_once()
+
+    result_from_mock.assert_not_called()
diff --git a/tests/covalent_dispatcher_tests/_db/update_test.py b/tests/covalent_dispatcher_tests/_db/update_test.py
index 70f00018..44a6882c 100644
--- a/tests/covalent_dispatcher_tests/_db/update_test.py
+++ b/tests/covalent_dispatcher_tests/_db/update_test.py
@@ -17,6 +17,7 @@
 # FITNESS FOR A PARTICULAR PURPOSE. See the License for more details.
 #
 # Relief from the License may be granted by purchasing a commercial license.
+
 import os
 import shutil
 from datetime import datetime as dt
@@ -68,7 +69,6 @@ def result_1():
     workflow_1.build_graph(a=1, b=2)
     received_lattice = LatticeClass.deserialize_from_json(workflow_1.serialize_to_json())
     result = Result(lattice=received_lattice, dispatch_id="dispatch_1")
-    #    result.lattice.metadata["results_dir"] = TEMP_RESULTS_DIR
     result._initialize_nodes()
     return result
 
diff --git a/tests/covalent_dispatcher_tests/_db/write_result_to_db_test.py b/tests/covalent_dispatcher_tests/_db/write_result_to_db_test.py
index f4633c66..125c1c82 100644
--- a/tests/covalent_dispatcher_tests/_db/write_result_to_db_test.py
+++ b/tests/covalent_dispatcher_tests/_db/write_result_to_db_test.py
@@ -244,7 +244,7 @@ def get_electron_kwargs(
 
 
 def test_update_lattice_completed_electron_num(test_db, mocker):
-    """Test the funtion used to update the number of completed electrons for a lattice by 1."""
+    """Test the function used to update the number of completed electrons for a lattice by 1."""
 
     mocker.patch("covalent_dispatcher._db.write_result_to_db.workflow_db", test_db)
     cur_time = dt.now(timezone.utc)
diff --git a/tests/covalent_dispatcher_tests/_service/app_test.py b/tests/covalent_dispatcher_tests/_service/app_test.py
index 26ffade2..0a9b88c0 100644
--- a/tests/covalent_dispatcher_tests/_service/app_test.py
+++ b/tests/covalent_dispatcher_tests/_service/app_test.py
@@ -61,11 +61,6 @@ class MockDataStore:
             yield session
 
 
-@pytest.fixture
-def app():
-    yield fast_app
-
-
 @pytest.fixture
 def client():
     with TestClient(fast_app) as c:
@@ -75,41 +70,98 @@ def client():
 @pytest.fixture
 def test_db():
     """Instantiate and return an in-memory database."""
-
     return MockDataStore(db_URL="sqlite+pysqlite:///:memory:")
 
 
 @pytest.fixture
 def test_db_file():
     """Instantiate and return a database."""
-
     return MockDataStore(db_URL="sqlite+pysqlite:////tmp/testdb.sqlite")
 
 
-def test_submit(mocker, app, client):
-    mocker.patch("covalent_dispatcher.run_dispatcher", return_value=DISPATCH_ID)
-    response = client.post("/api/submit", data=json.dumps({}))
+@pytest.mark.asyncio
+async def test_submit(mocker, client):
+    """Test the submit endpoint."""
+    mock_data = json.dumps({}).encode("utf-8")
+    run_dispatcher_mock = mocker.patch(
+        "covalent_dispatcher.run_dispatcher", return_value=DISPATCH_ID
+    )
+    response = client.post("/api/submit", data=mock_data)
     assert response.json() == DISPATCH_ID
+    run_dispatcher_mock.assert_called_once_with(mock_data)
+
+
+@pytest.mark.asyncio
+async def test_submit_exception(mocker, client):
+    """Test the submit endpoint."""
+    mock_data = json.dumps({}).encode("utf-8")
+    mocker.patch("covalent_dispatcher.run_dispatcher", side_effect=Exception("mock"))
+    response = client.post("/api/submit", data=mock_data)
+    assert response.status_code == 400
+    assert response.json()["detail"] == "Failed to submit workflow: mock"
+
+
+@pytest.mark.asyncio
+async def test_redispatch(mocker, client):
+    """Test the redispatch endpoint."""
+    json_lattice = None
+    electron_updates = None
+    reuse_previous_results = False
+    mock_data = json.dumps(
+        {
+            "dispatch_id": DISPATCH_ID,
+            "json_lattice": json_lattice,
+            "electron_updates": electron_updates,
+            "reuse_previous_results": reuse_previous_results,
+        }
+    ).encode("utf-8")
+    run_redispatch_mock = mocker.patch(
+        "covalent_dispatcher.run_redispatch", return_value=DISPATCH_ID
+    )
 
+    response = client.post("/api/redispatch", data=mock_data)
+    assert response.json() == DISPATCH_ID
+    run_redispatch_mock.assert_called_once_with(
+        DISPATCH_ID, json_lattice, electron_updates, reuse_previous_results
+    )
 
-def test_cancel(app, client):
-    response = client.post("/api/cancel", data=DISPATCH_ID.encode("utf-8"))
-    assert response.json() == f"Dispatch {DISPATCH_ID} cancelled."
+
+@pytest.mark.asyncio
+async def test_redispatch_exception(mocker, client):
+    """Test the redispatch endpoint."""
+    response = client.post("/api/redispatch", data="bad data")
+    assert response.status_code == 400
+    assert (
+        response.json()["detail"]
+        == "Failed to redispatch workflow: Expecting value: line 1 column 1 (char 0)"
+    )
 
 
-def test_db_path(mocker, app, client):
-    dbpath = "/Users/root/covalent/results.db"
+@pytest.mark.asyncio
+async def test_cancel(mocker, client):
+    """Test the cancel endpoint."""
+    cancel_running_dispatch_mock = mocker.patch(
+        "covalent_dispatcher.cancel_running_dispatch", return_value=DISPATCH_ID
+    )
+    response = client.post("/api/cancel", data=DISPATCH_ID.encode("utf-8"))
+    assert response.json() == f"Dispatch {DISPATCH_ID} cancelled."
+    cancel_running_dispatch_mock.assert_called_once_with(DISPATCH_ID)
 
-    def __init__(self, dbpath=dbpath):
-        self._dbpath = dbpath
 
-    mocker.patch.object(DispatchDB, "__init__", __init__)
-    response = client.get("/api/db-path")
-    result = response.json().replace("\\", "").replace('"', "")
-    assert result == dbpath
+@pytest.mark.asyncio
+async def test_cancel_exception(mocker, client):
+    """Test the cancel endpoint."""
+    cancel_running_dispatch_mock = mocker.patch(
+        "covalent_dispatcher.cancel_running_dispatch", side_effect=Exception("mock")
+    )
+    response = client.post("/api/cancel", data=DISPATCH_ID.encode("utf-8"))
+    assert response.status_code == 400
+    assert response.json()["detail"] == "Failed to cancel workflow: mock"
+    cancel_running_dispatch_mock.assert_called_once_with(DISPATCH_ID)
 
 
-def test_get_result(mocker, app, client, test_db_file, tmp_path):
+def test_get_result(mocker, client, test_db_file):
+    """Test the get-result endpoint."""
     lattice = MockLattice(
         status=str(Result.COMPLETED),
         dispatch_id=DISPATCH_ID,
@@ -129,7 +181,8 @@ def test_get_result(mocker, app, client, test_db_file, tmp_path):
     os.remove("/tmp/testdb.sqlite")
 
 
-def test_get_result_503(mocker, app, client, test_db_file, tmp_path):
+def test_get_result_503(mocker, client, test_db_file):
+    """Test the get-result endpoint."""
     lattice = MockLattice(
         status=str(Result.NEW_OBJ),
         dispatch_id=DISPATCH_ID,
@@ -145,8 +198,8 @@ def test_get_result_503(mocker, app, client, test_db_file, tmp_path):
     os.remove("/tmp/testdb.sqlite")
 
 
-def test_get_result_dispatch_id_not_found(mocker, test_db_file, client, tmp_path):
-
+def test_get_result_dispatch_id_not_found(mocker, test_db_file, client):
+    """Test the get-result endpoint and that 404 is returned if the dispatch ID is not found in the database."""
     mocker.patch("covalent_dispatcher._service.app._result_from", return_value={})
     mocker.patch("covalent_dispatcher._service.app.workflow_db", test_db_file)
     mocker.patch("covalent_dispatcher._service.app.Lattice", MockLattice)
@@ -155,10 +208,9 @@ def test_get_result_dispatch_id_not_found(mocker, test_db_file, client, tmp_path
 
 
 def test_db_path_get_config(mocker):
-    dbpath = "/Users/root/covalent/results.db"
-
+    """Test that the db path is retrieved from the config.""" ""
     get_config_mock = mocker.patch("covalent_dispatcher._db.dispatchdb.get_config")
 
-    dispatch_db = DispatchDB()
+    DispatchDB()
 
     get_config_mock.assert_called_once()
diff --git a/tests/covalent_dispatcher_tests/entry_point_test.py b/tests/covalent_dispatcher_tests/entry_point_test.py
index db3e28ba..3a9a3c06 100644
--- a/tests/covalent_dispatcher_tests/entry_point_test.py
+++ b/tests/covalent_dispatcher_tests/entry_point_test.py
@@ -23,7 +23,7 @@
 
 import pytest
 
-from covalent_dispatcher.entry_point import cancel_running_dispatch, run_dispatcher
+from covalent_dispatcher.entry_point import cancel_running_dispatch, run_dispatcher, run_redispatch
 
 DISPATCH_ID = "f34671d1-48f2-41ce-89d9-9a8cb5c60e5d"
 
@@ -51,6 +51,18 @@ async def test_run_dispatcher(mocker):
     mock_run_dispatch.assert_called_with(dispatch_id)
 
 
+@pytest.mark.asyncio
+async def test_run_redispatch(mocker):
+    """Test the run redispatch function."""
+    make_derived_dispatch_mock = mocker.patch(
+        "covalent_dispatcher._core.make_derived_dispatch", return_value="mock-redispatch-id"
+    )
+    run_dispatch_mock = mocker.patch("covalent_dispatcher._core.run_dispatch")
+    redispatch_id = await run_redispatch(DISPATCH_ID, "mock-json-lattice", {}, False)
+    run_dispatch_mock.assert_called_once_with(redispatch_id)
+    make_derived_dispatch_mock.assert_called_once_with(DISPATCH_ID, "mock-json-lattice", {}, False)
+
+
 def test_cancel_running_dispatch(mocker):
     mock_cancel_workflow = mocker.patch("covalent_dispatcher._core.cancel_workflow")
     cancel_running_dispatch(DISPATCH_ID)
diff --git a/tests/functional_tests/workflow_stack_test.py b/tests/functional_tests/workflow_stack_test.py
index ec91727e..36a48ff3 100644
--- a/tests/functional_tests/workflow_stack_test.py
+++ b/tests/functional_tests/workflow_stack_test.py
@@ -836,3 +836,98 @@ def test_workflows_with_dict_nodes():
     assert res_obj.result == 36
 
     rm._delete_result(dispatch_id)
+
+
+def test_redispatch():
+    """Test redispatching workflows"""
+
+    @ct.electron
+    def sum_task(x, y):
+        return x + y
+
+    @ct.electron
+    def mult_task(x, y):
+        return x * y
+
+    @ct.electron
+    def square_task(x):
+        return x * x
+
+    @ct.electron
+    def cube_task(x):
+        return x**3
+
+    @ct.lattice
+    def workflow(x, y):
+        res_1 = sum_task(x, y)
+        res_2 = square_task(res_1)
+        return res_2
+
+    dispatch_id = ct.dispatch(workflow)(2, 3)
+    res_obj = ct.get_result(dispatch_id, wait=True)
+    assert res_obj.result == 25
+
+    redispatch_id = ct.redispatch(dispatch_id, replace_electrons={"square_task": cube_task})()
+    res_obj_2 = ct.get_result(redispatch_id, wait=True)
+    assert res_obj_2.result == 125
+
+    redispatch_id = ct.redispatch(dispatch_id, replace_electrons={"sum_task": mult_task})()
+    res_obj_3 = ct.get_result(redispatch_id, wait=True)
+    assert res_obj_3.result == 36
+
+    redispatch_id = ct.redispatch(dispatch_id, replace_electrons={"sum_task": mult_task})(2, 5)
+    res_obj_4 = ct.get_result(redispatch_id, wait=True)
+    assert res_obj_4.result == 100
+
+    redispatch_id = ct.redispatch(dispatch_id)(2, 5)
+    res_obj_5 = ct.get_result(redispatch_id, wait=True)
+    assert res_obj_5.result == 49
+
+
+def test_redispatch_reusing_previous_results():
+    """Test reusing previous results for redispatching"""
+
+    @ct.electron
+    def sum_task(x, y):
+        return x + y
+
+    @ct.electron
+    def mult_task(x, y):
+        return x * y
+
+    @ct.electron
+    def square_task(x):
+        return x * x
+
+    @ct.electron
+    def cube_task(x):
+        return x**3
+
+    @ct.lattice
+    def workflow(x, y):
+        res_1 = sum_task(x, y)
+        res_2 = square_task(res_1)
+        return res_2
+
+    dispatch_id = ct.dispatch(workflow)(2, 3)
+    res_obj = ct.get_result(dispatch_id, wait=True)
+    assert res_obj.result == 25
+
+    redispatch_id = ct.redispatch(
+        dispatch_id, replace_electrons={"square_task": cube_task}, reuse_previous_results=True
+    )()
+    res_obj_2 = ct.get_result(redispatch_id, wait=True)
+    assert res_obj_2.result == 125
+
+    # Check that node 0 isn't re-run
+    assert res_obj_2.get_node_result(0)["start_time"] == res_obj_2.get_node_result(0)["end_time"]
+
+    # Check that nothing is re-run if no electron replacements and same workflow inputs
+    redispatch_id = ct.redispatch(dispatch_id, reuse_previous_results=True)()
+    res_obj_3 = ct.get_result(redispatch_id, wait=True)
+    assert res_obj_3.result == 25
+
+    for i in res_obj_3.lattice.transport_graph._graph.nodes:
+        assert (
+            res_obj_3.get_node_result(i)["start_time"] == res_obj_3.get_node_result(i)["end_time"]
+        )