fix(issue-19): bound the subscriber daemon to its workspace, forward SIGTERM, keep sandboxes from leaking it

create_session.sh detaches `reconcile.sh --subscribe --idle-timeout 0` per
workspace; test sandboxes never reaped it and the daemon neither noticed its
workspace vanish nor honoured SIGTERM (bash deferred the signal behind the
foreground python child). 202 orphans were found holding public-broker
wildcard subscriptions for deleted pytest tmpdirs.

- daemon.py / lib.sh: MAM_NO_SUBSCRIBER_DAEMON=1 (literal) skips the spawn but
  still reports a running daemon; cli prints a stderr note, stdout stays empty.
- reconcile.sh: python subscriber runs as a waited background child with a
  TERM/INT trap (exit 143); the MQTT loop exits when the workspace dir is gone
  or .mam/monitor.lock is no longer the held inode; SIGTERM/SIGINT handled via
  the stop event so loop_stop/disconnect run; the polling fallback checks the
  same and sleeps in a waited child.
- conftest: sandbox default MAM_NO_SUBSCRIBER_DAEMON=1, per-sandbox reaper
  finalizer, session-scope leak guard over the pytest basetemp.
- tests: 11 lifecycle tests (tests/test_issue19_subscriber_daemon_lifecycle.py);
  phase-1 watchdog/daemon characterization tests opt back in.
- docs: monitor SKILL.md lifecycle contract + pitfalls, create SKILL.md note.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
This commit is contained in:
GodopuandClaude Fable 5.1 committed 2026-09-13 15:34:56 +09:00
1 parent 8e7282c948
commit ce315d8c5e
9 files changed
+536 -12

No files matched your search

@@ -72,12 +72,27 @@ if [ "$SUBSCRIBE" = "1" ]; then
# no single interpreter needs both paho and PyYAML (review items 4/5/6).
PYBIN="$(_delegate_py_bin)"
# Issue #19: the subscriber runs as a background child and this shell waits
# on it, so SIGTERM/SIGINT reach the daemon promptly. With the child in the
# foreground bash deferred the signal until the child exited on its own,
# which for --idle-timeout 0 was never - only SIGKILL worked.
_sub_pid=""
_sub_stop() {
trap - TERM INT
if [ -n "$_sub_pid" ]; then
kill -TERM "$_sub_pid" 2>/dev/null || true
wait "$_sub_pid" 2>/dev/null || true
fi
exit 143
}
trap _sub_stop TERM INT
# The MQTT subscribe loop exits 3 to signal "broker unavailable → poll instead".
set +e
YAML_PATH="$AGENT_SESSIONS_YAML" HOME_DIR="$HOME_DIR" CLAUDE_PROJECT_DIR="$CLAUDE_PROJECT_DIR" LOCAL_BIN="$LOCAL_BIN" \
WORKSPACE_ROOT="$WORKSPACE_ROOT" SUB_TIMEOUT="$SUB_TIMEOUT" SUB_IDLE_TIMEOUT="$SUB_IDLE_TIMEOUT" \
SKILLS_DIR="$SKILLS_DIR" LIB_SH="$LIB_SH" \
"$PYBIN" - <<'PYEOF'
"$PYBIN" - <<'PYEOF' &
import os, sys, json, time, subprocess
lib_sh = os.environ.get('LIB_SH', '')
@@ -100,6 +115,27 @@ except BlockingIOError:
except Exception as e:
print(f"MQTT Monitor: failed to acquire monitor lock ({e}). Exiting.", flush=True)
sys.exit(1)
lock_ino = os.fstat(lock_file.fileno()).st_ino
def _workspace_gone():
"""Issue #19: the daemon serves exactly one workspace. Once that workspace
is deleted (a purged sandbox, an rm -rf'd project) or its `.mam/monitor.lock`
is no longer the inode we hold (a re-created `.mam/`, where a fresh daemon
could now take a new lock), there is nothing left to reconcile - exit
instead of holding a broker subscription forever."""
try:
root = os.path.abspath(workspace_root) if workspace_root else os.getcwd()
except OSError:
return "cwd no longer exists"
if not os.path.isdir(root):
return f"workspace {root} no longer exists"
try:
if os.stat(lock_file_path).st_ino != lock_ino:
return f"{lock_file_path} was replaced"
except OSError:
return f"{lock_file_path} no longer exists"
return None
# Locate skills/multi-agent-mux-delegate-job/scripts to import mqtt_common — relative first, then
# an upward walk from cwd. No hardcoded absolute path (review item 6).
@@ -271,11 +307,28 @@ if not state['connected']:
client.loop_stop()
sys.exit(3)
import threading
import threading, signal
stop_event = threading.Event()
state['stop_signal'] = None
def _on_stop_signal(signum, _frame):
state['stop_signal'] = signum
stop_event.set()
signal.signal(signal.SIGTERM, _on_stop_signal)
signal.signal(signal.SIGINT, _on_stop_signal)
start = time.time()
try:
while True:
if state['stop_signal'] is not None:
print(f"MQTT Monitor: signal {state['stop_signal']} received, exiting", flush=True)
break
gone = _workspace_gone()
if gone:
print(f"MQTT Monitor: {gone}; exiting", flush=True)
break
now = time.time()
wall_left = (timeout - (now - start)) if timeout else None
idle_left = (idle_timeout - (now - state['last_msg'])) if idle_timeout else None
@@ -302,19 +355,30 @@ finally:
pass
sys.exit(0)
PYEOF
_sub_pid=$!
wait "$_sub_pid"
sub_rc=$?
_sub_pid=""
set -e
if [ "$sub_rc" = "3" ]; then
echo "MQTT Monitor: broker unavailable — falling back to polling (interval ${POLL_INTERVAL}s)" >&2
_self="$SKILLS_DIR/multi-agent-mux-monitor/scripts/reconcile.sh"
_start=$(date +%s)
# Issue #19: same lifecycle as the MQTT loop - stop when the workspace is
# gone, and sleep in a waited child so SIGTERM/SIGINT are not deferred.
trap 'trap - TERM INT; exit 143' TERM INT
while :; do
if [ ! -d "$WORKSPACE_ROOT" ] || [ ! -f "$WORKSPACE_ROOT/.mam/monitor.lock" ]; then
echo "MQTT Monitor: workspace $WORKSPACE_ROOT or its monitor.lock is gone; exiting" >&2
break
fi
bash "$_self" --once --emit-diff >/dev/null 2>&1 || true
if [ "$SUB_TIMEOUT" != "0" ] && [ "$(( $(date +%s) - _start ))" -ge "$SUB_TIMEOUT" ]; then
break
fi
sleep "$POLL_INTERVAL"
sleep "$POLL_INTERVAL" &
wait $! || true
done
fi
exit 0