[feat] Durable cross-device agent session list (archive, ended, titles) - #5479
Conversation
A kill/archive soft-deletes the session_streams row, but the full unique constraint on (project_id, session_id) keeps the tombstone in the slot. On the next turn _start_turn read None, create hit the slot, and update matched nothing, leaving the durable row dead while Redis went alive — so a resumed session vanished from the list and fetch/heartbeat returned None. Detect the no-match update and revive that same row (unarchive + re-flag), preserving its id and header across the kill boundary.
Add querySessions (POST /sessions/query) + extend the stream schema with the
header (name/description) and lifecycle timestamps, so the durable session list
is reachable from the FE. Add projectSessionsQueryAtomFamily(appId), mirroring the
liveness query: one low-priority, focus/interval-revalidated query per agent,
scoped by references [{id: appId}] against the turns' workflow refs. Not yet wired
into the sidebar — the reconciler that folds this over the localStorage list follows.
Kill soft-deletes the stream row, so the default query (deleted_at IS NULL) drops killed-but-resumable sessions from the list — under STOP_KILLS_SESSION that's most of them. Add include_ended to SessionQuery/SessionStreamQuery so the durable list keeps ended sessions (deleted_at populated for the caller to mark them). Absence from the list then means genuinely hard-deleted, which the FE uses to prune its cache.
Fold the agent's /sessions/query result into sessionsByAppAtom: adopt sessions the server knows and we don't (cross-device, and recovers a wiped localStorage), enrich title/createdAt (a local user title wins), and drop a session the server dropped — server-confirmed before, absent now = hard-deleted elsewhere — never a purely-local optimistic one (tracked via a new serverKnown flag; also exempts it from husk pruning). Open tabs/active stay per-device. Gated on query success so a failed fetch never drops.
renameSession only wrote the local cache, so a title never left the browser. Add setSessionHeader (PUT /sessions/streams/header) and fire it from renameSession — optimistic local update, best-effort server write. Combined with the reconciler adopting the server name, a rename now round-trips across devices and survives a localStorage wipe (creates the stream row if the rename precedes the first run).
Delete was localStorage-only, so a delete never left the browser. Add deleteSession (DELETE /sessions/, the root hard-delete) and fire it from deleteSessionAtomFamily for server-known sessions only (a local husk has no row; the reconciler's own drop path never routes here, so no loop) — a delete now propagates to every device. And surface killed/ended sessions: include_ended already lists soft-deleted rows, so carry an 'ended' flag (deleted_at) through the reconciler and show a muted 'Ended' chip in the rail and history popover. (Kill and archive both set deleted_at on this backend, so an archived session also reads as ended until the backend distinguishes them.)
Archive and kill both set deleted_at, so the durable list couldn't tell a killed-but- resumable session from a deliberately-hidden one. Add an archived_at column (migration 018) and repoint archive/unarchive to it: archive sets archived_at, unarchive clears it, kill's deleted_at is untouched (so BE-1 revive is unaffected). The list hides archived_at rows by default; a new include_archived surfaces them for an archived view — orthogonal to include_ended. Updates the root-service archive tests to the new semantics.
Adds an archive action to the session rail and history popover, backed by the new archived_at server state. The durable list query now includes archived rows so the reconciler carries an `archived` flag rather than mistaking an archived session for a hard-delete and pruning it; the flag hides the row from the main history/tabs and surfaces it under a collapsible "Archived" section with restore + delete. Archive/unarchive fire the server archiveSession/unarchiveSession for server-known sessions so the state syncs across devices, and stay purely local otherwise.
Regenerated the TS client from the #5436 backend OpenAPI spec so `SessionQueryRequest` carries `include_ended`/`include_archived` and `SessionStream` carries `archived_at` as first-class typed fields, and dropped the runtime cast on `querySessions` that stood in for them. Pulls in the rest of the #5436 sessions/mounts drift the checked-in client predated (SessionTurn `turn_id`, a SessionTurnComplete request, the mount file-order enum). No new tsc error signatures introduced (verified against the pre-regen baseline).
_start_turn only cleared deleted_at (the killed-tombstone revive), so starting a turn on an archived-but-not-killed session updated its flags to alive/running while archived_at stayed set — a live session absent from the default list. Now a live row that carries archived_at is un-hidden on new activity (clears only when actually archived, so the hot path is untouched for the common case).
The durable list filters archived_at per project (the default list excludes it, the archived view selects it), so back that predicate with an index instead of leaning on the created_at index alone. Mirrors ix_session_streams_project_id_created_at; migration 019.
A session started in another tab/device showed as "Untitled chat" in the durable list until opened, because the list label comes only from the server header `name` (set on explicit rename) and an adopted session has no local messages to derive a label from. Now the first user message auto-titles the session (capped, synced to the durable header via setSessionHeader), so the list is labeled everywhere before it's opened. The write atom no-ops once the session already has a title, so it fires at most once and never overwrites an explicit rename.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
📝 WalkthroughWalkthroughThe PR adds a persistent ChangesSession archiving
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant AgentChatPanel
participant useReconcileServerSessions
participant querySessions
participant SessionHistoryMenu
AgentChatPanel->>useReconcileServerSessions: reconcile scope
useReconcileServerSessions->>querySessions: fetch project sessions
querySessions-->>useReconcileServerSessions: validated session summaries
useReconcileServerSessions->>SessionHistoryMenu: update local session state
SessionHistoryMenu->>querySessions: archive or unarchive session
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
|
@coderabbitai review |
✅ Action performedReview finished.
|
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
api/oss/src/core/sessions/streams/service.py (1)
497-511: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy liftSerialize archive and turn-start transitions.
A concurrent archive can commit after the flag update but before the stale
updated.archived_atcheck, leaving a running session hidden from the default list. A concurrent hard delete can likewise leave the revive retry empty while this method still returns a turn ID. Make archive/start/revive one per-session atomic transition (or use a shared lock), and fail or retry unless the final row update succeeds.Also applies to: 580-612
🧹 Nitpick comments (1)
web/oss/src/components/AgentChatSlice/state/sessions.ts (1)
1-13: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winRemove or wire
routerAppIdAtominto the scoped session mutations.
routerAppIdAtomis unused after the import, whilesetSessionHeader/archive/unarchive/delete calls here, pluskillSessioncallers, only passsessionId/projectId. If these operations are meant to be app-scoped, use the current scope atom in these remote calls instead of leaving it unused here.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 758abb71-75b2-4b7f-8a81-46e68c99c81f
⛔ Files ignored due to path filters (15)
web/packages/agenta-api-client/src/generated/api/resources/index.tsis excluded by!**/generated/**web/packages/agenta-api-client/src/generated/api/resources/mounts/client/Client.tsis excluded by!**/generated/**web/packages/agenta-api-client/src/generated/api/resources/mounts/client/requests/GetMountFilesRequest.tsis excluded by!**/generated/**web/packages/agenta-api-client/src/generated/api/resources/mounts/index.tsis excluded by!**/generated/**web/packages/agenta-api-client/src/generated/api/resources/mounts/types/GetMountFilesRequestOrder.tsis excluded by!**/generated/**web/packages/agenta-api-client/src/generated/api/resources/mounts/types/index.tsis excluded by!**/generated/**web/packages/agenta-api-client/src/generated/api/resources/sessions/client/Client.tsis excluded by!**/generated/**web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionQueryRequest.tsis excluded by!**/generated/**web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionTurnAppendRequest.tsis excluded by!**/generated/**web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/SessionTurnCompleteRequest.tsis excluded by!**/generated/**web/packages/agenta-api-client/src/generated/api/resources/sessions/client/requests/index.tsis excluded by!**/generated/**web/packages/agenta-api-client/src/generated/api/types/MountFile.tsis excluded by!**/generated/**web/packages/agenta-api-client/src/generated/api/types/SessionStream.tsis excluded by!**/generated/**web/packages/agenta-api-client/src/generated/api/types/SessionTurn.tsis excluded by!**/generated/**web/packages/agenta-api-client/src/generated/api/types/index.tsis excluded by!**/generated/**
📒 Files selected for processing (23)
api/oss/databases/postgres/migrations/core_oss/versions/oss000000018_add_session_streams_archived_at.pyapi/oss/databases/postgres/migrations/core_oss/versions/oss000000019_index_session_streams_archived_at.pyapi/oss/src/apis/fastapi/sessions/models.pyapi/oss/src/apis/fastapi/sessions/router.pyapi/oss/src/core/sessions/dtos.pyapi/oss/src/core/sessions/service.pyapi/oss/src/core/sessions/streams/dtos.pyapi/oss/src/core/sessions/streams/interfaces.pyapi/oss/src/core/sessions/streams/service.pyapi/oss/src/dbs/postgres/sessions/streams/dao.pyapi/oss/src/dbs/postgres/sessions/streams/dbes.pyapi/oss/src/dbs/postgres/sessions/streams/mappings.pyapi/oss/tests/pytest/unit/sessions/test_resume_after_kill_revives_tombstone.pyapi/oss/tests/pytest/unit/sessions/test_sessions_root_service.pyweb/oss/src/components/AgentChatSlice/AgentChatPanel.tsxweb/oss/src/components/AgentChatSlice/AgentConversation.tsxweb/oss/src/components/AgentChatSlice/components/SessionHistoryMenu.tsxweb/oss/src/components/AgentChatSlice/components/SessionRail.tsxweb/oss/src/components/AgentChatSlice/state/projectSessions.tsweb/oss/src/components/AgentChatSlice/state/sessions.tsweb/packages/agenta-entities/src/session/api/api.tsweb/packages/agenta-entities/src/session/core/schema.tsweb/packages/agenta-entities/src/session/index.ts
| """A resume after a STOP_KILLS_SESSION kill (or archive) must re-nest the durable row. | ||
|
|
||
| Kill/archive soft-deletes the `session_streams` row (`deleted_at`). The unique constraint on | ||
| `(project_id, session_id)` is full, so the tombstone keeps occupying the slot: on the next turn | ||
| `_start_turn` reads `None` (get filters `deleted_at IS NULL`), `create` hits the unique slot -> | ||
| `SessionStreamAlreadyExists`, and the follow-up `update` (also `deleted_at IS NULL`) matches | ||
| nothing -> no-op. Without a revive, Redis goes alive but the durable row stays a dead tombstone, | ||
| so the session vanishes from the list and `fetch`/`heartbeat` return `None`. `_start_turn` must | ||
| detect the no-match update and revive that same row (clear `deleted_at`) so resume re-nests it. |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Correct the archive lifecycle description.
The docstring still says archive soft-deletes the row via deleted_at, but this change makes archive preserve deleted_at and set archived_at; only kill creates the tombstone.
Proposed wording update
-"""A resume after a STOP_KILLS_SESSION kill (or archive) must re-nest the durable row.
+"""A resume after a STOP_KILLS_SESSION kill must re-nest the durable row.
-Kill/archive soft-deletes the `session_streams` row (`deleted_at`).
+Kill soft-deletes the `session_streams` row (`deleted_at`); archive keeps it live
+and uses `archived_at` to hide it from the default list.📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| """A resume after a STOP_KILLS_SESSION kill (or archive) must re-nest the durable row. | |
| Kill/archive soft-deletes the `session_streams` row (`deleted_at`). The unique constraint on | |
| `(project_id, session_id)` is full, so the tombstone keeps occupying the slot: on the next turn | |
| `_start_turn` reads `None` (get filters `deleted_at IS NULL`), `create` hits the unique slot -> | |
| `SessionStreamAlreadyExists`, and the follow-up `update` (also `deleted_at IS NULL`) matches | |
| nothing -> no-op. Without a revive, Redis goes alive but the durable row stays a dead tombstone, | |
| so the session vanishes from the list and `fetch`/`heartbeat` return `None`. `_start_turn` must | |
| detect the no-match update and revive that same row (clear `deleted_at`) so resume re-nests it. | |
| """A resume after a STOP_KILLS_SESSION kill must re-nest the durable row. | |
| Kill soft-deletes the `session_streams` row (`deleted_at`); archive keeps it live | |
| and uses `archived_at` to hide it from the default list. The unique constraint on | |
| `(project_id, session_id)` is full, so the tombstone keeps occupying the slot: on the next turn | |
| `_start_turn` reads `None` (get filters `deleted_at IS NULL`), `create` hits the unique slot -> | |
| `SessionStreamAlreadyExists`, and the follow-up `update` (also `deleted_at IS NULL`) matches | |
| nothing -> no-op. Without a revive, Redis goes alive but the durable row stays a dead tombstone, | |
| so the session vanishes from the list and `fetch`/`heartbeat` return `None`. `_start_turn` must | |
| detect the no-match update and revive that same row (clear `deleted_at`) so resume re-nests it. | |
| """ |
| async def archive(self, *, project_id, user_id, session_id): | ||
| self.archive_calls.append( | ||
| {"project_id": project_id, "user_id": user_id, "session_id": session_id} | ||
| ) | ||
| if self.row is not None: | ||
| self.row = self.row.model_copy( | ||
| update={"deleted_at": "2026-01-01T00:00:00Z"} | ||
| update={"archived_at": "2026-01-01T00:00:00Z"} | ||
| ) |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== locate relevant files =="
git ls-files | rg 'api/oss/tests/pytest/unit/sessions/test_sessions_root_service.py|api/oss/src/.*SessionStream|src|sessions' | head -200
echo
echo "== inspect target test =="
sed -n '1,160p' api/oss/tests/pytest/unit/sessions/test_sessions_root_service.py | cat -n
echo
echo "== find SessionStream/session model definitions =="
rg -n "class SessionStream|archived_at|SessionStream" api/oss/src api/oss/tests -g '*.py' | head -200Repository: Agenta-AI/agenta
Length of output: 8994
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== deterministic model_copy behavior probe =="
python3 - <<'PY'
import sys
try:
from datetime import datetime, timezone
from pydantic import BaseModel
except Exception as e:
print(f"import_error: {type(e).__name__}: {e}")
sys.exit(0)
class Model(BaseModel):
archived_at: datetime
for value in [None, "2026-01-01T00:00:00Z", datetime(2026, 1, 1, tzinfo=timezone.utc)]:
m = Model(archived_at=None, row=None)
print("- value", repr(value), "type", type(value).__name__)
try:
m2 = m.model_copy(update={"archived_at": value, "row": m.row})
print(" copied archived_at:", repr(m2.archived_at), "type:", type(m2.archived_at).__name__)
except Exception as e:
print(" copy_error:", type(e).__name__ + ":", str(e))
# Compare to construction validation.
for value in [None, "2026-01-01T00:00:00Z", datetime(2026, 1, 1, tzinfo=timezone.utc)]:
try:
m = Model(archived_at=value, row=None)
print(" constructed archived_at:", repr(m.archived_at), "type:", type(m.archived_at).__name__)
except Exception as e:
print(" construct_error:", type(e).__name__ + ":", str(e))
PYRepository: Agenta-AI/agenta
Length of output: 810
🏁 Script executed:
#!/bin/bash
set -u
echo "== python/pydantic availability =="
python3 - <<'PY'
from datetime import datetime, timezone
try:
import pydantic
print("pydantic", pydantic.__version__)
from pydantic import BaseModel
print("ok pydantic imported")
except Exception as e:
print("import_error", type(e).__name__ + ":", str(e))
PY
python3 - <<'PY'
from datetime import datetime, timezone
import pydantic
print("pydantic", pydantic.__version__)
from pydantic import BaseModel
class Model(BaseModel):
archived_at: datetime | None = None
row: str | None = None
for value in [None, "2026-01-01T00:00:00Z", datetime(2026, 1, 1, tzinfo=timezone.utc)]:
m = Model()
try:
m = m.model_copy(update={"archived_at": value})
print("input", repr(value), "output", repr(m.archived_at), "type", type(m.archived_at).__name__)
except Exception as e:
print("input", repr(value), "copy_error", type(e).__name__ + ":", str(e))
PY
echo
echo "== focused test file =="
if [ -f api/oss/tests/pytest/unit/sessions/test_sessions_root_service.py ]; then
sed -n '1,180p' api/oss/tests/pytest/unit/sessions/test_sessions_root_service.py | cat -n
else
echo "missing target file"
fi
echo
echo "== focused SessionStream definitions/usages =="
rg -n "class SessionStream|archived_at|SessionStream" api/oss/src api/oss/tests || trueRepository: Agenta-AI/agenta
Length of output: 46919
Keep the archive mock’s timestamp type-faithful.
SessionStream.archived_at is modeled as Optional[datetime], and model_copy(update=...) in the supported Pydantic version preserves unvalidated strings. Use a timezone-aware datetime here so the fake row matches production’s typed value.
Proposed fix
- update={"archived_at": "2026-01-01T00:00:00Z"}
+ update={"archived_at": datetime(2026, 1, 1, tzinfo=timezone.utc)}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| async def archive(self, *, project_id, user_id, session_id): | |
| self.archive_calls.append( | |
| {"project_id": project_id, "user_id": user_id, "session_id": session_id} | |
| ) | |
| if self.row is not None: | |
| self.row = self.row.model_copy( | |
| update={"deleted_at": "2026-01-01T00:00:00Z"} | |
| update={"archived_at": "2026-01-01T00:00:00Z"} | |
| ) | |
| async def archive(self, *, project_id, user_id, session_id): | |
| self.archive_calls.append( | |
| {"project_id": project_id, "user_id": user_id, "session_id": session_id} | |
| ) | |
| if self.row is not None: | |
| self.row = self.row.model_copy( | |
| update={"archived_at": datetime(2026, 1, 1, tzinfo=timezone.utc)} | |
| ) |
The unit fakes stamped datetime lifecycle fields with ISO strings via model_copy(update=...), which does not re-validate, so archived_at/deleted_at stayed str where the DTOs declare Optional[datetime]. Use real datetimes throughout. Also correct two stale docstrings that described archive as soft-deleting via deleted_at; archive round-trips archived_at and never touches the kill tombstone path.
Railway Preview Environment
Updated at 2026-07-28T07:12:47.529Z |
Context
The agent-chat session list was localStorage-only. It lived in a single browser, so it never synced across devices or tabs, and a delete or rename never left that browser. Open the app elsewhere and you saw nothing; a killed session simply vanished from history. This PR makes the list server-backed and reconciled, so it is durable and consistent everywhere.
What this adds
The list is now backed by the durable
/sessions/queryendpoint and folded over the localStorage cache by a reconciler:include_endedkeeps killed-but-resumable sessions in the list with a muted "Ended" chip, and resuming a killed session revives its durable row.archived_atcolumn (distinct fromdeleted_at) gives a hide-but-recoverable state, with archive/unarchive actions in the rail and history popover, an "Archived" section, andinclude_archivedfor the archived view. A live turn on an archived session auto-unarchives it.Before: the session list was one browser's localStorage; delete, rename, and kill stayed local; there was no archive.
After: the list is durable server streams reconciled over localStorage; delete, rename, archive, and ended state sync across devices.
Backend
session_streamsgainsarchived_at(migrationoss018) plus an index on(project_id, archived_at)(oss019).archive/unarchiveusearchived_atso kill'sdeleted_atpath is untouched.include_endedandinclude_archivedare threaded through the query path and filter independently.include_ended,include_archived, andarchived_atare typed rather than cast at runtime.Tests / notes
oss018revisesoss017(the current base head).generate.sh --localbefore merge guarantees the client matches the backend.What to QA