diff --git a/hermes_cli/cli_agent_setup_mixin.py b/hermes_cli/cli_agent_setup_mixin.py index d037cf997a..f1b4060b53 100644 --- a/hermes_cli/cli_agent_setup_mixin.py +++ b/hermes_cli/cli_agent_setup_mixin.py @@ -463,11 +463,7 @@ class CLIAgentSetupMixin: ) # Re-open the session (clear ended_at so it's active again) try: - self._session_db._conn.execute( - "UPDATE sessions SET ended_at = NULL, end_reason = NULL WHERE id = ?", - (self.session_id,), - ) - self._session_db._conn.commit() + self._session_db.reopen_session(self.session_id) except Exception: pass @@ -731,12 +727,7 @@ class CLIAgentSetupMixin: # Re-open the session (clear ended_at so it's active again) try: - self._session_db._conn.execute( - "UPDATE sessions SET ended_at = NULL, end_reason = NULL " - "WHERE id = ?", - (self.session_id,), - ) - self._session_db._conn.commit() + self._session_db.reopen_session(self.session_id) except Exception: pass diff --git a/hermes_state.py b/hermes_state.py index fd65296c2c..dd4f3e25ec 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -54,6 +54,7 @@ from hermes_state_common import ( # noqa: F401 (re-exported for back-compat) _FTS_TRIGGERS, _LISTABLE_CHILD_SQL, _PREVIEW_RAW_SELECT, + _RESET_END_REASONS, _ephemeral_child_sql, _shape_preview, _sql_session_last_active, @@ -5155,8 +5156,28 @@ class SessionDB(SessionSearchMixin, SessionSchemaMixin, SessionPortabilityMixin) self._execute_write(_do) def reopen_session(self, session_id: str) -> None: - """Clear ended_at/end_reason so a session can be resumed.""" + """Clear ended_at/end_reason so a session can be resumed. + + Before clearing a reset boundary, stabilize markerless legacy reset + children that still depend on the parent's mutable end_reason. + """ def _do(conn): + placeholders = ",".join("?" for _ in _RESET_END_REASONS) + conn.execute( + "UPDATE sessions AS child SET model_config = json_set(" + "COALESCE(child.model_config, '{}'), '$._reset_from', " + "child.parent_session_id) " + "WHERE child.parent_session_id = ? " + "AND json_extract(COALESCE(child.model_config, '{}'), " + " '$._reset_from') IS NULL " + "AND child.session_key IS NOT NULL " + "AND child.session_key != '' " + "AND EXISTS (SELECT 1 FROM sessions parent " + " WHERE parent.id = child.parent_session_id " + f" AND parent.end_reason IN ({placeholders}) " + " AND parent.session_key = child.session_key)", + (session_id, *_RESET_END_REASONS), + ) conn.execute( "UPDATE sessions SET ended_at = NULL, end_reason = NULL WHERE id = ?", (session_id,), diff --git a/tests/cli/test_resume_display.py b/tests/cli/test_resume_display.py index 4c15008d86..d3090c75a6 100644 --- a/tests/cli/test_resume_display.py +++ b/tests/cli/test_resume_display.py @@ -260,19 +260,14 @@ class TestPreloadResumedSession: mock_db = MagicMock() mock_db.get_session.return_value = {"id": "reopen_session", "title": None} mock_db.get_resume_conversations.return_value = (messages, messages) - mock_conn = MagicMock() - mock_db._conn = mock_conn + mock_db.resolve_resume_session_id.return_value = "reopen_session" cli._session_db = mock_db buf = StringIO() cli.console.file = buf cli._preload_resumed_session() - # Should have executed UPDATE to clear ended_at - mock_conn.execute.assert_called_once() - call_args = mock_conn.execute.call_args - assert "ended_at = NULL" in call_args[0][0] - mock_conn.commit.assert_called_once() + mock_db.reopen_session.assert_called_once_with("reopen_session") def test_rejects_runaway_transcript_before_history_load(self): from hermes_state import SessionResumeTooLargeError diff --git a/tests/gateway/test_resume_command.py b/tests/gateway/test_resume_command.py index 496fc61f54..4a69a31637 100644 --- a/tests/gateway/test_resume_command.py +++ b/tests/gateway/test_resume_command.py @@ -375,6 +375,57 @@ class TestHandleResumeCommand: class TestHandleSessionsCommand: """Tests for GatewayRunner._handle_sessions_command.""" + @pytest.mark.asyncio + async def test_sessions_full_keeps_legacy_reset_child_after_parent_resume( + self, tmp_path + ): + import json + + from gateway.config import GatewayConfig + from gateway.session import AsyncSessionStore, SessionStore + from hermes_state import AsyncSessionDB + + event = _make_event(text="/sessions full") + store = SessionStore( + sessions_dir=tmp_path / "sessions", + config=GatewayConfig(), + ) + db = store._db + assert db is not None + + root = store.get_or_create_session(event.source) + root_id = root.session_id + db.set_session_title(root_id, "Legacy reset parent") + child = store.reset_session(root.session_key) + assert child is not None + child_id = child.session_id + db.set_session_title(child_id, "Legacy reset child") + # Reproduce the on-disk shape from before _reset_from existed. + db._conn.execute( + "UPDATE sessions SET model_config = NULL WHERE id = ?", + (child_id,), + ) + db._conn.commit() + + runner = _make_runner(session_db=None, event=event) + runner.session_store = store + runner._async_session_store = AsyncSessionStore(store) + runner._session_db = AsyncSessionDB(db) + + before_resume = await runner._handle_sessions_command(event) + assert "Legacy reset parent" in before_resume + + switched = store.switch_session(root.session_key, root_id) + assert switched is not None + after_resume = await runner._handle_sessions_command(event) + + assert "Legacy reset child" in after_resume + assert "Legacy reset parent" not in after_resume + child_row = db.get_session(child_id) + assert child_row is not None + assert json.loads(child_row["model_config"])["_reset_from"] == root_id + db.close() + @pytest.mark.asyncio async def test_sessions_full_lists_conversations_created_by_gateway_resets( self, tmp_path