EP-0129: Multi-Process State Synchronization and File Locking Architecture¶
| Field | Value |
|---|---|
| EP | 0129 |
| Title | Multi-Process State Synchronization and File Locking Architecture |
| Author | Eran Rivlis & Ariel |
| Status | Final |
| Type | Standards Track |
| Created | 2026-08-25 |
| Updated | 2026-08-25 |
Abstract¶
This proposal specifies the integration of the zero-dependency filelock
library into Tur's core runtime. While atomic file replacement (os.replace) ensures individual file integrity, it
cannot prevent read-modify-write race conditions when multiple agent harnesses (e.g., Claude Code, Gemini CLI, Cursor)
access or mutate shared persona indices, session notes, and memory graphs concurrently. This proposal establishes a
two-tiered advisory file-locking architecture that synchronizes multi-process state transitions across both workspace
terrain and global persona boundaries.
Motivation¶
As Tur transitions into a multi-agent symbiote (EP-0107, EP-0118, EP-0123), concurrent execution by multiple AI
harnesses operating on the same repository or persona is standard. Tur currently protects state writes using POSIX
atomic renames (os.replace via temporary files per EP-0106 and EP-0120).
However, atomic replacement only guarantees that a reader never sees a half-written file; it provides zero transaction isolation for read-modify-write sequences:
- Index Overwrite Clashing: If Agent A and Agent B simultaneously call
note(), both readsessions.yamlat timestamp \(T_0\). Agent A appends Note A and atomically writes to disk. Milliseconds later, Agent B appends Note B to its stale in-memory index and atomically replaces the file— silently clobbering Note A. - Storage Migration Collisions (EP-0125): If an administrator or automated process initiates a 5-stage migration
(
tur-adm migrate), an active MCP server could write new L1 events into the legacy schema mid-flight, corrupting the atomic staging directory. - Graph Compaction Races (EP-0103, EP-0119): Two concurrent instances initiating deductive memory compaction simultaneously can produce competing topological node graphs and redundant Merkle calculations.
To make Tur genuinely swarm-safe and crash-resilient across Linux, macOS, and Windows, Tur requires a standardized, cross-platform advisory file locking mechanism.
Rationale¶
Council Alignment¶
- The Golem Protocol (Containment & Integrity): Prevents silent data clobbering and state corruption across distributed agent processes, ensuring that the system fails gracefully with explicit timeouts rather than corrupted memory.
- The Noether Module (Symmetry & Invariance):
filelockmaps cleanly to native OS primitives (msvcrt.lockingon Windows,fcntl.flockon POSIX), presenting an identical, symmetrical locking API regardless of host platform. - The Shannon Module (Efficiency):
filelockis pure Python, zero-dependency, and lightweight (<100 KB). Its configurable polling intervals and timeout controls eliminate CPU spinning while minimizing lock contention latency. - The Steward Principle (Harmony & Pragmatism): Adopting the standard, battle-tested library maintained by the PyPA/tox-dev ecosystem avoids rolling fragile, ad-hoc PID files or platform-specific locking shims.
Architectural Trade-off Analysis: Why filelock Over Alternatives¶
| Library | OS Mechanism Used | Best For | Pros | Cons / Verdict for Tur |
|---|---|---|---|---|
tox-dev/filelock |
fcntl.flock (POSIX) / msvcrt.locking (Win) |
Local multi-process synchronization. | Zero dependencies, pure Python, sidecar file preserves os.replace safety. |
Selected: Optimal match for local state and atomic writes. |
portalocker |
fcntl.flock (POSIX) / pywin32 API (Win) |
Direct file locking with shared read locks. | Supports LOCK_SH (shared reads). |
Rejected: Collides with atomic os.replace; requires heavy pywin32 binary on Windows. |
fasteners |
fcntl.flock (POSIX) / msvcrt (Win) |
Mixed thread + process pipelines. | Advanced inter-process + inter-thread locks. | Rejected: Unnecessary API complexity; higher dependency overhead. |
flufl.lock |
NFS-safe link(2) & stat(2) |
NFS / Cloud-mounted network shares. | Survives network node reboots. | Rejected: Tur runs on local filesystems; NFS lease overhead is unnecessary. |
Stdlib (fcntl / msvcrt) |
Raw OS system calls | Zero pip dependencies. | Built-in to Python. | Rejected: Requires maintaining custom platform-branching wrapper code. |
The Inode / Handle Replacement Hazard (Why Direct-File Locking Fails with os.replace)¶
Tur strictly mandates POSIX atomic file replacement (temp_file.replace(target_file) per EP-0106 and EP-0120) to
prevent partial-file corruption. Attempting to lock the target data file directly (the portalocker approach) produces
severe cross-platform hazards:
- Windows File Handle Collision: On Windows, locking an open file descriptor creates an exclusive lock on that
handle. A concurrent
os.replace(temp, target)fails immediately withPermissionError: [WinError 32] The process cannot access the file because it is being used by another process. - POSIX Inode Invalidation: On POSIX systems,
os.replacechanges the directory entry to a new inode. Any other process holding an open descriptor on the target file retains a lock on the old unlinked inode, completely breaking synchronization for subsequent processes opening the new inode. - The Sidecar Solution: By applying native OS locks to an independent tracker file (
.tur/.locks/session.lock), the lock target's inode and handle remain permanently stable. The underlying data file (sessions.yaml) can be safely swapped atomically underneath without lock collisions.
Why Shared Read Locks (LOCK_SH) Are Unnecessary in Tur¶
While portalocker offers shared read locks (LOCK_SH), Tur's architecture does not require reader locks:
- Merkle L1 memories are immutable: Individual memory nodes (
.tur/memories/L1/*.md) are content-addressed and write-once, allowing safe lock-free concurrent streaming. - Index mutations are sub-millisecond: Reading
sessions.yaml, appending an episodic note, and executingos.replacecompletes in <2ms, making short exclusive lock acquisitions imperceptible to parallel agents.
Specification¶
1. Core Dependency (pyproject.toml)¶
Add filelock to core dependencies:
[project]
dependencies = [
"typer>=0.12.0",
"pydantic>=2.6.0",
"jinja2>=3.1.0",
"pyyaml>=6.0.0",
"rich>=13.0.0",
"networkx>=3.6",
"platformdirs>=4.2.0",
"filelock>=3.14.0",
]
2. Two-Tiered Locking Hierarchy & Deadlock Invariant¶
File locks are partitioned cleanly along the Traveler vs. Terrain architectural boundary:
graph TD
subgraph Global [Traveler Scope - OS Runtime Dir]
GMigrate[Global Migration Lock<br/>resolve_runtime_dir/locks/migration.lock]
GLock[Global Persona Lock<br/>resolve_runtime_dir/locks/persona_uuid.lock]
end
subgraph Local [Terrain Scope - Workspace .tur Dir]
LGraph[Deductive Graph Lock<br/>workspace/.tur/.locks/compaction.lock]
LLock[Workspace Session Lock<br/>workspace/.tur/.locks/session.lock]
end
GMigrate --> GLock
GLock --> LGraph
LGraph --> LLock
- Workspace Terrain Locks (
<workspace>/.tur/.locks/):session.lock: Serializes updates tosessions.yaml, session notes, and short-term L1 episodic logs.compaction.lock: Serializes execution ofintrospect/sleepmemory graph consolidation.
- Global Traveler Locks (
resolve_runtime_dir() / "locks" /per EP-0128):<persona_id>.lock: Serializes modifications to global persona definitions (persona.yaml), covenants, and universal memory banks.migration.lock: Exclusively held bytur-admduring EP-0125 storage evolution procedures.
Total Lock Acquisition Ordering Invariant (Anti-Deadlock Rule)¶
To prevent cross-process cyclic wait deadlocks (AB-BA deadlocks), any composite operation requiring multiple locks must acquire them in strict descending topological order: $\(\text{Global (Migration)} \succ \text{Global (Persona)} \succ \text{Local (Compaction)} \succ \text{Local (Session)}\)$ A process must never acquire a Global Traveler lock while holding a Local Terrain lock.
3. Transactional Locking Helpers (src/tur/locking.py)¶
A centralized locking module provides sync and async context managers configured for low-latency contention recovery,
singleton thread re-entrancy, and Windows handle collision prevention (preserve_lock_file=True):
from collections.abc import AsyncIterator, Iterator
from contextlib import asynccontextmanager, contextmanager
import logging
import os
from pathlib import Path
import socket
from filelock import AsyncFileLock, FileLock, Timeout
logger = logging.getLogger(__name__)
DEFAULT_POLL_INTERVAL_SECONDS: float = 0.005 # 5ms fast probe eliminates latency quantization
FAST_LOCK_TIMEOUT_SECONDS: float = 3.0 # Interactive state mutations (session notes, telemetry)
HEAVY_LOCK_TIMEOUT_SECONDS: float = 30.0 # Storage migrations and Merkle graph compaction
DEFAULT_LOCK_TIMEOUT_SECONDS: float = FAST_LOCK_TIMEOUT_SECONDS
class LockTimeoutError(TimeoutError):
"""Raised when a file lock cannot be acquired within the timeout window."""
def __init__(self, lock_path: Path, timeout: float) -> None:
self.lock_path = lock_path
self.timeout = timeout
super().__init__(
f"Could not acquire lock on {lock_path} after {timeout:.2f}s (held by another process)"
)
def _stamp_lock_holder(fd: int) -> None:
"""Stamp holder PID and hostname into native lock descriptor for debugging."""
try:
os.lseek(fd, 0, os.SEEK_SET)
payload = f"pid={os.getpid()} host={socket.gethostname()}\n".encode()
os.write(fd, payload)
os.ftruncate(fd, len(payload))
except OSError:
pass
def get_file_lock(
lock_path: Path,
timeout: float = DEFAULT_LOCK_TIMEOUT_SECONDS,
poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS,
) -> FileLock:
"""Instantiate a platform-aware singleton FileLock with production defaults."""
resolved_path = lock_path.resolve()
resolved_path.parent.mkdir(parents=True, exist_ok=True)
return FileLock(
str(resolved_path),
timeout=timeout,
poll_interval=poll_interval,
is_singleton=True,
preserve_lock_file=True,
close_error_policy="suppress",
on_acquired=_stamp_lock_holder,
)
@contextmanager
def state_lock(
lock_path: Path,
timeout: float = DEFAULT_LOCK_TIMEOUT_SECONDS,
poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS,
) -> Iterator[FileLock]:
"""Context manager for acquiring an advisory multi-process lock.
Guarantees parent directory creation, enables singleton thread re-entrancy,
preserves lock files on Windows to eliminate handle release collisions,
and performs fast-probe contention logging before blocking.
"""
lock = get_file_lock(lock_path, timeout=timeout, poll_interval=poll_interval)
# 1. Fast probe: Check if immediately available without waiting
try:
lock.acquire(blocking=False)
except Timeout:
logger.info(
"Lock %s is currently held by another process; waiting up to %.1fs...",
lock.lock_file,
timeout,
)
try:
# 2. Block with deadline
lock.acquire(timeout=timeout, poll_interval=poll_interval)
except Timeout as exc:
raise LockTimeoutError(lock_path=lock_path, timeout=timeout) from exc
try:
yield lock
finally:
lock.release()
@asynccontextmanager
async def async_state_lock(
lock_path: Path,
timeout: float = DEFAULT_LOCK_TIMEOUT_SECONDS,
poll_interval: float = DEFAULT_POLL_INTERVAL_SECONDS,
) -> AsyncIterator[AsyncFileLock]:
"""Asynchronous lock context manager for non-blocking MCP tool endpoints."""
resolved_path = lock_path.resolve()
resolved_path.parent.mkdir(parents=True, exist_ok=True)
lock = AsyncFileLock(
str(resolved_path),
timeout=timeout,
poll_interval=poll_interval,
is_singleton=True,
preserve_lock_file=True,
close_error_policy="suppress",
)
try:
async with lock:
yield lock
except Timeout as exc:
raise LockTimeoutError(lock_path=lock_path, timeout=timeout) from exc
4. Integration with Read-Modify-Write Cycles and Deductive Compaction¶
All operations in tur.session, tur.memory, tur.introspection, and tur.persona wrapping multi-step file mutations must acquire the
appropriate lock before reading and release after atomic replacement:
def note_logic(persona_dir: Path, session_id: str, content: str) -> None:
"""Append a session note safely under multi-process lock with atomic file replacement."""
lock_file = persona_dir / ".locks" / "session.lock"
with state_lock(lock_file, timeout=FAST_LOCK_TIMEOUT_SECONDS):
# 1. Read existing index under lock
index = load_session_index(persona_dir)
# 2. Modify in-memory state
# ... append note, update session status ...
# 3. Atomically replace file under lock using tempfile + os.replace
atomic_yaml_write(get_session_file(persona_dir, session_id), session_data)
save_session_index(persona_dir, index)
Additionally, deductive graph compaction and Council of Giants introspection serialize under the Terrain Compaction Lock:
def run_introspection(persona_dir: Path, ...) -> nx.DiGraph:
"""Run cognitive introspection compaction loop serialized under compaction.lock."""
compaction_lock = persona_dir / ".locks" / "compaction.lock"
with state_lock(compaction_lock, timeout=HEAVY_LOCK_TIMEOUT_SECONDS):
# Read L1 memories, execute Council assembly, save L2 graph atomically, and subsume L1s
...
5. Non-Fatal Contention Handling across Interfaces¶
To ensure agents in multi-manifestation swarms recover gracefully from temporary contention rather than crashing:
- MCP Tool Interface (
src/tur/mcp_server.py): Tool endpoints (wake,learn,note,sleep,status,evolve,tired,introspect) interceptLockTimeoutErrorand return clean, structured non-fatal retry guidance:- String responses:
"Status: Contended. The state lock is currently held by another agent or process: {e}. Please retry shortly." - Structured responses (
status()):{"status": "contended", "error": "State lock is currently held by another process: {e}"}
- String responses:
- Agent CLI Interface (
src/tur/cli/agent.py): Commands interceptLockTimeoutErrorand format a distinct warning via Rich:[bold yellow]Contention Warning: State lock is held by another process: {e}[/bold yellow]
Backwards Compatibility¶
- Non-Invasive Lock Files: Lock files (
.lock) are advisory and created inside.tur/.locks/or OS runtime directories. They do not alter existing YAML or OKF markdown schemas. - Ignored in Version Control:
.tur/.locks/is automatically added to.tur/.gitignoreupon initialization. - Single-Process Zero Impact: In environments with a single agent, lock acquisition incurs sub-millisecond overhead (12–45µs).
How to Teach This / Documentation Plan¶
- Update
docs/concepts/harness-integration.mdto explain how concurrent harnesses coordinate via advisory locks. - Document
LockTimeoutErrorhandling in MCP server documentation: MCP tool endpoints catchLockTimeoutErrorand return structured JSON-RPC responses (Status: Contended. The state lock is currently held by another agent.) rather than raising raw unhandled stack traces.
Reference Implementation¶
- Locking Engine: Fully implemented in
src/tur/locking.py(state_lock,async_state_lock,LockTimeoutError,_CACHED_HOSTNAMEmodule-level caching, PID owner stamping). - Pipeline Integration: Wrapped around
tur.session(start_session_logic,end_session_logic,note_logic,tired_logic,save_session_indexviaatomic_yaml_write) andtur.introspection(run_introspectionviacompaction.lock). - Interface Guardrails: Explicit
LockTimeoutErrorhandling insrc/tur/mcp_server.pyandsrc/tur/cli/agent.py. - Empirical Verification: Complete 6-matrix concurrency suite in
tests/test_locking.py(M1: Barrier zero-lost-updates, M2: 5ms contention fast-probe polling, M3: Real non-singleton timeout exception, M4: Abnormal SIGKILL/exit (42) crash recovery, M5: Async state lock coroutine serialization, M6: Total lock hierarchy descending order), lock holder stamping tests, andtests/test_mcp_server.py::test_mcp_lock_contention_graceful_handling.
Rejected Ideas¶
- Direct-File Locking (
portalocker): Rejected. Locking the data file directly collides with atomicos.replaceoperations, causing WindowsPermissionError: [WinError 32]and POSIX unlinked inode desynchronization. Furthermore, it introduces a heavypywin32binary dependency on Windows. - Distributed / NFS-Safe Locking (
flufl.lock): Rejected. Tur operates strictly on local repository filesystems; NFS lease-time negotiation adds unnecessary latency and complexity. - In-Memory Threading Locks (
threading.Lock/asyncio.Lock): Rejected. Threading locks only synchronize within a single Python process and offer zero protection across independent CLI or MCP harness processes. - Pure SQLite Database Locking: Rejected for file storage. While SQLite manages its own internal locking for IASP (EP-0118), Tur's primary memory formats are OKF Markdown and YAML files, which require OS-level file locking.
- PID File Polling: Rejected. Writing custom PID files requires complex stale-lock recovery logic upon process
crashes;
filelockrelies on kernel-level advisory locks that the OS automatically releases if a process terminates abnormally.
Open Questions & Council Directives¶
- Multiprocessing Concurrency Suite (Bacon): Implement a 6-matrix pytest test suite in
tests/test_locking.pyusingmultiprocessing.Barrierto empirically verify zero data loss under concurrent agent writes.- Resolution: Fully implemented and passing across all 6 test matrices (M1–M6) in
tests/test_locking.py, empirically proving 100% zero-lost-update guarantees, 5ms fast polling, SIGKILL crash recovery, async serialization, and anti-deadlock hierarchy.
- Resolution: Fully implemented and passing across all 6 test matrices (M1–M6) in
- Total Lock Ordering Validation (Maharal & Popper): Assert that no code path attempts to acquire a Global
Traveler lock while holding a Local Terrain lock.
- Resolution: Codified into the Total Lock Ordering Hierarchy specification (\(\text{Global} \succ \text{Local}\))
and verified in
test_matrix_m6_total_lock_hierarchy_ordering.
- Resolution: Codified into the Total Lock Ordering Hierarchy specification (\(\text{Global} \succ \text{Local}\))
and verified in
- Graceful MCP Tool Contention Response (Steward): Verify that MCP tools return non-fatal retry guidance when
encountering
LockTimeoutError.- Resolution: Handled across all tool handlers in
src/tur/mcp_server.pyandsrc/tur/cli/agent.py, verified intests/test_mcp_server.py::test_mcp_lock_contention_graceful_handling.
- Resolution: Handled across all tool handlers in
Change Log¶
- 2026-08-26:
- Hardened implementation based on REV-0006 Council of Giants Implementation Audit:
- Integrated
atomic_yaml_write(tempfile.mkstemp+os.replace) across all locked session write paths to prevent partial file truncation on abnormal process crash. - Attached
on_acquired=_stamp_lock_holderand boundedasyncio.wait_fortimeout inasync_state_lock. - Wrapped
run_introspection()undercompaction.lock(HEAVY_LOCK_TIMEOUT_SECONDS = 30.0s). - Cached
_CACHED_HOSTNAMEto eliminate per-acquisition socket lookups. - Refactored Matrix M3 test to verify native
state_lockexception handling under real non-singleton lock contention. - Verified full passing suite: 259 / 259 tests (100%).
- 2026-08-25:
- Implemented in
src/tur/locking.py,src/tur/session.py,src/tur/mcp_server.py, andsrc/tur/cli/agent.py. Promoted status to Implemented with 100% test pass rate across 252 tests. - Added explicit
LockTimeoutErrorinterception in MCP tool endpoints and agent CLI commands to return structured non-fatal retry instructions without dropping active sessions. - Integrated Council of Giants Review hardening mandates: Total Lock Ordering Hierarchy invariant (anti-deadlock),
5ms polling interval optimization (
poll_interval=0.005), tiered timeout defaults (FAST_LOCK_TIMEOUT=3.0s,HEAVY_LOCK_TIMEOUT=30.0s),LockTimeoutError(TimeoutError)subtyping, and 6-matrix multiprocessing barrier test suite specification. - Enhanced proposal with comparative analysis against
portalocker,fasteners, andflufl.lock, detailing theos.replaceinode replacement hazard and shared read lock trade-offs. - Initial Draft proposing
filelockintegration for cross-platform process synchronization and race condition elimination.
- Implemented in