fix(sessions): spend each write marker only on the tool that wrote it

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KcyBRxE8j3ujCYkum9KrC4
This commit is contained in:
Diego Imbert
2026-09-05 21:44:07 +02:00
co-authored by Claude Opus 5
parent c9c3b23585
commit 947fd6b1cb
4 changed files with 41 additions and 2 deletions
@@ -2160,6 +2160,27 @@ describe('global AI tools', () => {
expect(UserDraft.takeDraftOnlyDiscard('resource', path, { workspace: WORKSPACE })).toBe(true)
})
// Nothing consumes a marker outside a session, so one can outlive the item it
// was about. Recreating the item must void it, or the next action on the
// recreated item reads it and sends a perfectly valid editor back to its list.
it('voids a draft-only removal marker when the item is written again', async () => {
const path = 'f/resources/discard-then-recreate'
await callGlobalTool('write_resource', {
path,
resource_type: 'postgresql',
value: { host: 'localhost' }
})
await callGlobalTool('discard_local_draft', { type: 'resource', path })
// The marker is standing (no listener consumed it), and now the item is back.
await callGlobalTool('write_resource', {
path,
resource_type: 'postgresql',
value: { host: 'elsewhere' }
})
expect(UserDraft.takeDraftOnlyDiscard('resource', path, { workspace: WORKSPACE })).toBe(false)
})
// "Create a resource, then never mind": delete_workspace_item must reject a path
// that was never deployed, before the confirmation card — otherwise the user
// confirms a workspace mutation that 404s past the draft cleanup, leaving the
@@ -421,6 +421,9 @@ export async function persistGlobalDraft(
const itemKind = itemKindFor(type, opts.triggerKind)
if (!itemKind) throw new Error(`Unsupported draft type "${type}".`)
const storagePath = resolveDraftStoragePath(workspace, itemKind, path)
// Writing the item back means it exists again, so any removal marker left from
// an earlier draft-only discard is void.
UserDraft.clearDraftOnlyDiscard(itemKind, storagePath, { workspace })
UserDraft.seed(itemKind, storagePath, value, { workspace })
await UserDraftDbSyncer.save({
workspace,
+7
View File
@@ -442,6 +442,13 @@ export const UserDraft = {
noteMarker(draftOnlyDiscards, mapKey(resolveWorkspace(opts), itemKind, path))
},
/** Drop any removal marker for this cell — the item exists again, so a marker
* still standing (nothing consumed it, because nothing was listening) would be
* read by whatever touches it next and send its editor away. */
clearDraftOnlyDiscard(itemKind: UserDraftItemKind, path: string, opts?: UserDraftOptions): void {
draftOnlyDiscards.delete(mapKey(resolveWorkspace(opts), itemKind, path))
},
/** Whether the last discard for this cell removed the item outright (see
* {@link recordDraftOnlyDiscard}), clearing the record. */
takeDraftOnlyDiscard(
@@ -625,12 +625,20 @@
// editor still loading holds none yet and the seed no-opped past it. The
// miss is recorded when it happens — asking now would be too late, since
// the editor can have acquired the cell during the write's round-trip.
// Each marker is read only by the kind of tool that writes it. A marker
// left unread — nothing consumes them outside a session — would otherwise
// be spent by whatever touched the item next, and a removal marker read by
// a deploy sends a live editor away.
const kind = path ? entityKindForPage(pages[0]) : undefined
const seedReachedEditor = !kind || !path || !UserDraft.takeSeedMiss(kind, path, { workspace })
const readable = !!kind && !!path
const seedReachedEditor =
!readable || entity !== 'none' || !UserDraft.takeSeedMiss(kind!, path!, { workspace })
// A discard of a draft-only item removed the item itself, so its editor has
// nothing left to re-read and must leave instead.
const itemSurvives =
!kind || !path || !UserDraft.takeDraftOnlyDiscard(kind, path, { workspace })
!readable ||
name !== 'discard_local_draft' ||
!UserDraft.takeDraftOnlyDiscard(kind!, path!, { workspace })
const effect = effectForDiscard(effectForWrite(entity, seedReachedEditor), itemSurvives)
if (effect !== 'none') pendingMutations.push({ pages, effect, path, workspace })
clearTimeout(reloadHandle)