refactor: per-operation DB sessions and ServiceError in SessionService
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user