Three reviewers examined fc0bd96..8def235. Codex and Claude each reproduced a defect; all three findings were re-verified here before being accepted, and none were rebutted. R1 (codex) — the shell helpers ran `python -m lib_py.delegation.cli`. For -m, sys.path[0] is the working directory, which therefore beats PYTHONPATH: with cwd inside a tree containing lib_py, the live package won even with the frozen path listed first. The documented B-13 guarantee for this path did not hold. Reproduced, then replaced with an explicit bootstrap that inserts SKILL_DIR itself. cwd is left alone, because the registry and audit-log paths are relative to it. The file-based shim path was never affected. R2 (codex) — the persistent connection was reused on host and port alone. Two jobs sharing an endpoint but carrying different credentials or TLS settings silently published over the first connection. Reproduced (the client factory ran once, under the first account, with TLS off). The guard now compares an immutable snapshot of the whole connection identity and names only the differing field, never its value. F-1 (claude) — paho queues a publish issued while disconnected and raises RuntimeError from wait_for_publish. RuntimeError is deliberately outside the retry tuple, so the one state a persistent connection exists to survive, the gap between a drop and paho's reconnect, failed on the first attempt. Confirmed against paho 2.1.0. That single condition is now translated into a retryable error; the tuple is unchanged, so genuine programming errors are still not retried. Two fixtures were hiding these: FakeInfo had no rc and never raised. It now mirrors paho's contract, which is what makes the new tests meaningful. Also folds in the duplication both Claude (O-1) and I had flagged: the CLI path kept its own copy of connect-publish-disconnect plus its own timeout constants. _publish_once now delegates to OneShotPublisher.publish_raw, so a transport fix lands once. Having two copies is exactly why these bugs existed in one of them. Documentation corrected rather than defended: the benchmark's rationale for keeping three forks was imprecise (the || true already guarantees rc 0; the lookup buys quietness and legacy parity), SKILL.md no longer claims PYTHONPATH decides the frozen path, and the daemon's directory creation is recorded as a deliberate difference. Trailing whitespace inherited by the relocated file is stripped, so the commit range is now clean too. Verified: each regression fails against the pre-fix code and passes after (R1 1, R2 4, F-1 1). 66 phase-1 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UrDNJkHmm18uu7n7JwV5Qd
93 lines
3.5 KiB
Python
93 lines
3.5 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:
|
|
"""Start the subscriber detached and return its pid.
|
|
|
|
One deliberate difference from the shell original: the log directory is
|
|
created if missing. The legacy `>>` redirect failed outright when `.mam/`
|
|
did not exist, so this is strictly more robust, not a contract change.
|
|
"""
|
|
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))]
|