diff --git a/docs/agent-locks.md b/docs/agent-locks.md new file mode 100644 index 0000000..0e553dd --- /dev/null +++ b/docs/agent-locks.md @@ -0,0 +1,372 @@ +# Agent Locks — Design Spec (DRAFT, pre-implementation) + +Status: **DRAFT.** Not frozen. Written 2026-07-30 after reading FleetDeck's +lock engine (`U:\1. Projects\AgentSync\fleetdeck\locks.py`, `paths.py`, +`hook.py`) and C3's existing concurrency surface. Nothing here is built yet. +Freeze this document before Phase 3 starts. + +Agent Locks let multiple agents work in one repo — or across several C3 +projects — without clobbering each other. Like Access Guard, it is a +**cooperative coordination layer, not containment**: it protects against +agents racing each other, not against a hostile process. See §9 Coverage +matrix for exactly what is and is not covered. + +--- + +## 1. The problem, precisely + +Two distinct failures get conflated. They need different mechanisms. + +**Layer A — torn writes.** Two `c3_edit` calls interleave read → replace → +write on one file; one edit is silently lost. Today `cli/tools/edit.py:20` +guards this with a `threading.Lock`, which is per-process. Every Claude Code +session spawns its own `c3-mcp` stdio server, so across sessions this is +**unguarded**. There is also no mtime/hash staleness check anywhere in +`edit.py`. + +**Layer B — overlapping work.** Two agents both refactor `services/router.py` +over ten minutes. No per-call locking helps. This needs a *lease* an agent +holds across many edits, with a declared intent. + +Layer A is the correctness floor. Layer B is the feature. + +### 1.1 Why FleetDeck alone does not cover this + +FleetDeck installs a user-level PreToolUse hook with matcher +`Edit|Write|MultiEdit|NotebookEdit`, and `fleetdeck/hook.py:23` hard-codes the +same set as `EDIT_TOOLS`. C3's own PreToolUse hook **blocks those tools** and +routes edits through `mcp__c3__c3_edit`. In a C3 project, FleetDeck's gate +therefore never fires. The two systems currently cancel out. + +--- + +## 2. Schema + +Lock state is a per-project file, in the **target** project — never the +caller's. `c3_project(action='edit', allow_write=true)` lets an agent in +project A mutate project B; caller-scoped state would put the two agents in +different files and neither would see the other. + +`.c3/locks.json` (project scope only; no global scope): + +```json +{ + "version": 1, + "fencing": 47, + "mode": "advisory", + "locks": [ + { + "relpath": "services/router.py", + "agent_id": "claude-code:a3f19c2b", + "session_id": "a3f19c2b-...", + "fencing_token": 47, + "intent": "refactor retry backoff", + "acquired_at": 1785432100.4, + "expires_at": 1785433000.4, + "lock_id": "9f2c..." + } + ] +} +``` + +Mutual exclusion on the file itself uses the cross-process `_FileLock` already +in `services/task_store.py:71` (msvcrt `LK_NBLCK` on Windows, `flock` on +POSIX, 30 s bounded acquire, OS-released if the holder dies). Same +load → mutate → atomic-save transaction pattern as `task_store` and +`time_tracker`. + +Config lives in `.c3/config.json`, section `locks`: + +```json +{ + "locks": { + "mode": "advisory", + "default_ttl_s": 900, + "backend": "local" + } +} +``` + +- `mode`: `advisory` (default) or `strict`. Strict fails **closed** when lock + state cannot be read or the backend is unreachable. +- `backend`: `local` (default) or `fleetdeck`. See §10 — this is chosen + per repo at config time and is **never** switched at call time. +- Mutations are human-only (`c3 locks` CLI / Hub UI), except acquire/release, + which are the agent's normal working verbs. + +--- + +## 3. Lock identity + +Key is `(repo_id, relpath)` — adopt FleetDeck's scheme verbatim from +`fleetdeck/paths.py` so both systems name the same file the same way: + +- `repo_id` = sha1 of the canonical repo root (absolute, casefolded, + forward-slash Windows form). +- `relpath` = casefolded, forward-slash, repo-relative. +- Normalization is **purely lexical** — no filesystem access. Files that do + not exist yet still normalize, and the result never depends on cwd or + symlinks. +- `/mnt/u/repo/src/api.py` (WSL) and `U:\repo\SRC\API.PY` (Windows) collide on + one key. +- Unsupported forms are rejected with a machine-readable reason — + `unc`, `outside_repo`, `is_root`, `empty` — never guessed. A wrong guess + silently breaks mutual exclusion, which is worse than refusing. + +`agent_id` follows FleetDeck's convention: `claude-code:` +(`fleetdeck/hook.py:57`). Same agent, same name, both systems. + +--- + +## 4. Lease lifecycle + +Adopt FleetDeck's semantics (`fleetdeck/locks.py`). They are correct and +matching them is what makes the two systems interoperate rather than fight. + +| Op | Contract | +|---|---| +| `acquire` | **All-or-nothing** over a sorted relpath list. Deterministic ordering means two agents grabbing the same pair in opposite order cannot deadlock. Returns `{granted, locks[]}` or `{granted: false, conflicts[]}`. | +| `renew` | Extends TTL. Rejects if the fencing token is stale or the caller is not the holder. | +| `release` | By session, optionally scoped to paths. Idempotent. | +| `validate` | Non-mutating: does this session still hold these locks with current tokens? The strict-mode primitive. | +| `force_release` | Human override. **Bumps the fencing counter** so a returning holder is stale by construction. Audited. | +| `sweep` | Expires leases past TTL. Every expiry is an event. | + +Four properties to preserve: + +1. **TTL is the real release mechanism.** Agents forget to release. Design as + if `release` never happens; default 900 s, renewable. A crashed agent can + never wedge a repo. +2. **Fencing tokens** make `force_release` safe. +3. **All-or-nothing** multi-path acquire — required because `c3_edit` + encourages parallel calls across files. +4. **The engine never lies.** An advisory repo is badged advisory. We never + render "protected" for a repo we cannot actually protect. + +--- + +## 5. Gate placement + +C3 already has the right seam. `cli/tools/edit.py:259`: + +```python +op = "write" if path.exists() else "create" +denial = access_guard.check(str(path), op, svc.project_path) +if denial: + return finalize("c3_edit", {"file": file_path}, + access_guard.refusal(denial, file_path, op), "access-denied") +``` + +The lock gate is a structurally identical second evaluator on the next line — +`agent_locks.check(path, op, project_path) -> Held | None` plus a +`refusal()` renderer. Ordering: vault guard → Access Guard → **Agent Locks**. +Policy denials outrank contention; never tell an agent a file is locked when +it was never allowed to write it. + +Seams to gate: + +| Surface | File | Notes | +|---|---|---| +| `c3_edit` | `cli/tools/edit.py:259` | create, single, and batch modes alike | +| `c3_shell` | `cli/tools/shell.py` | only the git-mutating commands C3 already detects for the ledger — see §9 | +| `c3_project(action='edit')` | `cli/tools/project.py` | proxies to `handle_edit`, so inherited — verify the target project's lock file is used, not the caller's | +| native `Edit`/`Write` | `cli/hook_pretool_enforce.py` | C3 already runs a PreToolUse hook here; a lock check is a natural addition. Needed because `hook_edit_unlock.py` legitimately unlocks native Edit after a `c3_read`. | + +**Layer A is separate and unconditional.** Replace the `threading.Lock` at +`edit.py:20` with `_FileLock` regardless of whether leases ship. Held for the +duration of one read → replace → write, it gives genuine mutual exclusion for +`c3_edit`-vs-`c3_edit` with no daemon, no TTL, no recovery path. + +--- + +## 6. Refusal contract + +Reuse the established C3 refusal idiom. Tag `[c3-lock:held]`, matching +`[c3-mask:transformed]` / `[c3-mask:unsupported]`, which the instruction docs +already teach agents not to route around. + +``` +[c3-lock:held] services/router.py is held by claude-code:a3f19c2b + intent: "refactor retry backoff" expires in 6m12s +This is a policy block, not a transient error. Do not retry via c3_shell, +native Write, or another tool. Work on a different file, or ask the holder +to release. +``` + +The last sentence is load-bearing. Without an explicit "do not route around +this", models reach for `c3_shell` with a Python one-liner within about two +turns. This lesson is already banked from Mask Guard. + +Strict-mode backend failure is a distinct tag — `[c3-lock:unavailable]` — +so an agent can tell contention from infrastructure. + +--- + +## 7. Surfaces + +- **`c3_locks(action='list|acquire|release|renew|status')`** — MCP tool. + Mirrors the REST verbs. `force_release` is human-only. +- **`c3 locks list | release --all | force-release `** — CLI. The hammer + for when an agent is gone and TTL has not fired. +- **Hub UI tab** — per-repo lock table: holder, intent, draining lease ring, + honest `ADVISORY` / `ENFORCED` badge, force-release button. C3's Hub is + already cross-project, so this is a fleet-wide view without a fleet daemon. +- **Events** — denials to `.c3/notifications.jsonl`; acquire/release/expire as + cheap ledger events. Denials are the signal that tells you whether leases + were worth building. + +--- + +## 8. Granularity + +File-level for v1. Symbol-level is tempting — `c3_impact` already resolves +symbol blast radius — but it roughly doubles the state model and the failure +modes (overlapping ranges, edits that move symbol boundaries, renames). The +known pain is coarse locks on large files like `cli/hub_server.py` (~1500 +lines). Accept that in v1; revisit only if the denial log shows it dominating. + +--- + +## 9. Coverage matrix + +Honest scope. Nothing here is containment. + +| Path to a file mutation | Covered | How | +|---|---|---| +| `c3_edit` (create / single / batch) | **Yes** | gate at `edit.py:259` + `_FileLock` | +| `c3_project(action='edit')` | **Yes** | proxies `handle_edit`; target-project lock file | +| native `Edit` / `Write` / `MultiEdit` in Claude Code | **Yes** | `hook_pretool_enforce.py` | +| `c3_shell` running git mutations (`checkout`, `reset`, `restore`, `merge`) | **Partial** | only commands C3 already parses for the ledger | +| `c3_shell` running arbitrary code that writes files (`sed`, a test writing fixtures, a build step) | **No** | paths are not declarable; genuinely uncoverable | +| A non-Claude agent (Codex / Gemini / Ollama) editing directly | **No** | needs FleetDeck, or that agent calling `c3 locks acquire` | +| A human in an editor | **No** | out of scope | +| A repo with no `.c3/` | **No** | no lock file, no coordination | +| Two machines on a shared drive | **No** | neither C3 nor FleetDeck handles this | + +The Hub badge must reflect this matrix. A repo where agents mostly work +through `c3_shell` is not meaningfully protected and should not look like it +is. + +--- + +## 10. Backends, and the namespace trap + +The obvious design — *try FleetDeck, fall back to `.c3/locks.json` if the +daemon is down* — **is a correctness bug.** If the daemon flaps, agent A lands +in the FleetDeck namespace and agent B in the local one. Neither sees the +other, and the badge still says protected. Silent collisions are worse than no +locking, because you stop watching for them. + +Rule: **backend is chosen per repo, at config time, deterministically.** When +the chosen backend is unreachable, behaviour is governed by `mode` — +`advisory` fails open, `strict` fails closed — but the namespace **never** +switches. + +Default is `local`. C3 ships as a product (see `commercial/`) and cannot +depend on personal infrastructure. The local backend needs no daemon, which +for a lock system is a real correctness advantage: it cannot fail open because +a process died. + +--- + +## 11. Relationship to FleetDeck + +**Recommended direction: C3 owns locks; FleetDeck owns the fleet.** + +This inverts the more obvious "C3 as a FleetDeck client" arrangement. The +deciding factor is that C3's lock state is files on disk with OS-level +locking, so it works with **no daemon running** — whereas FleetDeck's engine +fails open when its daemon is down. For a mutual-exclusion primitive, that +asymmetry decides it. C3 also has the depth: it is the only thing that sees +`c3_edit`, `c3_shell`, `c3_project`, Access Guard verdicts and masked paths. + +So the split is: + +| | C3 Agent Locks | FleetDeck | +|---|---|---| +| Scope | per project, any number of projects | machine-wide | +| Substrates | Claude Code (MCP tools + hook) | all — Claude, Codex, Gemini, Ollama, CLI sessions | +| Needs a daemon | no | yes | +| Owns | lock state, leases, fencing | presence, heartbeats, task leases, messaging, cockpit | + +Integration, once both exist: FleetDeck **reads** each registered repo's +`.c3/locks.json` to render its per-repo lock tables, and its non-Claude +wrappers acquire through `c3 locks acquire` or C3's REST surface. One +namespace, C3 authoritative, FleetDeck as the cross-substrate front door and +the cockpit. + +### Do you need to run FleetDeck? + +**No — not for locking**, once Phases 1–3 land. Every Claude Code session, +interactive or subagent, across every c3-installed project on the machine, is +covered by C3 alone with nothing running in the background. + +**Yes — for these**, which C3 does not do and should not grow: + +- A non-Claude substrate (a Codex, Gemini, or Ollama daemon) editing the same + repos. C3's gate lives inside C3's own tools and hook; it cannot see them. +- Presence — "which agents are alive right now, on what substrate, doing + what." C3 has per-project sessions, not a machine-wide live roster. +- Cross-agent messaging / broadcast into each agent's inbox. +- Repos with no `.c3/`. + +Until a non-Claude agent shares a repo with a Claude session, FleetDeck is +optional for this purpose. + +### Immediate stopgap, before any of this is built + +Add `mcp__c3__c3_edit` to FleetDeck's PreToolUse matcher **and** to +`EDIT_TOOLS` in `fleetdeck/hook.py:23`. Claude Code matchers regex against MCP +tool names, and `_PATH_KEYS` already leads with `file_path`, which is +`c3_edit`'s parameter. Two lines, restores coverage today, and starts +producing the denial data that tells you whether Phase 3 is worth building. + +--- + +## 12. Failure modes to design for + +| Mode | Handling | +|---|---| +| Agent never releases | TTL. Assume release never happens. | +| Agent crashes mid-edit | `_FileLock` is OS-released on process death; the lease expires on TTL. | +| `.c3/locks.json` corrupt | Advisory: log and proceed unlocked. Strict: refuse all writes with `[c3-lock:unavailable]`. Never silently reset to empty. | +| Two agents, opposite acquisition order | All-or-nothing over a sorted list makes deadlock impossible. | +| Clock skew | Single machine; `time.time()` is fine. Revisit if state is ever shared across hosts. | +| Lease held by a session that ended | Stop-hook release, plus TTL as backstop. | +| Agent routes around a denial | Explicit refusal wording (§6) + the same instruction-doc treatment masked paths get. Cooperative, not enforced. | + +--- + +## 13. Phasing + +1. **`_FileLock` in `edit.py`.** Cross-process torn-write safety. ~15 LOC + + tests. No new concepts, no daemon, ships standalone. Do this regardless of + everything below. +2. **FleetDeck two-line matcher fix** (§11). Restores coverage today and + answers the empirical question: *do these agents actually collide, and + where?* +3. **Lease service + `c3_locks` + gates + refusal strings.** ~400 LOC. Local + backend only. +4. **Hub tab + FleetDeck read integration.** ~200 LOC. + +Phases 1–2 are an afternoon. Run them, read the denial log, and let that +decide whether 3 and 4 are worth building. + +--- + +## 14. Open questions + +- Should acquiring a lease be **implicit** (first `c3_edit` on a file takes a + lease) or **explicit** (`c3_locks(action='acquire')` with a declared + intent)? Implicit is invisible and always correct; explicit produces far + better intent strings for the other agent to read. Probably implicit-with- + auto-intent derived from the edit summary, explicit as an override. +- Should a lease block **reads**? Almost certainly not — but an agent reading + a file another agent is mid-refactor on will act on stale content. A + read-side *warning* (not a block) may be worth more than the lock. +- Does `c3_impact` belong in the acquire path — locking a symbol's callers + alongside the symbol? Powerful, and a good way to turn one edit into a + twenty-file lease that wedges everyone. Defer. +- Sub-projects (`services/subprojects.py`): does a child branch share the + parent's lock namespace, or keep its own? Follow whatever the federated + memory scope already does.