From f6a90e8f4d262868f9fc3e0c75cfbb1644116d8a Mon Sep 17 00:00:00 2001 From: Felix Martin Date: Wed, 10 Jun 2026 20:43:52 -0400 Subject: [PATCH] fix: refuse ambiguous Codex session-id capture When more than one rollout matches the project cwd within the capture window, return None and warn instead of guessing the earliest. The worst case is now a kept placeholder id, never a wrong one. Part of the P2 capture-race fix. Co-Authored-By: Claude Fable 5 --- src/hqt/harnesses/configurators/codex.py | 15 ++++++- tests/test_harnesses.py | 51 +++++------------------- 2 files changed, 23 insertions(+), 43 deletions(-) diff --git a/src/hqt/harnesses/configurators/codex.py b/src/hqt/harnesses/configurators/codex.py index dead135..10107f4 100644 --- a/src/hqt/harnesses/configurators/codex.py +++ b/src/hqt/harnesses/configurators/codex.py @@ -1,9 +1,12 @@ import json +import logging from datetime import datetime from pathlib import Path from hqt.harnesses.base import HarnessConfigurator, SpawnConfig +log = logging.getLogger(__name__) + class CodexConfigurator(HarnessConfigurator): name = "codex" @@ -73,5 +76,15 @@ class CodexConfigurator(HarnessConfigurator): candidates.append((started_at, payload["id"])) except (json.JSONDecodeError, KeyError, OSError, TypeError): continue - candidates.sort(key=lambda t: t[0]) + if len(candidates) > 1: + # Ambiguous: more than one Codex rollout matches this cwd + time + # window (e.g. a second Codex started in the same project). Guessing + # risks storing the wrong conversation id, so keep the placeholder. + log.warning( + "capture_session_id: %d rollouts match cwd=%s since=%s; refusing to guess", + len(candidates), + project_path, + since, + ) + return None return candidates[0][1] if candidates else None diff --git a/tests/test_harnesses.py b/tests/test_harnesses.py index 74dddb8..2e164a6 100644 --- a/tests/test_harnesses.py +++ b/tests/test_harnesses.py @@ -1,7 +1,6 @@ import json import os import time -from datetime import UTC, datetime from pathlib import Path from unittest.mock import patch @@ -184,57 +183,25 @@ def test_codex_capture_accepts_file_after_since(tmp_path): assert result == "new-id" -def test_codex_capture_first_post_since_cwd_match_wins(tmp_path): - """Among multiple post-since rollouts, the one started nearest since wins.""" +def test_codex_capture_ambiguous_returns_none(tmp_path): + """Two rollouts match cwd + since window -> refuse to guess, return None.""" sessions_dir = tmp_path / ".codex" / "sessions" / "dir" sessions_dir.mkdir(parents=True) - rollout_older = sessions_dir / "rollout-older.jsonl" - rollout_newer = sessions_dir / "rollout-newer.jsonl" - _write_rollout(rollout_older, "older-id", "/projects/foo") - _write_rollout(rollout_newer, "newer-id", "/projects/foo") + rollout_a = sessions_dir / "rollout-a.jsonl" + rollout_b = sessions_dir / "rollout-b.jsonl" + _write_rollout(rollout_a, "id-a", "/projects/foo") + _write_rollout(rollout_b, "id-b", "/projects/foo") now = time.time() since = now - 120 - os.utime(rollout_older, (now - 30, now - 30)) - os.utime(rollout_newer, (now - 10, now - 10)) + os.utime(rollout_a, (now - 30, now - 30)) + os.utime(rollout_b, (now - 10, now - 10)) c = CodexConfigurator() with patch("hqt.harnesses.configurators.codex.Path.home", return_value=tmp_path): result = c.capture_session_id(Path("/projects/foo"), since) - assert result == "older-id" - - -def test_codex_capture_prefers_started_nearest_since_over_newest_file(tmp_path): - """Late capture should match the Codex session spawned at since, not a later same-cwd session.""" - sessions_dir = tmp_path / ".codex" / "sessions" / "dir" - sessions_dir.mkdir(parents=True) - - target = sessions_dir / "rollout-target.jsonl" - unrelated_later = sessions_dir / "rollout-later.jsonl" - - since = datetime(2026, 6, 10, 21, 9, 15, tzinfo=UTC).timestamp() - _write_rollout( - target, - "target-id", - "/projects/foo", - timestamp="2026-06-10T21:09:16.000Z", - ) - _write_rollout( - unrelated_later, - "later-id", - "/projects/foo", - timestamp="2026-06-10T21:45:45.000Z", - ) - - now = time.time() - os.utime(target, (now - 60, now - 60)) - os.utime(unrelated_later, (now, now)) - - c = CodexConfigurator() - with patch("hqt.harnesses.configurators.codex.Path.home", return_value=tmp_path): - result = c.capture_session_id(Path("/projects/foo"), since) - assert result == "target-id" + assert result is None def test_codex_capture_skips_non_matching_cwd_picks_older_matching(tmp_path):