Correction (2026-08-15): the original analysis was wrong about where the atomicity is missing. The Host already decides this in one step; the defect is in the desktop client's retry loop. Rewritten accordingly.
Problem
Deleting a task that is concurrently restored — from a second window or another client — deletes it permanently anyway, and the caller is told it was removed.
Found by @M4n5ter reviewing #2985, reproduced deterministically by restoring the second task after the first removal completed.
What already works
The compare-and-set that should prevent this is present end to end:
session-retirement-coordinator.ts #remove compares target.revision !== input.expectedRevision inside the #withStableFamily admission lock and returns revision_conflict.
sqlite-session-metadata-store.ts removeVersioned re-checks metadataVersion inside the same SQL transaction as the DELETE.
setLifecycleVersioned writes through updateHeaderSync, so unarchiving bumps metadataVersion.
A concurrent restore therefore makes the first remove fail. Runtime Host is not missing a precondition, and session.remove does not need a new one.
Where it breaks
apps/desktop/src/main/runtime-host-client.ts, removeSession:
for (let attempt = 0; attempt < MAX_SESSION_REVISION_ATTEMPTS; attempt += 1) {
const current = await this.#requireSession(sessionId);
const result = await this.request("session.remove", { sessionId, expectedRevision: current.revision });
if (result.kind === "removed") return;
}
The conflict is treated as a stale read and the delete is replayed with the fresh revision, up to 8 times. Replay is right for rename and configuration writes — the write means the same thing at any revision. It is wrong for remove: the conflict is precisely the signal that the task was touched after the caller decided to destroy it, and the replay carries out a destruction whose premise no longer holds. The loop also widens the window from "between two calls" to "the whole retry".
Direction
Lifecycle writes always bump metadataVersion, and the version check and the DELETE share one transaction. So "read as archived at revision R" plus "removed with expectedRevision R" implies "still archived when deleted" — the loop only has to re-assert the precondition on each re-read, not carry a new one over the wire.
removeSession should stop replaying blindly: on each attempt, give up if the re-read session is no longer archived, and return an outcome distinguishable from a failure. purgeSessions then reports "restored meanwhile" separately from "failed", counting it as neither removed nor errored.
This stays in apps/desktop, outside the Runtime Host protected area. The earlier proposal — an expected lifecycle on session.remove, validated by the retirement coordinator — is over-built given the CAS already there, and is dropped.
Open question: whether the single-row delete path, which shares removeSessionFamily, wants the same treatment or is adequately covered by its own confirm.
Impact
Permanent data loss, but it needs concurrent activity: the purge page disables its own controls while a sweep runs, so the trigger is a second window or another client. Not blocking anything currently in flight.
Problem
Deleting a task that is concurrently restored — from a second window or another client — deletes it permanently anyway, and the caller is told it was removed.
Found by @M4n5ter reviewing #2985, reproduced deterministically by restoring the second task after the first removal completed.
What already works
The compare-and-set that should prevent this is present end to end:
session-retirement-coordinator.ts#removecomparestarget.revision !== input.expectedRevisioninside the#withStableFamilyadmission lock and returnsrevision_conflict.sqlite-session-metadata-store.tsremoveVersionedre-checksmetadataVersioninside the same SQL transaction as theDELETE.setLifecycleVersionedwrites throughupdateHeaderSync, so unarchiving bumpsmetadataVersion.A concurrent restore therefore makes the first remove fail. Runtime Host is not missing a precondition, and
session.removedoes not need a new one.Where it breaks
apps/desktop/src/main/runtime-host-client.ts,removeSession:The conflict is treated as a stale read and the delete is replayed with the fresh revision, up to 8 times. Replay is right for rename and configuration writes — the write means the same thing at any revision. It is wrong for remove: the conflict is precisely the signal that the task was touched after the caller decided to destroy it, and the replay carries out a destruction whose premise no longer holds. The loop also widens the window from "between two calls" to "the whole retry".
Direction
Lifecycle writes always bump
metadataVersion, and the version check and theDELETEshare one transaction. So "read as archived at revision R" plus "removed withexpectedRevisionR" implies "still archived when deleted" — the loop only has to re-assert the precondition on each re-read, not carry a new one over the wire.removeSessionshould stop replaying blindly: on each attempt, give up if the re-read session is no longer archived, and return an outcome distinguishable from a failure.purgeSessionsthen reports "restored meanwhile" separately from "failed", counting it as neither removed nor errored.This stays in
apps/desktop, outside the Runtime Host protected area. The earlier proposal — an expected lifecycle onsession.remove, validated by the retirement coordinator — is over-built given the CAS already there, and is dropped.Open question: whether the single-row delete path, which shares
removeSessionFamily, wants the same treatment or is adequately covered by its own confirm.Impact
Permanent data loss, but it needs concurrent activity: the purge page disables its own controls while a sweep runs, so the trigger is a second window or another client. Not blocking anything currently in flight.