Files
hqt/docs/superpowers/plans/2026-06-10-p2-fixes.md
T
2026-06-10 20:01:06 -04:00

32 KiB

P2 Fixes Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Resolve the three P2 findings in TODO.md — validate project paths at create/update, harden Codex session-id capture against the multi-session race, and add isolated real-tmux smoke tests.

Architecture: Three independent fixes. (1) ProjectService gains a _normalize_path helper that rejects non-directories. (2) CodexConfigurator.capture_session_id refuses to guess when more than one rollout matches, and SessionService serializes the spawn→capture window with an asyncio.Lock for capturing harnesses. (3) A new tests/test_tmux_smoke.py drives the real TmuxRunner against a throwaway tmux server isolated via TMUX_TMPDIR, auto-skipping when tmux is absent.

Tech Stack: Python 3.12, SQLAlchemy 2.0, Textual, pytest + pytest-asyncio, uv, tmux.

Design spec: docs/superpowers/specs/2026-06-10-p2-fixes-design.md

Conventions for every task:

  • Run quality gates after code/test changes: uv run ruff format src tests && uv run ruff check src tests && uv run ty check.
  • Run tests with uv run pytest.
  • Commit messages end with the trailer:
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
    
  • NEVER git add -A or git add .src/hqt/tmux/runner.py carries unrelated uncommitted WIP that must stay out of every commit. Always git add the exact files listed in the task. No task in this plan modifies runner.py.
  • Work on main, committing directly to it.

File Structure

File Responsibility Tasks
src/hqt/projects/service.py Project CRUD; add path normalization/validation 1
tests/test_services.py ProjectService tests; add path-validation cases 1
src/hqt/harnesses/configurators/codex.py Codex capture; refuse ambiguous matches 2
tests/test_harnesses.py Codex capture tests; replace earliest-wins cases with ambiguity case 2
src/hqt/sessions/service.py Session lifecycle; serialize spawn→capture with a lock 3
tests/test_sessions.py SessionService tests; assert lock behavior 3
tests/test_tmux_smoke.py (new) Real-tmux smoke tests on an isolated server 4
pyproject.toml Register the tmux pytest marker 4
TODO.md Remove the three resolved P2 lines 5

Task 1: Project path validation

Files:

  • Modify: src/hqt/projects/service.py

  • Test: tests/test_services.py

  • Step 1: Write the failing tests

Add these tests to the TestProjectService class in tests/test_services.py. They need a real directory, so they use pytest's tmp_path fixture.

    def test_create_rejects_nonexistent_path(self, factory, tmp_path):
        svc = ProjectService(factory)
        missing = tmp_path / "does-not-exist"
        with pytest.raises(ServiceError, match="does not exist or is not a directory"):
            svc.create("p", str(missing))

    def test_create_rejects_file_path(self, factory, tmp_path):
        svc = ProjectService(factory)
        a_file = tmp_path / "afile"
        a_file.write_text("x")
        with pytest.raises(ServiceError, match="does not exist or is not a directory"):
            svc.create("p", str(a_file))

    def test_create_stores_resolved_absolute_path(self, factory, tmp_path):
        svc = ProjectService(factory)
        p = svc.create("p", str(tmp_path))
        assert p.path == str(tmp_path.resolve())

    def test_create_expands_user_home(self, factory, tmp_path, monkeypatch):
        # Point ~ at tmp_path; "~" must expand to the existing tmp_path dir.
        monkeypatch.setenv("HOME", str(tmp_path))
        svc = ProjectService(factory)
        p = svc.create("p", "~")
        assert p.path == str(tmp_path.resolve())

    def test_update_rejects_nonexistent_path_and_leaves_row(self, factory, tmp_path):
        svc = ProjectService(factory)
        p = svc.create("p", str(tmp_path))
        missing = tmp_path / "nope"
        with pytest.raises(ServiceError, match="does not exist or is not a directory"):
            svc.update(p.id, "p", str(missing))
        assert svc.get(p.id).path == str(tmp_path.resolve())

