Files
multi-agent-mux/.agents/skills/lib_py/loop/watcher.py
T
GodopuandClaude Opus 5 3bcaa39ab0 fix(issue-23): a herdr call to another server drops the caller pane's workspace/pane/tab ids
A MAM script often runs inside one herdr server's pane (the orchestrator's)
while targeting another (the isolated MAM server). The pane exports
HERDR_WORKSPACE_ID, HERDR_PANE_ID and HERDR_TAB_ID, and those ids only mean
something on the server that issued them. They reached the scope filters as a
foreign workspace, whose fail-closed miss (ISSUE-3 / F-2b) reports a live
session as absent: has-session, kill-session, agent prompt and the delegate-job
preflight all answered "no such session". Reproduced on Linux and macOS in the
#23 comments, worked around until now with `env -u` at every call site.

mam_herdr drops the three ids for a call aimed at a named server other than the
caller's own. Scope then falls back to .mam/herdr_workspace_id and finally to
server-global, which is what _herdr_ws_scope already does. `default` is not a
crossing: it is the sentinel for "pass no session flag", so herdr answers on
the ambient server, which for a caller in a pane is that caller's own. A caller
outside any pane keeps an exported scope, so H-18/H-21/H-23 keep their meaning.

The fix lives in mam_herdr, not in the shim heredoc: that heredoc is
materialised into $WORKSPACE_ROOT/.mam/shim/herdr, one file shared by every
checkout bound to a control plane and rewritten on every herdr call, so editing
it would put unreviewed code in the control root and let two checkouts
overwrite each other. S11 fixes that invariant.

lib_py/loop/watcher.py builds its own herdr argv and is the one path that does
not go through mam_herdr, so it applies the same rule via scoped_env.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-22 04:14:45 +00:00

236 lines
9.1 KiB
Python

