From 8f9745b1286f35c1fd6a6d05eba3ff77ddc421cf Mon Sep 17 00:00:00 2001 From: Godopu Date: Sun, 13 Sep 2026 16:35:23 +0900 Subject: [PATCH] fix(issue-19): match the daemon's whole script path so spaced sandbox paths are reaped Review round 2 (planner-reviewer-codex-01, NOT PASS P2): - conftest `_is_subscriber_daemon_argv`: `ps -o args=` joins argv unquoted, so the script path is taken from the shell word up to the first `/reconcile.sh --subscribe` instead of as one whitespace token. A sandbox or --basetemp path with a space no longer hides its daemon from the fixture reaper and the session leak guard. A candidate with whitespace counts only when it names an existing file, so a bash wrapper under the root whose later argv carries the words is still excluded. - tests: a whitespace-path selector regression and an owned live-process cleanup check (both fail on c096cb4); the R-2 test also pins the bash wrapper exclusion. - docs: the polling fallback's signal handling is stated precisely in monitor SKILL.md and VERSIONS.md (a running --once pass finishes first, the interval's sleep child ends on its own); create SKILL.md says an already-running daemon's PID is still reported under the opt-out; VERSIONS selector wording and test count (14). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe --- .../skills/multi-agent-mux-create/SKILL.md | 2 +- .../skills/multi-agent-mux-monitor/SKILL.md | 5 +- VERSIONS.md | 6 +- tests/conftest.py | 28 +++++--- ...est_issue19_subscriber_daemon_lifecycle.py | 68 ++++++++++++++++++- 5 files changed, 95 insertions(+), 14 deletions(-) diff --git a/.agents/skills/multi-agent-mux-create/SKILL.md b/.agents/skills/multi-agent-mux-create/SKILL.md index 79df6b2..608d7c9 100644 --- a/.agents/skills/multi-agent-mux-create/SKILL.md +++ b/.agents/skills/multi-agent-mux-create/SKILL.md @@ -68,7 +68,7 @@ If any check fails β†’ abort with a non-zero exit and report the reason (automat When running multiple agent sessions alongside other workflows (e.g., cmux, background workers, manual herdr sessions), sharing the default herdr server can lead to session name conflicts, monitoring clutter, and accidental destruction of user sessions via global commands. To prevent this, you can run this skill inside an **isolated herdr session** using the `HERDR_SESSION_NAME` environment variable or the `--herdr-session ` flag (opt-in; alias: `--herdr-server`; legacy env alias: `HERDR_SERVER_NAME`). -On success the script also starts the per-workspace monitor daemon through `start_watchdog` (`reconcile.sh --subscribe --idle-timeout 0`, one per workspace, see the lifecycle contract in [multi-agent-mux-monitor/SKILL.md](../multi-agent-mux-monitor/SKILL.md)). Set `MAM_NO_SUBSCRIBER_DAEMON=1` to skip that spawn when the monitor is supervised elsewhere or the workspace is ephemeral (Issue #19); the `watchdog PID:` line is then empty. +On success the script also starts the per-workspace monitor daemon through `start_watchdog` (`reconcile.sh --subscribe --idle-timeout 0`, one per workspace, see the lifecycle contract in [multi-agent-mux-monitor/SKILL.md](../multi-agent-mux-monitor/SKILL.md)). Set `MAM_NO_SUBSCRIBER_DAEMON=1` to skip that spawn when the monitor is supervised elsewhere or the workspace is ephemeral (Issue #19); the `watchdog PID:` line is then empty unless a daemon already runs for the workspace, whose PID is still reported. Additionally, you can specify `--herdr-workspace ` (default: workspace slug without `mam-` prefix) to name and group the agent panes inside a dedicated Herdr workspace tab in the Herdr runtime (`herdr workspace create --label` / `rename`) as well as recording it in `.mam/agent-sessions.yaml`. Note that `--herdr-workspace` configures the workspace tab label within the session, whereas `--herdr-session` selects the daemon socket itself. diff --git a/.agents/skills/multi-agent-mux-monitor/SKILL.md b/.agents/skills/multi-agent-mux-monitor/SKILL.md index dd7e7b1..45159e3 100644 --- a/.agents/skills/multi-agent-mux-monitor/SKILL.md +++ b/.agents/skills/multi-agent-mux-monitor/SKILL.md @@ -84,7 +84,10 @@ per workspace through `lib.sh::start_watchdog` (`lib_py.delegation.daemon`). Tha the lock (the python subscriber released it on exit), so while polling it does not exclude a second daemon, as before; - **stops promptly on `SIGTERM`/`SIGINT`** β€” the bash wrapper forwards the signal to the - python subscriber and exits 143. `SIGKILL` is never required; + python subscriber and exits 143. In the broker-down polling fallback the signal cuts the + interval wait short, but a reconcile pass (`--once`) already running finishes first, and + the interval's `sleep` child is left to end on its own (≀ `RECONCILE_POLL_INTERVAL`). + `SIGKILL` is never required; - is **not started when `MAM_NO_SUBSCRIBER_DAEMON=1`** β€” the literal `1` only. Use it when the monitor is supervised elsewhere; a daemon that already runs is still reported. Test sandboxes set it by default and additionally reap any daemon anchored in the sandbox. diff --git a/VERSIONS.md b/VERSIONS.md index a5a83af..214d17c 100644 --- a/VERSIONS.md +++ b/VERSIONS.md @@ -44,11 +44,11 @@ #### 🌟 μ£Όμš” κ°œμ„  사항 1. **`MAM_NO_SUBSCRIBER_DAEMON=1` μ˜΅νŠΈμ•„μ›ƒ (`lib_py/delegation/daemon.py`, `lib.sh: _start_watchdog_legacy`)**: λ¦¬ν„°λŸ΄ `1` 일 λ•Œλ§Œ 데λͺ¬μ„ μƒˆλ‘œ λ„μš°μ§€ μ•ŠλŠ”λ‹€. 이미 λ„λŠ” 데λͺ¬μ˜ PID λŠ” κ·ΈλŒ€λ‘œ λ³΄κ³ ν•˜λ―€λ‘œ `start_watchdog`/`ensure_subscriber_daemon` 의 dedup 계약은 μœ μ§€λ˜κ³ , 미기동 μ‹œ stderr 에 ν•œ 쀄 남긴닀(stdout 은 비움). ν…ŒμŠ€νŠΈ μƒŒλ“œλ°•μŠ€(`mam_sandbox`)κ°€ 기본으둜 켜고, 데λͺ¬ 자체λ₯Ό κ²€μ¦ν•˜λŠ” ν…ŒμŠ€νŠΈλ§Œ `0`/μ‚­μ œλ‘œ λ˜λŒλ¦°λ‹€. 2. **데λͺ¬ 자기 μ’…λ£Œ (`reconcile.sh --subscribe`)**: MQTT λ£¨ν”„λŠ” 5초 μ£ΌκΈ° wake λ§ˆλ‹€ μ›Œν¬μŠ€νŽ˜μ΄μŠ€ 디렉터리 μ‘΄μž¬μ™€ `.mam/monitor.lock` 의 `(st_dev, st_ino)` κ°€ μžμ‹ μ΄ μž‘μ€ 것과 같은지 ν™•μΈν•˜κ³ , μ‚¬λΌμ‘Œκ±°λ‚˜ ꡐ체됐으면(`.mam/` μž¬μƒμ„± β†’ μƒˆ 데λͺ¬μ΄ μƒˆ lock 을 μž‘μ„ 수 μžˆλŠ” μƒνƒœ) 정리 ν›„ rc 0 으둜 λλ‚œλ‹€. 브둜컀 λΆˆκ°€ 폴링 폴백은 λ§€ 회 디렉터리와 lock 파일의 **쑴재만** ν™•μΈν•œλ‹€(python μžμ‹μ΄ λλ‚˜λ©° lock 을 λ†“μ•˜μœΌλ―€λ‘œ 폴백 쀑 두 번째 데λͺ¬μ„ λ°°μ œν•˜μ§€λŠ” λͺ»ν•œλ‹€ β€” μ’…μ „ λ™μž‘). μ‚­μ œλœ pytest μž„μ‹œ λ””λ ‰ν„°λ¦¬λ‚˜ `rm -rf` 된 ν”„λ‘œμ νŠΈλ₯Ό 바라보며 곡개 브둜컀 ꡬ독을 μ˜μ›νžˆ μœ μ§€ν•˜λ˜ 데λͺ¬μ΄ 사라진닀. -3. **`SIGTERM`/`SIGINT` μ¦‰μ‹œ 전달 (`reconcile.sh`)**: python κ΅¬λ…μžλ₯Ό λ°±κ·ΈλΌμš΄λ“œ μžμ‹μœΌλ‘œ 두고 bash κ°€ `wait` ν•˜λ„λ‘ λ°”κΏ” μ‹œκ·Έλ„μ΄ 미뀄지지 μ•ŠλŠ”λ‹€(μ’…μ „μ—” ν¬κ·ΈλΌμš΄λ“œ μžμ‹μ΄ 끝날 λ•ŒκΉŒμ§€ bash κ°€ μ‹œκ·Έλ„μ„ 보λ₯˜ν•΄ `SIGKILL` 만 ν†΅ν–ˆλ‹€). 트랩이 μžμ‹μ— `SIGTERM` 을 μ „λ‹¬ν•˜κ³  rc 143 으둜 λλ‚˜λ©°, python μͺ½μ€ ν•Έλ“€λŸ¬λ‘œ 이벀트λ₯Ό κΉ¨μ›Œ `loop_stop`/`disconnect` λ₯Ό κ±°μΉœλ‹€. 폴백 λ£¨ν”„μ˜ `sleep` 도 `& wait` 둜 λ°”κΏ” μ¦‰μ‹œ μ€‘λ‹¨λœλ‹€. -4. **μƒŒλ“œλ°•μŠ€ νšŒμˆ˜Β·νšŒκ·€ κ°€λ“œ (`tests/conftest.py`)**: `mam_sandbox` finalizer κ°€ argv ν˜•νƒœ(`bash|sh <μƒŒλ“œλ°•μŠ€>/…/reconcile.sh --subscribe`)둜 μ„ μ •ν•œ 데λͺ¬κ³Ό κ·Έ μžμ‹μ„ `SIGTERM`β†’`SIGKILL` 둜 νšŒμˆ˜ν•œλ‹€(λΈŒλ¦¬ν”„λ₯Ό argv 에 싀은 λž˜νΌλ‚˜ 접두 좩돌 ν˜•μ œ λ£¨νŠΈλŠ” μ„ μ •λ˜μ§€ μ•ŠμŒ). μ„Έμ…˜ μŠ€μ½”ν”„ `subscriber_daemon_leak_guard` λŠ” μŠ€μœ„νŠΈ μ’…λ£Œ μ‹œ basetemp μ•„λž˜ 데λͺ¬μ΄ 남아 있으면 νšŒμˆ˜ν•œ λ’€ μ‹€νŒ¨μ‹œν‚¨λ‹€(#15 λ ˆμ§€μŠ€νŠΈλ¦¬ κ°€λ“œμ™€ 같은 방식). +3. **`SIGTERM`/`SIGINT` μ¦‰μ‹œ 전달 (`reconcile.sh`)**: python κ΅¬λ…μžλ₯Ό λ°±κ·ΈλΌμš΄λ“œ μžμ‹μœΌλ‘œ 두고 bash κ°€ `wait` ν•˜λ„λ‘ λ°”κΏ” μ‹œκ·Έλ„μ΄ 미뀄지지 μ•ŠλŠ”λ‹€(μ’…μ „μ—” ν¬κ·ΈλΌμš΄λ“œ μžμ‹μ΄ 끝날 λ•ŒκΉŒμ§€ bash κ°€ μ‹œκ·Έλ„μ„ 보λ₯˜ν•΄ `SIGKILL` 만 ν†΅ν–ˆλ‹€). 트랩이 μžμ‹μ— `SIGTERM` 을 μ „λ‹¬ν•˜κ³  rc 143 으둜 λλ‚˜λ©°, python μͺ½μ€ ν•Έλ“€λŸ¬λ‘œ 이벀트λ₯Ό κΉ¨μ›Œ `loop_stop`/`disconnect` λ₯Ό κ±°μΉœλ‹€. 폴백 λ£¨ν”„μ˜ λŒ€κΈ°λ„ `sleep … & wait` 둜 λ°”κΏ” μ‹œκ·Έλ„μ΄ κ³§λ°”λ‘œ μ²˜λ¦¬λœλ‹€(끊긴 λŒ€κΈ°μ˜ `sleep` μžμ‹μ€ 남은 간격 μ•ˆμ— 슀슀둜 λλ‚˜κ³ , 이미 돌고 μžˆλŠ” `--once` μ‘°μ • 1νšŒλŠ” λλ‚œ λ’€ μ²˜λ¦¬λœλ‹€). +4. **μƒŒλ“œλ°•μŠ€ νšŒμˆ˜Β·νšŒκ·€ κ°€λ“œ (`tests/conftest.py`)**: `mam_sandbox` finalizer κ°€ argv ν˜•νƒœ(`bash|sh <μƒŒλ“œλ°•μŠ€>/…/reconcile.sh --subscribe`)둜 μ„ μ •ν•œ 데λͺ¬κ³Ό κ·Έ μžμ‹μ„ `SIGTERM`β†’`SIGKILL` 둜 νšŒμˆ˜ν•œλ‹€(λΈŒλ¦¬ν”„λ₯Ό argv 에 싀은 λž˜νΌλ‚˜ 접두 좩돌 ν˜•μ œ λ£¨νŠΈλŠ” μ„ μ •λ˜μ§€ μ•ŠμŒ). `ps -o args=` λŠ” argv λ₯Ό λ”°μ˜΄ν‘œ 없이 곡백으둜 이어 λΆ™μ΄λ―€λ‘œ 슀크립트 κ²½λ‘œλŠ” 곡백 토큰이 μ•„λ‹ˆλΌ μ…Έ 단어뢀터 첫 `/reconcile.sh --subscribe` κΉŒμ§€ ν†΅μ§Έλ‘œ μž‘λŠ”λ‹€ β€” 곡백이 λ“  μƒŒλ“œλ°•μŠ€Β·`--basetemp` 경둜의 데λͺ¬λ„ μ„ μ •λ˜κ³ , 곡백이 λ“  ν›„λ³΄λŠ” μ‹€μ œ 파일일 λ•Œλ§Œ 인정해 λ’€μͺ½ μΈμžμ— 같은 낱말을 싀은 bash λž˜νΌλŠ” λ°°μ œν•œλ‹€. μ„Έμ…˜ μŠ€μ½”ν”„ `subscriber_daemon_leak_guard` λŠ” μŠ€μœ„νŠΈ μ’…λ£Œ μ‹œ basetemp μ•„λž˜ 데λͺ¬μ΄ 남아 있으면 νšŒμˆ˜ν•œ λ’€ μ‹€νŒ¨μ‹œν‚¨λ‹€(#15 λ ˆμ§€μŠ€νŠΈλ¦¬ κ°€λ“œμ™€ 같은 방식). #### βœ… ν’ˆμ§ˆ 검증 -- **μ‹ κ·œ ν…ŒμŠ€νŠΈ 12개** (`tests/test_issue19_subscriber_daemon_lifecycle.py`): μƒŒλ“œλ°•μŠ€ κΈ°λ³Έκ°’μ—μ„œ 두 helper 본체 λͺ¨λ‘ 데λͺ¬ 미기동(2), μ˜΅νŠΈμ•„μ›ƒ 쀑에도 κΈ°μ‘΄ 데λͺ¬ PID 보고, λ¦¬ν„°λŸ΄ `1` 만 인정, `create_session.sh` μƒŒλ“œλ°•μŠ€ μ‹€ν–‰ ν›„ 데λͺ¬ μ—†μŒ, μ˜΅νŠΈμ•„μ›ƒ ν•΄μ œ μ‹œ λˆ„μˆ˜ μž¬ν˜„ + 회수기 제거, 회수기 λ‹¨μœ„ 검증·argv μ„ μ • κΈ°μ€€ 검증, 폴링 폴백의 lock μ†Œμ‹€ μ’…λ£ŒΒ·`SIGTERM` μ¦‰μ‹œ μ’…λ£Œ(2, 브둜컀 λΆˆν•„μš”), MQTT λ£¨ν”„μ˜ lock ꡐ체 μ’…λ£ŒΒ·`SIGTERM` 정상 μ’…λ£Œ(2, 브둜컀 미도달 μ‹œ skip). +- **μ‹ κ·œ ν…ŒμŠ€νŠΈ 14개** (`tests/test_issue19_subscriber_daemon_lifecycle.py`): μƒŒλ“œλ°•μŠ€ κΈ°λ³Έκ°’μ—μ„œ 두 helper 본체 λͺ¨λ‘ 데λͺ¬ 미기동(2), μ˜΅νŠΈμ•„μ›ƒ 쀑에도 κΈ°μ‘΄ 데λͺ¬ PID 보고, λ¦¬ν„°λŸ΄ `1` 만 인정, `create_session.sh` μƒŒλ“œλ°•μŠ€ μ‹€ν–‰ ν›„ 데λͺ¬ μ—†μŒ, μ˜΅νŠΈμ•„μ›ƒ ν•΄μ œ μ‹œ λˆ„μˆ˜ μž¬ν˜„ + 회수기 제거, 회수기 λ‹¨μœ„ 검증·argv μ„ μ • κΈ°μ€€ 검증, 곡백이 λ“  경둜의 argv μ„ μ •Β·μ‹€μ œ 데λͺ¬ 회수(2), 폴링 폴백의 lock μ†Œμ‹€ μ’…λ£ŒΒ·`SIGTERM` μ¦‰μ‹œ μ’…λ£Œ(2, 브둜컀 λΆˆν•„μš”), MQTT λ£¨ν”„μ˜ lock ꡐ체 μ’…λ£ŒΒ·`SIGTERM` 정상 μ’…λ£Œ(2, 브둜컀 미도달 μ‹œ skip). - **κΈ°μ‘΄ ν…ŒμŠ€νŠΈ μ‘°μ •**: `tests/test_issue5_phase1_delegation.py` 의 watchdog/daemon νŠΉμ„±ν™” ν…ŒμŠ€νŠΈκ°€ `MAM_NO_SUBSCRIBER_DAEMON=0`(λ˜λŠ” delenv)으둜 μ˜΅νŠΈμ•„μ›ƒμ„ ν•΄μ œ. - **μ‹€μΈ‘**: 전체 μŠ€μœ„νŠΈ(870 톡과) ν›„ pytest basetemp μ•„λž˜ `bash …/reconcile.sh --subscribe` 데λͺ¬ 0건(argv ν˜•νƒœ κΈ°μ€€; raw `pgrep -f` λŠ” λΈŒλ¦¬ν”„λ₯Ό 싀은 루프 λ“œλΌμ΄λ²„Β·λž˜νΌλ„ λ§€μΉ˜ν•˜λ―€λ‘œ νŒμ •μ— μ“°μ§€ μ•ŠμŒ). diff --git a/tests/conftest.py b/tests/conftest.py index e64f515..32587ad 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -34,16 +34,28 @@ def _is_subscriber_daemon_argv(args, root): --subscribe ...`. A process that merely *quotes* those words (a loop driver or delegate wrapper carrying a brief in argv) and a sibling root that is a string prefix of `root` (`.../pytest-9` vs `.../pytest-99`) are excluded - (review R-2) - the reaper kills what it selects.""" - argv = args.split() - if len(argv) < 3: + (review R-2) - the reaper kills what it selects. + + `ps -o args=` joins argv with single spaces and quotes nothing, so the + script path is taken whole - from the shell word up to the first + `/reconcile.sh --subscribe` - not as one whitespace token: a sandbox or + `--basetemp` path with a space in it must not hide its daemon from the + reaper and the leak guard (codex review P2). In that flat form a path with + whitespace is ambiguous - a bash wrapper under `root` can carry the words + later in its argv - so such a path counts only when it names an existing + file. A daemon whose workspace is already gone exits on its own (Issue #19).""" + import re as _re + shell, _, rest = args.strip().partition(" ") + if os.path.basename(shell) not in ("bash", "sh"): return False - if os.path.basename(argv[0]) not in ("bash", "sh"): + rest = rest.lstrip() + if not rest.startswith(str(root).rstrip(os.sep) + os.sep): return False - script = argv[1] - return (script.startswith(str(root).rstrip(os.sep) + os.sep) - and script.endswith(os.sep + "reconcile.sh") - and argv[2] == "--subscribe") + m = _re.match(r"(.*?" + _re.escape(os.sep) + r"reconcile\.sh) --subscribe(?: |$)", rest) + if not m: + return False + script = m.group(1) + return not any(c.isspace() for c in script) or os.path.isfile(script) def _subscriber_daemon_pids(root): diff --git a/tests/test_issue19_subscriber_daemon_lifecycle.py b/tests/test_issue19_subscriber_daemon_lifecycle.py index cacafd6..23e5d9f 100644 --- a/tests/test_issue19_subscriber_daemon_lifecycle.py +++ b/tests/test_issue19_subscriber_daemon_lifecycle.py @@ -58,7 +58,7 @@ def _run_helper(sandbox: Path, snippet: str, env=None) -> subprocess.CompletedPr def _daemons_for(sandbox: Path) -> list: - """Same argv-token selection as the conftest reaper (review R-2).""" + """Same argv-shape selection as the conftest reaper (review R-2, P2).""" from conftest import _is_subscriber_daemon_argv out = subprocess.run(["pgrep", "-f", "reconcile.sh --subscribe"], capture_output=True, text=True).stdout.split() @@ -228,10 +228,76 @@ def test_reaper_selects_only_the_daemon_argv_shape(tmp_path): # brief-carrying wrapper that merely quotes the words and the path quoting = "python3 -m lib_py.delegation.cli submit --prompt 'note: reconcile.sh --subscribe under {}/.agents/skills/x'".format(root) assert not _is_subscriber_daemon_argv(quoting, root) + # ...and one run by bash from under the root, its prompt ending in the daemon's words + wrapper = "bash {0}/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job submit --prompt restart {1}".format(root, daemon) + assert not _is_subscriber_daemon_argv(wrapper, root) # the real reconcile.sh in a different mode assert not _is_subscriber_daemon_argv(daemon.replace("--subscribe", "--once"), root) assert not _is_subscriber_daemon_argv("", root) + +def _spaced_monitor(tmp_path: Path): + """A monitor script under a sandbox path with a space in it, as a pytest + `--basetemp` or temp dir may have (codex review P2).""" + root = tmp_path / "pytest review" / "case" + script = root / ".agents" / "skills" / "multi-agent-mux-monitor" / "scripts" / "reconcile.sh" + script.parent.mkdir(parents=True) + script.write_text("#!/usr/bin/env bash\nwhile :; do sleep 1; done\n", encoding="utf-8") + script.chmod(0o755) + return root, script + + +def test_reaper_selects_daemons_under_whitespace_paths(tmp_path): + """`ps -o args=` does not quote: the script path must be matched whole, both + at the sandbox root (fixture reaper) and at an ancestor (session guard).""" + from conftest import _is_subscriber_daemon_argv + + root, script = _spaced_monitor(tmp_path) + daemon = "bash {} --subscribe --idle-timeout 0".format(script) + assert _is_subscriber_daemon_argv(daemon + "\n", str(root)) + assert _is_subscriber_daemon_argv(daemon, str(tmp_path)) + assert _is_subscriber_daemon_argv("/bin/sh " + daemon.split(" ", 1)[1], str(root)) + # the text before the space names another root, not an ancestor + assert not _is_subscriber_daemon_argv(daemon, str(tmp_path / "pytest")) + # sibling root that this one is a string prefix of + assert not _is_subscriber_daemon_argv(daemon.replace("/case/", "/case2/"), str(root)) + assert not _is_subscriber_daemon_argv(daemon.replace("--subscribe", "--once"), str(root)) + # wrappers carrying the words: python, and bash from under the spaced root + quoting = "python3 -m lib_py.delegation.cli submit --prompt note: {}".format(daemon) + assert not _is_subscriber_daemon_argv(quoting, str(root)) + wrapper = "bash {0}/.agents/skills/multi-agent-mux-delegate-job/multi-agent-mux-delegate-job submit --prompt restart {1}".format(root, daemon) + assert not _is_subscriber_daemon_argv(wrapper, str(root)) + + +def test_reaper_removes_a_live_daemon_under_a_whitespace_path(tmp_path): + """The owned live-process half of P2: the reaper and the leak guard find a + running `bash /reconcile.sh --subscribe` and take it down.""" + from conftest import _reap_subscriber_daemons, _subscriber_daemon_pids + + root, script = _spaced_monitor(tmp_path) + proc = subprocess.Popen( + ["bash", str(script), "--subscribe", "--idle-timeout", "0"], + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + start_new_session=True, + ) + try: + deadline = time.time() + 5 + while time.time() < deadline and proc.pid not in _subscriber_daemon_pids(root): + time.sleep(0.1) + assert proc.pid in _subscriber_daemon_pids(root), "fixture reaper does not see the daemon" + assert proc.pid in _subscriber_daemon_pids(tmp_path), "session leak guard does not see the daemon" + assert proc.pid in _reap_subscriber_daemons(root) + assert _wait_exit(proc, 5) is not None, "reaper left the daemon running" + assert _subscriber_daemon_pids(root) == [] + finally: + if proc.poll() is None: + try: + os.killpg(proc.pid, signal.SIGKILL) + except OSError: + pass + proc.wait(timeout=5) + + def test_sandbox_reaper_terminates_daemons_anchored_in_the_sandbox(mam_sandbox, monkeypatch): from conftest import _reap_subscriber_daemons, _subscriber_daemon_pids from lib_py.delegation import daemon