From 01e70fdd40709f475ea2e3e93ef691941ade129d Mon Sep 17 00:00:00 2001 From: ciregenz Date: Thu, 2 Jul 2026 02:48:21 -0700 Subject: [PATCH] [eric] apps: restart.sh lets the agent bounce its own runtime (sentinel watched by the harness) --- backend/apps/outputs/app_builder_skill.md | 12 ++--- backend/apps/outputs/runtime.py | 47 ++++++++++++++++ .../apps/outputs/view_builder_templates.py | 2 +- .../outputs/webapp_template/backend_init.sh | 8 ++- .../apps/outputs/webapp_template/restart.sh | 33 ++++++++++++ backend/tests/test_restart_sentinel.py | 54 +++++++++++++++++++ scripts/fetch-webapp-template.sh | 49 +++++++++++++++-- 7 files changed, 188 insertions(+), 17 deletions(-) create mode 100755 backend/apps/outputs/webapp_template/restart.sh create mode 100644 backend/tests/test_restart_sentinel.py diff --git a/backend/apps/outputs/app_builder_skill.md b/backend/apps/outputs/app_builder_skill.md index a43f2aa4..51375f07 100644 --- a/backend/apps/outputs/app_builder_skill.md +++ b/backend/apps/outputs/app_builder_skill.md @@ -83,6 +83,7 @@ workspace/ │ # when you change either) ├── run.sh # OpenSwarm's runtime spawns this; you don't ├── backend_init.sh # Run this when you need a backend (see below) +├── restart.sh # Run this to restart the app runtime (see below) ├── SKILL.md # This document └── frontend/ ├── package.json # React 18, MUI v7, Redux Toolkit, Framer @@ -258,9 +259,8 @@ bash backend_init.sh This script COPIES the canonical backend scaffold (FastAPI + SubApp pattern + swarm-debug pre-installed) into your workspace, allocates a free port, -and flips `BACKEND_PORT` in both `.env` and `.env.example`. Then **hard- -reload the preview** (right-click the reload button) so the runtime -restarts and brings the backend up. +and flips `BACKEND_PORT` in both `.env` and `.env.example`. Then run +**`bash restart.sh`** so the runtime restarts and brings the backend up. **You MUST NOT roll your own backend.** Do not: - Hand-write a `backend/main.py` from scratch. @@ -510,7 +510,8 @@ Common deps already in the template: ## Workflow tips - **Edits are auto-saved**. As soon as you write a file via the Edit/Write tool, it's on disk. Vite HMR re-renders the preview within ~100ms. -- **Hard Reload (right-click the reload button)** restarts the runtime — useful after you `bash backend_init.sh` or change `.env` values. +- **`bash restart.sh` restarts the app runtime yourself** — backend + vite, no user action needed. Use it after `bash backend_init.sh`, after editing `.env`, or whenever backend code must reload (uvicorn runs WITHOUT --reload, so backend edits do NOT hot-apply). Never ask the user to restart for you, and never try to kill/rerun run.sh — the harness owns the process. If `restart.sh` is missing (older app), `mkdir -p .openswarm && touch .openswarm/restart-requested` does the same thing. +- After a restart, wait a few seconds and check `.openswarm/terminal.log` to confirm the boot looked clean. - **`meta.json`** at workspace root drives the app's name + description in the OpenSwarm sidebar, App Builder header, and Apps page. Write it FIRST when starting a new app (see step 1 of the Quick start checklist), and revise it any time the app's purpose shifts. --- @@ -551,8 +552,7 @@ The three most common ways agent edits crash a React preview: rm -rf frontend/node_modules && rm -rf frontend/.vite-cache ``` - Then trigger Hard Reload on the preview (right-click the reload - button in the toolbar). The workspace's `frontend/node_modules` + Then run `bash restart.sh`. The workspace's `frontend/node_modules` will re-symlink to the shared warm cache on next vite boot — only ONE React copy exists across all App Builder apps, so the duplicate is gone. The `.vite-cache` wipe is important because diff --git a/backend/apps/outputs/runtime.py b/backend/apps/outputs/runtime.py index bc067ade..d34095e4 100644 --- a/backend/apps/outputs/runtime.py +++ b/backend/apps/outputs/runtime.py @@ -49,6 +49,12 @@ logger = logging.getLogger(__name__) # Module-level lock so only ONE vite optimizeDeps runs at a time; must be acquired before manager.p_lock to avoid deadlock with manager.attach. p_vite_boot_lock = asyncio.Lock() +# How often the manager stats each attached workspace for a restart sentinel; one stat/sec/runtime is noise. +RESTART_SENTINEL_POLL_SECONDS = 1.0 + +# The agent-facing restart handshake: the workspace's restart.sh touches this and the manager restarts the runtime, so agents never need an API token to bounce their own app. +RESTART_SENTINEL_NAME = "restart-requested" + @dataclass class LogLine: @@ -470,6 +476,10 @@ class AppRuntime: except Exception: pass + def announce(self, text: str) -> None: + """Emit a [runtime]-stream status line; the public door for the manager (p_broadcast is class-private).""" + self.p_broadcast(LogLine("runtime", text)) + def record_frontend_log(self, level: str, text: str) -> None: """Fold a webview console line into the terminal stream (ring buffer, WS subscribers, terminal.log). Called by the console-log beacon endpoint.""" stream = {"warn": "frontend-warn", "error": "frontend-error"}.get(level, "frontend") @@ -556,8 +566,45 @@ class AppRuntimeManager: # workspace_id → AppRuntime with no subscribers but still alive. OrderedDict gives O(1) move_to_end + popitem(last=False) for LRU semantics. self.idle_lru: "OrderedDict[str, AppRuntime]" = OrderedDict() self.p_lock = asyncio.Lock() + # Public: tests cancel it during teardown. + self.restart_watch_task: Optional[asyncio.Task] = None + + def p_ensure_restart_watcher(self) -> None: + """Lazy-start the sentinel watcher on first attach; __init__ runs at import time, before any event loop exists.""" + if self.restart_watch_task is None or self.restart_watch_task.done(): + self.restart_watch_task = asyncio.create_task(self.p_watch_restart_sentinels()) + + async def p_watch_restart_sentinels(self) -> None: + """The agent-side restart path: a workspace's restart.sh (or a bare `touch + .openswarm/restart-requested`) asks the harness to bounce the app runtime, + which the agent cannot do itself (no API token, and uvicorn runs without + --reload). Consume the sentinel FIRST so restart.sh's wait loop unblocks, + then restart every attached instance of that workspace.""" + while True: + await asyncio.sleep(RESTART_SENTINEL_POLL_SECONDS) + try: + seen_paths: set[str] = set() + for rt in list(self.runtimes.values()): + ws_path = rt.workspace_path + if ws_path in seen_paths or rt.p_suspended or not rt.running: + continue + sentinel = os.path.join(ws_path, ".openswarm", RESTART_SENTINEL_NAME) + if not os.path.exists(sentinel): + continue + seen_paths.add(ws_path) + try: + os.remove(sentinel) + except OSError: + continue + for peer in list(self.runtimes.values()): + if peer.workspace_path == ws_path and peer.running and not peer.p_suspended: + peer.announce("[runtime] restart requested from the workspace (restart.sh); restarting...") + asyncio.create_task(peer.restart()) + except Exception: + logger.exception("restart-sentinel watcher tick failed") async def attach(self, workspace_id: str, workspace_path: str, instance: int = 1) -> AppRuntime: + self.p_ensure_restart_watcher() key = runtime_key(workspace_id, instance) revived = False # Defined here so every code path below leaves it bound; the revive-idle branch used to skip the assignment, leaving the post-lock `if dead is not None:` check throwing UnboundLocalError. diff --git a/backend/apps/outputs/view_builder_templates.py b/backend/apps/outputs/view_builder_templates.py index 9db26734..0930f16a 100644 --- a/backend/apps/outputs/view_builder_templates.py +++ b/backend/apps/outputs/view_builder_templates.py @@ -596,7 +596,7 @@ def seed_webapp_template_workspace(workspace_dir: str, frontend_port: int) -> No patch_env_port(env_path, "OPENSWARM_BACKEND_VENV_CACHE", warm_venv_dir()) # Make the shipped scripts executable. tarball/git extracts may strip the +x bit depending on how the snapshot was vendored. - for script in ("run.sh", "backend_init.sh", "frontend/run.sh"): + for script in ("run.sh", "backend_init.sh", "restart.sh", "frontend/run.sh"): p = os.path.join(workspace_dir, script) if os.path.exists(p): os.chmod(p, 0o755) diff --git a/backend/apps/outputs/webapp_template/backend_init.sh b/backend/apps/outputs/webapp_template/backend_init.sh index aa27aef5..92e87e42 100755 --- a/backend/apps/outputs/webapp_template/backend_init.sh +++ b/backend/apps/outputs/webapp_template/backend_init.sh @@ -6,9 +6,8 @@ # code; it copies the master template's backend/ into the workspace # and flips BACKEND_PORT in both .env files to a free port. # -# After running this, hard-reload the preview (right-click the reload -# button in the App Builder) so the runtime restarts with the new -# BACKEND_PORT and `bash run.sh` brings the backend up. +# After running this, run `bash restart.sh` so the runtime restarts +# with the new BACKEND_PORT and `bash run.sh` brings the backend up. set -euo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -100,5 +99,4 @@ fi echo "" echo "Backend enabled on port $PORT." -echo "Hard-reload the preview (right-click the reload button in" -echo "the App Builder) to bring it up." +echo "Run 'bash restart.sh' to bring it up (restarts the app runtime)." diff --git a/backend/apps/outputs/webapp_template/restart.sh b/backend/apps/outputs/webapp_template/restart.sh new file mode 100755 index 00000000..96b75514 --- /dev/null +++ b/backend/apps/outputs/webapp_template/restart.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Restart this app's runtime (backend + vite), managed by the OpenSwarm harness. +# +# The runtime is spawned and owned by OpenSwarm, so you can't just kill/rerun +# run.sh from here. This script writes a sentinel the harness watches; the +# harness consumes it and restarts the whole runtime. No API token needed. +# Use after `bash backend_init.sh`, after editing `.env`, or whenever the +# backend must reload code/schema (uvicorn runs WITHOUT --reload on purpose). + +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +mkdir -p "$HERE/.openswarm" +SENTINEL="$HERE/.openswarm/restart-requested" +touch "$SENTINEL" +echo "Restart requested; waiting for the OpenSwarm harness to pick it up..." + +for _ in $(seq 1 30); do + if [[ ! -f "$SENTINEL" ]]; then + echo "Restart under way. The runtime takes a few seconds to come back;" + echo "then check .openswarm/terminal.log for boot output:" + sleep 6 + tail -n 20 "$HERE/.openswarm/terminal.log" 2>/dev/null || true + exit 0 + fi + sleep 1 +done + +rm -f "$SENTINEL" +echo "ERROR: the harness didn't pick up the restart within 30s." >&2 +echo "The runtime only runs while the app is open in OpenSwarm (preview card or" >&2 +echo "App Builder). If you're running this app standalone via 'bash run.sh'," >&2 +echo "just Ctrl-C that process and rerun it instead." >&2 +exit 1 diff --git a/backend/tests/test_restart_sentinel.py b/backend/tests/test_restart_sentinel.py new file mode 100644 index 00000000..c969b2d5 --- /dev/null +++ b/backend/tests/test_restart_sentinel.py @@ -0,0 +1,54 @@ +"""Agent self-restart handshake: a workspace's restart.sh touches +.openswarm/restart-requested and the AppRuntimeManager watcher consumes it and +restarts every attached instance of that workspace. This is the only restart +path an agent has (no API token, uvicorn runs without --reload), so pin both +the pickup and the sentinel consumption restart.sh's wait loop depends on.""" + +import asyncio +import os + +import pytest + +from backend.apps.outputs import runtime as runtime_mod +from backend.apps.outputs.runtime import AppRuntime, AppRuntimeManager + + +@pytest.mark.asyncio +async def test_sentinel_restarts_all_attached_instances(tmp_path, monkeypatch): + monkeypatch.setattr(runtime_mod, "RESTART_SENTINEL_POLL_SECONDS", 0.05) + restarted: list[int] = [] + + async def p_fake_start(self): + return True + + async def p_fake_restart(self): + restarted.append(self.instance) + return True + monkeypatch.setattr(AppRuntime, "start", p_fake_start) + monkeypatch.setattr(AppRuntime, "restart", p_fake_restart) + # The stub spawns no process; the watcher only restarts live runtimes, so fake liveness. + monkeypatch.setattr(AppRuntime, "running", property(lambda self: True)) + + mgr = AppRuntimeManager() + ws = str(tmp_path) + await mgr.attach("ws1", ws, 1) + await mgr.attach("ws1", ws, 2) + + os.makedirs(os.path.join(ws, ".openswarm"), exist_ok=True) + sentinel = os.path.join(ws, ".openswarm", "restart-requested") + with open(sentinel, "w", encoding="utf-8") as f: + f.write("") + + for _ in range(100): + await asyncio.sleep(0.05) + if len(restarted) >= 2: + break + # Sentinel consumed (restart.sh's wait loop unblocks) and BOTH instances bounced. + assert not os.path.exists(sentinel) + assert sorted(restarted) == [1, 2] + + # No sentinel -> no further restarts. + await asyncio.sleep(0.2) + assert sorted(restarted) == [1, 2] + if mgr.restart_watch_task: + mgr.restart_watch_task.cancel() diff --git a/scripts/fetch-webapp-template.sh b/scripts/fetch-webapp-template.sh index c27c97a7..a9dbdbc2 100755 --- a/scripts/fetch-webapp-template.sh +++ b/scripts/fetch-webapp-template.sh @@ -141,9 +141,8 @@ cat > "$DEST/backend_init.sh" <<'EOF' # code — it copies the master template's backend/ into the workspace # and flips BACKEND_PORT in both .env files to a free port. # -# After running this, hard-reload the preview (right-click the reload -# button in the App Builder) so the runtime restarts with the new -# BACKEND_PORT and `bash run.sh` brings the backend up. +# After running this, run `bash restart.sh` so the runtime restarts +# with the new BACKEND_PORT and `bash run.sh` brings the backend up. set -euo pipefail HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -212,11 +211,51 @@ fi echo "" echo "Backend enabled on port $PORT." -echo "Hard-reload the preview (right-click the reload button in" -echo "the App Builder) to bring it up." +echo "Run 'bash restart.sh' to bring it up (restarts the app runtime)." EOF chmod +x "$DEST/backend_init.sh" +# Patch 4: restart.sh — the agent-facing runtime restart. The runtime is +# owned by the OpenSwarm harness, so agents can't bounce it from Bash; +# this writes the sentinel the AppRuntimeManager watcher consumes +# (runtime.py RESTART_SENTINEL_NAME) and waits for pickup. +cat > "$DEST/restart.sh" <<'EOF' +#!/usr/bin/env bash +# Restart this app's runtime (backend + vite), managed by the OpenSwarm harness. +# +# The runtime is spawned and owned by OpenSwarm, so you can't just kill/rerun +# run.sh from here. This script writes a sentinel the harness watches; the +# harness consumes it and restarts the whole runtime. No API token needed. +# Use after `bash backend_init.sh`, after editing `.env`, or whenever the +# backend must reload code/schema (uvicorn runs WITHOUT --reload on purpose). + +set -euo pipefail +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +mkdir -p "$HERE/.openswarm" +SENTINEL="$HERE/.openswarm/restart-requested" +touch "$SENTINEL" +echo "Restart requested; waiting for the OpenSwarm harness to pick it up..." + +for _ in $(seq 1 30); do + if [[ ! -f "$SENTINEL" ]]; then + echo "Restart under way. The runtime takes a few seconds to come back;" + echo "then check .openswarm/terminal.log for boot output:" + sleep 6 + tail -n 20 "$HERE/.openswarm/terminal.log" 2>/dev/null || true + exit 0 + fi + sleep 1 +done + +rm -f "$SENTINEL" +echo "ERROR: the harness didn't pick up the restart within 30s." >&2 +echo "The runtime only runs while the app is open in OpenSwarm (preview card or" >&2 +echo "App Builder). If you're running this app standalone via 'bash run.sh'," >&2 +echo "just Ctrl-C that process and rerun it instead." >&2 +exit 1 +EOF +chmod +x "$DEST/restart.sh" + echo "" echo "[fetch-webapp-template] vendored snapshot at $DEST" echo "[fetch-webapp-template] pinned ref: $REF"