Several existing tests pass made-up paths like /tmp/test, /a, /old/path that are not guaranteed to exist. Update those literals to real directories so they keep passing. Replace the existing TestProjectService methods test_create_and_get, test_list_excludes_archived, test_update_name_and_path, test_update_duplicate_path_raises, and test_create_duplicate_path_raises_service_error to seed paths from tmp_path:

    def test_create_and_get(self, factory, tmp_path):
        svc = ProjectService(factory)
        p = svc.create("test", str(tmp_path))
        assert p.id is not None
        assert svc.get(p.id).name == "test"

    def test_list_excludes_archived(self, factory, tmp_path):
        a = tmp_path / "a"
        a.mkdir()
        b = tmp_path / "b"
        b.mkdir()
        svc = ProjectService(factory)
        svc.create("a", str(a))
        p2 = svc.create("b", str(b))
        svc.archive(p2.id)
        assert len(svc.list_all()) == 1
        assert len(svc.list_all(include_archived=True)) == 2

    def test_update_name_and_path(self, factory, tmp_path):
        old = tmp_path / "old"
        old.mkdir()
        new = tmp_path / "new"
        new.mkdir()
        svc = ProjectService(factory)
        p = svc.create("old", str(old))
        updated = svc.update(p.id, "new", str(new))
        assert updated.name == "new"
        assert updated.path == str(new.resolve())
        assert svc.get(p.id).path == str(new.resolve())

    def test_update_duplicate_path_raises(self, factory, tmp_path):
        a = tmp_path / "a"
        a.mkdir()
        b = tmp_path / "b"
        b.mkdir()
        svc = ProjectService(factory)
        svc.create("a", str(a))
        p2 = svc.create("b", str(b))
        with pytest.raises(ServiceError, match="already uses path"):
            svc.update(p2.id, "b", str(a))
        # DB session must remain usable after rollback, values unchanged
        assert svc.get(p2.id).path == str(b.resolve())
        assert svc.get(p2.id).name == "b"

    def test_create_duplicate_path_raises_service_error(self, factory, tmp_path):
        dup = tmp_path / "dup"
        dup.mkdir()
        svc = ProjectService(factory)
        svc.create("a", str(dup))
        with pytest.raises(ServiceError, match="already uses path"):
            svc.create("b", str(dup))

Note: TestMcpService.test_bind_unbind creates a project at /proj; change that literal to str(tmp_path) and add tmp_path to its signature:

    def test_bind_unbind(self, factory, tmp_path):
        psvc = ProjectService(factory)
        msvc = McpService(factory)
        project = psvc.create("proj", str(tmp_path))
        server = msvc.create("s1", "stdio", command="cmd")
        msvc.bind_to_project(project.id, server.id)
        assert len(msvc.get_project_mcps(project.id)) == 1
        msvc.unbind_from_project(project.id, server.id)
        assert len(msvc.get_project_mcps(project.id)) == 0
  • Step 2: Run the new tests to verify they fail

Run: uv run pytest tests/test_services.py -k "rejects or resolved or expands_user" -v Expected: FAIL — ServiceError not raised / path not resolved (validation not implemented yet).

  • Step 3: Implement _normalize_path and call it from create/update

In src/hqt/projects/service.py, add the Path import and the helper, and call it at the top of create and update. Full file:

from pathlib import Path

from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import sessionmaker

from hqt.db.models import Project
from hqt.errors import ServiceError


