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"