fix: serialize Codex spawn->capture with a lock

Hold an asyncio.Lock across the spawn->capture window for harnesses that
capture a session id, so two concurrent hqt starts in the same project
never produce overlapping capture windows. Completes the P2 capture-race
fix alongside the ambiguity guard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 20:47:35 -04:00
parent f6a90e8f4d
commit 6ed598b443
2 changed files with 113 additions and 47 deletions
+18 -2
View File
@@ -1,4 +1,5 @@
import asyncio
import contextlib
import logging
import time
from collections.abc import Mapping
@@ -61,6 +62,19 @@ class SessionService:
self.factory = factory
self.tmux = tmux
self.harnesses = harnesses
# Serializes the spawn->capture window for harnesses that capture a
# session id (codex), so two concurrent hqt starts in the same project
# never overlap and confuse capture_session_id.
self._capture_lock = asyncio.Lock()
@contextlib.asynccontextmanager
async def _maybe_capture_lock(self, needed: bool):
"""Hold the capture lock only when the harness captures a session id."""
if needed:
async with self._capture_lock:
yield
else:
yield
def _configurator(self, harness_name: str) -> HarnessConfigurator:
configurator = self.harnesses.get(harness_name)
@@ -171,6 +185,7 @@ class SessionService:
spawn_cfg.command,
spawn_cfg.cwd,
)
async with self._maybe_capture_lock(configurator.captures_session_id):
since = time.time()
spawn_result = await self.tmux.spawn(
SpawnRequest(
@@ -261,6 +276,7 @@ class SessionService:
spawn_cfg = configurator.build_spawn_config(
project_path, harness_session_id, sess.model
)
async with self._maybe_capture_lock(configurator.captures_session_id):
since = time.time()
result = await self.tmux.respawn_verified(
window_name, spawn_cfg.command, str(spawn_cfg.cwd), env=spawn_cfg.env
@@ -273,8 +289,8 @@ class SessionService:
)
return False
# Rung-2 succeeded — capture the new session id if the harness supports
# it so that future resumes target this fresh conversation.
# Rung-2 succeeded — capture the new session id if the harness
# supports it so that future resumes target this fresh conversation.
if configurator.captures_session_id:
new_id = await self._capture_session_id_with_retry(
configurator, project_path, since, window_name
+50
View File
@@ -1106,3 +1106,53 @@ async def test_list_sessions_skips_capture_for_deleted_project(
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 (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