From 7e2030a005bfe81f87a10b9ecec78af63a6f87ea Mon Sep 17 00:00:00 2001 From: boskodev790 Date: Wed, 15 Jul 2026 15:45:40 -0400 Subject: [PATCH] fix(capture): read the session id off the SessionStart hook payload finalize-all resolved the current session id from --session-id or VOUCH_SESSION_ID only. the shipped SessionStart hook passes neither, and nothing in the adapter or runtime exports VOUCH_SESSION_ID, so sid was always "" and the sweep returned empty on every session start. that made the #304 backstop dead: buffers orphaned by sessions that ended without SessionEnd accumulated in .vouch/captures/ and never rolled up into pending proposals, so the session never reached the review queue. the id now comes off the stdin payload the same way capture finalize reads it at SessionEnd, with --session-id and VOUCH_SESSION_ID kept as fallbacks. a malformed payload still no-ops rather than failing the host's turn. the adapter hook test asserted observe/finalize/banner but never finalize-all, which is why the shipped wiring went unexercised; it now covers it. --- CHANGELOG.md | 6 ++++ src/vouch/cli.py | 29 +++++++++++++++++-- tests/test_capture.py | 65 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3ff39a63..6c4a6082 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,12 @@ All notable changes to vouch are documented here. Format follows - demo image build: `hatch_build.py` (the build hook pyproject.toml declares for console bundling) is copied into the docker build context; the image had been unbuildable since the hook landed (#474). +- `vouch capture finalize-all` reads the session id off the SessionStart + hook payload on stdin, the same wire `capture finalize` reads at + SessionEnd. the shipped hook passes no `--session-id` and nothing sets + `VOUCH_SESSION_ID`, so the sweep silently no-opped on every session + start and buffers orphaned by sessions that ended without SessionEnd + were never rolled up into pending proposals (#492). ## [1.3.0] — 2026-07-14 diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 5a8bd3ce..b2cf7f23 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -2428,11 +2428,34 @@ def capture_answer_cmd(session_id: str | None) -> None: @capture.command("finalize-all") -@click.option("--session-id", default=None, help="Current session id (else env VOUCH_SESSION_ID).") +@click.option( + "--session-id", default=None, + help="Current session id (else stdin payload, else env VOUCH_SESSION_ID).", +) @click.option("--max-age-seconds", type=float, default=3600.0, help="Max age in seconds.") def capture_finalize_all_cmd(session_id: str | None, max_age_seconds: float) -> None: - """Finalize all capture buffers except current session (SessionStart cleanup).""" - sid = session_id or os.environ.get("VOUCH_SESSION_ID") or "" + """Finalize all buffers except current session (SessionStart hook payload on stdin).""" + # the shipped SessionStart hook passes no --session-id and nothing sets + # VOUCH_SESSION_ID, so the current session id has to come off the hook + # payload on stdin — the same wire `capture finalize` reads at SessionEnd. + # without it sid is always "" and the sweep no-ops, leaving the buffers of + # sessions that died without SessionEnd on disk forever. + payload: dict[str, Any] = {} + if not sys.stdin.isatty(): + raw = sys.stdin.read() + if raw.strip(): + try: + loaded = json.loads(raw) + if isinstance(loaded, dict): + payload = loaded + except json.JSONDecodeError: + payload = {} + sid = ( + session_id + or str(payload.get("session_id") or "") + or os.environ.get("VOUCH_SESSION_ID") + or "" + ) if not sid: # No session ID provided; silently succeed _emit_json({"finalized": [], "skipped_recent": [], "skipped_current": []}) diff --git a/tests/test_capture.py b/tests/test_capture.py index e40ca0ed..9d8144e1 100644 --- a/tests/test_capture.py +++ b/tests/test_capture.py @@ -413,6 +413,7 @@ def commands(event: str) -> list[str]: assert any("capture observe" in c for c in commands("PostToolUse")) assert any("capture finalize" in c for c in commands("SessionEnd")) assert any("capture banner" in c for c in commands("SessionStart")) + assert any("capture finalize-all" in c for c in commands("SessionStart")) def test_capture_finalize_all_cmd_with_old_buffers(tmp_path: Path, monkeypatch) -> None: @@ -477,6 +478,70 @@ def test_capture_finalize_all_cmd_reads_session_from_env(tmp_path: Path, monkeyp assert current_sess in output["skipped_current"] +def test_capture_finalize_all_cmd_reads_session_from_stdin(tmp_path: Path) -> None: + """The shipped SessionStart hook passes no --session-id, so the current + session id has to come off the hook payload on stdin.""" + import os + import time as time_mod + + store = _make_store(tmp_path) + current_sess = "current-from-stdin" + old_sess = "old-session" + + old_path = cap.buffer_path(store, old_sess) + old_path.parent.mkdir(parents=True, exist_ok=True) + old_path.write_text( + '{"ts": 1.0, "tool": "Read", "summary": "test1"}\n' + '{"ts": 2.0, "tool": "Read", "summary": "test2"}\n' + '{"ts": 3.0, "tool": "Read", "summary": "test3"}\n' + ) + old_mtime = time_mod.time() - 7200 + os.utime(old_path, (old_mtime, old_mtime)) + + curr_path = cap.buffer_path(store, current_sess) + curr_path.write_text('{"ts": 1.0, "tool": "Read", "summary": "test"}\n') + + result = CliRunner().invoke( + cli, ["capture", "finalize-all"], + input=_json.dumps({"session_id": current_sess, "cwd": str(tmp_path)}), + env={"VOUCH_KB_PATH": str(store.kb_dir)}, + ) + + assert result.exit_code == 0 + output = _json.loads(result.output) + assert old_sess in output["finalized"] + assert current_sess in output["skipped_current"] + + +def test_capture_finalize_all_cmd_survives_malformed_stdin(tmp_path: Path) -> None: + """A hook must never fail the host turn on an unparseable payload.""" + store = _make_store(tmp_path) + result = CliRunner().invoke( + cli, ["capture", "finalize-all"], + input="not json at all", + env={"VOUCH_KB_PATH": str(store.kb_dir)}, + ) + assert result.exit_code == 0 + assert _json.loads(result.output)["finalized"] == [] + + +def test_capture_finalize_all_cmd_flag_beats_stdin(tmp_path: Path) -> None: + """An explicit --session-id wins over the stdin payload.""" + store = _make_store(tmp_path) + curr_path = cap.buffer_path(store, "from-flag") + curr_path.parent.mkdir(parents=True, exist_ok=True) + curr_path.write_text('{"ts": 1.0, "tool": "Read", "summary": "test"}\n') + + result = CliRunner().invoke( + cli, ["capture", "finalize-all", "--session-id", "from-flag"], + input=_json.dumps({"session_id": "from-stdin"}), + env={"VOUCH_KB_PATH": str(store.kb_dir)}, + ) + + assert result.exit_code == 0 + assert "from-flag" in _json.loads(result.output)["skipped_current"] + + def test_capture_finalize_all_cmd_silent_on_no_kb(tmp_path: Path, monkeypatch) -> None: """CLI command should silently succeed if KB not found.""" runner = CliRunner()