Skip to content

Commit 3065d48

Browse files
authored
fix(gateway): auto-resume sessions after drain-timeout restart (NousResearch#11852) (NousResearch#12301)
The shutdown banner promised "send any message after restart to resume where you left off" but the code did the opposite: a drain-timeout restart skipped the .clean_shutdown marker, which made the next startup call suspend_recently_active(), which marked the session suspended, which made get_or_create_session() spawn a fresh session_id with a 'Session automatically reset. Use /resume...' notice — contradicting the banner. Introduce a resume_pending state on SessionEntry that is distinct from suspended. Drain-timeout shutdown flags active sessions resume_pending instead of letting startup-wide suspension destroy them. The next message on the same session_key preserves the session_id, reloads the transcript, and the agent receives a reason-aware restart-resume system note that subsumes the existing tool-tail auto-continue note (PR NousResearch#9934). Terminal escalation still flows through the existing .restart_failure_counts stuck-loop counter (PR NousResearch#7536, threshold 3) — no parallel counter on SessionEntry. suspended still wins over resume_pending in get_or_create_session() so genuinely stuck sessions converge to a clean slate. Spec: PR NousResearch#11852 (BrennerSpear). Implementation follows the spec with the approved correction (reuse .restart_failure_counts rather than adding a resume_attempts field). Changes: - gateway/session.py: SessionEntry.resume_pending/resume_reason/ last_resume_marked_at + to_dict/from_dict; SessionStore .mark_resume_pending()/clear_resume_pending(); get_or_create_session() returns existing entry when resume_pending (suspended still wins); suspend_recently_active() skips resume_pending entries. - gateway/run.py: _stop_impl() drain-timeout branch marks active sessions resume_pending before _interrupt_running_agents(); _run_agent() injects reason-aware restart-resume system note that subsumes the tool-tail case; successful-turn cleanup also clears resume_pending next to _clear_restart_failure_count(); _notify_active_sessions_of_shutdown() softens the restart banner to 'I'll try to resume where you left off' (honest about stuck-loop escalation). - tests/gateway/test_restart_resume_pending.py: 29 new tests covering SessionEntry roundtrip, mark/clear helpers, get_or_create_session precedence (suspended > resume_pending), suspend_recently_active skip, drain-timeout mark reason (restart vs shutdown), system-note injection decision tree (including tool-tail subsumption), banner wording, and stuck-loop escalation override.
1 parent de175d7 commit 3065d48

3 files changed

Lines changed: 782 additions & 5 deletions

File tree

gateway/run.py

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1539,7 +1539,7 @@ async def _notify_active_sessions_of_shutdown(self) -> None:
15391539
action = "restarting" if self._restart_requested else "shutting down"
15401540
hint = (
15411541
"Your current task will be interrupted. "
1542-
"Send any message after restart to resume where it left off."
1542+
"Send any message after restart and I'll try to resume where you left off."
15431543
if self._restart_requested
15441544
else "Your current task will be interrupted."
15451545
)
@@ -2373,6 +2373,27 @@ async def _stop_impl() -> None:
23732373
timeout,
23742374
self._running_agent_count(),
23752375
)
2376+
# Mark forcibly-interrupted sessions as resume_pending BEFORE
2377+
# interrupting the agents. This preserves each session's
2378+
# session_id + transcript so the next message on the same
2379+
# session_key auto-resumes from the existing conversation
2380+
# instead of getting routed through suspend_recently_active()
2381+
# and converted into a fresh session. Terminal escalation
2382+
# for genuinely stuck sessions still flows through the
2383+
# existing ``.restart_failure_counts`` stuck-loop counter
2384+
# (incremented below, threshold 3), which sets
2385+
# ``suspended=True`` and overrides resume_pending.
2386+
_resume_reason = (
2387+
"restart_timeout" if self._restart_requested else "shutdown_timeout"
2388+
)
2389+
for _sk in list(active_agents.keys()):
2390+
try:
2391+
self.session_store.mark_resume_pending(_sk, _resume_reason)
2392+
except Exception as _e:
2393+
logger.debug(
2394+
"mark_resume_pending failed for %s: %s",
2395+
_sk[:20], _e,
2396+
)
23762397
self._interrupt_running_agents(
23772398
"Gateway restarting" if self._restart_requested else "Gateway shutting down"
23782399
)
@@ -4152,8 +4173,20 @@ async def _handle_message_with_agent(self, event, source, _quick_key: str):
41524173
# Successful turn — clear any stuck-loop counter for this session.
41534174
# This ensures the counter only accumulates across CONSECUTIVE
41544175
# restarts where the session was active (never completed).
4176+
#
4177+
# Also clear the resume_pending flag (set by drain-timeout
4178+
# shutdown) — the turn ran to completion, so recovery
4179+
# succeeded and subsequent messages should no longer receive
4180+
# the restart-interruption system note.
41554181
if session_key:
41564182
self._clear_restart_failure_count(session_key)
4183+
try:
4184+
self.session_store.clear_resume_pending(session_key)
4185+
except Exception as _e:
4186+
logger.debug(
4187+
"clear_resume_pending failed for %s: %s",
4188+
session_key[:20], _e,
4189+
)
41574190

41584191
# Surface error details when the agent failed silently (final_response=None)
41594192
if not response and agent_result.get("failed"):
@@ -9427,7 +9460,40 @@ def _approval_notify_sync(approval_data: dict) -> None:
94279460
# restart, crash, SIGTERM). Prepend a system note so the model
94289461
# finishes processing the pending tool results before addressing
94299462
# the user's new message. (#4493)
9430-
if agent_history and agent_history[-1].get("role") == "tool":
9463+
#
9464+
# Session-level resume_pending (set on drain-timeout shutdown)
9465+
# escalates the wording — the transcript's last role may be
9466+
# anything (tool, assistant with unfinished work, etc.), so we
9467+
# give a stronger, reason-aware instruction that subsumes the
9468+
# tool-tail case.
9469+
_resume_entry = None
9470+
if session_key:
9471+
try:
9472+
_resume_entry = self.session_store._entries.get(session_key)
9473+
except Exception:
9474+
_resume_entry = None
9475+
_is_resume_pending = bool(
9476+
_resume_entry is not None and getattr(_resume_entry, "resume_pending", False)
9477+
)
9478+
9479+
if _is_resume_pending:
9480+
_reason = getattr(_resume_entry, "resume_reason", None) or "restart_timeout"
9481+
_reason_phrase = (
9482+
"a gateway restart"
9483+
if _reason == "restart_timeout"
9484+
else "a gateway shutdown"
9485+
if _reason == "shutdown_timeout"
9486+
else "a gateway interruption"
9487+
)
9488+
message = (
9489+
f"[System note: Your previous turn in this session was interrupted "
9490+
f"by {_reason_phrase}. The conversation history below is intact. "
9491+
f"If it contains unfinished tool result(s), process them first and "
9492+
f"summarize what was accomplished, then address the user's new "
9493+
f"message below.]\n\n"
9494+
+ message
9495+
)
9496+
elif agent_history and agent_history[-1].get("role") == "tool":
94319497
message = (
94329498
"[System note: Your previous turn was interrupted before you could "
94339499
"process the last tool result(s). The conversation history contains "

gateway/session.py

Lines changed: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,19 @@ class SessionEntry:
377377
# this session (create a new session_id) so the user starts fresh.
378378
# Set by /stop to break stuck-resume loops (#7536).
379379
suspended: bool = False
380-
380+
381+
# When True the session was interrupted by a gateway restart/shutdown
382+
# drain timeout, but recovery is still expected. Unlike ``suspended``,
383+
# ``resume_pending`` preserves the existing session_id on next access —
384+
# the user stays on the same transcript and the agent auto-continues
385+
# from where it left off. Cleared after the next successful turn.
386+
# Escalation to ``suspended`` is handled by the existing
387+
# ``.restart_failure_counts`` stuck-loop counter (#7536), not by a
388+
# parallel counter on this entry.
389+
resume_pending: bool = False
390+
resume_reason: Optional[str] = None # e.g. "restart_timeout"
391+
last_resume_marked_at: Optional[datetime] = None
392+
381393
def to_dict(self) -> Dict[str, Any]:
382394
result = {
383395
"session_key": self.session_key,
@@ -397,6 +409,13 @@ def to_dict(self) -> Dict[str, Any]:
397409
"cost_status": self.cost_status,
398410
"memory_flushed": self.memory_flushed,
399411
"suspended": self.suspended,
412+
"resume_pending": self.resume_pending,
413+
"resume_reason": self.resume_reason,
414+
"last_resume_marked_at": (
415+
self.last_resume_marked_at.isoformat()
416+
if self.last_resume_marked_at
417+
else None
418+
),
400419
}
401420
if self.origin:
402421
result["origin"] = self.origin.to_dict()
@@ -414,7 +433,15 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionEntry":
414433
platform = Platform(data["platform"])
415434
except ValueError as e:
416435
logger.debug("Unknown platform value %r: %s", data["platform"], e)
417-
436+
437+
last_resume_marked_at = None
438+
_lrma = data.get("last_resume_marked_at")
439+
if _lrma:
440+
try:
441+
last_resume_marked_at = datetime.fromisoformat(_lrma)
442+
except (TypeError, ValueError):
443+
last_resume_marked_at = None
444+
418445
return cls(
419446
session_key=data["session_key"],
420447
session_id=data["session_id"],
@@ -434,6 +461,9 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionEntry":
434461
cost_status=data.get("cost_status", "unknown"),
435462
memory_flushed=data.get("memory_flushed", False),
436463
suspended=data.get("suspended", False),
464+
resume_pending=data.get("resume_pending", False),
465+
resume_reason=data.get("resume_reason"),
466+
last_resume_marked_at=last_resume_marked_at,
437467
)
438468

439469

@@ -710,9 +740,23 @@ def get_or_create_session(
710740
entry = self._entries[session_key]
711741

712742
# Auto-reset sessions marked as suspended (e.g. after /stop
713-
# broke a stuck loop — #7536).
743+
# broke a stuck loop — #7536). ``suspended`` is the hard
744+
# forced-wipe signal and always wins over ``resume_pending``,
745+
# so repeated interrupted restarts that escalate via the
746+
# existing ``.restart_failure_counts`` stuck-loop counter
747+
# still converge to a clean slate.
714748
if entry.suspended:
715749
reset_reason = "suspended"
750+
elif entry.resume_pending:
751+
# Restart-interrupted session: preserve the session_id
752+
# and return the existing entry so the transcript
753+
# reloads intact. ``resume_pending`` is cleared after
754+
# the NEXT successful turn completes (not here), which
755+
# means a re-interrupted retry keeps trying — the
756+
# stuck-loop counter handles terminal escalation.
757+
entry.updated_at = now
758+
self._save()
759+
return entry
716760
else:
717761
reset_reason = self._should_reset(entry, source)
718762
if not reset_reason:
@@ -802,6 +846,55 @@ def suspend_session(self, session_key: str) -> bool:
802846
return True
803847
return False
804848

849+
def mark_resume_pending(
850+
self,
851+
session_key: str,
852+
reason: str = "restart_timeout",
853+
) -> bool:
854+
"""Mark a session as resumable after a restart interruption.
855+
856+
Unlike ``suspend_session()``, this preserves the existing
857+
``session_id`` and the transcript. The next call to
858+
``get_or_create_session()`` for this key returns the same entry
859+
so the user auto-resumes on the same conversation lane.
860+
861+
Returns True if the session existed and was marked.
862+
"""
863+
with self._lock:
864+
self._ensure_loaded_locked()
865+
if session_key in self._entries:
866+
entry = self._entries[session_key]
867+
# Never override an explicit ``suspended`` — that is a hard
868+
# forced-wipe signal (from /stop or stuck-loop escalation).
869+
if entry.suspended:
870+
return False
871+
entry.resume_pending = True
872+
entry.resume_reason = reason
873+
entry.last_resume_marked_at = _now()
874+
self._save()
875+
return True
876+
return False
877+
878+
def clear_resume_pending(self, session_key: str) -> bool:
879+
"""Clear the resume-pending flag after a successful resumed turn.
880+
881+
Called from the gateway after ``run_conversation()`` returns a
882+
final response for a session that had ``resume_pending=True``,
883+
signalling that recovery succeeded.
884+
885+
Returns True if a flag was cleared.
886+
"""
887+
with self._lock:
888+
self._ensure_loaded_locked()
889+
entry = self._entries.get(session_key)
890+
if entry is None or not entry.resume_pending:
891+
return False
892+
entry.resume_pending = False
893+
entry.resume_reason = None
894+
entry.last_resume_marked_at = None
895+
self._save()
896+
return True
897+
805898
def prune_old_entries(self, max_age_days: int) -> int:
806899
"""Drop SessionEntry records older than max_age_days.
807900
@@ -861,6 +954,12 @@ def suspend_recently_active(self, max_age_seconds: int = 120) -> int:
861954
(#7536). Only suspends sessions updated within *max_age_seconds*
862955
to avoid resetting long-idle sessions that are harmless to resume.
863956
Returns the number of sessions that were suspended.
957+
958+
Entries flagged ``resume_pending=True`` are skipped — those were
959+
marked intentionally by the drain-timeout path as recoverable.
960+
Terminal escalation for genuinely stuck ``resume_pending`` sessions
961+
is handled by the existing ``.restart_failure_counts`` stuck-loop
962+
counter, which runs after this method on startup.
864963
"""
865964
from datetime import timedelta
866965

@@ -869,6 +968,8 @@ def suspend_recently_active(self, max_age_seconds: int = 120) -> int:
869968
with self._lock:
870969
self._ensure_loaded_locked()
871970
for entry in self._entries.values():
971+
if entry.resume_pending:
972+
continue
872973
if not entry.suspended and entry.updated_at >= cutoff:
873974
entry.suspended = True
874975
count += 1

0 commit comments

Comments
 (0)