Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
9.7 KiB
P2 Fixes Design
Date: 2026-06-10
Goal: Resolve the three P2 findings in TODO.md — the Codex session-id
capture race, the absence of real-tmux smoke tests, and unvalidated project
paths — without touching unrelated behavior.
Scope: These three findings only. No other P2 hardening, no refactors beyond what each fix strictly requires.
Finding 1 — Codex session-id capture race
Problem
SessionService captures a Codex rollout id after spawning by scanning
~/.codex/sessions/rollout-*.jsonl for the earliest rollout whose cwd matches
the project path and whose started_at >= since (the spawn timestamp). If a
second Codex session starts in the same project during the retry window
(CAPTURE_MAX_ATTEMPTS=6 × CAPTURE_RETRY_INTERVAL=0.5s ≈ 3s), two rollouts
match and the code blindly picks the earliest — which may be the wrong
conversation. A later codex resume <id> then restores the wrong session.
Approach: serialize hqt starts + refuse ambiguous matches
Two complementary changes. The lock removes the race between hqt-launched sessions; the ambiguity guard makes a wrong capture impossible even when the lock cannot help (a Codex started manually by the user in the same directory).
1. Serialize the spawn→capture critical section.
Add an asyncio.Lock to SessionService:
def __init__(self, factory, tmux, harnesses):
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()
Hold the lock around the since = time.time() → tmux.spawn/respawn →
_capture_session_id_with_retry region, but only when
configurator.captures_session_id is true (non-capturing harnesses need no
serialization). This applies in two places:
create_session— the initial spawn + capture._respawn_with_fallbackrung 2 — the fresh-spawn + capture.
Holding the lock across the retry loop means it can be held for up to ~3s, so rapid session creation against capturing harnesses queues. Session creation is user-initiated and infrequent, so this is acceptable.
2. Refuse ambiguous captures in CodexConfigurator.capture_session_id.
Today the method sorts candidates and returns the earliest. Change it so that:
- 0 candidates → return
None(unchanged; retry loop tries again). - exactly 1 candidate → return its id (the lock-protected normal case).
- more than 1 candidate → ambiguous; log a warning and return
Nonerather than guess.
candidates.sort(key=lambda t: t[0])
if len(candidates) > 1:
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
codex.py gains a module-level log = logging.getLogger(__name__).
This guard also hardens the poller path
(SessionService._maybe_capture_missing_session_id), which calls
capture_session_id without the lock: an ambiguous match there now keeps the
placeholder id instead of risking a wrong one.
Outcome
The worst case becomes "keep the placeholder id" (a missing-but-safe capture), never "store the wrong id." The placeholder path already exists and is logged.
Tests (tests/test_harnesses.py, tests/test_sessions.py)
capture_session_idwith two matching rollouts in the same cwd/time window returnsNoneand logs a warning (usetmp_pathas a fake~/.codex, monkeypatchingPath.home).capture_session_idwith exactly one matching rollout returns its id (regression guard for the normal case).SessionServiceexposes_capture_lockas anasyncio.Lock; a unit test assertscreate_sessionacquires it for a capturing harness. (Drive via the existing fake/mocked tmux + a stub configurator withcaptures_session_id=True; assert the lock is held during the spawn call, e.g. by checkingservice._capture_lock.locked()from inside the stub'scapture_session_id.)
Finding 2 — Real-tmux smoke tests
Problem
tmux behavior is exercised almost entirely through mocked _exec. Theming, new
windows, respawn, and labels are never validated against a real tmux binary, so
a wrong flag or format string passes the suite.
Approach: isolated real-tmux server via TMUX_TMPDIR, auto-skip
Add tests/test_tmux_smoke.py. No source change to runner.py is required:
tmux places its server socket in $TMUX_TMPDIR, so pointing that at a fresh
temp dir gives a throwaway server fully isolated from the user's real sessions.
Gating. Module-level skip when tmux is unavailable:
import shutil
import pytest
pytestmark = [
pytest.mark.tmux,
pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux not installed"),
]
Isolation fixture.
@pytest.fixture
def tmux_runner(tmp_path, monkeypatch):
monkeypatch.setenv("TMUX_TMPDIR", str(tmp_path))
session = "hqt-test"
subprocess.run(
["tmux", "new-session", "-d", "-s", session, "-x", "200", "-y", "50"],
check=True,
env={**os.environ, "TMUX_TMPDIR": str(tmp_path)},
)
runner = TmuxRunner(tmux_path="tmux", session_name=session)
try:
yield runner
finally:
subprocess.run(
["tmux", "kill-server"],
env={**os.environ, "TMUX_TMPDIR": str(tmp_path)},
check=False,
)
TmuxRunner._exec uses asyncio.create_subprocess_exec without an explicit
env, so it inherits the monkeypatched TMUX_TMPDIR and targets the isolated
server automatically.
Coverage (one test each, all async/pytest.mark.asyncio):
- new window:
await runner.new_window("hqt-1", str(tmp_path), "sh")returns a pane/window id; the window appears inawait runner.list_windows(). - label round-trip:
await runner.set_window_label("hqt-1", "•hqt-1"), then read the@hqt_labeluser option back via a rawtmux show-options -w -t hqt-test:=hqt-1(orrunneraccessor if present) and assert it equals what was set. - respawn after death: create a window running a command that exits, confirm
the pane is dead (
await runner.is_pane_dead(...)is true), thenawait runner.respawn_pane(...)with a long-lived command and assert the pane is alive again viaawait runner.verify_window_alive(...). (Test the runner directly;TmuxManager.respawn_verifiedis out of scope here.) - theme:
await runner.apply_theme(), then assert a representative session option (e.g.status-justify leftorstatuson) is set via rawtmux show-options -t hqt-test.
Marker registration (pyproject.toml). Register the tmux marker so pytest
emits no unknown-marker warning:
[tool.pytest.ini_options]
markers = [
"tmux: real-tmux smoke tests; require a tmux binary and run on an isolated TMUX_TMPDIR server",
]
(If a [tool.pytest.ini_options] block does not yet exist, add it; otherwise
extend it.)
Outcome
Real-tmux tests run by default wherever tmux exists (developer machines, CI with
tmux installed) and skip cleanly elsewhere. They never touch the user's live
tmux server because every invocation is scoped to the temp TMUX_TMPDIR.
Finding 3 — Project path validation
Problem
ProjectService.create/update store whatever path string they are given. A
nonexistent or non-directory path is accepted and only fails later when a
session spawned there dies, surfacing as a confusing dead session rather than an
early, clear rejection.
Approach: validate and normalize at create/update
Add a private helper to ProjectService:
from pathlib import Path
def _normalize_path(self, raw: str) -> str:
"""Expand ~, require an existing directory, return the resolved absolute path.
Raises ServiceError if the path does not exist or is not a directory, so the
TUI surfaces it as a notification instead of letting the bad path become a
dead session later.
"""
path = Path(raw).expanduser()
if not path.is_dir():
raise ServiceError(f"Path does not exist or is not a directory: {raw}")
return str(path.resolve())
create and update call path = self._normalize_path(path) before
constructing/assigning the Project, so the stored path is always an absolute,
existing directory. The existing duplicate-path IntegrityError handling is
unchanged and runs after normalization (so duplicate detection compares resolved
paths consistently).
No TUI change. action_add_project (app.py:201-205) and
action_edit_project (app.py:223-227) already catch ServiceError and call
self.notify(str(err), severity="error"), so a rejected path shows as an error
notification with no further wiring.
Tests (tests/test_services.py)
createwith a nonexistent path raisesServiceError.createwith a path that is a file (not a directory) raisesServiceError.createwith a valid directory stores the resolved absolute path (assertproject.path == str(tmp_path.resolve())).createwith a~-prefixed path expands it (monkeypatchPath.homeorHOMEtotmp_pathand assert expansion).updatewith a nonexistent path raisesServiceErrorand leaves the row unchanged (re-read afterdb.expire_all()).
Out of scope
- Any change to
src/hqt/tmux/runner.py(it carries unrelated uncommitted WIP; the chosen designs deliberately avoid touching it). - Broader Codex correctness work (e.g. matching on an injected marker, or
isolating
CODEX_HOME— rejected because a per-sessionCODEX_HOMEwould also isolate Codex'sconfig.toml/auth and break the harness). - Any new P3-level hardening or refactors.