Adversarial review of the previous commit found a leak it introduced.
`attach_terminal()` subscribes and only then are the response headers written.
A client that dropped in that instant makes `end_headers()` raise
BrokenPipeError/ConnectionResetError, and because the `try/finally` started
*after* those writes, the exception escaped `_handle_terminal_output` with the
queue still in `_subscribers`. That pins `unwatched_since` at None — only
`unsubscribe()` on an empty subscriber list resets it — so the terminal became
permanently unreapable: the exact fd/thread leak (#4633) this reaper exists to
prevent, and strictly worse than the TOCTOU the previous commit fixed, since
that at least still allowed cleanup.
The try now starts immediately after the attach, so every exit path
unsubscribes. Regression test drives a handler whose `end_headers()` raises and
asserts the subscriber list is empty, `unwatched_since` is restored, and the
terminal is reapable again once the grace elapses. It fails against the
previous ordering.
`_terminals_to_reap()` snapshots victims under `_LOCK` and then releases it,
but `subscribe()` clears `unwatched_since` and appends to `_subscribers` under
the terminal's own `_sub_lock`, which the selection pass never held. A viewer
reconnecting in that gap left object identity — all `close_terminal(expected=…)`
ever checked — still true, so the reaper killed a terminal that now had a live
viewer.
- `_claim_reap_victim()` re-establishes the *whole* selection predicate while
holding `_LOCK` and the terminal's `_sub_lock`, and removes the registry
entry in that same critical section: same object, no subscribers, and
`unwatched_since` still set and still expired. A dead process is still
reaped unconditionally — it cannot come back, and a viewer watching a corpse
must not pin the entry forever.
- `attach_terminal()` does the registry lookup and the subscribe atomically
under `_LOCK`, replacing `get_terminal()` + `term.subscribe()` in the SSE
route. It takes the same two locks in the same order as the claim, so
exactly one side wins: the viewer attaches (and the reap is refused), or the
entry is already gone (and the route answers "terminal not running" before
committing a 200, instead of streaming from a corpse). Registration is the
authority — a registered-but-`closed` terminal still attaches so the viewer
receives `terminal_closed`.
- Teardown moved to `_teardown_terminal()` and runs after both locks are
released; `killpg`+`wait` can block ~2.5s and must not pin the registry.
- Dropped the duplicated `self.last_activity = time.time()` the rebase left in
`put_output()`.
Tests: a barrier regression that pauses the reaper after selection, attaches a
viewer through the production path, resumes, and asserts the terminal stays
registered/live and still receives output; the inverse where the reap claims
first and the attach reports not-running; refreshed-grace and replaced-entry
claims; dead-with-viewer; and a probe that `_LOCK` is free during teardown.
Three of them fail against an identity-only claim.
Render structured background-process completions and watch matches as accessible collapsed cards while preserving raw fallback, verbatim output, and full watch patterns.
Co-authored-by: b3nw <b3nw@duck.com>
* feat(extensions): token-v1 proxy->sidecar auth boundary
Mint a per-extension secret core injects (X-Hermes-Sidecar-Token) on every
proxied request; sidecars validate it. Closes the hole where a loopback sidecar
port is reachable by any local process and cannot distinguish a proxied request
from a direct one.
- api/extension_sidecar_auth.py: per-extension token lifecycle (atomic mint,
re-read-verified so an unpersisted token is never injected, per-request
mtime-cached read for live rotation, path-escape-safe)
- manifest proxy_auth negotiation: absent=legacy, token-v1=enforce, unknown=fail-closed
- proxy: inject token, strip inbound + response x-hermes-*, auth-off posture
(loopback-only local_unprotected, else 503), fail-closed when token unavailable
- consent-time auth_required in status payload; mint-on-consent
- docs/EXTENSIONS.md proxy_auth section
- 7 new tests (26/26 green)
* fix(extensions): address Codex+Fable gate on token-v1 (6 findings)
- align token-module extension-id grammar with core _EXTENSION_ID_RE (was
narrower -> legally-named ext consented then 503'd forever)
- resolve token dir dynamically (mirror _extension_state_dir) -> real test
isolation, no import-time STATE_DIR cache
- cross-process mint: O_CREAT|O_EXCL no-clobber claim (was os.replace clobber)
- rotation cache keyed on full fingerprint (ino/dev/mtime/ctime/size) +
re-fingerprint after read -> same-size/mtime replacement no longer stale
- validate token format on read (url-safe, 16-256) -> malformed file can't
leak via a ValueError echoed in a 502
- consent fails 503 when token can't be provisioned (was silent-swallow ->
persisted consent then 503 forever)
- rename status auth_required -> posture enum (protected|local_unprotected):
nothing is blocked for loopback, so 'required' was misleading
- panels.js: render local_unprotected warning on the consent row (+ CSS)
- docs: token-path resolution order, 401-vs-503, explicit 'legacy' acceptance
- tests: route-level token-injection+response-strip test; fix illusory isolation
in token-module test; 27/27 green
* fix(extensions): close 2 token-mint races (Codex re-gate round 2)
- mint via temp-file + atomic os.replace (not O_CREAT|O_EXCL) so the final
path is never observed empty/half-written — a concurrent loser can no longer
read an empty token file and 503
- single _stable_read helper (fingerprint-read-refingerprint, bounded retry on
mid-read change) used by BOTH ensure_token and current_token — a token that
changes during the read is never returned or cached stale
- stress-verified: 20 concurrent first-mints converge on 1 persisted token; 27/27
* fix(extensions): atomic no-clobber token publish via os.link (Codex re-gate round 3)
os.replace fixed empty-file exposure but still clobbered cross-process: two
processes could both write+replace and a reader between them got a token no
longer on disk -> 401. Switch to the repo's TOCTOU-safe os.link create-or-fail
idiom (session_recovery.py:627): write temp -> link into place (fails if a
winner already published) -> loser drops its temp and reads the winner via
_stable_read. Proven: 16 concurrent PROCESSES converge on 1 persisted token,
all matching disk. 27/27 tests green.
* fix(extensions): resolve Frank+Greptile #6331 review — token-v1 fail-closed when auth off (consent+resolution), token-v1-only proxy_auth/posture status fields, fullmatch ext-id validator
* fix(extensions): finish Frank and Greptile sidecar review
---------
Co-authored-by: nesquena-hermes <nesquena-hermes@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
* fix: preserve live anchor run identity
* Validate envelope run ids before snapshot cursor use
* docs(changelog): stamp #6201 stable Anchor run identity
---------
Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
* fix(webui): keep the content search from evicting the user's working set
/api/sessions/search?content=1 walks EVERY session and pulls each one through
get_session(), which inserts it into the SESSIONS LRU and marks it
recently-used. On any install with more sessions than sessions_cache_max
(default 300), a single search therefore flushes the whole cache and refills it
with sessions the user is not looking at — the classic buffer-pool
scan-pollution problem. The sessions actually open in the UI are exactly the
ones evicted, and the search is keystroke-debounced, so it repeats while typing.
A scan reads each session exactly once, so nothing it touches has earned
"recently used". get_session_for_scan() reuses a resident session without
promoting it, and reads a cold one straight from disk without caching it. It
returns None rather than raising, since a scan skips what it cannot open.
This is a correctness fix for cache behaviour, not a latency fix. The
multi-second searches that led here were contention, not scan cost: a trivial
/api/profiles took 9.2s in the same window, and a full read+parse of ~1700 real
sessions measures ~4s total.
test_sessions_search_depth_validation patched api.routes.get_session. With the
search reading through the scan accessor it now patches get_session_for_scan —
left unfixed, two of its cases fail and the third passes vacuously against an
empty result set.
Validation:
pytest tests/test_issue4765_sessions_lru_eviction.py
tests/test_sessions_search_depth_validation.py -> 13 passed
Both added eviction tests fail on the pre-fix accessor (verified by revert):
the working set drops from 4/4 to 0/4 resident after one scan.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* docs(changelog): stamp #6084 content-search working-set preservation
---------
Co-authored-by: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
* feat(terminal): broadcast output to every viewer instead of one shared queue
TerminalSession.output was a single queue.Queue read destructively by the SSE
handler. Two tabs/windows viewing the SAME session each open their own
EventSource, so two _handle_terminal_output handlers competed on that one queue:
every PTY chunk was delivered to exactly one of them. Each tab saw a disjoint
half of the byte stream, and only one ever received terminal_closed.
Output now fans out, mirroring StreamChannel/SessionChannel: each SSE consumer
subscribe()s its own queue (seeded with a bounded backlog so a first/late attach
still replays the recent scrollback, preserving the old buffer-until-first-
consumer behaviour), and put_output broadcasts to all subscribers. A slow
viewer's queue drops its own oldest chunk (drop-oldest, isolated per subscriber)
so one lagging tab can't starve another. The handler unsubscribes in a finally
so the subscriber list can't grow.
Stacked on the terminal fd-leak fix (same file).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix: cover terminal broadcast lifecycle
* fix: serialize terminal subscriber fanout
* test: cover terminal unsubscribe publication race
* restore timing-flaky pytest.skip on test_terminal_survives_short_lived_request_thread (keep API-updated body); changelog #5836
---------
Co-authored-by: ai-ag2026 <261867348+ai-ag2026@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
* fix: catch unhandled exception in POST /api/updates/check (defensive hardening) (#6180)
* fix: generate non-empty approval_id when gateway approval.request omits it (#6008) (#6168)
* chore: mark update-check try/except as defensive-only guard, drop #6086 linkage
Per maintainer review, the try/except wrapper is defense-in-depth only —
it does NOT fix#6086 (root cause is signal/process-group reaping).
Updated log message and added inline comment to make this explicit.
Leave #6086 open.
* test: isolate state-dir probes from user state
* docs(changelog): stamp #6168 approval_id, #6180 update-check guard, #6305 test isolation
---------
Co-authored-by: webtecnica <75556242+webtecnica@users.noreply.github.com>
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Co-authored-by: pxxD1998 <214340659+pxxD1998@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
* perf: optimize large session reconnect by adding file-size threshold to tail optimization (#6241)
When a sidecar JSON file exceeds 500 KB, the display-path tail optimization
now fires even if the message count is within the raw_budget. This prevents
sessions with few messages but large tool outputs (multi-MB JSON) from
forcing a full-scan merge of all messages on reconnect.
Changes:
- Added _sidecar_file_exceeds_threshold() helper
- Added _SIDECAR_BYTE_TAIL_THRESHOLD = 500_000 constant
- Fall-through to truncation in _state_db_since_timestamp_for_limited_display
when the sidecar file exceeds the threshold, regardless of message count
* CHANGELOG: byte-size reconnect tail optimization (#6260)
---------
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
* feat: expose msg_limit ceiling via /api/session metadata, frontend reads dynamically (#6177)
Backend exposes _MAX_MSG_LIMIT as _msg_limit_max in every /api/session
response. Frontend reads it dynamically, falling back to _MSG_LIMIT_MAX
for older servers. This removes the hand-mirrored coupling between the
two layers.
Removed test_msg_limit_ceiling_drift.py since the mirror pattern that
required the drift guard is replaced by dynamic metadata.
This is the standalone metadata-decoupling piece from #6206,
without the clamp/paging that already shipped in exp-v0.52.98 via #6152/#6154.
* fix(session): declare _msgLimitMax at module scope + CHANGELOG + gate fixes (#6214 follow-up)
The submitted PR used _msgLimitMax at two read sites (boundedReloadLimit in
_ensureMessagesLoaded, useBeforePaging in _loadOlderMessages) but never declared
it and read it before assignment -> undefined on cold load -> full-transcript
fetch every load (regression) + implicit global. Declared `let _msgLimitMax =
_MSG_LIMIT_MAX;` at module scope so the reload-width paths always read a defined
value (the static fallback) until the server's _msg_limit_max lands.
Also: defined the ceiling globals in the two inline node-harness tests that copy
_ensureMessagesLoaded's body (test_cross_session_message_load_isolation,
test_session_unread_dot_on_visit), updated the source-string assertion in
test_webui_external_refresh_frontend, and added 3 decoupling tests (backend field
present, module-scope declaration + fallback, both paths read the live ceiling)
replacing the deleted drift-guard.
Gate: Codex adversarial SAFE TO SHIP (executable probes: cold-load fallback,
mixed-version omission, live update, cross-session isolation, over-ceiling bare
refresh, msg_before row preservation). Full sharded suite green.
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
---------
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
* fix(routes): clamp GET /api/session msg_limit to a server-side ceiling
Memory: ?msg_limit= had no upper bound — a client could request
msg_limit=1000000 (or the frontend's msg_limit=9999 outline-jump path) and
force the server to assemble and serialize an unbounded message payload. The
frontend's real pagination grows windows by ~30 at a time (initial load 30,
load-older +30, msg_before paging 30), so only the outline-jump and
pathological/hostile cases ask for huge values.
Add _MAX_MSG_LIMIT (500) and clamp at the query-parse site
(max(1, min(int(_msg_limit), _MAX_MSG_LIMIT))). The ceiling is generous — far
above any legitimate visible-row window — so real pagination is unaffected. The
existing _messages_truncated signal already covers the clamped case: when the
request exceeds the ceiling, _message_window_for_display returns
_messages_offset > 0 and the client learns more rows exist (load-older still
works). The bare no-msg_limit path is intentionally unchanged — it is the
documented "give me everything" escape hatch used by branch/undo/jump-to-start
flows that need the full transcript for correct global indices.
Test: tests/test_session_msg_limit_ceiling.py — the ceiling constant is
reasonable (100..2000), an over-ceiling request is clamped and signals
truncation, and legit sizes (5/30/60/100) are unaltered. Existing window/tail
tests (19) unchanged.
Contract Routing
- Task type: memory-bound fix (unbounded msg_limit request payload).
- Touched areas: GET /api/session query parsing (api/routes.py).
- Relevant public docs: AGENTS.md, CONTRIBUTING.md, docs/CONTRACTS.md.
- State layer mutated: none (read-only endpoint response shaping).
- Contract change (additive): ?msg_limit=N is now clamped to _MAX_MSG_LIMIT=500.
Compatibility: the only frontend caller above the ceiling is outline.js's
msg_limit=9999, which now receives up to 500 rows + _messages_truncated=true
(load-older still works). All other callers (30/60) are unaffected. The bare
no-msg_limit path is unchanged.
Release note: GET /api/session now caps ?msg_limit at 500 rows server-side,
bounding the worst-case payload for oversized requests.
Model Used: builtin:zai-coding-plan/GLM-5.2 (ZCode agent).
* fix(frontend): route msg_limit-heavy paths around the server ceiling
PR #6152 clamps GET /api/session ?msg_limit= to a max of 500 server-side. Two
frontend paths relied on unbounded msg_limit and silently broke for sessions
> 500 visible rows:
1. outline.js _jumpToMessage sent msg_limit=9999 as a "give me everything" hack
so it could scroll to any message by absolute index (msg-user-<rawIdx>).
Clamped to 500, the target row's DOM id doesn't exist for early messages in
long sessions, and the jump silently no-ops (line 119 `if (r)` is falsy).
Fix: drop msg_limit=9999 — use the bare messages=1 path, which #6152 left
unbounded precisely for callers that need the full transcript. This restores
exact pre-#6152 behavior for outline jumps.
2. sessions.js _loadOlderMessages grows msg_limit by +_INITIAL_MSG_LIMIT each
load (30 -> 60 -> ... -> 500 -> clamped). Past 500 the server returns the
same clamped tail, olderMsgs shrinks to 0, and endless-scroll / "Load
earlier" silently stalls — the head becomes unreachable. Fix: once the grown
requestedLimit meets/exceeds the new _MSG_LIMIT_MAX mirror (500), switch the
primary fetch to msg_before paging (a fixed _INITIAL_MSG_LIMIT backward page
keyed off _oldestIdx) — the same bounded paging the existing race-fallback
uses, so the head stays reachable for arbitrarily long transcripts. The
response is handled by the existing prepend path (tailMatches is naturally
false for a page, so the !tailMatches branch reuses the fetched data).
Added _MSG_LIMIT_MAX = 500 constant in sessions.js mirroring backend
_MAX_MSG_LIMIT, documented as kept-in-sync.
Verification: node --check passes on both files; the backend window-contract
tests (test_session_tail_payload, test_session_message_window_renderable_tail,
19 tests) still pass. No JS test harness exists in this repo (vanilla JS,
manual browser verification per TESTING.md) — manual verification needed: open a
> 500-message session, (a) click an outline entry for an early message and
confirm it scrolls+flashes, (b) scroll up past 500 loaded rows and confirm
"Load earlier" keeps producing older messages without stalling.
Contract Routing
- Task type: frontend follow-up to a backend memory fix (#6152).
- Touched areas: outline jump-to-message (static/outline.js), endless-scroll /
load-older (static/sessions.js _loadOlderMessages).
- Relevant public docs: AGENTS.md, CONTRIBUTING.md, docs/CONTRACTS.md.
- State layer mutated: client-side S.messages (the active-session transcript),
same as the existing load-older path. No backend state change.
- Scope boundaries: does not weaken the #6152 clamp. The bare messages=1 path
(outline) was intentionally left unbounded by #6152; the msg_before paging
path (load-older) is bounded by _INITIAL_MSG_LIMIT and never hits the ceiling.
Release note: fixes outline jump-to-message and endless scroll silently
breaking on sessions longer than 500 messages after the server-side msg_limit
ceiling (companion to #6152).
Model Used: builtin:zai-coding-plan/GLM-5.2 (ZCode agent).
* test(frontend): update static-source assertion for refactored _loadOlderMessages fetch
The static-source test test_session_message_loads_keep_explicit_longer_timeouts
asserted the exact multi-line shape of the _loadOlderMessages fetch strings.
The #6154 refactor wrapped them in a useBeforePaging ternary (tail-growth vs
msg_before paging), reindenting them — the contract the test guards (both
fetches keep timeoutMs:120000 for large state.db loads) is preserved, but the
literal source shape changed, so the assertion failed on shard 0.
Updated the assertions to match the new source shape while keeping the test's
intent: assert each fetch URL + its {timeoutMs:120000} survive in sessions.js.
Model Used: builtin:zai-coding-plan/GLM-5.2 (ZCode agent).
* test(msg-limit): stopgap drift guard for _MSG_LIMIT_MAX === _MAX_MSG_LIMIT
Reviewer guidance (#6154 round-2): the stopgap drift-test belongs in THIS PR
(#6152) because _MAX_MSG_LIMIT is defined here. The frontend _MSG_LIMIT_MAX
mirror lives on #6154 (not master yet), so the assertion SKIPS when the JS
constant is absent and activates automatically once both branches land on master.
The guard: extract _MAX_MSG_LIMIT from api/routes.py and _MSG_LIMIT_MAX from
static/sessions.js via regex; assert equality when both present. If they drift,
load-older silently stalls at the wrong value (the exact regression #6154 fixed).
Retired by #6177 (metadata-exposure), which removes the mirror entirely.
Verified: on this branch the cross-check SKIPS (_MSG_LIMIT_MAX absent, as
expected — #6154 not landed) and the backend-existence test PASSES. Extraction
logic confirmed correct against simulated match (500==500 passes) and drift
(999!=500 fails) cases.
Model Used: builtin:zai-coding-plan/GLM-5.2 (ZCode agent).
* fix(session): prevent silent row-loss in msg_limit ceiling paging (gate follow-up)
Adversarial review of #6152/#6154 reproduced two silent row-loss regressions in
static/sessions.js; both fixed here, plus a stale source-string assertion update
and the CHANGELOG entry:
1. _loadOlderMessages: gate the suffix-continuity heuristic on !useBeforePaging so
a raw-row-heavy msg_before cursor page whose visible text repeats the current
tail can't be misclassified as the cumulative tail and wholesale-replace it
(previously dropped 470 of 940 rows). msg_before pages are always prepended.
2. loadSession same-session refresh: only apply the width-hint msg_limit when it
is <= _MSG_LIMIT_MAX; above the ceiling the backend would clamp and silently
shrink an already-loaded >500-row transcript to the last 500. Over-ceiling
reloads now use the bare full-transcript path (no msg_limit / no expand_renderable).
3. tests/test_webui_external_refresh_frontend.py: update the reloadLimitParam
source assertion to the boundedReloadLimit form.
Re-gate: Codex adversarial SAFE TO SHIP (940/940 rows retained on cursor paging,
1000/1000 on refresh, no seam duplication, small sessions still use the fast tail
window); targeted + sessions/pagination/regression suites green.
Co-authored-by: Ruby Hartono <rh-id@users.noreply.github.com>
* test: fix two node-harness tests broken by the boundedReloadLimit change
- test_issue3162_ensure_messages_loaded: replace the brittle fixed 4500-char
window (which the boundedReloadLimit lines pushed the carry-forward past) with
robust brace-balance function-body extraction.
- test_cross_session_message_load_isolation: define _MSG_LIMIT_MAX=500 in the node
harness env; _ensureMessagesLoaded now references it, and an undefined value made
boundedReloadLimit=null -> the fetch URL dropped msg_limit/expand_renderable and
mismatched the ordered api() harness -> stall/timeout.
---------
Co-authored-by: Ruby Hartono <58564005+rh-id@users.noreply.github.com>
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: Ruby Hartono <rh-id@users.noreply.github.com>
Reviewer regression (reproduced): _STATE_DB_DISPLAY_ROW_BACKSTOP (newest-50k SQL
cap) was applied UNCONDITIONALLY to the GET /api/session display path's state.db
read. But merge_session_messages_append_only needs the rows at/around the
session's truncation_boundary (the preserved prefix) to reconcile correctly. A
newest-N-only cap drops those boundary rows for a >N-row session and corrupts
the merge (silently losing the preserved prefix), and also prevents older-page
(msg_before) requests from ever reaching them. So a pathological-but-real >50k-
row compressed session got a corrupted merge / unreachable older history rather
than a clean bound.
Fix: gate the backstop on the SAME conditions
_state_db_since_timestamp_for_limited_display uses to decide a read is
boundary-free — not msg_before paging, and no truncation_watermark /
truncation_boundary. Those cases stay on the full (uncapped) read; the backstop
is now a defensive net for the common uncompressed initial-tail path only.
Extracted into _state_db_backstop_limit_for_display for direct test coverage.
The get_state_db_session_messages(limit=) signature + newest-rows subquery are
unchanged — only the display-path APPLICATION of the cap is gated.
Test: tests/test_state_db_backstop_boundary_gate.py — backstop applies with no
boundary prefix; SKIPPED when truncation_boundary / truncation_watermark set or
msg_before paging; empty-string markers treated as unset (matching the existing
helper). Existing backstop + reconciliation suites (53 tests) unchanged.
Model Used: builtin:zai-coding-plan/GLM-5.2 (ZCode agent).
Memory: get_state_db_session_messages had no SQL LIMIT — it full-scanned the
state.db messages table and fetchall()'d into a Python list. The display path
(GET /api/session) already has a careful since_timestamp optimization that
bounds the common uncompressed tail load, but it deliberately bails (returns
None) on compressed sessions (truncation_watermark/boundary) and msg_before
paging — in those cases the read used to full-scan with no cap. A
pathological/huge state.db could then materialize unbounded rows into memory.
Add an opt-in `limit` parameter to get_state_db_session_messages (applied as a
SQL LIMIT that keeps the NEWEST rows via a DESC subquery re-sorted ASC). It is
a BACKSTOP, not a semantic window: the display window counts visible rows
post-reconciliation, so a true window LIMIT here would corrupt the
sidecar/state.db merge (see _state_db_since_timestamp_for_limited_display,
which deliberately does NOT SQL-LIMIT raw rows for exactly that reason).
Thread a generous _STATE_DB_DISPLAY_ROW_BACKSTOP (50000) from the GET /api/session
display caller only. The full-history model-context callers (reconciliation at
models.py:3437, context reconstruction at :8576, new-turn context at
streaming.py:8443) do NOT pass a limit — their semantics require the full
history, unchanged.
Test: tests/test_state_db_read_backstop.py — no-limit returns full history
oldest-first (model-context contract unchanged); limit=N keeps the newest N
re-sorted oldest-first; limit > row count returns all; limit<=0 clamps to 1
(newest); invalid limit falls back to unbounded; limit composes with
since_timestamp. Existing state.db active-filter + reconciliation suites (54
tests) unchanged.
Contract Routing
- Task type: memory-bound fix (unbounded state.db row materialization on the
display path's bail cases).
- Touched areas: state.db message reader (api/models.py) + GET /api/session
display caller (api/routes.py). NOT the model-context callers.
- Relevant public docs: AGENTS.md, CONTRIBUTING.md, docs/CONTRACTS.md,
docs/rfcs/webui-run-state-consistency-contract.md (Model context layer).
- State layer mutated: none (read-only). The model-context read path is
UNCHANGED (no limit passed) — Invariant 1 (visible current turns enter model
context) and the reconciliation contract are preserved. Only the display path
gets the defensive cap, which does not affect what the agent receives.
- Scope boundaries: this is narrower than a naive "add LIMIT everywhere" —
_state_db_since_timestamp_for_limited_display's docstring explains why a
semantic window LIMIT would corrupt the merge; this cap is purely a
pathological-input safety net above any legitimate session size.
Release note: GET /api/session's state.db read now has a defensive row backstop,
capping memory when the bounded since_timestamp optimization bails on
compressed/paged sessions.
Model Used: builtin:zai-coding-plan/GLM-5.2 (ZCode agent).
Reconcile the gateway pending-mirror in _session_attention_summary()
before counting sidebar approval attention, so a parent session's red
approval dot clears once the underlying gateway approval has drained.
Live approvals stay visible; clarify fallback unchanged. WebUI read-path
mitigation; child-session context-propagation root cause stays in #6100.
Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
Co-authored-by: rodboev <rodboev@users.noreply.github.com>
Codex re-gate found the same derived-title leak on the sibling /api/sessions/search
endpoint (3 branches redacted only 'title'). Extract _redact_sidebar_title_fields()
shared helper, apply it in both _sidebar_session_response_item and all 3 search
branches, read api_redact_enabled once per search response (mirrors #4662). +helper
test + source-guard that every search branch calls the redactor.
Codex gate found the #6056 derived display_title (from raw user-message content)
bypassed sidebar redaction — the serializer redacted only 'title', so a credential
in a delegated goal leaked to the sidebar even with api_redact_enabled=True.
Redact display_title + _state_db_title + parent_title on the same path; +regression
test (proven to fail on the unfixed serializer).
Gate review follow-ups for the #6022 no-leak rule: every non-deliberate
newSession/session-new call site passes worktree:false; only deliberate
New Chat inherits the config default.
- Strict boolean config read: only YAML true enables the default;
quoted strings/ints/lists/dicts/null fall to the safe no-worktree
default instead of truthiness-coercing.
- Presence-based explicit detection: 'worktree' in body — an explicit
worktree:null is an opt-out, never a fall-through to config.
- Guarded remaining auto-mint call sites with explicit worktree:false:
profile switch (panels.js), promptWorkspacePath + switchToWorkspace
blank-page binds (panels.js), promptNewFile + promptNewFolder
(ui.js), terminal auto-session (commands.js).
- Tests: worktree:null vs config-true (factory must not be called),
strict-boolean malformed-shape matrix, static regression gates for
every auto-mint site + a bare-body sweep so future auto-mint paths
can't silently omit the key; updated the cmdTerminal preflight
assertion for the new call shape.