docs: design spec for P2 fixes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,259 @@
|
|||||||
|
# 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`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
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_fallback` rung 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 `None`
|
||||||
|
rather than guess.
|
||||||
|
|
||||||
|
```python
|
||||||
|
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_id` with two matching rollouts in the same cwd/time window
|
||||||
|
returns `None` and logs a warning (use `tmp_path` as a fake `~/.codex`,
|
||||||
|
monkeypatching `Path.home`).
|
||||||
|
- `capture_session_id` with exactly one matching rollout returns its id
|
||||||
|
(regression guard for the normal case).
|
||||||
|
- `SessionService` exposes `_capture_lock` as an `asyncio.Lock`; a unit test
|
||||||
|
asserts `create_session` acquires it for a capturing harness. (Drive via the
|
||||||
|
existing fake/mocked tmux + a stub configurator with
|
||||||
|
`captures_session_id=True`; assert the lock is held during the spawn call,
|
||||||
|
e.g. by checking `service._capture_lock.locked()` from inside the stub's
|
||||||
|
`capture_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:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import shutil
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytestmark = [
|
||||||
|
pytest.mark.tmux,
|
||||||
|
pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux not installed"),
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Isolation fixture.**
|
||||||
|
|
||||||
|
```python
|
||||||
|
@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 in `await runner.list_windows()`.
|
||||||
|
- **label round-trip:** `await runner.set_window_label("hqt-1", "•hqt-1")`, then
|
||||||
|
read the `@hqt_label` user option back via a raw
|
||||||
|
`tmux show-options -w -t hqt-test:=hqt-1` (or `runner` accessor 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), then
|
||||||
|
`await runner.respawn_pane(...)` with a long-lived command and assert the pane
|
||||||
|
is alive again via `await runner.verify_window_alive(...)`. (Test the runner
|
||||||
|
directly; `TmuxManager.respawn_verified` is out of scope here.)
|
||||||
|
- **theme:** `await runner.apply_theme()`, then assert a representative session
|
||||||
|
option (e.g. `status-justify left` or `status` on) is set via raw
|
||||||
|
`tmux show-options -t hqt-test`.
|
||||||
|
|
||||||
|
**Marker registration (`pyproject.toml`).** Register the `tmux` marker so pytest
|
||||||
|
emits no unknown-marker warning:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[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`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
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`)
|
||||||
|
|
||||||
|
- `create` with a nonexistent path raises `ServiceError`.
|
||||||
|
- `create` with a path that is a file (not a directory) raises `ServiceError`.
|
||||||
|
- `create` with a valid directory stores the resolved absolute path
|
||||||
|
(assert `project.path == str(tmp_path.resolve())`).
|
||||||
|
- `create` with a `~`-prefixed path expands it (monkeypatch `Path.home` or
|
||||||
|
`HOME` to `tmp_path` and assert expansion).
|
||||||
|
- `update` with a nonexistent path raises `ServiceError` and leaves the row
|
||||||
|
unchanged (re-read after `db.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-session `CODEX_HOME` would also
|
||||||
|
isolate Codex's `config.toml`/auth and break the harness).
|
||||||
|
- Any new P3-level hardening or refactors.
|
||||||
Reference in New Issue
Block a user