Adds the python side of the delegation helpers and switches lib.sh over to it.
daemon.py port of start_watchdog. Same monitor path, same pgrep pattern, so
a daemon started by either implementation is found by the other;
same stderr wording and exit 1 when reconcile.sh is missing; same
"print the PID" contract, multiple pgrep matches included, since
the shell echoed them all and trimming would be a silent change.
start_new_session detaches it, and SIGHUP is ignored across the
exec so it outlives its parent exactly as nohup did.
cli.py thin router. submit/publish forward to the existing main() of the
module that already owned the behaviour, so exit codes, stdout
shape and side effects cannot drift between the shell path and
the script path. Only ensure-daemon carries logic of its own.
service.py DelegationService facade, with an optional persistent connection
for the phase 2a engine. CLI and shell always use one-shot.
lib.sh keeps the three legacy bodies verbatim as _*_legacy behind
MAM_DELEGATION_PY=0, which is the instant rollback switch. PYTHONPATH is passed
explicitly so `-m` resolves lib_py from the tree this lib.sh belongs to, which
is what makes a frozen loop run frozen delegation code. start_watchdog survives
as an alias for ensure_subscriber_daemon.
The wrapper and run_loop.sh are untouched; reconcile.sh gains only the
ensure_env_loaded line from the previous commit.
Verified: C-01..C-09 pass against both the legacy and the python bodies, the
daemon dedups and survives its parent, the cli reproduces the legacy failure
line exactly, and publish failure semantics (status synced, seq consumed,
audit records published=false) are identical through the shim and the cli.
tier1+tier2 109 passed; bash -n clean on all six shell files.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UrDNJkHmm18uu7n7JwV5Qd
87 lines
3.2 KiB
Python
87 lines
3.2 KiB
Python
"""Subscriber daemon supervision — the python port of lib.sh's `start_watchdog`.
|
|
|
|
Behaviour is byte-for-byte compatible with the shell original (issue #5 phase 1,
|
|
D5): same monitor path, same stderr wording and exit status when it is missing,
|
|
same `pgrep -f` pre-check pattern, same log file, same "print the PID" contract.
|
|
Callers capture stdout with $( ), so nothing else may be printed there.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import signal
|
|
import subprocess
|
|
from typing import List, Optional
|
|
|
|
logger = logging.getLogger("delegate_job.daemon")
|
|
|
|
MONITOR_RELPATH = os.path.join(
|
|
".agents", "skills", "multi-agent-mux-monitor", "scripts", "reconcile.sh"
|
|
)
|
|
LOG_RELPATH = os.path.join(".mam", "multi-agent-mux-monitor.log")
|
|
|
|
|
|
def monitor_script_path(workdir: str) -> str:
|
|
return os.path.join(workdir, MONITOR_RELPATH)
|
|
|
|
|
|
def find_running(monitor_script: str) -> List[str]:
|
|
"""PIDs of subscribers already running for this monitor script.
|
|
|
|
Uses the exact `pgrep -f` pattern the shell helper used, so a daemon started
|
|
by either implementation is found by the other. Multiple matches are returned
|
|
in full: the legacy helper echoed every line and callers only ever echo it
|
|
back, so trimming here would be a silent behaviour change.
|
|
"""
|
|
try:
|
|
res = subprocess.run(
|
|
["pgrep", "-f", "bash {} --subscribe".format(monitor_script)],
|
|
capture_output=True, text=True,
|
|
)
|
|
except OSError as exc: # pragma: no cover - pgrep missing
|
|
logger.warning("pgrep unavailable: %s", exc)
|
|
return []
|
|
return [line for line in res.stdout.split() if line.strip()]
|
|
|
|
|
|
def _ignore_sighup(): # pragma: no cover - runs in the forked child
|
|
"""Match `nohup`: SIG_IGN survives execve, so the daemon outlives its parent."""
|
|
signal.signal(signal.SIGHUP, signal.SIG_IGN)
|
|
|
|
|
|
def spawn(monitor_script: str, workdir: str) -> int:
|
|
log_file = os.path.join(workdir, LOG_RELPATH)
|
|
os.makedirs(os.path.dirname(log_file), exist_ok=True)
|
|
log_handle = open(log_file, "ab")
|
|
try:
|
|
proc = subprocess.Popen(
|
|
["bash", monitor_script, "--subscribe", "--idle-timeout", "0"],
|
|
cwd=workdir,
|
|
stdin=subprocess.DEVNULL,
|
|
stdout=log_handle,
|
|
stderr=subprocess.STDOUT,
|
|
start_new_session=True,
|
|
preexec_fn=_ignore_sighup,
|
|
)
|
|
finally:
|
|
log_handle.close()
|
|
return proc.pid
|
|
|
|
|
|
def ensure_subscriber_daemon(job_id: str, workdir: Optional[str] = None) -> List[str]:
|
|
"""Return the PIDs of the running subscriber, starting one if needed.
|
|
|
|
`job_id` is accepted for signature compatibility and is deliberately unused:
|
|
the daemon is per-workspace (log paths anchor on `workdir`), exactly as in
|
|
the shell original. Raises FileNotFoundError when the monitor is absent so
|
|
the CLI can reproduce the legacy stderr line and exit 1.
|
|
"""
|
|
workdir = workdir or os.getcwd()
|
|
monitor_script = monitor_script_path(workdir)
|
|
if not os.path.isfile(monitor_script):
|
|
raise FileNotFoundError(monitor_script)
|
|
running = find_running(monitor_script)
|
|
if running:
|
|
return running
|
|
return [str(spawn(monitor_script, workdir))]
|