@@ -245,30 +245,6 @@ def test_malformed_timestamp_is_tolerated(self):
245245
246246
247247class TestMarkResumePending :
248- def test_list_resume_pending_returns_fresh_entries_with_origins (self , tmp_path ):
249- store = _make_store (tmp_path )
250- fresh = store .get_or_create_session (_make_source (chat_id = "fresh" ))
251- stale = store .get_or_create_session (_make_source (chat_id = "stale" ))
252- missing_origin = store .get_or_create_session (_make_source (chat_id = "missing-origin" ))
253- suspended = store .get_or_create_session (_make_source (chat_id = "suspended" ))
254-
255- store .mark_resume_pending (fresh .session_key , reason = "restart_timeout" )
256- store .mark_resume_pending (stale .session_key , reason = "restart_timeout" )
257- store .mark_resume_pending (missing_origin .session_key , reason = "restart_timeout" )
258- store .mark_resume_pending (suspended .session_key , reason = "restart_timeout" )
259- old = datetime .now () - timedelta (hours = 3 )
260- store ._entries [stale .session_key ].last_resume_marked_at = old
261- store ._entries [missing_origin .session_key ].origin = None
262- store ._entries [suspended .session_key ].suspended = True
263-
264- pending = store .list_resume_pending (
265- window_secs = 3600 ,
266- now = datetime .now ().timestamp (),
267- allowed_reasons = {"restart_timeout" },
268- )
269-
270- assert [entry .session_key for entry in pending ] == [fresh .session_key ]
271-
272248 def test_marks_existing_session (self , tmp_path ):
273249 store = _make_store (tmp_path )
274250 source = _make_source ()
@@ -978,24 +954,158 @@ async def test_startup_auto_resume_schedules_fresh_pending_sessions():
978954 resume_reason = "restart_timeout" ,
979955 last_resume_marked_at = datetime .now (),
980956 )
981- runner .session_store .list_resume_pending = MagicMock ( return_value = [ pending_entry ])
957+ runner .session_store ._entries = { pending_entry . session_key : pending_entry }
982958 adapter .handle_message = AsyncMock ()
983959
984960 scheduled = runner ._schedule_resume_pending_sessions ()
985961 await asyncio .sleep (0 )
986962
987963 assert scheduled == 1
988- runner .session_store .list_resume_pending .assert_called_once_with (
989- window_secs = _auto_continue_freshness_window (),
990- allowed_reasons = {"restart_timeout" , "shutdown_timeout" },
991- )
992964 adapter .handle_message .assert_awaited_once ()
993965 event = adapter .handle_message .await_args .args [0 ]
994966 assert isinstance (event , MessageEvent )
995967 assert event .internal is True
996968 assert event .message_type == MessageType .TEXT
997969 assert event .source == source
998- assert event .text .startswith ("[System note: The gateway restarted" )
970+ # Text is empty — the existing _is_resume_pending branch in
971+ # _handle_message_with_agent owns the system-note injection so we don't
972+ # double it up.
973+ assert event .text == ""
974+
975+
976+ @pytest .mark .asyncio
977+ async def test_startup_auto_resume_includes_crash_recovery ():
978+ """Crash-recovered sessions (reason=restart_interrupted) are also auto-resumed.
979+
980+ suspend_recently_active() marks in-flight sessions with resume_reason
981+ "restart_interrupted" when the previous gateway exit was not clean
982+ (crash/SIGKILL/OOM). These should get the same magic continuation as
983+ drain-timeout interruptions.
984+ """
985+ runner , adapter = make_restart_runner ()
986+ source = make_restart_source (chat_id = "crash-chat" )
987+ pending_entry = SessionEntry (
988+ session_key = "agent:main:telegram:dm:crash-chat" ,
989+ session_id = "sid" ,
990+ created_at = datetime .now (),
991+ updated_at = datetime .now (),
992+ origin = source ,
993+ platform = Platform .TELEGRAM ,
994+ chat_type = "dm" ,
995+ resume_pending = True ,
996+ resume_reason = "restart_interrupted" ,
997+ last_resume_marked_at = datetime .now (),
998+ )
999+ runner .session_store ._entries = {pending_entry .session_key : pending_entry }
1000+ adapter .handle_message = AsyncMock ()
1001+
1002+ scheduled = runner ._schedule_resume_pending_sessions ()
1003+ await asyncio .sleep (0 )
1004+
1005+ assert scheduled == 1
1006+ adapter .handle_message .assert_awaited_once ()
1007+
1008+
1009+ @pytest .mark .asyncio
1010+ async def test_startup_auto_resume_skips_stale_entries ():
1011+ """Entries older than the freshness window must not be auto-resumed."""
1012+ runner , adapter = make_restart_runner ()
1013+ source = make_restart_source (chat_id = "stale-chat" )
1014+ stale_marker = datetime .now () - timedelta (
1015+ seconds = _auto_continue_freshness_window () + 60
1016+ )
1017+ stale_entry = SessionEntry (
1018+ session_key = "agent:main:telegram:dm:stale-chat" ,
1019+ session_id = "sid" ,
1020+ created_at = stale_marker ,
1021+ updated_at = stale_marker ,
1022+ origin = source ,
1023+ platform = Platform .TELEGRAM ,
1024+ chat_type = "dm" ,
1025+ resume_pending = True ,
1026+ resume_reason = "restart_timeout" ,
1027+ last_resume_marked_at = stale_marker ,
1028+ )
1029+ runner .session_store ._entries = {stale_entry .session_key : stale_entry }
1030+ adapter .handle_message = AsyncMock ()
1031+
1032+ scheduled = runner ._schedule_resume_pending_sessions ()
1033+
1034+ assert scheduled == 0
1035+ adapter .handle_message .assert_not_called ()
1036+
1037+
1038+ @pytest .mark .asyncio
1039+ async def test_startup_auto_resume_skips_suspended_and_originless ():
1040+ """suspended entries and entries with no origin are excluded."""
1041+ runner , adapter = make_restart_runner ()
1042+ source = make_restart_source (chat_id = "ok" )
1043+ suspended_entry = SessionEntry (
1044+ session_key = "agent:main:telegram:dm:suspended" ,
1045+ session_id = "sid-s" ,
1046+ created_at = datetime .now (),
1047+ updated_at = datetime .now (),
1048+ origin = source ,
1049+ platform = Platform .TELEGRAM ,
1050+ chat_type = "dm" ,
1051+ resume_pending = True ,
1052+ resume_reason = "restart_timeout" ,
1053+ suspended = True ,
1054+ last_resume_marked_at = datetime .now (),
1055+ )
1056+ originless = SessionEntry (
1057+ session_key = "agent:main:telegram:dm:originless" ,
1058+ session_id = "sid-o" ,
1059+ created_at = datetime .now (),
1060+ updated_at = datetime .now (),
1061+ origin = None ,
1062+ platform = Platform .TELEGRAM ,
1063+ chat_type = "dm" ,
1064+ resume_pending = True ,
1065+ resume_reason = "restart_timeout" ,
1066+ last_resume_marked_at = datetime .now (),
1067+ )
1068+ runner .session_store ._entries = {
1069+ suspended_entry .session_key : suspended_entry ,
1070+ originless .session_key : originless ,
1071+ }
1072+ adapter .handle_message = AsyncMock ()
1073+
1074+ scheduled = runner ._schedule_resume_pending_sessions ()
1075+
1076+ assert scheduled == 0
1077+ adapter .handle_message .assert_not_called ()
1078+
1079+
1080+ @pytest .mark .asyncio
1081+ async def test_startup_auto_resume_skips_disallowed_reasons ():
1082+ """Reasons outside the auto-resume set (e.g. a future custom reason) are skipped.
1083+
1084+ These sessions still auto-resume on the next real user message via the
1085+ existing _is_resume_pending branch — we just don't synthesize a turn
1086+ for them at startup.
1087+ """
1088+ runner , adapter = make_restart_runner ()
1089+ source = make_restart_source (chat_id = "other" )
1090+ other_entry = SessionEntry (
1091+ session_key = "agent:main:telegram:dm:other" ,
1092+ session_id = "sid" ,
1093+ created_at = datetime .now (),
1094+ updated_at = datetime .now (),
1095+ origin = source ,
1096+ platform = Platform .TELEGRAM ,
1097+ chat_type = "dm" ,
1098+ resume_pending = True ,
1099+ resume_reason = "manual_resume_request" ,
1100+ last_resume_marked_at = datetime .now (),
1101+ )
1102+ runner .session_store ._entries = {other_entry .session_key : other_entry }
1103+ adapter .handle_message = AsyncMock ()
1104+
1105+ scheduled = runner ._schedule_resume_pending_sessions ()
1106+
1107+ assert scheduled == 0
1108+ adapter .handle_message .assert_not_called ()
9991109
10001110
10011111@pytest .mark .asyncio
@@ -1014,7 +1124,7 @@ async def test_startup_auto_resume_skips_when_adapter_unavailable():
10141124 resume_reason = "restart_timeout" ,
10151125 last_resume_marked_at = datetime .now (),
10161126 )
1017- runner .session_store .list_resume_pending = MagicMock ( return_value = [ pending_entry ])
1127+ runner .session_store ._entries = { pending_entry . session_key : pending_entry }
10181128 runner .adapters = {}
10191129 adapter .handle_message = AsyncMock ()
10201130
0 commit comments