Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 33 additions & 11 deletions apodex/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import asyncio
import json
import os
import threading
from pathlib import Path
from typing import Any

Expand Down Expand Up @@ -189,6 +190,10 @@ def __init__(
# plugins.tools._path_auth._authorized_local_path). Without this they
# only allow a few default dirs and deny the user's repo.
self._authorize_workspace(cwd)
# _persist() now runs both on the main thread (start_new_session,
# rename_session) and off-thread (_on_turn's asyncio.to_thread), so
# concurrent writers must serialize on the same checkpoint file.
self._persist_lock = threading.Lock()

@staticmethod
def _active_spill_workspace() -> Path | None:
Expand Down Expand Up @@ -476,7 +481,11 @@ async def _on_turn(self, turn: int, messages: list, metadata: dict) -> None:
after each completed turn — keep history current and persist."""
self.history = list(messages)
self.display_history = list(messages)
self._persist()
# _persist() does synchronous file I/O over the full history; run it
# off the event loop so long sessions don't stall on every turn.
# Awaited between turns; _persist_lock also serializes snapshots and
# writes if cancellation leaves this worker running in the background.
await asyncio.to_thread(self._persist)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in c61ff52.

The write lock and atomic replacement prevented interleaved JSON writes, but the payload was still built before acquiring the lock. Cancelling _on_turn leaves the asyncio.to_thread worker running, so that worker could capture old state, let a subsequent save finish, then overwrite it with the stale payload.

The lock now covers the full checkpoint operation: TUI snapshot, session/path and payload capture, temporary-file write, and atomic replacement. A waiting writer therefore captures current state only after acquiring the lock; an in-progress snapshot finishes writing before a newer save can proceed. Per-turn persistence remains off the event loop. The comment on _on_turn now also makes the cancellation behavior explicit.

Added a deterministic regression test that pauses the old snapshot, cancels _on_turn, updates history and renames the session, then lets the background worker finish. It verifies that the newest name and both histories remain on disk. This test fails on the previous PR head and passes with the fix.

Validation: 137 tests passed in apodex/tests/test_changes.py and apodex/tests/test_features.py; Ruff passed for both changed files.


# ── persistence (interrupt-safe resume) ───────────────────────────────
def _enrich_task(self, task: str) -> str:
Expand Down Expand Up @@ -591,21 +600,29 @@ def replay_history(self) -> list[Message]:

def _persist(self) -> None:
"""Checkpoint session state so ``--resume <id>`` can continue it.
Best-effort; a failed write never disrupts the session."""
Best-effort; a failed write never disrupts the session.

Serialized via ``_persist_lock`` and written atomically (tmp file +
``os.replace``) because this runs from both the main thread
(``start_new_session`` / ``rename_session``) and a worker thread
(``_on_turn``'s ``asyncio.to_thread``) — without both, concurrent
writers can interleave and corrupt the checkpoint file."""
try:
import json

from apodex.todo import get_todos

snapshot = getattr(self.r, "snapshot_state", None)
if callable(snapshot):
raw_tui_state = snapshot()
self.tui_state = raw_tui_state if isinstance(raw_tui_state, dict) else {}
# Snapshot under the same lock as the write: a cancelled
# to_thread worker can otherwise overwrite a newer checkpoint
# with a payload it captured before waiting for this lock.
with self._persist_lock:
snapshot = getattr(self.r, "snapshot_state", None)
if callable(snapshot):
raw_tui_state = snapshot()
self.tui_state = raw_tui_state if isinstance(raw_tui_state, dict) else {}

path = _session_state_path(self.session_id)
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w", encoding="utf-8") as f:
json.dump({
path = _session_state_path(self.session_id)
payload = {
"session_id": self.session_id,
"created_at": self.created_at,
"local_timezone": self.local_timezone,
Expand Down Expand Up @@ -633,7 +650,12 @@ def _persist(self) -> None:
{"content": item.content, "status": item.status}
for item in get_todos()
],
}, f, ensure_ascii=False)
}
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp_path = f"{path}.{os.getpid()}.{threading.get_ident()}.tmp"
with open(tmp_path, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False)
os.replace(tmp_path, path)
except Exception:
pass

Expand Down
83 changes: 83 additions & 0 deletions apodex/tests/test_changes.py
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,89 @@ def test_session_persist_and_resume(tmp_path, monkeypatch):
assert "edited.py" in s2.journal.to_dict() or True # journal restored shape



@pytest.mark.asyncio
async def test_cancelled_checkpoint_cannot_overwrite_newer_save(tmp_path, monkeypatch):
import threading
from types import SimpleNamespace

from apodex import session as session_module
from apodex.session import TerminalSession

checkpoint = tmp_path / "state.json"
monkeypatch.setattr(session_module, "_session_state_path", lambda _: str(checkpoint))
snapshot_started = threading.Event()
release_snapshot = threading.Event()
old_save_finished = threading.Event()
old_thread_id = None

class CheckpointLock:
def __init__(self):
self.lock = threading.Lock()

def __enter__(self):
# Let the old snapshot finish once a newer save is waiting for
# its lock. Before the fix the old snapshot owns no lock, so the
# newer save completes first and releases it in the test below.
if threading.get_ident() != old_thread_id and self.lock.locked():
release_snapshot.set()
self.lock.acquire()

def __exit__(self, *_):
self.lock.release()

def journal_snapshot():
if threading.get_ident() == old_thread_id:
snapshot_started.set()
if not release_snapshot.wait(5):
raise TimeoutError("old checkpoint was never released")
return {}

session = TerminalSession.__new__(TerminalSession)
session.__dict__.update(
r=SimpleNamespace(), session_id="test", created_at="", local_timezone="",
session_name="old name", mode="coding", cwd=str(tmp_path),
cfg=SimpleNamespace(model="fake"), history=[], display_history=[],
workflow_turns=[], usage=SimpleNamespace(to_dict=lambda: {}), tui_state={},
journal=SimpleNamespace(to_dict=journal_snapshot,
observed_paths=lambda: [], revert_bases=lambda: {}),
plan_state=SimpleNamespace(active=False), _persist_lock=CheckpointLock(),
)
persist = session._persist

def tracked_persist():
nonlocal old_thread_id
is_old = old_thread_id is None
if is_old:
old_thread_id = threading.get_ident()
try:
persist()
finally:
if is_old:
old_save_finished.set()

monkeypatch.setattr(session, "_persist", tracked_persist)
turn = asyncio.create_task(session._on_turn(1, [{"role": "user", "content": "old"}], {}))
try:
assert await asyncio.to_thread(snapshot_started.wait, 5)
turn.cancel()
with pytest.raises(asyncio.CancelledError):
await turn
session.history = [{"role": "user", "content": "new"}]
session.display_history = list(session.history)
await asyncio.to_thread(session.rename_session, "new name")
finally:
release_snapshot.set()
assert await asyncio.to_thread(old_save_finished.wait, 5)
if not turn.done():
await turn

state = json.loads(checkpoint.read_text())
assert state["name"] == "new name"
assert state["history"] == session.history
assert state["display_history"] == session.display_history


def test_follow_up_receives_exact_agent_and_host_deliverable_paths(tmp_path, monkeypatch):
from apodex.config import ModelConfig
from apodex.render import Renderer
Expand Down
Loading