diff --git a/README.md b/README.md index c08ca7e8..86d07fc6 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ One Render web service running nanobot's `gateway` and its bundled WebUI. You ch This is not a free deploy. Expect: -- **Render Starter plan** — ~$7/mo for the web service. +- **Render Starter plan** — ~$7/mo for the web service. Runs the core experience well; resource-intensive tasks (npm-based CLI Apps, image rendering) may need a larger plan — see the note under [Deploy](#deploy). - **1 GB persistent disk** — ~$0.25/mo for sessions and memory. - **Anthropic API usage** — billed separately by Anthropic based on your agent's LLM calls. @@ -35,7 +35,7 @@ This is not a free deploy. Expect: 5. The WebUI shows an access prompt — paste the same `NANOBOT_WEB_TOKEN` to sign in. It's stored only in your browser. 6. Send a message. You're talking to your agent. -**Note:** If you hit an error like `No space left on device` after performing a resource-intensive dask, upgrade your persistent disk size to 5 GB or larger in the **Disk** page in your Render dashboard. +**Note:** The Starter plan comfortably runs the core experience — chat, web search/fetch, and persistent memory. Resource-intensive actions, such as installing npm-based CLI Apps or generating/rendering images, can briefly overwhelm the small instance: it may trigger a ~15s restart before recovering, or fail with `No space left on device`. To run these reliably, upgrade to a larger plan (e.g. **Standard**, 2 GB) under **Settings → Instance Type**, and/or increase your persistent disk to 5 GB or larger on the **Disk** page, in your Render dashboard. ## Environment variables diff --git a/nanobot/apps/cli/service.py b/nanobot/apps/cli/service.py index 8fdcbe7b..5b4bf2f2 100644 --- a/nanobot/apps/cli/service.py +++ b/nanobot/apps/cli/service.py @@ -857,10 +857,15 @@ def _npm_argv(self, app: dict[str, Any], action: str) -> list[str]: package = str(app.get("npm_package") or "") if not package: raise CliAppError("registry entry has no npm_package") + # Lightweight flags flatten npm's CPU/network burst so a global install + # doesn't starve a small instance's health check into a restart: + # --no-audit/--no-fund skip an extra audit pass; --maxsockets caps + # concurrent connections. Uninstall doesn't need them. + light = ["--no-audit", "--no-fund", "--maxsockets", "4"] if action == "install": - return [npm, "install", "-g", package] + return [npm, "install", "-g", package, *light] if action == "update": - return [npm, "install", "-g", package + "@latest"] + return [npm, "install", "-g", package + "@latest", *light] return [npm, "uninstall", "-g", package] def _cleanup_stale_npm_install(self, app: dict[str, Any]) -> bool: @@ -944,12 +949,21 @@ def _argv_for_action( def _run_argv(self, argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[str]: command = subprocess.list2cmdline(argv) logger.info("CLI Apps: running {}", command) - result = subprocess.run( - argv, - capture_output=True, - text=True, - timeout=timeout, - ) + try: + result = subprocess.run( + argv, + capture_output=True, + text=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired as exc: + logger.warning("CLI Apps: command timed out after {}s: {}", timeout, command) + raise CliAppError( + f"Timed out after {timeout}s. CLI-app installs are resource-intensive and " + "can overwhelm a small instance. Upgrade your Render instance to a larger " + "plan and try again. See the README.", + status=503, + ) from exc logger.info("CLI Apps: command exited with code {}: {}", result.returncode, command) output = (result.stderr or result.stdout or "").strip() if output: diff --git a/tests/cli_apps/test_service.py b/tests/cli_apps/test_service.py index d33a8f83..59c8c40d 100644 --- a/tests/cli_apps/test_service.py +++ b/tests/cli_apps/test_service.py @@ -451,7 +451,16 @@ def fake_run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[st nonlocal install_attempts if argv == [npm, "root", "-g"]: return subprocess.CompletedProcess(argv, 0, stdout=str(global_root), stderr="") - if argv == [npm, "install", "-g", "hyperframes"]: + if argv == [ + npm, + "install", + "-g", + "hyperframes", + "--no-audit", + "--no-fund", + "--maxsockets", + "4", + ]: install_attempts += 1 if install_attempts == 1: return subprocess.CompletedProcess( @@ -954,3 +963,67 @@ def fake_run(argv: list[str], *, timeout: int) -> subprocess.CompletedProcess[st sys.executable, "suno-cli", ] + + +def test_npm_argv_install_and_update_use_lightweight_flags( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + manager = _manager(tmp_path) + npm = str(tmp_path / "bin" / "npm") + monkeypatch.setattr( + "nanobot.apps.cli.service.shutil.which", + lambda command: npm if command == "npm" else None, + ) + app = {"package_manager": "npm", "npm_package": "hyperframes"} + + # install/update carry lightweight flags to flatten the CPU/network burst that + # otherwise starves a small instance's health check. + assert manager._npm_argv(app, "install") == [ + npm, + "install", + "-g", + "hyperframes", + "--no-audit", + "--no-fund", + "--maxsockets", + "4", + ] + assert manager._npm_argv(app, "update") == [ + npm, + "install", + "-g", + "hyperframes@latest", + "--no-audit", + "--no-fund", + "--maxsockets", + "4", + ] + # uninstall stays minimal — install-only flags don't apply. + assert manager._npm_argv(app, "uninstall") == [npm, "uninstall", "-g", "hyperframes"] + + +def test_run_argv_timeout_raises_friendly_cli_app_error( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from nanobot.apps.cli import service as cli_service + + manager = _manager(tmp_path) + + def fake_run( + argv: list[str], + *, + capture_output: bool, + text: bool, + timeout: int, + ) -> subprocess.CompletedProcess[str]: + raise subprocess.TimeoutExpired(cmd=argv, timeout=timeout) + + monkeypatch.setattr(cli_service.subprocess, "run", fake_run) + + with pytest.raises(CliAppError) as excinfo: + manager._run_argv(["npm", "install", "-g", "hyperframes"], timeout=5) + + assert excinfo.value.status == 503 + assert "upgrade" in excinfo.value.message.lower()