fix(agent-edit-v2): detect live ref drift against persisted base not live fallback#62
Conversation
…live fallback findLiveRefDrift compared the live authoritative fragment against a "persisted" base that was itself sourced from the live-fallback canonical read (doc.markdown from getCanonicalReadableDocument). Under live presence those are the same fragment, so the guard compared the live fragment against itself and never detected drift, exactly when it is needed. When an agent edits against a baseRevision (no baseToken) and a concurrent client inserts a block before the agent's referenced block, the positional b-ref resolved to the now-shifted wrong block: replace_block silently clobbered the concurrent human insert and landed on the wrong content, returning 200 success. Source the drift baseline from the real persisted document row (getDocumentBySlug) for baseRevision-only edits, the frame the agent's refs were captured against. When a baseToken is supplied the agent opted into the live authoritative base directly, so that base stays the comparison frame (behavior unchanged). Now returns 409 FRAGMENT_DIVERGENCE and preserves the concurrent edit.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 48a7abde2d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| // (validated above), so that base is already the correct comparison frame. | ||
| const driftBaselineMarkdown = baseToken | ||
| ? stripEphemeralCollabSpans(doc.markdown ?? '') | ||
| : stripEphemeralCollabSpans(getDocumentBySlug(slug)?.markdown ?? doc.markdown ?? ''); |
There was a problem hiding this comment.
Do not reject freshly refreshed live refs
This makes every baseRevision-only edit compare refs against the persisted row whenever the authoritative base is live. However /snapshot//state can build the advertised bN refs from the live Yjs fallback while still returning the row revision, so a client that refreshes after a live insert and posts that fresh snapshot.revision (e.g. persisted A/B/C, live A/X/B/C, then replace fresh b3) will now compare live b3 to persisted b3 and get FRAGMENT_DIVERGENCE forever unless it switches to baseToken. Since the endpoint still accepts baseRevision and the snapshot exposes it, this rejects valid fresh live-state edits rather than only stale refs.
Useful? React with 👍 / 👎.
The drift baseline re-read the document row with a second getDocumentBySlug call, several awaits after doc was fetched and revision-validated against baseRevision. That second read was not itself revision-checked, so a concurrent write landing in between returned a newer row than the frame the agent's refs were validated against, and the newer content could be compared as if it were the base - a spurious FRAGMENT_DIVERGENCE on an otherwise valid edit. Only use the re-read row when its revision still matches baseRevision, and fall back to the already-validated doc otherwise. The baseToken path is unchanged. Signed-off-by: Yash Raj Pandey <yashpn62@gmail.com> Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
The bot's P2 on the drift baseline was a real find - fixed in d7dadc8. The baseline re-read the row with a second Now the re-read row is only used when its revision still matches One note on the bot's framing: "permanently reject" overstates it - a retry would pick up the new revision and succeed. It's a spurious rejection, not a permanent one. I didn't add a regression test: reproducing this deterministically needs a timing seam to inject a concurrent write between the two reads, and the current harness has no such hook - a test without it would assert on shape rather than behavior. The existing |
Problem
POST /api/agent/:slug/edit/v2could silently clobber a concurrent human edit and apply an agent'sreplace_blockto the wrong block, returning200 successinstead of rejecting.Scenario (covered by the existing
agent-edit-v2-live-structural-drift-regressiontest):b3= "Second paragraph.").b4), without bumping the persisted revision.replace_blockon its capturedb3withbaseRevision= the snapshot revision.Expected:
409 FRAGMENT_DIVERGENCE(the block topology drifted under the agent's ref).Actual:
200- the ref resolved positionally to the now-shiftedb3(the human's new block) and replaced it, discarding the concurrent insert.Root cause
The ref-drift guard
findLiveRefDrift(persistedDescriptors, liveDescriptors, ...)is meant to compare the persisted base (what the agent's refs were captured against) with the live authoritative fragment. But both sides were sourced from the same value:liveDescriptorscome fromauthoritativeBase.base.markdown(the live fragment).persistedDescriptorscome fromdoc.markdown, wheredoc = await getCanonicalReadableDocument(slug, 'state') ?? getDocumentBySlug(slug).Under live presence,
getCanonicalReadableDocument(..., 'state')returns the live (yjs) fallback, sodoc.markdownis the live fragment too. The guard therefore compared the live fragment against itself and never detected drift - exactly when it is needed.Instrumented confirmation: with a live client connected,
persistedDescriptorsandliveDescriptorswere identical (both the 5-block live fragment, including the human insert), while the real persisted row (getDocumentBySlug) still held the 4-block base the agent'sb3was captured against.Fix
Source the drift baseline from the real persisted document row for
baseRevision-only edits (the frame the agent's refs were actually captured against). When abaseTokenis supplied, the agent explicitly opted into the live authoritative base (already validated above), so that base remains the comparison frame and behavior is unchanged.Verification
src/tests/agent-edit-v2-live-structural-drift-regression.test.ts: fails onmain(200), passes with this change (409, the human insert is preserved and the persisted revision is unchanged).baseToken-based edits are unaffected (no spuriousFRAGMENT_DIVERGENCE).tsc -p tsconfig.server.jsonunchanged).