mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-26 06:22:22 +02:00
[eric] apps: restart.sh lets the agent bounce its own runtime (sentinel watched by the harness)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)."
|
||||
|
||||
+33
@@ -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
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user