diff --git a/.agents/skills/lib.sh b/.agents/skills/lib.sh index 8f8375c..861b2b2 100644 --- a/.agents/skills/lib.sh +++ b/.agents/skills/lib.sh @@ -2297,10 +2297,16 @@ _start_watchdog_legacy() { elif [ -z "$pid" ]; then # Start the wildcard monitor subscriber daemon with --idle-timeout 0 (never idle out) # and ensure it runs with $workdir as cwd to anchor relative log paths. - local orig_pwd="$PWD" + # Issue #72 (decision 2): job control (`set -m`) starts it in its own + # process group, out of the caller's, as the python body's new session + # does; macOS has no setsid(1). The caller's option is restored. + local orig_pwd="$PWD" had_m=0 + case $- in *m*) had_m=1 ;; esac cd "$workdir" || return 1 + set -m nohup bash "$monitor_script" --subscribe --idle-timeout 0 > "$log_file" 2>&1 & pid=$! + [ "$had_m" = 1 ] || set +m cd "$orig_pwd" || return 1 fi diff --git a/tests/test_issue72_update.py b/tests/test_issue72_update.py index cbce937..6335237 100644 --- a/tests/test_issue72_update.py +++ b/tests/test_issue72_update.py @@ -181,6 +181,18 @@ def _exact_monitor_pids(script) -> list: return pids +def _descendants(pid) -> list: + kids = subprocess.run(["pgrep", "-P", str(pid)], capture_output=True, text=True).stdout.split() + return [d for k in kids for d in [k] + _descendants(k)] + + +def _processes_naming(path) -> list: + """Live processes whose command line names PATH (read only, to report leftovers).""" + out = subprocess.run(["ps", "-A", "-ww", "-o", "pid=", "-o", "args="], capture_output=True, text=True).stdout + return [line.strip() for line in out.splitlines() + if str(path) in line and line.split(None, 1)[0] != str(os.getpid())] + + def _lock_held(path: Path) -> bool: if not path.exists(): return False @@ -206,12 +218,26 @@ def box(template, tmp_path): try: yield b finally: + # Only what this test started: its helpers' process groups, and the + # monitors whose exact command line is this test's own workspace + # (detached, so in a group of their own), with their children. No + # pattern kill: the host runs other workspaces' monitors. for p in b.helpers: try: os.killpg(p.pid, signal.SIGKILL) except OSError: pass - subprocess.run(["pkill", "-KILL", "-f", str(ws)], capture_output=True) + p.wait() + for pid in _exact_monitor_pids(ws / MONITOR_REL): + for q in [pid] + _descendants(pid): + try: + os.kill(int(q), signal.SIGKILL) + except OSError: + pass + try: + _until(lambda: not _processes_naming(ws), timeout=10) + except AssertionError: + raise AssertionError("processes of this test outlived it: %s" % _processes_naming(ws)) # --- helpers --------------------------------------------------------------------- @@ -673,8 +699,9 @@ def test_only_this_workspaces_exact_monitor_command_line_is_its_monitor(tmp_path def test_every_restart_starts_a_real_monitor_in_both_delegation_modes(box, mode, case): """MAM_DELEGATION_PY=0 keeps the legacy start_watchdog, the switch the delegate skill documents. Its restart, after an install, after a stop - timeout or after a rollback, must start this workspace's monitor again and - not report a process that only names the monitor path in an argument.""" + timeout or after a rollback, must start this workspace's monitor again, out + of the caller's process group (decision 2), and not report a process that + only names the monitor path in an argument.""" env = {"MAM_DELEGATION_PY": mode} box.stub_monitor() box.start_monitor(env=dict(env, STUB_MONITOR_IGNORE_TERM="1") if case == "killed" else env) @@ -693,6 +720,8 @@ def test_every_restart_starts_a_real_monitor_in_both_delegation_modes(box, mode, assert "PID %s)" % decoy.pid not in res.stdout, "the decoy is reported as the monitor" _until(lambda: bool(box.monitor_pids())) assert not (set(box.monitor_pids()) & first) and box.monitor_starts() == 2 + # box.update runs update.sh in this test's process group: the caller's. + assert all(os.getpgid(int(p)) != os.getpgrp() for p in box.monitor_pids()), "left in the caller's group" assert decoy.poll() is None, "the decoy was not signalled"