fix(console): skip the checkpoints prune confirmation the console already took
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".
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user