Skip to content

Commit e82f932

Browse files
Implement issue #100: Session persistence and recovery after restart
mark_interrupted_sessions() on startup, graceful shutdown marks active sessions. resume_interrupted_session() replays last user message. POST /api/sessions/{id}/resume-interrupted endpoint. Status transitions in send_message (executing -> waiting_input/failed). 16 new tests.
1 parent f2f3cff commit e82f932

6 files changed

Lines changed: 556 additions & 3 deletions

File tree

backend/codehive/api/app.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""FastAPI application factory."""
22

33
import contextlib
4+
import logging
45
from collections.abc import AsyncGenerator
56

67
from fastapi import Depends, FastAPI
@@ -12,6 +13,7 @@
1213
from codehive.api.errors import register_error_handling
1314
from codehive.logging import configure_logging
1415
from codehive.core.first_run import print_credentials, seed_first_run
16+
from codehive.core.session import mark_interrupted_sessions
1517
from codehive.db.session import async_session_factory
1618
from codehive.api.routes.approvals import approvals_router
1719
from codehive.api.routes.auth import auth_router
@@ -39,20 +41,34 @@
3941
from codehive.api.routes.transcript import transcript_router
4042
from codehive.api.ws import router as ws_router
4143

44+
logger = logging.getLogger(__name__)
45+
4246

4347
def create_app() -> FastAPI:
4448
"""Create and configure the FastAPI application."""
4549

4650
@contextlib.asynccontextmanager
4751
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
48-
"""Run first-run setup on startup."""
52+
"""Run first-run setup and session recovery on startup; mark sessions on shutdown."""
4953
session_maker = async_session_factory()
5054
async with session_maker() as db:
5155
credentials = await seed_first_run(db)
5256
if credentials is not None:
5357
print_credentials(credentials)
58+
59+
# Startup recovery: mark any sessions stuck in 'executing' as 'interrupted'
60+
async with session_maker() as db:
61+
count = await mark_interrupted_sessions(db)
62+
logger.info("Startup recovery: %d session(s) marked as interrupted", count)
63+
5464
yield
5565

66+
# Graceful shutdown: mark executing sessions as interrupted
67+
async with session_maker() as db:
68+
count = await mark_interrupted_sessions(db)
69+
if count:
70+
logger.info("Shutdown: marked %d executing session(s) as interrupted", count)
71+
5672
settings = Settings()
5773
configure_logging(settings)
5874

backend/codehive/api/routes/sessions.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
from codehive.core.session import (
2121
InvalidStatusTransitionError,
2222
IssueNotFoundError,
23+
NoUserMessageError,
2324
ProjectNotFoundError,
2425
SessionHasDependentsError,
2526
SessionNotFoundError,
@@ -29,6 +30,7 @@
2930
list_child_sessions,
3031
list_sessions,
3132
pause_session,
33+
resume_interrupted_session,
3234
resume_session,
3335
update_session,
3436
)
@@ -160,6 +162,24 @@ async def resume_session_endpoint(
160162
return SessionRead.model_validate(session)
161163

162164

165+
@sessions_router.post("/{session_id}/resume-interrupted", response_model=SessionRead)
166+
async def resume_interrupted_endpoint(
167+
session_id: uuid.UUID,
168+
db: AsyncSession = Depends(get_db),
169+
) -> SessionRead:
170+
"""Resume an interrupted session by replaying the last user message."""
171+
await _get_session_or_404(db, session_id)
172+
try:
173+
session, _last_message = await resume_interrupted_session(db, session_id)
174+
except SessionNotFoundError:
175+
raise HTTPException(status_code=404, detail="Session not found")
176+
except InvalidStatusTransitionError as exc:
177+
raise HTTPException(status_code=409, detail=str(exc))
178+
except NoUserMessageError as exc:
179+
raise HTTPException(status_code=409, detail=str(exc))
180+
return SessionRead.model_validate(session)
181+
182+
163183
@sessions_router.post("/{session_id}/switch-mode", response_model=SessionRead)
164184
async def switch_mode_endpoint(
165185
session_id: uuid.UUID,
@@ -355,15 +375,34 @@ async def send_message_endpoint(
355375
if session is None:
356376
raise HTTPException(status_code=404, detail="Session not found")
357377

378+
# Mark session as executing
379+
try:
380+
await update_session(db, session_id, status="executing")
381+
except SessionNotFoundError:
382+
raise HTTPException(status_code=404, detail="Session not found")
383+
358384
try:
359385
engine = await _build_engine(session.config, engine_type=session.engine)
360386

361387
events: list[dict[str, Any]] = []
362388
async for event in engine.send_message(session_id, body.content, db=db):
363389
events.append(event)
364390

391+
# Engine finished a turn -- mark as waiting_input
392+
await update_session(db, session_id, status="waiting_input")
393+
365394
return events
366395
except HTTPException:
396+
# On HTTP errors, mark as failed
397+
try:
398+
await update_session(db, session_id, status="failed")
399+
except Exception:
400+
pass
367401
raise
368402
except Exception as exc:
403+
# On unexpected errors, mark as failed
404+
try:
405+
await update_session(db, session_id, status="failed")
406+
except Exception:
407+
pass
369408
raise HTTPException(status_code=500, detail=str(exc))

backend/codehive/core/session.py

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
"""Session business logic (DB queries, state machine)."""
22

3+
import logging
34
import uuid
45
from datetime import datetime, timezone
56

6-
from sqlalchemy import select
7+
from sqlalchemy import select, update
78
from sqlalchemy.ext.asyncio import AsyncSession
89

910
from codehive.api.schemas.session import QueueEmptyAction
10-
from codehive.db.models import Issue, Project
11+
from codehive.db.models import Issue, Message, Project
1112
from codehive.db.models import Session as SessionModel
1213

14+
logger = logging.getLogger(__name__)
15+
1316

1417
class ProjectNotFoundError(Exception):
1518
"""Raised when a project_id does not exist."""
@@ -31,6 +34,10 @@ class InvalidStatusTransitionError(Exception):
3134
"""Raised when a status transition is not allowed."""
3235

3336

37+
class NoUserMessageError(Exception):
38+
"""Raised when an interrupted session has no user messages to replay."""
39+
40+
3441
def _validate_queue_empty_action(config: dict | None) -> None:
3542
"""Raise ValueError if config contains an invalid queue_empty_action."""
3643
if config is None:
@@ -237,3 +244,61 @@ async def resume_session(
237244
await db.commit()
238245
await db.refresh(session)
239246
return session
247+
248+
249+
async def mark_interrupted_sessions(db: AsyncSession) -> int:
250+
"""Bulk-update all sessions with status ``executing`` to ``interrupted``.
251+
252+
Returns the number of sessions that were updated.
253+
"""
254+
result = await db.execute(
255+
update(SessionModel).where(SessionModel.status == "executing").values(status="interrupted")
256+
)
257+
await db.commit()
258+
count = result.rowcount # type: ignore[union-attr]
259+
if count:
260+
logger.info("Marked %d executing session(s) as interrupted", count)
261+
return count
262+
263+
264+
async def resume_interrupted_session(
265+
db: AsyncSession,
266+
session_id: uuid.UUID,
267+
) -> tuple[SessionModel, str]:
268+
"""Resume an interrupted session.
269+
270+
Validates that the session status is ``interrupted``, fetches the last
271+
user message, and transitions the status to ``executing``.
272+
273+
Returns a tuple of (session, last_user_message_content).
274+
275+
Raises:
276+
SessionNotFoundError: if the session does not exist
277+
InvalidStatusTransitionError: if the session is not in ``interrupted`` status
278+
NoUserMessageError: if there are no user messages in the session
279+
"""
280+
session = await db.get(SessionModel, session_id)
281+
if session is None:
282+
raise SessionNotFoundError(f"Session {session_id} not found")
283+
284+
if session.status != "interrupted":
285+
raise InvalidStatusTransitionError(
286+
f"Cannot resume-interrupted session in '{session.status}' status. "
287+
f"Resume-interrupted is only allowed from: interrupted"
288+
)
289+
290+
# Fetch the last user message
291+
result = await db.execute(
292+
select(Message)
293+
.where(Message.session_id == session_id, Message.role == "user")
294+
.order_by(Message.created_at.desc())
295+
.limit(1)
296+
)
297+
last_msg = result.scalars().first()
298+
if last_msg is None:
299+
raise NoUserMessageError(f"Session {session_id} has no user messages to replay")
300+
301+
session.status = "executing"
302+
await db.commit()
303+
await db.refresh(session)
304+
return session, last_msg.content

0 commit comments

Comments
 (0)