feat: delete a browser's copy of an AI session past its workspace retention (#11156)

* feat: delete a browser's copy of an AI session past its workspace retention

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: tell the AI session retention only to a member who can reach the workspace

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: keep the retention sweep's design narrative in the docs, not the code

* fix: give the session retention its own route, leaving the status contract alone

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: shorten the retention route comment to its constraints

* docs: name the two clocks in the retention setting, and the deploy window

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-09-16 09:57:43 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent ee6d317e31
commit a48ae656ae
17 changed files with 718 additions and 55 deletions
+79 -25
View File
@@ -240,32 +240,86 @@ the first push after it or on the next page load, whichever comes first.
`ai_config.sessions_retention_days` (per workspace, in the AI settings; unset by default;
the `sessions_storage_disabled` pattern: no migration, carried by settings export and the
CLI; 1 to 3650) puts an age on backups, counted from the last push of the session that
completed. It applies to the backup only: a browser keeps its copy whatever the retention,
and a backup swept while a browser still has the session comes back once that browser writes
to it again (its incremental push is refused and goes whole).
CLI; 1 to 3650) puts an age on sessions, counted from their last activity. Each side applies
it with its own clock against its own timestamps, so no clock is compared with another
machine's, and the two do not time the same event: the server counts the last push that
completed, a browser its last local activity, which includes reading new messages and is not
pushed. A backup swept while a browser still reads its copy comes back once that browser
writes to the session again (its incremental push is refused and goes whole):
The server sweeps the object store (`sweep_expired_ai_session_backups`, from the monitor about
every 40 minutes on each server, one pass at a time under a session-level advisory lock). For
every workspace with a retention it takes the store its backups live in, its own storage or
the instance store standing in, decided from the row it reads the generation from as the
routes do, names the users under the generation prefix (`list_with_delimiter`) and lists each
user's `index/` once: one object per session, nothing of what the sessions hold. A session
whose marker is older than the retention is removed under its lock (`lock_session`), once its
markers, listed again there, are still all older: a push that renewed the session between the
walk and the lock keeps it, and one split over parts either holds the lock or has the session
unlisted with its token next to the markers (`index/{sid}/push`), which the sweep leaves
alone while the token is younger than the retention: an older one is a push a browser
abandoned, whose landed parts nothing lists, and it goes the same way. Before deleting
anything the sweep writes a record next to the markers (`index/{sid}/sweep`, not an epoch, so
neither `list` nor `pull` counts it), and `remove_session` deletes it last: a removal cut
short, its markers already gone, is found by the next pass and finished, unless a push listed
the session again first. At most 1000 sessions per workspace and pass; the rest wait for the
next. `list` leaves an expired marker out of its answer meanwhile, so a browser never restores
a session the sweep has not reached. The marker's modification time is the storage's clock and
the cutoff the server's. The sweep reaches only the backups the routes would: a deleted
workspace's stay in its storage, and so do those a workspace keeps in the instance store once
`ai_sessions_instance_storage_fallback` is set to false.
- The server sweeps the object store (`sweep_expired_ai_session_backups`, from the monitor
about every 40 minutes on each server, one pass at a time under a session-level advisory
lock). For every workspace with a retention it takes the store its backups live in, its
own storage or the instance store standing in, decided from the row it reads the
generation from as the routes do, names the users under the generation prefix
(`list_with_delimiter`) and lists each user's `index/` once: one object
per session, nothing of what the sessions hold. A session whose marker is older than the
retention is removed under its lock (`lock_session`), once its markers, listed again
there, are still all older: a push that renewed the session between the walk and the lock
keeps it, and one split over parts either holds the lock or has the session unlisted with
its token next to the markers (`index/{sid}/push`), which the sweep leaves alone while the
token is younger than the retention: an older one is a push a browser abandoned, whose
landed parts nothing lists, and it goes the same way. Before deleting anything the sweep
writes a record next to
the markers (`index/{sid}/sweep`, not an epoch, so neither `list` nor `pull` counts it),
and `remove_session` deletes it last: a removal cut short, its markers already gone, is
found by the next pass and finished, unless a push listed the session again first. At
most 1000 sessions per workspace and pass; the rest wait for the next. `list` leaves an
expired marker out of its answer meanwhile, so a browser never restores a session the
sweep has not reached. The marker's modification time is the storage's clock and the
cutoff the server's. The sweep reaches only the backups the routes would: a deleted
workspace's stay in its storage, and so do those a workspace keeps in the instance store
once `ai_sessions_instance_storage_fallback` is set to false.
- The browser sweeps its own stores when a tab resolves the logged-in user
(`sweepExpiredSessions`, from the one `onUserChange` in `sessionState.svelte.ts`), before
that tab reads a single session. A session whose last activity is older than the retention
by the browser's clock is deleted locally, record, chats, images, attached files and
artifacts. A restored session carries the backup's time as its last activity, the storage's
clock, so it counts from the later of that and the moment it was restored here
(`restoredAt`): a browser clock ahead of the storage's never deletes a session it just
brought back. Archived sessions count like any other, and persisted unsent drafts by their
pending workspace.
The stores are shared by the user's tabs, and each keeps copies of the sessions in memory,
so every tab holds a shared Web Lock from before it reads them until it stops using them,
and the sweep deletes only while holding that lock exclusively, requested if available:
granted exactly when no tab of the user has the sessions loaded, which is why the sweep
runs where it does and nowhere else. Nothing holds a copy of what it deletes and nothing
writes the stores meanwhile, so it deletes one record at a time and without re-reading. It
also takes the tab lock the flush and the restore take, again only if available, so neither
plans nor stages a session half deleted; like the restore, it does not run where Web Locks
do not exist. With several tabs open nothing is swept, until one of them reloads alone.
The hold is only as good as the tabs that take it, so a tab still running a build from before
it has the sessions loaded and holds nothing. A tab loaded after that one, across a deploy,
can sweep a session the older tab has in memory, and a write there afterwards brings the
record back without its chats, which the next flush pushes. It needs a tab left open across a
deploy, a session untouched for the whole retention, and the user going back to that session
in the older tab; the next sweep deletes it again. The same window is open to the
workspace-lifecycle delete in `reconcileSessionsLifecycle`, which no lock guards at all.
What deletes is the retention the server gives as the sweep runs, asked for under both locks
(`POST /workspaces/session_workspace_retention`, its own route rather than a field on the
lifecycle status, whose answer a tab loaded before this version still reads). Never a
remembered one: a retention raised or cleared since would otherwise delete a session that is
now within it, and a persisted unsent draft has no backup to come back from. What the sweep
keeps in localStorage decides only whether to ask again — it asks when it has asked nothing
yet, when the answer it has is a day old, or when that answer marks a session expired — so
an ordinary load costs no request at all. An answer that does not arrive within five seconds
leaves the sessions for the next load rather than delete on what this browser guessed. That
route answers for a workspace the caller can be authed into, unlike the status: a status is
what to do with the caller's own sessions, a setting is the workspace's to tell, so a
disabled membership is told nothing though its sessions still reconcile.
Each session's record goes before its pieces, so nothing plans a push for it afterwards,
and a localStorage key written before the record and removed once every piece is gone makes
a later sweep finish a deletion that failed, unless a restore brought the session back
since. The record is deleted without the tombstone a user delete leaves, which is what lets
a restore bring it back. The session's dirty mark and sync row go with it (`sessionSwept`),
unless the row still carries a removal or a restore's staging. Nothing is sent to the storage: the local
copy's age says nothing about another device's, which may have pushed the session since,
and the server applies the rule to the backup on its own. A session swept here that the
storage still lists comes back on the next restore.
## Limits