Avoid rewriting unrelated chats on project changes

Project updates and deletion previously persisted every loaded chat, while matching chats were written twice through the existing activation helpers. Keep the established project lifecycle flow and remove the unconditional writes so only affected chats are saved once. Add focused regression coverage and document the persistence contract.

Alessandro committed Aug 12, 2026 at 00:38 UTC db4913ff765c8392d74b5026b06a616e60fd8506
3 files changed +36 -2
helpers/projects.py
-2
@@ -493,7 +493,6 @@ def reactivate_project_in_chats(name: str):
493 for context in AgentContext.all():
494 if context.get_data(CONTEXT_DATA_KEY_PROJECT) == name:
495 activate_project(context.id, name, mark_dirty=False)
496 - persist_chat.save_tmp_chat(context)
496
497 from helpers.state_monitor_integration import mark_dirty_all
498 mark_dirty_all(reason="projects.reactivate_project_in_chats")
@@ -505,7 +504,6 @@ def deactivate_project_in_chats(name: str):
504 for context in AgentContext.all():
505 if context.get_data(CONTEXT_DATA_KEY_PROJECT) == name:
506 deactivate_project(context.id, mark_dirty=False)
508 - persist_chat.save_tmp_chat(context)
507
508 from helpers.state_monitor_integration import mark_dirty_all
509 mark_dirty_all(reason="projects.deactivate_project_in_chats")
helpers/projects.py.dox.md
+2
@@ -91,6 +91,8 @@
91 scope; only chats whose active profile actually changes are persisted and
92 marked dirty. Context creation uses the same reconciliation after resolving
93 its scope, so a disabled configured profile cannot become invisibly active.
94 +- Project updates and deletion refresh only chats assigned to that project and
95 + persist each affected chat once; unrelated chats are never rewritten.
96 - Observed side-effect areas: filesystem reads, filesystem writes, filesystem deletion, plugin state, settings/state persistence, secret handling.
97 - Imported dependency areas include: `helpers`, `helpers.print_style`, `os`,
98 `typing`.
tests/test_projects.py
+34
@@ -299,6 +299,40 @@ def test_bulk_profile_reconciliation_persists_only_changed_chats(
299 assert catalog_lookups == ([None, "demo"] if all_scopes else [None])
300
301
302 +def test_project_refresh_touches_only_matching_chats(monkeypatch) -> None:
303 + contexts = [
304 + SimpleNamespace(id="matching", get_data=lambda _key: "demo"),
305 + SimpleNamespace(id="unrelated", get_data=lambda _key: "other"),
306 + ]
307 + calls: list[tuple] = []
308 + monkeypatch.setattr(
309 + AgentContext, "all", staticmethod(lambda: contexts)
310 + )
311 + monkeypatch.setattr(
312 + projects,
313 + "activate_project",
314 + lambda context_id, name, *, mark_dirty: calls.append(
315 + ("activate", context_id, name, mark_dirty)
316 + ),
317 + )
318 + monkeypatch.setattr(
319 + projects,
320 + "deactivate_project",
321 + lambda context_id, *, mark_dirty: calls.append(
322 + ("deactivate", context_id, mark_dirty)
323 + ),
324 + )
325 + monkeypatch.setattr(state_monitor_integration, "mark_dirty_all", lambda **_kwargs: None)
326 +
327 + projects.reactivate_project_in_chats("demo")
328 + projects.deactivate_project_in_chats("demo")
329 +
330 + assert calls == [
331 + ("activate", "matching", "demo", False),
332 + ("deactivate", "matching", False),
333 + ]
334 +
335 +
336 def test_project_include_agents_md_defaults_true_and_saves(monkeypatch, tmp_path):
337 _prepare_project_tree(monkeypatch, tmp_path)
338 meta = tmp_path / "usr" / "projects" / "demo" / ".a0proj"