Merge pull request #51025 from NousResearch/salvage/cron-autoreset-override

fix(gateway): consume was_auto_reset so /model survives session auto-reset (#48031)
This commit is contained in:
kshitij 2026-06-24 19:20:11 +05:30 committed by GitHub
commit ae20c3fb90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 106 additions and 4 deletions

View file

@ -8969,7 +8969,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
self._record_telegram_topic_binding(source, session_entry)
except Exception:
logger.debug("Failed to record Telegram topic binding", exc_info=True)
if getattr(session_entry, "was_auto_reset", False):
# Capture and immediately consume was_auto_reset so it does not
# re-fire on subsequent messages — preventing the cleanup from
# wiping model/reasoning overrides set between turns (Closes #48031).
_was_auto_reset = getattr(session_entry, "was_auto_reset", False)
if _was_auto_reset:
# Treat auto-reset as a full conversation boundary — drop every
# session-scoped transient state so the fresh session does not
# inherit the previous conversation's model/reasoning overrides
@ -8978,11 +8982,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
self._set_session_reasoning_override(session_key, None)
if hasattr(self, "_pending_model_notes"):
self._pending_model_notes.pop(session_key, None)
session_entry.was_auto_reset = False
# Emit session:start for new or auto-reset sessions
_is_new_session = (
session_entry.created_at == session_entry.updated_at
or getattr(session_entry, "was_auto_reset", False)
or _was_auto_reset
or getattr(session_entry, "is_fresh_reset", False)
)
# Consume the is_fresh_reset flag immediately so it doesn't leak
@ -9018,7 +9023,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# If the previous session expired and was auto-reset, prepend a notice
# so the agent knows this is a fresh conversation (not an intentional /reset).
if getattr(session_entry, 'was_auto_reset', False):
if _was_auto_reset:
reset_reason = getattr(session_entry, 'auto_reset_reason', None) or 'idle'
if reset_reason == "suspended":
context_note = "[System note: The user's previous session was stopped and suspended. This is a fresh conversation with no prior context.]"
@ -9077,7 +9082,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
except Exception as e:
logger.debug("Auto-reset notification failed (non-fatal): %s", e)
session_entry.was_auto_reset = False
# was_auto_reset is already consumed in the cleanup block above
# (single source of truth); only the reset reason needs clearing here.
session_entry.auto_reset_reason = None
# Auto-load skill(s) for topic/channel bindings (Telegram DM Topics,

View file

@ -1488,6 +1488,11 @@ class GatewaySlashCommandsMixin:
if _sess_db is not None:
try:
_sess_entry = self.session_store.get_or_create_session(source)
# If this session was auto-reset, consume the flag so the
# next regular message's cleanup does not wipe the model
# override just stored below (Closes #48031).
if getattr(_sess_entry, "was_auto_reset", False):
_sess_entry.was_auto_reset = False
_sess_db.update_session_model(
_sess_entry.session_id, result.new_model
)

View file

@ -0,0 +1,91 @@
"""Regression test for #48031 — /model switch lost after session auto-reset.
When `/model X` is the FIRST message after an idle/daily/suspended auto-reset,
it stores a session model override but the `was_auto_reset` flag is left True
(the slash-command path doesn't pass through the message handler that consumes
it). On the NEXT regular message, the auto-reset cleanup block in
`_handle_message_with_agent` pops the freshly-stored override BEFORE the flag
is consumed, so the switch is silently lost and resolution falls back to the
config default while the session DB still shows the switched model (a
two-sources-of-truth divergence).
The fix consumes `was_auto_reset` at two sites:
1. the cleanup block in gateway/run.py captures it into a local and sets the
attribute False immediately (so it can't re-fire next message);
2. the slash-command model path in gateway/slash_commands.py consumes it
before storing the override (so a /model-first-after-reset isn't wiped).
These are AST invariants load-bearing pins that fail if either consume is
removed (mirrors test_35809_auto_reset_clean_context.py's approach).
"""
from __future__ import annotations
import ast
import inspect
from gateway import run as gateway_run
from gateway import slash_commands as gateway_slash
def _assigns_false(node: ast.AST, attr: str) -> bool:
"""True if `node` contains an assignment `<something>.<attr> = False`."""
for sub in ast.walk(node):
if isinstance(sub, ast.Assign):
for tgt in sub.targets:
if (
isinstance(tgt, ast.Attribute)
and tgt.attr == attr
and isinstance(sub.value, ast.Constant)
and sub.value.value is False
):
return True
return False
def test_run_consumes_was_auto_reset_in_cleanup_block():
"""The auto-reset cleanup block in gateway/run.py must set
`session_entry.was_auto_reset = False` so the cleanup (which pops the
session model/reasoning overrides) cannot re-fire on the next message and
wipe an override stored between turns (#48031)."""
tree = ast.parse(inspect.getsource(gateway_run))
# Find the cleanup branch: an `if <flag>:` block that pops a model/reasoning
# override AND clears the flag. We assert at least one such block sets
# was_auto_reset False.
found = False
for node in ast.walk(tree):
if not isinstance(node, ast.If):
continue
names = {
n.attr
for n in ast.walk(node)
if isinstance(n, ast.Attribute)
}
calls = {
n.func.attr
for n in ast.walk(node)
if isinstance(n, ast.Call) and isinstance(n.func, ast.Attribute)
}
# The cleanup block references the reasoning-override setter and pops
# pending model notes — fingerprint of the transient-state cleanup.
if "_set_session_reasoning_override" in calls and _assigns_false(node, "was_auto_reset"):
found = True
break
assert found, (
"gateway/run.py auto-reset cleanup block must consume "
"`was_auto_reset` (set it False) so it can't re-fire and wipe a "
"model override stored between turns (#48031)."
)
def test_slash_command_model_path_consumes_was_auto_reset():
"""The slash-command model path in gateway/slash_commands.py must consume
`was_auto_reset` before storing the new model override, so a
/model-first-after-auto-reset isn't wiped by the next message's cleanup
(#48031)."""
src = inspect.getsource(gateway_slash)
tree = ast.parse(src)
assert _assigns_false(tree, "was_auto_reset"), (
"gateway/slash_commands.py model path must set "
"`was_auto_reset = False` before storing the model override (#48031)."
)