1111from helprs .modules .container .models import ContainerStatus
1212from helprs .modules .container .service import (
1313 CONTAINER_TTL_SECONDS ,
14+ ContainerInfo ,
1415 cleanup_expired ,
1516 create_session ,
1617 get_session ,
1718 get_session_or_404 ,
1819 mark_completed ,
20+ reconcile_on_startup ,
1921 start_container ,
2022 stop_container ,
2123 stream_output ,
@@ -40,12 +42,16 @@ def __init__(
4042 fail_on_create : bool = False ,
4143 fail_on_stop : bool = False ,
4244 log_lines : list [str ] | None = None ,
45+ listed_containers : list [ContainerInfo ] | None = None ,
46+ fail_on_list : bool = False ,
4347 ):
4448 self ._container_id = container_id
4549 self ._exit_code = exit_code
4650 self ._fail_on_create = fail_on_create
4751 self ._fail_on_stop = fail_on_stop
52+ self ._fail_on_list = fail_on_list
4853 self ._log_lines = log_lines or ["line 1" , "line 2" ]
54+ self ._listed_containers = listed_containers or []
4955 self .created : list [dict ] = []
5056 self .started : list [str ] = []
5157 self .stopped : list [str ] = []
@@ -81,6 +87,11 @@ async def container_logs(self, container_id: str, follow: bool = False) -> Async
8187 async def wait_container (self , container_id : str ) -> int :
8288 return self ._exit_code
8389
90+ async def list_containers (self , label_filter : str ) -> list [ContainerInfo ]:
91+ if self ._fail_on_list :
92+ raise RuntimeError ("Docker daemon unreachable" )
93+ return self ._listed_containers
94+
8495
8596# ---------------------------------------------------------------------------
8697# Fixtures
@@ -539,3 +550,143 @@ async def test_skips_completed_sessions(
539550
540551 cleaned = await cleanup_expired (db = db , docker = docker )
541552 assert cleaned == 0
553+
554+
555+ # ---------------------------------------------------------------------------
556+ # Tests: reconcile_on_startup
557+ # ---------------------------------------------------------------------------
558+
559+
560+ class TestReconcileOnStartup :
561+ async def test_removes_orphan_docker_containers (self , db : AsyncSession , installation : Installation ):
562+ """Docker container exists but has no matching active DB session."""
563+ orphan = ContainerInfo (
564+ container_id = "orphan-container-123" ,
565+ labels = {"helprs.session_id" : str (uuid .uuid4 ())},
566+ )
567+ docker = FakeDockerClient (listed_containers = [orphan ])
568+
569+ containers_removed , sessions_updated = await reconcile_on_startup (db , docker )
570+
571+ assert containers_removed == 1
572+ assert sessions_updated == 0
573+ assert "orphan-container-123" in docker .stopped
574+ assert "orphan-container-123" in docker .removed
575+
576+ async def test_marks_stale_sessions_failed (self , db : AsyncSession , installation : Installation ):
577+ """DB session is RUNNING but its container_id is not in Docker."""
578+ cs = await create_session (
579+ db = db ,
580+ installation_id = installation .id ,
581+ pr_number = 1 ,
582+ repo_full_name = "org/repo" ,
583+ skill_name = "challenge-me" ,
584+ )
585+ cs .status = ContainerStatus .RUNNING
586+ cs .container_id = "vanished-container-456"
587+ await db .flush ()
588+
589+ docker = FakeDockerClient (listed_containers = [])
590+
591+ containers_removed , sessions_updated = await reconcile_on_startup (db , docker )
592+
593+ assert sessions_updated == 1
594+ refreshed = await get_session (db , cs .id )
595+ assert refreshed is not None
596+ assert refreshed .status == ContainerStatus .FAILED
597+ assert refreshed .completed_at is not None
598+
599+ async def test_marks_expired_sessions_timeout (self , db : AsyncSession , installation : Installation ):
600+ """DB session is RUNNING and past TTL, with container still in Docker."""
601+ from datetime import UTC , datetime , timedelta
602+
603+ cs = await create_session (
604+ db = db ,
605+ installation_id = installation .id ,
606+ pr_number = 1 ,
607+ repo_full_name = "org/repo" ,
608+ skill_name = "challenge-me" ,
609+ )
610+ cs .status = ContainerStatus .RUNNING
611+ cs .container_id = "expired-container-789"
612+ cs .created_at = datetime .now (UTC ) - timedelta (seconds = CONTAINER_TTL_SECONDS + 120 )
613+ await db .flush ()
614+
615+ container_info = ContainerInfo (
616+ container_id = "expired-container-789" ,
617+ labels = {"helprs.session_id" : str (cs .id )},
618+ )
619+ docker = FakeDockerClient (listed_containers = [container_info ])
620+
621+ containers_removed , sessions_updated = await reconcile_on_startup (db , docker )
622+
623+ assert containers_removed == 1
624+ assert sessions_updated == 1
625+ refreshed = await get_session (db , cs .id )
626+ assert refreshed is not None
627+ assert refreshed .status == ContainerStatus .TIMEOUT
628+ assert "expired-container-789" in docker .stopped
629+
630+ async def test_marks_stuck_pending_failed (self , db : AsyncSession , installation : Installation ):
631+ """DB session stuck in PENDING with no container_id."""
632+ cs = await create_session (
633+ db = db ,
634+ installation_id = installation .id ,
635+ pr_number = 1 ,
636+ repo_full_name = "org/repo" ,
637+ skill_name = "challenge-me" ,
638+ )
639+ # create_session already sets PENDING and container_id=None
640+ docker = FakeDockerClient (listed_containers = [])
641+
642+ containers_removed , sessions_updated = await reconcile_on_startup (db , docker )
643+
644+ assert sessions_updated == 1
645+ refreshed = await get_session (db , cs .id )
646+ assert refreshed is not None
647+ assert refreshed .status == ContainerStatus .FAILED
648+
649+ async def test_leaves_valid_sessions_untouched (self , db : AsyncSession , installation : Installation ):
650+ """RUNNING session with recent created_at and matching Docker container."""
651+ cs = await create_session (
652+ db = db ,
653+ installation_id = installation .id ,
654+ pr_number = 1 ,
655+ repo_full_name = "org/repo" ,
656+ skill_name = "challenge-me" ,
657+ )
658+ cs .status = ContainerStatus .RUNNING
659+ cs .container_id = "active-container-abc"
660+ await db .flush ()
661+
662+ container_info = ContainerInfo (
663+ container_id = "active-container-abc" ,
664+ labels = {"helprs.session_id" : str (cs .id )},
665+ )
666+ docker = FakeDockerClient (listed_containers = [container_info ])
667+
668+ containers_removed , sessions_updated = await reconcile_on_startup (db , docker )
669+
670+ assert containers_removed == 0
671+ assert sessions_updated == 0
672+ refreshed = await get_session (db , cs .id )
673+ assert refreshed is not None
674+ assert refreshed .status == ContainerStatus .RUNNING
675+
676+ async def test_handles_docker_unreachable (self , db : AsyncSession ):
677+ """list_containers failure returns (0, 0) without crashing."""
678+ docker = FakeDockerClient (fail_on_list = True )
679+
680+ containers_removed , sessions_updated = await reconcile_on_startup (db , docker )
681+
682+ assert containers_removed == 0
683+ assert sessions_updated == 0
684+
685+ async def test_noop_on_empty_state (self , db : AsyncSession ):
686+ """No sessions, no containers -> (0, 0)."""
687+ docker = FakeDockerClient (listed_containers = [])
688+
689+ containers_removed , sessions_updated = await reconcile_on_startup (db , docker )
690+
691+ assert containers_removed == 0
692+ assert sessions_updated == 0
0 commit comments