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>
This commit is contained in:
2026-08-21 22:34:57 +09:00
co-authored by Claude Sonnet 5
parent ae4fe72b2e
commit cf542b7416
19 changed files with 853 additions and 27 deletions
+23 -4
View File
@@ -1126,6 +1126,15 @@ class ChatPanel(QWidget):
"snapshot_len": len(snapshot), "out_dir": out_dir,
"home_id": self.session_id, "home_messages": self.messages,
"home_title": self.title, "home_out_root": self.workspace_dir(),
# R06-T04: captured NOW, at submit time — see _persist_session's
# use of this. Without it, a background turn (this session isn't
# the one currently displayed) saves into whatever
# ctx.config.history_dir() resolves to AT THE TIME IT FINISHES,
# which is the *currently viewed* project's history folder if the
# user switched projects (ui/workspace_tab.py::_load_current)
# while this turn was still running — silently saving one
# project's conversation into another project's history folder.
"home_history_dir": self.ctx.config.history_dir(),
"detached": False,
# For re-rendering the in-progress turn if the user reopens this chat:
"display_text": text, "partial": "", "plan_steps": [],
@@ -1356,10 +1365,18 @@ class ChatPanel(QWidget):
return ctx.get("home_id") == self.session_id and not ctx.get("detached")
def _save_snapshot(self, session_id: str, messages: List[Dict[str, Any]],
title: str, inputs: Optional[List[str]] = None) -> None:
title: str, inputs: Optional[List[str]] = None,
history_dir: Optional[Path] = None) -> None:
"""Persist a conversation by id (used both to register it in History the
moment it starts and to save a finished background turn). No-op until it has
a user message. Never raises into the UI."""
a user message. Never raises into the UI.
``history_dir``, when given, is used INSTEAD of
``self.ctx.config.history_dir()`` — see ``_persist_session`` (R06-T04):
a background turn must save into the project it started in, not
whichever project happens to be selected in the Workspace screen by
the time the turn finishes.
"""
if not self.ctx.config.history.get("autosave", True):
return
if not any(m.get("role") == "user" for m in messages):
@@ -1367,7 +1384,8 @@ class ChatPanel(QWidget):
try:
from ..core.history import save_conversation
save_conversation(
self.ctx.config.history_dir(), self.kind, session_id,
history_dir if history_dir is not None else self.ctx.config.history_dir(),
self.kind, session_id,
messages, title, inputs=list(inputs or []), outputs=[],
# Only the CURRENT view knows its project for sure; a background
# turn's save must not overwrite another conversation's project
@@ -1383,7 +1401,8 @@ class ChatPanel(QWidget):
view-based _autosave can't). Outputs are rebuilt from disk on reopen."""
self._save_snapshot(ctx["home_id"], ctx["home_messages"],
ctx.get("home_title", ""),
inputs=ctx.get("record", {}).get("inputs", []))
inputs=ctx.get("record", {}).get("inputs", []),
history_dir=ctx.get("home_history_dir"))
self.history_changed.emit()
def running_session_ids(self):