Skip to content

[fix] A deleted young session no longer comes back on the next reconcile - #5830

Open
moataz-hjaiji wants to merge 3 commits into
Agenta-AI:mainfrom
moataz-hjaiji:fix/5543-deleted-session-resurrects
Open

[fix] A deleted young session no longer comes back on the next reconcile#5830
moataz-hjaiji wants to merge 3 commits into
Agenta-AI:mainfrom
moataz-hjaiji:fix/5543-deleted-session-resurrects

Conversation

@moataz-hjaiji

@moataz-hjaiji moataz-hjaiji commented Aug 9, 2026

Copy link
Copy Markdown

Summary

Fixes #5543 — a session deleted shortly after creation reappears, auto-titled and with its full content, on the next reconcile.

Fixes #5831 — deleting from Session History sends no backend request at all, so the session survives on other clients. Same gate, permanent rather than transient; see the scope note below (thanks @ardaerzin for spotting that these are the same root cause).

Root cause. The rail's delete only propagated to the server for a session the client had marked serverKnown:

if (projectId && target?.serverKnown) void deleteSessionRemote({sessionId: id, projectId})

But that flag lags the durable row. The row is created as soon as the first user message exists — autoTitleSessionAtomFamily calls setSessionHeader from an effect in AgentConversation.tsx — while serverKnown only flips on the next successful reconcile (staleTime: 30s, refetchInterval: 60s). Delete inside that window and the delete stayed local only: the server row survived, and because the id was no longer in the local list, the very next reconcile treated it as a session it had never seen and re-adopted it. That is also why the resurrected session comes back auto-titled — the server had the header all along.

This explains the report's "~69s" observation: the window is not a fixed 60s, it is "until the next reconcile actually runs".

And in some scopes it never runs at all. projectSessionsQueryAtomFamily is gated on isQueryableScope, which requires the scope key to be a real app UUID. The __global__, drawer:<entityId> and onboarding scope keys are not UUIDs, so the query is disabled, reconcileServerSessionsAtomFamily never fires, and serverKnown is never set on anything in those scopes. There the delete was local-only permanently — no DELETE request ever sent, session alive on every other client. That is #5831, and it is the same gate rather than a separate bug.

Fix. Two parts, because firing the request unconditionally is not sufficient on its own:

  1. Fire deleteSessionRemote for any session, not just a serverKnown one. This is safe: callFern swallows non-abort errors and returns null, so a 404 for a session the server never had logs and moves on — no unhandled rejection.
  2. Record the id in a per-scope tombstone set (deletedIdsByAppAtom, persisted alongside the other session atoms). The reconciler refuses to adopt a tombstoned id, and re-fires its delete until the server stops listing it. This closes the two windows an unconditional request alone leaves open:
    • the delete failed (offline / 5xx), so the row outlives it;
    • a server list fetched before the delete landed still carries the row.

Two details worth flagging for review:

  • The tombstone block runs before the existing if (!changed) return early-return in the reconciler. The steady state after a failed delete is "server list unchanged", which would otherwise skip the retry forever.
  • Tombstones prune against the server list, so the set is self-bounding in an app scope: an id the server never had clears on the first reconcile after the delete, and one that is successfully deleted clears as soon as the server stops listing it. In the non-queryable scopes above no reconcile runs, so nothing prunes there — but nothing can re-adopt there either, so the tombstone has nothing to guard and is bounded by how many sessions the user deletes in that scope. Left as-is rather than special-cased; the doc comment on the atom says so explicitly.

I also added one guard beyond the report: an id that is back in local history (a deep link re-adopts by id) drops its tombstone. Without it, a stale tombstone would keep re-deleting a session the user deliberately reopened.

Scope note: archiveSessionAtomFamily / unarchiveSessionAtomFamily carry the same serverKnown guard and have a related-but-distinct symptom (the optimistic archived flag is reverted by the next reconcile rather than the session being resurrected). I left them alone to keep this PR to the reported bug — happy to open a separate issue if you'd like.

