From 4c24629bc91e09ec8ed3f87d5e6019d11d017d48 Mon Sep 17 00:00:00 2001 From: briandevans <252620095+briandevans@users.noreply.github.com> Date: Sat, 8 Aug 2026 08:29:18 -0700 Subject: [PATCH] fix(console): skip the checkpoints prune confirmation the console already took MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes Console registers `checkpoints prune`, `clear` and `clear-legacy` as mutating, so it takes a console-level confirmation before dispatching any of them. `_apply_confirmed_defaults` then exists to keep the CLI layer from asking a second time — its docstring says so — but it only force-defaults `clear` and `clear-legacy`. `prune` was left out, even though `cmd_prune` gates its orphan preview on the identical `not args.force` shape. `_capture_output` redirects stdout and stderr but never stdin, so the unskipped `_confirm()` call hits `input()` with no terminal behind it: `EOFError` propagates into `_confirm`, which returns False, and `cmd_prune` prints "Aborted." and returns 1. The console turns that non-zero exit into a ConsoleCommandError, so `checkpoints prune` fails outright for any user who has at least one orphan checkpoint project — after that user already confirmed. When the server does happen to inherit a foreground terminal, the same call instead blocks a console worker thread and eats the operator's keystrokes. Forcing the flag is the documented behavior here rather than a weakening of the recent orphan-allowlist hardening. `orphan_allowlist` binds a deletion to the identities shown in the preview, guarding the window where a workdir disappears while the command waits on `input()`. Under the console there is no preview and no wait, which is exactly the `--force` case the comment on `cmd_prune` describes as "no restriction". --- hermes_cli/console_engine.py | 5 +- tests/hermes_cli/test_console_engine.py | 82 +++++++++++++++++++++++++ 2 files changed, 86 insertions(+), 1 deletion(-) diff --git a/hermes_cli/console_engine.py b/hermes_cli/console_engine.py index 10104b4ece..ab644297c0 100644 --- a/hermes_cli/console_engine.py +++ b/hermes_cli/console_engine.py @@ -1254,7 +1254,10 @@ def _apply_confirmed_defaults(args: argparse.Namespace) -> None: setattr(args, attr, True) if getattr(args, "_console_command", None) == "import": setattr(args, "force", True) - if getattr(args, "checkpoints_command", None) in {"clear", "clear-legacy"}: + # Every checkpoints subcommand the console registers as mutating gates its + # own confirmation on --force, so all three belong here. `prune` reaches + # _confirm() for its orphan preview, and the console never redirects stdin. + if getattr(args, "checkpoints_command", None) in {"prune", "clear", "clear-legacy"}: setattr(args, "force", True) if getattr(args, "plugins_action", None) == "install": if not getattr(args, "enable", False) and not getattr(args, "no_enable", False): diff --git a/tests/hermes_cli/test_console_engine.py b/tests/hermes_cli/test_console_engine.py index b99f7108db..1b56ef8d40 100644 --- a/tests/hermes_cli/test_console_engine.py +++ b/tests/hermes_cli/test_console_engine.py @@ -504,3 +504,85 @@ def test_execute_handler_string_exit_returns_error_not_crash(_isolate_hermes_hom assert result.status == "error" assert result.output + + +_ORPHAN_STORE_STATUS = { + "projects": [ + {"hash": "abc123", "workdir": "/gone/v2-project", "exists": False, "commits": 4}, + ], + "pre_v2_projects": [], +} + + +def _patch_checkpoint_manager(monkeypatch, prune_calls: list) -> None: + """Report one orphan project and record the resulting prune call.""" + import tools.checkpoint_manager as ckpt_mgr + + monkeypatch.setattr(ckpt_mgr, "store_status", lambda *a, **k: _ORPHAN_STORE_STATUS) + + def _fake_prune(**kwargs): + prune_calls.append(kwargs) + return { + "scanned": 1, + "deleted_orphan": 1, + "deleted_stale": 0, + "errors": 0, + "bytes_freed": 0, + } + + monkeypatch.setattr(ckpt_mgr, "prune_checkpoints", _fake_prune) + + +def test_console_checkpoints_prune_does_not_reprompt_for_orphans( + _isolate_hermes_home, monkeypatch +): + """`checkpoints prune` is console-mutating, so the nested prompt must be skipped. + + The console asks for confirmation itself before dispatching any command in the + `checkpoints` mutating set, and `_apply_confirmed_defaults` exists to keep the + CLI layer from asking a second time. `clear` and `clear-legacy` are force + defaulted; `prune` was not, so its orphan confirmation still called `input()`. + """ + prune_calls: list = [] + _patch_checkpoint_manager(monkeypatch, prune_calls) + + def _unexpected_input(_prompt): + raise AssertionError( + "input() must not be called: the console already confirmed `checkpoints prune`" + ) + + monkeypatch.setattr("builtins.input", _unexpected_input) + + result = HermesConsoleEngine().execute("checkpoints prune", confirmed=True) + + assert result.status == "ok" + assert len(prune_calls) == 1 + assert prune_calls[0]["delete_orphans"] is True + # No preview was shown, so there is nothing to bind the deletion to — the + # documented `--force` case for `orphan_allowlist`. + assert prune_calls[0]["orphan_allowlist"] is None + + +def test_console_checkpoints_prune_succeeds_without_a_tty( + _isolate_hermes_home, monkeypatch +): + """The dashboard console has no stdin, so an unskipped prompt aborts the command. + + `_capture_output` redirects stdout/stderr but never stdin, so `input()` raises + `EOFError`, `_confirm` returns False, and `cmd_prune` returns 1 — which the + console surfaces as a failed command for every user with an orphan project. + """ + prune_calls: list = [] + _patch_checkpoint_manager(monkeypatch, prune_calls) + + def _eof_input(_prompt): + raise EOFError + + monkeypatch.setattr("builtins.input", _eof_input) + + result = HermesConsoleEngine().execute("checkpoints prune", confirmed=True) + + assert result.status == "ok" + assert "Aborted." not in result.output + assert len(prune_calls) == 1 + assert prune_calls[0]["orphan_allowlist"] is None