fix(dev): reap orphaned agent processes on dev/staging exit

Ctrl+C on `just dev`/`just staging` tears down the Tauri app before its
in-process system sweep can finish, so agent workers spawned in their own
process groups survive as orphans and accumulate across sessions.

Add an EXIT trap that reaps this instance's agents via the PID-file receipts
the desktop already writes (one PGID per agent under
`<app-data>/agents/agent-pids/`). The app-data dir is keyed by the bundle
identifier, so the cleanup is scoped exactly to the instance that ran the
recipe — the main checkout never reaps a worktree's agents, or vice versa.

PID files are matched instead of the `SPROUT_MANAGED_AGENT` env marker because
macOS `pkill -f` matches only argv, not the environment, so an env-marker match
would silently reap nothing.

Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
2026-06-10 15:18:05 -04:00
parent e5f0c32648
commit c8270dc429
2 changed files with 67 additions and 0 deletions
+8
View File
@@ -277,6 +277,10 @@ dev *ARGS: _ensure-sidecar-stubs
cd {{desktop_dir}}
[[ -d node_modules ]] || pnpm install
source ../scripts/instance-env.sh
# Ctrl+C kills the Tauri app before its in-process sweep finishes, leaking
# agent workers. Reap this instance's agents on exit as a backstop.
INSTANCE_ID=$(node -e "console.log(JSON.parse(process.env.SPROUT_TAURI_CONFIG).identifier)")
trap '../scripts/cleanup-instance-agents.sh "$INSTANCE_ID"' EXIT
echo "Starting on Vite port ${SPROUT_VITE_PORT}, relay ${SPROUT_RELAY_URL}"
pnpm exec tauri dev --features mesh-llm --config "$SPROUT_TAURI_CONFIG" {{ARGS}}
@@ -294,6 +298,10 @@ staging *ARGS: _ensure-sidecar-stubs
cd {{desktop_dir}}
source ../scripts/instance-env.sh
export SPROUT_RELAY_URL="wss://sprout-oss.stage.blox.sqprod.co"
# Ctrl+C kills the Tauri app before its in-process sweep finishes, leaking
# agent workers. Reap this instance's agents on exit as a backstop.
INSTANCE_ID=$(node -e "console.log(JSON.parse(process.env.SPROUT_TAURI_CONFIG).identifier)")
trap '../scripts/cleanup-instance-agents.sh "$INSTANCE_ID"' EXIT
echo "Starting staging on Vite port ${SPROUT_VITE_PORT}, relay ${SPROUT_RELAY_URL}"
pnpm exec tauri dev --features mesh-llm --config "$SPROUT_TAURI_CONFIG" {{ARGS}}
+59
View File
@@ -0,0 +1,59 @@
#!/usr/bin/env bash
# Reap the agent processes belonging to a single desktop instance.
#
# `tauri dev` Ctrl+C tears down the Rust app before its in-process system sweep
# can finish, so agent workers (goose, sprout-agent, ...) it spawned in their
# own process groups survive as orphans. This script is the shell-side backstop:
# run it from an EXIT trap in the `just dev`/`just staging` recipes.
#
# It reads the PID-file receipts the desktop already writes — one file per agent
# under `<app-data>/agents/agent-pids/<pubkey>.pid`, each containing the agent's
# PGID (agents are spawned with `process_group(0)`, so PID == PGID). Killing by
# PGID reaches the whole agent subtree. We deliberately do NOT match the
# `SPROUT_MANAGED_AGENT` env var from the shell: on macOS `pkill -f` matches only
# argv, not the environment, so an env-marker match silently reaps nothing.
#
# Scoping is exact because the app-data directory is keyed by the instance's
# bundle identifier, so this only ever touches the receipts this instance wrote
# (the main checkout never reaps a worktree's agents, or vice versa).
#
# Usage: cleanup-instance-agents.sh <instance-id>
# <instance-id> is the desktop bundle identifier, e.g. `xyz.block.sprout.app.dev`
# (main checkout) or `xyz.block.sprout.app.dev.my-branch` (a worktree).
set -euo pipefail
instance_id="${1:-}"
if [[ -z "$instance_id" ]]; then
echo "cleanup-instance-agents: no instance id given, skipping" >&2
exit 0
fi
case "$(uname -s)" in
Darwin) app_data="$HOME/Library/Application Support/$instance_id" ;;
*) app_data="${XDG_DATA_HOME:-$HOME/.local/share}/$instance_id" ;;
esac
pids_dir="$app_data/agents/agent-pids"
[[ -d "$pids_dir" ]] || exit 0
shopt -s nullglob
pgids=()
for pid_file in "$pids_dir"/*.pid; do
pgid="$(<"$pid_file")"
pgid="${pgid//[$'\t\r\n ']/}"
[[ "$pgid" =~ ^[0-9]+$ ]] || continue
pgids+=("$pgid")
done
[[ ${#pgids[@]} -gt 0 ]] || exit 0
# SIGTERM the whole group first, give it a moment, then SIGKILL survivors.
# `kill -- -<pgid>` targets the process group. Failures are expected and fine
# (already-dead group, recycled PGID owned by someone else we can't signal).
for pgid in "${pgids[@]}"; do
kill -TERM -- "-$pgid" 2>/dev/null || true
done
sleep 0.2
for pgid in "${pgids[@]}"; do
kill -KILL -- "-$pgid" 2>/dev/null || true
done