import pytest from datetime import datetime, timedelta from pathlib import Path 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, ScheduledPrompt, Session from hqt.errors import ServiceError from hqt.sandbox import SandboxPolicy from hqt.sessions.service import SessionInfo, SessionService from hqt.tmux.manager import SpawnResult, TmuxManager @pytest.fixture def factory(): engine = create_engine( "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) Base.metadata.create_all(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")) session.commit() yield session session.close() @pytest.fixture def tmux(): m = MagicMock(spec=TmuxManager) m.spawn = AsyncMock(return_value=SpawnResult(ok=True, window_id="@1", error="")) m.kill = AsyncMock() m.is_alive = AsyncMock(return_value=True) m.poll_info = AsyncMock(return_value={}) m.capture_pane = AsyncMock(return_value="") m.attach = AsyncMock() m.send_text = AsyncMock(return_value=True) m.send_enter = AsyncMock(return_value=True) m.window_exists = AsyncMock(return_value=True) m.respawn_verified = AsyncMock( return_value=SpawnResult(ok=True, window_id=None, error="") ) return m @pytest.fixture def harnesses(): h = MagicMock() h.captures_session_id = False h.generate_session_id.return_value = "sess-1" h.build_spawn_config.return_value = MagicMock( command=["claude"], env={}, cwd=Path("/tmp/myproj") ) h.parse_status = MagicMock(return_value=None) return {"claude-code": h} @pytest.fixture 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 async def test_create_session(service, db, tmux, harnesses): result = await service.create_session( project_id=1, harness_name="claude-code", nickname="test" ) sess = result.session assert sess.id is not None assert sess.tmux_session_name == f"hqt-{sess.id}" assert sess.harness_session_id == "sess-1" tmux.spawn.assert_called_once() harnesses["claude-code"].build_spawn_config.assert_called_once() @pytest.mark.asyncio async def test_list_sessions(service, db, tmux): from hqt.tmux.runner import WindowInfo await service.create_session(project_id=1, harness_name="claude-code") tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=True, last_activity=1000)} result = await service.list_sessions(project_id=1) assert len(result) == 1 assert isinstance(result[0], SessionInfo) assert result[0].alive is True def test_schedule_prompt_creates_pending_row(service, db): sess = _seed_session(db) due_at = datetime(2026, 6, 11, 12) prompt = service.schedule_prompt(sess.id, " run the tests ", due_at) assert prompt.session_id == sess.id assert prompt.prompt == "run the tests" assert prompt.due_at == due_at assert prompt.status == "pending" assert prompt.error is None rows = db.query(ScheduledPrompt).all() assert len(rows) == 1 def test_schedule_prompt_replaces_existing_row_for_session(service, db): sess = _seed_session(db) first_due = datetime(2026, 6, 11, 12) second_due = datetime(2026, 6, 11, 13) first = service.schedule_prompt(sess.id, "old prompt", first_due) second = service.schedule_prompt(sess.id, "new prompt", second_due) rows = db.query(ScheduledPrompt).all() assert len(rows) == 1 assert second.id == first.id assert rows[0].id == first.id assert rows[0].prompt == "new prompt" assert rows[0].due_at == second_due assert rows[0].status == "pending" assert rows[0].error is None def test_schedule_prompt_rejects_empty_prompt(service): due_at = datetime(2026, 6, 11, 12) with pytest.raises(ServiceError, match="Prompt cannot be empty"): service.schedule_prompt(1, " ", due_at) def test_schedule_prompt_rejects_missing_session(service): due_at = datetime(2026, 6, 11, 12) with pytest.raises(ServiceError, match="Session not found"): service.schedule_prompt(999, "hello", due_at) def test_cancel_scheduled_prompt_deletes_row(service, db): sess = _seed_session(db) due_at = datetime(2026, 6, 11, 12) service.schedule_prompt(sess.id, "run the tests", due_at) service.cancel_scheduled_prompt(sess.id) assert db.query(ScheduledPrompt).all() == [] def test_get_scheduled_prompt_returns_pending_only(service, db): sess = _seed_session(db) due_at = datetime(2026, 6, 11, 12) service.schedule_prompt(sess.id, "run the tests", due_at) prompt = service.get_scheduled_prompt(sess.id) assert prompt is not None assert prompt.status == "pending" row = db.query(ScheduledPrompt).filter_by(session_id=sess.id).one() row.status = "failed" db.commit() assert service.get_scheduled_prompt(sess.id) is None @pytest.mark.asyncio async def test_list_sessions_includes_pending_scheduled_prompt(service, db, tmux): from hqt.tmux.runner import WindowInfo sess = _seed_session(db) due_at = datetime(2026, 6, 11, 12) service.schedule_prompt(sess.id, "run the tests", due_at) tmux.poll_info.return_value = { sess.tmux_session_name: WindowInfo(alive=True, last_activity=1000) } result = await service.list_sessions(project_id=1) assert result[0].scheduled_prompt is not None assert result[0].scheduled_prompt.prompt == "run the tests" assert result[0].scheduled_prompt.status == "pending" @pytest.mark.asyncio async def test_dispatch_due_prompt_alive_window_sends_and_marks_sent(service, db, tmux): sess = _seed_session(db) due = datetime.now() - timedelta(seconds=1) scheduled = service.schedule_prompt(sess.id, "continue", due) tmux.is_alive = AsyncMock(return_value=True) results = await service.dispatch_due_prompts(now=datetime.now()) assert len(results) == 1 assert results[0].ok is True assert results[0].session_id == sess.id assert results[0].window_name == "hqt-rename" tmux.send_text.assert_awaited_once_with("hqt-rename", "continue") tmux.send_enter.assert_awaited_once_with("hqt-rename") db.expire_all() row = db.get(ScheduledPrompt, scheduled.id) assert row.status == "sent" assert row.sent_at is not None @pytest.mark.asyncio async def test_dispatch_due_prompt_dead_window_resumes_without_attach( service, db, tmux, harnesses ): sess = _seed_session(db) service.schedule_prompt(sess.id, "continue", datetime.now() - timedelta(seconds=1)) tmux.is_alive = AsyncMock(return_value=False) tmux.respawn_verified = AsyncMock( return_value=SpawnResult(ok=True, window_id=None, error="") ) tmux.attach = AsyncMock(return_value=True) harnesses["claude-code"].build_resume_config = MagicMock( return_value=MagicMock( command=["claude", "--resume"], env={}, cwd=Path("/tmp/myproj") ) ) results = await service.dispatch_due_prompts(now=datetime.now()) assert len(results) == 1 assert results[0].ok is True tmux.respawn_verified.assert_awaited() tmux.attach.assert_not_called() tmux.send_text.assert_awaited_once_with("hqt-rename", "continue") tmux.send_enter.assert_awaited_once_with("hqt-rename") @pytest.mark.asyncio async def test_dispatch_due_prompt_resume_failure_marks_failed( service, db, tmux, harnesses ): sess = _seed_session(db) scheduled = service.schedule_prompt( sess.id, "continue", datetime.now() - timedelta(seconds=1) ) tmux.is_alive = AsyncMock(return_value=False) tmux.respawn_verified = AsyncMock( return_value=SpawnResult(ok=False, window_id=None, error="resume failed") ) harnesses["claude-code"].build_resume_config = MagicMock( return_value=MagicMock( command=["claude", "--resume"], env={}, cwd=Path("/tmp/myproj") ) ) harnesses["claude-code"].build_spawn_config = MagicMock( return_value=MagicMock(command=["claude"], env={}, cwd=Path("/tmp/myproj")) ) results = await service.dispatch_due_prompts(now=datetime.now()) assert len(results) == 1 assert results[0].ok is False assert "resume failed" in results[0].error tmux.send_text.assert_not_called() tmux.send_enter.assert_not_called() db.expire_all() row = db.get(ScheduledPrompt, scheduled.id) assert row.status == "failed" assert "resume failed" in row.error @pytest.mark.asyncio async def test_dispatch_due_prompt_send_failure_does_not_retry_next_poll( service, db, tmux ): sess = _seed_session(db) scheduled = service.schedule_prompt( sess.id, "continue", datetime.now() - timedelta(seconds=1) ) tmux.is_alive = AsyncMock(return_value=True) tmux.send_text = AsyncMock(return_value=False) first = await service.dispatch_due_prompts(now=datetime.now()) second = await service.dispatch_due_prompts(now=datetime.now()) assert len(first) == 1 assert first[0].ok is False assert second == [] assert tmux.send_text.await_count == 1 tmux.send_enter.assert_not_called() db.expire_all() row = db.get(ScheduledPrompt, scheduled.id) assert row.status == "failed" assert "send text failed" in row.error @pytest.mark.asyncio async def test_dispatch_due_prompt_missing_worktree_recreates_without_attach( factory, db, tmux, harnesses, fake_worktree ): missing = "/tmp/does-not-exist/.worktrees/feature-x" sess = _seed_worktree_session(db, path=missing) service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses) service.schedule_prompt(sess.id, "continue", datetime.now() - timedelta(seconds=1)) harnesses["claude-code"].build_resume_config = MagicMock( return_value=MagicMock(command=["claude"], env={}, cwd=Path(missing)) ) tmux.is_alive = AsyncMock(return_value=False) tmux.respawn_verified = AsyncMock( return_value=SpawnResult(ok=True, window_id=None, error="") ) tmux.attach = AsyncMock(return_value=True) results = await service.dispatch_due_prompts(now=datetime.now()) assert results[0].ok is True assert fake_worktree.add_calls == [(Path("/tmp/myproj"), "feature-x")] tmux.attach.assert_not_called() tmux.send_text.assert_awaited_once_with("hqt-wt", "continue") @pytest.mark.asyncio async def test_dispatch_due_prompt_late_capture_updates_id_before_resume( factory, db, tmux, harnesses_capture_fallback ): harness = harnesses_capture_fallback["claude-code"] harness.capture_session_id.return_value = "late-codex-id" sess = _seed_session(db) sess.harness_session_id = "placeholder-id" db.commit() service = SessionService( factory=factory, tmux=tmux, harnesses=harnesses_capture_fallback ) service.schedule_prompt(sess.id, "continue", datetime.now() - timedelta(seconds=1)) tmux.is_alive = AsyncMock(return_value=False) tmux.respawn_verified = AsyncMock( return_value=SpawnResult(ok=True, window_id="@2", error="") ) results = await service.dispatch_due_prompts(now=datetime.now()) assert results[0].ok is True harness.build_resume_config.assert_called_with( Path("/tmp/myproj"), "late-codex-id", sess.model, sandboxed=False ) db.expire_all() assert db.get(Session, sess.id).harness_session_id == "late-codex-id" @pytest.mark.asyncio async def test_dispatch_due_prompt_send_exception_marks_failed_and_does_not_retry( service, db, tmux ): sess = _seed_session(db) scheduled = service.schedule_prompt( sess.id, "continue", datetime.now() - timedelta(seconds=1) ) tmux.is_alive = AsyncMock(return_value=True) tmux.send_text = AsyncMock(side_effect=RuntimeError("send exploded")) first = await service.dispatch_due_prompts(now=datetime.now()) second = await service.dispatch_due_prompts(now=datetime.now()) assert len(first) == 1 assert first[0].ok is False assert "send exploded" in first[0].error assert second == [] tmux.send_text.assert_awaited_once() tmux.send_enter.assert_not_called() db.expire_all() row = db.get(ScheduledPrompt, scheduled.id) assert row.status == "failed" assert "send exploded" in row.error @pytest.mark.asyncio async def test_dispatch_due_prompt_resume_exception_marks_failed_without_send( service, db, tmux, harnesses ): sess = _seed_session(db) scheduled = service.schedule_prompt( sess.id, "continue", datetime.now() - timedelta(seconds=1) ) harnesses["claude-code"].build_resume_config = MagicMock( return_value=MagicMock( command=["claude", "--resume"], env={}, cwd=Path("/tmp/myproj") ) ) tmux.is_alive = AsyncMock(return_value=False) tmux.respawn_verified = AsyncMock(side_effect=RuntimeError("resume exploded")) results = await service.dispatch_due_prompts(now=datetime.now()) assert len(results) == 1 assert results[0].ok is False assert "resume exploded" in results[0].error tmux.send_text.assert_not_called() tmux.send_enter.assert_not_called() db.expire_all() row = db.get(ScheduledPrompt, scheduled.id) assert row.status == "failed" assert "resume exploded" in row.error @pytest.mark.asyncio async def test_dispatch_due_prompt_send_enter_failure_marks_failed(service, db, tmux): sess = _seed_session(db) scheduled = service.schedule_prompt( sess.id, "continue", datetime.now() - timedelta(seconds=1) ) tmux.is_alive = AsyncMock(return_value=True) tmux.send_enter = AsyncMock(return_value=False) results = await service.dispatch_due_prompts(now=datetime.now()) assert len(results) == 1 assert results[0].ok is False assert "send enter failed" in results[0].error tmux.send_text.assert_awaited_once_with("hqt-rename", "continue") tmux.send_enter.assert_awaited_once_with("hqt-rename") db.expire_all() row = db.get(ScheduledPrompt, scheduled.id) assert row.status == "failed" assert "send enter failed" in row.error @pytest.mark.asyncio async def test_dispatch_due_prompt_resume_fails_then_fallback_spawn_succeeds( service, db, tmux, harnesses ): sess = _seed_session(db) service.schedule_prompt(sess.id, "continue", datetime.now() - timedelta(seconds=1)) harnesses["claude-code"].build_resume_config = MagicMock( return_value=MagicMock( command=["claude", "--resume"], env={}, cwd=Path("/tmp/myproj") ) ) harnesses["claude-code"].build_spawn_config = MagicMock( return_value=MagicMock( command=["claude", "--session-id", "sess-seeded"], env={}, cwd=Path("/tmp/myproj"), ) ) tmux.is_alive = AsyncMock(return_value=False) tmux.respawn_verified = AsyncMock( side_effect=[ SpawnResult(ok=False, window_id=None, error="resume failed"), SpawnResult(ok=True, window_id="@2", error=""), ] ) results = await service.dispatch_due_prompts(now=datetime.now()) assert results[0].ok is True assert tmux.respawn_verified.await_count == 2 tmux.send_text.assert_awaited_once_with("hqt-rename", "continue") @pytest.mark.asyncio async def test_stop_session(service, tmux): await service.create_session(project_id=1, harness_name="claude-code") await service.stop_session(session_id=1) tmux.kill.assert_called_once_with("hqt-1") @pytest.mark.asyncio async def test_stop_session_no_kill_when_window_missing(service, db, tmux): """stop_session must NOT call kill when the window doesn't exist.""" await service.create_session(project_id=1, harness_name="claude-code") tmux.window_exists = AsyncMock(return_value=False) tmux.kill.reset_mock() await service.stop_session(session_id=1) tmux.kill.assert_not_called() @pytest.mark.asyncio async def test_create_session_logs_failed_spawn(service, db, tmux, caplog): """create_session logs error including pane text when spawn fails, but still commits.""" import logging tmux.spawn.return_value = SpawnResult( ok=False, window_id=None, error="bash: command not found" ) with caplog.at_level(logging.ERROR): result = await service.create_session(project_id=1, harness_name="claude-code") assert result.session.id is not None # row was committed # Error was logged and includes captured pane text error_messages = [r.message for r in caplog.records if r.levelno == logging.ERROR] assert any("command not found" in m for m in error_messages) @pytest.mark.asyncio async def test_attach_session_passes_env(service, db, tmux, harnesses): """attach_session passes cfg.env to tmux.respawn_verified when the window needs respawning.""" from unittest.mock import AsyncMock # Create a session first await service.create_session(project_id=1, harness_name="claude-code") # Set up harness to return a config with env env_cfg = MagicMock( command=["claude"], env={"SESSION_VAR": "abc"}, cwd=Path("/tmp/myproj") ) harnesses["claude-code"].build_resume_config = MagicMock(return_value=env_cfg) # Simulate window is not alive → respawn path tmux.is_alive = AsyncMock(return_value=False) tmux.respawn_verified = AsyncMock( return_value=SpawnResult(ok=True, window_id=None, error="") ) tmux.attach = AsyncMock(return_value=True) await service.attach_session(session_id=1) tmux.respawn_verified.assert_called() first_call = tmux.respawn_verified.call_args_list[0] # env must be forwarded (passed as keyword or positional) assert first_call.kwargs.get("env") == {"SESSION_VAR": "abc"} or ( len(first_call.args) >= 4 and first_call.args[3] == {"SESSION_VAR": "abc"} ) # --------------------------------------------------------------------------- # Task 3: fallback ladder + model preserved on resume # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_attach_session_dead_resume_ok_attaches_once( service, db, tmux, harnesses ): """attach_session: resume ok → attach called once, no fallback spawn attempted.""" await service.create_session(project_id=1, harness_name="claude-code") resume_cfg = MagicMock( command=["claude", "--resume", "sess-1"], env={}, cwd=Path("/tmp/myproj") ) harnesses["claude-code"].build_resume_config = MagicMock(return_value=resume_cfg) harnesses["claude-code"].build_spawn_config = MagicMock( return_value=MagicMock( command=["claude", "--session-id", "sess-1"], env={}, cwd=Path("/tmp/myproj"), ) ) tmux.is_alive = AsyncMock(return_value=False) tmux.respawn_verified = AsyncMock( return_value=SpawnResult(ok=True, window_id=None, error="") ) tmux.attach = AsyncMock(return_value=True) result = await service.attach_session(session_id=1) assert result is True tmux.attach.assert_called_once() # Only one respawn_verified call (the resume rung), no fallback assert tmux.respawn_verified.call_count == 1 first_cmd = tmux.respawn_verified.call_args_list[0].args[1] assert "--resume" in first_cmd @pytest.mark.asyncio async def test_attach_session_dead_resume_fails_fallback_spawn( service, db, tmux, harnesses ): """attach_session: resume dies → fallback spawn with session-id, attach called once.""" await service.create_session(project_id=1, harness_name="claude-code") resume_cfg = MagicMock( command=["claude", "--resume", "sess-1"], env={}, cwd=Path("/tmp/myproj") ) spawn_cfg = MagicMock( command=["claude", "--session-id", "sess-1"], env={}, cwd=Path("/tmp/myproj") ) harnesses["claude-code"].build_resume_config = MagicMock(return_value=resume_cfg) harnesses["claude-code"].build_spawn_config = MagicMock(return_value=spawn_cfg) tmux.is_alive = AsyncMock(return_value=False) tmux.respawn_verified = AsyncMock( side_effect=[ SpawnResult( ok=False, window_id=None, error="no transcript" ), # resume fails SpawnResult(ok=True, window_id="@2", error=""), # fresh spawn ok ] ) tmux.attach = AsyncMock(return_value=True) result = await service.attach_session(session_id=1) assert result is True # Two respawn_verified calls: first resume, then spawn assert tmux.respawn_verified.call_count == 2 first_cmd = tmux.respawn_verified.call_args_list[0].args[1] second_cmd = tmux.respawn_verified.call_args_list[1].args[1] assert "--resume" in first_cmd assert "--session-id" in second_cmd tmux.attach.assert_called_once() @pytest.mark.asyncio async def test_attach_session_both_rungs_fail_returns_false( service, db, tmux, harnesses ): """attach_session: both resume and spawn fail → False, no attach.""" await service.create_session(project_id=1, harness_name="claude-code") resume_cfg = MagicMock( command=["claude", "--resume", "sess-1"], env={}, cwd=Path("/tmp/myproj") ) spawn_cfg = MagicMock( command=["claude", "--session-id", "sess-1"], env={}, cwd=Path("/tmp/myproj") ) harnesses["claude-code"].build_resume_config = MagicMock(return_value=resume_cfg) harnesses["claude-code"].build_spawn_config = MagicMock(return_value=spawn_cfg) tmux.is_alive = AsyncMock(return_value=False) tmux.respawn_verified = AsyncMock( return_value=SpawnResult(ok=False, window_id=None, error="fatal") ) tmux.attach = AsyncMock(return_value=True) result = await service.attach_session(session_id=1) assert result is False tmux.attach.assert_not_called() # --------------------------------------------------------------------------- # Task 4: captures_session_id flag + bounded async retry in create_session # --------------------------------------------------------------------------- @pytest.fixture def harnesses_no_capture(): """Configurator with captures_session_id=False (like claude-code).""" h = MagicMock() h.captures_session_id = False h.generate_session_id.return_value = "sess-nc" h.build_spawn_config.return_value = MagicMock( command=["claude"], env={}, cwd=Path("/tmp/myproj") ) return {"claude-code": h} @pytest.fixture def harnesses_capture(): """Configurator with captures_session_id=True (like codex).""" h = MagicMock() h.captures_session_id = True h.generate_session_id.return_value = "placeholder-id" h.build_spawn_config.return_value = MagicMock( command=["codex"], env={}, cwd=Path("/tmp/myproj") ) return {"claude-code": h} @pytest.mark.asyncio async def test_create_session_no_capture_flag_never_calls_capture( factory, db, tmux, harnesses_no_capture ): """When captures_session_id=False, capture_session_id is never called.""" 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( 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 harnesses_capture["claude-code"].capture_session_id.side_effect = [ None, None, "captured-id-xyz", ] sleep_calls = [] async def fake_sleep(t): sleep_calls.append(t) 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") assert result.session.harness_session_id == "captured-id-xyz" # capture was called 3 times: first immediate, then twice after sleeps assert harnesses_capture["claude-code"].capture_session_id.call_count == 3 assert len(sleep_calls) == 2 @pytest.mark.asyncio async def test_create_session_capture_all_none_keeps_placeholder( factory, db, tmux, harnesses_capture, caplog ): """All capture attempts return None → placeholder id kept, no crash, warning logged.""" import logging import hqt.sessions.service as svc_mod 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 caplog.at_level(logging.WARNING): with patch.object(svc_mod, "_capture_sleep", fake_sleep): result = await service.create_session( project_id=1, harness_name="claude-code" ) assert result.session.harness_session_id == "placeholder-id" # Should have attempted up to 6 times (CAPTURE_MAX_ATTEMPTS) assert ( harnesses_capture["claude-code"].capture_session_id.call_count == svc_mod.CAPTURE_MAX_ATTEMPTS ) # Exhaustion must emit a warning warning_messages = [ r.message for r in caplog.records if r.levelno == logging.WARNING ] assert any("exhausted" in m for m in warning_messages) @pytest.mark.asyncio async def test_create_session_capture_first_attempt_succeeds( factory, db, tmux, harnesses_capture ): """captures_session_id=True: first attempt succeeds → no sleeps needed.""" import hqt.sessions.service as svc_mod harnesses_capture["claude-code"].capture_session_id.return_value = "immediate-id" sleep_calls = [] async def fake_sleep(t): sleep_calls.append(t) 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") assert result.session.harness_session_id == "immediate-id" assert len(sleep_calls) == 0 # no sleep needed — succeeded on first try # --------------------------------------------------------------------------- # Task 7: list_sessions status computation # --------------------------------------------------------------------------- @pytest.mark.asyncio 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( 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(factory=factory, tmux=tmux, harnesses=harnesses) result = await service.list_sessions(project_id=1) assert len(result) == 1 assert result[0].alive is False assert result[0].status == "dead" @pytest.mark.asyncio 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( 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(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(factory, db, tmux, harnesses): """Alive window + harness parse returns 'working' → status='working'.""" from hqt.tmux.runner import WindowInfo 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(factory=factory, tmux=tmux, harnesses=harnesses) result = await service.list_sessions(project_id=1, now=now) assert result[0].status == "working" assert result[0].alive is True @pytest.mark.asyncio 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( 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=1998) } # 2s ago tmux.capture_pane = AsyncMock(return_value="some text") harnesses["claude-code"].parse_status = MagicMock(return_value=None) 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( factory, db, tmux, harnesses ): """Alive window + parse_status None + stale activity → status='idle'.""" from hqt.tmux.runner import WindowInfo 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=1990) } # 10s ago tmux.capture_pane = AsyncMock(return_value="some text") harnesses["claude-code"].parse_status = MagicMock(return_value=None) 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( factory, db, tmux, harnesses ): """capture_pane returns empty text → activity-based fallback, no crash.""" from hqt.tmux.runner import WindowInfo 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(factory=factory, tmux=tmux, harnesses=harnesses) result = await service.list_sessions(project_id=1, now=now) # Should still produce a valid status via activity assert result[0].status in ("active", "idle") assert result[0].alive is True @pytest.mark.asyncio 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( 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(factory=factory, tmux=tmux, harnesses=harnesses) result = await service.list_sessions(project_id=1, now=now) assert result[0].status == "waiting" # --------------------------------------------------------------------------- # sync_window_labels: status-reflecting tmux window titles (session-wide) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_sync_window_labels_sets_status_symbol(factory, db, tmux, harnesses): """sync_window_labels pushes a ' ' label per session.""" from hqt.tmux.runner import WindowInfo service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses) await service.create_session( project_id=1, harness_name="claude-code", nickname="mywork" ) 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") tmux.set_window_label = AsyncMock() infos = await service.sync_window_labels(now=now) assert len(infos) == 1 assert infos[0].status == "working" # Label uses the working glyph (◐) and the nickname. tmux.set_window_label.assert_awaited_once_with("hqt-1", "◐ mywork") @pytest.mark.asyncio 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(factory=factory, tmux=tmux, harnesses=harnesses) await service.create_session( project_id=1, harness_name="claude-code" ) # no nickname tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=False, last_activity=0)} tmux.set_window_label = AsyncMock() infos = await service.sync_window_labels() assert infos[0].status == "dead" # No nickname → falls back to the tmux window name. tmux.set_window_label.assert_awaited_once_with("hqt-1", "○ hqt-1") @pytest.mark.asyncio 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 db.add(Project(name="proj2", path="/tmp/proj2")) db.commit() 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") tmux.poll_info.return_value = { "hqt-1": WindowInfo(alive=False, last_activity=0), "hqt-2": WindowInfo(alive=False, last_activity=0), } tmux.set_window_label = AsyncMock() infos = await service.sync_window_labels() assert len(infos) == 2 # both projects' sessions processed labelled = {c.args[0] for c in tmux.set_window_label.await_args_list} assert labelled == {"hqt-1", "hqt-2"} @pytest.mark.asyncio async def test_sync_window_labels_includes_pending_scheduled_prompt( factory, db, tmux, harnesses ): """sync_window_labels returns SessionInfo with pending scheduled prompt metadata.""" from hqt.tmux.runner import WindowInfo service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses) result = await service.create_session(project_id=1, harness_name="claude-code") due_at = datetime(2026, 6, 11, 12) service.schedule_prompt(result.session.id, "run the tests", due_at) tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=False, last_activity=0)} tmux.set_window_label = AsyncMock() infos = await service.sync_window_labels() assert infos[0].scheduled_prompt is not None assert infos[0].scheduled_prompt.prompt == "run the tests" assert infos[0].scheduled_prompt.status == "pending" # --------------------------------------------------------------------------- # Item 3: real-configurator integration-seam test # --------------------------------------------------------------------------- @pytest.mark.asyncio 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 gap and ensure parse_status is correctly wired end-to-end. TDD story: before the capture_pane delegate is added to TmuxManager, MagicMock(spec=TmuxManager).capture_pane raises AttributeError, which propagates as an AttributeError from list_sessions. After the delegate is added the spec allows the attribute and this test passes. """ from hqt.tmux.runner import WindowInfo from hqt.tmux.manager import TmuxManager from hqt.harnesses.configurators.claude import ClaudeConfigurator # spec=TmuxManager: only methods that exist on TmuxManager are accessible. # capture_pane MUST be on TmuxManager (via the delegate) for the spec to allow it. tmux_spec = MagicMock(spec=TmuxManager) tmux_spec.spawn = AsyncMock( return_value=SpawnResult(ok=True, window_id="@1", error="") ) tmux_spec.kill = AsyncMock() tmux_spec.is_alive = AsyncMock(return_value=True) tmux_spec.poll_info = AsyncMock(return_value={}) # capture_pane returns realistic "waiting" pane text; the spec must allow this # attribute — fails with AttributeError before TmuxManager.capture_pane delegate exists. tmux_spec.capture_pane = AsyncMock( return_value="Do you want to proceed?\n❯ 1. Yes\n" ) tmux_spec.attach = AsyncMock() tmux_spec.window_exists = AsyncMock(return_value=True) tmux_spec.respawn_verified = AsyncMock( return_value=SpawnResult(ok=True, window_id=None, error="") ) configurator = ClaudeConfigurator() harnesses = {"claude-code": configurator} 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") now = 2000.0 window_name = "hqt-1" tmux_spec.poll_info.return_value = { window_name: WindowInfo(alive=True, last_activity=1999) } result = await service.list_sessions(project_id=1, now=now) assert len(result) == 1 assert result[0].status == "waiting" assert result[0].alive is True tmux_spec.capture_pane.assert_called_once_with(window_name) # --------------------------------------------------------------------------- # Item 4: capture_pane NOT called for dead/missing windows # --------------------------------------------------------------------------- @pytest.mark.asyncio 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(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)} tmux.capture_pane.reset_mock() await service.list_sessions(project_id=1) tmux.capture_pane.assert_not_called() @pytest.mark.asyncio 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(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 tmux.capture_pane.reset_mock() await service.list_sessions(project_id=1) tmux.capture_pane.assert_not_called() # --------------------------------------------------------------------------- # Fix 1: _respawn_with_fallback rung-2 updates harness_session_id # --------------------------------------------------------------------------- @pytest.fixture def harnesses_capture_fallback(): """Configurator with captures_session_id=True for fallback ladder tests.""" h = MagicMock() h.name = "claude-code" h.captures_session_id = True h.generate_session_id.return_value = "placeholder-id" h.build_spawn_config.return_value = MagicMock( command=["codex"], env={}, cwd=Path("/tmp/myproj") ) h.build_resume_config.return_value = MagicMock( command=["codex", "resume", "placeholder-id"], env={}, cwd=Path("/tmp/myproj") ) return {"claude-code": h} @pytest.mark.asyncio async def test_respawn_fallback_rung2_updates_harness_session_id( 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 harnesses_capture_fallback[ "claude-code" ].capture_session_id.return_value = "new-codex-id" async def fake_sleep(t): pass 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") sess_id = result.session.id # Simulate rung-1 resume fails, rung-2 fresh spawn succeeds harnesses_capture_fallback["claude-code"].capture_session_id.reset_mock() harnesses_capture_fallback[ "claude-code" ].capture_session_id.return_value = "new-codex-id-after-rung2" tmux.is_alive = AsyncMock(return_value=False) tmux.respawn_verified = AsyncMock( side_effect=[ SpawnResult( ok=False, window_id=None, error="no transcript" ), # rung-1 resume fails SpawnResult(ok=True, window_id="@2", error=""), # rung-2 fresh spawn ok ] ) tmux.attach = AsyncMock(return_value=True) with patch.object(svc_mod, "_capture_sleep", fake_sleep): ok = await service.attach_session(session_id=sess_id) assert ok is True # harness_session_id must now be the newly captured id from hqt.db.models import Session as DBSession updated = db.get(DBSession, sess_id) assert updated.harness_session_id == "new-codex-id-after-rung2" @pytest.mark.asyncio async def test_attach_late_capture_updates_id_before_resume( 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 harness = harnesses_capture_fallback["claude-code"] harness.capture_session_id.side_effect = [None] * svc_mod.CAPTURE_MAX_ATTEMPTS + [ "late-codex-id" ] async def fake_sleep(t): pass 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") assert result.session.harness_session_id == "placeholder-id" tmux.is_alive = AsyncMock(return_value=False) tmux.respawn_verified = AsyncMock( return_value=SpawnResult(ok=True, window_id="@2", error="") ) tmux.attach = AsyncMock(return_value=True) ok = await service.attach_session(session_id=result.session.id) assert ok is True harness.build_resume_config.assert_called_with( Path("/tmp/myproj"), "late-codex-id", result.session.model, sandboxed=False ) from hqt.db.models import Session as DBSession updated = db.get(DBSession, result.session.id) assert updated.harness_session_id == "late-codex-id" @pytest.mark.asyncio async def test_respawn_fallback_rung2_capture_exhausted_keeps_old_id( factory, db, tmux, harnesses_capture_fallback, caplog ): """Rung-2 success + capture exhausted → old id kept + warning logged.""" import logging import hqt.sessions.service as svc_mod harnesses_capture_fallback[ "claude-code" ].capture_session_id.return_value = "placeholder-id" async def fake_sleep(t): pass 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") sess_id = result.session.id old_id = result.session.harness_session_id # After create, reset capture to always return None harnesses_capture_fallback["claude-code"].capture_session_id.reset_mock() harnesses_capture_fallback["claude-code"].capture_session_id.return_value = None tmux.is_alive = AsyncMock(return_value=False) tmux.respawn_verified = AsyncMock( side_effect=[ SpawnResult(ok=False, window_id=None, error="no transcript"), SpawnResult(ok=True, window_id="@2", error=""), ] ) tmux.attach = AsyncMock(return_value=True) with caplog.at_level(logging.WARNING): with patch.object(svc_mod, "_capture_sleep", fake_sleep): ok = await service.attach_session(session_id=sess_id) assert ok is True from hqt.db.models import Session as DBSession updated = db.get(DBSession, sess_id) # Old id preserved assert updated.harness_session_id == old_id # Warning emitted warning_messages = [ r.message for r in caplog.records if r.levelno == logging.WARNING ] assert any("exhausted" in m for m in warning_messages) @pytest.mark.asyncio 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" h.captures_session_id = False h.generate_session_id.return_value = "sess-nc" h.build_spawn_config.return_value = MagicMock( command=["claude"], env={}, cwd=Path("/tmp/myproj") ) h.build_resume_config.return_value = MagicMock( command=["claude", "--resume", "sess-nc"], env={}, cwd=Path("/tmp/myproj") ) harnesses_nc = {"claude-code": h} 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 h.capture_session_id.reset_mock() tmux.is_alive = AsyncMock(return_value=False) tmux.respawn_verified = AsyncMock( side_effect=[ SpawnResult(ok=False, window_id=None, error="no transcript"), SpawnResult(ok=True, window_id="@2", error=""), ] ) tmux.attach = AsyncMock(return_value=True) await service.attach_session(session_id=sess_id) h.capture_session_id.assert_not_called() # --------------------------------------------------------------------------- # Fix 2 (Minor): capture NOT called when spawn_result.ok is False # --------------------------------------------------------------------------- @pytest.mark.asyncio 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 tmux.spawn.return_value = SpawnResult( ok=False, window_id=None, error="spawn failed" ) async def fake_sleep(t): pass 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") harnesses_capture["claude-code"].capture_session_id.assert_not_called() assert result.spawn_ok is False assert "spawn failed" in result.spawn_error # --------------------------------------------------------------------------- # Fix 3: create_session returns CreateSessionResult # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_create_session_returns_result_dataclass(service, db, tmux, harnesses): """create_session returns a CreateSessionResult with spawn_ok=True on success.""" from hqt.sessions.service import CreateSessionResult result = await service.create_session( project_id=1, harness_name="claude-code", nickname="test" ) assert isinstance(result, CreateSessionResult) assert result.spawn_ok is True assert result.spawn_error == "" assert result.session.id is not None @pytest.mark.asyncio 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(factory=factory, tmux=tmux, harnesses=harnesses) result = await service.create_session(project_id=1, harness_name="claude-code") assert isinstance(result, CreateSessionResult) assert result.spawn_ok is False assert "command not found" in result.spawn_error # Session row was still committed assert result.session.id is not None # --------------------------------------------------------------------------- # rename_session / get_session # --------------------------------------------------------------------------- def _seed_session(db, project_id: int = 1, nickname=None) -> Session: """Create a session for the seeded 'claude-code' harness.""" harness = db.query(Harness).filter_by(name="claude-code").first() sess = Session( project_id=project_id, harness_id=harness.id, nickname=nickname, tmux_session_name="hqt-rename", harness_session_id="sess-seeded", archived=False, ) db.add(sess) db.commit() return sess def test_rename_session_sets_nickname(service, db): from hqt.db.models import Session 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" def test_rename_session_empty_clears_to_none(service, db): from hqt.db.models import Session sess = _seed_session(db, nickname="old") service.rename_session(sess.id, "") db.expire_all() assert db.get(Session, sess.id).nickname is None def test_rename_session_whitespace_clears_to_none(service, db): from hqt.db.models import Session sess = _seed_session(db, nickname="old") service.rename_session(sess.id, " ") db.expire_all() assert db.get(Session, sess.id).nickname is None def test_get_session_returns_row(service, db): sess = _seed_session(db, nickname="x") assert service.get_session(sess.id).id == sess.id 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 # --------------------------------------------------------------------------- # Task 3: worktree-aware session lifecycle # --------------------------------------------------------------------------- WT_PATH = Path("/tmp/myproj/.worktrees/feature-x") @pytest.fixture def fake_worktree(monkeypatch): """Monkeypatch hqt.git.worktree functions with recording async fakes. Returns a namespace exposing the recorded call args and the WorktreeState class so tests can assert ordering and arguments. """ import hqt.git.worktree as wt_mod class Recorder: def __init__(self): self.create_calls = [] self.add_calls = [] self.remove_calls = [] self.state_calls = [] self.create_path = WT_PATH self.create_error: Exception | None = None self.add_error: Exception | None = None self.remove_error: Exception | None = None self.state_result = wt_mod.WorktreeState( exists=True, dirty=False, unique_commits=2 ) async def create_worktree(self, repo, branch): self.create_calls.append((repo, branch)) if self.create_error is not None: raise self.create_error return self.create_path async def add_worktree_for_branch(self, repo, branch): self.add_calls.append((repo, branch)) if self.add_error is not None: raise self.add_error return self.create_path async def remove_worktree(self, repo, path, branch, force): self.remove_calls.append((repo, path, branch, force)) if self.remove_error is not None: raise self.remove_error async def worktree_state(self, repo, path, branch): self.state_calls.append((repo, path, branch)) return self.state_result rec = Recorder() monkeypatch.setattr(wt_mod, "create_worktree", rec.create_worktree) monkeypatch.setattr(wt_mod, "add_worktree_for_branch", rec.add_worktree_for_branch) monkeypatch.setattr(wt_mod, "remove_worktree", rec.remove_worktree) monkeypatch.setattr(wt_mod, "worktree_state", rec.worktree_state) return rec @pytest.mark.asyncio async def test_create_session_worktree_branch( service, db, tmux, harnesses, fake_worktree ): """worktree_branch set → create_worktree called with (project_path, branch); spawn cwd and capture use the worktree path; row stores both fields.""" harnesses["claude-code"].captures_session_id = True harnesses["claude-code"].capture_session_id.return_value = "cap-id" harnesses["claude-code"].build_spawn_config.return_value = MagicMock( command=["claude"], env={}, cwd=WT_PATH ) result = await service.create_session( project_id=1, harness_name="claude-code", worktree_branch="feature-x" ) # create_worktree called with project path + branch assert fake_worktree.create_calls == [(Path("/tmp/myproj"), "feature-x")] # spawn request cwd is the worktree path spawn_req = tmux.spawn.call_args.args[0] assert spawn_req.cwd == str(WT_PATH) # build_spawn_config used the worktree path, not the project path assert harnesses["claude-code"].build_spawn_config.call_args.args[0] == WT_PATH # capture used the worktree path assert harnesses["claude-code"].capture_session_id.call_args.args[0] == WT_PATH # row carries both worktree fields sess = result.session assert sess.worktree_path == str(WT_PATH) assert sess.worktree_branch == "feature-x" @pytest.mark.asyncio async def test_create_session_worktree_blank_nickname_defaults_to_branch( service, db, tmux, harnesses, fake_worktree ): """Blank nickname + worktree_branch → nickname defaults to branch name.""" result = await service.create_session( project_id=1, harness_name="claude-code", nickname="", worktree_branch="feature-x", ) assert result.session.nickname == "feature-x" @pytest.mark.asyncio async def test_create_session_worktree_explicit_nickname_preserved( service, db, tmux, harnesses, fake_worktree ): """Explicit nickname + worktree_branch → nickname preserved.""" result = await service.create_session( project_id=1, harness_name="claude-code", nickname="my-work", worktree_branch="feature-x", ) assert result.session.nickname == "my-work" @pytest.mark.asyncio async def test_create_session_worktree_create_fails_no_row_no_spawn( factory, db, tmux, harnesses, fake_worktree ): """create_worktree raising ServiceError → propagates, no Session row, no spawn.""" from hqt.db.models import Session fake_worktree.create_error = ServiceError("git worktree add failed") service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses) with pytest.raises(ServiceError, match="worktree add failed"): await service.create_session( project_id=1, harness_name="claude-code", worktree_branch="feature-x" ) # No Session row committed db.expire_all() assert db.query(Session).count() == 0 # tmux spawn never called tmux.spawn.assert_not_called() @pytest.mark.asyncio async def test_create_session_without_worktree_branch_regression( service, db, tmux, harnesses, fake_worktree ): """No worktree_branch → create_worktree not called, row worktree fields None.""" result = await service.create_session(project_id=1, harness_name="claude-code") assert fake_worktree.create_calls == [] assert result.session.worktree_path is None assert result.session.worktree_branch is None def _seed_worktree_session(db, branch="feature-x", path=str(WT_PATH)): """Seed a worktree session for project 1 / harness claude-code.""" from hqt.db.models import Harness, Session harness = db.query(Harness).filter_by(name="claude-code").first() sess = Session( project_id=1, harness_id=harness.id, tmux_session_name="hqt-wt", harness_session_id="sess-1", worktree_path=path, worktree_branch=branch, archived=False, ) db.add(sess) db.commit() return sess @pytest.mark.asyncio async def test_attach_session_recreates_missing_worktree( factory, db, tmux, harnesses, fake_worktree ): """attach_session: worktree dir missing on disk → add_worktree_for_branch called.""" # Use a path that does not exist on disk. missing = "/tmp/does-not-exist/.worktrees/feature-x" sess = _seed_worktree_session(db, path=missing) harnesses["claude-code"].build_resume_config = MagicMock( return_value=MagicMock(command=["claude"], env={}, cwd=Path(missing)) ) tmux.is_alive = AsyncMock(return_value=False) tmux.respawn_verified = AsyncMock( return_value=SpawnResult(ok=True, window_id=None, error="") ) tmux.attach = AsyncMock(return_value=True) service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses) await service.attach_session(session_id=sess.id) assert fake_worktree.add_calls == [(Path("/tmp/myproj"), "feature-x")] @pytest.mark.asyncio async def test_attach_session_missing_worktree_recreate_fails( factory, db, tmux, harnesses, fake_worktree ): """attach_session: recreate raises → ServiceError mentioning deleting the session.""" missing = "/tmp/does-not-exist/.worktrees/feature-x" sess = _seed_worktree_session(db, path=missing) fake_worktree.add_error = ServiceError("branch gone") service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses) with pytest.raises(ServiceError, match="delete the session"): await service.attach_session(session_id=sess.id) @pytest.mark.asyncio async def test_attach_session_worktree_resume_uses_worktree_path( factory, db, tmux, harnesses, fake_worktree ): """Resume config for a worktree session uses the worktree path as cwd.""" # WT_PATH parent exists (/tmp/myproj/.worktrees won't, but use a real existing dir) import tempfile with tempfile.TemporaryDirectory() as tmpdir: sess = _seed_worktree_session(db, path=tmpdir) resume_cfg = MagicMock(command=["claude", "--resume"], env={}, cwd=Path(tmpdir)) harnesses["claude-code"].build_resume_config = MagicMock( return_value=resume_cfg ) tmux.is_alive = AsyncMock(return_value=False) tmux.respawn_verified = AsyncMock( return_value=SpawnResult(ok=True, window_id=None, error="") ) tmux.attach = AsyncMock(return_value=True) service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses) await service.attach_session(session_id=sess.id) # build_resume_config received the worktree path as the project path arg assert harnesses["claude-code"].build_resume_config.call_args.args[0] == Path( tmpdir ) # since the worktree exists on disk, recreate must not have been attempted assert fake_worktree.add_calls == [] @pytest.mark.asyncio async def test_delete_session_remove_worktree_true( factory, db, tmux, harnesses, fake_worktree ): """delete_session(remove_worktree=True) calls remove_worktree and deletes the row.""" from hqt.db.models import Session sess = _seed_worktree_session(db) sess_id = sess.id service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses) await service.delete_session(sess_id, remove_worktree=True, force=True) assert fake_worktree.remove_calls == [ (Path("/tmp/myproj"), WT_PATH, "feature-x", True) ] db.expire_all() assert db.get(Session, sess_id) is None @pytest.mark.asyncio async def test_delete_session_remove_worktree_raises_keeps_row( factory, db, tmux, harnesses, fake_worktree ): """remove_worktree raising → ServiceError propagates, row still present.""" from hqt.db.models import Session sess = _seed_worktree_session(db) sess_id = sess.id fake_worktree.remove_error = ServiceError("worktree dirty") service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses) with pytest.raises(ServiceError, match="worktree dirty"): await service.delete_session(sess_id, remove_worktree=True) db.expire_all() assert db.get(Session, sess_id) is not None @pytest.mark.asyncio async def test_delete_session_remove_worktree_false_skips_removal( factory, db, tmux, harnesses, fake_worktree ): """remove_worktree=False → remove_worktree not called, row deleted.""" from hqt.db.models import Session sess = _seed_worktree_session(db) sess_id = sess.id service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses) await service.delete_session(sess_id, remove_worktree=False) assert fake_worktree.remove_calls == [] db.expire_all() assert db.get(Session, sess_id) is None @pytest.mark.asyncio async def test_delete_session_deletes_scheduled_prompt(service, db): sess = _seed_session(db) sess_id = sess.id due_at = datetime(2026, 6, 11, 12) service.schedule_prompt(sess_id, "run the tests", due_at) await service.delete_session(sess_id) db.expire_all() assert db.get(Session, sess_id) is None assert db.query(ScheduledPrompt).filter_by(session_id=sess_id).one_or_none() is None @pytest.mark.asyncio async def test_worktree_state_for_plain_session_returns_none( service, db, tmux, harnesses, fake_worktree ): """worktree_state_for: plain session → None.""" sess = _seed_session(db) assert await service.worktree_state_for(sess.id) is None @pytest.mark.asyncio async def test_worktree_state_for_missing_id_returns_none(service, db, fake_worktree): """worktree_state_for: unknown session id → None.""" assert await service.worktree_state_for(99999) is None @pytest.mark.asyncio async def test_worktree_state_for_worktree_session_returns_state( factory, db, tmux, harnesses, fake_worktree ): """worktree_state_for: worktree session → returns the module's WorktreeState.""" sess = _seed_worktree_session(db) service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses) state = await service.worktree_state_for(sess.id) assert state is fake_worktree.state_result assert fake_worktree.state_calls == [(Path("/tmp/myproj"), WT_PATH, "feature-x")] @pytest.mark.asyncio async def test_repo_path_fallback_slash_branch_when_project_gone( factory, db, tmux, harnesses, fake_worktree ): """When the project row is gone, the repo is derived from the worktree layout even for slash-containing branch names (feature/foo). create_worktree builds the path as /.worktrees/, so for a branch like 'feature/foo' the path is /.worktrees/feature/foo. The fallback must strip '.worktrees/feature/foo' and recover , not the wrong parent.parent result (/.worktrees). """ from hqt.db.models import Session repo = Path("/tmp/myproj") branch = "feature/foo" # Mirror how worktree.create_worktree builds the path. wt_path = repo / ".worktrees" / branch sess = _seed_worktree_session(db, branch=branch, path=str(wt_path)) sess_id = sess.id # Delete the project row so the fallback derivation is exercised. db.query(Project).delete() db.commit() db.expire_all() # Session row outlives the project (no FK cascade in this seed). assert db.get(Session, sess_id) is not None service = SessionService(factory=factory, tmux=tmux, harnesses=harnesses) await service.worktree_state_for(sess_id) # The fallback must recover the real repo, not /.worktrees. assert fake_worktree.state_calls == [(repo, wt_path, branch)] # --------------------------------------------------------------------------- # Task 3 (P2): serialize the spawn->capture window with a lock # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_create_session_holds_capture_lock_for_capturing_harness( factory, db, tmux ): observed = {} def _capture(project_path, since): # capture runs inside the spawn->capture critical section observed["locked_during_capture"] = service._capture_lock.locked() return "real-codex-id" h = MagicMock() h.captures_session_id = True h.generate_session_id.return_value = "placeholder" h.build_spawn_config.return_value = MagicMock( command=["codex"], env={}, cwd=Path("/tmp/myproj") ) h.capture_session_id.side_effect = _capture service = SessionService(factory=factory, tmux=tmux, harnesses={"claude-code": h}) result = await service.create_session(project_id=1, harness_name="claude-code") assert observed["locked_during_capture"] is True assert result.session.harness_session_id == "real-codex-id" # Lock is released after create_session returns. assert service._capture_lock.locked() is False @pytest.mark.asyncio async def test_create_session_no_lock_for_non_capturing_harness(service, db, tmux): observed = {} async def _spawn(req): observed["locked_during_spawn"] = service._capture_lock.locked() return SpawnResult(ok=True, window_id="@1", error="") tmux.spawn.side_effect = _spawn await service.create_session(project_id=1, harness_name="claude-code") # The `service` fixture's harness has captures_session_id = False, so the # spawn must NOT be serialized under the capture lock. assert observed["locked_during_spawn"] is False # --------------------------------------------------------------------------- # Task 5: sandbox wrapping on spawn and resume # --------------------------------------------------------------------------- @pytest.fixture def sandbox_harness(): h = MagicMock() h.captures_session_id = False h.generate_session_id.return_value = "sess-1" h.build_spawn_config.return_value = MagicMock( command=["claude", "--dangerously-skip-permissions"], env={}, cwd=Path("/tmp/myproj"), ) h.sandbox_binds.return_value = [] h.parse_status = MagicMock(return_value=None) return {"claude-code": h} @pytest.fixture def sandbox_service(factory, db, tmux, sandbox_harness): return SessionService(factory=factory, tmux=tmux, harnesses=sandbox_harness) @pytest.mark.asyncio async def test_sandboxed_create_wraps_command( sandbox_service, db, tmux, sandbox_harness ): with ( patch("hqt.sessions.service.sandbox.is_available", return_value=True), patch( "hqt.sessions.service.sandbox.wrap", return_value=["bwrap", "--", "claude"] ) as mock_wrap, ): result = await sandbox_service.create_session( project_id=1, harness_name="claude-code", sandbox=SandboxPolicy(fs="rw", net=True), ) mock_wrap.assert_called_once() sandbox_harness["claude-code"].build_spawn_config.assert_called_once() assert ( sandbox_harness["claude-code"].build_spawn_config.call_args.kwargs["sandboxed"] is True ) assert tmux.spawn.call_args.args[0].command == ["bwrap", "--", "claude"] assert result.session.sandbox_json == '{"fs": "rw", "net": true}' @pytest.mark.asyncio async def test_unsandboxed_create_does_not_wrap(service, db, tmux, harnesses): with patch("hqt.sessions.service.sandbox.wrap") as mock_wrap: result = await service.create_session(project_id=1, harness_name="claude-code") mock_wrap.assert_not_called() assert result.session.sandbox_json is None @pytest.mark.asyncio async def test_sandboxed_create_raises_when_bwrap_missing(sandbox_service, db): with patch("hqt.sessions.service.sandbox.is_available", return_value=False): with pytest.raises(ServiceError, match="bubblewrap"): await sandbox_service.create_session( project_id=1, harness_name="claude-code", sandbox=SandboxPolicy(fs="rw", net=True), ) @pytest.mark.asyncio async def test_sandboxed_worktree_binds_repo_git_dir( sandbox_service, db, tmux, sandbox_harness, fake_worktree ): """A sandboxed worktree session binds the repo's common .git writable so git works inside the jail (the worktree's gitdir lives under /.git).""" from hqt.sandbox import Bind with ( patch("hqt.sessions.service.sandbox.is_available", return_value=True), patch( "hqt.sessions.service.sandbox.wrap", return_value=["bwrap", "--", "claude"] ) as mock_wrap, ): await sandbox_service.create_session( project_id=1, harness_name="claude-code", worktree_branch="feature-x", sandbox=SandboxPolicy(fs="rw", net=True), ) binds = mock_wrap.call_args.args[3] assert Bind(Path("/tmp/myproj/.git"), writable=True) in binds # --------------------------------------------------------------------------- # Task 4: SessionService tool-window methods # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_open_tool_window_spawns_styled_window(service, db, tmux, monkeypatch): await service.create_session(project_id=1, harness_name="claude-code") monkeypatch.setattr("hqt.sessions.service.shutil.which", lambda b: "/usr/bin/" + b) tmux.open_aux_window = AsyncMock(return_value="@5") wid = await service.open_tool_window(1, "lazygit") assert wid == "@5" tmux.open_aux_window.assert_awaited_once_with( "lazygit", "/tmp/myproj", ["lazygit"], "lazygit · myproj" ) @pytest.mark.asyncio async def test_open_tool_window_shell_skips_which_check(service, db, tmux, monkeypatch): await service.create_session(project_id=1, harness_name="claude-code") monkeypatch.setattr("hqt.sessions.service.shutil.which", lambda b: None) tmux.open_aux_window = AsyncMock(return_value="@3") wid = await service.open_tool_window(1, "shell") assert wid == "@3" tmux.open_aux_window.assert_awaited_once_with( "shell", "/tmp/myproj", [], "shell · myproj" ) @pytest.mark.asyncio async def test_open_tool_window_unknown_tool_raises(service): with pytest.raises(ServiceError): await service.open_tool_window(1, "emacs") @pytest.mark.asyncio async def test_open_tool_window_missing_binary_raises(service, db, monkeypatch): await service.create_session(project_id=1, harness_name="claude-code") monkeypatch.setattr("hqt.sessions.service.shutil.which", lambda b: None) with pytest.raises(ServiceError): await service.open_tool_window(1, "lazygit") @pytest.mark.asyncio async def test_open_tool_window_unknown_session_raises(service, monkeypatch): monkeypatch.setattr("hqt.sessions.service.shutil.which", lambda b: "/usr/bin/" + b) with pytest.raises(ServiceError): await service.open_tool_window(999, "nvim") @pytest.mark.asyncio async def test_session_id_for_window_resolves_and_misses(service, db): await service.create_session(project_id=1, harness_name="claude-code") assert service.session_id_for_window("hqt-1") == 1 assert service.session_id_for_window("not-an-hqt-window") is None @pytest.mark.asyncio async def test_open_tool_window_for_window_resolves_by_name( service, db, tmux, monkeypatch ): await service.create_session(project_id=1, harness_name="claude-code") monkeypatch.setattr("hqt.sessions.service.shutil.which", lambda b: "/usr/bin/" + b) tmux.open_aux_window = AsyncMock(return_value="@2") wid = await service.open_tool_window_for_window("hqt-1", "nvim") assert wid == "@2" tmux.open_aux_window.assert_awaited_once_with( "nvim", "/tmp/myproj", ["nvim"], "nvim · myproj" ) @pytest.mark.asyncio async def test_open_tool_window_for_window_unknown_window_raises(service): with pytest.raises(ServiceError): await service.open_tool_window_for_window("not-an-hqt-window", "nvim") # --------------------------------------------------------------------------- # Task 5: clone_session_for_window # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_clone_session_for_window_reuses_project_harness_model( service, db, monkeypatch ): await service.create_session( project_id=1, harness_name="claude-code", nickname="orig", model="opus" ) captured = {} async def fake_create( project_id, harness_name, nickname=None, model=None, worktree_branch=None ): captured["args"] = (project_id, harness_name, nickname, model) return "SENTINEL" monkeypatch.setattr(service, "create_session", fake_create) result = await service.clone_session_for_window("hqt-1") assert result == "SENTINEL" # Same project + harness + model; a fresh sibling, so no nickname. assert captured["args"] == (1, "claude-code", None, "opus") @pytest.mark.asyncio async def test_clone_session_for_window_unknown_window_raises(service): with pytest.raises(ServiceError): await service.clone_session_for_window("not-an-hqt-window")