class ProjectService:
    def __init__(self, factory: sessionmaker):
        # factory must be created with expire_on_commit=False so rows returned
        # from these methods stay readable after their session closes.
        self.factory = factory

    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())

    def create(self, name: str, path: str) -> Project:
        path = self._normalize_path(path)
        with self.factory() as db:
            project = Project(name=name, path=path)
            db.add(project)
            try:
                db.commit()
            except IntegrityError as err:
                db.rollback()
                raise ServiceError(f"Another project already uses path {path}") from err
            return project

    def list_all(self, include_archived: bool = False) -> list[Project]:
        with self.factory() as db:
            q = db.query(Project)
            if not include_archived:
                q = q.filter_by(archived=False)
            return list(q.all())

    def get(self, project_id: int) -> Project | None:
        with self.factory() as db:
            return db.get(Project, project_id)

    def update(self, project_id: int, name: str, path: str) -> Project:
        path = self._normalize_path(path)
        with self.factory() as db:
            project = db.get(Project, project_id)
            if project is None:
                raise ServiceError(f"Project {project_id} not found")
            project.name = name
            project.path = path
            try:
                db.commit()
            except IntegrityError as err:
                db.rollback()
                raise ServiceError(f"Another project already uses path {path}") from err
            return project

    def archive(self, project_id: int) -> None:
        with self.factory() as db:
            project = db.get(Project, project_id)
            if project:
                project.archived = True
                db.commit()

Note ordering: _normalize_path runs before opening the DB session, so a bad path is rejected without touching the DB. update validates the path before checking the project exists — that's fine; both raise ServiceError.

  • Step 4: Run the full service test file

Run: uv run pytest tests/test_services.py -v Expected: PASS (new validation tests + all updated existing tests).

  • Step 5: Run quality gates

Run: uv run ruff format src tests && uv run ruff check src tests && uv run ty check Expected: all green.

  • Step 6: Commit
git add src/hqt/projects/service.py tests/test_services.py
git commit -m "feat: validate project paths at create/update

Reject non-existent or non-directory paths with ServiceError (already
surfaced as a TUI notification), and store the resolved absolute path.
Closes the P2 finding where bad paths became dead sessions later.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"

Task 2: Codex capture refuses ambiguous matches

Files:

  • Modify: src/hqt/harnesses/configurators/codex.py
  • Test: tests/test_harnesses.py

Context: capture_session_id scans ~/.codex/sessions/rollout-*.jsonl for rollouts whose cwd matches the project and whose start time is >= since. Today it sorts the matches and returns the earliest. When two Codex sessions ran in the same directory in the capture window, that guess can be the wrong conversation. The fix: if more than one rollout matches, refuse to guess (return None + warn). The single-match normal case is unchanged.

  • Step 1: Replace the earliest-wins tests with an ambiguity test

In tests/test_harnesses.py:

  1. Delete test_codex_capture_first_post_since_cwd_match_wins (it asserts the now-removed earliest-wins behavior with two matching rollouts).
  2. Delete test_codex_capture_prefers_started_nearest_since_over_newest_file (also asserts earliest-wins among two matching rollouts).
  3. Add this test (place it where the deleted ones were):
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_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_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 is None

The single-match regression cases (test_codex_capture_session_id, test_codex_capture_accepts_file_after_since, test_codex_capture_skips_non_matching_cwd_picks_older_matching — which has only one cwd-matching rollout) stay as-is and must keep passing.

  • Step 2: Run the capture tests to verify the new one fails

Run: uv run pytest tests/test_harnesses.py -k "capture" -v Expected: test_codex_capture_ambiguous_returns_none FAILS (current code returns "id-a", not None). The deleted tests are gone.

  • Step 3: Implement the ambiguity guard

In src/hqt/harnesses/configurators/codex.py, add logging and change capture_session_id. Replace the imports block at the top:

import json
import logging
from datetime import datetime
from pathlib import Path

from hqt.harnesses.base import HarnessConfigurator, SpawnConfig

log = logging.getLogger(__name__)

Replace the tail of capture_session_id (the candidates.sort(...) / return lines) with the ambiguity guard. The full method:

    def capture_session_id(self, project_path: Path, since: float) -> str | None:
        sessions_dir = Path.home() / ".codex" / "sessions"
        if not sessions_dir.exists():
            return None
        candidates: list[tuple[float, str]] = []
        for rollout in sessions_dir.rglob("rollout-*.jsonl"):
            try:
                mtime = rollout.stat().st_mtime
                with rollout.open() as f:
                    meta = json.loads(f.readline())
                payload = meta["payload"]
                if meta.get("type") != "session_meta" or str(
                    Path(payload["cwd"])
                ) != str(project_path):
                    continue
                started_at = self._meta_timestamp(payload) or mtime
                if started_at >= since:
                    candidates.append((started_at, payload["id"]))
            except (json.JSONDecodeError, KeyError, OSError, TypeError):
                continue
        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

