fix: credit only a row the live item actually wrote

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-15 19:59:29 +02:00
co-authored by Claude Opus 5
parent d53075edef
commit 8f70a64d50
2 changed files with 34 additions and 1 deletions
+7 -1
View File
@@ -249,6 +249,9 @@ class Entry<V> {
* each, and each row a caller says it wrote for one of them. A row left over after that is a
* row nobody here has the value of. */
private values = 0
/** Whether the last value handed in made this entry write a row of its own. Loading, it
* cannot: the caller's own row is then the only one, and is already counted. */
private seedWroteRow = false
private queue: Promise<unknown> = Promise.resolve()
private ports: ItemRowPort
private store: StoreInternals
@@ -528,18 +531,21 @@ class Entry<V> {
* rather than leaving the row to this entry: the two are one value, and counting the second
* row as unaccounted would read as someone else having written it. */
noteRow(): void {
this.values++
if (this.seedWroteRow) this.values++
}
applyExternal(value: V): number {
this.externals++
this.values++
this.seedWroteRow = false
if (this.loaded && serialize(value) === serialize(this.value)) return this.revision
this.edits++
this.pristine = false
this.removed = false
this.replaceValue(value)
const rowsBefore = this.ports.rowMark(this.key)
this.reconcile()
this.seedWroteRow = this.ports.rowMark(this.key) !== rowsBefore
return this.revision
}
+27
View File
@@ -906,6 +906,33 @@ describe('item store: origins', () => {
expect(rows.writes.at(-1)).toEqual({ path: 'u/me/r', value: typed })
})
it('is not fooled by the chat crediting a row a loading item never wrote', async () => {
const rows = fakeRows()
const read = deferred<ItemLoad<Res>>()
const store = createItemStore(rows.port)
const key: ItemKey = { workspace: 'w', kind: 'resource', path: 'u/me/r' }
const { handle: item } = store.acquire(
key,
{ workspace: 'w', path: 'u/me/r' },
adapter(() => read.promise)
)
await settle()
// The chat seeds and persists while the first GET is out. Loading, this entry writes no
// row of its own, so the chat's save is the only row that value produced.
item.applyExternal({ ...deployedRes, description: 'from the chat' })
store.bridge.noteRow('w', 'resource', 'u/me/r')
rows.handExternally('u/me/r')
// The legacy agent editor then writes the same resource: a row nobody here has.
rows.handExternally('u/me/r')
read.resolve({ deployed: deployedRes })
await settle()
// Two rows, one of them unaccounted for. Crediting the chat twice would hide it and let
// this editor post over the agent editor's newer draft.
expect(item.canSave).toBe(false)
})
it('stays blocked when an unseen row follows the edit it kept', async () => {
const rows = fakeRows()
const read = deferred<ItemLoad<Res>>()