diff --git a/hermes_cli/update_cmd.py b/hermes_cli/update_cmd.py index a7e10d0806..7929afdd70 100644 --- a/hermes_cli/update_cmd.py +++ b/hermes_cli/update_cmd.py @@ -8683,8 +8683,11 @@ def _cmd_update_impl(args, gateway_mode: bool): gateway_mode=gateway_mode, pre_update_snapshot_id=pre_update_snapshot_id, ) - _print_update_completion("✓ Update complete!") + current_checkout_complete = _print_verified_update_completion( + "✓ Update complete!" + ) else: + current_checkout_complete = False print(f"⚠ Venv still unhealthy after repair: {detail_after}") print(" Close all Hermes windows/gateways and re-run: hermes update") else: diff --git a/tests/hermes_cli/test_cmd_update.py b/tests/hermes_cli/test_cmd_update.py index 0ca3a52c6c..13a2beae7a 100644 --- a/tests/hermes_cli/test_cmd_update.py +++ b/tests/hermes_cli/test_cmd_update.py @@ -353,12 +353,79 @@ class TestCmdUpdateBranchFallback: assert "official repo not checked" in captured.out assert "Already up to date!" not in captured.out + @pytest.mark.parametrize( + ("health_after_repair", "runtime_status", "expected_runtime_checks"), + [ + (True, (False, SimpleNamespace(sqlite_version_string="3.46.1")), 1), + (False, (True, None), 0), + ], + ) @patch("shutil.which", return_value=None) @patch("subprocess.run") - def test_current_checkout_verification_failure_is_durable( + def test_current_checkout_python_repair_failure_is_durable( + self, + mock_run, + _mock_which, + mock_args, + health_after_repair, + runtime_status, + expected_runtime_checks, + ): + """Python repair must not bypass runtime and durable outcome checks.""" + from hermes_cli import main as hm + from hermes_cli import update_cmd + + mock_args.gateway = True + mock_run.side_effect = _make_run_side_effect( + branch="main", verify_ok=True, commit_count="0" + ) + + with patch.object( + hm, + "_get_origin_url", + return_value="https://github.com/example/hermes-agent.git", + ), patch.object(hm, "_sync_with_upstream_if_needed"), patch.object( + update_cmd, + "_venv_core_imports_healthy", + side_effect=[ + (False, "broken before repair"), + (health_after_repair, "broken after repair"), + ], + ), patch.object( + hm, "_install_python_dependencies_with_optional_fallback" + ), patch.object( + hm, "_refresh_active_lazy_features" + ), patch.object( + hm, "_restore_active_tool_dependencies" + ), patch.object( + update_cmd, "_write_update_incomplete_marker" + ), patch.object( + hm, "_clear_update_incomplete_marker" + ), patch.object( + update_cmd, + "_post_update_sqlite_runtime_status", + return_value=runtime_status, + ) as runtime_check, patch.object( + update_cmd, "_write_gateway_update_exit_code" + ) as write_gateway_exit, patch( + "hermes_cli.update_receipt.finalize_update_receipt" + ) as finalize_receipt, patch( + "hermes_cli.update_receipt.finalize_pending_update_receipt" + ): + with pytest.raises(SystemExit) as exit_info: + cmd_update(mock_args) + + assert exit_info.value.code == 1 + assert runtime_check.call_count == expected_runtime_checks + write_gateway_exit.assert_called_once_with(False) + finalize_receipt.assert_called_once_with("partial") + + @patch("shutil.which", return_value=None) + @patch("subprocess.run") + def test_current_checkout_node_repair_verification_failure_is_durable( self, mock_run, _mock_which, mock_args ): - """A failed runtime check must fail receipts and gateway automation.""" + """A failed Node-path runtime check must fail durable outcomes.""" from hermes_cli import main as hm from hermes_cli import update_cmd @@ -388,7 +455,6 @@ class TestCmdUpdateBranchFallback: assert exit_info.value.code == 1 write_gateway_exit.assert_called_once_with(False) finalize_receipt.assert_called_once_with("partial") - @patch("shutil.which", return_value=None) @patch("subprocess.run") def test_fork_upstream_sync_that_moves_head_runs_post_update_steps(