Skip to content

Removing a session should require it to still be archived #3050

Description

@Astro-Han

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions