Files
cowork-local/tests/integration/test_history_dir_race.py
T
vudt15andClaude Sonnet 5 cf542b7416 feat(R06): workspace session snapshot, atomic persistence, history-dir race fix
EPIC R06 (Team Hoa) - workspace/filesystem isolation, no cross-project
mutable state.

R06-T01 domain/workspaces/workspace_session.py
  WorkspaceSession - project_id/workspace_root/sandbox_dir/allowed_paths
  frozen snapshot + is_allowed(path), same "capture once at submit time"
  shape as R04's ConversationExecutionRequest.

R06-T02 infrastructure/persistence/json/{atomic_write,workspace_repository_impl,conversation_repository_impl}.py
  Real bug fixed: core/projects.py::save_project and core/history.py's
  save_conversation/rename_conversation/set_pinned did a plain
  path.write_text(json.dumps(...)) - two syscalls, no atomicity. A crash
  between them leaves a half-written file that load_project/load_conversation
  then silently treat as "missing". All four now write through
  atomic_write.write_json (temp file + os.replace). WorkspaceRepository/
  ConversationRepository are thin object-shaped facades over the same
  (now-atomic) functions, for future application-layer callers.
  NOTE: atomic_write.py is deliberately NOT named atomic_json_file.py -
  R02-T01 (Team Nam) claims that filename for the same purpose app-wide;
  see the checklist for the consolidation TODO.

R06-T03 infrastructure/filesystem/execution_workspace.py
  ExecutionWorkspace names the output_dir/scratch_dir split that already
  exists (core/chat_agent.py's flat workspace_root/.scratch) - does not
  move anything.

R06-T04 ui/chat_panel.py
  The actual race: ChatPanel._persist_session (saves a BACKGROUND turn's
  conversation) resolved its save directory via a live
  self.ctx.config.history_dir() read at save time. ui/workspace_tab.py::
  _load_current mutates that same config field on every project switch, so
  a turn still running when the user switched projects got saved into the
  NEW project's history folder. Fixed by adding "home_history_dir" to the
  per-turn ctx dict (same "home_*" snapshot convention already used for
  session id/messages/title), captured at submit time. Verified with a real
  offscreen-Qt test, not just a unit double:
  tests/integration/test_history_dir_race.py.

R06-T05 application/workspaces/file_workspace_service.py
  FileWorkspaceService - the File Explorer / AI Editor entry point for the
  same safe read/write/edit operations the agent tool loop has, by calling
  core/tools.py::execute_tool directly (same dispatch, same ToolContext
  containment, same audit log) rather than reimplementing any of it.

New tests: tests/unit/test_workspace_session.py,
test_atomic_write_and_repositories.py, test_execution_workspace.py,
test_file_workspace_service.py, tests/integration/test_history_dir_race.py
(29 new tests, incl. 2 real offscreen-Qt integration tests).

Suite: 283 passed, 4 pre-existing failures unrelated to R05/R06 (see
checklist). check_imports: PASS. All new files < 400 LOC.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-21 22:34:57 +09:00

86 lines
3.4 KiB
Python

"""EPIC R06-T04: the race in ``ui/workspace_tab.py::_load_current``.
``_load_current`` sets ``ctx.config._project_history_dir`` on the SHARED
``AppConfig`` every time the user switches projects in the Workspace screen.
A background turn (one that isn't the conversation currently displayed) used
to resolve its save directory by calling ``ctx.config.history_dir()`` at
``_persist_session`` time - i.e. whenever the turn actually finished, not
when it started. If the user switched projects while it was still running,
the turn's conversation got written into the NEW project's history folder
instead of the one it actually belongs to.
The fix threads a ``home_history_dir`` captured at submit time (same "home_*"
snapshot convention ``ui/chat_panel.py`` already uses for session id/title/
messages) through to the save call. This test drives the real
``ChatPanel._persist_session`` - the actual save path - offscreen.
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from cowork_local.config import AppConfig # noqa: E402
from cowork_local.core.history import list_conversations # noqa: E402
from cowork_local.state import AppContext # noqa: E402
pytest.importorskip("PySide6", reason="Qt is required for the integration suite")
@pytest.fixture(scope="module")
def qt_app():
from PySide6.QtWidgets import QApplication
return QApplication.instance() or QApplication([])
@pytest.fixture
def chat_panel(qt_app, tmp_path: Path):
from cowork_local.ui.chat_panel import ChatPanel
ctx = AppContext(AppConfig.load(tmp_path / "config.json"))
return ChatPanel(ctx, "cowork", "Test")
def test_background_turn_saves_into_the_project_it_started_in(chat_panel, tmp_path):
project_a_dir = tmp_path / "project-a-history"
project_b_dir = tmp_path / "project-b-history"
chat_panel.ctx.config._project_history_dir = project_a_dir
# What ChatPanel._start_turn captures into the per-turn ctx dict at
# submit time (see the "home_history_dir" entry added there for R06-T04).
turn_ctx = {
"home_id": chat_panel.session_id,
"home_messages": [{"role": "user", "content": "hi"}],
"home_title": "Background turn",
"home_history_dir": chat_panel.ctx.config.history_dir(),
"record": {},
}
assert turn_ctx["home_history_dir"] == project_a_dir
# The user switches projects in the Workspace screen WHILE this turn is
# still running - exactly what ui/workspace_tab.py::_load_current does.
chat_panel.ctx.config._project_history_dir = project_b_dir
chat_panel._persist_session(turn_ctx)
assert len(list_conversations(project_a_dir)) == 1
assert list_conversations(project_b_dir) == []
def test_the_currently_viewed_conversation_still_follows_live_selection(chat_panel, tmp_path):
"""_save_snapshot's OTHER caller (the initial "register it in History right
away" call, and _autosave) has no captured history_dir and must keep
resolving it live - that path is for the conversation ACTUALLY on screen,
which should follow whatever project the user has selected right now."""
project_dir = tmp_path / "currently-viewed"
chat_panel.ctx.config._project_history_dir = project_dir
chat_panel._save_snapshot(chat_panel.session_id,
[{"role": "user", "content": "hi"}], "Live view")
assert len(list_conversations(project_dir)) == 1