(The candidates.sort(...) line is removed — with at most one returned candidate there is nothing to sort.)

  • Step 4: Run the capture tests

Run: uv run pytest tests/test_harnesses.py -k "capture" -v Expected: PASS, including test_codex_capture_ambiguous_returns_none.

  • Step 5: Run quality gates

Run: uv run ruff format src tests && uv run ruff check src tests && uv run ty check Expected: all green.

  • Step 6: Commit
git add src/hqt/harnesses/configurators/codex.py tests/test_harnesses.py
git commit -m "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 <noreply@anthropic.com>"

Task 3: Serialize the spawn→capture window in SessionService

Files:

  • Modify: src/hqt/sessions/service.py
  • Test: tests/test_sessions.py

Context: The Task 2 guard makes wrong captures impossible, but two hqt-launched Codex sessions starting in the same project would now each see an ambiguous window and capture nothing. An asyncio.Lock held across the spawn→capture region (only for harnesses that captures_session_id) keeps hqt-launched starts from overlapping, so each capture sees exactly its own rollout.

  • Step 1: Write the failing tests

Add to tests/test_sessions.py. The first asserts the lock is held while a capturing harness spawns+captures; the second asserts it is NOT held for a non-capturing harness (so we only pay the serialization cost when needed).

@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
  • Step 2: Run the new tests to verify they fail

Run: uv run pytest tests/test_sessions.py -k "capture_lock or no_lock" -v Expected: FAIL with AttributeError: 'SessionService' object has no attribute '_capture_lock'.

  • Step 3: Add the lock and the conditional guard helper

In src/hqt/sessions/service.py:

Add import contextlib near the top imports (alongside import asyncio):

import asyncio
import contextlib
import logging
import time

Add the lock in __init__ and a helper context manager. Update __init__:

    def __init__(
        self,
        factory: sessionmaker,
        tmux: TmuxManager,
        harnesses: Mapping[str, HarnessConfigurator],
    ):
        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
  • Step 4: Wrap the spawn→capture region in create_session

In create_session, wrap from since = time.time() through the capture block in the guard. The relevant region becomes (note the added async with and the indentation of the lines it now encloses):

            spawn_cfg = configurator.build_spawn_config(
                project_path, harness_session_id, model
            )
            log.info(
                "Spawning window %s: cmd=%s cwd=%s",
                sess.tmux_session_name,
                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(
                        window_name=sess.tmux_session_name,
                        command=spawn_cfg.command,
                        cwd=str(spawn_cfg.cwd),
                        env=spawn_cfg.env,
                    )
                )
                if not spawn_result.ok:
                    log.error(
                        "Failed to spawn window %s: %s",
                        sess.tmux_session_name,
                        spawn_result.error or "(no output captured)",
                    )
                # Only attempt capture when the spawn actually succeeded; a failed
                # spawn could inadvertently capture an unrelated session running in
                # the same cwd.
                if spawn_result.ok and configurator.captures_session_id:
                    captured_id = await self._capture_session_id_with_retry(
                        configurator, project_path, since, sess.tmux_session_name
                    )
                    if captured_id:
                        sess.harness_session_id = captured_id
            db.commit()

spawn_result is referenced after the async with block (in the return CreateSessionResult(...) that follows) — it remains in scope because async with does not introduce a new scope. Leave the rest of the method (the log.info and return CreateSessionResult(...)) unchanged.

  • Step 5: Wrap the rung-2 fresh-spawn region in _respawn_with_fallback

