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 asyncio
|
||||||
|
import contextlib
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
@@ -61,6 +62,19 @@ class SessionService:
|
|||||||
self.factory = factory
|
self.factory = factory
|
||||||
self.tmux = tmux
|
self.tmux = tmux
|
||||||
self.harnesses = harnesses
|
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:
|
def _configurator(self, harness_name: str) -> HarnessConfigurator:
|
||||||
configurator = self.harnesses.get(harness_name)
|
configurator = self.harnesses.get(harness_name)
|
||||||
@@ -171,6 +185,7 @@ class SessionService:
|
|||||||
spawn_cfg.command,
|
spawn_cfg.command,
|
||||||
spawn_cfg.cwd,
|
spawn_cfg.cwd,
|
||||||
)
|
)
|
||||||
|
async with self._maybe_capture_lock(configurator.captures_session_id):
|
||||||
since = time.time()
|
since = time.time()
|
||||||
spawn_result = await self.tmux.spawn(
|
spawn_result = await self.tmux.spawn(
|
||||||
SpawnRequest(
|
SpawnRequest(
|
||||||
@@ -261,6 +276,7 @@ class SessionService:
|
|||||||
spawn_cfg = configurator.build_spawn_config(
|
spawn_cfg = configurator.build_spawn_config(
|
||||||
project_path, harness_session_id, sess.model
|
project_path, harness_session_id, sess.model
|
||||||
)
|
)
|
||||||
|
async with self._maybe_capture_lock(configurator.captures_session_id):
|
||||||
since = time.time()
|
since = time.time()
|
||||||
result = await self.tmux.respawn_verified(
|
result = await self.tmux.respawn_verified(
|
||||||
window_name, spawn_cfg.command, str(spawn_cfg.cwd), env=spawn_cfg.env
|
window_name, spawn_cfg.command, str(spawn_cfg.cwd), env=spawn_cfg.env
|
||||||
@@ -273,8 +289,8 @@ class SessionService:
|
|||||||
)
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
# Rung-2 succeeded — capture the new session id if the harness supports
|
# Rung-2 succeeded — capture the new session id if the harness
|
||||||
# it so that future resumes target this fresh conversation.
|
# supports it so that future resumes target this fresh conversation.
|
||||||
if configurator.captures_session_id:
|
if configurator.captures_session_id:
|
||||||
new_id = await self._capture_session_id_with_retry(
|
new_id = await self._capture_session_id_with_retry(
|
||||||
configurator, project_path, since, window_name
|
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"
|
harnesses_capture["claude-code"].capture_session_id.return_value = "would-capture"
|
||||||
infos = await service.list_sessions(1)
|
infos = await service.list_sessions(1)
|
||||||
assert len(infos) == 1 # no ServiceError escaped
|
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