Repository navigation
fix(chat): stat-failure listing goes silent, and compaction summary composes after the reset (#14248, #14322) - #14392
Conversation
…14248) _build_session_entry caught os.stat failures, logged a generic filename-keyed message, and returned None -- the caller dropped the entry silently, so the session vanished from list_sessions_fast with no way for an operator to tell 'unreadable' from 'never existed', and skill distillation never saw it to report it as skipped. The log now names the session id explicitly and reads distinctly from the 'Chat session %s not found' wording used elsewhere for a session that never existed, matching the malformed-timestamp precedent in skill_distillation_scheduler.py: loud, then excluded, never silent. Adds session_listing_stat_failure_test.py, driving the real listing path (real SessionListingMixin over real files, os.stat failing for exactly one of them) rather than a stubbed lister.
…reset (#14322) _create_summary computed retained_tokens before reset_session with a comment citing #14065 -- the tracker must not reset while the history it claims to have compressed is still uncompressed -- but composed the summary (_compose_summary, and through it _extract_state, _render_state_block, _preserved_user_messages) after that reset. It could not raise today because every helper is isinstance-guarded and falls back on an unexpected shape, so the violation was silent: a future edit to any of those three helpers that starts reading tracker-derived state would reintroduce #14065 one call deeper, past the caller's except SummarizationFailed. Composition now runs before the reset, on the same side of the invariant as retained_tokens. Also fixes the multi-round idempotency test, which fed round 1's composed summary back as {'role': 'user', 'content': first} -- a shape production never writes. overflow_integration.create_summary_message actually injects {'sender': 'system', 'messageType': 'context_summary', ...}; _role_of reads role or sender, so the real artifact's role is 'system', and _preserved_user_messages rescues only role == 'user'. Under the fake role the test passed by mistaking the entire prior summary for a user instruction; under the real shape the verbatim guarantee does not compound across rounds -- a prior round's own summary swept into a later summarization window is prose to that call, not a re-protected structure, and parsing it back out would reintroduce the 'prose, not guarantee' failure #14066 exists to avoid. The test and the docstrings it backs now state that scope explicitly instead of claiming more than they demonstrate. Adds a call-order test (TestCompositionRunsBeforeTheReset) that drives the real _create_summary path and asserts compose runs before reset -- the only way to catch an ordering regression that cannot manifest as a different composed string under this suite's mocked summarizer.
✅ SSOT Configuration Compliance: Passing🎉 No hardcoded values detected that have SSOT config equivalents! |
Applied Black, isort, and autoflake to match code-quality checks. Triggered by workflow auto-fix-formatting.yml.
Review — APPROVEBoth issues verified against their acceptance criteria, and the most valuable finding here is a test that was passing for the wrong reason. #14248 — matches the criteria exactlyThe issue asks for three things, not for the session to be re-included:
Keeping the session excluded from that listing pass while logging loudly is the right call — it matches the malformed-timestamp precedent in The note about the assertion is a good catch in its own right: a loose "id appears somewhere in the message" check would pass trivially, because #14322 — ordering fix is correct, and it exposed a false testMoving The real find: Production injects a summary as Narrowing the docstring to the scope that actually holds is the honest fix. And declining to "solve" it by re-parsing a prior summary's markdown is right: that would reintroduce the prose-not-guarantee fragility the deterministic path exists to remove, as a regex over model-authored text re-run every round. One thing I addedThe behaviour behind that narrowed guarantee is a real limitation — a standing user instruction can still erode across several compaction rounds, one probabilistic step at a time. Since the module previously claimed the stronger guarantee, that limitation should be tracked rather than live only in a docstring. Filed as #14393, with the mechanism, why the obvious fix is wrong, and three fix directions (structured carry-forward alongside the summary rather than embedded in its prose). This PR is unchanged by it — the scope correction here is right; #14393 is about widening the behaviour later. Note on evidenceMutation table is statically traced, correctly labelled. Worth confirming No blocking findings. |
Closes #14248, #14322
Thinking Path
Both issues are the same failure mode on the chat session path: an ordering or
error-handling defect where nothing raises and nothing logs, so a caller sees
wrong or missing data instead of a visible failure.
#14248 —
SessionListingMixin._build_session_entry(chat_history/session_listing.py)already caught an
os.statfailure and logged it, but the message was keyed onthe filename ("Error reading file stats for %s: %s") and read no differently
from any other I/O error. It did not name the session id in a way an operator
(or a log-scraping alert) could grep for, and it did not distinguish "this
conversation exists but is unreadable" from "Chat session %s not found" — the
wording used elsewhere in
chat_historyfor a session that never existed.Because the entry is dropped either way, distillation's
_select_pending_sessionsnever even sees a
session_idfor it, so it can't emit its own "no usabletimestamp" style warning (
skill_distillation_scheduler.py:578-583) — thelisting layer is the only place this failure is observable at all, and it read
the same as routine noise.
#14322 —
ContextOverflowProtection._create_summarycomputedretained_tokensbefore
reset_session, with a comment citing #14065 that the tracker must notreset while the history it claims to have compressed is still uncompressed —
but
_compose_summary(and through it_extract_state,_render_state_block,_preserved_user_messages) ran after that reset, on the wrong side of theinvariant the comment states one statement above it. It doesn't raise today
because every helper is
isinstance-guarded and falls back on an unexpectedshape, which is exactly why it needed an issue instead of a runtime crash: the
next edit to any of those three helpers that starts reading tracker state would
reintroduce #14065 one call deeper, past the caller's
except SummarizationFailed.The issue also flagged that PR #14249's multi-round idempotency test fed round
1's composed summary back into round 2 as
{"role": "user", "content": first}—a shape production never writes.
overflow_integration.create_summary_messageactually injects
{"sender": "system", "messageType": "context_summary", ...},and
_role_ofreadsrole or sender, so the real artifact's role is"system".Under the fake
"user"role,_preserved_user_messagesmistook the entireround-1 summary blob for a user instruction and re-protected it wholesale — the
test passed for the wrong reason. Traced through the real shape: the guarantee
does not survive round 2. The issue offered two ways out — teach extraction to
recognise a prior summary's verbatim block, or scope the docstring claim to a
single round. Recognising a prior summary's rendered markdown block would mean
parsing prose back into structured data, which is precisely the "prose, not
guarantee" failure mode #14066 built the deterministic extraction path to
avoid, one layer removed. I scoped the claim instead, and turned the test into
a guard that pins the corrected scope so it can't quietly regress back to
"believed to work across every round."
What Changed
autobot-backend/chat_history/session_listing.py_build_session_entry's stat-failure log now names the session id explicitlyand reads "Chat session %s exists (%s) but its file stats could not be read
— excluded from this listing pass: %s" — grep-able by id, and worded so it
cannot be confused with "not found".
autobot-backend/chat_history/session_listing_stat_failure_test.py(new)list_sessions_fast()path over real files on disk, withos.statmonkeypatched to fail for exactly one of them (not a stubbedlister). Asserts: the healthy session still lists; the failure produces a
logged error naming the session id in a
"session <id> "position (notmerely as an incidental filename substring); the wording differs from "not
found"; the failing session is still excluded from that pass (matching the
malformed-timestamp precedent — loud, then excluded, never silent).
autobot-backend/chat_history/context_overflow.py_create_summary:_compose_summary(...)now runs beforeself.tracker.reset_session(...), on the same side of the bug(chat): summarization failure returns a success-shaped placeholder — half the conversation is destroyed and reported as compacted #14065 invariantas
retained_tokens._preserved_user_messagesdocstring corrected: the verbatim guarantee isscoped to raw
role == "user"turns within the round that summarizes them,not to a prior round's own composed summary swept into a later window.
autobot-backend/chat_history/context_overflow_boundary_test.pyTestCompositionRunsBeforeTheReset: patches the module-level_compose_summaryto record call order against areset_sessionAsyncMock,drives the real
_create_summarypath, and assertscomposeprecedesreset._compose_summarycan't raise today, so an ordering regressionwould not show up as a different composed string under this suite's mocked
summarizer — only the call order proves it.
test_two_successive_compactions_do_not_erode_the_instructionreplaced withtest_a_prior_rounds_summary_is_not_silently_re_protected_as_a_user_turn,which feeds round 2 the real
{"sender": "system", "messageType": "context_summary", ...}shape and asserts the single-round guarantee holdswhile the cross-round one explicitly does not, with a comment steering a
future author toward a real fix rather than relaxing the assertion.
Verification
Static only — per instructions, application code and the test suite were not
executed; CI runs pytest. Verified by:
python3 -m py_compileon all four touched files — clean._pick_boundary,_role_of,_preserved_user_messagesand_compose_summaryagainst each new test'sfixture to confirm the asserted outcome (documented inline in the tests
themselves).
profile = "black"/ straight-imports-before-from-imports convention alreadyused in this file.
Mutations — statically traced, not executed (no test run per instructions):
"Error reading file stats for %s: %s", filename, str(e)removes the"session <id> "phrase and the word "exists" — bothtest_unreadable_session_is_logged_with_its_idandtest_unreadable_session_wording_differs_from_not_foundgo red. (A looser"id is a substring of the message" check would NOT catch this —
chat_idisalways a substring of its own filename by construction — which is why the
first test asserts the id in a
"session <id> "position instead.)summary_text = _compose_summary(...)back tobelow
await self.tracker.reset_session(...)flipsTestCompositionRunsBeforeTheReset's recordedcall_orderto["reset", "compose"], red against the asserted["compose", "reset"].test_a_prior_rounds_summary_is_not_silently_re_protected_as_a_user_turn'sinjected_summaryback to{"role": "user", "content": first}makes_role_ofreturn"user", so_preserved_user_messagesre-protects theentire round-1 blob (which contains "never touch the prod database" as
quoted text) and the
assert ... not in secondgoes red — reproducing theoriginal mis-passing test.
Model Used
Claude Sonnet 5