Files
multi-agent-mux/.agents/skills/lib_py/stall_watch.py
T
GodopuandClaude Opus 5.5 bf0f5c1014 fix(issue-30-33): review 5ec8ef33 - a multi-line codex draft is not an empty box, blocked notices reach the loop while the job runs, and both clocks are labelled on every close path
- R1 (P1): without rules the input region runs from the last prompt line to
  the end of the pane; only a last line matching the adapter's footer pattern
  (codex: `  <model> · ~/<cwd> · …`) is dropped, so a draft on a second line
  keeps the nudge withheld.
- R2: while the synchronous submit runs, a relay thread finds the job it
  registers (same session, created since the submit) and prints its
  stalled:/stall_watch: notices; before, they appeared only after the job.
- R3: the broker-unavailable disk wait that runs into the deadline is the wall
  clock: wall_clock_timeout (caller rc stays 3).
- R4: a state_change_seq change seen right before the judgement restarts the
  idle window (decision 9: S seconds of continuous idle).

Regression tests are the reviewer's reproductions, each with its passing
twin; the wrapper-level #30 tests now use this environment's broker instead
of the #18 harness's public default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 14:01:53 +00:00

376 lines
17 KiB
Python

"""Issue #30: a stall watcher that only notifies.
Runs next to the delegate wrapper's subscriber for a local herdr job and tells
people when the agent looks stuck. It never closes the job: its only output is
a `progress` event (through `_notify`, the single publisher) plus log lines,
and one short nudge prompt per job. Closing stays with the protocol events and
the two clocks of #18/#33.
* blocked -> `progress stalled:blocked <dialog line>` once per episode, no reply.
* idle for MAM_STALL_IDLE_S (600) with no background children of the agent
process and an empty input box -> `progress stalled:idle` and one nudge.
* herdr failing (TRANSPORT_ERROR / AGENT_NOT_FOUND) is never read as "no
event": warning + `progress stall_watch:herdr_unavailable:<code>`, backoff,
and exit 2 after MAM_STALL_HERDR_FAIL_S (120) without recovery.
Signals are collected in one place (`HerdrSource.collect`) and judged by the
pure `judge`, so a future remote gateway can hand over the same `Signals`.
Exit codes: 0 the job reached a terminal status, 2 herdr stayed unavailable.
It lives at the top of lib_py, not in `delegation` or `loop`: it needs the
agent adapters, which neither package may import (their sibling-import rules),
and it runs as its own process, never inside a frozen loop.
"""
from __future__ import annotations
import argparse
import json
import logging
import os
import re
import signal
import subprocess
import sys
import time
from dataclasses import dataclass, field
from typing import List, Optional
from lib_py.agents.input_region import extract_input_region
from lib_py.agents.registry import agent_of_row, get_adapter
from lib_py.delegation.engine import TERMINAL_STATUSES
from lib_py.loop import watcher
log = logging.getLogger("stall_watch")
RC_TERMINAL = 0
RC_HERDR_UNAVAILABLE = 2
NOTIFY_BLOCKED = "notify_blocked"
NOTIFY_IDLE_AND_NUDGE = "notify_idle_and_nudge"
IDLE_STATES = ("idle", "done")
HEARTBEAT_MARK = "lib_py.delegation.heartbeat"
SKILLS_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
NUDGE = ("Job {job}: you look idle with no background work. If the job is done, publish "
"completed or error with the command in your brief; otherwise continue where you left off.")
@dataclass
class Signals:
agent_status: Optional[str] = None
state_change_seq: Optional[int] = None
pane_id: Optional[str] = None
blocked_text: str = ""
agent_pid: Optional[int] = None
background_children: List[str] = field(default_factory=list)
input_empty: Optional[bool] = None
job_status: Optional[str] = None
idle_for: float = 0.0
@dataclass
class WatchState:
idle_s: float = 600.0
reliable: bool = True
nudged: bool = False
blocked_notified: bool = False
def judge(sig: Signals, st: WatchState) -> Optional[str]:
"""Pure decision over one set of signals. Never an action that closes a job."""
if sig.job_status in TERMINAL_STATUSES:
return None
if sig.agent_status == "blocked":
return None if st.blocked_notified else NOTIFY_BLOCKED
if (st.reliable and not st.nudged and sig.agent_status in IDLE_STATES
and sig.idle_for >= st.idle_s and sig.agent_pid
and not sig.background_children and sig.input_empty is True):
return NOTIFY_IDLE_AND_NUDGE
return None
def _proc_table():
"""pid -> (ppid, name, cmdline) from /proc (Linux); `ps` elsewhere."""
table = {}
if os.path.isdir("/proc/self"):
for d in os.listdir("/proc"):
if not d.isdigit():
continue
try:
with open("/proc/%s/stat" % d) as f:
stat = f.read()
with open("/proc/%s/cmdline" % d, "rb") as f:
argv = [a.decode("utf-8", "replace") for a in f.read().split(b"\0") if a]
except OSError:
continue
name = stat[stat.index("(") + 1:stat.rindex(")")]
ppid = int(stat[stat.rindex(")") + 2:].split()[1])
table[int(d)] = (ppid, name, argv)
return table
out = subprocess.run(["ps", "-eo", "pid=,ppid=,comm=,args="], capture_output=True, text=True).stdout
for line in out.splitlines():
parts = line.split(None, 3)
if len(parts) >= 3:
table[int(parts[0])] = (int(parts[1]), os.path.basename(parts[2]), (parts[3] if len(parts) > 3 else parts[2]).split())
return table
def background_children(agent_pid: int, resident=()) -> List[str]:
"""Descendants of the agent process, minus the adapter's resident children
and the heartbeat timer (decision 9: it must never count as work)."""
table = _proc_table()
kids = {}
for pid, (ppid, _, _) in table.items():
kids.setdefault(ppid, []).append(pid)
found, todo = [], list(kids.get(agent_pid, []))
while todo:
pid = todo.pop()
todo.extend(kids.get(pid, []))
_, name, argv = table[pid]
base = os.path.basename(argv[0]) if argv else ""
if name in resident or base in resident or name[:15] in [r[:15] for r in resident]:
continue
if HEARTBEAT_MARK in " ".join(argv):
continue
found.append("%d:%s" % (pid, name))
return found
def resolve_agent(agent, target):
"""Adapter name for the wrapper's --agent (its default is `claude-code`),
else from the session name; None when no adapter matches."""
for name in (agent, (agent or "").split("-")[0], agent_of_row({}, session_name=target)):
if name and get_adapter(name):
return get_adapter(name).name
return None
class HerdrError(Exception):
def __init__(self, code):
super().__init__(code)
self.code = code
class HerdrSource(object):
"""Every herdr and OS read the judgement needs, in one place."""
def __init__(self, target, agent, session=None):
self.target, self.session = target, session or None
self.agent = resolve_agent(agent, target)
adapter = get_adapter(self.agent) if self.agent else None
self.resident = tuple(adapter.resident_children) if adapter else ()
def _herdr(self, *args):
argv = ["herdr"] + (["--session", self.session] if self.session and self.session != "default" else []) + list(args)
try:
proc = subprocess.run(argv, capture_output=True, text=True, timeout=30,
env=watcher.scoped_env(self.session, argv=argv))
except Exception as exc:
raise HerdrError("exec_failed: %s" % exc)
if proc.returncode != 0:
try:
code = json.loads(proc.stderr)["error"]["code"]
except Exception:
code = "rc%d" % proc.returncode
raise HerdrError(code)
return proc.stdout
def agent_get(self):
payload = json.loads(self._herdr("agent", "get", self.target) or "{}")
return (payload.get("result") or {}).get("agent") or {}
def agent_pid(self, pane_id):
info = json.loads(self._herdr("pane", "process-info", "--pane", pane_id) or "{}")
procs = ((info.get("result") or {}).get("process_info") or {}).get("foreground_processes") or []
# Not shell_pid: the shell is not the agent; without a pid we withhold.
return procs[0].get("pid") if procs else None
def blocked_text(self, pane_id):
text = self._herdr("pane", "read", pane_id, "--source", "detection", "--lines", "8")
for line in text.splitlines():
line = re.sub(r"[^\x20-\x7e]+", " ", line).strip()
line = re.sub(r"\s+", " ", line)
if line:
return line[:160]
return ""
def input_empty(self):
"""True/False from the adapter's input-box facts, None when it has none."""
if not self.agent:
return None
try:
return extract_input_region(self._herdr("agent", "read", self.target), self.agent) == ""
except ValueError:
return None
def prompt(self, text):
self._herdr("agent", "prompt", self.target, text)
def collect(self, sig: Signals) -> Signals:
"""Fill the process and input signals of an idle agent."""
agent = self.agent_get()
sig.agent_status = agent.get("agent_status", sig.agent_status)
sig.state_change_seq = agent.get("state_change_seq", sig.state_change_seq)
sig.pane_id = agent.get("pane_id") or sig.pane_id
sig.agent_pid = self.agent_pid(sig.pane_id) if sig.pane_id else None
sig.background_children = background_children(sig.agent_pid, self.resident) if sig.agent_pid else []
sig.input_empty = self.input_empty()
return sig
class StallWatch(object):
def __init__(self, args, source=None):
self.args = args
self.src = source or HerdrSource(args.target, args.agent, args.herdr_session)
name = resolve_agent(args.agent, args.target)
adapter = get_adapter(name) if name else None
reliable = bool(adapter.agent_status_reliable) if adapter else True
if os.environ.get("MAM_TEST_UNRELIABLE_AGENT") == args.agent:
reliable = False
self.st = WatchState(idle_s=float(os.environ.get("MAM_STALL_IDLE_S", "600")), reliable=reliable)
self.fail_s = float(os.environ.get("MAM_STALL_HERDR_FAIL_S", "120"))
self.once = set()
def _notify(self, detail):
"""The only publisher: a `progress` event, nothing else."""
cmd = [sys.executable, "-m", "lib_py.delegation.publish_event", "--registry-dir", self.args.registry_dir,
"--job", self.args.job, "--event", "progress", "--detail", detail, "--attempts", "1"]
if self.args.expect_dispatch_token:
cmd += ["--expect-dispatch-token", self.args.expect_dispatch_token]
if self.args.attempt_id is not None:
cmd += ["--attempt-id", str(self.args.attempt_id)]
env = dict(os.environ)
env["PYTHONPATH"] = SKILLS_DIR + (os.pathsep + env["PYTHONPATH"] if env.get("PYTHONPATH") else "")
# rc 2 = broker unreachable after the local record and events.log were written.
res = subprocess.run(cmd, capture_output=True, text=True, env=env, timeout=120)
if res.returncode not in (0, 2):
log.warning("stall_watch: publishing %r failed (rc %d): %s", detail, res.returncode, res.stderr.strip()[-200:])
def _notify_once(self, key, detail, message):
if key in self.once:
return
self.once.add(key)
log.warning(message)
self._notify(detail)
def _job_status(self):
try:
with open(os.path.join(self.args.registry_dir, self.args.job + ".json")) as f:
return json.load(f).get("status")
except (OSError, ValueError):
return None
def run(self) -> int:
st, sig, agent = self.st, Signals(), self.args.agent
if not st.reliable:
log.warning("stall_watch: idle judgement off for %s (agent_status unreliable)", agent)
backoff = watcher.ReconnectBackoff()
fail_since, idle_since = None, None
while True:
sig.job_status = self._job_status()
if sig.job_status in TERMINAL_STATUSES:
return RC_TERMINAL
if sig.agent_status is None:
until, tmo = watcher.VALID_STATES, 1000 # learn the current state
elif sig.agent_status in IDLE_STATES:
left = st.idle_s - (time.monotonic() - idle_since)
# Past S the judgement repeats every 30 s, not on every tick.
until, tmo = ("working", "blocked"), int(1000 * (min(left, 30.0) if left > 0 else 30.0))
tmo = max(tmo, 1000)
elif sig.agent_status == "blocked":
until, tmo = ("idle", "done", "working"), 60000
else:
until, tmo = ("idle", "done", "blocked"), 60000
out = watcher.wait_once(self.args.target, until=until, timeout_ms=tmo, session=self.args.herdr_session)
if out.kind in (watcher.TRANSPORT_ERROR, watcher.AGENT_NOT_FOUND):
code = "agent_not_found" if out.kind == watcher.AGENT_NOT_FOUND else (out.error_code or "transport_error")
now = time.monotonic()
if fail_since is None:
fail_since = now
log.warning("stall_watch: herdr unavailable: %s %s", code, out.detail)
self._notify("stall_watch:agent_not_found" if code == "agent_not_found"
else "stall_watch:herdr_unavailable:%s" % code)
if now - fail_since >= self.fail_s:
log.warning("stall_watch: herdr unavailable for %ds; stall watch stops (%s)", int(now - fail_since), code)
return RC_HERDR_UNAVAILABLE
time.sleep(min(backoff.record_failure(), max(0.1, self.fail_s - (now - fail_since))))
sig.agent_status = None # re-observe after a gap
continue
backoff.record_success()
fail_since = None
if out.kind == watcher.MATCHED:
new = out.agent.get("agent_status")
sig.pane_id = out.agent.get("pane_id") or sig.pane_id
if new in IDLE_STATES and sig.agent_status not in IDLE_STATES:
idle_since, sig.state_change_seq = time.monotonic(), out.agent.get("state_change_seq")
if new != "blocked":
st.blocked_notified = False
sig.agent_status = new
try:
idle_since = self._act(sig, idle_since)
except HerdrError as exc:
log.warning("stall_watch: herdr call failed while judging: %s", exc.code)
def _act(self, sig, idle_since):
"""Notify/nudge per `judge`; returns the idle start to keep (restarted
when a transition was missed between the wait and the judgement)."""
st, agent = self.st, self.args.agent
if sig.agent_status == "blocked" and not st.blocked_notified:
sig.blocked_text = self.src.blocked_text(sig.pane_id or self.src.agent_get().get("pane_id", ""))
if judge(sig, st) == NOTIFY_BLOCKED:
log.warning("stall_watch: agent %s is blocked: %s", self.args.target, sig.blocked_text)
self._notify(("stalled:blocked " + sig.blocked_text).strip())
st.blocked_notified = True
return idle_since
if sig.agent_status not in IDLE_STATES or not st.reliable or st.nudged:
return idle_since
sig.idle_for = time.monotonic() - idle_since
if sig.idle_for < st.idle_s:
return idle_since
seq = sig.state_change_seq
self.src.collect(sig)
if seq is not None and sig.state_change_seq is not None and sig.state_change_seq != seq:
# Review R4: a transition we did not see. Decision 9 wants S seconds
# of *continuous* idle, so the window restarts from this observation
# (collect() already took the new seq as the baseline).
log.info("stall_watch: state_change_seq %s -> %s since the idle start; idle window restarts",
seq, sig.state_change_seq)
return time.monotonic()
if sig.background_children:
log.debug("stall_watch: idle but background children %s", sig.background_children)
elif sig.input_empty is None:
self._notify_once("input_unavailable", "stall_watch:input_check_unavailable:%s" % agent,
"stall_watch: input check unavailable for %s; idle nudge withheld" % agent)
elif sig.input_empty is False:
log.info("stall_watch: input box of %s is not empty; nudge withheld", self.args.target)
if sig.idle_for >= 3 * st.idle_s:
self._notify_once("never_empty", "stall_watch:input_never_empty:%s" % agent,
"stall_watch: input box of %s never looked empty in %ds" % (agent, int(sig.idle_for)))
if judge(sig, st) == NOTIFY_IDLE_AND_NUDGE:
log.warning("stall_watch: agent %s idle for %ds with no background work; nudging once",
self.args.target, int(sig.idle_for))
self._notify("stalled:idle")
st.nudged = True
self.src.prompt(NUDGE.format(job=self.args.job))
return idle_since
def main(argv=None) -> int:
p = argparse.ArgumentParser(description="notify-only stall watcher for one local herdr job (#30)")
p.add_argument("--job", required=True)
p.add_argument("--registry-dir", required=True)
p.add_argument("--target", required=True, help="herdr agent name of the job's session")
p.add_argument("--agent", required=True, help="agent CLI (adapter name)")
p.add_argument("--herdr-session", default="")
p.add_argument("--expect-dispatch-token", default=None)
p.add_argument("--attempt-id", type=int, default=None)
args = p.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(asctime)s stall_watch %(levelname)s %(message)s")
# The wrapper stops us with SIGTERM once its subscriber ends: leave quietly.
signal.signal(signal.SIGTERM, lambda *_: os._exit(RC_TERMINAL))
return StallWatch(args).run()
if __name__ == "__main__":
sys.exit(main())