refactor: per-operation DB sessions and ServiceError in SessionService

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 19:16:15 -04:00
parent c93135b338
commit 208a9028e7
3 changed files with 359 additions and 209 deletions
+88 -46
View File
@@ -7,8 +7,10 @@ from datetime import UTC, datetime
from pathlib import Path
from sqlalchemy.orm import Session as DBSession
from sqlalchemy.orm import selectinload, sessionmaker
from hqt.db.models import Harness, Project, Session
from hqt.errors import ServiceError
from hqt.harnesses.base import HarnessConfigurator, SpawnConfig
from hqt.status import window_label
from hqt.tmux.manager import SpawnRequest, TmuxManager
@@ -42,20 +44,34 @@ class CreateSessionResult:
class SessionService:
"""Session lifecycle operations.
Holds a sessionmaker, not a live session: every public method opens its
own short-lived DB session, so concurrent Textual workers and the 3s
poller never share ORM state. Rows returned to callers are detached;
queries eager-load the harness relationship so widgets can read it.
"""
def __init__(
self,
db: DBSession,
factory: sessionmaker,
tmux: TmuxManager,
harnesses: Mapping[str, HarnessConfigurator],
):
self.db = db
self.factory = factory
self.tmux = tmux
self.harnesses = harnesses
def _project_path(self, project_id: int) -> Path:
project = self.db.get(Project, project_id)
def _configurator(self, harness_name: str) -> HarnessConfigurator:
configurator = self.harnesses.get(harness_name)
if configurator is None:
raise ServiceError(f"Harness '{harness_name}' is not installed")
return configurator
def _project_path(self, db: DBSession, project_id: int) -> Path:
project = db.get(Project, project_id)
if project is None:
raise ValueError(f"Unknown project: {project_id}")
raise ServiceError(f"Project {project_id} no longer exists")
return Path(project.path)
async def _capture_session_id_with_retry(
@@ -93,7 +109,7 @@ class SessionService:
return created_at.timestamp()
return 0.0
def _maybe_capture_missing_session_id(self, sess: Session) -> None:
def _maybe_capture_missing_session_id(self, db: DBSession, sess: Session) -> None:
configurator = self.harnesses.get(sess.harness.name)
if configurator is None or not configurator.captures_session_id:
return
@@ -102,12 +118,16 @@ class SessionService:
if sess.harness_session_id and sess.harness_session_id != placeholder_id:
return
# Polling path: a deleted project just means we skip capture, never crash.
project = db.get(Project, sess.project_id)
if project is None:
return
captured_id = configurator.capture_session_id(
self._project_path(sess.project_id), self._session_created_epoch(sess)
Path(project.path), self._session_created_epoch(sess)
)
if captured_id and captured_id != sess.harness_session_id:
sess.harness_session_id = captured_id
self.db.commit()
db.commit()
async def create_session(
self,
@@ -122,11 +142,13 @@ class SessionService:
The capture-retry loop only runs when spawn succeeded.
"""
log.info("Creating session: project=%s harness=%s", project_id, harness_name)
harness_row = self.db.query(Harness).filter_by(name=harness_name).first()
configurator = self._configurator(harness_name)
with self.factory() as db:
harness_row = db.query(Harness).filter_by(name=harness_name).first()
if not harness_row:
log.error("Harness not in DB: %s", harness_name)
raise ValueError(f"Unknown harness: {harness_name}")
configurator = self.harnesses[harness_name]
raise ServiceError(f"Unknown harness: {harness_name}")
project_path = self._project_path(db, project_id)
sess = Session(
project_id=project_id,
harness_id=harness_row.id,
@@ -135,10 +157,9 @@ class SessionService:
tmux_session_name="placeholder",
archived=False,
)
self.db.add(sess)
self.db.flush()
db.add(sess)
db.flush()
sess.tmux_session_name = f"hqt-{sess.id}"
project_path = self._project_path(project_id)
harness_session_id = configurator.generate_session_id(sess.id)
sess.harness_session_id = harness_session_id
spawn_cfg = configurator.build_spawn_config(
@@ -165,16 +186,19 @@ class SessionService:
sess.tmux_session_name,
spawn_result.error or "(no output captured)",
)
# Only attempt capture when the spawn actually succeeded; a failed spawn
# could inadvertently capture an unrelated session running in the same cwd.
# Only attempt capture when the spawn actually succeeded; a failed
# spawn could inadvertently capture an unrelated session running in
# the same cwd.
if spawn_result.ok and configurator.captures_session_id:
captured_id = await self._capture_session_id_with_retry(
configurator, project_path, since, sess.tmux_session_name
)
if captured_id:
sess.harness_session_id = captured_id
self.db.commit()
log.info("Session created: id=%s window=%s", sess.id, sess.tmux_session_name)
db.commit()
log.info(
"Session created: id=%s window=%s", sess.id, sess.tmux_session_name
)
return CreateSessionResult(
session=sess,
spawn_ok=spawn_result.ok,
@@ -183,24 +207,29 @@ class SessionService:
async def attach_session(self, session_id: int) -> bool:
"""Attach to a session. Handles all states: alive, dead pane, gone."""
sess = self.db.get(Session, session_id)
with self.factory() as db:
sess = db.get(Session, session_id, options=[selectinload(Session.harness)])
if sess is None:
return False
raise ServiceError("Session not found")
window_name = sess.tmux_session_name
self._maybe_capture_missing_session_id(sess)
self._maybe_capture_missing_session_id(db, sess)
if await self.tmux.is_alive(window_name):
# Window exists, process running — just switch
return await self.tmux.attach(window_name)
# Window exists but pane is dead, OR window is completely gone
log.info("Session %s not alive, respawning with fallback ladder", window_name)
ok = await self._respawn_with_fallback(sess, window_name)
log.info(
"Session %s not alive, respawning with fallback ladder", window_name
)
ok = await self._respawn_with_fallback(db, sess, window_name)
if not ok:
return False
return await self.tmux.attach(window_name)
async def _respawn_with_fallback(self, sess: Session, window_name: str) -> bool:
async def _respawn_with_fallback(
self, db: DBSession, sess: Session, window_name: str
) -> bool:
"""Try resume config first; if it dies, fall back to fresh spawn config.
Returns True if either rung succeeded, False if both failed.
@@ -210,7 +239,7 @@ class SessionService:
conversation rather than the stale/nonexistent one.
"""
# Rung 1: resume (restore prior conversation)
resume_cfg = self._get_resume_config(sess)
resume_cfg = self._get_resume_config(db, sess)
result = await self.tmux.respawn_verified(
window_name, resume_cfg.command, str(resume_cfg.cwd), env=resume_cfg.env
)
@@ -224,8 +253,8 @@ class SessionService:
)
# Rung 2: fresh spawn (codex ignores the old session id; start fresh)
configurator = self.harnesses[sess.harness.name]
project_path = self._project_path(sess.project_id)
configurator = self._configurator(sess.harness.name)
project_path = self._project_path(db, sess.project_id)
harness_session_id = (
sess.harness_session_id or configurator.generate_session_id(sess.id)
)
@@ -244,15 +273,15 @@ class SessionService:
)
return False
# Rung-2 succeeded — capture the new session id if the harness supports it
# so that future resumes target this fresh conversation.
# Rung-2 succeeded — capture the new session id if the harness supports
# it so that future resumes target this fresh conversation.
if configurator.captures_session_id:
new_id = await self._capture_session_id_with_retry(
configurator, project_path, since, window_name
)
if new_id:
sess.harness_session_id = new_id
self.db.commit()
db.commit()
else:
log.warning(
"capture_session_id exhausted after rung-2 for %s; keeping old id %s",
@@ -262,15 +291,18 @@ class SessionService:
return True
async def stop_session(self, session_id: int) -> None:
sess = self.db.get(Session, session_id)
with self.factory() as db:
sess = db.get(Session, session_id)
if sess is None:
return
if await self.tmux.window_exists(sess.tmux_session_name):
await self.tmux.kill(sess.tmux_session_name)
window_name = sess.tmux_session_name
if await self.tmux.window_exists(window_name):
await self.tmux.kill(window_name)
def get_session(self, session_id: int) -> Session | None:
"""Return the session row, or None if it does not exist."""
return self.db.get(Session, session_id)
with self.factory() as db:
return db.get(Session, session_id, options=[selectinload(Session.harness)])
def rename_session(self, session_id: int, nickname: str | None) -> None:
"""Update a session's display nickname.
@@ -279,20 +311,22 @@ class SessionService:
the tmux window name. The tmux window label is refreshed by the next
poll (sync_window_labels), so no immediate tmux call is needed here.
"""
sess = self.db.get(Session, session_id)
with self.factory() as db:
sess = db.get(Session, session_id)
if sess is None:
return
sess.nickname = (nickname or "").strip() or None
self.db.commit()
db.commit()
async def delete_session(self, session_id: int) -> None:
sess = self.db.get(Session, session_id)
with self.factory() as db:
sess = db.get(Session, session_id)
if sess is None:
return
if await self.tmux.window_exists(sess.tmux_session_name):
await self.tmux.kill(sess.tmux_session_name)
self.db.delete(sess)
self.db.commit()
db.delete(sess)
db.commit()
async def _status_for(
self, sess: Session, wi: WindowInfo | None, now: float
@@ -320,8 +354,10 @@ class SessionService:
) -> list[SessionInfo]:
if now is None:
now = time.time()
with self.factory() as db:
sessions = (
self.db.query(Session)
db.query(Session)
.options(selectinload(Session.harness))
.filter_by(project_id=project_id, archived=False)
.all()
)
@@ -329,7 +365,7 @@ class SessionService:
info_map = await self.tmux.poll_info(names)
result: list[SessionInfo] = []
for s in sessions:
self._maybe_capture_missing_session_id(s)
self._maybe_capture_missing_session_id(db, s)
wi = info_map.get(s.tmux_session_name)
status = await self._status_for(s, wi, now)
result.append(
@@ -348,12 +384,18 @@ class SessionService:
"""
if now is None:
now = time.time()
sessions = self.db.query(Session).filter_by(archived=False).all()
with self.factory() as db:
sessions = (
db.query(Session)
.options(selectinload(Session.harness))
.filter_by(archived=False)
.all()
)
names = [s.tmux_session_name for s in sessions]
info_map = await self.tmux.poll_info(names)
result: list[SessionInfo] = []
for s in sessions:
self._maybe_capture_missing_session_id(s)
self._maybe_capture_missing_session_id(db, s)
wi = info_map.get(s.tmux_session_name)
alive = bool(wi and wi.alive)
status = await self._status_for(s, wi, now)
@@ -364,11 +406,11 @@ class SessionService:
)
return result
def _get_resume_config(self, sess: Session) -> SpawnConfig:
configurator = self.harnesses[sess.harness.name]
def _get_resume_config(self, db: DBSession, sess: Session) -> SpawnConfig:
configurator = self._configurator(sess.harness.name)
harness_session_id = (
sess.harness_session_id or configurator.generate_session_id(sess.id)
)
return configurator.build_resume_config(
self._project_path(sess.project_id), harness_session_id, sess.model
self._project_path(db, sess.project_id), harness_session_id, sess.model
)
+9 -4
View File
@@ -3,6 +3,7 @@ from unittest.mock import AsyncMock, MagicMock
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from hqt.db.models import Base, Harness
from hqt.projects.service import ProjectService
@@ -13,9 +14,13 @@ from hqt.harnesses.configurators.claude import ClaudeConfigurator
@pytest.fixture
def setup():
engine = create_engine("sqlite:///:memory:")
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
factory = sessionmaker(bind=engine, expire_on_commit=False)
db = factory()
db.add(Harness(name="claude-code", display_name="Claude Code"))
db.commit()
@@ -32,8 +37,8 @@ def setup():
configurator = ClaudeConfigurator()
harnesses = {"claude-code": configurator}
proj_svc = ProjectService(db)
sess_svc = SessionService(db=db, tmux=tmux, harnesses=harnesses)
proj_svc = ProjectService(factory)
sess_svc = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
return proj_svc, sess_svc, tmux
+175 -72
View File
@@ -4,17 +4,28 @@ from unittest.mock import AsyncMock, MagicMock, patch
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import StaticPool
from hqt.db.models import Base, Harness, Project
from hqt.errors import ServiceError
from hqt.sessions.service import SessionInfo, SessionService
from hqt.tmux.manager import SpawnResult, TmuxManager
@pytest.fixture
def db():
engine = create_engine("sqlite:///:memory:")
def factory():
engine = create_engine(
"sqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(engine)
factory = sessionmaker(bind=engine)
return sessionmaker(bind=engine, expire_on_commit=False)
@pytest.fixture
def db(factory):
# Seeding/assertion handle onto the same in-memory DB the service uses.
session = factory()
session.add(Harness(name="claude-code", display_name="Claude Code"))
session.add(Project(name="myproj", path="/tmp/myproj"))
@@ -52,8 +63,9 @@ def harnesses():
@pytest.fixture
def service(db, tmux, harnesses):
return SessionService(db=db, tmux=tmux, harnesses=harnesses)
def service(factory, db, tmux, harnesses):
# depends on db so the seed rows exist before the service runs
return SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
@pytest.mark.asyncio
@@ -283,17 +295,17 @@ def harnesses_capture():
@pytest.mark.asyncio
async def test_create_session_no_capture_flag_never_calls_capture(
db, tmux, harnesses_no_capture
factory, db, tmux, harnesses_no_capture
):
"""When captures_session_id=False, capture_session_id is never called."""
service = SessionService(db=db, tmux=tmux, harnesses=harnesses_no_capture)
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses_no_capture)
await service.create_session(project_id=1, harness_name="claude-code")
harnesses_no_capture["claude-code"].capture_session_id.assert_not_called()
@pytest.mark.asyncio
async def test_create_session_capture_retries_until_success(
db, tmux, harnesses_capture
factory, db, tmux, harnesses_capture
):
"""captures_session_id=True: returns None twice then an id → retries and stores captured id."""
import hqt.sessions.service as svc_mod
@@ -309,7 +321,7 @@ async def test_create_session_capture_retries_until_success(
async def fake_sleep(t):
sleep_calls.append(t)
service = SessionService(db=db, tmux=tmux, harnesses=harnesses_capture)
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses_capture)
with patch.object(svc_mod, "_capture_sleep", fake_sleep):
result = await service.create_session(project_id=1, harness_name="claude-code")
@@ -321,7 +333,7 @@ async def test_create_session_capture_retries_until_success(
@pytest.mark.asyncio
async def test_create_session_capture_all_none_keeps_placeholder(
db, tmux, harnesses_capture, caplog
factory, db, tmux, harnesses_capture, caplog
):
"""All capture attempts return None → placeholder id kept, no crash, warning logged."""
import logging
@@ -332,7 +344,7 @@ async def test_create_session_capture_all_none_keeps_placeholder(
async def fake_sleep(t):
pass
service = SessionService(db=db, tmux=tmux, harnesses=harnesses_capture)
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses_capture)
with caplog.at_level(logging.WARNING):
with patch.object(svc_mod, "_capture_sleep", fake_sleep):
result = await service.create_session(
@@ -354,7 +366,7 @@ async def test_create_session_capture_all_none_keeps_placeholder(
@pytest.mark.asyncio
async def test_create_session_capture_first_attempt_succeeds(
db, tmux, harnesses_capture
factory, db, tmux, harnesses_capture
):
"""captures_session_id=True: first attempt succeeds → no sleeps needed."""
import hqt.sessions.service as svc_mod
@@ -366,7 +378,7 @@ async def test_create_session_capture_first_attempt_succeeds(
async def fake_sleep(t):
sleep_calls.append(t)
service = SessionService(db=db, tmux=tmux, harnesses=harnesses_capture)
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses_capture)
with patch.object(svc_mod, "_capture_sleep", fake_sleep):
result = await service.create_session(project_id=1, harness_name="claude-code")
@@ -380,15 +392,15 @@ async def test_create_session_capture_first_attempt_succeeds(
@pytest.mark.asyncio
async def test_list_sessions_dead_window(db, tmux, harnesses):
async def test_list_sessions_dead_window(factory, db, tmux, harnesses):
"""list_sessions: dead window → status='dead', alive=False."""
from hqt.tmux.runner import WindowInfo
await SessionService(db=db, tmux=tmux, harnesses=harnesses).create_session(
project_id=1, harness_name="claude-code"
)
await SessionService(
factory=factory, tmux=tmux, harnesses=harnesses
).create_session(project_id=1, harness_name="claude-code")
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=False, last_activity=0)}
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
result = await service.list_sessions(project_id=1)
assert len(result) == 1
assert result[0].alive is False
@@ -396,33 +408,33 @@ async def test_list_sessions_dead_window(db, tmux, harnesses):
@pytest.mark.asyncio
async def test_list_sessions_missing_window_is_dead(db, tmux, harnesses):
async def test_list_sessions_missing_window_is_dead(factory, db, tmux, harnesses):
"""list_sessions: window not in poll_info result → status='dead', alive=False."""
await SessionService(db=db, tmux=tmux, harnesses=harnesses).create_session(
project_id=1, harness_name="claude-code"
)
await SessionService(
factory=factory, tmux=tmux, harnesses=harnesses
).create_session(project_id=1, harness_name="claude-code")
tmux.poll_info.return_value = {} # no windows at all
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
result = await service.list_sessions(project_id=1)
assert result[0].alive is False
assert result[0].status == "dead"
@pytest.mark.asyncio
async def test_list_sessions_alive_parse_working(db, tmux, harnesses):
async def test_list_sessions_alive_parse_working(factory, db, tmux, harnesses):
"""Alive window + harness parse returns 'working' → status='working'."""
from hqt.tmux.runner import WindowInfo
await SessionService(db=db, tmux=tmux, harnesses=harnesses).create_session(
project_id=1, harness_name="claude-code"
)
await SessionService(
factory=factory, tmux=tmux, harnesses=harnesses
).create_session(project_id=1, harness_name="claude-code")
now = 2000.0
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=True, last_activity=1999)}
tmux.capture_pane = AsyncMock(return_value="Esc to interrupt\n")
harnesses["claude-code"].parse_status = MagicMock(return_value="working")
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
result = await service.list_sessions(project_id=1, now=now)
assert result[0].status == "working"
@@ -430,13 +442,15 @@ async def test_list_sessions_alive_parse_working(db, tmux, harnesses):
@pytest.mark.asyncio
async def test_list_sessions_alive_parse_none_recent_activity(db, tmux, harnesses):
async def test_list_sessions_alive_parse_none_recent_activity(
factory, db, tmux, harnesses
):
"""Alive window + parse_status None + recent activity → status='active'."""
from hqt.tmux.runner import WindowInfo
await SessionService(db=db, tmux=tmux, harnesses=harnesses).create_session(
project_id=1, harness_name="claude-code"
)
await SessionService(
factory=factory, tmux=tmux, harnesses=harnesses
).create_session(project_id=1, harness_name="claude-code")
now = 2000.0
tmux.poll_info.return_value = {
@@ -445,20 +459,22 @@ async def test_list_sessions_alive_parse_none_recent_activity(db, tmux, harnesse
tmux.capture_pane = AsyncMock(return_value="some text")
harnesses["claude-code"].parse_status = MagicMock(return_value=None)
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
result = await service.list_sessions(project_id=1, now=now)
assert result[0].status == "active"
@pytest.mark.asyncio
async def test_list_sessions_alive_parse_none_stale_activity(db, tmux, harnesses):
async def test_list_sessions_alive_parse_none_stale_activity(
factory, db, tmux, harnesses
):
"""Alive window + parse_status None + stale activity → status='idle'."""
from hqt.tmux.runner import WindowInfo
await SessionService(db=db, tmux=tmux, harnesses=harnesses).create_session(
project_id=1, harness_name="claude-code"
)
await SessionService(
factory=factory, tmux=tmux, harnesses=harnesses
).create_session(project_id=1, harness_name="claude-code")
now = 2000.0
tmux.poll_info.return_value = {
@@ -467,27 +483,29 @@ async def test_list_sessions_alive_parse_none_stale_activity(db, tmux, harnesses
tmux.capture_pane = AsyncMock(return_value="some text")
harnesses["claude-code"].parse_status = MagicMock(return_value=None)
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
result = await service.list_sessions(project_id=1, now=now)
assert result[0].status == "idle"
@pytest.mark.asyncio
async def test_list_sessions_capture_empty_falls_back_to_activity(db, tmux, harnesses):
async def test_list_sessions_capture_empty_falls_back_to_activity(
factory, db, tmux, harnesses
):
"""capture_pane returns empty text → activity-based fallback, no crash."""
from hqt.tmux.runner import WindowInfo
await SessionService(db=db, tmux=tmux, harnesses=harnesses).create_session(
project_id=1, harness_name="claude-code"
)
await SessionService(
factory=factory, tmux=tmux, harnesses=harnesses
).create_session(project_id=1, harness_name="claude-code")
now = 2000.0
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=True, last_activity=1999)}
tmux.capture_pane = AsyncMock(return_value="") # empty — treat as capture failure
harnesses["claude-code"].parse_status = MagicMock(return_value=None)
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
result = await service.list_sessions(project_id=1, now=now)
# Should still produce a valid status via activity
@@ -496,20 +514,20 @@ async def test_list_sessions_capture_empty_falls_back_to_activity(db, tmux, harn
@pytest.mark.asyncio
async def test_list_sessions_alive_parse_waiting(db, tmux, harnesses):
async def test_list_sessions_alive_parse_waiting(factory, db, tmux, harnesses):
"""Alive window + parse_status 'waiting' → status='waiting'."""
from hqt.tmux.runner import WindowInfo
await SessionService(db=db, tmux=tmux, harnesses=harnesses).create_session(
project_id=1, harness_name="claude-code"
)
await SessionService(
factory=factory, tmux=tmux, harnesses=harnesses
).create_session(project_id=1, harness_name="claude-code")
now = 2000.0
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=True, last_activity=1999)}
tmux.capture_pane = AsyncMock(return_value="Do you want to proceed?\n 1. Yes\n")
harnesses["claude-code"].parse_status = MagicMock(return_value="waiting")
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
result = await service.list_sessions(project_id=1, now=now)
assert result[0].status == "waiting"
@@ -521,11 +539,11 @@ async def test_list_sessions_alive_parse_waiting(db, tmux, harnesses):
@pytest.mark.asyncio
async def test_sync_window_labels_sets_status_symbol(db, tmux, harnesses):
async def test_sync_window_labels_sets_status_symbol(factory, db, tmux, harnesses):
"""sync_window_labels pushes a '<symbol> <nickname>' label per session."""
from hqt.tmux.runner import WindowInfo
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
await service.create_session(
project_id=1, harness_name="claude-code", nickname="mywork"
)
@@ -545,11 +563,13 @@ async def test_sync_window_labels_sets_status_symbol(db, tmux, harnesses):
@pytest.mark.asyncio
async def test_sync_window_labels_dead_window_uses_dead_glyph(db, tmux, harnesses):
async def test_sync_window_labels_dead_window_uses_dead_glyph(
factory, db, tmux, harnesses
):
"""A dead/missing window gets the ○ glyph and falls back to the window name."""
from hqt.tmux.runner import WindowInfo
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
await service.create_session(
project_id=1, harness_name="claude-code"
) # no nickname
@@ -565,7 +585,7 @@ async def test_sync_window_labels_dead_window_uses_dead_glyph(db, tmux, harnesse
@pytest.mark.asyncio
async def test_sync_window_labels_covers_all_projects(db, tmux, harnesses):
async def test_sync_window_labels_covers_all_projects(factory, db, tmux, harnesses):
"""sync_window_labels is session-wide: it labels sessions across all projects."""
from hqt.db.models import Project
from hqt.tmux.runner import WindowInfo
@@ -573,7 +593,7 @@ async def test_sync_window_labels_covers_all_projects(db, tmux, harnesses):
db.add(Project(name="proj2", path="/tmp/proj2"))
db.commit()
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
await service.create_session(project_id=1, harness_name="claude-code", nickname="a")
await service.create_session(project_id=2, harness_name="claude-code", nickname="b")
@@ -596,7 +616,7 @@ async def test_sync_window_labels_covers_all_projects(db, tmux, harnesses):
@pytest.mark.asyncio
async def test_list_sessions_real_configurator_waiting(db):
async def test_list_sessions_real_configurator_waiting(factory, db):
"""list_sessions with real ClaudeConfigurator + spec'd TmuxManager mock → status='waiting'.
Uses the REAL ClaudeConfigurator (not a mock) to close the decorative-fixture
@@ -633,7 +653,7 @@ async def test_list_sessions_real_configurator_waiting(db):
configurator = ClaudeConfigurator()
harnesses = {"claude-code": configurator}
service = SessionService(db=db, tmux=tmux_spec, harnesses=harnesses)
service = SessionService(factory=factory, tmux=tmux_spec, harnesses=harnesses)
# Create a session so there is a row in the DB
await service.create_session(project_id=1, harness_name="claude-code")
@@ -658,13 +678,13 @@ async def test_list_sessions_real_configurator_waiting(db):
@pytest.mark.asyncio
async def test_list_sessions_dead_window_no_capture(db, tmux):
async def test_list_sessions_dead_window_no_capture(factory, db, tmux):
"""list_sessions: dead window → capture_pane must NOT be called."""
from hqt.tmux.runner import WindowInfo
from hqt.harnesses.configurators.claude import ClaudeConfigurator
harnesses = {"claude-code": ClaudeConfigurator()}
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
await service.create_session(project_id=1, harness_name="claude-code")
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=False, last_activity=0)}
@@ -676,12 +696,12 @@ async def test_list_sessions_dead_window_no_capture(db, tmux):
@pytest.mark.asyncio
async def test_list_sessions_missing_window_no_capture(db, tmux):
async def test_list_sessions_missing_window_no_capture(factory, db, tmux):
"""list_sessions: window missing from poll_info → capture_pane must NOT be called."""
from hqt.harnesses.configurators.claude import ClaudeConfigurator
harnesses = {"claude-code": ClaudeConfigurator()}
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
await service.create_session(project_id=1, harness_name="claude-code")
tmux.poll_info.return_value = {} # window not present at all
@@ -715,7 +735,7 @@ def harnesses_capture_fallback():
@pytest.mark.asyncio
async def test_respawn_fallback_rung2_updates_harness_session_id(
db, tmux, harnesses_capture_fallback
factory, db, tmux, harnesses_capture_fallback
):
"""Rung-2 success + captures_session_id=True + capture returns new id → sess.harness_session_id updated and committed."""
import hqt.sessions.service as svc_mod
@@ -727,7 +747,9 @@ async def test_respawn_fallback_rung2_updates_harness_session_id(
async def fake_sleep(t):
pass
service = SessionService(db=db, tmux=tmux, harnesses=harnesses_capture_fallback)
service = SessionService(
factory=factory, tmux=tmux, harnesses=harnesses_capture_fallback
)
with patch.object(svc_mod, "_capture_sleep", fake_sleep):
result = await service.create_session(project_id=1, harness_name="claude-code")
@@ -763,7 +785,7 @@ async def test_respawn_fallback_rung2_updates_harness_session_id(
@pytest.mark.asyncio
async def test_attach_late_capture_updates_id_before_resume(
db, tmux, harnesses_capture_fallback
factory, db, tmux, harnesses_capture_fallback
):
"""If create-time Codex capture missed, attach captures the real id before resuming."""
import hqt.sessions.service as svc_mod
@@ -776,7 +798,9 @@ async def test_attach_late_capture_updates_id_before_resume(
async def fake_sleep(t):
pass
service = SessionService(db=db, tmux=tmux, harnesses=harnesses_capture_fallback)
service = SessionService(
factory=factory, tmux=tmux, harnesses=harnesses_capture_fallback
)
with patch.object(svc_mod, "_capture_sleep", fake_sleep):
result = await service.create_session(project_id=1, harness_name="claude-code")
@@ -802,7 +826,7 @@ async def test_attach_late_capture_updates_id_before_resume(
@pytest.mark.asyncio
async def test_respawn_fallback_rung2_capture_exhausted_keeps_old_id(
db, tmux, harnesses_capture_fallback, caplog
factory, db, tmux, harnesses_capture_fallback, caplog
):
"""Rung-2 success + capture exhausted → old id kept + warning logged."""
import logging
@@ -815,7 +839,9 @@ async def test_respawn_fallback_rung2_capture_exhausted_keeps_old_id(
async def fake_sleep(t):
pass
service = SessionService(db=db, tmux=tmux, harnesses=harnesses_capture_fallback)
service = SessionService(
factory=factory, tmux=tmux, harnesses=harnesses_capture_fallback
)
with patch.object(svc_mod, "_capture_sleep", fake_sleep):
result = await service.create_session(project_id=1, harness_name="claude-code")
@@ -853,7 +879,7 @@ async def test_respawn_fallback_rung2_capture_exhausted_keeps_old_id(
@pytest.mark.asyncio
async def test_respawn_fallback_rung2_no_capture_when_flag_false(db, tmux):
async def test_respawn_fallback_rung2_no_capture_when_flag_false(factory, db, tmux):
"""Rung-2 success + captures_session_id=False → capture_session_id never called."""
h = MagicMock()
h.name = "claude-code"
@@ -867,7 +893,7 @@ async def test_respawn_fallback_rung2_no_capture_when_flag_false(db, tmux):
)
harnesses_nc = {"claude-code": h}
service = SessionService(db=db, tmux=tmux, harnesses=harnesses_nc)
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses_nc)
result = await service.create_session(project_id=1, harness_name="claude-code")
sess_id = result.session.id
@@ -893,7 +919,9 @@ async def test_respawn_fallback_rung2_no_capture_when_flag_false(db, tmux):
@pytest.mark.asyncio
async def test_create_session_no_capture_when_spawn_fails(db, tmux, harnesses_capture):
async def test_create_session_no_capture_when_spawn_fails(
factory, db, tmux, harnesses_capture
):
"""create_session: spawn fails → capture_session_id must NOT be called."""
import hqt.sessions.service as svc_mod
@@ -904,7 +932,7 @@ async def test_create_session_no_capture_when_spawn_fails(db, tmux, harnesses_ca
async def fake_sleep(t):
pass
service = SessionService(db=db, tmux=tmux, harnesses=harnesses_capture)
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses_capture)
with patch.object(svc_mod, "_capture_sleep", fake_sleep):
result = await service.create_session(project_id=1, harness_name="claude-code")
@@ -933,14 +961,16 @@ async def test_create_session_returns_result_dataclass(service, db, tmux, harnes
@pytest.mark.asyncio
async def test_create_session_result_propagates_spawn_failure(db, tmux, harnesses):
async def test_create_session_result_propagates_spawn_failure(
factory, db, tmux, harnesses
):
"""create_session result reflects spawn failure."""
from hqt.sessions.service import CreateSessionResult
tmux.spawn.return_value = SpawnResult(
ok=False, window_id=None, error="command not found: claude"
)
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses)
result = await service.create_session(project_id=1, harness_name="claude-code")
assert isinstance(result, CreateSessionResult)
@@ -977,6 +1007,7 @@ def test_rename_session_sets_nickname(service, db):
sess = _seed_session(db, nickname="old")
service.rename_session(sess.id, "new-name")
db.expire_all()
assert db.get(Session, sess.id).nickname == "new-name"
@@ -985,6 +1016,7 @@ def test_rename_session_empty_clears_to_none(service, db):
sess = _seed_session(db, nickname="old")
service.rename_session(sess.id, "")
db.expire_all()
assert db.get(Session, sess.id).nickname is None
@@ -993,6 +1025,7 @@ def test_rename_session_whitespace_clears_to_none(service, db):
sess = _seed_session(db, nickname="old")
service.rename_session(sess.id, " ")
db.expire_all()
assert db.get(Session, sess.id).nickname is None
@@ -1003,3 +1036,73 @@ def test_get_session_returns_row(service, db):
def test_get_session_missing_returns_none(service):
assert service.get_session(99999) is None
# ---------------------------------------------------------------------------
# Task 4: per-operation sessions, ServiceError, eager-load harness
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_create_session_unknown_project_raises(service):
with pytest.raises(ServiceError, match="no longer exists"):
await service.create_session(9999, "claude-code")
@pytest.mark.asyncio
async def test_create_session_uninstalled_harness_raises(factory, db, tmux):
# Harness row exists in the DB but the binary is gone from PATH
# (self.harnesses has no configurator for it).
service = SessionService(factory=factory, tmux=tmux, harnesses={})
with pytest.raises(ServiceError, match="not installed"):
await service.create_session(1, "claude-code")
@pytest.mark.asyncio
async def test_attach_session_missing_row_raises(service):
with pytest.raises(ServiceError, match="not found"):
await service.attach_session(9999)
@pytest.mark.asyncio
async def test_list_sessions_rows_usable_after_return(service, db, tmux):
await service.create_session(1, "claude-code")
infos = await service.list_sessions(1)
# The widget reads session.harness.name on detached rows; selectinload
# must have eager-loaded the relationship.
assert infos[0].session.harness.name == "claude-code"
@pytest.mark.asyncio
async def test_list_sessions_skips_capture_for_deleted_project(
factory, db, tmux, harnesses_capture
):
# A session whose project row was deleted must not crash the poller.
# Capture returns None at create time so the placeholder id is kept; the
# poller then re-enters the capture path and must SKIP on the missing
# project rather than raise.
import hqt.sessions.service as svc_mod
from hqt.db.models import Session
harnesses_capture["claude-code"].capture_session_id.return_value = None
async def fake_sleep(t):
pass
service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses_capture)
with patch.object(svc_mod, "_capture_sleep", fake_sleep):
await service.create_session(1, "claude-code")
# The placeholder id must still be in place so the poller attempts capture.
db.expire_all()
fresh = db.query(Session).filter_by(project_id=1).first()
assert fresh.harness_session_id == "placeholder-id"
db.query(Project).delete()
db.commit()
# Capture would now find an id, but the project lookup must short-circuit
# before it is consulted; either way no ServiceError may escape.
harnesses_capture["claude-code"].capture_session_id.return_value = "would-capture"
infos = await service.list_sessions(1)
assert len(infos) == 1 # no ServiceError escaped