fix(config): persist removed registered projects (#1925)

Fixes regression introduced in unreleased commit 8a4d116

Co-authored-by: Yu-0312 <2.66498311e+08+Yu-0312@users.noreply.github.com>
Co-authored-by: Dr. Dominik Jain <dominik.jain@oraios-ai.de>
This commit is contained in:
authored and GitHub committed 2026-09-03 15:54:09 +02:00
1 parent 24940a1560
commit ef30801377
2 files changed
+70 -8

No files matched your search

+28 -8
View File
@@ -958,6 +958,8 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes):
the path to the configuration file to which updates of the configuration shall be saved;
if None, the configuration is not saved to disk
"""
_projects_at_load: set[str] = field(default_factory=set, repr=False)
"""Project roots present when this configuration instance was last persisted."""
# *** static members ***
@@ -1096,6 +1098,8 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes):
)
instance.projects.append(project)
instance._projects_at_load = {str(project.project_root) for project in instance.projects}
# determine language backend
language_backend = get_dataclass_default(SerenaConfig, "language_backend")
if "language_backend" in loaded_commented_yaml:
@@ -1295,22 +1299,38 @@ class SerenaConfig(SharedConfig, ModeSelectionDefinitionWithBaseModes):
self._persist_projects()
def _persist_projects(self) -> None:
"""
Persists the list of registered projects, merging it with the list currently found on disk
(parallel agent instances may have added or removed projects in the meantime).
"""Persist this instance's project-list changes without undoing concurrent edits.
The disk copy is reloaded because multiple agent processes may update the global
configuration concurrently. The instance baseline lets us distinguish this instance's
removals and additions from changes made by another process: unchanged baseline projects
follow the current disk copy, while removed baseline projects are filtered out and newly
added instance projects are appended.
"""
if self.config_file_path is None:
return
persisted = SerenaConfig.from_config_file()
current_projects_by_path = {str(project.project_root): project for project in self.projects}
current_paths = set(current_projects_by_path)
removed_paths = self._projects_at_load - current_paths
added_paths = current_paths - self._projects_at_load
combined_projects = []
handled_project_paths = set()
for p in persisted.projects + self.projects:
str_path = str(p.project_root)
if str_path not in handled_project_paths:
combined_projects.append(p)
handled_project_paths.add(str_path)
for project in persisted.projects:
project_path = str(project.project_root)
if project_path not in removed_paths:
combined_projects.append(project)
handled_project_paths.add(project_path)
for project_path in added_paths:
if project_path not in handled_project_paths:
combined_projects.append(current_projects_by_path[project_path])
handled_project_paths.add(project_path)
persisted.projects = combined_projects
persisted._save()
self._projects_at_load = current_paths
def _save(self) -> None:
"""
+42
View File
@@ -604,6 +604,48 @@ class TestSerenaConfigLoadSave:
assert len(resulting_config.projects) == 4
def test_remove_project_persists_across_reload(self):
"""Removing a project must update the on-disk project registry."""
p1 = self._make_project_dir("project1", 'project_name: "project1"\nlanguages: ["python"]\n')
p2 = self._make_project_dir("project2", 'project_name: "project2"\nlanguages: ["python"]\n')
self._write_master_config([p1, p2])
config = SerenaConfig.from_config_file(generate_if_missing=False)
config.remove_project("project2")
reloaded = SerenaConfig.from_config_file(generate_if_missing=False)
assert [project.project_config.project_name for project in reloaded.projects] == ["project1"]
def test_remove_project_preserves_concurrent_addition(self):
"""A removal must not discard a project added by another config instance."""
p1 = self._make_project_dir("project1", 'project_name: "project1"\nlanguages: ["python"]\n')
p2 = self._make_project_dir("project2", 'project_name: "project2"\nlanguages: ["python"]\n')
p3 = self._make_project_dir("project3", 'project_name: "project3"\nlanguages: ["python"]\n')
self._write_master_config([p1, p2])
removing_config = SerenaConfig.from_config_file(generate_if_missing=False)
adding_config = SerenaConfig.from_config_file(generate_if_missing=False)
adding_config.add_project_from_path(p3)
removing_config.remove_project("project2")
reloaded = SerenaConfig.from_config_file(generate_if_missing=False)
assert {project.project_config.project_name for project in reloaded.projects} == {"project1", "project3"}
def test_add_project_preserves_concurrent_removal(self):
"""An addition must not resurrect a project removed by another config instance."""
p1 = self._make_project_dir("project1", 'project_name: "project1"\nlanguages: ["python"]\n')
p2 = self._make_project_dir("project2", 'project_name: "project2"\nlanguages: ["python"]\n')
p3 = self._make_project_dir("project3", 'project_name: "project3"\nlanguages: ["python"]\n')
self._write_master_config([p1, p2])
removing_config = SerenaConfig.from_config_file(generate_if_missing=False)
adding_config = SerenaConfig.from_config_file(generate_if_missing=False)
removing_config.remove_project("project2")
adding_config.add_project_from_path(p3)
reloaded = SerenaConfig.from_config_file(generate_if_missing=False)
assert {project.project_config.project_name for project in reloaded.projects} == {"project1", "project3"}
class TestGetRegisteredProjectWithDanglingProject:
"""A registered project whose root directory was deleted (e.g. a removed git