-
Notifications
You must be signed in to change notification settings - Fork 0
Supervise the RFC #122 bridge as a LaunchAgent #139
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| .PHONY: check fmt lint test clean install-server install-client uninstall dev venv restart logs | ||
| .PHONY: check fmt lint test clean install-server install-client uninstall dev venv restart logs install-bridge uninstall-bridge | ||
|
|
||
| # Canonical paths (override with matching AGENT_EVENT_BUS_* env vars) | ||
| LOG_FILE := $(or $(AGENT_EVENT_BUS_LOG),$(HOME)/.claude/contrib/agent-event-bus/agent-event-bus.log) | ||
|
|
@@ -96,10 +96,36 @@ install-client: | |
| @echo "Add to your shell profile (~/.zshrc, ~/.bashrc, or ~/.extra):" | ||
| @echo ' export AGENT_EVENT_BUS_URL="$(REMOTE_URL)"' | ||
|
|
||
| # Supervise the RFC #122 bridge (macOS only; idempotent, restarts on crash). | ||
| # Separate from install-server on purpose: the bridge is experimental, the bus | ||
| # is not, and a bus host does not have to run one. Requires the venv that | ||
| # install-server (or `make dev`) creates. | ||
| install-bridge: | ||
| @if [ "$$(uname)" != "Darwin" ]; then \ | ||
| echo "install-bridge is macOS-only (LaunchAgent)."; \ | ||
| echo "On Linux, run the bridge under your own supervisor:"; \ | ||
| echo " uv run agent-event-bus-bridge"; \ | ||
| exit 1; \ | ||
| fi | ||
| ./scripts/install-bridge-launchagent.sh | ||
|
|
||
| # Stop supervising the bridge. Leaves the bus, the database, and wake/ alone. | ||
| uninstall-bridge: | ||
| @if [ "$$(uname)" != "Darwin" ]; then \ | ||
| echo "uninstall-bridge is macOS-only (LaunchAgent)."; \ | ||
| exit 1; \ | ||
| fi | ||
| ./scripts/uninstall-bridge-launchagent.sh | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] |
||
|
|
||
| # Uninstall: service + CLI + MCP config | ||
| uninstall: | ||
| @echo "Uninstalling..." | ||
| @if [ "$$(uname)" = "Darwin" ]; then \ | ||
| if [ -f "$$HOME/Library/LaunchAgents/com.evansenter.agent-event-bus-bridge.plist" ]; then \ | ||
| echo "Removing bridge LaunchAgent first (KeepAlive would respawn it against a bus that is gone)..."; \ | ||
| ./scripts/uninstall-bridge-launchagent.sh; \ | ||
| echo ""; \ | ||
| fi; \ | ||
| ./scripts/uninstall-launchagent.sh; \ | ||
| else \ | ||
| ./scripts/uninstall-systemd.sh; \ | ||
|
|
@@ -125,7 +151,7 @@ restart: | |
| launchctl unload "$$PLIST" 2>/dev/null || true; \ | ||
| launchctl load "$$PLIST"; \ | ||
| sleep 1; \ | ||
| if launchctl list | grep -q "com.evansenter.agent-event-bus"; then \ | ||
| if launchctl list | grep -q "com.evansenter.agent-event-bus$$"; then \ | ||
| echo "Service restarted successfully"; \ | ||
| else \ | ||
| echo "Error: Service failed to start. Check $(ERR_FILE)"; \ | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,75 @@ | ||
| <?xml version="1.0" encoding="UTF-8"?> | ||
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||
| <plist version="1.0"> | ||
| <dict> | ||
| <key>Label</key> | ||
| <string>com.evansenter.agent-event-bus-bridge</string> | ||
|
|
||
| <!-- Runs as a module, matching the bus unit. bridge.py has the __main__ | ||
| guard, so this is equivalent to the agent-event-bus-bridge console | ||
| script without depending on the venv's bin/ being on PATH. --> | ||
| <key>ProgramArguments</key> | ||
| <array> | ||
| <string>__VENV_PYTHON__</string> | ||
| <string>-m</string> | ||
| <string>agent_event_bus.bridge</string> | ||
| </array> | ||
|
|
||
| <key>WorkingDirectory</key> | ||
| <string>__PROJECT_DIR__</string> | ||
|
|
||
| <key>EnvironmentVariables</key> | ||
| <dict> | ||
| <key>PATH</key> | ||
| <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string> | ||
| <key>PYTHONPATH</key> | ||
| <string>__PROJECT_DIR__/src</string> | ||
| <!-- Pinned rather than inherited. launchd does not run a shell, so a | ||
| profile that exports a different backend cannot reach this | ||
| process - but being explicit means the unit describes what runs | ||
| instead of leaving it to whatever the default happens to be. | ||
| tmux would additionally need wake/panes.json maintained by | ||
| something session-side; spool has no such dependency. --> | ||
| <key>AGENT_EVENT_BUS_BRIDGE_BACKEND</key> | ||
| <string>spool</string> | ||
| <!-- AGENT_EVENT_BUS_URL is deliberately NOT set: unset means the | ||
| bridge's own default (http://127.0.0.1:8080/mcp), which is | ||
| correct on the machine hosting the bus. Set it here only if the | ||
| bus moves off this box - and then the hook URL stops being | ||
| loopback, which makes AGENT_EVENT_BUS_BRIDGE_SECRET mandatory | ||
| (validate_config refuses an exposed listener without one). --> | ||
| </dict> | ||
|
|
||
| <key>RunAtLoad</key> | ||
| <true/> | ||
|
|
||
| <!-- Restart on crash. Safe with the singleton lock: flock is released | ||
| when the dead process's fd closes, so the replacement acquires it | ||
| cleanly rather than refusing to start. The startup sweep also | ||
| reclaims the webhook row the dead instance left registered. --> | ||
| <key>KeepAlive</key> | ||
| <true/> | ||
|
|
||
| <!-- launchd's default respawn throttle, stated explicitly because it is | ||
| load-bearing here: it bounds how fast a crash loop rewrites the log | ||
| below, and how fast a failing bridge re-registers against the bus. --> | ||
| <key>ThrottleInterval</key> | ||
| <integer>10</integer> | ||
|
|
||
| <!-- The bridge logs via logging.basicConfig, which is stderr-only, so | ||
| this file IS the bridge's log - there is no separate append-mode | ||
| file logger like the bus has. launchd TRUNCATES these on every | ||
| process start, so a crash loop overwrites the evidence of earlier | ||
| iterations; ThrottleInterval above is what keeps the surviving | ||
| window useful. Giving the bridge its own append-mode file handler | ||
| is the follow-up that removes this caveat. --> | ||
| <key>StandardOutPath</key> | ||
| <string>__BRIDGE_LOG_FILE__</string> | ||
|
|
||
| <key>StandardErrorPath</key> | ||
| <string>__BRIDGE_ERR_FILE__</string> | ||
|
|
||
| <key>ProcessType</key> | ||
| <string>Background</string> | ||
| </dict> | ||
| </plist> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| #!/bin/bash | ||
| # Install the RFC #122 bridge as a macOS LaunchAgent (auto-starts on login, | ||
| # restarts on crash). The bus has its own unit - this supervises only the | ||
| # webhook->injection bridge, which until now had no install target at all. | ||
|
|
||
| set -e | ||
|
|
||
| SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | ||
| PROJECT_DIR="$(dirname "$SCRIPT_DIR")" | ||
| VENV_PYTHON="$PROJECT_DIR/.venv/bin/python" | ||
| PLIST_TEMPLATE="$SCRIPT_DIR/com.evansenter.agent-event-bus-bridge.plist" | ||
| PLIST_DEST="$HOME/Library/LaunchAgents/com.evansenter.agent-event-bus-bridge.plist" | ||
| LABEL="com.evansenter.agent-event-bus-bridge" | ||
|
|
||
| DATA_DIR="$HOME/.claude/contrib/agent-event-bus" | ||
| BRIDGE_LOG_FILE="${AGENT_EVENT_BUS_BRIDGE_LOG:-$DATA_DIR/agent-event-bus-bridge.log}" | ||
| BRIDGE_ERR_FILE="${AGENT_EVENT_BUS_BRIDGE_ERR:-$DATA_DIR/agent-event-bus-bridge.err}" | ||
|
|
||
| if [[ ! -f "$VENV_PYTHON" ]]; then | ||
| echo "Error: Virtual environment not found at $PROJECT_DIR/.venv" | ||
| echo "Run: make install-server (or: uv sync)" | ||
| exit 1 | ||
| fi | ||
|
|
||
| # Preflight the import rather than running `uv sync` here. A stale venv missing | ||
| # a bridge dependency would otherwise become an import crash-loop under | ||
| # KeepAlive - the one case where the log-truncation caveat bites hardest, since | ||
| # every respawn wipes the previous traceback. Checking beats syncing: `uv sync | ||
| # --no-dev` (what install-server runs) would silently strip pytest/ruff from a | ||
| # venv someone just set up with `make dev`. | ||
| if ! PYTHONPATH="$PROJECT_DIR/src" "$VENV_PYTHON" -c "import agent_event_bus.bridge" 2>/tmp/bridge-import-check.$$; then | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The preflight imports In practice |
||
| echo "Error: the venv cannot import agent_event_bus.bridge:" | ||
| sed 's/^/ /' /tmp/bridge-import-check.$$ | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion]
|
||
| rm -f /tmp/bridge-import-check.$$ | ||
| echo "Run: make dev (or: uv sync)" | ||
| exit 1 | ||
| fi | ||
| rm -f /tmp/bridge-import-check.$$ | ||
|
|
||
| # The bridge is useless without a bus to register against. It would retry with | ||
| # backoff rather than die (register_with_retry), so this is a warning and not a | ||
| # hard failure - but starting a bridge on a box with no bus is almost always a | ||
| # mistake worth naming at install time rather than discovering in the log. | ||
| if ! launchctl list | grep -q "com.evansenter.agent-event-bus$"; then | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Important] This check is correctly anchored with
Fails when: the bridge LaunchAgent is loaded and the bus Fix is the |
||
| echo "Warning: the bus LaunchAgent does not appear to be loaded." | ||
| echo " The bridge will start and retry registration with backoff," | ||
| echo " but it cannot deliver anything until the bus is up." | ||
| echo " Install it with: make install-server" | ||
| echo "" | ||
| fi | ||
|
|
||
| mkdir -p "$HOME/Library/LaunchAgents" | ||
| mkdir -p "$DATA_DIR" | ||
|
|
||
| if launchctl list | grep -q "$LABEL$"; then | ||
| echo "Stopping existing bridge service..." | ||
| launchctl unload "$PLIST_DEST" 2>/dev/null || true | ||
| fi | ||
|
|
||
| echo "Installing bridge LaunchAgent..." | ||
| sed -e "s|__VENV_PYTHON__|$VENV_PYTHON|g" \ | ||
| -e "s|__PROJECT_DIR__|$PROJECT_DIR|g" \ | ||
| -e "s|__BRIDGE_LOG_FILE__|$BRIDGE_LOG_FILE|g" \ | ||
| -e "s|__BRIDGE_ERR_FILE__|$BRIDGE_ERR_FILE|g" \ | ||
| "$PLIST_TEMPLATE" > "$PLIST_DEST" | ||
|
|
||
| echo "Starting bridge..." | ||
| launchctl load "$PLIST_DEST" | ||
|
|
||
| # POLL rather than sleep a fixed interval. On a re-install over a live bridge, | ||
| # `launchctl unload` returns as soon as SIGTERM is delivered, so the outgoing | ||
| # process can still hold the singleton flock and port 8082 when the replacement | ||
| # starts. The replacement then exits on the lock - correctly; that ordering is | ||
| # what keeps the outgoing instance's unregister from racing a new registration | ||
| # - and KeepAlive only retries after ThrottleInterval (~10s). A flat 2s probe | ||
| # lands inside that window and reports a perfectly healthy idempotent | ||
| # re-install as a failure. 20s covers the throttle with room to spare. | ||
| HEALTH="" | ||
| for _ in $(seq 1 40); do | ||
| HEALTH="$(curl -fsS --max-time 2 http://127.0.0.1:8082/health 2>/dev/null || true)" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] The poll breaks on the first successful
End state is still correct ( |
||
| [[ -n "$HEALTH" ]] && break | ||
| sleep 0.5 | ||
| done | ||
|
|
||
| if ! launchctl list | grep -q "$LABEL$"; then | ||
| echo "Error: bridge failed to start. Check $BRIDGE_ERR_FILE" | ||
| exit 1 | ||
| fi | ||
|
|
||
| echo "" | ||
| echo "Bridge installed and running." | ||
| # Labelled by what each file actually receives: bridge.py logs via | ||
| # logging.basicConfig with no stream=, which defaults to STDERR, so every | ||
| # bridge record lands in the .err file. The .log file gets uvicorn's access | ||
| # lines only. Following a "Logs:" pointer at the .log would show a reader no | ||
| # bridge messages at all. | ||
| echo " Bridge log (records, warnings, errors): $BRIDGE_ERR_FILE" | ||
| echo " Access log (uvicorn requests): $BRIDGE_LOG_FILE" | ||
| echo " Health: curl -s http://127.0.0.1:8082/health" | ||
| echo "" | ||
|
|
||
| # registered:false is not a failure here - it means registration is still | ||
| # backing off (bus not up yet), which resolves on its own. Report what we see | ||
| # rather than asserting success we have not confirmed. | ||
| if [[ -n "$HEALTH" ]]; then | ||
| echo " /health -> $HEALTH" | ||
| case "$HEALTH" in | ||
| *'"registered":true'*|*'"registered": true'*) | ||
| echo " Registered on the bus." ;; | ||
| *) | ||
| echo " Not registered yet - retrying with backoff (is the bus up?)." ;; | ||
| esac | ||
| else | ||
| echo " /health did not answer within 20s; check $BRIDGE_ERR_FILE." | ||
| fi | ||
|
|
||
| echo "" | ||
| echo "NOTE: nothing drains wake/<session>.jsonl yet (agent-event-bus#134)." | ||
| echo " The bridge will spool actionable DMs durably, but no session is" | ||
| echo " woken by them until a drain hook exists." | ||
| echo "" | ||
| echo "To uninstall: $SCRIPT_DIR/uninstall-bridge-launchagent.sh" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| #!/bin/bash | ||
| # Remove the bridge LaunchAgent. Leaves the bus, the database, and the wake | ||
| # directory alone - this only stops supervising the bridge. | ||
|
|
||
| set -e | ||
|
|
||
| PLIST_DEST="$HOME/Library/LaunchAgents/com.evansenter.agent-event-bus-bridge.plist" | ||
| LABEL="com.evansenter.agent-event-bus-bridge" | ||
|
|
||
| if launchctl list | grep -q "$LABEL$"; then | ||
| echo "Stopping bridge..." | ||
| # SIGTERM via unload, so the shutdown path runs: the lifespan's shielded | ||
| # stop-join-unregister removes the webhook row rather than leaving the bus | ||
| # POSTing at a dead port. | ||
| launchctl unload "$PLIST_DEST" 2>/dev/null || true | ||
| else | ||
| echo "Bridge service not loaded." | ||
| fi | ||
|
|
||
| if [[ -f "$PLIST_DEST" ]]; then | ||
| rm "$PLIST_DEST" | ||
| echo "Removed $PLIST_DEST" | ||
| fi | ||
|
|
||
| echo "" | ||
| echo "Bridge uninstalled. The bus, the database, and wake/ are untouched." | ||
| echo "Confirm the webhook row is gone with: agent-event-bus-cli webhook list" | ||
| echo "" | ||
| echo "The wake directory is transient and safe to clear BY HAND once no" | ||
| echo "bridge is running (clearing it under a live bridge orphans its" | ||
| echo "singleton lock inode):" | ||
| echo " ~/.claude/contrib/agent-event-bus/wake/" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Suggestion]
_BRIDGE_LOGand_BRIDGE_ERRare listed alongside_BRIDGE_PORT,_BRIDGE_BACKEND, and the rest, but they are a different kind of variable: every other_BRIDGE_*name is read bybridge.pyat runtime, whereas these two are read only byscripts/install-bridge-launchagent.sh:16-17and baked into the plist at install time. Setting them in the environment of a running bridge does nothing.The Operations section already carries exactly this warning for the bus
_LOG/_ERRpair (they must be in the environment ofmake install-serveritself); the bridge pair needs the same note againstmake install-bridge.Related: the installer runs
mkdir -p "$DATA_DIR"only, so anAGENT_EVENT_BUS_BRIDGE_LOGpointing outside the data directory leaves launchd unable to openStandardOutPath. Amkdir -p "$(dirname "$BRIDGE_LOG_FILE")"would cover it.