In _respawn_with_fallback, wrap the rung-2 region (from since = time.time() through the capture block) in the same guard. The rung-2 portion becomes:

        # Rung 2: fresh spawn (codex ignores the old session id; start fresh)
        configurator = self._configurator(sess.harness.name)
        project_path = self._project_path(db, sess.project_id)
        harness_session_id = (
            sess.harness_session_id or configurator.generate_session_id(sess.id)
        )
        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
            )
            if not result.ok:
                log.error(
                    "Fallback spawn also failed for %s: %s",
                    window_name,
                    result.error or "(no output)",
                )
                return False

            # 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
                )
                if new_id:
                    sess.harness_session_id = new_id
                    db.commit()
                else:
                    log.warning(
                        "capture_session_id exhausted after rung-2 for %s; keeping old id %s",
                        window_name,
                        sess.harness_session_id,
                    )
        return True

The return False inside the async with correctly releases the lock on the way out. Leave rung 1 (the resume attempt above this region) unchanged — it does not spawn a new conversation, so it needs no lock.

  • Step 6: Run the session tests

Run: uv run pytest tests/test_sessions.py -v Expected: PASS (new lock tests + all existing session tests).

  • Step 7: Run quality gates

Run: uv run ruff format src tests && uv run ruff check src tests && uv run ty check Expected: all green.

  • Step 8: Commit
git add src/hqt/sessions/service.py tests/test_sessions.py
git commit -m "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>"

Task 4: Real-tmux smoke tests

Files:

  • Create: tests/test_tmux_smoke.py
  • Modify: pyproject.toml

Context: tmux behavior is otherwise tested with mocked _exec. These tests drive the real TmuxRunner against a throwaway tmux server isolated via TMUX_TMPDIR, so they never touch the user's live sessions, and skip when tmux is absent.

  • Step 1: Register the tmux pytest marker

In pyproject.toml, add a [tool.pytest.ini_options] block (none exists yet — add it after the [dependency-groups] block, before [tool.hqt.quality]):

[tool.pytest.ini_options]
markers = [
    "tmux: real-tmux smoke tests; require a tmux binary and run on an isolated TMUX_TMPDIR server",
]

Note: the existing async tests already run with explicit @pytest.mark.asyncio decorators under pytest-asyncio's default strict mode, so do not add an asyncio_mode setting — only the markers entry is needed here.

  • Step 2: Write the smoke tests

Create tests/test_tmux_smoke.py:

"""Real-tmux smoke tests.

These drive the actual TmuxRunner against a throwaway tmux server whose socket
lives in a temp TMUX_TMPDIR, so they never touch the user's live tmux sessions.
They skip when no tmux binary is available.
"""

import asyncio
import os
import shutil
import subprocess

import pytest

from hqt.tmux.runner import TmuxRunner

pytestmark = [
    pytest.mark.tmux,
    pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux not installed"),
]

SESSION = "hqt-test"


def _tmux(env, *args):
    """Run a raw tmux command against the isolated server, return stdout."""
    return subprocess.run(
        ["tmux", *args],
        env=env,
        capture_output=True,
        text=True,
        check=False,
    )


@pytest.fixture
def tmux_env(tmp_path, monkeypatch):
    # Point tmux's server socket at the temp dir: a fully isolated server.
    monkeypatch.setenv("TMUX_TMPDIR", str(tmp_path))
    env = {**os.environ, "TMUX_TMPDIR": str(tmp_path)}
    _tmux(env, "new-session", "-d", "-s", SESSION, "-x", "200", "-y", "50")
    try:
        yield env
    finally:
        _tmux(env, "kill-server")


@pytest.fixture
def runner(tmux_env):
    return TmuxRunner(tmux_path="tmux", session_name=SESSION)


async def _wait_pane_dead(runner, window, timeout=2.0):
    deadline = asyncio.get_running_loop().time() + timeout
    while asyncio.get_running_loop().time() < deadline:
        if await runner.is_pane_dead(window):
            return True
        await asyncio.sleep(0.05)
    return await runner.is_pane_dead(window)


