From 80447e78928be6afec623f12bc8ff92d5bd554bc Mon Sep 17 00:00:00 2001 From: Jiongan Mu Date: Sun, 3 May 2026 15:51:08 -0700 Subject: [PATCH 1/2] Native macOS .app: pywebview launcher + bundle build Adds a one-window native Mac app so you can launch gorchestra from Spotlight/Dock instead of running 'make backend' and 'make frontend' in two terminals. - launcher.py: pywebview window pointed at FastAPI on a pinned port (8765) so localStorage settings persist across launches. SPA mount serves frontend/dist/ from the same origin. private_mode=False keeps WKWebView's data store between runs. - scripts/build_app.sh: builds gorchestra.app with a tiny C wrapper in Contents/MacOS/ (real arm64 Mach-O so LaunchServices reads the arch correctly and doesn't falsely demand Rosetta), then ad-hoc signs for macOS 15+ App Management. - runner: child subprocess now spawned with start_new_session=True; events.cancel uses killpg() so cancellation reaps the runner's grandchildren (shell tools etc.) too. Window-close hook reuses the same teardown path. - Makefile: app-install, app, app-bundle, install-app targets. The install-app target is the one-shot 'build everything + drop into /Applications'. --- .gitignore | 1 + Makefile | 32 ++++++- README.md | 51 ++++++++++++ backend/app/runner/events.py | 14 +++- backend/app/runner/runner.py | 3 + backend/pyproject.toml | 3 + launcher.py | 157 +++++++++++++++++++++++++++++++++++ scripts/build_app.sh | 113 +++++++++++++++++++++++++ 8 files changed, 370 insertions(+), 4 deletions(-) create mode 100644 launcher.py create mode 100644 scripts/build_app.sh diff --git a/.gitignore b/.gitignore index abe35f5..9014600 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,4 @@ build/ .vite/ *.tsbuildinfo .claude/settings.local.json +*.app/ diff --git a/Makefile b/Makefile index 6759993..6895b2f 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: install backend frontend dev test clean +.PHONY: install backend frontend dev test clean app app-install app-build app-bundle install-app VENV := backend/.venv @@ -23,7 +23,35 @@ dev: test: cd backend && .venv/bin/pytest -q +# --- native window app ----------------------------------------------------- +app-install: + cd backend && .venv/bin/pip install -e ".[dev,app]" + +app-build: + cd frontend && npm run build + +app: app-build + $(VENV)/bin/python launcher.py + +app-bundle: app-build + bash scripts/build_app.sh + +# One-shot: build frontend, build .app, copy to /Applications. +# Quits any running instance first so the copy doesn't trip over an +# in-use bundle. +install-app: app-bundle + @echo "→ quitting any running gorchestra..." + -@pkill -f launcher.py 2>/dev/null || true + -@osascript -e 'tell application "gorchestra" to quit' >/dev/null 2>&1 || true + @sleep 1 + @echo "→ installing to /Applications/gorchestra.app..." + @rm -rf /Applications/gorchestra.app + @cp -R gorchestra.app /Applications/ + @echo "" + @echo "Installed. Launch via Spotlight (Cmd+Space → gorchestra) or Launchpad." + @echo "On first launch, right-click → Open to bypass the Gatekeeper warning." + clean: rm -f backend/*.db rm -rf backend/__pycache__ backend/app/__pycache__ backend/app/*/__pycache__ - rm -rf frontend/node_modules frontend/dist + rm -rf frontend/node_modules frontend/dist gorchestra.app diff --git a/README.md b/README.md index 483f0dc..2ab5010 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,57 @@ Then either: directly. (Topology — adding/removing nodes and edges, designating input/ output — is owned by the orchestrator; you ask via chat.) +## Run as a native Mac app + +Wrap the whole thing in a real `.app` bundle so it shows up in Spotlight, +Launchpad, and the Dock — no terminals, no `localhost:5173` to remember. + +```bash +make app-install # one-time: adds pywebview to the backend venv +make install-app # build frontend + build .app + copy to /Applications +``` + +After `make install-app`, hit `Cmd+Space`, type `gorchestra`, press Enter. +On the very first launch, right-click the app → **Open** to bypass the +Gatekeeper "unidentified developer" warning (it's ad-hoc signed, not +notarised). After that, normal double-click works. + +Other targets if you want finer control: + +```bash +make app # dev mode: launch in a window without bundling +make app-bundle # just build gorchestra.app in the project root +``` + +### How it works + +- `launcher.py` runs FastAPI in a daemon thread on port `8765` (pinned so + `localStorage` settings persist across launches — that store is keyed by + origin) and opens a native WKWebView window via `pywebview` with + `private_mode=False` so the data store survives restarts. +- The frontend's static `dist/` is served from the same origin, so all + existing relative API/WebSocket URLs work unchanged. +- `scripts/build_app.sh` produces the bundle. The executable in + `Contents/MacOS/` is a tiny C wrapper compiled to native arm64 — it just + `execv`s the project's venv Python on `launcher.py`. It has to be a real + Mach-O binary (not a shell script) or LaunchServices misreads the + architecture and falsely demands Rosetta. The bundle is then ad-hoc + signed so macOS 15+ App Management lets it launch. +- Closing the window kills any in-flight workflow runs by signalling their + process group (the runner spawns its child with `start_new_session=True`). +- Logs go to `$TMPDIR/gorchestra.log`. + +### Caveats + +- The bundle is hard-linked to this checkout — its launcher execs + `backend/.venv/bin/python` on this project's `launcher.py`. Move the + project directory and you'll need to `make install-app` again. For a + fully relocatable bundle, swap the C wrapper for `py2app`. +- `localStorage` is keyed by the host process's bundle ID. Settings set via + `gorchestra.app` (bundle id `local.gorchestra`) won't appear when running + `make app` directly (Homebrew Python's `org.python.python`). Pick one + launch method and stick with it. + ## Node code contract ```python diff --git a/backend/app/runner/events.py b/backend/app/runner/events.py index 896698c..d7eb818 100644 --- a/backend/app/runner/events.py +++ b/backend/app/runner/events.py @@ -5,6 +5,8 @@ """ from __future__ import annotations import asyncio +import os +import signal import subprocess import threading from dataclasses import dataclass, field @@ -64,7 +66,12 @@ def set_proc(run_id: str, proc: subprocess.Popen) -> None: def cancel(run_id: str) -> bool: - """Best-effort: SIGTERM the run's subprocess. Returns True if a signal was sent.""" + """Best-effort: SIGTERM the run's process group. Returns True if a signal was sent. + + The runner spawns the child with `start_new_session=True`, so signalling + the whole process group also reaps any grandchildren the child spawned + (e.g. `shell` tool subprocesses). + """ st = _RUNS.get(run_id) if not st or st.finished: return False @@ -74,7 +81,10 @@ def cancel(run_id: str) -> bool: with st.lock: st.cancelled = True try: - proc.terminate() + try: + os.killpg(os.getpgid(proc.pid), signal.SIGTERM) + except (ProcessLookupError, PermissionError, OSError): + proc.terminate() return True except Exception: return False diff --git a/backend/app/runner/runner.py b/backend/app/runner/runner.py index fe6e85a..e8c5b6c 100644 --- a/backend/app/runner/runner.py +++ b/backend/app/runner/runner.py @@ -78,6 +78,9 @@ def run_workflow_streaming( stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, + # New session = new process group, so a single killpg() reaps + # the child plus anything it spawned (shell tools, etc.). + start_new_session=True, ) except Exception as e: ev_mod.append_event( diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 5089d00..a5a712f 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -14,6 +14,9 @@ dependencies = [ dev = [ "pytest>=8.0", ] +app = [ + "pywebview>=5.0", +] [build-system] requires = ["setuptools>=68"] diff --git a/launcher.py b/launcher.py new file mode 100644 index 0000000..8816924 --- /dev/null +++ b/launcher.py @@ -0,0 +1,157 @@ +"""Native-window launcher for gorchestra. + +Spins up the FastAPI backend in a daemon thread on a free localhost port, +serves the built frontend (`frontend/dist/`) from the same origin, and opens +a pywebview window pointed at it. Closing the window kills any in-flight +runs (process groups) and exits. + +Usage: + cd frontend && npm run build # one-time + backend/.venv/bin/pip install pywebview + backend/.venv/bin/python launcher.py +""" +from __future__ import annotations + +import atexit +import os +import signal +import socket +import sys +import threading +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parent +BACKEND = ROOT / "backend" +DIST = ROOT / "frontend" / "dist" + +# Pin the SQLite path so we don't depend on cwd (mirrors `make backend`, +# which runs from backend/ and produces backend/workflow_builder.db). +os.environ.setdefault("DATABASE_URL", f"sqlite:///{BACKEND / 'workflow_builder.db'}") + +sys.path.insert(0, str(BACKEND)) + +if not DIST.exists(): + sys.stderr.write( + f"Frontend not built: {DIST} missing.\n" + "Run: cd frontend && npm run build\n" + ) + sys.exit(1) + +try: + import webview # type: ignore +except ImportError: + sys.stderr.write( + "pywebview not installed.\n" + "Run: backend/.venv/bin/pip install pywebview\n" + ) + sys.exit(1) + +import uvicorn +from fastapi.responses import FileResponse + +from app.main import app +from app.runner import events as ev_mod + + +# SPA mount: serve dist files when they exist, fall back to index.html for +# any other path so client-side state-driven routing keeps working. +# Registered AFTER app.main has already included all /api/* routers, so +# those still match first. +@app.get("/{full_path:path}", include_in_schema=False) +def _spa(full_path: str): + target = DIST / full_path + if full_path and target.is_file(): + return FileResponse(target) + return FileResponse(DIST / "index.html") + + +# Pinned port keeps localStorage stable across launches (origin = scheme+host+port, +# so a shifting port would orphan saved API keys / settings every relaunch). +# Falls back to an ephemeral port only if 8765 is in use. +PREFERRED_PORT = 8765 + + +def _pick_port() -> int: + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + try: + s.bind(("127.0.0.1", PREFERRED_PORT)) + return PREFERRED_PORT + except OSError: + s.close() + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + sys.stderr.write( + f"port {PREFERRED_PORT} in use, using {port} " + "(saved settings won't carry over from previous launches)\n" + ) + return port + finally: + s.close() + + +PORT = _pick_port() +_server = uvicorn.Server( + uvicorn.Config(app, host="127.0.0.1", port=PORT, log_level="warning") +) + + +def _serve() -> None: + _server.run() + + +def _wait_until_up(timeout_s: float = 5.0) -> None: + deadline = time.time() + timeout_s + while time.time() < deadline: + try: + with socket.create_connection(("127.0.0.1", PORT), timeout=0.1): + return + except OSError: + time.sleep(0.05) + + +def _kill_active_runs() -> None: + for st in list(ev_mod._RUNS.values()): + proc = st.proc + if not proc or proc.poll() is not None: + continue + try: + os.killpg(os.getpgid(proc.pid), signal.SIGTERM) + except (ProcessLookupError, PermissionError, OSError): + try: + proc.terminate() + except Exception: + pass + + +def _shutdown() -> None: + _kill_active_runs() + _server.should_exit = True + + +atexit.register(_shutdown) + +threading.Thread(target=_serve, daemon=True).start() +_wait_until_up() + +window = webview.create_window( + "gorchestra", + f"http://127.0.0.1:{PORT}", + width=1400, + height=900, + min_size=(900, 600), +) +window.events.closing += _shutdown +# private_mode=False makes pywebview keep WKWebView's default data store +# instead of wiping it on startup, so localStorage (API keys, default +# models) survives across launches. +# +# On macOS, `storage_path` is silently ignored — Cocoa always uses +# WKWebsiteDataStore.defaultDataStore(), which lives under +# ~/Library/WebKit//. The bundle ID differs depending on +# how you launch us: +# - via gorchestra.app → local.gorchestra (Info.plist) +# - via `python launcher.py` → org.python.python (Homebrew Python.app) +# So pick one launch method and stick with it; settings won't carry across. +webview.start(private_mode=False) diff --git a/scripts/build_app.sh b/scripts/build_app.sh new file mode 100644 index 0000000..1888784 --- /dev/null +++ b/scripts/build_app.sh @@ -0,0 +1,113 @@ +#!/bin/bash +# Build a minimal macOS .app bundle that wraps launcher.py. +# +# The bundle is hard-linked to this checkout: it execs the project's +# backend/.venv python on this project's launcher.py. Move the project +# directory and the .app stops working — that's the trade-off for the +# 5-minute wrapper approach. For a relocatable bundle, use py2app. +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +APP_NAME="${APP_NAME:-gorchestra}" +APP="${ROOT}/${APP_NAME}.app" +PYTHON="${ROOT}/backend/.venv/bin/python" +LAUNCHER="${ROOT}/launcher.py" +LOG_FILE="${TMPDIR:-/tmp}/${APP_NAME}.log" + +if [[ ! -x "$PYTHON" ]]; then + echo "error: $PYTHON not found. Run 'make install' first." >&2 + exit 1 +fi +if [[ ! -f "$LAUNCHER" ]]; then + echo "error: $LAUNCHER not found." >&2 + exit 1 +fi +if [[ ! -d "${ROOT}/frontend/dist" ]]; then + echo "error: frontend/dist not found. Run 'make app-build' first." >&2 + exit 1 +fi + +rm -rf "$APP" +mkdir -p "$APP/Contents/MacOS" +mkdir -p "$APP/Contents/Resources" + +# The executable in Contents/MacOS/ MUST be a real Mach-O binary, not a +# shell script. Otherwise LaunchServices can't read the architecture from +# the file header, falls back to assuming x86_64, and refuses to launch on +# Apple Silicon with a misleading "Rosetta required" / -10669 error. +# We compile a tiny C wrapper that execs the Python launcher. +TMPDIR_BUILD="$(mktemp -d -t gorchestra_build)" +SRC="${TMPDIR_BUILD}/launcher.c" +trap 'rm -rf "$TMPDIR_BUILD"' EXIT + +cat > "$SRC" < +#include +#include + +int main(void) { + int fd = open("${LOG_FILE}", O_WRONLY | O_CREAT | O_APPEND, 0644); + if (fd >= 0) { dup2(fd, 1); dup2(fd, 2); close(fd); } + chdir("${ROOT}"); + execl("${PYTHON}", "python", "${LAUNCHER}", (char *)NULL); + return 1; +} +EOF + +# Build for the host arch (arm64 on Apple Silicon). The point of the C +# wrapper is purely to give LaunchServices a real Mach-O header to read; +# no x86_64 or Rosetta is involved at any point. +clang -O2 -o "$APP/Contents/MacOS/$APP_NAME" "$SRC" +chmod +x "$APP/Contents/MacOS/$APP_NAME" + +# Ad-hoc sign so Gatekeeper / App Management on macOS 15+ lets us launch +# without the "damaged or untrusted developer" prompt. Identity "-" means +# no real cert; sufficient for local-only use. +codesign --force --sign - "$APP" >/dev/null 2>&1 || true + +cat > "$APP/Contents/Info.plist" < + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + ${APP_NAME} + CFBundleIdentifier + local.gorchestra + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + ${APP_NAME} + CFBundleDisplayName + gorchestra + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.1.0 + CFBundleVersion + 0.1.0 + LSMinimumSystemVersion + 11.0 + LSUIElement + + NSHighResolutionCapable + + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + + +EOF + +# Touch the bundle so Finder picks up changes immediately. +touch "$APP" + +echo "Built: $APP" +echo "Logs: $LOG_FILE" +echo +echo "Drag $APP_NAME.app into /Applications, or run:" +echo " open '$APP'" From b4e3ed5d4b06df759c81aad8cf926a1acde6090b Mon Sep 17 00:00:00 2001 From: Jiongan Mu Date: Sun, 3 May 2026 16:02:57 -0700 Subject: [PATCH 2/2] Runner child: parent-death watchdog so force-quit doesn't orphan runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a pipe-EOF watchdog to the runner child so abrupt parent death (SIGKILL, force-quit, segfault — anything that skips atexit and signal handlers) is detected instantly and the child tears down its own process group, taking any 'shell' tool grandchildren with it. - runner.py: open a pipe before Popen; pass the read end via pass_fds with PARENT_DEATH_FD env var. Parent holds the write end open for the duration of the run and closes it in a finally block when the child has exited. - child.py: install a daemon thread early in main() that blocks reading the inherited fd. EOF means parent is gone; we double-check via getppid()==1 to avoid spurious teardown, then SIGTERM our process group and _exit. - README: fix incorrect bundle-id caveat. Both 'make app' and gorchestra.app run through Homebrew's embedded Python.app and share a WKWebView store under ~/Library/WebKit/org.python.python/, so localStorage carries between them. Only 'make dev' (browser) is separate. Also note the new force-quit teardown guarantee. Verified end to end: SIGKILL'd a parent runner that had spawned a node which had in turn spawned 'sleep 60'. Within 2s both the runner child and the sleep grandchild were gone. 51/51 backend tests still pass. --- README.md | 19 +++-- backend/app/runner/child.py | 37 +++++++++ backend/app/runner/runner.py | 140 +++++++++++++++++++++-------------- 3 files changed, 133 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index 2ab5010..a214cb7 100644 --- a/README.md +++ b/README.md @@ -94,8 +94,14 @@ make app-bundle # just build gorchestra.app in the project root Mach-O binary (not a shell script) or LaunchServices misreads the architecture and falsely demands Rosetta. The bundle is then ad-hoc signed so macOS 15+ App Management lets it launch. -- Closing the window kills any in-flight workflow runs by signalling their - process group (the runner spawns its child with `start_new_session=True`). +- Closing the window — or force-quitting the app entirely — kills any + in-flight workflow runs by signalling their process group (the runner + spawns its child with `start_new_session=True`). For graceful close + (Cmd+Q, red button) the launcher's `closing` hook does it directly. For + abrupt death (SIGKILL, force-quit) the runner child has its own + parent-death watchdog: it holds the read end of a pipe whose write end + the parent keeps open, gets EOF the instant parent dies, and tears down + its own process group. So no orphaned `shell` tool subprocesses either way. - Logs go to `$TMPDIR/gorchestra.log`. ### Caveats @@ -104,10 +110,11 @@ make app-bundle # just build gorchestra.app in the project root `backend/.venv/bin/python` on this project's `launcher.py`. Move the project directory and you'll need to `make install-app` again. For a fully relocatable bundle, swap the C wrapper for `py2app`. -- `localStorage` is keyed by the host process's bundle ID. Settings set via - `gorchestra.app` (bundle id `local.gorchestra`) won't appear when running - `make app` directly (Homebrew Python's `org.python.python`). Pick one - launch method and stick with it. +- `make dev` runs the frontend in your browser (Chrome/Safari) and uses + that browser's `localStorage`. The native-window launches (`make app` + and `gorchestra.app`) both run via Homebrew's embedded Python.app and + share a WKWebView store under `~/Library/WebKit/org.python.python/`, + so settings carry between those two — but not from the browser. ## Node code contract diff --git a/backend/app/runner/child.py b/backend/app/runner/child.py index e169f07..dc727fa 100644 --- a/backend/app/runner/child.py +++ b/backend/app/runner/child.py @@ -64,8 +64,45 @@ def _handler(signum, frame): pass +def _install_parent_death_watchdog() -> None: + """Self-terminate if the parent runner dies abruptly (e.g. SIGKILL). + + The parent passes the read end of a pipe via PARENT_DEATH_FD. As long as + parent is alive, it holds the write end open. If parent is killed by any + means (force-quit, OOM kill, segfault), the kernel closes the write end + and our blocking read returns EOF immediately. We then SIGTERM our own + process group to take down any grandchildren (shell tools, etc.) and + exit. Without this, force-quitting the app would orphan in-flight runs. + """ + fd_str = os.environ.get("PARENT_DEATH_FD") + if not fd_str: + return + try: + fd = int(fd_str) + except ValueError: + return + + def _watch() -> None: + try: + os.read(fd, 1) + except (OSError, ValueError): + pass + # Defensive: if ppid != 1, the FD closed for some other reason and + # parent is still alive — leave it alone. + if os.getppid() != 1: + return + try: + os.killpg(os.getpgid(0), signal.SIGTERM) + except (ProcessLookupError, OSError): + pass + os._exit(1) + + threading.Thread(target=_watch, daemon=True).start() + + def main() -> None: _install_sigterm_handler() + _install_parent_death_watchdog() raw = sys.stdin.read() payload = json.loads(raw) diff --git a/backend/app/runner/runner.py b/backend/app/runner/runner.py index e8c5b6c..17facc3 100644 --- a/backend/app/runner/runner.py +++ b/backend/app/runner/runner.py @@ -71,6 +71,14 @@ def run_workflow_streaming( env = os.environ.copy() env["PYTHONUNBUFFERED"] = "1" + # Parent-death pipe: child blocks reading from death_r; if parent dies for + # any reason (SIGKILL, force-quit, segfault), the kernel closes death_w + # and the child's read returns EOF immediately, triggering self-teardown + # of the child + its process group. Covers the case the SIGTERM-based + # cancel and atexit hooks can't (since neither runs on SIGKILL). + death_r, death_w = os.pipe() + env["PARENT_DEATH_FD"] = str(death_r) + try: proc = subprocess.Popen( [sys.executable, "-m", "app.runner.child"], @@ -81,8 +89,15 @@ def run_workflow_streaming( # New session = new process group, so a single killpg() reaps # the child plus anything it spawned (shell tools, etc.). start_new_session=True, + # Inherit the read end of the death pipe with the same fd number. + pass_fds=(death_r,), ) except Exception as e: + for fd in (death_r, death_w): + try: + os.close(fd) + except OSError: + pass ev_mod.append_event( run_id, { @@ -95,76 +110,87 @@ def run_workflow_streaming( ) return + # Parent doesn't need the read end; only the child does. + os.close(death_r) ev_mod.set_proc(run_id, proc) try: - assert proc.stdin is not None - proc.stdin.write(json.dumps(payload).encode()) - proc.stdin.close() - except Exception as e: - ev_mod.append_event( - run_id, - { - "type": "run_finished", - "status": "error", - "error": f"failed to write to runner stdin: {e}", - "outputs": {}, - "total_cost": 0.0, - }, - ) - try: - proc.kill() - except Exception: - pass - return - - saw_finished = False - assert proc.stdout is not None - for raw in proc.stdout: - line = raw.decode(errors="replace").strip() - if not line: - continue try: - event = json.loads(line) - except Exception: - continue - ev_mod.append_event(run_id, event) - if event.get("type") == "run_finished": - saw_finished = True - - rc = proc.wait() - - if not saw_finished: - stderr_text = "" - try: - if proc.stderr is not None: - stderr_text = proc.stderr.read().decode(errors="replace") - except Exception: - pass - st = ev_mod.get(run_id) - cancelled = bool(st and st.cancelled) - if cancelled or rc < 0: - ev_mod.append_event( - run_id, - { - "type": "run_finished", - "status": "cancelled", - "error": "cancelled by user" if cancelled else f"runner killed (rc={rc})", - "outputs": {}, - "total_cost": 0.0, - }, - ) - else: + assert proc.stdin is not None + proc.stdin.write(json.dumps(payload).encode()) + proc.stdin.close() + except Exception as e: ev_mod.append_event( run_id, { "type": "run_finished", "status": "error", - "error": f"runner exited rc={rc}: {stderr_text[-1000:]}", + "error": f"failed to write to runner stdin: {e}", "outputs": {}, "total_cost": 0.0, }, ) + try: + proc.kill() + except Exception: + pass + return + + saw_finished = False + assert proc.stdout is not None + for raw in proc.stdout: + line = raw.decode(errors="replace").strip() + if not line: + continue + try: + event = json.loads(line) + except Exception: + continue + ev_mod.append_event(run_id, event) + if event.get("type") == "run_finished": + saw_finished = True + + rc = proc.wait() + + if not saw_finished: + stderr_text = "" + try: + if proc.stderr is not None: + stderr_text = proc.stderr.read().decode(errors="replace") + except Exception: + pass + st = ev_mod.get(run_id) + cancelled = bool(st and st.cancelled) + if cancelled or rc < 0: + ev_mod.append_event( + run_id, + { + "type": "run_finished", + "status": "cancelled", + "error": "cancelled by user" if cancelled else f"runner killed (rc={rc})", + "outputs": {}, + "total_cost": 0.0, + }, + ) + else: + ev_mod.append_event( + run_id, + { + "type": "run_finished", + "status": "error", + "error": f"runner exited rc={rc}: {stderr_text[-1000:]}", + "outputs": {}, + "total_cost": 0.0, + }, + ) + finally: + # Child has exited (proc.wait completed). Close the write end so the + # FD doesn't leak. The watchdog in the (already-dead) child can't + # observe this, but on parent SIGKILL the kernel does this for us. + try: + os.close(death_w) + except OSError: + pass def run_workflow_sync(