Staleness and conflicts
Staleness and conflicts
A proposal is stale when the file it targets has changed since the proposal was created. This page covers when that happens, how Lettuce surfaces it, and how to resolve it.
How staleness is detected
Every proposal captures the file’s before content at the moment of creation. At accept time, Lettuce re-reads the file and compares it to that captured content:
For a
modifyordelete, the current file must still equal the capturedbefore— otherwise the reason iscontentDrifted. If the file is gone entirely, it’ssourceMissing.For a
create, the target path must still be empty — otherwisetargetAlreadyExists.For a
move, the source must still matchbefore; if it does but the destination is now occupied, the reason isdestinationOccupied.
There’s no per-proposal content hash and no stale flag in the queue — staleness is computed fresh from disk at the moment you accept.
What happens on a stale accept
Lettuce refuses the write and surfaces the reason (contentDrifted, targetAlreadyExists, sourceMissing, or destinationOccupied). There is no merge UI — the proposal is simply not applied. From there you can:
Reject — discard the now-irrelevant proposal.
Re-ask the agent — have it re-read the current file and re-propose with fresh context. (An agent that polls
list_my_proposalsand reconciles against the live file does this on its own.)
Why proposals go stale
Common causes:
You edited the file. Most common.
Another agent edited the file. Especially likely with multiple agents.
An automation edited the file. Same effect.
Frontmatter normalisation. Even a no-op save can change the file’s content, drifting it from the captured
before(see Agent reference/07 Concurrency and soft-locks).
Agent self-healing
Agents that periodically check their queue can reconcile against the live file and clean up:
my_props = client.tools.list_my_proposals()
for p in my_props["proposals"]:
# Re-read the current file and decide whether the proposal still applies...
fresh = client.tools.read_note(path=p["path"])
client.tools.withdraw_proposal(id=p["id"])
# ...then re-issue the write (it queues a fresh proposal against current content)...
client.tools.apply_edits(
path=p["path"],
edits=rebuild_edits(fresh["content"]),
)
# ...or just withdraw if it no longer applies.
list_my_proposals takes no arguments — it returns every pending proposal authored by the calling key (id, tool, path, operation, timestamp). withdraw_proposal cancels one by id and refuses to touch another agent’s queue. A schedule running this every few hours keeps queues clean. See Recipes/03 Tag curator for a working example.
Conflict between two proposals
When two agents propose overlapping changes to the same file:
The first accept wins. The second proposal’s captured
beforeno longer matches the file, so accepting it is refused withcontentDrifted— reject it or have its agent re-propose.If you reject the first, the second is still fresh (its
beforeis the file before either landed).
Practical: review proposals oldest-first unless you have a reason not to, so each accept’s before is more likely to still match.