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

+7 -6
View File
@@ -194,7 +194,7 @@ def test_c07_watchdog_spawns_daemon_with_workdir_and_log(mam_sandbox, mode):
res = _run_helper(
mam_sandbox,
'start_watchdog job1 "{}"'.format(workdir),
env={"MAM_DELEGATION_PY": mode},
env={"MAM_DELEGATION_PY": mode, "MAM_NO_SUBSCRIBER_DAEMON": "0"},
)
assert res.returncode == 0, res.stderr
pid = res.stdout.strip()
@@ -219,12 +219,12 @@ def test_c08_watchdog_second_call_reuses_running_daemon(mam_sandbox, mode):
spawned = []
try:
first = _run_helper(mam_sandbox, 'start_watchdog job1 "{}"'.format(workdir),
env={"MAM_DELEGATION_PY": mode})
env={"MAM_DELEGATION_PY": mode, "MAM_NO_SUBSCRIBER_DAEMON": "0"})
pid1 = first.stdout.strip()
spawned.append(pid1)
assert _alive(pid1)
second = _run_helper(mam_sandbox, 'start_watchdog job1 "{}"'.format(workdir),
env={"MAM_DELEGATION_PY": mode})
env={"MAM_DELEGATION_PY": mode, "MAM_NO_SUBSCRIBER_DAEMON": "0"})
assert second.returncode == 0, second.stderr
assert second.stdout.strip() == pid1, (first.stdout, second.stdout)
matches = subprocess.run(
@@ -259,9 +259,9 @@ def test_c09_start_watchdog_and_ensure_subscriber_daemon_agree(mam_sandbox, mode
spawned = []
try:
a = _run_helper(mam_sandbox, 'start_watchdog job1 "{}"'.format(workdir),
env={"MAM_DELEGATION_PY": mode})
env={"MAM_DELEGATION_PY": mode, "MAM_NO_SUBSCRIBER_DAEMON": "0"})
b = _run_helper(mam_sandbox, 'ensure_subscriber_daemon job1 "{}"'.format(workdir),
env={"MAM_DELEGATION_PY": mode})
env={"MAM_DELEGATION_PY": mode, "MAM_NO_SUBSCRIBER_DAEMON": "0"})
spawned += [a.stdout.strip(), b.stdout.strip()]
assert a.returncode == b.returncode == 0, (a.stderr, b.stderr)
assert a.stdout.strip() == b.stdout.strip(), (a.stdout, b.stdout)
@@ -649,10 +649,11 @@ def test_daemon_missing_monitor_raises_with_the_legacy_path(mam_sandbox):
assert exc.value.args[0] == str(daemon.monitor_script_path(str(workdir)))
def test_daemon_spawn_dedup_and_survives_parent_exit(mam_sandbox):
def test_daemon_spawn_dedup_and_survives_parent_exit(mam_sandbox, monkeypatch):
"""The daemon must outlive the shell that started it and never double-spawn."""
from lib_py.delegation import daemon
monkeypatch.delenv("MAM_NO_SUBSCRIBER_DAEMON", raising=False) # issue #19 sandbox default
monitor = _install_fake_monitor(mam_sandbox)
workdir = str(mam_sandbox)
spawned = []