Testing

Verified locally

Dev stack via ./hosting/docker-compose/run.sh --oss --dev --web-local, comparing main against this branch.

  • On main: create a session, send one message, delete it from the rail within ~30s, refresh — the session returns, auto-titled, with its content.
  • On this branch: same steps — it stays gone across a refresh, and across a second hard refresh (the tombstone is persisted, and the remote delete actually fires).

Added or updated tests

web/oss/src/components/AgentChatSlice/state/sessions.delete.test.ts — 5 tests using the real store, real atoms, and the real reconciler, with only the network boundary (@agenta/entities/session) stubbed:

I checked these are not vacuous:

  • reverting the fix fails 4 of the 5, including the repro (expected [ 'young-2' ] to deeply equal []);
  • the 5th passes trivially pre-fix, so I mutation-tested it — removing its !existingIds.has(id) guard makes it fail.

Full slice suite green: 19 test files, 137 tests passed. prettier, eslint, and tsc --noEmit clean on both files.

QA follow-up

  • Cross-device: delete a young session on device A, confirm it does not reappear on device B after its next reconcile.
  • Offline delete: delete with the network cut, restore it, confirm the retry lands and the session does not reappear.
  • Multi-scope: confirm the tombstone stays scoped (playground vs. the create/edit drawer's drawer:<entityId> scope).
  • Confirm localStorage growth is a non-issue in practice — pruning is per-reconcile, so a scope that never reconciles (e.g. a non-UUID scope where the query is disabled) retains its tombstones until it does.

Demo

https://www.loom.com/share/7e3497a1086c4ae2b399c1f377f09903

The recording compares main against this branch: create a session, send one message, delete it from the session-history popover within the pre-reconcile window, then refresh. On main the session returns, auto-titled; on this branch it stays gone.

Checklist

  • I have included a video or screen recording for UI changes, or marked Demo as N/A
  • Relevant tests pass locally
  • Relevant linting and formatting pass locally
  • I have signed the CLA, or I will sign it when the bot prompts me

The rail's delete only propagated to the server for a `serverKnown`
session, but that flag lags the durable row: the row exists from the
first message, while `serverKnown` flips only on the next successful
reconcile. Deleting inside that window deleted locally ONLY, so the very
next reconcile re-adopted the still-listed row as a brand-new server
session — auto-titled, full content. The delete visibly undid itself.

Fire the remote delete for any session, not just a `serverKnown` one,
and record the id in a per-scope tombstone set. The reconciler refuses to
adopt a tombstoned id and re-fires its delete until the server stops
listing it, which also covers the two windows an unconditional request
alone leaves open: a delete that failed (offline/5xx), and a server list
fetched before the delete landed. Tombstones prune against the server
list, so an id the server never had clears on the next reconcile and the
set cannot grow without bound.

Fixes Agenta-AI#5543
Copilot AI lite review requested due to automatic review settings August 9, 2026 15:49
@vercel

vercel Bot commented Aug 9, 2026

Copy link
Copy Markdown

@moataz-hjaiji is attempting to deploy a commit to the agenta projects Team on Vercel.

A member of the Team first needs to authorize it.

@CLAassistant

CLAassistant commented Aug 9, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes
    • Improved session deletion reliability, including before server confirmation.
    • Deleted sessions no longer reappear during synchronization.
    • Failed deletion requests are retried automatically until removal succeeds.
    • Intentionally restored sessions can be adopted again, while unrelated sessions continue syncing normally.
  • Tests
    • Added comprehensive coverage for deletion, retries, synchronization, restoration, and failure scenarios.

Walkthrough

Session deletion now persists per-scope tombstones, sends remote deletion requests for all sessions, retries failed deletions during reconciliation, and prevents tombstoned sessions from being re-adopted.

Changes

Session deletion lifecycle

Layer / File(s) Summary
Deletion tombstone state
web/oss/src/components/AgentChatSlice/state/sessions.ts
Session state now stores persisted, scope-keyed deletion tombstones.
Deletion request handling
web/oss/src/components/AgentChatSlice/state/sessions.ts, web/oss/src/components/AgentChatSlice/state/sessions.delete.test.ts
Deletion records a tombstone and sends a remote request without checking serverKnown. Request failures remain recoverable for later retries.
Reconciliation and validation
web/oss/src/components/AgentChatSlice/state/sessions.ts, web/oss/src/components/AgentChatSlice/state/sessions.delete.test.ts
Reconciliation retries pending deletions, clears settled or deliberately re-adopted tombstones, skips tombstoned sessions, and continues adopting unrelated sessions. Tests cover these cases.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant SessionStore
  participant RemoteDeleteAPI
  participant SessionServer
  participant Reconciler

  SessionStore->>SessionStore: record deletion tombstone
  SessionStore->>RemoteDeleteAPI: request session deletion
  RemoteDeleteAPI->>SessionServer: delete session
  Reconciler->>SessionServer: list sessions
  SessionServer-->>Reconciler: return server sessions
  Reconciler->>SessionStore: retry retained tombstoned deletions
  Reconciler->>SessionStore: skip tombstoned sessions
  Reconciler->>SessionStore: adopt unrelated sessions
Loading

Possibly related PRs

  • Agenta-AI/agenta#5479: Both changes update session deletion and reconciliation to prevent deleted sessions from reappearing.
  • Agenta-AI/agenta#5306: Both changes modify session lifecycle and deletion cleanup in sessions.ts.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Title check ✅ Passed The title clearly summarizes the primary fix: deleted sessions no longer return during reconciliation.
Description check ✅ Passed The description directly explains the session deletion bug, the tombstone-based fix, testing, and validation results.
Linked Issues check ✅ Passed The description identifies and explains the relationship between issues #5543 and #5831.
Out of Scope Changes check ✅ Passed The changes stay focused on durable session deletion and reconciliation without modifying the related archive behavior.
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Fixes a session-rail reconciliation bug where deleting a recently-created session could be undone on the next server reconcile because the delete was previously gated on a lagging serverKnown flag.

Changes:

  • Add a persisted per-scope “tombstone” set (deletedIdsByAppAtom) to prevent re-adoption of recently deleted sessions and to drive retry deletes during reconcile.
  • Fire deleteSessionRemote for deletes regardless of serverKnown, and retry deletes from the reconciler until the server no longer lists the session.
  • Add a dedicated vitest suite covering the regression and retry/tombstone behaviors.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
web/oss/src/components/AgentChatSlice/state/sessions.ts Adds tombstone persistence + reconcile behavior to prevent deleted sessions being re-adopted and to retry remote deletes.
web/oss/src/components/AgentChatSlice/state/sessions.delete.test.ts Adds regression + durability tests for delete behavior using real atoms/store with the network boundary mocked.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread web/oss/src/components/AgentChatSlice/state/sessions.ts
Comment thread web/oss/src/components/AgentChatSlice/state/sessions.delete.test.ts Outdated
Addresses review feedback: the `...(args as [])` spread made the mock look
zero-arity and skipped type checking of the call payload.
@dosubot dosubot Bot added size:M This PR changes 30-99 lines, ignoring generated files. bug report Something isn't working frontend tests labels Aug 9, 2026
Copilot AI review requested due to automatic review settings August 9, 2026 15:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

Suppressed comments (2)

web/oss/src/components/AgentChatSlice/state/sessions.ts:117

  • Doc comment uses "Ids"; project style elsewhere typically uses the acronym "IDs", and the current phrasing reads a bit awkwardly. Consider updating to "IDs of sessions…" for clarity.
 * Ids the user deleted whose server row may still be listed — a tombstone set, per scope.

web/oss/src/components/AgentChatSlice/state/sessions.delete.test.ts:54

  • These tests exercise atoms backed by atomWithStorage with getOnInit: true, so they can rehydrate prior localStorage state. Because the scopes are fixed strings (e.g. "delete-retry"), reruns in watch mode can become order-dependent/flaky if prior runs left data behind. Clearing the relevant localStorage keys in beforeEach (or generating unique scope keys) will make the suite deterministic.
beforeEach(() => {
    deleteSessionRemote.mockClear()
})

@ardaerzin

Copy link
Copy Markdown
Contributor

Reviewed this against the code — the fix is right, and I want to flag two things for whoever merges it.

1. This also closes #5831, not just #5543.

The PR frames the bug as the window between "durable row exists" and "next reconcile stamps serverKnown". That is the common case, but the gate fails permanently in some scopes. serverKnown is only ever set by reconcileServerSessionsAtomFamily, which only runs when the session-list query is enabled — and that query requires the scope key to be a real app UUID:

// state/projectSessions.ts
const isQueryableScope = (appId: string): boolean => Boolean(appId) && isValidUUID(appId)

So for __global__, drawer:<entityId> (drawerScopeKey) and onboarding (ONBOARDING_SCOPE_KEY), no reconcile ever runs, serverKnown is never set on anything, and delete was local-only forever — not just for a minute. That matches the #5831 report ("no DELETE request is sent", session persists on other clients) better than a timing window does. Worth adding Fixes #5831 alongside Fixes #5543.

Note this also means the tombstone set in those scopes is never pruned (pruning happens in the reconciler, which never runs there). It is bounded by how many sessions a user deletes in a drawer, so not a blocker — but it never self-clears there, unlike in an app scope.

2. archiveSessionAtomFamily / unarchiveSessionAtomFamily still carry the identical gate.

Agreed with the scope call in the PR body — the symptom is different enough (the optimistic archived flag gets reverted by the next reconcile, rather than the session resurrecting) that it deserves its own change. Flagging it here so it does not get lost when this merges; happy to open a follow-up issue.

One detail I want to explicitly endorse: .catch(() => {}) rather than void on the fire-and-forget deletes is correct and not cosmetic. callFern rethrows aborts (if (isAbortError(error)) throw error), so a bare void here would be an unhandled rejection rather than a swallowed failure.

Review feedback: the non-UUID scopes (__global__, drawer:<id>, onboarding)
never run a reconcile, so serverKnown was never set there and the delete
stayed local permanently rather than for one poll cycle (Agenta-AI#5831). Those
scopes also never prune tombstones — harmless, since nothing re-adopts
there, but the comment claimed pruning bounds the set unconditionally.
Copilot AI review requested due to automatic review settings August 9, 2026 22:57

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.

@moataz-hjaiji

moataz-hjaiji commented Aug 9, 2026

Copy link
Copy Markdown
Author

Thanks @ardaerzin — this is a better characterisation than mine.

Verified point 1 against the code and you are right: isQueryableScope requires a UUID, and __global__, drawer:<entityId> (drawerScopeKey) and ONBOARDING_SCOPE_KEY are none of them, so those scopes never reconcile, serverKnown is never set, and the delete was local-only permanently rather than for one poll cycle. That describes #5831 far better than a timing window does. Added Fixes #5831 and reworked the root-cause section to lead with the permanent case.

Took the tombstone-pruning note too — my comment claimed pruning bounds the set unconditionally, which only holds in a reconciling scope. Corrected in e4dd360: in the non-queryable scopes nothing prunes, but nothing can re-adopt there either, so the tombstone has nothing to guard and is bounded by how many sessions the user deletes in that scope. I left the behaviour alone rather than special-casing the scope check into this atom, since you called it a non-blocker — say the word if you would rather it skipped tombstoning entirely when the scope is not queryable.

On point 2: opened #5861 for the archive/unarchive gate so it does not get lost. Happy to reframe or close it if you had a different shape in mind.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug report Something isn't working frontend size:M This PR changes 30-99 lines, ignoring generated files. tests

Projects

None yet

4 participants