Skip to content
Merged
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@

### Fixed

- **`GET /api/session?msg_limit=` is now bounded by a server-side ceiling, and the frontend paginates around it without dropping rows.** A client could request `msg_limit=1000000` (or an outline-jump path asked for `9999`) and force the server to assemble and serialize an unbounded message payload. The backend now clamps `?msg_limit=` to `[1, 500]` via a dedicated `_parse_msg_limit()` helper and sets the existing `_messages_truncated` signal when it clamps; the bare no-`msg_limit` path (branch/undo/jump-to-start) still returns the full transcript. The frontend mirrors the ceiling: `_loadOlderMessages` grows its tail window below the ceiling and switches to bounded `msg_before` backward paging once the server would clamp, and the outline jump uses the bare full-transcript path instead of the old `msg_limit=9999` hack. Two silent row-loss regressions found during review were fixed before ship: a raw-row-heavy `msg_before` page that textually repeated the current tail could be misclassified as the cumulative tail and wholesale-replace it (losing older rows), and a same-session refresh above the ceiling could shrink an already-loaded >500-row transcript to the last 500 — both now route through the correct paging/full-transcript path. Thanks @rh-id. (#6152, #6154, #6177)

- **Background subagents no longer fail to persist when the parent's cached agent is reused mid-turn.** A parent session's `SessionDB` handle is shared by reference with the background subagents it spawns (`delegate_tool`), but the cached-agent reuse path unconditionally closed that handle when the parent got a new turn / server-side wakeup — so every still-running child then failed `append_message` with `'NoneType' object has no attribute 'execute'` (observed 600+ times under one parent). The reuse path now keeps a still-open handle (closing only the unused fresh one, preserving the #1421 FD-leak fix) and only replaces a dead/missing one. The credential self-heal path was hardened the same way: if rebuilding the handle fails it degrades cleanly to a lazy reinit instead of reusing a closed handle. Thanks @carlotestor. (#6143)

- **The CLI/cron session-list projection cache is now bounded.** `_CLI_SESSIONS_CACHE` was an unbounded plain dict of deep-copied session-list projections whose fingerprint-folded key advances on every streamed message, so orphaned heavy copies accumulated on a long-lived server until the next structural clear. It's now an `OrderedDict` capped at 8 entries with drop-oldest LRU eviction (recency refresh on hit), mirroring the existing `_CLAUDE_CODE_PARSE_CACHE` / `_SIDECAR_METADATA_CACHE` pattern; TTL stays the freshness control. Thanks @rh-id. (#6140)
Expand Down
40 changes: 35 additions & 5 deletions api/routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -8446,6 +8446,34 @@ def _message_window_for_display(messages, msg_limit=None, msg_before=None, expan


_LIMITED_TOOL_CONTENT_MAX_CHARS = 4096
# Server-side ceiling on the ?msg_limit= tail-window size. A client could
# otherwise request msg_limit=1000000 and force the server to assemble and
# serialize an unbounded message payload (the frontend's own pagination grows
# by ~30 at a time, with one outline-jump path asking for 9999). The ceiling is
# generous — far above any legitimate visible-row window — so real pagination is
# unaffected; it only caps the pathological/oversized request. When the request
# exceeds the ceiling the response is silently clamped and _messages_truncated
# is set (the existing truncation signal already covers "more rows exist").
_MAX_MSG_LIMIT = 500


def _parse_msg_limit(raw):
"""Parse and clamp the ``?msg_limit=`` query value.

Returns a positive int clamped to ``[1, _MAX_MSG_LIMIT]``, or ``None`` when
the value is absent/empty/malformed (the bare no-``msg_limit`` path, which
intentionally returns the full transcript for callers that need it).
Extracted from the handler so the clamp expression has direct test coverage.
"""
if not raw:
return None
try:
value = int(raw)
except (TypeError, ValueError):
return None
return max(1, min(value, _MAX_MSG_LIMIT))


# Defensive row backstop for the GET /api/session display path's state.db read.
# This is NOT a semantic window (the display window counts visible rows
# post-reconciliation via _message_window_for_display); it is a safety net so a
Expand Down Expand Up @@ -12400,11 +12428,13 @@ def handle_get(handler, parsed) -> bool:
# transcript rows. Hidden tool-result rows do not consume the budget;
# they are included only when they sit inside the selected window and
# are bounded before serialization. Older rows load on-demand.
_msg_limit = query.get("msg_limit", [None])[0]
try:
msg_limit = max(1, int(_msg_limit)) if _msg_limit else None
except (ValueError, TypeError):
msg_limit = None
# Clamp to _MAX_MSG_LIMIT so an oversized request (e.g. msg_limit=9999
# from an outline jump, or a hostile value) can't force an unbounded
# payload; the existing _messages_truncated signal covers the clamped
# case (the client sees there are more rows than returned). Parsing +
# clamping live in _parse_msg_limit so the expression has direct test
# coverage; None means the bare no-msg_limit path (full transcript).
msg_limit = _parse_msg_limit(query.get("msg_limit", [None])[0])
# ?msg_before=N — 0-based index into the full message array.
# Returns messages before this index (for scroll-to-top lazy loading).
# Combined with msg_limit for paging.
Expand Down
8 changes: 7 additions & 1 deletion static/outline.js
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,16 @@ function _jumpToMessage(rawIdx) {
}

// Row is outside the render window — reload the full session and retry.
// Use the bare messages=1 path (no msg_limit) so the server returns the
// COMPLETE transcript: the target row is addressed by absolute index
// (msg-user-<rawIdx>), so a bounded tail window would miss early rows.
// (A previous version sent msg_limit=9999 as a "give me everything" hack,
// but the server now clamps msg_limit, so the bare path is the correct way
// to request the full transcript here.)
if (typeof api !== 'function') return;
if (S.busy || S.activeStreamId) return;
api('/api/session?session_id=' + encodeURIComponent(sid) +
'&messages=1&resolve_model=0&msg_limit=9999')
'&messages=1&resolve_model=0')
.then(function(data) {
if (!data || !data.session) return;
if (!S.session || S.session.session_id !== sid) return; // session switched
Expand Down
100 changes: 74 additions & 26 deletions static/sessions.js
Original file line number Diff line number Diff line change
Expand Up @@ -2928,6 +2928,19 @@ let _messagesTruncated = false;
// server-bounded and do not consume the visible-message budget.
// Older messages are loaded on-demand via _loadOlderMessages().
const _INITIAL_MSG_LIMIT = 30;
// ============================================================================
// COUPLED CONSTANT — keep in sync with api/routes.py:_MAX_MSG_LIMIT.
// ============================================================================
// This is a hand-mirrored copy of the backend's GET /api/session ?msg_limit=
// ceiling. _loadOlderMessages grows its msg_limit tail window by
// +_INITIAL_MSG_LIMIT each load; once growth would exceed this ceiling the
// server clamps and the tail stops growing, so we switch to msg_before paging
// (a fixed-size backward page keyed off _oldestIdx) instead. If you change the
// backend _MAX_MSG_LIMIT, change this value to match — otherwise load-older
// silently stalls again for long sessions. The durable fix (so this mirror
// isn't needed) is to expose the ceiling in the /api/session metadata response
// and read it here; see tracking issue for that follow-up.
const _MSG_LIMIT_MAX = 500;
let _sameSessionForceReloadHint = null;

function _currentLoadedRenderableMessageCount(){
Expand Down Expand Up @@ -3032,11 +3045,19 @@ async function _ensureMessagesLoaded(sid, opts) {
}
// Fetch session messages with a tail window for fast initial load.
const reloadLimit = _messageReloadLimitForSession(sid); // defaults to _INITIAL_MSG_LIMIT
const reloadLimitParam = reloadLimit ? `&msg_limit=${reloadLimit}` : '';
// A reload window above the server's msg_limit ceiling would be clamped by
// the backend (returning only the last _MSG_LIMIT_MAX rows), which can
// silently SHRINK an already-loaded transcript that had more than the ceiling
// of rows visible (rows 400–999 replaced by 500–999). When the requested
// window exceeds the ceiling, fall back to the bare full-transcript request
// (no msg_limit / no expand_renderable) so a same-session refresh never drops
// already-loaded older rows (Codex gate #6154, silent row-loss).
const boundedReloadLimit = (reloadLimit && reloadLimit <= _MSG_LIMIT_MAX) ? reloadLimit : null;
const reloadLimitParam = boundedReloadLimit ? `&msg_limit=${boundedReloadLimit}` : '';
// Older frontends used expand_renderable=1 to request visible-row expansion.
// The server now counts msg_limit by visible transcript rows by default; keep
// the flag for compatibility with mixed-version deployments.
const expandParam = reloadLimit ? '&expand_renderable=1' : '';
const expandParam = boundedReloadLimit ? '&expand_renderable=1' : '';
let data;
try {
data = await api(
Expand Down Expand Up @@ -3531,18 +3552,34 @@ async function _loadOlderMessages() {
// rebuilt transcript (#1937).
const startGeneration = _messagesGeneration;
try {
// Ask the server for a larger authoritative tail window instead of a
// separate msg_before page. The same /api/session contract handles both —
// post-#2716 the backend always runs the full append-only merge, so a
// larger msg_limit on the same call produces the same merged transcript
// we'd get by stitching pages, but without client-side index bookkeeping.
// Cumulative growth: each "load more" asks for currentLoaded + 30, and the
// newly exposed head is what we expose to the user.
// Two strategies, chosen by whether the growing tail window still fits under
// the server's msg_limit ceiling (_MSG_LIMIT_MAX, mirroring backend
// _MAX_MSG_LIMIT):
//
// - Below the ceiling: ask for a larger authoritative tail window
// (currentLoaded + _INITIAL_MSG_LIMIT). Post-#2716 the backend runs the
// full append-only merge, so a larger msg_limit produces the same merged
// transcript we'd get by stitching pages, without client-side index
// bookkeeping. The newly exposed head is what we expose to the user.
//
// - At/above the ceiling: the server clamps msg_limit, so the tail window
// stops growing and this strategy would stall (the same clamped tail is
// returned, olderMsgs -> 0). Switch to msg_before paging — a fixed
// _INITIAL_MSG_LIMIT backward page keyed off _oldestIdx — which is
// bounded and never hits the ceiling, so the head stays reachable for
// arbitrarily long transcripts. (This is the same paging request the
// race-fallback below uses, proven correct there.)
const requestedLimit = Math.max(_INITIAL_MSG_LIMIT, (S.messages || []).length + _INITIAL_MSG_LIMIT);
const data = await api(
`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0&msg_limit=${requestedLimit}`,
{timeoutMs:120000}
);
const useBeforePaging = requestedLimit >= _MSG_LIMIT_MAX;
const data = useBeforePaging
? await api(
`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0&msg_before=${_oldestIdx}&msg_limit=${_INITIAL_MSG_LIMIT}`,
{timeoutMs:120000}
)
: await api(
`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0&msg_limit=${requestedLimit}`,
{timeoutMs:120000}
);
// Guard: api() may have redirected (401) and returned undefined.
if (!data || !data.session) { _loadingOlder = false; return; }
// - response shape sane
Expand All @@ -3569,7 +3606,14 @@ async function _loadOlderMessages() {
// the server appended new messages (or merge filtered something) while we
// were awaiting, the suffix won't line up — fall back to the legacy
// msg_before page so we never drop visible older messages on the floor.
let tailMatches = expandedMsgs.length >= currentLen;
// When useBeforePaging is true, `data` is a bounded msg_before OLDER page,
// not a cumulative tail. A raw-row-heavy older page whose visible text
// repeats the current tail could otherwise pass the suffix check below and
// be wholesale-replaced AS IF it were the full tail — silently discarding
// the current (newer) rows and marking history complete. Gate the suffix
// heuristic on !useBeforePaging so every msg_before page is always treated
// as an older page and prepended (Codex gate #6154, silent row-loss).
let tailMatches = !useBeforePaging && expandedMsgs.length >= currentLen;
if (tailMatches && currentLen > 0) {
const start = expandedMsgs.length - currentLen;
for (let i = 0; i < currentLen; i++) {
Expand All @@ -3583,18 +3627,22 @@ async function _loadOlderMessages() {
let olderMsgs = expandedMsgs.slice(0, olderCount);
let nextMessages = expandedMsgs;
if (!tailMatches) {
// Race fallback: keep the legacy index-page request as the
// correctness-preserving alternative. Same guards reapplied because
// we just awaited again.
const fallback = await api(
`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0&msg_before=${_oldestIdx}&msg_limit=${_INITIAL_MSG_LIMIT}`,
{timeoutMs:120000}
);
if (!fallback || !fallback.session) { _loadingOlder = false; return; }
if (!S.session || S.session.session_id !== sid) return;
if (_loadingSessionId !== null && _loadingSessionId !== sid) return;
if (_messagesGeneration !== startGeneration) return;
responseSession = fallback.session;
// Race fallback (or the over-ceiling msg_before primary path): keep the
// legacy index-page request as the correctness-preserving alternative.
// When useBeforePaging is true we already fetched a msg_before page as
// the primary `data`, so reuse it instead of re-fetching. Same guards
// reapplied because we just awaited again (skipped for the reuse case).
if (!useBeforePaging) {
const fallback = await api(
`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0&msg_before=${_oldestIdx}&msg_limit=${_INITIAL_MSG_LIMIT}`,
{timeoutMs:120000}
);
if (!fallback || !fallback.session) { _loadingOlder = false; return; }
if (!S.session || S.session.session_id !== sid) return;
if (_loadingSessionId !== null && _loadingSessionId !== sid) return;
if (_messagesGeneration !== startGeneration) return;
responseSession = fallback.session;
}
olderMsgs = (responseSession.messages || []).filter(m => m && m.role);
nextMessages = [...olderMsgs, ...S.messages];
}
Expand Down
15 changes: 7 additions & 8 deletions tests/test_api_timeout.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,17 +230,16 @@ def test_session_message_loads_keep_explicit_longer_timeouts():
" {timeoutMs:120000}\n"
" )"
) in src
# _loadOlderMessages now picks between two strategies (tail-growth vs
# msg_before paging) via a useBeforePaging ternary, but both keep the long
# timeoutMs:120000. Assert each URL + timeout survives in the source.
assert (
"api(\n"
" `/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0&msg_limit=${requestedLimit}`,\n"
" {timeoutMs:120000}\n"
" )"
"`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0&msg_before=${_oldestIdx}&msg_limit=${_INITIAL_MSG_LIMIT}`,\n"
" {timeoutMs:120000}"
) in src
assert (
"api(\n"
" `/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0&msg_before=${_oldestIdx}&msg_limit=${_INITIAL_MSG_LIMIT}`,\n"
" {timeoutMs:120000}\n"
" )"
"`/api/session?session_id=${encodeURIComponent(sid)}&messages=1&resolve_model=0&msg_limit=${requestedLimit}`,\n"
" {timeoutMs:120000}"
) in src


Expand Down
7 changes: 7 additions & 0 deletions tests/test_cross_session_message_load_isolation.py
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,13 @@ def test_ensure_messages_loaded_ownership_guard_pre_and_post_await():
globalThis._oldestIdx = 0;
globalThis._messageRenderWindowSize = 0;
globalThis._messageReloadLimitForSession = () => 2;
// sessions.js module-level const, referenced by _ensureMessagesLoaded's
// boundedReloadLimit ceiling check (#6152/#6154). Not one of the extracted
// functions, so define it in the harness (matching the real value) or the
// reload-width path resolves it as undefined -> boundedReloadLimit=null ->
// the fetch URL drops msg_limit/expand_renderable and mismatches the
// enqueued buildMessageUrl(), stalling the ordered api() harness.
globalThis._MSG_LIMIT_MAX = 500;
globalThis._currentMessageRenderWindowSize = () => 1;
globalThis._messageRenderableMessageCount = () => 2;

Expand Down
23 changes: 19 additions & 4 deletions tests/test_issue3162_ensure_messages_loaded.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,25 @@

def _ensure_messages_loaded_body() -> str:
start = SESSIONS_JS.index("async function _ensureMessagesLoaded")
# Window widened (#3326 added reload-width-hint handling inside this function,
# pushing the carry-forward reassignment further down; #3790 added the
# cold-load expand_renderable param + comment, pushing it further still).
return SESSIONS_JS[start: start + 4500]
# Extract the ACTUAL function body via brace-balance instead of a fixed
# character window. The old fixed 4500-char window kept needing bumps as the
# function grew (#3326 reload-width-hint, #3790 cold-load expand_renderable,
# #6152/#6154 the msg_limit ceiling boundedReloadLimit path — each pushed the
# carry-forward reassignment further down and eventually past the window).
# Balancing braces from the opening `{` is robust to any in-function growth.
brace = SESSIONS_JS.index("{", start)
depth = 0
end = brace
for i in range(brace, len(SESSIONS_JS)):
c = SESSIONS_JS[i]
if c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
end = i + 1
break
return SESSIONS_JS[start:end]


def test_ensure_messages_loaded_declares_msgs_with_let():
Expand Down
Loading
Loading