Skip to content
Closed
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
4 changes: 4 additions & 0 deletions gateway/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ class SessionResetPolicy:
idle_minutes: int = 1440 # Minutes of inactivity before reset (24 hours)
notify: bool = True # Send a notification to the user when auto-reset occurs
notify_exclude_platforms: tuple = ("api_server", "webhook") # Platforms that don't get reset notifications
reset_suspended: bool = True # Reset sessions that were stopped/interrupted mid-turn

def to_dict(self) -> Dict[str, Any]:
return {
Expand All @@ -232,6 +233,7 @@ def to_dict(self) -> Dict[str, Any]:
"idle_minutes": self.idle_minutes,
"notify": self.notify,
"notify_exclude_platforms": list(self.notify_exclude_platforms),
"reset_suspended": self.reset_suspended,
}

@classmethod
Expand All @@ -242,12 +244,14 @@ def from_dict(cls, data: Dict[str, Any]) -> "SessionResetPolicy":
idle_minutes = data.get("idle_minutes")
notify = data.get("notify")
exclude = data.get("notify_exclude_platforms")
reset_suspended = data.get("reset_suspended")
return cls(
mode=mode if mode is not None else "both",
at_hour=at_hour if at_hour is not None else 4,
idle_minutes=idle_minutes if idle_minutes is not None else 1440,
notify=_coerce_bool(notify, True),
notify_exclude_platforms=tuple(exclude) if exclude is not None else ("api_server", "webhook"),
reset_suspended=_coerce_bool(reset_suspended, True),
)


Expand Down
22 changes: 17 additions & 5 deletions gateway/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -873,12 +873,24 @@ def get_or_create_session(
entry = self._entries[session_key]

# Auto-reset sessions marked as suspended (e.g. after /stop
# broke a stuck loop — #7536). ``suspended`` is the hard
# forced-wipe signal and always wins over ``resume_pending``,
# so repeated interrupted restarts that escalate via the
# existing ``.restart_failure_counts`` stuck-loop counter
# still converge to a clean slate.
# broke a stuck loop — #7536) unless the reset policy opts into
# preserving stopped/interrupted sessions. The preserve path
# clears ``suspended`` and marks the session as resume_pending
# so the next turn gets the same recovery note as a restart
# interruption.
if entry.suspended:
policy = self.config.get_reset_policy(
platform=source.platform,
session_type=source.chat_type,
)
if not getattr(policy, "reset_suspended", True):
entry.suspended = False
entry.resume_pending = True
entry.resume_reason = entry.resume_reason or "suspended"
entry.last_resume_marked_at = now
entry.updated_at = now
self._save()
return entry
reset_reason = "suspended"
elif entry.resume_pending:
# Restart-interrupted session: preserve the session_id
Expand Down
8 changes: 7 additions & 1 deletion tests/gateway/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -136,19 +136,25 @@ def test_defaults(self):
assert policy.mode == "both"
assert policy.at_hour == 4
assert policy.idle_minutes == 1440
assert policy.reset_suspended is True

def test_from_dict_treats_null_values_as_defaults(self):
restored = SessionResetPolicy.from_dict(
{"mode": None, "at_hour": None, "idle_minutes": None}
{"mode": None, "at_hour": None, "idle_minutes": None, "reset_suspended": None}
)
assert restored.mode == "both"
assert restored.at_hour == 4
assert restored.idle_minutes == 1440
assert restored.reset_suspended is True

def test_from_dict_coerces_quoted_false_notify(self):
restored = SessionResetPolicy.from_dict({"notify": "false"})
assert restored.notify is False

def test_from_dict_coerces_quoted_false_reset_suspended(self):
restored = SessionResetPolicy.from_dict({"reset_suspended": "false"})
assert restored.reset_suspended is False


class TestStreamingConfig:
def test_from_dict_coerces_quoted_false_enabled(self):
Expand Down
23 changes: 22 additions & 1 deletion tests/gateway/test_restart_resume_pending.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@

import pytest

from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.config import GatewayConfig, Platform, PlatformConfig, SessionResetPolicy
from gateway.run import (
_auto_continue_freshness_window,
_coerce_gateway_timestamp,
Expand Down Expand Up @@ -334,6 +334,27 @@ def test_suspended_still_creates_new_session(self, tmp_path):
assert second.was_auto_reset is True
assert second.auto_reset_reason == "suspended"

def test_suspended_can_resume_when_policy_disables_reset(self, tmp_path):
"""Configured deployments can preserve stopped/interrupted sessions."""
store = SessionStore(
sessions_dir=tmp_path,
config=GatewayConfig(
default_reset_policy=SessionResetPolicy(reset_suspended=False)
),
)
source = _make_source()
first = store.get_or_create_session(source)
original_sid = first.session_id
store.suspend_session(first.session_key)

second = store.get_or_create_session(source)
assert second.session_id == original_sid
assert second.was_auto_reset is False
assert second.auto_reset_reason is None
assert second.suspended is False
assert second.resume_pending is True
assert second.resume_reason == "suspended"

def test_suspended_overrides_resume_pending(self, tmp_path):
"""Terminal escalation: a session that somehow has BOTH flags must
behave like ``suspended`` — forced wipe + auto_reset_reason."""
Expand Down
1 change: 1 addition & 0 deletions tests/gateway/test_session_reset_notify.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,3 +205,4 @@ def test_to_dict_roundtrip(self):
assert restored.notify == original.notify
assert restored.notify_exclude_platforms == original.notify_exclude_platforms
assert restored.mode == original.mode
assert restored.reset_suspended == original.reset_suspended
7 changes: 7 additions & 0 deletions website/docs/user-guide/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,13 @@ Before a session is auto-reset, the agent is given a turn to save any important

Sessions with **active background processes** are never auto-reset, regardless of policy.

Sessions interrupted by `/stop`, a gateway crash, or repeated restart failures are normally treated as suspended and reset on the next message so a stuck loop does not immediately resume. To keep those sessions resumable instead, set:

```yaml
session_reset:
reset_suspended: false
```

## Storage Locations

| What | Path | Description |
Expand Down