Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -13694,15 +13694,61 @@ def handle_post(handler, parsed) -> bool:
return bad(handler, "Session not found", 404)
sid = body["session_id"]
with _get_session_agent_lock(sid):
s.messages = []
# Clear is a full truncate: keep zero messages. Route it through the
# SAME helper the /api/session/truncate handler uses so the merge
# contract is not forked (#5532 P0 data loss).
#
# Before this fix /clear wiped s.messages/s.tool_calls but never set
# truncation_watermark. The append-only state.db merge
# (merge_session_messages_append_only) treats an unset watermark as
# "keep everything", so on the next /api/session read the cleared
# turns were resurrected from state.db (history reappeared after
# clear+refresh) and — because context_messages also survived — a
# continued turn still carried the pre-clear context.
#
# truncate_session_at_keep(s, 0) empties messages AND context_messages
# and sets truncation_watermark = truncation_boundary =
# _truncation_watermark_for([]) == 0.0. 0.0 is the #2914
# "truncate-to-empty" sentinel that blocks ALL state.db replay, so the
# merge honors the clear instead of re-adding the deleted transcript.
from api.session_ops import (
apply_session_title_rename,
truncate_session_at_keep,
)
truncate_session_at_keep(s, 0)
s.tool_calls = []
# #5532 (Codex gate): a compressed-continuation child persists its
# archived transcript in a parent sidecar marked
# pre_compression_snapshot and stitches it back for display via
# _webui_sidecar_lineage_messages_for_display(). That stitch merges
# the child's own messages with truncation_watermark=None, so the
# 0.0 truncate-to-empty sentinel we just set on the CHILD does NOT
# stop the PARENT snapshot from resurrecting the pre-clear transcript
# on refresh. Detach the compression lineage so a cleared session is
# genuinely empty: drop the snapshot parent link + the anchor fields
# so the child no longer resolves a pre_compression_snapshot parent.
s.parent_session_id = None
s.compression_anchor_visible_idx = None
s.compression_anchor_message_key = None
s.compression_anchor_summary = None
# Reset the title via the rename helper so clearing a manually-named
# session also clears manual_title/llm_title_generated — otherwise the
# reused session keeps its manual-title protection and never auto-names
# again (#3542 lifecycle gap).
from api.session_ops import apply_session_title_rename
apply_session_title_rename(s, "Untitled")
s.save()
# #5532 (Codex gate): s.save() writes a pre-clear .json.bak (messages
# shrank to []). On the next WebUI startup, session_recovery restores
# any session whose .bak has MORE messages than the live file
# (bak_count > live_count), and it does NOT know about the live
# truncation_watermark==0.0 — so it would resurrect the cleared
# transcript (with a None watermark) after a restart. Drop the stale
# backup so the intentional clear can't be undone, exactly as the
# manual-compress and delete paths already do.
try:
s.path.with_suffix(".json.bak").unlink(missing_ok=True)
except OSError:
pass
# Evict cached agent outside the per-session lock. Eviction may run a
# boundary memory commit for batch-extraction providers, and provider
# I/O must not hold the session mutation lock.
Expand Down
22 changes: 11 additions & 11 deletions docs/rfcs/session-sse-contract-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ against current source before any route is added.
route, handler, or related code is added in this PR.
- This RFC does **not** modify `GET /api/sessions/events` (the existing global
session-list invalidation stream routed at `api/routes.py:12345-12346` and
implemented by `_handle_session_events_stream()` at `api/routes.py:16177`).
implemented by `_handle_session_events_stream()` at `api/routes.py:16223`).
- This RFC does **not** replace or modify existing streams: `/api/chat/stream`,
`/api/approval/stream`, or `/api/clarify/stream`.
- This RFC does **not** introduce Android, iOS, or PWA client code.
Expand All @@ -54,7 +54,7 @@ against current source before any route is added.

`GET /api/sessions/events` is a **different endpoint** from the one this RFC
proposes. It is routed at `api/routes.py:12345-12346` and implemented by
`_handle_session_events_stream()` at `api/routes.py:16177`. It emits bare
`_handle_session_events_stream()` at `api/routes.py:16223`. It emits bare
`sessions_changed` events and keepalives for any change to the session list. It
is a global invalidation signal, not a per-session lifecycle stream. The proposed
`GET /api/sessions/{session_id}/events` is per-session and path-distinct.
Expand All @@ -73,19 +73,19 @@ Line ranges in this inventory were verified against WebUI `master` when this
RFC was written. Function, constant, and endpoint names are the stable anchors
if source layout moves later.

- `_parse_run_journal_event_id()` (`api/routes.py:15673-15686`) and
`_parse_run_journal_after_seq()` (`api/routes.py:15688-15701`) parse the replay
- `_parse_run_journal_event_id()` (`api/routes.py:15719-15732`) and
`_parse_run_journal_after_seq()` (`api/routes.py:15734-15747`) parse the replay
cursor from the `after_event_id` / `after_seq` **query params** (not the
`Last-Event-ID` header — that header is the *proposed* new-endpoint contract
below, §Reconnect).
- `_runner_event_id()` at `api/routes.py:15765-15772` constructs the event `id`
- `_runner_event_id()` at `api/routes.py:15811-15818` constructs the event `id`
field as `stream_id:seq`.
- SSE frames carry their `id:` via the `_sse_with_id()` helper, emitted on the
live `/api/chat/stream` path at `api/routes.py:15918`, on the runner-observe
path at `api/routes.py:15811`, and during journal replay at
`api/routes.py:15721` / `15734`.
live `/api/chat/stream` path at `api/routes.py:15964`, on the runner-observe
path at `api/routes.py:15857`, and during journal replay at
`api/routes.py:15767` / `15780`.
- `_replay_run_journal()` reads events by `(session_id, stream_id)` at
`api/routes.py:15703-15735`.
`api/routes.py:15749-15781`.
- `api/streaming.py:6265-6285` writes current live agent streams to
`STREAMS[stream_id]`.
- `api/streaming.py:6620-6634` appends SSE events to the run journal and carries
Expand Down Expand Up @@ -165,7 +165,7 @@ position.

**`event_id` is opaque to clients.** Its current source-compatible form is
`stream_id:seq`, as constructed by `_runner_event_id()` at
`api/routes.py:15765-15772`. Clients must treat it as an opaque string and must
`api/routes.py:15811-15818`. Clients must treat it as an opaque string and must
not parse or construct cursor values.

**`seq` is monotonic within a stream/run.** It is not a session-global counter
Expand All @@ -183,7 +183,7 @@ events. The live `STREAMS[stream_id]` queue (`api/streaming.py:6265-6285`) is
not a reliable replay source because it holds only recent in-memory state.

A future implementation must replay from the run journal via the existing
`_replay_run_journal()` path (`api/routes.py:15703-15735`) and fall back to the
`_replay_run_journal()` path (`api/routes.py:15749-15781`) and fall back to the
snapshot mechanism when journal entries are unavailable for a given cursor.

## Snapshot fallback
Expand Down
Loading
Loading