Skip to content

fix(edit): stop two C3 sessions from clobbering the same file - #55

Merged
drknowhow merged 2 commits into
mainfrom
feat/agent-locks-phase1
Jul 30, 2026
Merged

fix(edit): stop two C3 sessions from clobbering the same file#55
drknowhow merged 2 commits into
mainfrom
feat/agent-locks-phase1

Conversation

@drknowhow

Copy link
Copy Markdown
Owner

Implements Phase 1 of docs/agent-locks.md (merged in #54), and amends the spec with what building it found.

The bug

c3_edit serialized same-file edits with a per-file threading.Lock (cli/tools/edit.py:20). That holds within one process, and every Claude Code session spawns its own c3-mcp stdio server. Two sessions editing one file could interleave their read → replace → write cycles and lose an edit, with no error on either side.

Create mode was worse and wasn't in the spec: the lock was only obtained at edit.py:298, after the create branch had already returned. Two agents creating the same path both reported success and one file silently won.

The fix

_edit_lock composes the existing threading.Lock with the cross-process _FileLock already used by task_store and time_tracker, held across the whole cycle — create, single-edit and batch alike.

handle_edit keeps path resolution and the vault/access guards, then hands off to _edit_locked under the lock. Ordering is deliberate: policy denials outrank contention, so an agent is never told a file is busy when it was never allowed to write it.

Three decisions worth reviewing

Sidecar is machine-global, not project-scoped. ~/.c3/edit_locks/<sha1>.lock, keyed on the normcased resolved target path. It must not live under svc.project_path, because c3_project(action='edit') proxies into handle_edit with the caller's svc — a project-scoped sidecar would hand two agents editing one file two different locks and no mutual exclusion at all. This doesn't contradict §2 of the spec: lease state is target-project-scoped; the Layer A mutex is machine-scoped and has no project identity.

os.path.normcase, not casefold. Windows paths are case-insensitive so two spellings must collide; POSIX paths are case-sensitive so they must not. test_case_follows_platform asserts exactly that, per platform.

Contention is a refusal, not a wait. _FileLock raises TimeoutError where threading.Lock blocked forever. Unbounded waiting would hang the MCP server behind a wedged holder, so it surfaces as [c3-lock:busy] — with an explicit "do not route around it via c3_shell or native Write", because agents reach for a shell one-liner within about two turns otherwise. That lesson is already banked from Mask Guard.

Tests

tests/test_edit_locking.py, 12 tests. The exclusion tests spawn a real second process to hold the lock — an in-process thread would prove nothing about the failure this fixes. Coverage: sidecar identity and platform case rules, edit/create/batch all blocked under contention, lock released cleanly so the next edit succeeds, and the uncontended path unchanged.

Full suite: 1730 passed, 1 failure — tests/test_shell_robustness.py::TestPopenUtf8::test_forces_utf8_env, which is pre-existing on main and unrelated (shell.py sets PYTHONIOENCODING='utf-8:surrogateescape', the test asserts 'utf-8'). This branch touches only edit.py. Worth a separate one-line fix — it's red on main right now.

Spec amendments

docs/agent-locks.md is corrected in place rather than quietly patched: it claimed ~15 LOC (it's ~40 plus an extract-method refactor), missed the create-mode hole, missed the TimeoutError semantics, and implied the Layer A sidecar was project-scoped. §5 records all three, §6 adds the busy vs held distinction, §13 marks P1–P2 done and keeps P3 gated on denial data.

Companion change

Phase 2 lands in AgentSync (drknowhow/AgentSync) — mcp__c3__c3_edit added to hook.EDIT_TOOLS, with the PreToolUse matcher now derived from that same tuple so the gate and the matcher can't drift apart again. That drift is how FleetDeck came to be silently blind in every C3 project.

https://claude.ai/code/session_01Sbd9NbQfDcoJvvURD7KCVf

c3_edit serialized same-file edits with a per-file threading.Lock. That only
holds within one process, and every Claude Code session spawns its own c3-mcp
stdio server -- so two sessions editing one file could interleave their
read -> replace -> write cycles and lose an edit with no error on either side.

Create mode was worse: the lock was only obtained after the create branch had
already returned, so two agents creating the same path both reported success
and one file silently won.

_edit_lock now composes the existing threading.Lock with the cross-process
_FileLock already used by task_store and time_tracker, and is held across the
whole cycle for create, single-edit and batch alike. handle_edit keeps path
resolution and the vault/access guards, then hands off to _edit_locked under
the lock -- policy denials still outrank contention, so an agent is never told
a file is busy when it was never allowed to write it.

The lock sidecar is machine-global, keyed by sha1 of the normcased resolved
target path, in ~/.c3/edit_locks. It deliberately does not live under
svc.project_path: c3_project(action='edit') proxies into handle_edit with the
CALLER's svc, so a project-scoped sidecar would hand two agents editing one
file two different locks. normcase rather than casefold, so two spellings
collide on Windows and stay distinct on POSIX.

_FileLock raises TimeoutError where threading.Lock blocked forever. Unbounded
waiting would hang the MCP server behind a wedged holder, so contention
surfaces as a [c3-lock:busy] refusal that names the anti-pattern -- agents
reach for c3_shell within about two turns otherwise, as Mask Guard taught.

Tests use a real second process. An in-process thread would prove nothing
about the failure this fixes.

docs/agent-locks.md is amended in place: the spec claimed ~15 LOC, missed the
create-mode hole, missed the TimeoutError semantics, and implied the Layer A
sidecar was project-scoped. Those corrections are recorded rather than quietly
patched over.

Claude-Session: https://claude.ai/code/session_01Sbd9NbQfDcoJvvURD7KCVf
CI caught a lock that could be keyed two ways, which is not a lock.

handle_edit resolves a path before taking the sidecar, but _lock_sidecar did
not resolve it itself -- so the exclusion tests, which computed the sidecar
from the unresolved path, had the holder locking one file while c3_edit locked
another. Every one of them passed while proving nothing.

It stayed hidden because both environments I ran it in lack the constraint:
Linux CI has a real /tmp, and this dev box has a username too short to be given
an 8.3 alias. It surfaced on macOS, where TemporaryDirectory returns /var/...
and resolve() rewrites it to /private/var/..., and on Windows CI, where the
runner's home resolves RUNNER~1 to runneradmin.

_lock_sidecar now resolves internally, so no caller can key it the other way --
the same reasoning as deriving FleetDeck's matcher from its gate rather than
maintaining two lists. Non-strict resolve, so create mode still works on a path
that does not exist yet; OSError falls back to hashing the given path rather
than silently locking nothing.

Tests: _hold now asserts its sidecar matches the resolved one, so a future
drift fails naming its own cause instead of looking like "the edit wasn't
blocked". Added test_sidecar_is_spelling_independent (verified non-tautological
-- the ../ spelling produces a different digest without the fix) and
test_sidecar_follows_symlinked_parent, which reproduces the macOS /var case
directly and skips on Windows where symlinks need privilege.

Recorded the macOS case-insensitive-volume residual in the coverage matrix
rather than leaving it implied: there API.py and api.py are one file but get
two sidecars.

Claude-Session: https://claude.ai/code/session_01Sbd9NbQfDcoJvvURD7KCVf
@drknowhow
drknowhow merged commit 0a4b8bc into main Jul 30, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant