Concurrency and soft-locks
Concurrency and soft-locks
What happens when two agents (or an agent and you) touch the same note at the same time.
The model: no explicit version tokens
Lettuce does not use version tokens or optimistic-concurrency hashes on writes. There is no base_sha256 parameter, and no “only apply if the file still matches” precondition. Conflict-safety comes from three concrete mechanisms instead:
Exactly-once matching on
apply_edits. Eachfindstring must match exactly once. If the file drifted out from under you — an anchor went missing or now appears twice — the edit fails loudly and the whole call rolls back, rather than writing to the wrong place.Soft-locks on the editor. A note you’re actively editing is flagged; agent writes are warned or (for full replaces) rejected so they don’t clobber your unsaved buffer.
Proposal staleness re-checks. A queued proposal is re-validated against the live file at accept time; if the target drifted, accepting fails with
proposal-staleinstead of applying blindly.
Agent A: read_note → content X (anchor "## Notes" present)
Agent A: thinks for 30s
Agent B: apply_edits → rewrites that section, anchor text changes
Agent A: apply_edits with find="## Notes\n\nold body" → ERROR: find not found
Agent A: re-reads, rebuilds find strings, retries → OK
How agents stay conflict-safe
Use apply_edits for modifications: the exactly-once-match rule is itself the guard. Pick find strings specific enough that they can only match the version of the file you read; if someone changed that region first, your edit fails loudly and you re-read and retry.
write_note is a full-content replace, semantically “I want this to be the new content regardless”. It has no anchoring, so it’s the easiest way to clobber a concurrent change — prefer apply_edits for modifications, and use mode: create to guard against accidental overwrite (it fails with note-exists if the file already exists).
What can shift your anchors
Any byte change to the file in the region you’re anchoring on.
Frontmatter normalisation: Lettuce re-emits frontmatter with stable key ordering on every save. If you handwrote
tags: [a,b]and the file is re-saved by Lettuce, it becomestags: [a, b].Trailing newline normalisation: Lettuce ensures one trailing newline. Files without one get one added on first save.
So a “no-op” save can still move text around. Agents should expect this and re-read + retry when an apply_edits find stops matching, rather than assuming the world is malicious.
Soft locks for the editor
When you’re actively editing a note in Lettuce, the file is soft-locked for as long as it’s focused in the editor. Any agent write that targets a locked note — apply_edits, write_note, patch_section, or complete_directive — fails with a hard error (kind: target-is-being-edited) rather than touching your open buffer. There’s no “advisory” path: a focused note is off-limits to direct writes.
The agent’s options: wait and retry once you blur or close the note, or run under a propose-only key, where the write is queued as a proposal for you to accept later (so it never contends with your buffer). The lock is released the moment you blur or close the note.
Locking semantics in detail
(The “propose-only key” column is any write tool — apply_edits / write_note / patch_section — issued under a propose-only key, which queues a proposal instead of committing, so the editor lock never applies.)
| State | write key (apply_edits / write_note / patch_section) |
propose-only key |
|---|---|---|
| Note focused in the editor | ❌ target-is-being-edited |
✅ proposed |
| Note not open (or QuickLook only) | ✅ applies | ✅ proposed |
Multi-agent contention
Two agents editing the same note:
Both call
apply_editson the same region — they serialise. The first lands; the second’sfindno longer matches the (now-changed) text, so it fails loudly with a not-found/not-unique error. Re-read, rebuild thefindstrings, retry — both edits eventually land.One calls
apply_edits(write key), one is on a propose-only key — the apply lands immediately. If the queued proposal then targets a region that moved, accepting it later fails withproposal-staleand it’s flagged in the proposals UI.Both call
write_note— last write wins. Strongly avoided in practice (usemode: createorapply_edits).
Multi-vault contention
Each vault has its own lock domain. Agents working on different vaults never block each other. The server is fully async and can handle many simultaneous tool calls.
Practical recipes
Retry loop
for attempt in range(3):
note = client.tools.read_note(path=path)
# Build `find` strings from the content you just read, so they only
# match this version of the file.
edits = build_edits(note.content)
try:
client.tools.apply_edits(path=path, edits=edits)
break
except EditNotUnique:
# Someone changed the region first; the anchor no longer matches.
# Re-read and rebuild on the next loop.
continue
else:
raise RuntimeError(f"Could not apply edits after 3 retries")
Check before clobber
try:
client.tools.write_note(
path=path,
content=...,
mode="create" # fails if the file already exists
)
except NoteExists: # kind: note-exists
# someone (or another agent) got there first; use apply_edits
...