Skip to content

fix(ai): prevent stale agent edit overwrites - #309

Merged
EtienneLescot merged 2 commits into
getopenscreen:mainfrom
arhxam:codex/prevent-stale-agent-overwrites
Aug 20, 2026
Merged

fix(ai): prevent stale agent edit overwrites#309
EtienneLescot merged 2 commits into
getopenscreen:mainfrom
arhxam:codex/prevent-stale-agent-overwrites

Conversation

@arhxam

@arhxam arhxam commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Summary

  • capture the project document and revision atomically when an AI turn starts
  • refuse to apply a returned full-document edit when any local document mutation advanced that revision
  • leave the user's live document and on-disk project untouched on conflict, while still retaining the assistant's chat response
  • show a localized warning explaining why the agent edits were not applied
  • preserve intentional chat rewind behavior by allowing that confirmed action to replace the current revision

Related issue

Fixes #284

Type of change

  • Bug fix
  • Feature
  • Enhancement
  • Documentation
  • Refactor / maintenance
  • Performance
  • Security

Release impact

  • Patch
  • Minor
  • Major / breaking change
  • No release note needed

Desktop impact

  • Windows
  • macOS
  • Linux
  • Installer / packaging
  • Not platform-specific

Screenshots / video

Not included; the visible change is a localized warning toast on a concurrent-edit conflict.

Testing

  • Started with a failing regression test before the revision guard existed
  • npx vitest --run src/lib/ai-edition/store/agentDocumentApply.test.ts (3 passed)
  • npm run test (1,680 passed, 1 skipped across 141 files)
  • npx tsc --noEmit
  • npx tsc -p tsconfig.test.json --noEmit
  • npx biome check on the implementation, test, component, and locale files
  • npm run docs:check
  • npm run i18n:check
  • npm run build-vite

Authored with Codex assistance and manually verified against the issue's concurrent-edit reproduction.

Summary by CodeRabbit

  • New Features

    • Agent-generated edits are now protected from overwriting newer manual project changes.
    • Conflicting edits trigger a warning instead of being applied.
    • Rewind actions continue to replace the current project content as expected.
  • Localization

    • Added conflict warnings across all supported languages.
  • Bug Fixes

    • Improved reliability when agent processing overlaps with project edits.

@arhxam
arhxam requested a review from EtienneLescot as a code owner August 8, 2026 18:26
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@EtienneLescot, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 14 minutes

Limit details: You’ve used all 4 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 37e1f3b3-1b65-47e0-aeb8-a21341dfb0f5

📥 Commits

Reviewing files that changed from the base of the PR and between 3a2255d and 25f7c6a.

📒 Files selected for processing (16)
  • src/components/ai-edition/LeftPanel.tsx
  • src/i18n/locales/ar/editor.json
  • src/i18n/locales/en/editor.json
  • src/i18n/locales/es/editor.json
  • src/i18n/locales/fr/editor.json
  • src/i18n/locales/it/editor.json
  • src/i18n/locales/ja-JP/editor.json
  • src/i18n/locales/ko-KR/editor.json
  • src/i18n/locales/pt-BR/editor.json
  • src/i18n/locales/ru/editor.json
  • src/i18n/locales/tr/editor.json
  • src/i18n/locales/vi/editor.json
  • src/i18n/locales/zh-CN/editor.json
  • src/i18n/locales/zh-TW/editor.json
  • src/lib/ai-edition/store/agentDocumentApply.test.ts
  • src/lib/ai-edition/store/agentDocumentApply.ts
📝 Walkthrough

Walkthrough

Changes

Agent document conflict handling

