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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user