@pytest.mark.asyncio
async def test_new_window_appears_in_list(runner, tmp_path):
    window_id = await runner.new_window("hqt-1", str(tmp_path), "sleep 60")
    assert window_id is not None
    assert "hqt-1" in await runner.list_windows()


@pytest.mark.asyncio
async def test_set_window_label_round_trips(runner, tmp_path, tmux_env):
    await runner.new_window("hqt-1", str(tmp_path), "sleep 60")
    await runner.set_window_label("hqt-1", "•hqt-1")
    shown = _tmux(
        tmux_env,
        "show-options",
        "-w",
        "-t",
        f"{SESSION}:=hqt-1",
        "@hqt_label",
    ).stdout
    assert "•hqt-1" in shown


@pytest.mark.asyncio
async def test_respawn_revives_dead_pane(runner, tmp_path):
    # Window whose command exits immediately -> pane dies (remain-on-exit keeps it).
    await runner.new_window("hqt-1", str(tmp_path), "true")
    assert await _wait_pane_dead(runner, "hqt-1") is True
    assert await runner.respawn_pane("hqt-1", "sleep 60", str(tmp_path)) is True
    alive, _ = await runner.verify_window_alive("hqt-1", timeout=1.0)
    assert alive is True


@pytest.mark.asyncio
async def test_apply_theme_sets_session_options(runner, tmux_env):
    await runner.apply_theme()
    shown = _tmux(tmux_env, "show-options", "-t", SESSION, "status-justify").stdout
    assert "left" in shown
  • Step 3: Run the smoke tests

Run: uv run pytest tests/test_tmux_smoke.py -v Expected: PASS where tmux is installed (4 tests). If tmux is absent: all SKIPPED with reason "tmux not installed".

  • Step 4: Verify the marker is registered (no warnings)

Run: uv run pytest tests/test_tmux_smoke.py -v -W error::pytest.PytestUnknownMarkWarning Expected: PASS/SKIP with no PytestUnknownMarkWarning (proves the marker is registered).

  • Step 5: Run the full suite + quality gates

Run: uv run pytest && uv run ruff format src tests && uv run ruff check src tests && uv run ty check Expected: full suite green (smoke tests pass or skip), gates green.

  • Step 6: Commit
git add tests/test_tmux_smoke.py pyproject.toml
git commit -m "test: add isolated real-tmux smoke tests

Drive the real TmuxRunner against a throwaway tmux server isolated via
TMUX_TMPDIR (never touches live sessions), covering new windows, label
round-trip, respawn, and theming. Auto-skip when tmux is absent; register
the 'tmux' marker. Closes the P2 real-tmux testing gap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"

Task 5: Remove resolved P2 findings from TODO.md

Files:

  • Modify: TODO.md

Context: All three P2 findings are now resolved. TODO.md currently contains exactly those three lines and nothing else.

  • Step 1: Empty out the resolved findings

TODO.md currently reads:

P2: Codex session-id capture is heuristic and can store the wrong rollout if another Codex session starts in the same project during the retry window.
P2: tmux behavior is mostly tested with mocks; add isolated real-tmux smoke tests for theming, new windows, respawn, and labels.
P2: Project paths are accepted without existence validation, so bad paths become dead sessions later instead of being rejected early.

Replace the entire file contents with a single placeholder line so the file is not left dangling:

No open findings.
  • Step 2: Verify

Run: cat TODO.md Expected: No open findings.

  • Step 3: Commit
git add TODO.md
git commit -m "chore: clear resolved P2 findings from TODO

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>"

Final verification (after all tasks)

  • Run the full suite and gates once more:
    uv run pytest && uv run ruff format src tests && uv run ruff check src tests && uv run ty check
    
    Expected: all tests pass (or smoke tests skip without tmux), all gates green.
  • Confirm src/hqt/tmux/runner.py is still only modified in the working tree (its pre-existing WIP) and was never committed:
    git log --oneline -6
    git status --short
    
    Expected: the six new commits are present; git status --short shows only M src/hqt/tmux/runner.py.