diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e3198dcf..d6eabb6fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * **telemetry:** anonymous usage telemetry is now **opt-in** (off by default) instead of opt-out. Nothing is collected or sent unless you set `HEADROOM_TELEMETRY=on` or pass `--telemetry` to `headroom proxy` / `headroom install apply`. `is_telemetry_enabled()` is fail-closed — only explicit on-values (`on`/`true`/`1`/`yes`/`enable`/`enabled`) enable it; unset, empty, or unrecognized values stay disabled. The existing `--no-telemetry` flag and `HEADROOM_TELEMETRY=off` remain accepted for back-compat, and install manifests now write the `HEADROOM_TELEMETRY` value explicitly so generated deployments are unambiguous. +### Fixed + +* **rtk:** stop `rtk` hook registration from spuriously timing out during `headroom wrap`. Output is captured to a temp file instead of pipes, and `stdin` is closed, so a background process forked by `rtk init` can no longer hold the pipe open and block `subprocess.run` past its 10s timeout after the hooks were already registered. + ### Features * **learn:** weight loops in `headroom learn`. A new loop detector (`headroom/learn/loops.py`) recognizes repeated tool-call patterns — including RTK re-fetch loops, where RTK's output truncation makes the agent re-run larger-limit variants of a *successful* command — collapses output-limit variants to one signature, measures the wasted tokens, surfaces loops as a highest-priority digest section, and weights loop guardrails above one-off rules by their measured waste. Previously loops had no special weight and a no-failure re-fetch loop was skipped entirely. Adds an RTK-loop eval (`benchmarks/rtk_loop_learn_eval.py`) that reproduces a loop, runs it through Learn, and asserts the generated guardrail ranks first and prevents re-triggering. diff --git a/headroom/rtk/installer.py b/headroom/rtk/installer.py index 4122ceb05..4d7468b32 100644 --- a/headroom/rtk/installer.py +++ b/headroom/rtk/installer.py @@ -9,6 +9,7 @@ import platform import stat import subprocess import tarfile +import tempfile import zipfile from pathlib import Path from urllib.request import urlopen @@ -169,18 +170,35 @@ def register_claude_hooks(rtk_path: Path | None = None) -> bool: """ rtk_path = rtk_path or RTK_BIN_PATH + # Capture output to a temp file rather than pipes: `rtk init` may fork a + # background process that inherits our stdout/stderr, and a piped + # `subprocess.run` drains those pipes until EOF — which never arrives while + # the daemon holds them open, so it blocks to the timeout even though + # `rtk init` itself exited and already registered the hooks. A file fd has + # no such reader, so we wait only on the direct child. stdin is DEVNULL so a + # stray prompt can never block either. try: - result = run( - [str(rtk_path), "init", "--global", "--auto-patch"], - capture_output=True, - text=True, - timeout=10, - ) - if result.returncode == 0: - logger.info("rtk hooks registered in Claude Code") - return True - else: - logger.warning("rtk init failed: %s", result.stderr) + with tempfile.TemporaryFile(mode="w+", encoding="utf-8", errors="replace") as out: + try: + result = subprocess.run( + [str(rtk_path), "init", "--global", "--auto-patch"], + stdin=subprocess.DEVNULL, + stdout=out, + stderr=out, + timeout=10, + ) + except subprocess.TimeoutExpired: + # Read the temp file while it is still open — the outer handler + # runs after the `with` closes it, so any captured diagnostics + # would be gone by then. + out.seek(0) + logger.warning("rtk init timed out: %s", out.read().strip()) + return False + if result.returncode == 0: + logger.info("rtk hooks registered in Claude Code") + return True + out.seek(0) + logger.warning("rtk init failed: %s", out.read().strip()) return False except Exception as e: logger.warning("Failed to register rtk hooks: %s", e) diff --git a/tests/test_rtk_installer.py b/tests/test_rtk_installer.py index f0e83b1b0..54e33134c 100644 --- a/tests/test_rtk_installer.py +++ b/tests/test_rtk_installer.py @@ -3,6 +3,7 @@ from __future__ import annotations import io +import stat import tarfile from pathlib import Path from unittest.mock import patch @@ -56,3 +57,18 @@ def test_download_rtk_skips_verify_for_non_native_target(monkeypatch, tmp_path: assert installed_path == tmp_path / "rtk" assert installed_path.exists() subprocess_run.assert_not_called() + + +def test_register_claude_hooks_survives_forked_daemon(tmp_path: Path) -> None: + """rtk init that exits fast but leaves a child holding stdout must not hang. + + Regression: capturing through pipes made subprocess.run drain until EOF, + which a lingering grandchild deferred past the 10s timeout even though the + hooks were already registered. Output now goes to a temp file, so we wait + only on the direct child. + """ + fake_rtk = tmp_path / "rtk" + fake_rtk.write_text("#!/bin/bash\n( sleep 30 ) &\necho done\nexit 0\n") + fake_rtk.chmod(fake_rtk.stat().st_mode | stat.S_IEXEC) + + assert installer.register_claude_hooks(fake_rtk) is True