Release: compression-recovery action — focused continuation on compression_exhausted (#5538) - #5646
Conversation
Make focused compression-recovery continuation creation idempotent by marking child sessions and reusing the existing child on repeated start requests. Also surface malformed successful recovery responses in the UI instead of silently re-enabling the action. Co-Authored-By: Paperclip <noreply@paperclip.ing>
Restore the compact() source-order expectation used by the branch metadata contract test while keeping focused compression recovery markers in the compact payload. Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Resolved the session SSE RFC anchor conflict by keeping the latest symbol-based wording from master. Co-Authored-By: Paperclip <noreply@paperclip.ing>
Treat a cleared session-level compression_recovery payload as authoritative so old message metadata cannot intercept a later generic continuation after a successful new turn. Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
Co-Authored-By: Paperclip <noreply@paperclip.ing>
… on compression_exhausted (#5538) Folds in gate fixes: map the stale-card 409 to a neutral info toast + retire the card (finally-guard so it stays retired), + consumed-state CSS. Full gate: Codex SAFE, Fable SHIP-UX, suite 12140/0, browser-smoke clean. Co-authored-by: Frank Song <franksong2702@gmail.com>
|
| Filename | Overview |
|---|---|
| api/compression_recovery.py | New module implementing recovery metadata stamping, payload building, and the generic-continuation intent classifier; logic is sound with one cosmetic issue (re.UNICODE is redundant in Python 3). |
| api/models.py | Adds four recovery fields to Session; find_compression_recovery_session correctly de-duplicates across memory+disk, but the oldest-match selection means a second exhaustion event on the same source session silently reuses the first recovery child. |
| api/routes.py | New /api/session/compression-recovery/start endpoint and generic-continuation 409 guard added; clear/restore logic around _start_run is correct, but the server-side 409 for generic continuations has no dedicated client-side handler for the stale-session-state edge case. |
| api/streaming.py | Stamps recovery metadata on both inline and exception compression_exhausted error paths; change is minimal and correctly attached to the right error classification branches. |
| static/ui.js | Adds _compressionRecoveryHtml, startCompressionRecovery, intercept helpers, and the 409 stale-card retirement branch; XSS safety via esc() is maintained throughout and the finally guard correctly skips re-enabling a retired button. |
| static/messages.js | Adds pre-send intercept hook and attaches _compressionRecovery to assistant error messages; changes are minimal and correctly guard with typeof checks before each call. |
| tests/test_compression_recovery_action.py | New test file covering intent classification, generic-continuation blocking, clear/restore round-trips, deduplication, profile scoping, DB sidecar round-trips, and the stale-card 409 path; coverage is thorough. |
| api/session_recovery.py | Correctly plumbs all four new recovery fields through _state_db_row_to_sidecar with appropriate null-safe defaults. |
| api/webui_session_db.py | Adds the four new recovery fields to _METADATA_FIELDS so they are included in metadata-only loads; straightforward and correct. |
| static/style.css | Adds compression-recovery card styles including the consumed-state attribute selector and responsive layout; no issues. |
| tests/test_auto_compression_terminal_failure.py | Existing tests extended to assert recovery metadata is present on exhausted session payloads; additions are consistent with the new fields. |
| docs/troubleshooting.md | Adds a well-structured troubleshooting entry covering symptom, why, diagnostic commands, fix, and when-to-file-bug sections. |
| CHANGELOG.md | Changelog entry added as part of the release process; accurate summary of the feature. |
Sequence Diagram
%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant U as User
participant JS as Browser
participant API as /api/chat/start
participant SR as /api/session/compression-recovery/start
participant STR as Streaming engine
participant DB as Session store
STR->>DB: stamp_compression_exhausted_recovery(session)
STR->>JS: SSE error event with compression_recovery
JS->>JS: render recovery card
alt User types generic continuation
U->>JS: send()
JS->>JS: shouldInterceptCompressionRecoveryContinuation()
JS->>U: scroll to card + warning toast
end
alt User clicks Start focused continuation
U->>JS: startCompressionRecovery(btn)
JS->>SR: POST session_id
SR->>DB: find_compression_recovery_session()
alt No existing child
SR->>DB: new Session empty transcript
SR->>JS: "200 created=true"
else Existing child found
SR->>JS: "200 created=false"
end
JS->>JS: loadSession(new_sid)
alt 409 recovery already cleared
SR->>JS: 409
JS->>JS: "retire card consumed=1"
JS->>U: info toast
end
end
alt User sends substantive prompt
U->>JS: send()
JS->>API: POST chat/start
API->>DB: clear_compression_recovery(session)
API->>STR: _start_run(session)
STR->>DB: session.save()
end
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant U as User
participant JS as Browser
participant API as /api/chat/start
participant SR as /api/session/compression-recovery/start
participant STR as Streaming engine
participant DB as Session store
STR->>DB: stamp_compression_exhausted_recovery(session)
STR->>JS: SSE error event with compression_recovery
JS->>JS: render recovery card
alt User types generic continuation
U->>JS: send()
JS->>JS: shouldInterceptCompressionRecoveryContinuation()
JS->>U: scroll to card + warning toast
end
alt User clicks Start focused continuation
U->>JS: startCompressionRecovery(btn)
JS->>SR: POST session_id
SR->>DB: find_compression_recovery_session()
alt No existing child
SR->>DB: new Session empty transcript
SR->>JS: "200 created=true"
else Existing child found
SR->>JS: "200 created=false"
end
JS->>JS: loadSession(new_sid)
alt 409 recovery already cleared
SR->>JS: 409
JS->>JS: "retire card consumed=1"
JS->>U: info toast
end
end
alt User sends substantive prompt
U->>JS: send()
JS->>API: POST chat/start
API->>DB: clear_compression_recovery(session)
API->>STR: _start_run(session)
STR->>DB: session.save()
end
Reviews (1): Last reviewed commit: "release: compression-recovery action — o..." | Re-trigger Greptile
| def find_compression_recovery_session( | ||
| source_session_id: str, | ||
| action: str, | ||
| source_profile=_COMPRESSION_RECOVERY_PROFILE_UNSET, | ||
| ): | ||
| """Return an existing focused recovery child for ``source_session_id``. | ||
|
|
||
| The recovery-start endpoint is a retryable UI action. A persisted marker on | ||
| the child session makes double-clicks, repeated calls, and cache reloads | ||
| converge on the same continuation instead of creating duplicate siblings. | ||
| """ | ||
|
|
||
| source_sid = str(source_session_id or "").strip() | ||
| recovery_action = str(action or "").strip() | ||
| if not source_sid or not recovery_action: | ||
| return None | ||
|
|
||
| matches = [] | ||
| seen_ids: set[str] = set() | ||
| try: | ||
| with LOCK: | ||
| memory_sessions = list(SESSIONS.values()) | ||
| for session in memory_sessions: | ||
| sid = str(getattr(session, "session_id", "") or "").strip() | ||
| if sid: | ||
| seen_ids.add(sid) | ||
| if _compression_recovery_child_matches(session, source_sid, recovery_action, source_profile): | ||
| matches.append(session) | ||
| except Exception: | ||
| logger.debug("Failed to scan cached compression recovery sessions", exc_info=True) | ||
|
|
||
| try: | ||
| persisted_ids = _persisted_session_ids_snapshot() | ||
| except Exception: | ||
| persisted_ids = frozenset() | ||
| for sid in persisted_ids: | ||
| if sid in seen_ids: | ||
| continue | ||
| try: | ||
| meta = Session.load_metadata_only(sid) | ||
| except Exception: | ||
| logger.debug("Failed to inspect compression recovery session %s", sid, exc_info=True) | ||
| continue | ||
| if not meta or not _compression_recovery_child_matches(meta, source_sid, recovery_action, source_profile): | ||
| continue | ||
| try: | ||
| matches.append(get_session(sid)) | ||
| except Exception: | ||
| matches.append(meta) | ||
|
|
||
| if not matches: | ||
| return None | ||
|
|
||
| def _sort_key(session): | ||
| try: | ||
| created_at = float(getattr(session, "created_at", 0) or 0) | ||
| except (TypeError, ValueError): | ||
| created_at = 0.0 | ||
| try: | ||
| updated_at = float(getattr(session, "updated_at", 0) or 0) | ||
| except (TypeError, ValueError): | ||
| updated_at = 0.0 | ||
| return (created_at, updated_at, str(getattr(session, "session_id", "") or "")) | ||
|
|
||
| return sorted(matches, key=_sort_key)[0] |
There was a problem hiding this comment.
Stale recovery child reused after a second exhaustion event
find_compression_recovery_session matches solely on (compression_recovery_source_session_id, action, profile) and returns the oldest match. If source session S is exhausted once, C1 is created; later a substantive prompt in S clears the recovery flag and starts a new run; if that run also hits compression_exhausted, the second stamp_compression_exhausted_recovery stamps S again. The next click on "Start focused continuation" calls find_compression_recovery_session(S.id, "start_focused_continuation"), which finds C1 (its markers are permanent and never cleared), and the user is silently redirected to that old child — which may already contain unrelated work — instead of getting a fresh session.
The fix would be to scope the child lookup to the specific recovery event, e.g., by including a recovery_event_id or compression_recovery_created_at in both the payload and the child markers, and matching on it during the lookup.
| recovery = compression_recovery_payload_for_session(s) | ||
| if recovery and not attachments and is_generic_continuation_intent(msg): | ||
| return j( | ||
| handler, | ||
| { | ||
| "error": "This session exhausted context compression. Start a focused continuation, then describe the next narrow task.", | ||
| "type": "compression_recovery_required", | ||
| "recommended_recovery_action": recovery.get("recommended_action"), | ||
| "compression_recovery": recovery, | ||
| "session_id": getattr(s, "session_id", body["session_id"]), | ||
| }, | ||
| status=409, | ||
| ) |
There was a problem hiding this comment.
Server-side 409 for generic continuation lacks a dedicated client-side handler
When the JS intercept in send() does not fire — for example, when a session was loaded before compression_recovery was present on the server-side session object, so Object.prototype.hasOwnProperty.call(S.session,'compression_recovery') returns false AND no _compressionRecovery message is found — the API call proceeds. The server correctly returns a structured 409 with type: "compression_recovery_required", but send() has no branch that inspects e.status === 409 on this code path and shows the focused-continuation hint. The result is that the existing generic-error toast fires with the raw string rather than the scroll-to-card hint.
| if not raw: | ||
| return "" | ||
| # Keep CJK letters while dropping punctuation/emoji/noise around short intents. | ||
| return re.sub(r"[\W_]+", " ", raw, flags=re.UNICODE).strip() |
There was a problem hiding this comment.
re.UNICODE is the implicit default for str patterns in Python 3 and has no effect here. Dropping it removes a small maintenance confuser — readers might assume it's doing something meaningful beyond the default.
| return re.sub(r"[\W_]+", " ", raw, flags=re.UNICODE).strip() | |
| return re.sub(r"[\W_]+", " ", raw).strip() |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
🎬 Cutter preview — PR #5646
|

Release: compression-recovery action (#5538) — one-click focused continuation on
compression_exhaustedShips #5538 (@franksong2702) implementing the #4685 recovery slice: when a long conversation hits
compression_exhausted, the terminal error shows a Start focused continuation action that opens a fresh linked session (same workspace/model/profile/toolset, empty model-facing transcript). Bare "continue"/"go on"/继续 on an exhausted conversation is intercepted; substantive prompts pass through; repeated clicks/tabs converge on the same child.Gate folds (applied on top of the contributor's head)
Fable + Codex flagged the stale-card edge; folded in before ship:
data-compression-recovery-consumed), instead of a raw error toast.finally-guard (retiredRecoveryCard) so the retired button is not re-enabled.cursor:progress).Full authoritative gate (converged clean)
node --check-p no:xdist)Both round-1 gate-cert COREs (recovery-child lineage isolation + profile-scoped reuse) verified fixed by independent code read.
#5592soft-partial layering preserved. Nathan approved the concept + reviewed desktop/mobile screenshots.Attribution:
Co-authored-by: Frank Song+ CHANGELOG credit.