From 11710a7f68dce91de4fe8bafd97d09418692f042 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 16 Feb 2026 02:04:02 +0000 Subject: [PATCH 01/30] Modify quickrun to allow resuming --- environment.yml | 2 +- src/openfecli/commands/quickrun.py | 19 +++++++++++++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/environment.yml b/environment.yml index c45d8102a..f74301b03 100644 --- a/environment.yml +++ b/environment.yml @@ -53,7 +53,7 @@ dependencies: # Control blas/openmp threads - threadpoolctl - pip: - - git+https://github.com/OpenFreeEnergy/gufe@main + - git+https://github.com/OpenFreeEnergy/gufe@restart_execute - run_constrained: # drop this pin when handled upstream in espaloma-feedstock - smirnoff99frosst>=1.1.0.1 #https://github.com/openforcefield/smirnoff99Frosst/issues/109 diff --git a/src/openfecli/commands/quickrun.py b/src/openfecli/commands/quickrun.py index f34410d69..443e20a7d 100644 --- a/src/openfecli/commands/quickrun.py +++ b/src/openfecli/commands/quickrun.py @@ -49,9 +49,11 @@ def quickrun(transformation, work_dir, output): for each repeat of the sampling process (by default 3). """ import logging + from json import JSONDecodeError import os import sys + from gufe import ProtocolDAG from gufe.protocols.protocoldag import execute_DAG from gufe.tokenization import JSON_HANDLER from gufe.transformations.transformation import Transformation @@ -94,13 +96,26 @@ def quickrun(transformation, work_dir, output): else: output.parent.mkdir(exist_ok=True, parents=True) - write("Planning simulations for this edge...") - dag = trans.create() + # Attempt to either deserialize or freshly create DAG + if (work_dir / "protocol_dag.json").is_file(): + write("Attempting to recover edge simulations from file") + try: + dag = ProtocolDAG.from_json(work_dir / "protocol_dag.json") + except JSONDecodeError: + errmsg = "Recovery failed, please clean workdir before continuing" + raise click.ClickException(errmsg) + else: + # Create the DAG instead and then serialize for later resuming + write("Planning simulations for this edge...") + dag = trans.create() + dag.to_json(work_dir / "protocol_dag.json") + write("Starting the simulations for this edge...") dagresult = execute_DAG( dag, shared_basedir=work_dir, scratch_basedir=work_dir, + unitresults_basedir=work_dir, keep_shared=True, raise_error=False, n_retries=2, From 322bc23858bae0b187b1b38131fe526259723a87 Mon Sep 17 00:00:00 2001 From: IAlibay Date: Mon, 16 Feb 2026 12:16:24 +0000 Subject: [PATCH 02/30] fix the gather tests --- .../protocols/openmm_abfe/test_abfe_protocol_results.py | 6 +++--- .../protocols/openmm_ahfe/test_ahfe_protocol_results.py | 6 +++--- .../tests/protocols/openmm_md/test_plain_md_protocol.py | 6 +++--- .../tests/protocols/openmm_rfe/test_hybrid_top_protocol.py | 6 +++--- .../tests/protocols/openmm_septop/test_septop_protocol.py | 6 +++--- 5 files changed, 15 insertions(+), 15 deletions(-) diff --git a/src/openfe/tests/protocols/openmm_abfe/test_abfe_protocol_results.py b/src/openfe/tests/protocols/openmm_abfe/test_abfe_protocol_results.py index 5d815c713..53574f972 100644 --- a/src/openfe/tests/protocols/openmm_abfe/test_abfe_protocol_results.py +++ b/src/openfe/tests/protocols/openmm_abfe/test_abfe_protocol_results.py @@ -79,12 +79,12 @@ def patcher(): yield -def test_gather(benzene_complex_dag, patcher, tmpdir): +def test_gather(benzene_complex_dag, patcher, tmp_path): # check that .gather behaves as expected dagres = gufe.protocols.execute_DAG( benzene_complex_dag, - shared_basedir=tmpdir, - scratch_basedir=tmpdir, + shared_basedir=tmp_path, + scratch_basedir=tmp_path, keep_shared=True, ) diff --git a/src/openfe/tests/protocols/openmm_ahfe/test_ahfe_protocol_results.py b/src/openfe/tests/protocols/openmm_ahfe/test_ahfe_protocol_results.py index 0cb2d2d25..619b199d1 100644 --- a/src/openfe/tests/protocols/openmm_ahfe/test_ahfe_protocol_results.py +++ b/src/openfe/tests/protocols/openmm_ahfe/test_ahfe_protocol_results.py @@ -99,12 +99,12 @@ def patcher(): yield -def test_gather(benzene_solvation_dag, patcher, tmpdir): +def test_gather(benzene_solvation_dag, patcher, tmp_path): # check that .gather behaves as expected dagres = gufe.protocols.execute_DAG( benzene_solvation_dag, - shared_basedir=tmpdir, - scratch_basedir=tmpdir, + shared_basedir=tmp_path, + scratch_basedir=tmp_path, keep_shared=True, ) diff --git a/src/openfe/tests/protocols/openmm_md/test_plain_md_protocol.py b/src/openfe/tests/protocols/openmm_md/test_plain_md_protocol.py index 60c7e8c47..b8e3153ff 100644 --- a/src/openfe/tests/protocols/openmm_md/test_plain_md_protocol.py +++ b/src/openfe/tests/protocols/openmm_md/test_plain_md_protocol.py @@ -508,7 +508,7 @@ def test_unit_tagging(solvent_protocol_dag, tmpdir): assert len(repeats) == 3 -def test_gather(solvent_protocol_dag, tmpdir): +def test_gather(solvent_protocol_dag, tmp_path): # check .gather behaves as expected with mock.patch( "openfe.protocols.openmm_md.plain_md_methods.PlainMDProtocolUnit.run", @@ -519,8 +519,8 @@ def test_gather(solvent_protocol_dag, tmpdir): ): dagres = gufe.protocols.execute_DAG( solvent_protocol_dag, - shared_basedir=tmpdir, - scratch_basedir=tmpdir, + shared_basedir=tmp_path, + scratch_basedir=tmp_path, keep_shared=True, ) diff --git a/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py b/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py index bd7a1f72f..c632505ea 100644 --- a/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py +++ b/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py @@ -1225,7 +1225,7 @@ def test_unit_tagging(solvent_protocol_dag, tmpdir): assert len(setup_results) == len(sim_results) == len(analysis_results) == 3 -def test_gather(solvent_protocol_dag, tmpdir): +def test_gather(solvent_protocol_dag, tmp_path): # check .gather behaves as expected with ( mock.patch( @@ -1263,8 +1263,8 @@ def test_gather(solvent_protocol_dag, tmpdir): ): dagres = gufe.protocols.execute_DAG( solvent_protocol_dag, - shared_basedir=tmpdir, - scratch_basedir=tmpdir, + shared_basedir=tmp_path, + scratch_basedir=tmp_path, keep_shared=True, ) diff --git a/src/openfe/tests/protocols/openmm_septop/test_septop_protocol.py b/src/openfe/tests/protocols/openmm_septop/test_septop_protocol.py index e5e4a9f91..4a22aebcf 100644 --- a/src/openfe/tests/protocols/openmm_septop/test_septop_protocol.py +++ b/src/openfe/tests/protocols/openmm_septop/test_septop_protocol.py @@ -1295,7 +1295,7 @@ def test_unit_tagging(benzene_toluene_dag, tmpdir): assert len(complex_repeats) == len(solv_repeats) == 2 -def test_gather(benzene_toluene_dag, tmpdir): +def test_gather(benzene_toluene_dag, tmp_path): # check that .gather behaves as expected with ( mock.patch( @@ -1339,8 +1339,8 @@ def test_gather(benzene_toluene_dag, tmpdir): ): dagres = gufe.protocols.execute_DAG( benzene_toluene_dag, - shared_basedir=tmpdir, - scratch_basedir=tmpdir, + shared_basedir=tmp_path, + scratch_basedir=tmp_path, keep_shared=True, ) From a61598e79bfdabbdd12215b03fad2cdc1b3d8f72 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:02:17 +0000 Subject: [PATCH 03/30] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/openfecli/commands/quickrun.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openfecli/commands/quickrun.py b/src/openfecli/commands/quickrun.py index 443e20a7d..cf809605c 100644 --- a/src/openfecli/commands/quickrun.py +++ b/src/openfecli/commands/quickrun.py @@ -49,9 +49,9 @@ def quickrun(transformation, work_dir, output): for each repeat of the sampling process (by default 3). """ import logging - from json import JSONDecodeError import os import sys + from json import JSONDecodeError from gufe import ProtocolDAG from gufe.protocols.protocoldag import execute_DAG From 0f43f6fcb1659c30b5e44f30d279e282902d6393 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 11 Mar 2026 14:04:29 -0700 Subject: [PATCH 04/30] add check for protocol_dag.json --- src/openfecli/tests/commands/test_quickrun.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/openfecli/tests/commands/test_quickrun.py b/src/openfecli/tests/commands/test_quickrun.py index 86fe00b26..2bab4c53b 100644 --- a/src/openfecli/tests/commands/test_quickrun.py +++ b/src/openfecli/tests/commands/test_quickrun.py @@ -34,6 +34,8 @@ def test_quickrun(extra_args, json_file): assert result.exit_code == 0 assert "Here is the result" in result.output + assert pathlib.Path(extra_args.get("-d", ""), "protocol_dag.json").exists() + if outfile := extra_args.get("-o"): assert pathlib.Path(outfile).exists() with open(outfile, mode="r") as outf: From 5e1a21c7bce95ff6e268113eede2722b8f3b321c Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 11 Mar 2026 14:26:44 -0700 Subject: [PATCH 05/30] add basic test --- src/openfecli/tests/commands/test_quickrun.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/openfecli/tests/commands/test_quickrun.py b/src/openfecli/tests/commands/test_quickrun.py index 2bab4c53b..64b487821 100644 --- a/src/openfecli/tests/commands/test_quickrun.py +++ b/src/openfecli/tests/commands/test_quickrun.py @@ -5,6 +5,7 @@ import click import pytest from click.testing import CliRunner +from gufe import Transformation from gufe.tokenization import JSON_HANDLER from openfecli.commands.quickrun import quickrun @@ -94,3 +95,16 @@ def test_quickrun_unit_error(): # to be stored in JSON # not sure whether that means we should always be storing all # protocol dag results maybe? + + +def test_quickrun_resume(json_file): + trans = Transformation.from_json(json_file) + dag = trans.create() + + runner = CliRunner() + with runner.isolated_filesystem(): + dag.to_json("protocol_dag.json") + result = runner.invoke(quickrun, [json_file]) + + assert result.exit_code == 0 + assert "Attempting to recover edge simulations" in result.output From 182562fed57ed38f3946eabe3cc9edeb5ddbf915 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 11 Mar 2026 14:45:17 -0700 Subject: [PATCH 06/30] clearer language, hopefully --- src/openfecli/commands/quickrun.py | 7 ++++--- src/openfecli/tests/commands/test_quickrun.py | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/openfecli/commands/quickrun.py b/src/openfecli/commands/quickrun.py index cf809605c..3169c6d94 100644 --- a/src/openfecli/commands/quickrun.py +++ b/src/openfecli/commands/quickrun.py @@ -97,12 +97,13 @@ def quickrun(transformation, work_dir, output): output.parent.mkdir(exist_ok=True, parents=True) # Attempt to either deserialize or freshly create DAG - if (work_dir / "protocol_dag.json").is_file(): - write("Attempting to recover edge simulations from file") + dag_json = work_dir / "protocol_dag.json" + if dag_json.is_file(): + write(f"Attempting to resume execution using existing edges from '{dag_json}'") try: dag = ProtocolDAG.from_json(work_dir / "protocol_dag.json") except JSONDecodeError: - errmsg = "Recovery failed, please clean workdir before continuing" + errmsg = f"Recovery failed, please remove {dag_json} and any results from your working directory before continuing to create a new protocol." raise click.ClickException(errmsg) else: # Create the DAG instead and then serialize for later resuming diff --git a/src/openfecli/tests/commands/test_quickrun.py b/src/openfecli/tests/commands/test_quickrun.py index 64b487821..b7e89625e 100644 --- a/src/openfecli/tests/commands/test_quickrun.py +++ b/src/openfecli/tests/commands/test_quickrun.py @@ -107,4 +107,4 @@ def test_quickrun_resume(json_file): result = runner.invoke(quickrun, [json_file]) assert result.exit_code == 0 - assert "Attempting to recover edge simulations" in result.output + assert "Attempting to resume" in result.output From 3180b8c98848003625f6e2929a0da978f47c55e2 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 11 Mar 2026 15:30:08 -0700 Subject: [PATCH 07/30] store protocol dag using transformation key --- src/openfecli/commands/quickrun.py | 13 +++++++------ src/openfecli/tests/commands/test_quickrun.py | 10 +++++++--- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/openfecli/commands/quickrun.py b/src/openfecli/commands/quickrun.py index 3169c6d94..308d8f7b0 100644 --- a/src/openfecli/commands/quickrun.py +++ b/src/openfecli/commands/quickrun.py @@ -97,19 +97,20 @@ def quickrun(transformation, work_dir, output): output.parent.mkdir(exist_ok=True, parents=True) # Attempt to either deserialize or freshly create DAG - dag_json = work_dir / "protocol_dag.json" - if dag_json.is_file(): - write(f"Attempting to resume execution using existing edges from '{dag_json}'") + trans_DAG_json = work_dir / f"Transformation-{trans.key}-protocolDAG.json" + + if trans_DAG_json.is_file(): + write(f"Attempting to resume execution using existing edges from '{trans_DAG_json}'") try: - dag = ProtocolDAG.from_json(work_dir / "protocol_dag.json") + dag = ProtocolDAG.from_json(trans_DAG_json) except JSONDecodeError: - errmsg = f"Recovery failed, please remove {dag_json} and any results from your working directory before continuing to create a new protocol." + errmsg = f"Recovery failed, please remove {trans_DAG_json} and any results from your working directory before continuing to create a new protocol." raise click.ClickException(errmsg) else: # Create the DAG instead and then serialize for later resuming write("Planning simulations for this edge...") dag = trans.create() - dag.to_json(work_dir / "protocol_dag.json") + dag.to_json(trans_DAG_json) write("Starting the simulations for this edge...") dagresult = execute_DAG( diff --git a/src/openfecli/tests/commands/test_quickrun.py b/src/openfecli/tests/commands/test_quickrun.py index b7e89625e..6a290cbac 100644 --- a/src/openfecli/tests/commands/test_quickrun.py +++ b/src/openfecli/tests/commands/test_quickrun.py @@ -10,6 +10,8 @@ from openfecli.commands.quickrun import quickrun +# from ..utils import assert_click_success + @pytest.fixture def json_file(): @@ -34,8 +36,10 @@ def test_quickrun(extra_args, json_file): result = runner.invoke(quickrun, [json_file] + extras) assert result.exit_code == 0 assert "Here is the result" in result.output - - assert pathlib.Path(extra_args.get("-d", ""), "protocol_dag.json").exists() + trans = Transformation.from_json(json_file) + assert pathlib.Path( + extra_args.get("-d", ""), f"Transformation-{trans.key}-protocolDAG.json" + ).exists() if outfile := extra_args.get("-o"): assert pathlib.Path(outfile).exists() @@ -103,7 +107,7 @@ def test_quickrun_resume(json_file): runner = CliRunner() with runner.isolated_filesystem(): - dag.to_json("protocol_dag.json") + dag.to_json(f"Transformation-{trans.key}-protocolDAG.json") result = runner.invoke(quickrun, [json_file]) assert result.exit_code == 0 From 1c0fdf79c0aa066f4ea6aab97b3fe65ff943053c Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Thu, 12 Mar 2026 07:58:17 -0700 Subject: [PATCH 08/30] another tmpdir -> tmp_path fix --- .../tests/protocols/openmm_rfe/test_hybrid_top_protocol.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py b/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py index 7e257e865..83b795eb2 100644 --- a/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py +++ b/src/openfe/tests/protocols/openmm_rfe/test_hybrid_top_protocol.py @@ -1257,12 +1257,12 @@ def test_unit_tagging(solvent_protocol_dag, unit_mock_patcher, tmpdir): assert len(setup_results) == len(sim_results) == len(analysis_results) == 3 -def test_gather(solvent_protocol_dag, unit_mock_patcher, tmpdir): +def test_gather(solvent_protocol_dag, unit_mock_patcher, tmp_path): # check .gather behaves as expected dagres = gufe.protocols.execute_DAG( solvent_protocol_dag, - shared_basedir=tmpdir, - scratch_basedir=tmpdir, + shared_basedir=tmp_path, + scratch_basedir=tmp_path, keep_shared=True, ) From 156320b2d6b263092290ca22adb38a9ba022e5fe Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Thu, 12 Mar 2026 16:21:40 -0700 Subject: [PATCH 09/30] add error handling check --- src/openfecli/commands/quickrun.py | 2 +- src/openfecli/tests/commands/test_quickrun.py | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/openfecli/commands/quickrun.py b/src/openfecli/commands/quickrun.py index 308d8f7b0..c747bdc20 100644 --- a/src/openfecli/commands/quickrun.py +++ b/src/openfecli/commands/quickrun.py @@ -97,7 +97,7 @@ def quickrun(transformation, work_dir, output): output.parent.mkdir(exist_ok=True, parents=True) # Attempt to either deserialize or freshly create DAG - trans_DAG_json = work_dir / f"Transformation-{trans.key}-protocolDAG.json" + trans_DAG_json = work_dir / f"{trans.key}-protocolDAG.json" if trans_DAG_json.is_file(): write(f"Attempting to resume execution using existing edges from '{trans_DAG_json}'") diff --git a/src/openfecli/tests/commands/test_quickrun.py b/src/openfecli/tests/commands/test_quickrun.py index 6a290cbac..9dda94f50 100644 --- a/src/openfecli/tests/commands/test_quickrun.py +++ b/src/openfecli/tests/commands/test_quickrun.py @@ -107,8 +107,22 @@ def test_quickrun_resume(json_file): runner = CliRunner() with runner.isolated_filesystem(): - dag.to_json(f"Transformation-{trans.key}-protocolDAG.json") + dag.to_json(f"{trans.key}-protocolDAG.json") result = runner.invoke(quickrun, [json_file]) assert result.exit_code == 0 assert "Attempting to resume" in result.output + + +def test_quickrun_resume_json_invalid(json_file): + """Fail if the output file doesn't load properly.""" + trans = Transformation.from_json(json_file) + + runner = CliRunner() + with runner.isolated_filesystem(): + pathlib.Path(f"{trans.key}-protocolDAG.json").touch() + result = runner.invoke(quickrun, [json_file]) + + assert result.exit_code == 1 + assert "Attempting to resume" in result.output + assert "Recovery failed" in result.stderr From 360882a7ded6fdc6b241e7aa3074d55addfb04b4 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Thu, 12 Mar 2026 17:09:07 -0700 Subject: [PATCH 10/30] fix naming in test --- src/openfecli/tests/commands/test_quickrun.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/openfecli/tests/commands/test_quickrun.py b/src/openfecli/tests/commands/test_quickrun.py index 9dda94f50..b4bfe1eab 100644 --- a/src/openfecli/tests/commands/test_quickrun.py +++ b/src/openfecli/tests/commands/test_quickrun.py @@ -37,9 +37,7 @@ def test_quickrun(extra_args, json_file): assert result.exit_code == 0 assert "Here is the result" in result.output trans = Transformation.from_json(json_file) - assert pathlib.Path( - extra_args.get("-d", ""), f"Transformation-{trans.key}-protocolDAG.json" - ).exists() + assert pathlib.Path(extra_args.get("-d", ""), f"{trans.key}-protocolDAG.json").exists() if outfile := extra_args.get("-o"): assert pathlib.Path(outfile).exists() From 04d47bbcf31cc3bbaf0e56e1a392708bc865ee21 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Thu, 12 Mar 2026 17:12:57 -0700 Subject: [PATCH 11/30] add news item --- news/quickrun_resume.rst | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 news/quickrun_resume.rst diff --git a/news/quickrun_resume.rst b/news/quickrun_resume.rst new file mode 100644 index 000000000..789c20df5 --- /dev/null +++ b/news/quickrun_resume.rst @@ -0,0 +1,23 @@ +**Added:** + +* ``openfe quickrun`` now stores ``protocolDAG`` information for each transformation, and will attempt to load this file to resume execution of an incomplete transformation. + +**Changed:** + +* + +**Deprecated:** + +* + +**Removed:** + +* + +**Fixed:** + +* + +**Security:** + +* From 1055c1b47b26054f2a72672d7777752ea6c0d816 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Fri, 13 Mar 2026 09:12:10 -0700 Subject: [PATCH 12/30] use assert_click_success --- src/openfecli/tests/commands/test_quickrun.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/openfecli/tests/commands/test_quickrun.py b/src/openfecli/tests/commands/test_quickrun.py index b4bfe1eab..942c18068 100644 --- a/src/openfecli/tests/commands/test_quickrun.py +++ b/src/openfecli/tests/commands/test_quickrun.py @@ -10,7 +10,7 @@ from openfecli.commands.quickrun import quickrun -# from ..utils import assert_click_success +from ..utils import assert_click_success @pytest.fixture @@ -34,7 +34,8 @@ def test_quickrun(extra_args, json_file): runner = CliRunner() with runner.isolated_filesystem(): result = runner.invoke(quickrun, [json_file] + extras) - assert result.exit_code == 0 + + assert_click_success(result) assert "Here is the result" in result.output trans = Transformation.from_json(json_file) assert pathlib.Path(extra_args.get("-d", ""), f"{trans.key}-protocolDAG.json").exists() @@ -70,7 +71,7 @@ def test_quickrun_output_file_in_nonexistent_directory(json_file): with runner.isolated_filesystem(): outfile = pathlib.Path("not_dir/foo.json") result = runner.invoke(quickrun, [json_file, "-o", outfile]) - assert result.exit_code == 0 + assert_click_success(result) assert outfile.parent.is_dir() @@ -81,7 +82,7 @@ def test_quickrun_dir_created_at_runtime(json_file): outdir = "not_dir" outfile = outdir + "foo.json" result = runner.invoke(quickrun, [json_file, "-d", outdir, "-o", outfile]) - assert result.exit_code == 0 + assert_click_success(result) def test_quickrun_unit_error(): @@ -108,7 +109,7 @@ def test_quickrun_resume(json_file): dag.to_json(f"{trans.key}-protocolDAG.json") result = runner.invoke(quickrun, [json_file]) - assert result.exit_code == 0 + assert_click_success(result) assert "Attempting to resume" in result.output From c8a03d8c0ae2f1637c3f2fef23edd9a5686e793d Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 18 Mar 2026 10:10:12 -0700 Subject: [PATCH 13/30] add test for interrupted job --- src/openfecli/tests/commands/test_quickrun.py | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/src/openfecli/tests/commands/test_quickrun.py b/src/openfecli/tests/commands/test_quickrun.py index 942c18068..6943e2be1 100644 --- a/src/openfecli/tests/commands/test_quickrun.py +++ b/src/openfecli/tests/commands/test_quickrun.py @@ -1,8 +1,8 @@ import json import pathlib from importlib import resources +from unittest import mock -import click import pytest from click.testing import CliRunner from gufe import Transformation @@ -21,13 +21,7 @@ def json_file(): return json_file -@pytest.mark.parametrize( - "extra_args", - [ - {}, - {"-d": "foo_dir", "-o": "foo.json"}, - ], -) +@pytest.mark.parametrize("extra_args", [{}, {"-d": "foo_dir", "-o": "foo.json"}]) def test_quickrun(extra_args, json_file): extras = sum([list(kv) for kv in extra_args.items()], []) @@ -55,6 +49,21 @@ def test_quickrun(extra_args, json_file): # assert len(list(dirpath.iterdir())) > 0 +@pytest.mark.parametrize("extra_args", [{}, {"-d": "foo_dir", "-o": "foo.json"}]) +def test_quickrun_interrupted(extra_args, json_file): + """If a quickrun is unable to complete, the protocolDAG.json checkpoint should exist.""" + extras = sum([list(kv) for kv in extra_args.items()], []) + + runner = CliRunner() + with runner.isolated_filesystem(): + with mock.patch("gufe.protocols.protocoldag.execute_DAG", side_effect=RuntimeError): + result = runner.invoke(quickrun, [json_file] + extras) + + assert "Here is the result" not in result.output + trans = Transformation.from_json(json_file) + assert pathlib.Path(extra_args.get("-d", ""), f"{trans.key}-protocolDAG.json").exists() + + def test_quickrun_output_file_exists(json_file): """Fail if the output file already exists.""" runner = CliRunner() From d735c7fcaecc9b8efbcc6ea166e6d52547431fcb Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 18 Mar 2026 10:11:43 -0700 Subject: [PATCH 14/30] remove checkpoint when a job has completed successfully --- src/openfecli/commands/quickrun.py | 3 +++ src/openfecli/tests/commands/test_quickrun.py | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/openfecli/commands/quickrun.py b/src/openfecli/commands/quickrun.py index c747bdc20..b469d3cd8 100644 --- a/src/openfecli/commands/quickrun.py +++ b/src/openfecli/commands/quickrun.py @@ -143,6 +143,9 @@ def quickrun(transformation, work_dir, output): with open(output, mode="w") as outf: json.dump(out_dict, outf, cls=JSON_HANDLER.encoder) + # remove the checkpoint since the job has completed + os.remove(trans_DAG_json) + write(f"Here is the result:\n\tdG = {estimate} ± {uncertainty}\n") write("") diff --git a/src/openfecli/tests/commands/test_quickrun.py b/src/openfecli/tests/commands/test_quickrun.py index 6943e2be1..9ca9f2557 100644 --- a/src/openfecli/tests/commands/test_quickrun.py +++ b/src/openfecli/tests/commands/test_quickrun.py @@ -32,7 +32,8 @@ def test_quickrun(extra_args, json_file): assert_click_success(result) assert "Here is the result" in result.output trans = Transformation.from_json(json_file) - assert pathlib.Path(extra_args.get("-d", ""), f"{trans.key}-protocolDAG.json").exists() + # checkpoint should be deleted when job is complete + assert not pathlib.Path(extra_args.get("-d", ""), f"{trans.key}-protocolDAG.json").exists() if outfile := extra_args.get("-o"): assert pathlib.Path(outfile).exists() From 6518499a16c211b82b9718a4feef1e047fdf463f Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 18 Mar 2026 11:28:57 -0700 Subject: [PATCH 15/30] add handling for checkpoint error handling without --resume --- src/openfecli/commands/quickrun.py | 10 ++++++--- src/openfecli/tests/commands/test_quickrun.py | 22 ++++++++++++++++--- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/openfecli/commands/quickrun.py b/src/openfecli/commands/quickrun.py index b469d3cd8..8ef5bc7ce 100644 --- a/src/openfecli/commands/quickrun.py +++ b/src/openfecli/commands/quickrun.py @@ -30,8 +30,9 @@ def _format_exception(exception) -> str: type=click.Path(dir_okay=False, file_okay=False, path_type=pathlib.Path), help="Filepath at which to create and write the JSON-formatted results.", ) # fmt: skip +@click.option("--resume", is_flag=True, default=False, help=("")) # TODO: add help msg @print_duration -def quickrun(transformation, work_dir, output): +def quickrun(transformation, work_dir, output, resume): """Run the transformation (edge) in the given JSON file. Simulation JSON files can be created with the @@ -99,13 +100,16 @@ def quickrun(transformation, work_dir, output): # Attempt to either deserialize or freshly create DAG trans_DAG_json = work_dir / f"{trans.key}-protocolDAG.json" - if trans_DAG_json.is_file(): + if resume and trans_DAG_json.is_file(): write(f"Attempting to resume execution using existing edges from '{trans_DAG_json}'") try: dag = ProtocolDAG.from_json(trans_DAG_json) except JSONDecodeError: - errmsg = f"Recovery failed, please remove {trans_DAG_json} and any results from your working directory before continuing to create a new protocol." + errmsg = f"Recovery failed, please remove {trans_DAG_json} and any results from your working directory before continuing to create a new protocol, or run without `--resume`." raise click.ClickException(errmsg) + elif not resume and trans_DAG_json.is_file(): + errmsg = f"Transformation has been started but is incomplete. Please remove {trans_DAG_json} and rerun, or resume execution using the ``--resume`` flag." + raise RuntimeError(errmsg) else: # Create the DAG instead and then serialize for later resuming write("Planning simulations for this edge...") diff --git a/src/openfecli/tests/commands/test_quickrun.py b/src/openfecli/tests/commands/test_quickrun.py index 9ca9f2557..59bcb0582 100644 --- a/src/openfecli/tests/commands/test_quickrun.py +++ b/src/openfecli/tests/commands/test_quickrun.py @@ -110,7 +110,8 @@ def test_quickrun_unit_error(): # protocol dag results maybe? -def test_quickrun_resume(json_file): +def test_quickrun_existing_checkpoint(json_file): + """In the default case where resume=False, if the checkpoint exists, quickrun should error out and not attempt to execute.""" trans = Transformation.from_json(json_file) dag = trans.create() @@ -118,20 +119,35 @@ def test_quickrun_resume(json_file): with runner.isolated_filesystem(): dag.to_json(f"{trans.key}-protocolDAG.json") result = runner.invoke(quickrun, [json_file]) + assert isinstance(result.exception, RuntimeError) + assert "Attempting to resume" not in result.output + + +def test_quickrun_resume_from_checkpoint(json_file): + trans = Transformation.from_json(json_file) + dag = trans.create() + + runner = CliRunner() + with runner.isolated_filesystem(): + dag.to_json(f"{trans.key}-protocolDAG.json") + result = runner.invoke(quickrun, [json_file, "--resume"]) assert_click_success(result) assert "Attempting to resume" in result.output -def test_quickrun_resume_json_invalid(json_file): +def test_quickrun_resume_invalid_checkpoint(json_file): """Fail if the output file doesn't load properly.""" trans = Transformation.from_json(json_file) runner = CliRunner() with runner.isolated_filesystem(): pathlib.Path(f"{trans.key}-protocolDAG.json").touch() - result = runner.invoke(quickrun, [json_file]) + result = runner.invoke(quickrun, [json_file, "--resume"]) assert result.exit_code == 1 assert "Attempting to resume" in result.output assert "Recovery failed" in result.stderr + + +# def test_quickrun_resume_missing_checkpoint(json_file): From 5cd437ed4c76f51741947de4e74b5cdf37f9641c Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 18 Mar 2026 11:42:38 -0700 Subject: [PATCH 16/30] clean up logic --- src/openfecli/commands/quickrun.py | 28 ++++++++++++------- src/openfecli/tests/commands/test_quickrun.py | 9 +++++- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/openfecli/commands/quickrun.py b/src/openfecli/commands/quickrun.py index 8ef5bc7ce..d6c828e07 100644 --- a/src/openfecli/commands/quickrun.py +++ b/src/openfecli/commands/quickrun.py @@ -3,6 +3,7 @@ import json import pathlib +import warnings import click @@ -100,17 +101,24 @@ def quickrun(transformation, work_dir, output, resume): # Attempt to either deserialize or freshly create DAG trans_DAG_json = work_dir / f"{trans.key}-protocolDAG.json" - if resume and trans_DAG_json.is_file(): - write(f"Attempting to resume execution using existing edges from '{trans_DAG_json}'") - try: - dag = ProtocolDAG.from_json(trans_DAG_json) - except JSONDecodeError: - errmsg = f"Recovery failed, please remove {trans_DAG_json} and any results from your working directory before continuing to create a new protocol, or run without `--resume`." - raise click.ClickException(errmsg) - elif not resume and trans_DAG_json.is_file(): - errmsg = f"Transformation has been started but is incomplete. Please remove {trans_DAG_json} and rerun, or resume execution using the ``--resume`` flag." - raise RuntimeError(errmsg) + if trans_DAG_json.is_file(): + if resume: + write(f"Attempting to resume execution using existing edges from '{trans_DAG_json}'") + try: + dag = ProtocolDAG.from_json(trans_DAG_json) + except JSONDecodeError: + errmsg = f"Recovery failed, please remove {trans_DAG_json} and any results from your working directory before continuing to create a new protocol, or run without `--resume`." + raise click.ClickException(errmsg) + else: + errmsg = f"Transformation has been started but is incomplete. Please remove {trans_DAG_json} and rerun, or resume execution using the ``--resume`` flag." + raise RuntimeError(errmsg) + else: + if resume: + warnings.warn( + f"No checkpoint found at {trans_DAG_json}! Starting new execution." + ) # TODO: make this clearer + # Create the DAG instead and then serialize for later resuming write("Planning simulations for this edge...") dag = trans.create() diff --git a/src/openfecli/tests/commands/test_quickrun.py b/src/openfecli/tests/commands/test_quickrun.py index 59bcb0582..0dba3f8f5 100644 --- a/src/openfecli/tests/commands/test_quickrun.py +++ b/src/openfecli/tests/commands/test_quickrun.py @@ -150,4 +150,11 @@ def test_quickrun_resume_invalid_checkpoint(json_file): assert "Recovery failed" in result.stderr -# def test_quickrun_resume_missing_checkpoint(json_file): +def test_quickrun_resume_missing_checkpoint(json_file): + """If --resume is passed but there's not checkpoint, just warn and keep going""" + runner = CliRunner() + with runner.isolated_filesystem(): + result = runner.invoke(quickrun, [json_file, "--resume"]) + + assert result.exit_code == 0 + # TODO: check for warning From 61a97b63a2b45c5f0c5c5fed0416a2c5aefb2f76 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 18 Mar 2026 12:08:40 -0700 Subject: [PATCH 17/30] check for warning --- src/openfecli/commands/quickrun.py | 5 ++--- src/openfecli/tests/commands/test_quickrun.py | 7 +++---- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/openfecli/commands/quickrun.py b/src/openfecli/commands/quickrun.py index d6c828e07..a47d7c82b 100644 --- a/src/openfecli/commands/quickrun.py +++ b/src/openfecli/commands/quickrun.py @@ -115,9 +115,8 @@ def quickrun(transformation, work_dir, output, resume): else: if resume: - warnings.warn( - f"No checkpoint found at {trans_DAG_json}! Starting new execution." - ) # TODO: make this clearer + # TODO: make this message clearer + warnings.warn(f"No checkpoint found at {trans_DAG_json}! Starting new execution.") # Create the DAG instead and then serialize for later resuming write("Planning simulations for this edge...") diff --git a/src/openfecli/tests/commands/test_quickrun.py b/src/openfecli/tests/commands/test_quickrun.py index 0dba3f8f5..7b9391d48 100644 --- a/src/openfecli/tests/commands/test_quickrun.py +++ b/src/openfecli/tests/commands/test_quickrun.py @@ -154,7 +154,6 @@ def test_quickrun_resume_missing_checkpoint(json_file): """If --resume is passed but there's not checkpoint, just warn and keep going""" runner = CliRunner() with runner.isolated_filesystem(): - result = runner.invoke(quickrun, [json_file, "--resume"]) - - assert result.exit_code == 0 - # TODO: check for warning + with pytest.warns(): + result = runner.invoke(quickrun, [json_file, "--resume"]) + assert result.exit_code == 0 From 48ab9c8f9c9c4dc4252a1ecac0b77c4d4626bd8c Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 18 Mar 2026 15:15:23 -0700 Subject: [PATCH 18/30] add docs --- news/quickrun_resume.rst | 3 ++- src/openfecli/commands/quickrun.py | 8 ++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/news/quickrun_resume.rst b/news/quickrun_resume.rst index 789c20df5..e53e52738 100644 --- a/news/quickrun_resume.rst +++ b/news/quickrun_resume.rst @@ -1,6 +1,7 @@ **Added:** -* ``openfe quickrun`` now stores ``protocolDAG`` information for each transformation, and will attempt to load this file to resume execution of an incomplete transformation. +* Added ``--resume`` flag to ``openfe quickrun``. + Quickrun now temporarily caches ``protocolDAG`` information and when used with the ``--resume`` flag, quickrun will attempt resume execution of an incomplete transformation. **Changed:** diff --git a/src/openfecli/commands/quickrun.py b/src/openfecli/commands/quickrun.py index a47d7c82b..7b8d692f4 100644 --- a/src/openfecli/commands/quickrun.py +++ b/src/openfecli/commands/quickrun.py @@ -31,7 +31,12 @@ def _format_exception(exception) -> str: type=click.Path(dir_okay=False, file_okay=False, path_type=pathlib.Path), help="Filepath at which to create and write the JSON-formatted results.", ) # fmt: skip -@click.option("--resume", is_flag=True, default=False, help=("")) # TODO: add help msg +@click.option( + "--resume", + is_flag=True, + default=False, + help=("Attempt to resume this transformation's execution using the cache."), +) @print_duration def quickrun(transformation, work_dir, output, resume): """Run the transformation (edge) in the given JSON file. @@ -115,7 +120,6 @@ def quickrun(transformation, work_dir, output, resume): else: if resume: - # TODO: make this message clearer warnings.warn(f"No checkpoint found at {trans_DAG_json}! Starting new execution.") # Create the DAG instead and then serialize for later resuming From 31a6589d5d747ffe83d87ff7a69e1ee2b039c15f Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Wed, 18 Mar 2026 15:34:24 -0700 Subject: [PATCH 19/30] make a cache dir --- src/openfecli/commands/quickrun.py | 3 ++- src/openfecli/tests/commands/test_quickrun.py | 20 +++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/openfecli/commands/quickrun.py b/src/openfecli/commands/quickrun.py index 7b8d692f4..15e099237 100644 --- a/src/openfecli/commands/quickrun.py +++ b/src/openfecli/commands/quickrun.py @@ -104,7 +104,7 @@ def quickrun(transformation, work_dir, output, resume): output.parent.mkdir(exist_ok=True, parents=True) # Attempt to either deserialize or freshly create DAG - trans_DAG_json = work_dir / f"{trans.key}-protocolDAG.json" + trans_DAG_json = work_dir / "quickrun_cache" / f"{trans.key}-protocolDAG.json" if trans_DAG_json.is_file(): if resume: @@ -125,6 +125,7 @@ def quickrun(transformation, work_dir, output, resume): # Create the DAG instead and then serialize for later resuming write("Planning simulations for this edge...") dag = trans.create() + pathlib.Path(work_dir, "quickrun_cache").mkdir(exist_ok=True) dag.to_json(trans_DAG_json) write("Starting the simulations for this edge...") diff --git a/src/openfecli/tests/commands/test_quickrun.py b/src/openfecli/tests/commands/test_quickrun.py index 7b9391d48..786e3a484 100644 --- a/src/openfecli/tests/commands/test_quickrun.py +++ b/src/openfecli/tests/commands/test_quickrun.py @@ -1,4 +1,5 @@ import json +import os import pathlib from importlib import resources from unittest import mock @@ -33,7 +34,9 @@ def test_quickrun(extra_args, json_file): assert "Here is the result" in result.output trans = Transformation.from_json(json_file) # checkpoint should be deleted when job is complete - assert not pathlib.Path(extra_args.get("-d", ""), f"{trans.key}-protocolDAG.json").exists() + assert not pathlib.Path( + extra_args.get("-d", ""), "quickrun_cache", f"{trans.key}-protocolDAG.json" + ).exists() if outfile := extra_args.get("-o"): assert pathlib.Path(outfile).exists() @@ -62,7 +65,9 @@ def test_quickrun_interrupted(extra_args, json_file): assert "Here is the result" not in result.output trans = Transformation.from_json(json_file) - assert pathlib.Path(extra_args.get("-d", ""), f"{trans.key}-protocolDAG.json").exists() + assert pathlib.Path( + extra_args.get("-d", ""), "quickrun_cache", f"{trans.key}-protocolDAG.json" + ).exists() def test_quickrun_output_file_exists(json_file): @@ -117,7 +122,8 @@ def test_quickrun_existing_checkpoint(json_file): runner = CliRunner() with runner.isolated_filesystem(): - dag.to_json(f"{trans.key}-protocolDAG.json") + pathlib.Path("quickrun_cache").mkdir() + dag.to_json(pathlib.Path("quickrun_cache", f"{trans.key}-protocolDAG.json")) result = runner.invoke(quickrun, [json_file]) assert isinstance(result.exception, RuntimeError) assert "Attempting to resume" not in result.output @@ -129,7 +135,8 @@ def test_quickrun_resume_from_checkpoint(json_file): runner = CliRunner() with runner.isolated_filesystem(): - dag.to_json(f"{trans.key}-protocolDAG.json") + pathlib.Path("quickrun_cache").mkdir() + dag.to_json(pathlib.Path("quickrun_cache", f"{trans.key}-protocolDAG.json")) result = runner.invoke(quickrun, [json_file, "--resume"]) assert_click_success(result) @@ -142,7 +149,8 @@ def test_quickrun_resume_invalid_checkpoint(json_file): runner = CliRunner() with runner.isolated_filesystem(): - pathlib.Path(f"{trans.key}-protocolDAG.json").touch() + pathlib.Path("quickrun_cache").mkdir() + pathlib.Path("quickrun_cache", f"{trans.key}-protocolDAG.json").touch() result = runner.invoke(quickrun, [json_file, "--resume"]) assert result.exit_code == 1 @@ -151,7 +159,7 @@ def test_quickrun_resume_invalid_checkpoint(json_file): def test_quickrun_resume_missing_checkpoint(json_file): - """If --resume is passed but there's not checkpoint, just warn and keep going""" + """If --resume is passed but there's not checkpoint, just warn and keep going.""" runner = CliRunner() with runner.isolated_filesystem(): with pytest.warns(): From fe746c8ca87c992e87e94edd4a10a08a8a01b193 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Fri, 20 Mar 2026 13:34:34 -0700 Subject: [PATCH 20/30] use clickexception --- environment.yml | 2 +- src/openfecli/commands/quickrun.py | 2 +- src/openfecli/tests/commands/test_quickrun.py | 3 ++- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/environment.yml b/environment.yml index b4fdbe9c1..5b23e9065 100644 --- a/environment.yml +++ b/environment.yml @@ -53,7 +53,7 @@ dependencies: # Control blas/openmp threads - threadpoolctl - pip: - - git+https://github.com/OpenFreeEnergy/gufe@restart_execute + - git+https://github.com/OpenFreeEnergy/gufe@main - run_constrained: # drop this pin when handled upstream in espaloma-feedstock - smirnoff99frosst>=1.1.0.1 #https://github.com/openforcefield/smirnoff99Frosst/issues/109 diff --git a/src/openfecli/commands/quickrun.py b/src/openfecli/commands/quickrun.py index 15e099237..660c252d3 100644 --- a/src/openfecli/commands/quickrun.py +++ b/src/openfecli/commands/quickrun.py @@ -116,7 +116,7 @@ def quickrun(transformation, work_dir, output, resume): raise click.ClickException(errmsg) else: errmsg = f"Transformation has been started but is incomplete. Please remove {trans_DAG_json} and rerun, or resume execution using the ``--resume`` flag." - raise RuntimeError(errmsg) + raise click.ClickException(errmsg) else: if resume: diff --git a/src/openfecli/tests/commands/test_quickrun.py b/src/openfecli/tests/commands/test_quickrun.py index 786e3a484..57666616d 100644 --- a/src/openfecli/tests/commands/test_quickrun.py +++ b/src/openfecli/tests/commands/test_quickrun.py @@ -125,8 +125,9 @@ def test_quickrun_existing_checkpoint(json_file): pathlib.Path("quickrun_cache").mkdir() dag.to_json(pathlib.Path("quickrun_cache", f"{trans.key}-protocolDAG.json")) result = runner.invoke(quickrun, [json_file]) - assert isinstance(result.exception, RuntimeError) + assert result.exit_code == 1 assert "Attempting to resume" not in result.output + assert "Transformation has been started but is incomplete." in result.stderr def test_quickrun_resume_from_checkpoint(json_file): From fd9253b3f68d794c196751e38b7df61d84768e2d Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Fri, 20 Mar 2026 14:31:02 -0700 Subject: [PATCH 21/30] update error message --- src/openfecli/commands/quickrun.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openfecli/commands/quickrun.py b/src/openfecli/commands/quickrun.py index 660c252d3..95bd9cb10 100644 --- a/src/openfecli/commands/quickrun.py +++ b/src/openfecli/commands/quickrun.py @@ -112,7 +112,7 @@ def quickrun(transformation, work_dir, output, resume): try: dag = ProtocolDAG.from_json(trans_DAG_json) except JSONDecodeError: - errmsg = f"Recovery failed, please remove {trans_DAG_json} and any results from your working directory before continuing to create a new protocol, or run without `--resume`." + errmsg = f"Recovery failed, please remove {trans_DAG_json} and any results from your working directory before continuing to create a new protocol." raise click.ClickException(errmsg) else: errmsg = f"Transformation has been started but is incomplete. Please remove {trans_DAG_json} and rerun, or resume execution using the ``--resume`` flag." From 4ed9b5ffc542f98ca47db8b56213f7c8f03559bd Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Fri, 20 Mar 2026 14:37:38 -0700 Subject: [PATCH 22/30] update kwarg --- src/openfecli/commands/quickrun.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/openfecli/commands/quickrun.py b/src/openfecli/commands/quickrun.py index 95bd9cb10..6956d1766 100644 --- a/src/openfecli/commands/quickrun.py +++ b/src/openfecli/commands/quickrun.py @@ -133,7 +133,7 @@ def quickrun(transformation, work_dir, output, resume): dag, shared_basedir=work_dir, scratch_basedir=work_dir, - unitresults_basedir=work_dir, + cache_basedir=work_dir, keep_shared=True, raise_error=False, n_retries=2, From 494eb0623fab5c7e7a20b0e519a6ce716e1d5616 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Fri, 20 Mar 2026 14:45:07 -0700 Subject: [PATCH 23/30] keep everything in the quickrun cache --- src/openfecli/commands/quickrun.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/openfecli/commands/quickrun.py b/src/openfecli/commands/quickrun.py index 6956d1766..94aee0112 100644 --- a/src/openfecli/commands/quickrun.py +++ b/src/openfecli/commands/quickrun.py @@ -104,7 +104,8 @@ def quickrun(transformation, work_dir, output, resume): output.parent.mkdir(exist_ok=True, parents=True) # Attempt to either deserialize or freshly create DAG - trans_DAG_json = work_dir / "quickrun_cache" / f"{trans.key}-protocolDAG.json" + cache_basedir = work_dir / "quickrun_cache" + trans_DAG_json = cache_basedir / f"{trans.key}-protocolDAG.json" if trans_DAG_json.is_file(): if resume: @@ -125,7 +126,7 @@ def quickrun(transformation, work_dir, output, resume): # Create the DAG instead and then serialize for later resuming write("Planning simulations for this edge...") dag = trans.create() - pathlib.Path(work_dir, "quickrun_cache").mkdir(exist_ok=True) + cache_basedir.mkdir(exist_ok=True) dag.to_json(trans_DAG_json) write("Starting the simulations for this edge...") @@ -133,7 +134,7 @@ def quickrun(transformation, work_dir, output, resume): dag, shared_basedir=work_dir, scratch_basedir=work_dir, - cache_basedir=work_dir, + cache_basedir=cache_basedir, keep_shared=True, raise_error=False, n_retries=2, From d9a2d5e57db79cca416f90e321d8c7bcbb05e7d7 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Fri, 20 Mar 2026 14:57:53 -0700 Subject: [PATCH 24/30] clearer message --- src/openfecli/commands/quickrun.py | 4 +++- src/openfecli/tests/commands/test_quickrun.py | 8 ++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/openfecli/commands/quickrun.py b/src/openfecli/commands/quickrun.py index 94aee0112..13061b4d3 100644 --- a/src/openfecli/commands/quickrun.py +++ b/src/openfecli/commands/quickrun.py @@ -121,7 +121,9 @@ def quickrun(transformation, work_dir, output, resume): else: if resume: - warnings.warn(f"No checkpoint found at {trans_DAG_json}! Starting new execution.") + click.echo( + f"openfe quickrun was run with --resume, but no checkpoint found at {trans_DAG_json}. Starting new execution." + ) # Create the DAG instead and then serialize for later resuming write("Planning simulations for this edge...") diff --git a/src/openfecli/tests/commands/test_quickrun.py b/src/openfecli/tests/commands/test_quickrun.py index 57666616d..08635c0a7 100644 --- a/src/openfecli/tests/commands/test_quickrun.py +++ b/src/openfecli/tests/commands/test_quickrun.py @@ -160,9 +160,9 @@ def test_quickrun_resume_invalid_checkpoint(json_file): def test_quickrun_resume_missing_checkpoint(json_file): - """If --resume is passed but there's not checkpoint, just warn and keep going.""" + """If --resume is passed but there's not checkpoint, just echo a message and keep going.""" runner = CliRunner() with runner.isolated_filesystem(): - with pytest.warns(): - result = runner.invoke(quickrun, [json_file, "--resume"]) - assert result.exit_code == 0 + result = runner.invoke(quickrun, [json_file, "--resume"]) + assert_click_success(result) + assert "openfe quickrun was run with --resume, but no checkpoint found at" in result.output From 58b5642dd10e4ee29602a92a31e5b355b86e0504 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Fri, 20 Mar 2026 14:58:19 -0700 Subject: [PATCH 25/30] it's ProtocolDAG not protocolDAG --- src/openfecli/commands/quickrun.py | 2 +- src/openfecli/tests/commands/test_quickrun.py | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/openfecli/commands/quickrun.py b/src/openfecli/commands/quickrun.py index 13061b4d3..7f1928a10 100644 --- a/src/openfecli/commands/quickrun.py +++ b/src/openfecli/commands/quickrun.py @@ -105,7 +105,7 @@ def quickrun(transformation, work_dir, output, resume): # Attempt to either deserialize or freshly create DAG cache_basedir = work_dir / "quickrun_cache" - trans_DAG_json = cache_basedir / f"{trans.key}-protocolDAG.json" + trans_DAG_json = cache_basedir / f"{trans.key}-ProtocolDAG.json" if trans_DAG_json.is_file(): if resume: diff --git a/src/openfecli/tests/commands/test_quickrun.py b/src/openfecli/tests/commands/test_quickrun.py index 08635c0a7..4cf74e456 100644 --- a/src/openfecli/tests/commands/test_quickrun.py +++ b/src/openfecli/tests/commands/test_quickrun.py @@ -35,7 +35,7 @@ def test_quickrun(extra_args, json_file): trans = Transformation.from_json(json_file) # checkpoint should be deleted when job is complete assert not pathlib.Path( - extra_args.get("-d", ""), "quickrun_cache", f"{trans.key}-protocolDAG.json" + extra_args.get("-d", ""), "quickrun_cache", f"{trans.key}-ProtocolDAG.json" ).exists() if outfile := extra_args.get("-o"): @@ -55,7 +55,7 @@ def test_quickrun(extra_args, json_file): @pytest.mark.parametrize("extra_args", [{}, {"-d": "foo_dir", "-o": "foo.json"}]) def test_quickrun_interrupted(extra_args, json_file): - """If a quickrun is unable to complete, the protocolDAG.json checkpoint should exist.""" + """If a quickrun is unable to complete, the ProtocolDAG.json checkpoint should exist.""" extras = sum([list(kv) for kv in extra_args.items()], []) runner = CliRunner() @@ -66,7 +66,7 @@ def test_quickrun_interrupted(extra_args, json_file): assert "Here is the result" not in result.output trans = Transformation.from_json(json_file) assert pathlib.Path( - extra_args.get("-d", ""), "quickrun_cache", f"{trans.key}-protocolDAG.json" + extra_args.get("-d", ""), "quickrun_cache", f"{trans.key}-ProtocolDAG.json" ).exists() @@ -123,7 +123,7 @@ def test_quickrun_existing_checkpoint(json_file): runner = CliRunner() with runner.isolated_filesystem(): pathlib.Path("quickrun_cache").mkdir() - dag.to_json(pathlib.Path("quickrun_cache", f"{trans.key}-protocolDAG.json")) + dag.to_json(pathlib.Path("quickrun_cache", f"{trans.key}-ProtocolDAG.json")) result = runner.invoke(quickrun, [json_file]) assert result.exit_code == 1 assert "Attempting to resume" not in result.output @@ -137,7 +137,7 @@ def test_quickrun_resume_from_checkpoint(json_file): runner = CliRunner() with runner.isolated_filesystem(): pathlib.Path("quickrun_cache").mkdir() - dag.to_json(pathlib.Path("quickrun_cache", f"{trans.key}-protocolDAG.json")) + dag.to_json(pathlib.Path("quickrun_cache", f"{trans.key}-ProtocolDAG.json")) result = runner.invoke(quickrun, [json_file, "--resume"]) assert_click_success(result) @@ -151,7 +151,7 @@ def test_quickrun_resume_invalid_checkpoint(json_file): runner = CliRunner() with runner.isolated_filesystem(): pathlib.Path("quickrun_cache").mkdir() - pathlib.Path("quickrun_cache", f"{trans.key}-protocolDAG.json").touch() + pathlib.Path("quickrun_cache", f"{trans.key}-ProtocolDAG.json").touch() result = runner.invoke(quickrun, [json_file, "--resume"]) assert result.exit_code == 1 From e7de6b87bcc3195dadff989abd701f8c489ef860 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Fri, 20 Mar 2026 15:56:09 -0700 Subject: [PATCH 26/30] bump CI From ec44cce38db69ecf0987b20c9105161d172b6e82 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Fri, 20 Mar 2026 16:03:01 -0700 Subject: [PATCH 27/30] bump CI From 6d8a3f7ad3436ff8db0053fe587c90fba0fbfbfa Mon Sep 17 00:00:00 2001 From: Alyssa Travitz <31974495+atravitz@users.noreply.github.com> Date: Thu, 26 Mar 2026 06:06:33 -0700 Subject: [PATCH 28/30] include output json in caching hash (#1890) * first pass at adding caching with output json * add (hopefully) helpful execution information * add resume command * fix testing path * check that output file isn't created early * clean up tests * line break --- src/openfecli/commands/quickrun.py | 51 +++++++++++---- src/openfecli/tests/commands/test_quickrun.py | 62 ++++++++++++------- 2 files changed, 79 insertions(+), 34 deletions(-) diff --git a/src/openfecli/commands/quickrun.py b/src/openfecli/commands/quickrun.py index 7f1928a10..ba673d94f 100644 --- a/src/openfecli/commands/quickrun.py +++ b/src/openfecli/commands/quickrun.py @@ -1,6 +1,7 @@ # This code is part of OpenFE and is licensed under the MIT license. # For details, see https://github.com/OpenFreeEnergy/openfe +import hashlib import json import pathlib import warnings @@ -16,6 +17,12 @@ def _format_exception(exception) -> str: return f"{exception[0]}: {exception[1][0]}" +def _hash_quickrun_inputs(output, transformation): + string_rep = f"{output.absolute()}{transformation.key}" + hasher = hashlib.md5(string_rep.encode(), usedforsecurity=False) + return hasher.hexdigest() + + @click.command("quickrun", short_help="Run a given transformation, saved as a JSON file") @click.argument("transformation", type=click.File(mode="r"), required=True) @click.option( @@ -90,12 +97,14 @@ def quickrun(transformation, work_dir, output, resume): # turn warnings into log message (don't show stack trace) logging.captureWarnings(True) + click.secho(f"\nCurrent directory: {os.getcwd()}/") if work_dir is None: + click.secho(f"Creating working directory: {work_dir}/") work_dir = pathlib.Path(os.getcwd()) else: + click.secho(f"Using existing working directory: {work_dir}/") work_dir.mkdir(exist_ok=True, parents=True) - write("Loading file...") trans = Transformation.from_json(transformation) if output is None: @@ -103,35 +112,51 @@ def quickrun(transformation, work_dir, output, resume): else: output.parent.mkdir(exist_ok=True, parents=True) + click.secho(f"Loading transformation from: {transformation.name}") + click.secho(f"When simulation is complete, results will be written to: {output}\n") + + resume_command = f"openfe quickrun {transformation.name} -o {output} -d {work_dir} --resume\n" + + click.secho( + "If this simulation is interrupted or fails, you may attempt to resume execution using:", + bold=True, + ) + click.secho(resume_command) + # Attempt to either deserialize or freshly create DAG cache_basedir = work_dir / "quickrun_cache" - trans_DAG_json = cache_basedir / f"{trans.key}-ProtocolDAG.json" + hashed_key = _hash_quickrun_inputs(output, trans) + cached_dag_path = cache_basedir / f"dag-cache-{hashed_key}.json" - if trans_DAG_json.is_file(): + if cached_dag_path.is_file(): if resume: - write(f"Attempting to resume execution using existing edges from '{trans_DAG_json}'") + write(f"Attempting to resume execution using '{cached_dag_path}'") try: - dag = ProtocolDAG.from_json(trans_DAG_json) + dag = ProtocolDAG.from_json(cached_dag_path) except JSONDecodeError: - errmsg = f"Recovery failed, please remove {trans_DAG_json} and any results from your working directory before continuing to create a new protocol." + # we can't tell the user which gufe-generated cache dir to delete, since we'd need to load the JSON to know the DAG's key + # however, just removing the cached_dag_path is sufficient to trigger a fresh DAG to be generated, and the gufe-generated cached dir will just be stale. + errmsg = f"Recovery failed, please remove {cached_dag_path} before continuing to create a new protocol." raise click.ClickException(errmsg) + + write("Success. Resuming execution...") else: - errmsg = f"Transformation has been started but is incomplete. Please remove {trans_DAG_json} and rerun, or resume execution using the ``--resume`` flag." - raise click.ClickException(errmsg) + errmsg = f"Transformation has been started but is incomplete. Please remove {cached_dag_path} and rerun, or resume execution using the ``--resume`` flag." + raise click.ClickException(click.style(errmsg, fg="red")) else: if resume: - click.echo( - f"openfe quickrun was run with --resume, but no checkpoint found at {trans_DAG_json}. Starting new execution." + write( + f"openfe quickrun was run with --resume, but no cached results found at {cached_dag_path}. Starting new execution." ) # Create the DAG instead and then serialize for later resuming write("Planning simulations for this edge...") dag = trans.create() cache_basedir.mkdir(exist_ok=True) - dag.to_json(trans_DAG_json) + dag.to_json(cached_dag_path) - write("Starting the simulations for this edge...") + write("\nStarting the simulations for this edge...\n") dagresult = execute_DAG( dag, shared_basedir=work_dir, @@ -163,7 +188,7 @@ def quickrun(transformation, work_dir, output, resume): json.dump(out_dict, outf, cls=JSON_HANDLER.encoder) # remove the checkpoint since the job has completed - os.remove(trans_DAG_json) + os.remove(cached_dag_path) write(f"Here is the result:\n\tdG = {estimate} ± {uncertainty}\n") write("") diff --git a/src/openfecli/tests/commands/test_quickrun.py b/src/openfecli/tests/commands/test_quickrun.py index c4de44496..9fdcce0c3 100644 --- a/src/openfecli/tests/commands/test_quickrun.py +++ b/src/openfecli/tests/commands/test_quickrun.py @@ -9,7 +9,7 @@ from gufe import Transformation from gufe.tokenization import JSON_HANDLER -from openfecli.commands.quickrun import quickrun +from openfecli.commands.quickrun import _hash_quickrun_inputs, quickrun from ..utils import assert_click_success @@ -28,29 +28,28 @@ def test_quickrun(extra_args, json_file): runner = CliRunner() with runner.isolated_filesystem(): + # figure out what cached json should be trans = Transformation.from_json(json_file) + work_dir = extra_args.get("-d", ".") outfile = pathlib.Path(extra_args.get("-o", f"{trans.key}_results.json")) + hashed_key = _hash_quickrun_inputs(outfile, trans) # output json shouldn't be created before quickrun is executed assert not pathlib.Path(outfile).exists() - result = runner.invoke(quickrun, [json_file] + extras) assert_click_success(result) assert "Here is the result" in result.output - trans = Transformation.from_json(json_file) + # checkpoint should be deleted when job is complete - assert not pathlib.Path( - extra_args.get("-d", ""), "quickrun_cache", f"{trans.key}-ProtocolDAG.json" - ).exists() + assert not pathlib.Path(work_dir, "quickrun_cache", f"dag-cache-{hashed_key}.json").exists() - # output json should exist and have results after execution + # output json should exist with data when job is complete assert pathlib.Path(outfile).exists() with open(outfile, mode="r") as outf: dct = json.load(outf, cls=JSON_HANDLER.decoder) assert set(dct) == {"estimate", "uncertainty", "protocol_result", "unit_results"} - # TODO: need a protocol that drops files to actually do this! # if directory := extra_args.get('-d'): # dirpath = pathlib.Path(directory) @@ -61,19 +60,22 @@ def test_quickrun(extra_args, json_file): @pytest.mark.parametrize("extra_args", [{}, {"-d": "foo_dir", "-o": "foo.json"}]) def test_quickrun_interrupted(extra_args, json_file): - """If a quickrun is unable to complete, the ProtocolDAG.json checkpoint should exist.""" + """If quickrun starts but is unable to complete, the ProtocolDAG.json cached checkpoint should exist.""" extras = sum([list(kv) for kv in extra_args.items()], []) runner = CliRunner() with runner.isolated_filesystem(): + # figure out what cached json should be + trans = Transformation.from_json(json_file) + work_dir = pathlib.Path(extra_args.get("-d", ".")).absolute() + outfile = pathlib.Path(extra_args.get("-o", f"{trans.key}_results.json")) + hashed_key = _hash_quickrun_inputs(outfile, trans) + with mock.patch("gufe.protocols.protocoldag.execute_DAG", side_effect=RuntimeError): result = runner.invoke(quickrun, [json_file] + extras) assert "Here is the result" not in result.output - trans = Transformation.from_json(json_file) - assert pathlib.Path( - extra_args.get("-d", ""), "quickrun_cache", f"{trans.key}-ProtocolDAG.json" - ).exists() + assert pathlib.Path(work_dir, "quickrun_cache", f"dag-cache-{hashed_key}.json").exists() def test_quickrun_output_file_exists(json_file): @@ -121,15 +123,17 @@ def test_quickrun_unit_error(): # protocol dag results maybe? -def test_quickrun_existing_checkpoint(json_file): +def test_quickrun_existing_checkpoint_error(json_file): """In the default case where resume=False, if the checkpoint exists, quickrun should error out and not attempt to execute.""" trans = Transformation.from_json(json_file) dag = trans.create() runner = CliRunner() with runner.isolated_filesystem(): + outfile = pathlib.Path(f"{trans.key}_results.json") + hashed_key = _hash_quickrun_inputs(outfile, trans) pathlib.Path("quickrun_cache").mkdir() - dag.to_json(pathlib.Path("quickrun_cache", f"{trans.key}-ProtocolDAG.json")) + dag.to_json(pathlib.Path("quickrun_cache", f"dag-cache-{hashed_key}.json")) result = runner.invoke(quickrun, [json_file]) assert result.exit_code == 1 assert "Attempting to resume" not in result.output @@ -142,12 +146,16 @@ def test_quickrun_resume_from_checkpoint(json_file): runner = CliRunner() with runner.isolated_filesystem(): + outfile = pathlib.Path(f"{trans.key}_results.json") + hashed_key = _hash_quickrun_inputs(outfile, trans) pathlib.Path("quickrun_cache").mkdir() - dag.to_json(pathlib.Path("quickrun_cache", f"{trans.key}-ProtocolDAG.json")) + dag_cache = pathlib.Path("quickrun_cache", f"dag-cache-{hashed_key}.json") + dag.to_json(dag_cache) result = runner.invoke(quickrun, [json_file, "--resume"]) assert_click_success(result) - assert "Attempting to resume" in result.output + assert f"resume execution using '{dag_cache.absolute()}" in result.output + assert "Success" in result.output def test_quickrun_resume_invalid_checkpoint(json_file): @@ -156,19 +164,31 @@ def test_quickrun_resume_invalid_checkpoint(json_file): runner = CliRunner() with runner.isolated_filesystem(): + outfile = pathlib.Path(f"{trans.key}_results.json") + hashed_key = _hash_quickrun_inputs(outfile, trans) pathlib.Path("quickrun_cache").mkdir() - pathlib.Path("quickrun_cache", f"{trans.key}-ProtocolDAG.json").touch() + dag_cache = pathlib.Path("quickrun_cache", f"dag-cache-{hashed_key}.json") + dag_cache.touch() result = runner.invoke(quickrun, [json_file, "--resume"]) assert result.exit_code == 1 - assert "Attempting to resume" in result.output + assert f"resume execution using '{dag_cache.absolute()}" in result.output assert "Recovery failed" in result.stderr def test_quickrun_resume_missing_checkpoint(json_file): - """If --resume is passed but there's not checkpoint, just echo a message and keep going.""" + """If --resume is passed but there's no checkpoint, just echo a message and start from scratch.""" runner = CliRunner() with runner.isolated_filesystem(): + # determine what the cache to be looked for should be named + trans = Transformation.from_json(json_file) + outfile = pathlib.Path(f"{trans.key}_results.json") + hashed_key = _hash_quickrun_inputs(outfile, trans) + dag_cache = pathlib.Path("quickrun_cache", f"dag-cache-{hashed_key}.json") + result = runner.invoke(quickrun, [json_file, "--resume"]) assert_click_success(result) - assert "openfe quickrun was run with --resume, but no checkpoint found at" in result.output + assert ( + f"openfe quickrun was run with --resume, but no cached results found at {dag_cache.absolute()}" + in result.output + ) From bd0e7504832876bcccf6d3eabbf5955196902021 Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Thu, 26 Mar 2026 07:54:17 -0700 Subject: [PATCH 29/30] fix some comments to say cache instead of checkpoint --- src/openfecli/commands/quickrun.py | 2 +- src/openfecli/tests/commands/test_quickrun.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/openfecli/commands/quickrun.py b/src/openfecli/commands/quickrun.py index ba673d94f..f5c1b33a4 100644 --- a/src/openfecli/commands/quickrun.py +++ b/src/openfecli/commands/quickrun.py @@ -187,7 +187,7 @@ def quickrun(transformation, work_dir, output, resume): with open(output, mode="w") as outf: json.dump(out_dict, outf, cls=JSON_HANDLER.encoder) - # remove the checkpoint since the job has completed + # remove the cached dag since the job has completed os.remove(cached_dag_path) write(f"Here is the result:\n\tdG = {estimate} ± {uncertainty}\n") diff --git a/src/openfecli/tests/commands/test_quickrun.py b/src/openfecli/tests/commands/test_quickrun.py index 9fdcce0c3..9cdcc7c3b 100644 --- a/src/openfecli/tests/commands/test_quickrun.py +++ b/src/openfecli/tests/commands/test_quickrun.py @@ -41,7 +41,7 @@ def test_quickrun(extra_args, json_file): assert_click_success(result) assert "Here is the result" in result.output - # checkpoint should be deleted when job is complete + # cache should be deleted when job is complete assert not pathlib.Path(work_dir, "quickrun_cache", f"dag-cache-{hashed_key}.json").exists() # output json should exist with data when job is complete @@ -60,7 +60,7 @@ def test_quickrun(extra_args, json_file): @pytest.mark.parametrize("extra_args", [{}, {"-d": "foo_dir", "-o": "foo.json"}]) def test_quickrun_interrupted(extra_args, json_file): - """If quickrun starts but is unable to complete, the ProtocolDAG.json cached checkpoint should exist.""" + """If quickrun starts but is unable to complete, the cached DAG should exist.""" extras = sum([list(kv) for kv in extra_args.items()], []) runner = CliRunner() @@ -123,8 +123,8 @@ def test_quickrun_unit_error(): # protocol dag results maybe? -def test_quickrun_existing_checkpoint_error(json_file): - """In the default case where resume=False, if the checkpoint exists, quickrun should error out and not attempt to execute.""" +def test_quickrun_existing_cache_error(json_file): + """In the default case where resume=False, if the cache exists, quickrun should error out and not attempt to execute.""" trans = Transformation.from_json(json_file) dag = trans.create() @@ -140,7 +140,7 @@ def test_quickrun_existing_checkpoint_error(json_file): assert "Transformation has been started but is incomplete." in result.stderr -def test_quickrun_resume_from_checkpoint(json_file): +def test_quickrun_resume_from_cache(json_file): trans = Transformation.from_json(json_file) dag = trans.create() @@ -158,7 +158,7 @@ def test_quickrun_resume_from_checkpoint(json_file): assert "Success" in result.output -def test_quickrun_resume_invalid_checkpoint(json_file): +def test_quickrun_resume_invalid_cache(json_file): """Fail if the output file doesn't load properly.""" trans = Transformation.from_json(json_file) @@ -176,8 +176,8 @@ def test_quickrun_resume_invalid_checkpoint(json_file): assert "Recovery failed" in result.stderr -def test_quickrun_resume_missing_checkpoint(json_file): - """If --resume is passed but there's no checkpoint, just echo a message and start from scratch.""" +def test_quickrun_resume_missing_cache(json_file): + """If --resume is passed but there's no cache, just echo a message and start from scratch.""" runner = CliRunner() with runner.isolated_filesystem(): # determine what the cache to be looked for should be named From c6db60d2c21ec74dd42a30fc055bb53bb92c841b Mon Sep 17 00:00:00 2001 From: Alyssa Travitz Date: Thu, 26 Mar 2026 07:55:47 -0700 Subject: [PATCH 30/30] fix typo in news item --- news/quickrun_resume.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/news/quickrun_resume.rst b/news/quickrun_resume.rst index e53e52738..c594c443a 100644 --- a/news/quickrun_resume.rst +++ b/news/quickrun_resume.rst @@ -1,7 +1,7 @@ **Added:** * Added ``--resume`` flag to ``openfe quickrun``. - Quickrun now temporarily caches ``protocolDAG`` information and when used with the ``--resume`` flag, quickrun will attempt resume execution of an incomplete transformation. + Quickrun now temporarily caches ``protocolDAG`` information and, when used with the ``--resume`` flag, quickrun will attempt to resume execution of an incomplete transformation. **Changed:**