Layer / File(s) Summary
Document application helper and tests
src/lib/ai-edition/store/agentDocumentApply.ts, src/lib/ai-edition/store/agentDocumentApply.test.ts
Added applyAgentDocumentIfCurrent and its result type. The helper validates documents, checks optional revisions, updates project state, persists documents, and supports rewind behavior. Tests cover application, conflicts, and rewinds.
Chat revision checks and localized feedback
src/components/ai-edition/LeftPanel.tsx, src/i18n/locales/*/editor.json
LeftPanel captures the document revision before each chat run. Returned agent documents apply only when the revision is unchanged. Conflicts show a localized warning, while rewinds remain revisionless.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant LeftPanel
  participant chatRun
  participant applyAgentDocumentIfCurrent
  participant useProjectStore
  LeftPanel->>useProjectStore: Capture document and revision
  LeftPanel->>chatRun: Start agent run
  chatRun-->>LeftPanel: Return agent document
  LeftPanel->>applyAgentDocumentIfCurrent: Apply with expected revision
  applyAgentDocumentIfCurrent->>useProjectStore: Check current revision
  alt Revision unchanged
    applyAgentDocumentIfCurrent->>useProjectStore: Set and save document
    applyAgentDocumentIfCurrent-->>LeftPanel: Return applied
  else Revision changed
    applyAgentDocumentIfCurrent-->>LeftPanel: Return conflict
    LeftPanel->>LeftPanel: Show warning toast
  end
Loading

Suggested reviewers: etiennelescot

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly describes the main change: preventing stale AI agent edits from overwriting current document changes.
Description check ✅ Passed The description follows the repository template and includes the change summary, issue link, classifications, impact, and detailed testing information.
Linked Issues check ✅ Passed The implementation detects revision conflicts, preserves local and saved changes, shows localized warnings, retains chat responses, and preserves rewind behavior for issue #284.
Out of Scope Changes check ✅ Passed The changes are limited to revision-safe agent document application, conflict localization, and regression tests required by issue #284.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@EtienneLescot EtienneLescot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at the PR head (3a2255db). Verdict: ship with nits, but please read §1 — the failure mode is more common than it looks.

The guard itself is correct for the window it targets, and I checked the things that usually go wrong here and did not find them: documentRevision is read from the same getState() snapshot as documentSnapshot (LeftPanel.tsx:930-932) and before the await chatRun, so there is no read-after-await bug. The comparison expectedRevision !== undefined && store.revision !== expectedRevision correctly treats revision 0 as a real value, and !== rather than < also catches a counter moving backwards. No stale closure — applyAgentDocument is a []-dep wrapper over an imported module function reading live state via getState() at call time.

Blast radius is genuinely small: only two call sites apply an agent document, and both go through the new module. I confirmed no chat event carries a document (AiEditionChatEvent in src/native/contracts.ts:779-784 is text/thinking/toolStart/toolEnd/error only), and that runTimelineOperation (chat-service.ts:526) has no IPC handler and no renderer caller, so it is not a live bypass.

i18n is clean — npm run i18n:check passes, all 13 locales have the key at the same position after chat.applyEditsFailed, every value is genuinely translated (zh-TW is correctly distinct from zh-CN, not a copy), and terminology for "agent" matches the neighbouring keys in each language. Both typecheck configs, biome and the new tests all pass.

1. A conflict discards the whole turn, and background writers will trigger it routinely

This is the finding I'd most like addressed before merge. The conflict path drops result.document on the floor with no recovery, and the thing that moves revision is usually not the user.

transcriptionStore.ts transcribes every asset that lands in the document automatically in the background and finishes with saveDocument at :398revision + 1. Same for the load-time dimension probe (useTimeline.ts:140-157) and the camera auto-link (projectStore.ts:172).

Concretely: import a fresh 5-minute recording, ask "cut the silences". Whisper finishes 20s into the agent turn. applyResult === "conflict". The user's 30 seconds and their tokens are gone, result.document is unrecoverable except by re-asking (which races again) — and the chat still renders the assistant's "done, I removed 14 silences" text plus its green tool-call summary chips, because LeftPanel.tsx:953-966 appends the message unconditionally. The only feedback is a toast blaming "the project changed" for something the user never did.

Minimal improvement — keep the document and make the toast actionable:

const pending = result.document;
toast.warning(t("chat.agentEditConflict"), {
  action: { label: t("chat.applyAnyway"), onClick: () => void applyAgentDocumentIfCurrent(pending) },
});

Better long-term would be re-running the turn against the fresh snapshot.

2. The guard closes the read window but not the write window

agentDocumentApply.ts:23-24 — the check happens before the write, but setDocument + saveDocument is not atomic.

projectStore.saveDocument (projectStore.ts:214-226) awaits nativeBridgeClient.aiEdition.save(document) and only then calls set({ document: parsed, revision: +1 }). So: guard passes → setDocument(agentDoc) repaints the timeline with the agent's cuts → the user immediately drags a clip (the moment they are most likely to react) → useTimeline calls its own saveDocument → both IPC calls are in flight independently → whichever set() resolves last wins in memory and on disk. The user's drag is silently reverted.

The PR narrows the window from "the whole agent turn" (seconds) to "one IPC + disk write" (tens of ms), which is a real improvement — but it is the same bug the PR is named after, just smaller. Cheapest fix inside the module is to re-check after the await. The structurally better version is to move the compare-and-swap into projectStore.saveDocument itself (optional expectedRevision arg), so useTimeline/transcriptionStore/captions all inherit it — or to route agent applies through the existing serialising queue in useSequentialTimelineOps.ts, whose file header documents precisely this "two concurrent calls both read the pre-edit doc and the second clobbers the first" race.

3. On a save failure, the UI shows the edits while the toast says they were rejected

agentDocumentApply.ts:23-24setDocument(parsed) mutates the store (and sets dirty: true) before saveDocument can throw.

Project file locked by another process, or disk full → saveDocument throws → LeftPanel.tsx:947-951 shows "Could not apply the agent's edits" → but the timeline is displaying the agent's edits anyway, dirty is true, and the next unrelated edit's saveDocument persists the agent's document to disk. The user was told it was rejected.

const prev = store.document;
store.setDocument(parsed);
try {
  await store.saveDocument(parsed);
} catch (e) {
  if (prev) store.setDocument(prev);
  throw e;
}

(Pre-existing ordering, but the new module is where it now lives.)

4. The rewind button undoes the protection the conflict just gave

LeftPanel.tsx:1026confirmRewind calls applyAgentDocument(doc) with no expectedRevision, deliberately. But it sits right next to the message that just conflicted: conflict toast appears → user clicks ↩ to retry the turn → chatRewind returns the checkpoint recorded before the turn (chat-service.ts:355-361) → the manual edit is overwritten, five seconds after the app told the user it was preserving it. The rewind popover copy doesn't warn that current work will be replaced.

Either pass the revision on that path too with a second confirmation, or amend the confirmation copy to say the current document will be replaced by the checkpoint.

Nits

  • LeftPanel.tsx:930-943expectedRevision is passed even when documentSnapshot is undefined. In that case runChat runs the agent against emptyDocumentForTextOnly(projectId) (chat-service.ts:401) carrying the real project id, so if the agent mutates it and the revision happens to match, a near-empty document gets written over a real project. I could not construct this today (loadProject sets projectId and document in the same set(), clear() nulls both), so purely defensive: if (result.document && documentSnapshot).
  • LeftPanel.tsx:876-879applyAgentDocument is a useCallback identity wrapper around an already-stable module-level function. Five lines of indirection plus a pointless entry in confirmRewind's dep array. Calling applyAgentDocumentIfCurrent(...) directly at both sites would be clearer.
  • agentDocumentApply.ts — the comment explaining why both setDocument and saveDocument are called did not survive the move. setDocument now looks redundant next to saveDocument (which also sets document), so the obvious "simplification" is to delete it — which silently breaks Ctrl+Z after an agent edit, since setDocument is the only thing that pushes the previous document onto the undo stack (projectStore.ts:228-236). Worth carrying that sentence over.
  • Extracting to a module was the right call despite the guard being ~3 lines — LeftPanel.tsx is 1976 lines with no test file, so this is what makes the tests possible at all.

One coverage gap worth knowing: the three tests exercise the module, but nothing pins the thing that would actually break. Move the documentRevision read below the await chatRun and all three still pass with the bug fully restored. A render test on ChatStripPanel, or extracting the send-and-apply sequence, would cover it.

@EtienneLescot
EtienneLescot force-pushed the codex/prevent-stale-agent-overwrites branch from 3a2255d to 17fa988 Compare August 20, 2026 11:46
@EtienneLescot

Copy link
Copy Markdown
Collaborator

Picking this up — @arhxam hasn't come back on the review. Pushed to the same branch, rebased onto main (183 commits behind; clean). #284 is still open and the race is still live.

§1 — a conflict discarded the whole turn. This was the one you most wanted addressed, and it's fixed. The document is kept and the toast carries an Apply anyway action, with no auto-dismiss since it's the only way back to it. Your point about who moves revision is what drove the shape: it's usually transcriptionStore finishing in the background, not the user, so throwing the turn away charges them a minute of waiting and their tokens for something they never did.

§2 — the write window. Deliberately not done here, and here's the coordination note: your §3 on #308 asks for the compare-and-swap's natural home, projectStore.saveDocument, and #308 now restructures exactly that function. Doing it in both would conflict. #308 owns it.

That also means these two PRs must merge in order. #308 makes saveDocument return a boolean instead of throwing. The rollback added below uses try/catch, so if #308 lands first that catch goes dead and the rollback stops firing. Whichever goes second needs ~3 lines adjusting in agentDocumentApply.ts — happy to do that rebase once the order is decided.

§3 — edits on screen while the toast said rejected. Fixed. The previous document is restored on a failed save, through setState rather than setDocument so the rejected document doesn't land on the undo stack, and dirty goes back to what it was — otherwise the next unrelated save persisted the document we had just said was rejected.

§4 — rewind undid the protection. The confirmation copy now says edits made since are replaced too, in all 13 locales. It sits one click from the conflict toast, so it had to stop saying the opposite.

Nits. The documentSnapshot === undefined hole is closed structurally (see below). The useCallback identity wrapper is gone — both sites call the module function directly. The "why both setDocument and saveDocument" sentence is carried over, since deleting setDocument as redundant is what silently breaks Ctrl+Z after an agent edit.

The coverage gap. You noted that moving the documentRevision read below the await left all three tests green with the bug fully restored. The send-and-apply pair now lives in runAgentTurn, which reads document and revision from one snapshot before awaiting the turn — so a test can hold a turn open, edit the store underneath it, and watch the apply refuse. That mutation now fails 2 tests. Text-only turns return "no-live-document" rather than relying on a revision match.

Verification. 1810 passed / 5 skipped; both typecheck configs, biome, i18n and docs clean. Removing the rollback fails 1 test.

arhxam and others added 2 commits August 20, 2026 15:08
Three things the conflict guard left open.

**The turn was thrown away.** On conflict the document was dropped on the
floor while the chat went on rendering the assistant's "done, I removed 14
silences" and its green tool-call chips, and the only feedback was a toast
blaming "the project changed". The thing that usually moves `revision`
mid-turn is not the user: `transcriptionStore` transcribes every asset
that lands in a document in the background and finishes with a save. So
importing a five-minute recording and asking for silences to be cut costs
a minute of waiting and the tokens, for something the user never did. The
document is kept now and the toast carries "Apply anyway", with no
auto-dismiss, because it is the only way back to it.

**A failed save left the edits on screen.** `setDocument` runs before
`saveDocument` can throw, so a locked project file showed the agent's
edits under a toast saying they had been rejected -- with `dirty` set, so
the next unrelated save persisted them. The previous document is restored
now, through `setState` so the rejected one does not enter the undo stack.

**Rewind quietly did the opposite of what the conflict just promised.**
The confirmation sits one click away from the conflict toast and replaces
the live document with the checkpoint, manual edit and all. Its copy said
only that the agent's turns would be rolled back. It now says the edits
made since are replaced too.

The send-and-apply pair moves into `runAgentTurn`, which is what makes the
guard testable: the revision has to be read from the same store snapshot
as the document and before the turn is awaited, and none of that was
visible at the call site, where the two are twenty lines apart in a
2,000-line component. Moving the read below the `await` restored the bug
in full with all three tests green. It now fails two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@EtienneLescot
EtienneLescot force-pushed the codex/prevent-stale-agent-overwrites branch from 17fa988 to 25f7c6a Compare August 20, 2026 13:17
@EtienneLescot
EtienneLescot merged commit d1e3319 into getopenscreen:main Aug 20, 2026
16 checks passed
EtienneLescot added a commit that referenced this pull request Aug 20, 2026
#308 and #309 merged twelve seconds apart and their contracts disagree.
`projectStore.saveDocument` returns a boolean and never rejects now, so
the `try`/`catch` #309 put around it went dead: nothing type-checks
wrong, no test in either PR could see it, and `main` went red on the one
test that could.

What shipped: an agent edit whose write failed stayed on screen with
`dirty` set, was never rolled back, and `applyAgentDocumentIfCurrent`
returned "applied" for a write that never happened. The next unrelated
save would then persist the document we had just told the user was
rejected -- the exact failure #309 exists to prevent, reintroduced by
landing its fix next to the one that changed the signature underneath it.

The check is on the returned `false` now, and "save-failed" is its own
result rather than being folded into "conflict": the two need different
words. A conflict means the project moved and the turn is still in hand,
so it offers "Apply anyway"; a failed write means the disk refused, and
retrying it would just fail again.

`LeftPanel` says what it cost without repeating why -- the store already
toasted the native error -- because the assistant's "done, I removed 14
silences" renders either way, and a bare save error next to it leaves the
two unconnected. Its `catch` stays for the one throw left on this path,
a malformed document from the agent, and no longer mislabels it a
conflict.

Both halves are pinned: removing the rollback fails the test, and so does
returning "applied" without checking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

[Bug]: edits made while the AI agent is running are overwritten when it answers

2 participants