"""Wraps `herdr agent wait`. Observes; never concludes.
Live contract (herdr 0.8.2, re-confirmed while planning):
herdr agent wait <TARGET> --until <STATUS>... --timeout <MS>
* `--timeout` is MILLISECONDS. The parameter here is named `timeout_ms` so the
unit cannot be misread at a call site; there is deliberately no seconds form.
* `--until` is always passed explicitly. Omitting it matches idle/done/blocked,
silently excluding `working`, which is a state the watcher cares about.
* success -> exit 0, snapshot JSON on STDOUT.
* timeout and unknown target -> exit 1, error JSON on STDERR, stdout empty.
The stream split is the contract: an error is never read from stdout.
A single wait is one synchronous barrier returning one final snapshot, so
continuous observation is built by repeating it, not by streaming.
"""
from __future__ import annotations
import json
import logging
import os
import subprocess
from typing import Any, Dict, Optional, Sequence
from lib_py.loop.evidence import Observation
logger = logging.getLogger("loop.watcher")
DEFAULT_UNTIL = ("idle", "done", "blocked")
VALID_STATES = ("idle", "working", "blocked", "done", "unknown")
# Outcome kinds. `timeout` is an ordinary no-event result, not a failure.
MATCHED = "matched"
TIMEOUT = "timeout"
AGENT_NOT_FOUND = "agent_not_found"
TRANSPORT_ERROR = "transport_error"
BACKOFF_START_S = 1.0
BACKOFF_MAX_S = 16.0
# ISSUE-23: ids a herdr pane exports about ITS OWN server. Passing them to a
# call aimed at a different server makes herdr scope the lookup to a workspace
# that does not exist there, and the target is reported as missing. The shell
# side drops them in mam_herdr; this is the one python path that runs herdr
# without going through it.
CALLER_SCOPE_VARS = ("HERDR_WORKSPACE_ID", "HERDR_PANE_ID", "HERDR_TAB_ID")
def scoped_env(session=None, environ=None):
"""The environment for a wait aimed at `session`.
Unchanged unless this process runs inside a pane of another server: only
then are the caller's own workspace/pane/tab ids meaningless for the call
and dropped. No pane (no HERDR_SESSION) and same-server keep every value.
"""
src = os.environ if environ is None else environ
caller = (src.get("HERDR_SESSION") or "").strip()
target = (session or "").strip()
# `default` is the same sentinel build_argv reads: no --session flag, so
# herdr answers on the ambient server - the caller's own.
if not caller or not target or target == "default" or caller == target:
return dict(src)
return dict((k, v) for k, v in src.items() if k not in CALLER_SCOPE_VARS)
class WaitOutcome(object):
__slots__ = ("kind", "agent", "error_code", "detail", "raw")
def __init__(self, kind, agent=None, error_code=None, detail="", raw=None):
self.kind = kind
self.agent = agent
self.error_code = error_code
self.detail = detail
self.raw = raw
@property
def healthy(self) -> bool:
"""A timeout is healthy: the socket answered, the state just did not change."""
return self.kind in (MATCHED, TIMEOUT, AGENT_NOT_FOUND)
def __repr__(self): # pragma: no cover - debugging aid
return "WaitOutcome({!r}, error_code={!r})".format(self.kind, self.error_code)
def build_argv(target: str, until: Sequence[str] = DEFAULT_UNTIL,
timeout_ms: Optional[int] = None,
session: Optional[str] = None) -> list:
if not target:
raise ValueError("wait target is required")
if session and session != "default":
argv = ["herdr", "--session", session, "agent", "wait", target]
else:
argv = ["herdr", "agent", "wait", target]
for state in until:
if state not in VALID_STATES:
raise ValueError("unsupported --until state: {!r}".format(state))
argv += ["--until", state]
if timeout_ms is not None:
if int(timeout_ms) <= 0:
raise ValueError("timeout_ms must be positive milliseconds")
argv += ["--timeout", str(int(timeout_ms))]
return argv
def _parse(stream: str) -> Optional[Dict[str, Any]]:
try:
payload = json.loads(stream)
return payload if isinstance(payload, dict) else None
except Exception:
return None
def wait_once(target: str, until: Sequence[str] = DEFAULT_UNTIL,
timeout_ms: Optional[int] = None, session: Optional[str] = None,
run=subprocess.run) -> WaitOutcome:
"""One barrier call. `run` is injectable so tests never touch a real herdr."""
argv = build_argv(target, until, timeout_ms, session=session)
try:
proc = run(argv, capture_output=True, text=True, env=scoped_env(session))
except Exception as exc:
return WaitOutcome(TRANSPORT_ERROR, detail="herdr could not be executed: {}".format(exc))
if proc.returncode == 0:
payload = _parse(proc.stdout or "")
agent = ((payload or {}).get("result") or {}).get("agent")
if not isinstance(agent, dict):
return WaitOutcome(TRANSPORT_ERROR,
detail="exit 0 without a parseable agent snapshot",
raw=proc.stdout)
return WaitOutcome(MATCHED, agent=agent, raw=payload)
# Non-zero: the payload is on stderr by contract, and stdout is empty.
payload = _parse(proc.stderr or "")
code = ((payload or {}).get("error") or {}).get("code")
if code == "timeout":
return WaitOutcome(TIMEOUT, error_code=code, raw=payload)
if code == "agent_not_found":
return WaitOutcome(AGENT_NOT_FOUND, error_code=code, raw=payload)
return WaitOutcome(TRANSPORT_ERROR, error_code=code,
detail="unrecognised failure (rc={})".format(proc.returncode),
raw=proc.stderr)
def observation_from(outcome: WaitOutcome, capture_ref=None) -> Optional[Observation]:
"""Turn a matched snapshot into evidence. Non-matches carry no agent state."""
if outcome.kind != MATCHED or not outcome.agent:
return None
agent = outcome.agent
return Observation(
"herdr",
{
"agent_status": agent.get("agent_status"),
"interactive_ready": agent.get("interactive_ready"),
"pane_id": agent.get("pane_id"),
"state_change_seq": agent.get("state_change_seq"),
},
capture_ref=capture_ref,
)
class ReconnectBackoff(object):
"""Doubling backoff for transport failures, reset by any healthy answer.
The snapshot cache is dropped on failure on purpose: after a socket gap the
watcher must re-observe rather than reason from a state that may have moved
while it was blind. That is what makes `hold_reconnect` the honest decision.
"""
def __init__(self, start=BACKOFF_START_S, maximum=BACKOFF_MAX_S):
self.start = start
self.maximum = maximum
self.current = 0.0
self.consecutive_failures = 0
self.cached_snapshot = None
def record_success(self, snapshot=None) -> None:
self.consecutive_failures = 0
self.current = 0.0
if snapshot is not None:
self.cached_snapshot = snapshot
def record_failure(self) -> float:
self.consecutive_failures += 1
self.cached_snapshot = None # stale evidence must not survive a gap
self.current = self.start if self.current == 0.0 else min(self.current * 2.0, self.maximum)
return self.current
def sequence(self, n: int) -> list:
"""The first n delays, for documentation and for tests to pin."""
saved = (self.current, self.consecutive_failures, self.cached_snapshot)
try:
self.current, self.consecutive_failures = 0.0, 0
return [self.record_failure() for _ in range(n)]
finally:
self.current, self.consecutive_failures, self.cached_snapshot = saved
def main(argv: Optional[Sequence[str]] = None) -> int:
import argparse
import os
import sys
parser = argparse.ArgumentParser(description="watcher CLI for herdr agent wait")
sub = parser.add_subparsers(dest="command", required=True)
p_wait = sub.add_parser("wait", help="wait for agent state")
p_wait.add_argument("target", help="target agent name")
p_wait.add_argument("--session", default=None, help="herdr session name")
p_wait.add_argument("--until", action="append", default=[], help="until state(s)")
p_wait.add_argument("--timeout-ms", type=int, default=None, help="timeout in ms")
args = parser.parse_args(argv)
session = args.session or os.environ.get("HERDR_SESSION_NAME") or None
until = args.until or DEFAULT_UNTIL
outcome = wait_once(args.target, until=until, timeout_ms=args.timeout_ms, session=session)
agent_st = None
if outcome.agent:
agent_st = outcome.agent.get("agent_status")
print(json.dumps({
"kind": outcome.kind,
"agent_status": agent_st,
"error_code": outcome.error_code,
}))
rc_map = {
MATCHED: 0,
TIMEOUT: 2,
AGENT_NOT_FOUND: 3,
TRANSPORT_ERROR: 4,
}
return rc_map.get(outcome.kind, 4)
if __name__ == "__main__":
import sys
sys.exit(main())