Phase 1.4.2: Session TTL and auto-expiry#32
Phase 1.4.2: Session TTL and auto-expiry#32richard-devbot wants to merge 14 commits intoCursorTouch:mainfrom
Conversation
Review Summary by QodoAdd session TTL and auto-expiry with activity tracking
WalkthroughsDescription• Add session TTL tracking with configurable auto-expiry mechanism • Implement is_expired() method checking idle time against TTL • Add touch() method to refresh activity clock and extend session lifetime • Automatically call touch() in add_message() for activity tracking • Export DEFAULT_SESSION_TTL constant (3600s) for consistent configuration • Add comprehensive test suite with 10 tests covering expiry scenarios Diagramflowchart LR
A["Session Creation"] -- "Initialize _last_activity" --> B["Session Active"]
B -- "add_message() or touch()" --> C["Clock Reset"]
C -- "Check is_expired()" --> D{Idle Time > TTL?}
D -- "No" --> B
D -- "Yes" --> E["Session Expired"]
File Changes1. operator_use/session/views.py
|
Code Review by Qodo
1. TTL not config-driven
|
Ready for review & merge ✅Hey @Jeomon, PR #32 is ready to land. This closes Issue #24 (session TTL and auto-expiry). What's in here:
Known limitation (flagged transparently): This PR is independent and can merge at any time. No conflicts with main. |
operator_use/session/views.py
Outdated
| DEFAULT_SESSION_TTL = 3600.0 # 1 hour | ||
|
|
||
|
|
||
| @dataclass | ||
| class Session: | ||
| """Session data class.""" | ||
|
|
||
| id: str | ||
| messages: list[BaseMessage]=field(default_factory=list) | ||
| created_at: datetime=field(default_factory=datetime.now) | ||
| updated_at: datetime=field(default_factory=datetime.now) | ||
| messages: list[BaseMessage] = field(default_factory=list) | ||
| created_at: datetime = field(default_factory=datetime.now) | ||
| updated_at: datetime = field(default_factory=datetime.now) | ||
| metadata: dict[str, Any] = field(default_factory=dict) | ||
| ttl: float = DEFAULT_SESSION_TTL | ||
| _last_activity: float = field(init=False, default_factory=time.monotonic) |
There was a problem hiding this comment.
1. Ttl not config-driven 📎 Requirement gap ⛨ Security
Session TTL is hard-coded to 1 hour and not sourced from config.json, so the system cannot enforce the required configurable TTL with a 24-hour default.
Agent Prompt
## Issue description
Session TTL is hard-coded (`DEFAULT_SESSION_TTL = 3600.0`) and not configurable via `config.json`, and the default is not the required 24 hours.
## Issue Context
Compliance requires a configurable TTL stored in `config.json` under a `session` block (or equivalent) and actually used by the session system, with a default of 24 hours when unspecified.
## Fix Focus Areas
- operator_use/session/views.py[10-23]
- operator_use/config/service.py[289-307]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| ttl: float = DEFAULT_SESSION_TTL | ||
| _last_activity: float = field(init=False, default_factory=time.monotonic) | ||
|
|
||
| def add_message(self, message: BaseMessage) -> None: | ||
| """Add a message and update updated_at.""" | ||
| self.messages.append(message) | ||
| self.updated_at = datetime.now() | ||
| self.touch() | ||
|
|
||
| def get_history(self) -> list[BaseMessage]: | ||
| """Return the message history.""" |
There was a problem hiding this comment.
2. Loaded sessions never expire 📎 Requirement gap ⛨ Security
_last_activity is initialized with time.monotonic() on object creation and is not restored from persisted timestamps, so sessions loaded from disk will appear “fresh” and won’t expire based on real age/idle time.
Agent Prompt
## Issue description
Sessions loaded from disk will not expire correctly because `_last_activity` is not persisted/restored and is initialized at load time.
## Issue Context
To expire sessions on next access, the expiry calculation must use a persisted timestamp (e.g., `updated_at` or a dedicated `last_activity` field stored in the JSONL metadata) and accessors like `get_or_create()` should invalidate/delete expired sessions.
## Fix Focus Areas
- operator_use/session/views.py[22-46]
- operator_use/session/service.py[29-58]
- operator_use/session/service.py[74-84]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
| """Tests for session TTL and auto-expiry. | ||
|
|
||
| Validates that Session tracks last_activity, expires after its | ||
| configurable TTL, and that touch() extends the session lifetime. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import time | ||
|
|
||
| from operator_use.session.views import Session, DEFAULT_SESSION_TTL | ||
|
|
||
|
|
||
| class TestSessionTTL: | ||
| def test_new_session_not_expired(self) -> None: | ||
| session = Session(id="test-1") | ||
| assert not session.is_expired() | ||
|
|
||
| def test_default_ttl_is_one_hour(self) -> None: | ||
| session = Session(id="test-2") | ||
| assert session.ttl == DEFAULT_SESSION_TTL | ||
| assert session.ttl == 3600.0 | ||
|
|
||
| def test_custom_ttl(self) -> None: | ||
| session = Session(id="test-3", ttl=120.0) | ||
| assert session.ttl == 120.0 | ||
|
|
||
| def test_session_expires_after_ttl(self) -> None: | ||
| session = Session(id="test-4", ttl=0.05) # 50ms TTL | ||
| assert not session.is_expired() | ||
| time.sleep(0.1) | ||
| assert session.is_expired() | ||
|
|
||
| def test_touch_resets_expiry_clock(self) -> None: | ||
| session = Session(id="test-5", ttl=0.1) # 100ms TTL | ||
| time.sleep(0.06) # 60ms elapsed — not expired yet | ||
| session.touch() # reset the clock | ||
| time.sleep(0.06) # 60ms since touch — still within TTL | ||
| assert not session.is_expired() | ||
|
|
||
| def test_session_expires_after_touch_if_ttl_passes(self) -> None: | ||
| session = Session(id="test-6", ttl=0.05) | ||
| session.touch() | ||
| time.sleep(0.1) # past TTL since last touch | ||
| assert session.is_expired() | ||
|
|
||
| def test_zero_ttl_immediately_expired(self) -> None: | ||
| session = Session(id="test-7", ttl=0.0) | ||
| time.sleep(0.001) # any elapsed time exceeds 0s TTL | ||
| assert session.is_expired() | ||
|
|
||
| def test_negative_ttl_immediately_expired(self) -> None: | ||
| session = Session(id="test-8", ttl=-1.0) | ||
| assert session.is_expired() | ||
|
|
||
| def test_very_large_ttl_does_not_expire(self) -> None: | ||
| session = Session(id="test-9", ttl=1e9) | ||
| assert not session.is_expired() | ||
|
|
||
| def test_multiple_touches_keep_session_alive(self) -> None: | ||
| session = Session(id="test-10", ttl=0.05) | ||
| for _ in range(5): | ||
| time.sleep(0.02) | ||
| session.touch() | ||
| assert not session.is_expired() |
There was a problem hiding this comment.
3. Tests miss cleanup/encryption 📎 Requirement gap ⚙ Maintainability
The added tests cover only TTL expiry/touch behavior and do not include required assertions for session cleanup or encryption round-trip.
Agent Prompt
## Issue description
Test coverage added in this PR validates only TTL expiry/touch, but compliance requires tests for cleanup and encryption round-trip as well.
## Issue Context
Add tests that (1) verify expired sessions are purged/invalidated by a cleanup API and/or on-access logic, and (2) verify encrypted-at-rest session persistence can be decrypted back to the original content when enabled.
## Fix Focus Areas
- tests/test_session_ttl.py[1-65]
ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools
Session now tracks last_activity via time.monotonic(). is_expired() returns True when idle time since last activity exceeds the configurable TTL (default 1h). touch() refreshes the clock; add_message() calls touch() automatically. 10 tests covering expiry, touch, and edge cases.
BrowserPlugin and ComputerPlugin no longer register hooks to the main agent — subagents manage their own state injection. Test assertions updated accordingly: - Remove stale XML-tag assertions from SYSTEM_PROMPT tests - Fix browser tool name: 'browser' -> 'browser_task' - Update hook tests: register_hooks() is now a no-op for main agent, so assertions verify hooks are NOT wired (not that they are)
…on [CursorTouch#24] Replaces timing-sensitive time.sleep() tests with deterministic monkeypatch clock (Bug 5). Adds test classes covering: - Config-driven TTL (Req Gap 1) - Loaded-session expiry from updated_at (Req Gap 2) - Cleanup method (Req Gap 3) - Encryption round-trip (Req Gap 3) - clear() calling touch() (Bug 4) All 25 tests are currently failing; implementation follows. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
- Change DEFAULT_SESSION_TTL from 3600.0 (1h) to 86400.0 (24h) - Use __post_init__ for _last_activity so monkeypatch can override time.monotonic before Session() is constructed (fixes timing-sensitive tests) - Add touch() call to clear() so session clears refresh the TTL window (Bug 4) - Add from_config() classmethod to source TTL from Config.session.ttl_hours - Add _from_persisted() classmethod that back-dates _last_activity from updated_at so loaded sessions expire based on real idle time (Req Gap 2) Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…ouch#24] - Add SessionConfig(Base) to operator_use/config/service.py with ttl_hours (float, default 24.0) and encrypt (bool, default False) fields - Add session: SessionConfig field to root Config class - Export SessionConfig from operator_use/config/__init__.py This satisfies Req Gap 1: session TTL is now configurable via config.json under the "session" block, with a 24-hour default. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…up and encryption [CursorTouch#24] - load() now calls Session._from_persisted() so _last_activity is anchored to real idle time (updated_at), not load time (Req Gap 2) - get_or_create() deletes expired sessions on access instead of serving them - Add cleanup(ttl) method that purges all disk sessions older than ttl - Add encryption_key param to __init__; save/load use Fernet (AES-256) when set — plaintext content never written to disk in encrypted mode (Req Gap 3) Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
177d1bc to
159f2a1
Compare
Response to Qodo Review — PR #32All 5 qodo findings resolved. TDD approach: tests written first (all failing), then implementations to make them pass. Full suite: 531 passed, 0 failures. Req Gap 1 — TTL not config-driven ✅ Fixed
Req Gap 2 — Loaded sessions never expire ✅ Fixed
Req Gap 3 — Tests miss cleanup/encryption ✅ Fixed
Bug 4 — clear() skips TTL refresh ✅ Fixed
Bug 5 — Timing-sensitive TTL tests ✅ Fixed
Commits on this branch:
All changes pushed to |
cryptography.fernet is used by SessionStore for at-rest encryption but was never declared as a project dependency, causing ImportError at runtime. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
The encrypt field was never wired to SessionStore — setting it in config silently did nothing. Encryption is opt-in via the encryption_key constructor arg on SessionStore, not a config toggle. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
CursorTouch#24] Before this fix, loading a Fernet-encrypted file without a key would fall through to the JSONL parser and raise an opaque JSONDecodeError. Now detects the Fernet token prefix (gAAAAA) early and raises a descriptive ValueError. Also tightens the test assertion from pytest.raises(Exception) to pytest.raises(ValueError). Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…ouch#24] Catching bare Exception masked any unexpected error during decryption. Now only catches cryptography.fernet.InvalidToken (wrong key or corrupt data), letting genuine unexpected errors propagate normally. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
…ch#24] cleanup() was looking up the filesystem stem (colon replaced with underscore) in self._sessions, which is keyed by the original session ID. Sessions with ':' in their IDs were never evicted from memory even after their files were deleted. Now reverse-maps stems to original IDs before evicting. Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
- E702: split semicolon-separated statements onto individual lines in macos desktop service - F401: remove unused `Any` import from zai/llm.py - F401: remove unused `os` and `time` imports from tests/test_session_ttl.py - Include uv.lock update for cryptography dependency (from prior branch commit)
Summary
Closes #24.
Sessioninoperator_use/session/views.pynow tracks_last_activityviatime.monotonic()is_expired()returnsTruewhen idle time since last activity exceedsttl(default: 3600s / 1h)touch()refreshes the activity clock;add_message()callstouch()automaticallyttl=Nat construction timeDEFAULT_SESSION_TTL = 3600.0constant for consistent use elsewhereTest Plan
pytest tests/test_session_ttl.py -v— 10/10 tests pass🤖 Generated with Claude Code