diff --git a/src/dstack/_internal/cli/services/presets/output.py b/src/dstack/_internal/cli/services/presets/output.py index 05db1828c..52df677d6 100644 --- a/src/dstack/_internal/cli/services/presets/output.py +++ b/src/dstack/_internal/cli/services/presets/output.py @@ -116,6 +116,7 @@ def get_presets_table( table.add_column("STATUS") table.add_column("SUBMITTED", style="secondary") presets_by_base: dict[str, list[AnyStoredPreset]] = defaultdict(list) + preset_ids = {preset.id for preset in presets} repo_to_base: dict[str, str] = {} for preset in presets: presets_by_base[preset.base].append(preset) @@ -123,9 +124,11 @@ def get_presets_table( sessions_by_model: dict[str, list[dict[str, Any]]] = defaultdict(list) creations_by_id: dict[str, dict[str, Any]] = {} for session in sessions or []: - if str(session.get("status")) == "success": - # A completed creation session decorates its preset row. - creations_by_id[str(session.get("id"))] = session + session_id = str(session.get("id")) + if session_id in preset_ids: + # The stored artifact is the completion record. Cleanup can be + # interrupted before session.json advances to `success`. + creations_by_id[session_id] = session continue model = str(session.get("model") or "unknown") sessions_by_model[repo_to_base.get(model, model)].append(session) diff --git a/src/dstack/_internal/cli/services/presets/session.py b/src/dstack/_internal/cli/services/presets/session.py index 68bd44446..4e0de3d4e 100644 --- a/src/dstack/_internal/cli/services/presets/session.py +++ b/src/dstack/_internal/cli/services/presets/session.py @@ -322,7 +322,9 @@ def load_resumable_session(preset_id: str) -> PresetSession: state = session.read_state() if not path.is_dir() or state is None: raise CLIError(f"Unknown preset: {preset_id}") - if state.status == "success": + # `PresetStore.save()` commits this file before best-effort run cleanup. + # An interrupted cleanup must not turn that durable result back into work. + if (path / "preset.yml").is_file() or state.status == "success": raise CLIError(f"Preset {preset_id} is already created; nothing to resume") if state.status == "failed": raise CLIError(f"Preset {preset_id} creation failed and cannot be resumed") diff --git a/src/tests/_internal/cli/commands/test_preset.py b/src/tests/_internal/cli/commands/test_preset.py index 4bc098c80..212225862 100644 --- a/src/tests/_internal/cli/commands/test_preset.py +++ b/src/tests/_internal/cli/commands/test_preset.py @@ -108,6 +108,29 @@ def test_resume_uses_the_configuration_and_prompt_pinned_at_creation(self, tmp_p assert kwargs["configuration"].model.base == "Qwen/Qwen3.5-27B" assert kwargs["user_prompt"] == "go deep" + def test_resume_refuses_a_session_with_a_saved_preset(self, tmp_path, capsys): + preset = get_preset() + session_dir = tmp_path / ".dstack" / "presets" / preset.id + session_dir.mkdir(parents=True) + session_dir.joinpath("session.json").write_text( + get_session_state( + id=preset.id, + status="interrupted", + run=get_session_run(claude_session_id="sid-1"), + ).model_dump_json(), + encoding="utf-8", + ) + PresetStore(session_dir.parent).save(preset) + + with patch("dstack._internal.cli.commands.preset.create_preset") as create: + exit_code = run_dstack_cli( + ["preset", "resume", preset.id], home_dir=tmp_path, repo_dir=tmp_path + ) + + assert exit_code == 1 + create.assert_not_called() + assert "already created; nothing to resume" in capsys.readouterr().out + def test_get_returns_the_requested_configuration_for_an_unfinished_preset( self, tmp_path, capsys ): diff --git a/src/tests/_internal/cli/services/presets/test_agent.py b/src/tests/_internal/cli/services/presets/test_agent.py index f101258c5..b18a9e41e 100644 --- a/src/tests/_internal/cli/services/presets/test_agent.py +++ b/src/tests/_internal/cli/services/presets/test_agent.py @@ -1027,6 +1027,21 @@ def test_treats_dead_running_session_as_resumable(self, tmp_path, monkeypatch): assert load_resumable_session("ab12cd34").preset_id == "ab12cd34" + def test_refuses_session_that_already_saved_a_preset(self, tmp_path, monkeypatch): + path = self._write_session( + tmp_path, + monkeypatch, + { + "id": "ab12cd34", + "status": "interrupted", + "run": get_session_run(claude_session_id="sid-1"), + }, + ) + (path / "preset.yml").write_text("status: verified\n", encoding="utf-8") + + with pytest.raises(CLIError, match="already created; nothing to resume"): + load_resumable_session("ab12cd34") + @pytest.mark.parametrize( ("state", "match"), [ diff --git a/src/tests/_internal/cli/services/presets/test_output.py b/src/tests/_internal/cli/services/presets/test_output.py index ce70c66f4..c301fb05a 100644 --- a/src/tests/_internal/cli/services/presets/test_output.py +++ b/src/tests/_internal/cli/services/presets/test_output.py @@ -305,14 +305,15 @@ def test_sorts_all_rows_newest_first_without_grouping(self, monkeypatch): class TestDoneProgress: - def test_completed_creation_decorates_preset_row_without_extra_session_row(self, monkeypatch): + @pytest.mark.parametrize("session_status", ["success", "interrupted", "failed"]) + def test_saved_preset_owns_row_regardless_of_session_status(self, monkeypatch, session_status): buffer = StringIO() monkeypatch.setattr(output_module, "console", plain_console(buffer, width=200)) preset = get_preset() sessions = [ { "id": preset.id, - "status": "success", + "status": session_status, "model": preset.base, "trials_num": 4, "trials": {"count": 3},