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
+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