Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions src/dstack/_internal/cli/services/presets/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,16 +116,19 @@ 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)
repo_to_base[preset.repo] = preset.base
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)
Expand Down
4 changes: 3 additions & 1 deletion src/dstack/_internal/cli/services/presets/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
23 changes: 23 additions & 0 deletions src/tests/_internal/cli/commands/test_preset.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
):
Expand Down
15 changes: 15 additions & 0 deletions src/tests/_internal/cli/services/presets/test_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
[
Expand Down
5 changes: 3 additions & 2 deletions src/tests/_internal/cli/services/presets/test_output.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down
Loading