Satisfy project quality gates
This commit is contained in:
+230
-66
@@ -33,7 +33,9 @@ def tmux():
|
||||
m.capture_pane = AsyncMock(return_value="")
|
||||
m.attach = AsyncMock()
|
||||
m.window_exists = AsyncMock(return_value=True)
|
||||
m.respawn_verified = AsyncMock(return_value=SpawnResult(ok=True, window_id=None, error=""))
|
||||
m.respawn_verified = AsyncMock(
|
||||
return_value=SpawnResult(ok=True, window_id=None, error="")
|
||||
)
|
||||
return m
|
||||
|
||||
|
||||
@@ -42,7 +44,9 @@ 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.build_spawn_config.return_value = MagicMock(
|
||||
command=["claude"], env={}, cwd=Path("/tmp/myproj")
|
||||
)
|
||||
h.parse_status = MagicMock(return_value=None)
|
||||
return {"claude-code": h}
|
||||
|
||||
@@ -54,7 +58,9 @@ def service(db, tmux, 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")
|
||||
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}"
|
||||
@@ -66,6 +72,7 @@ async def test_create_session(service, db, tmux, harnesses):
|
||||
@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)
|
||||
@@ -95,7 +102,10 @@ async def test_stop_session_no_kill_when_window_missing(service, db, tmux):
|
||||
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")
|
||||
|
||||
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
|
||||
@@ -108,16 +118,21 @@ async def test_create_session_logs_failed_spawn(service, db, tmux, caplog):
|
||||
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"))
|
||||
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.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)
|
||||
@@ -125,8 +140,9 @@ async def test_attach_session_passes_env(service, db, tmux, harnesses):
|
||||
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"})
|
||||
assert first_call.kwargs.get("env") == {"SESSION_VAR": "abc"} or (
|
||||
len(first_call.args) >= 4 and first_call.args[3] == {"SESSION_VAR": "abc"}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -135,18 +151,28 @@ async def test_attach_session_passes_env(service, db, tmux, harnesses):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attach_session_dead_resume_ok_attaches_once(service, db, tmux, harnesses):
|
||||
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"))
|
||||
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"))
|
||||
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.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)
|
||||
@@ -160,20 +186,30 @@ async def test_attach_session_dead_resume_ok_attaches_once(service, db, tmux, ha
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attach_session_dead_resume_fails_fallback_spawn(service, db, tmux, harnesses):
|
||||
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"))
|
||||
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.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)
|
||||
@@ -189,17 +225,25 @@ async def test_attach_session_dead_resume_fails_fallback_spawn(service, db, tmux
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_attach_session_both_rungs_fail_returns_false(service, db, tmux, harnesses):
|
||||
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"))
|
||||
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.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)
|
||||
@@ -219,7 +263,9 @@ def harnesses_no_capture():
|
||||
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"))
|
||||
h.build_spawn_config.return_value = MagicMock(
|
||||
command=["claude"], env={}, cwd=Path("/tmp/myproj")
|
||||
)
|
||||
return {"claude-code": h}
|
||||
|
||||
|
||||
@@ -229,12 +275,16 @@ def harnesses_capture():
|
||||
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"))
|
||||
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(db, tmux, harnesses_no_capture):
|
||||
async def test_create_session_no_capture_flag_never_calls_capture(
|
||||
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)
|
||||
await service.create_session(project_id=1, harness_name="claude-code")
|
||||
@@ -242,11 +292,17 @@ async def test_create_session_no_capture_flag_never_calls_capture(db, tmux, harn
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_capture_retries_until_success(db, tmux, harnesses_capture):
|
||||
async def test_create_session_capture_retries_until_success(
|
||||
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"]
|
||||
harnesses_capture["claude-code"].capture_session_id.side_effect = [
|
||||
None,
|
||||
None,
|
||||
"captured-id-xyz",
|
||||
]
|
||||
|
||||
sleep_calls = []
|
||||
|
||||
@@ -264,7 +320,9 @@ async def test_create_session_capture_retries_until_success(db, tmux, harnesses_
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_session_capture_all_none_keeps_placeholder(db, tmux, harnesses_capture, caplog):
|
||||
async def test_create_session_capture_all_none_keeps_placeholder(
|
||||
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
|
||||
@@ -277,18 +335,27 @@ async def test_create_session_capture_all_none_keeps_placeholder(db, tmux, harne
|
||||
service = SessionService(db=db, 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")
|
||||
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
|
||||
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]
|
||||
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(db, tmux, harnesses_capture):
|
||||
async def test_create_session_capture_first_attempt_succeeds(
|
||||
db, tmux, harnesses_capture
|
||||
):
|
||||
"""captures_session_id=True: first attempt succeeds → no sleeps needed."""
|
||||
import hqt.sessions.service as svc_mod
|
||||
|
||||
@@ -316,6 +383,7 @@ async def test_create_session_capture_first_attempt_succeeds(db, tmux, harnesses
|
||||
async def test_list_sessions_dead_window(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"
|
||||
)
|
||||
@@ -344,6 +412,7 @@ async def test_list_sessions_missing_window_is_dead(db, tmux, harnesses):
|
||||
async def test_list_sessions_alive_parse_working(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"
|
||||
)
|
||||
@@ -364,12 +433,15 @@ async def test_list_sessions_alive_parse_working(db, tmux, harnesses):
|
||||
async def test_list_sessions_alive_parse_none_recent_activity(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"
|
||||
)
|
||||
|
||||
now = 2000.0
|
||||
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=True, last_activity=1998)} # 2s ago
|
||||
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)
|
||||
|
||||
@@ -383,12 +455,15 @@ async def test_list_sessions_alive_parse_none_recent_activity(db, tmux, harnesse
|
||||
async def test_list_sessions_alive_parse_none_stale_activity(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"
|
||||
)
|
||||
|
||||
now = 2000.0
|
||||
tmux.poll_info.return_value = {"hqt-1": WindowInfo(alive=True, last_activity=1990)} # 10s ago
|
||||
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)
|
||||
|
||||
@@ -402,6 +477,7 @@ async def test_list_sessions_alive_parse_none_stale_activity(db, tmux, harnesses
|
||||
async def test_list_sessions_capture_empty_falls_back_to_activity(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"
|
||||
)
|
||||
@@ -423,6 +499,7 @@ async def test_list_sessions_capture_empty_falls_back_to_activity(db, tmux, harn
|
||||
async def test_list_sessions_alive_parse_waiting(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"
|
||||
)
|
||||
@@ -449,7 +526,9 @@ async def test_sync_window_labels_sets_status_symbol(db, tmux, harnesses):
|
||||
from hqt.tmux.runner import WindowInfo
|
||||
|
||||
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
|
||||
await service.create_session(project_id=1, harness_name="claude-code", nickname="mywork")
|
||||
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)}
|
||||
@@ -471,7 +550,9 @@ async def test_sync_window_labels_dead_window_uses_dead_glyph(db, tmux, harnesse
|
||||
from hqt.tmux.runner import WindowInfo
|
||||
|
||||
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
|
||||
await service.create_session(project_id=1, harness_name="claude-code") # no nickname
|
||||
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()
|
||||
@@ -533,16 +614,22 @@ async def test_list_sessions_real_configurator_waiting(db):
|
||||
# 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.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.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=""))
|
||||
tmux_spec.respawn_verified = AsyncMock(
|
||||
return_value=SpawnResult(ok=True, window_id=None, error="")
|
||||
)
|
||||
|
||||
configurator = ClaudeConfigurator()
|
||||
harnesses = {"claude-code": configurator}
|
||||
@@ -553,7 +640,9 @@ async def test_list_sessions_real_configurator_waiting(db):
|
||||
|
||||
now = 2000.0
|
||||
window_name = "hqt-1"
|
||||
tmux_spec.poll_info.return_value = {window_name: WindowInfo(alive=True, last_activity=1999)}
|
||||
tmux_spec.poll_info.return_value = {
|
||||
window_name: WindowInfo(alive=True, last_activity=1999)
|
||||
}
|
||||
|
||||
result = await service.list_sessions(project_id=1, now=now)
|
||||
|
||||
@@ -615,17 +704,25 @@ def harnesses_capture_fallback():
|
||||
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"))
|
||||
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(db, tmux, harnesses_capture_fallback):
|
||||
async def test_respawn_fallback_rung2_updates_harness_session_id(
|
||||
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"
|
||||
harnesses_capture_fallback[
|
||||
"claude-code"
|
||||
].capture_session_id.return_value = "new-codex-id"
|
||||
|
||||
async def fake_sleep(t):
|
||||
pass
|
||||
@@ -638,13 +735,19 @@ async def test_respawn_fallback_rung2_updates_harness_session_id(db, tmux, harne
|
||||
|
||||
# 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"
|
||||
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.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):
|
||||
@@ -653,17 +756,61 @@ async def test_respawn_fallback_rung2_updates_harness_session_id(db, tmux, harne
|
||||
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_respawn_fallback_rung2_capture_exhausted_keeps_old_id(db, tmux, harnesses_capture_fallback, caplog):
|
||||
async def test_attach_late_capture_updates_id_before_resume(
|
||||
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(db=db, 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
|
||||
)
|
||||
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(
|
||||
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"
|
||||
harnesses_capture_fallback[
|
||||
"claude-code"
|
||||
].capture_session_id.return_value = "placeholder-id"
|
||||
|
||||
async def fake_sleep(t):
|
||||
pass
|
||||
@@ -680,10 +827,12 @@ async def test_respawn_fallback_rung2_capture_exhausted_keeps_old_id(db, tmux, h
|
||||
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.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):
|
||||
@@ -692,11 +841,14 @@ async def test_respawn_fallback_rung2_capture_exhausted_keeps_old_id(db, tmux, h
|
||||
|
||||
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]
|
||||
warning_messages = [
|
||||
r.message for r in caplog.records if r.levelno == logging.WARNING
|
||||
]
|
||||
assert any("exhausted" in m for m in warning_messages)
|
||||
|
||||
|
||||
@@ -707,8 +859,12 @@ async def test_respawn_fallback_rung2_no_capture_when_flag_false(db, tmux):
|
||||
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"))
|
||||
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(db=db, tmux=tmux, harnesses=harnesses_nc)
|
||||
@@ -718,10 +874,12 @@ async def test_respawn_fallback_rung2_no_capture_when_flag_false(db, tmux):
|
||||
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.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)
|
||||
@@ -739,7 +897,9 @@ async def test_create_session_no_capture_when_spawn_fails(db, tmux, harnesses_ca
|
||||
"""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")
|
||||
tmux.spawn.return_value = SpawnResult(
|
||||
ok=False, window_id=None, error="spawn failed"
|
||||
)
|
||||
|
||||
async def fake_sleep(t):
|
||||
pass
|
||||
@@ -763,7 +923,9 @@ async def test_create_session_returns_result_dataclass(service, db, tmux, harnes
|
||||
"""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")
|
||||
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 == ""
|
||||
@@ -775,7 +937,9 @@ async def test_create_session_result_propagates_spawn_failure(db, tmux, harnesse
|
||||
"""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")
|
||||
tmux.spawn.return_value = SpawnResult(
|
||||
ok=False, window_id=None, error="command not found: claude"
|
||||
)
|
||||
service = SessionService(db=db, tmux=tmux, harnesses=harnesses)
|
||||
result = await service.create_session(project_id=1, harness_name="claude-code")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user