fix(#30, #33): 위임 잡 예산 조절과 벽시계 라벨 분리, 멈춘 에이전트 알림(알림 전용) #41

Merged
Godopu merged 9 commits from fix/issue-30-33 into main 2026-09-26 07:08:46 +00:00
5 changed files with 406 additions and 12 deletions
Showing only changes of commit af0a2a2557 - Show all commits

No files matched your search

+53 -3
View File
@@ -232,7 +232,8 @@ class StallWatch(object):
self.once = set() self.once = set()
def _notify(self, detail): def _notify(self, detail):
"""The only publisher: a `progress` event, nothing else.""" """The only publisher: a `progress` event, nothing else. False when the
registry or the wire refused it (a refused notice withholds the nudge)."""
cmd = [sys.executable, "-m", "lib_py.delegation.publish_event", "--registry-dir", self.args.registry_dir, 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"] "--job", self.args.job, "--event", "progress", "--detail", detail, "--attempts", "1"]
if self.args.expect_dispatch_token: if self.args.expect_dispatch_token:
@@ -245,6 +246,8 @@ class StallWatch(object):
res = subprocess.run(cmd, capture_output=True, text=True, env=env, timeout=120) res = subprocess.run(cmd, capture_output=True, text=True, env=env, timeout=120)
if res.returncode not in (0, 2): if res.returncode not in (0, 2):
log.warning("stall_watch: publishing %r failed (rc %d): %s", detail, res.returncode, res.stderr.strip()[-200:]) log.warning("stall_watch: publishing %r failed (rc %d): %s", detail, res.returncode, res.stderr.strip()[-200:])
return False
return True
def _notify_once(self, key, detail, message): def _notify_once(self, key, detail, message):
if key in self.once: if key in self.once:
@@ -260,6 +263,28 @@ class StallWatch(object):
except (OSError, ValueError): except (OSError, ValueError):
return None return None
def _dispatch_open(self):
"""Whether the job is still open *and* still the dispatch we were started
for: a `completed` or a re-dispatch that landed during a blocking herdr
wait must not be nudged (review R2). A missing record counts as gone;
an unreadable one as a transient."""
try:
with open(os.path.join(self.args.registry_dir, self.args.job + ".json")) as f:
rec = json.load(f)
except FileNotFoundError:
return False
except (OSError, ValueError):
return True
if rec.get("status") in TERMINAL_STATUSES:
return False
tok = self.args.expect_dispatch_token
if tok and rec.get("dispatch_token") and rec.get("dispatch_token") != tok:
return False
att = self.args.attempt_id
if att is not None and rec.get("attempt_id") is not None and int(rec["attempt_id"]) != int(att):
return False
return True
def run(self) -> int: def run(self) -> int:
st, sig, agent = self.st, Signals(), self.args.agent st, sig, agent = self.st, Signals(), self.args.agent
if not st.reliable: if not st.reliable:
@@ -268,7 +293,13 @@ class StallWatch(object):
fail_since, idle_since = None, None fail_since, idle_since = None, None
while True: while True:
sig.job_status = self._job_status() sig.job_status = self._job_status()
if sig.job_status in TERMINAL_STATUSES: if sig.job_status in TERMINAL_STATUSES or not self._dispatch_open():
return RC_TERMINAL
if self.args.watch_pid and not _alive(self.args.watch_pid):
# The wrapper is gone (killed, crashed): nobody will stop us, so
# stop ourselves. Checked between herdr calls, so at most one
# wait (<= 60 s) late.
log.info("stall_watch: wrapper pid %d is gone; stall watch stops", self.args.watch_pid)
return RC_TERMINAL return RC_TERMINAL
if sig.agent_status is None: if sig.agent_status is None:
until, tmo = watcher.VALID_STATES, 1000 # learn the current state until, tmo = watcher.VALID_STATES, 1000 # learn the current state
@@ -298,6 +329,9 @@ class StallWatch(object):
continue continue
backoff.record_success() backoff.record_success()
fail_since = None fail_since = None
if not self._dispatch_open():
log.info("stall_watch: job %s closed or re-dispatched during the wait; stall watch stops", self.args.job)
return RC_TERMINAL
if out.kind == watcher.MATCHED: if out.kind == watcher.MATCHED:
new = out.agent.get("agent_status") new = out.agent.get("agent_status")
sig.pane_id = out.agent.get("pane_id") or sig.pane_id sig.pane_id = out.agent.get("pane_id") or sig.pane_id
@@ -347,14 +381,29 @@ class StallWatch(object):
self._notify_once("never_empty", "stall_watch:input_never_empty:%s" % agent, 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))) "stall_watch: input box of %s never looked empty in %ds" % (agent, int(sig.idle_for)))
if judge(sig, st) == NOTIFY_IDLE_AND_NUDGE: if judge(sig, st) == NOTIFY_IDLE_AND_NUDGE:
if not self._dispatch_open(): # collect() took time; the job may have closed meanwhile
log.info("stall_watch: job %s closed or re-dispatched before the nudge; nothing sent", self.args.job)
return idle_since
log.warning("stall_watch: agent %s idle for %ds with no background work; nudging once", log.warning("stall_watch: agent %s idle for %ds with no background work; nudging once",
self.args.target, int(sig.idle_for)) self.args.target, int(sig.idle_for))
self._notify("stalled:idle")
st.nudged = True st.nudged = True
if not self._notify("stalled:idle"):
log.warning("stall_watch: the stalled:idle notice was refused; nudge withheld")
return idle_since
self.src.prompt(NUDGE.format(job=self.args.job)) self.src.prompt(NUDGE.format(job=self.args.job))
return idle_since return idle_since
def _alive(pid: int) -> bool:
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
except PermissionError:
return True
return True
def main(argv=None) -> int: def main(argv=None) -> int:
p = argparse.ArgumentParser(description="notify-only stall watcher for one local herdr job (#30)") p = argparse.ArgumentParser(description="notify-only stall watcher for one local herdr job (#30)")
p.add_argument("--job", required=True) p.add_argument("--job", required=True)
@@ -364,6 +413,7 @@ def main(argv=None) -> int:
p.add_argument("--herdr-session", default="") p.add_argument("--herdr-session", default="")
p.add_argument("--expect-dispatch-token", default=None) p.add_argument("--expect-dispatch-token", default=None)
p.add_argument("--attempt-id", type=int, default=None) p.add_argument("--attempt-id", type=int, default=None)
p.add_argument("--watch-pid", type=int, default=None, help="exit once this process (the wrapper) is gone")
args = p.parse_args(argv) args = p.parse_args(argv)
logging.basicConfig(level=logging.INFO, format="%(asctime)s stall_watch %(levelname)s %(message)s") 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. # The wrapper stops us with SIGTERM once its subscriber ends: leave quietly.
@@ -48,6 +48,8 @@ multi-agent-mux-delegate-job submit \
# argv string is capped at 131,072 bytes on Linux. The loop always uses it. # argv string is capped at 131,072 bytes on Linux. The loop always uses it.
# MAM_JOB_TIMEOUT_S: the budget when --timeout is not given (default 3600). # MAM_JOB_TIMEOUT_S: the budget when --timeout is not given (default 3600).
# MAM_STALL_IDLE_S (600) / MAM_STALL_HERDR_FAIL_S (120) / MAM_STALL_WATCH=0: the stall watcher (Issue #30). # MAM_STALL_IDLE_S (600) / MAM_STALL_HERDR_FAIL_S (120) / MAM_STALL_WATCH=0: the stall watcher (Issue #30).
# The watcher lives exactly as long as the job's wrapper: the wrapper stops it (TERM, 5 s, then KILL)
# when its subscriber ends, and the watcher exits on its own once the wrapper pid is gone.
# --parent-job <id> (Issue #34): this job is delegated from job <id>, which # --parent-job <id> (Issue #34): this job is delegated from job <id>, which
# must exist in the registry (else exit 1). A loop waiting on <id> waits for # must exist in the registry (else exit 1). A loop waiting on <id> waits for
# it too and shows it to reviewers. Default: $MAM_JOB_ID (the remote runner # it too and shows it to reviewers. Default: $MAM_JOB_ID (the remote runner
@@ -96,9 +98,9 @@ Under fail-closed operation (`MAM_DELEGATE_FAIL_CLOSED=1`, default), the CLI ret
|:---:|:---:|:---:|---| |:---:|:---:|:---:|---|
| `0` | Success | `completed` | Job succeeded and completed event received or verified on disk with approval (`PASS`/`AGREE`). | | `0` | Success | `completed` | Job succeeded and completed event received or verified on disk with approval (`PASS`/`AGREE`). |
| `1` | Failure | `error` | Execution failure / delivery failure: no `started` within `--ack-timeout` T s after the injection (`agent_blocked`; the prompt is never re-injected), or the input never reached the pane. | | `1` | Failure | `error` | Execution failure / delivery failure: no `started` within `--ack-timeout` T s after the injection (`agent_blocked`; the prompt is never re-injected), or the input never reached the pane. |
| `2` | Timeout | `error` (`deadline_timeout`) | Subscriber timeout (total budget or idle timeout reached without terminal event). **R1 liveness grace (Issue #18)**: after the subscriber idles out the wrapper keeps re-observing the disk while the job's events.log shows an event at most K x N (`--idle-timeout`) old - the automatic heartbeat counts - up to the wall-clock `--timeout`. herdr status and the pane are logged as a diagnostic only. | | `2` | Timeout | `error` (`deadline_timeout`) | Subscriber idle timeout (K x N without an event; the total budget is the wall-clock row below). **R1 liveness grace (Issue #18)**: after the subscriber idles out the wrapper keeps re-observing the disk while the job's events.log shows an event at most K x N (`--idle-timeout`) old - the automatic heartbeat counts - up to the wall-clock `--timeout`. herdr status and the pane are logged as a diagnostic only. |
| `2` | Wall clock | `error` (`wall_clock_timeout`) | The budget (`--timeout`) ran out, events or not (Issue #33; was `deadline_timeout` before v4.7.5). | | `2` | Wall clock | `error` (`wall_clock_timeout`) | The budget (`--timeout`) ran out, events or not (Issue #33; was `deadline_timeout` before v4.8.0). |
| `3` | Broker Error | `error` (`deadline_timeout`) | Broker unavailable (`MQTT_BROKER_UNAVAILABLE`) and terminal status not found on disk during deadline. | | `3` | Broker Error | `error` (`wall_clock_timeout` once the budget is spent, else `deadline_timeout`) | Broker unavailable (`MQTT_BROKER_UNAVAILABLE`) and terminal status not found on disk during deadline. |
| `5` | Session Busy | `cancelled` (`session_busy`) | Precondition failed / `session_busy`: Preflight check (`preflight_agent`) or TUI idle handshake wait failed (prompt never delivered, retryable). | | `5` | Session Busy | `cancelled` (`session_busy`) | Precondition failed / `session_busy`: Preflight check (`preflight_agent`) or TUI idle handshake wait failed (prompt never delivered, retryable). |
In all execution paths, the final line of standard output prints the persistent audit log directory path (`<logs_root>/<job_id>`), fulfilling the parsing contract for orchestrator runners. In all execution paths, the final line of standard output prints the persistent audit log directory path (`<logs_root>/<job_id>`), fulfilling the parsing contract for orchestrator runners.
@@ -692,7 +692,7 @@ start_stall_watch() {
echo "stall watch: human job, no local pane - stall detection off" >&2; return 0 echo "stall watch: human job, no local pane - stall detection off" >&2; return 0
fi fi
local args=(--job "$JOB_ID" --registry-dir "$REGISTRY_DIR" --target "${session#herdr:}" local args=(--job "$JOB_ID" --registry-dir "$REGISTRY_DIR" --target "${session#herdr:}"
--agent "$agent" --herdr-session "${HERDR_SESSION_NAME:-}") --agent "$agent" --herdr-session "${HERDR_SESSION_NAME:-}" --watch-pid "$$")
[ -n "$tok" ] && args+=(--expect-dispatch-token "$tok") [ -n "$tok" ] && args+=(--expect-dispatch-token "$tok")
[ -n "$att" ] && args+=(--attempt-id "$att") [ -n "$att" ] && args+=(--attempt-id "$att")
PYTHONPATH="$SCRIPT_DIR/..${PYTHONPATH:+:$PYTHONPATH}" "$PY" -m lib_py.stall_watch "${args[@]}" \ PYTHONPATH="$SCRIPT_DIR/..${PYTHONPATH:+:$PYTHONPATH}" "$PY" -m lib_py.stall_watch "${args[@]}" \
@@ -703,11 +703,23 @@ start_stall_watch() {
# A watcher that already ended on its own with an error (herdr unavailable) # A watcher that already ended on its own with an error (herdr unavailable)
# is not left silent: its last lines go to this wrapper's stderr. # is not left silent: its last lines go to this wrapper's stderr.
# The stop is bounded: a TERM that lands in the watcher's fork-exec window is
# swallowed (bash defers terminating signals in the forked child until the
# exec), and an unbounded `wait` then hung this wrapper until someone killed
# the watcher by hand (PR #41 review N1). TERM, up to 5 s, then KILL.
stop_stall_watch() { stop_stall_watch() {
[ -n "${STALL_PID:-}" ] || return 0 [ -n "${STALL_PID:-}" ] || return 0
local rc=0 local rc=0 i
if kill -0 "$STALL_PID" 2>/dev/null; then if kill -0 "$STALL_PID" 2>/dev/null; then
kill "$STALL_PID" 2>/dev/null || true kill "$STALL_PID" 2>/dev/null || true
for ((i = 0; i < 25; i++)); do
kill -0 "$STALL_PID" 2>/dev/null || break
sleep 0.2
done
if kill -0 "$STALL_PID" 2>/dev/null; then
echo "stall watch for $JOB_ID ignored TERM; killed (pid $STALL_PID)" >&2
kill -9 "$STALL_PID" 2>/dev/null || true
fi
wait "$STALL_PID" 2>/dev/null || true wait "$STALL_PID" 2>/dev/null || true
else else
wait "$STALL_PID" 2>/dev/null || rc=$? wait "$STALL_PID" 2>/dev/null || rc=$?
+8 -4
View File
@@ -36,7 +36,8 @@ from lib_py.agents.input_region import extract_input_region # noqa: E402
from lib_py import stall_watch # noqa: E402 from lib_py import stall_watch # noqa: E402
from lib_py.loop import watcher # noqa: E402 from lib_py.loop import watcher # noqa: E402
pytestmark = pytest.mark.skipif(not sys.platform.startswith("linux"), reason="real process trees via /proc") # Only S6 needs Linux (/proc); the rest run on macOS too (PR #41 review M8).
PROC = pytest.mark.skipif(not sys.platform.startswith("linux"), reason="real process trees via /proc")
UNREACHABLE = {"MQTT_BROKER": "127.0.0.1", "MQTT_PORT": "9", "MQTT_TLS": "0"} UNREACHABLE = {"MQTT_BROKER": "127.0.0.1", "MQTT_PORT": "9", "MQTT_TLS": "0"}
@@ -327,6 +328,7 @@ def test_s5_the_adapters_resident_children_change_the_judgement(box, agent_name,
# S6 - the heartbeat timer is never background work # S6 - the heartbeat timer is never background work
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@PROC
def test_s6_the_heartbeat_timer_is_not_a_background_child(box): def test_s6_the_heartbeat_timer_is_not_a_background_child(box):
_, state, reg, env = box _, state, reg, env = box
jid = _job(env, reg) jid = _job(env, reg)
@@ -687,12 +689,14 @@ def test_r4_a_missed_transition_restarts_the_idle_window(mam_sandbox, mock_herdr
#!/usr/bin/env python3 #!/usr/bin/env python3
import json, sys, time import json, sys, time
from pathlib import Path from pathlib import Path
# time.time(), not monotonic: these stamps are compared across processes,
# and macOS gives each process its own monotonic reference.
f = Path(__file__).parent.parent / 'observed.json' f = Path(__file__).parent.parent / 'observed.json'
state = json.loads(f.read_text()) state = json.loads(f.read_text())
args = sys.argv[1:] args = sys.argv[1:]
if args[:2] == ['agent', 'wait']: if args[:2] == ['agent', 'wait']:
if not state.get('started'): if not state.get('started'):
state['started'] = time.monotonic() state['started'] = time.time()
f.write_text(json.dumps(state)) f.write_text(json.dumps(state))
print(json.dumps({'result': {'agent': {'agent_status': 'idle', 'state_change_seq': 0, 'pane_id': 'w1:p1'}}})) print(json.dumps({'result': {'agent': {'agent_status': 'idle', 'state_change_seq': 0, 'pane_id': 'w1:p1'}}}))
else: else:
@@ -701,7 +705,7 @@ def test_r4_a_missed_transition_restarts_the_idle_window(mam_sandbox, mock_herdr
sys.exit(1) sys.exit(1)
elif args[:2] == ['agent', 'get']: elif args[:2] == ['agent', 'get']:
if state['miss'] and not state.get('changed'): if state['miss'] and not state.get('changed'):
state['changed'] = time.monotonic() state['changed'] = time.time()
state['seq'] = 2 state['seq'] = 2
f.write_text(json.dumps(state)) f.write_text(json.dumps(state))
print(json.dumps({'result': {'agent': {'agent_status': 'idle', 'state_change_seq': state['seq'], 'pane_id': 'w1:p1'}}})) print(json.dumps({'result': {'agent': {'agent_status': 'idle', 'state_change_seq': state['seq'], 'pane_id': 'w1:p1'}}}))
@@ -712,7 +716,7 @@ def test_r4_a_missed_transition_restarts_the_idle_window(mam_sandbox, mock_herdr
elif args[:2] == ['agent', 'read']: elif args[:2] == ['agent', 'read']:
print(state['pane']) print(state['pane'])
elif args[:2] == ['agent', 'prompt']: elif args[:2] == ['agent', 'prompt']:
state['prompted'] = time.monotonic() state['prompted'] = time.time()
f.write_text(json.dumps(state)) f.write_text(json.dumps(state))
print('{}') print('{}')
else: else:
+326
View File
@@ -0,0 +1,326 @@
"""Issue #30 review (PR #41, N1): the wrapper never waits forever for its
stall watcher, and the watcher never outlives its wrapper.
The wrapper stops the watcher with SIGTERM the moment its subscriber ends.
A TERM that lands inside the watcher's fork-exec window is swallowed (bash
defers terminating signals in the forked child until the exec), so with an
unbounded `wait` the wrapper hung until someone killed the watcher by hand.
Both tests run on macOS and Linux: no /proc, no live broker.
"""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
import time
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
SKILLS = REPO_ROOT / ".agents" / "skills"
WRAPPER = SKILLS / "multi-agent-mux-delegate-job" / "multi-agent-mux-delegate-job"
PUBLISH = SKILLS / "multi-agent-mux-delegate-job" / "scripts" / "publish_event.py"
UNREACHABLE = {"MQTT_BROKER": "127.0.0.1", "MQTT_HOST": "127.0.0.1", "MQTT_PORT": "65534", "MQTT_TLS": "0"}
def _env(home: Path, **extra) -> dict:
env = {k: v for k, v in os.environ.items() if not k.startswith("HERDR_")}
env.update(UNREACHABLE, DELEGATE_JOB_PYTHON=sys.executable, AGENT_PYTHON_BIN=sys.executable,
PYTHONPATH=str(SKILLS), MAM_JOB_ID="", HOME=str(home), WORKSPACE_ROOT=str(home),
DELEGATE_JOB_LOGS_DIR=str(home / ".mam" / "logs"))
env.update({k: str(v) for k, v in extra.items()})
return env
def _record(reg: Path, jid: str) -> dict:
return json.loads((reg / f"{jid}.json").read_text())
def _publish(env: dict, reg: Path, jid: str, event: str, *extra: str) -> None:
res = subprocess.run([sys.executable, str(PUBLISH), "--registry-dir", str(reg), "--job", jid, "--event", event,
"--expect-dispatch-token", _record(reg, jid)["dispatch_token"], "--attempts", "1", *extra],
capture_output=True, text=True, env=env, timeout=120)
assert res.returncode in (0, 2), res.stderr
def _alive(pid: int) -> bool:
try:
os.kill(pid, 0)
except ProcessLookupError:
return False
return True
def _until(pred, limit=20.0):
end = time.monotonic() + limit
while time.monotonic() < end:
if pred():
return True
time.sleep(0.2)
return pred()
def test_stop_stall_watch_is_bounded_when_the_watcher_ignores_term(tmp_path):
"""The wrapper's stop routine, run as is against a stand-in that ignores
SIGTERM (what a swallowed TERM looks like from the wrapper's side): it
returns within its 5 s budget, says so, and the stand-in is dead."""
fn = subprocess.run(["sed", "-n", "/^stop_stall_watch()/,/^}/p", str(WRAPPER)],
capture_output=True, text=True, check=True).stdout
assert fn.startswith("stop_stall_watch()") and fn.rstrip().endswith("}")
script = (fn + '\nJOB_ID=t; REGISTRY_DIR="$1"\n'
'(trap "" TERM; exec sleep 300) & STALL_PID=$!\n'
'sleep 0.5; t0=$SECONDS; stop_stall_watch; echo "took=$((SECONDS - t0))"\n'
'kill -0 "$!" 2>/dev/null && echo "stand-in alive" || echo "stand-in gone"\n')
t0 = time.monotonic()
res = subprocess.run(["bash", "-c", script, "stop", str(tmp_path)], capture_output=True, text=True, timeout=30)
took = time.monotonic() - t0
assert res.returncode == 0, res.stdout + res.stderr
assert "stand-in gone" in res.stdout, res.stdout
assert "stall watch for t ignored TERM; killed" in res.stderr, res.stderr
assert 4 < took < 12, took
def test_the_wrapper_returns_when_its_watcher_ignores_term(tmp_path, mock_herdr):
"""The Issue #14 ack scenario that hung: broker unreachable, the RPC fails,
the registry ack wins, the subscriber ends at once and the wrapper stops
the watcher. Here the watcher is a stand-in whose shim ignores SIGTERM from
its first line: the wrapper must still return, on its usual
broker_unavailable path, and leave no watcher behind."""
reg = tmp_path / "jobs"
reg.mkdir()
pidfile = tmp_path / "watcher.pid"
shim = tmp_path / "py-shim"
shim.write_text(
"#!/bin/sh\n"
'case "$*" in *lib_py.stall_watch*)\n'
" trap '' TERM\n"
f' exec "{sys.executable}" -c "import os, time; '
f"open({str(pidfile)!r}, 'w').write(str(os.getpid())); time.sleep(300)\" ;;\n"
"esac\n"
f'exec "{sys.executable}" "$@"\n')
shim.chmod(0o755)
state_file = Path(mock_herdr)
state = json.loads(state_file.read_text())
state["agents"]["ack-agent"] = {"agent": "codex", "agent_status": "idle", "prompt_wait_result": "transport_error"}
state["prompt_wait_result"] = "transport_error"
state_file.write_text(json.dumps(state, indent=2))
env = _env(tmp_path, DELEGATE_JOB_PYTHON=str(shim), SKS_IDLE_TIMEOUT_MS=500, MAM_INJECT_ACK_GRACE_S=8)
proc = subprocess.Popen(["bash", str(WRAPPER), "submit", "--agent", "codex", "--agent-session", "herdr:ack-agent",
"--prompt", "issue 30 stop", "--registry-dir", str(reg), "--workdir", str(tmp_path),
"--timeout", "6", "--idle-timeout", "2"],
cwd=str(tmp_path), env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
try:
assert _until(lambda: list(reg.glob("*.json")), 10), "job was not registered"
jid = list(reg.glob("*.json"))[0].stem
_publish(env, reg, jid, "started", "--attempt-id", "1")
t0 = time.monotonic()
stdout, stderr = proc.communicate(timeout=60)
took = time.monotonic() - t0
finally:
if proc.poll() is None:
proc.kill()
assert proc.returncode == 3, f"expected the broker_unavailable path (rc=3), got {proc.returncode}\n{stderr}\n{stdout}"
# ack grace (8 s) + the 5 s TERM budget + slack; an unbounded wait never returns.
assert took < 40, took
if pidfile.exists(): # the stand-in got past its shim (a TERM in the shim's own start-up can still end it)
assert _until(lambda: not _alive(int(pidfile.read_text())), 5), "the watcher outlived the wrapper"
assert re.search(r"stall watch for \S+ ignored TERM", stderr), stderr
def test_the_watcher_stops_once_its_wrapper_is_gone(mam_sandbox, mock_herdr):
"""`--watch-pid`: a watcher whose wrapper vanished (killed, crashed) exits
with rc 0 on its own instead of polling herdr until the job closes."""
reg = mam_sandbox / ".mam" / "jobs"
env = _env(mam_sandbox, MAM_STALL_IDLE_S=600)
res = subprocess.run(["bash", str(WRAPPER), "submit", "--agent", "human", "--agent-session", "herdr:img",
"--dry-run", "--registry-dir", str(reg), "--prompt", "work"],
capture_output=True, text=True, env=env, cwd=str(mam_sandbox), timeout=120)
assert res.returncode == 0, res.stdout + res.stderr
jid = re.search(r"registered job:\s*(\S+)", res.stdout).group(1)
_publish(env, reg, jid, "started")
assert _record(reg, jid)["status"] == "running"
state_file = Path(mock_herdr)
state = json.loads(state_file.read_text())
state["wait_blocks_s"] = 0.5
state.setdefault("agents", {})["creator-codex-t"] = {
"name": "creator-codex-t", "agent": "codex", "status": "running", "agent_status": "idle",
"pane_id": "w1:p1", "workspace_id": "w1", "pid": 4242, "buffer": "› Ask Codex to do anything"}
state_file.write_text(json.dumps(state, indent=2))
stand_in = subprocess.Popen(["sleep", "300"])
w = subprocess.Popen([sys.executable, "-m", "lib_py.stall_watch", "--job", jid, "--registry-dir", str(reg),
"--target", "creator-codex-t", "--agent", "codex", "--watch-pid", str(stand_in.pid)],
env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
try:
time.sleep(3)
assert w.poll() is None, "the watcher left while its wrapper was alive:\n" + w.stdout.read()
stand_in.kill()
stand_in.wait()
out, _ = w.communicate(timeout=30)
finally:
if w.poll() is None:
w.kill()
assert w.returncode == 0, out
assert f"wrapper pid {stand_in.pid} is gone" in out, out
assert _record(reg, jid)["status"] == "running" # notify-only: it closed nothing
# ---------------------------------------------------------------------------
# codex R1 - the real wrapper killed by TERM leaves no watcher behind
# ---------------------------------------------------------------------------
def test_a_terminated_wrapper_leaves_no_watcher_behind(mam_sandbox, mock_herdr):
"""The wrapper's own functions: `start_stall_watch` then `wait`, and TERM to
the wrapper only. The orphaned watcher must end on its own (rc 0), since
no trap can run for a wrapper that is simply gone."""
reg = mam_sandbox / ".mam" / "jobs"
env = _env(mam_sandbox, MAM_STALL_IDLE_S=600)
res = subprocess.run(["bash", str(WRAPPER), "submit", "--agent", "human", "--agent-session", "herdr:img",
"--dry-run", "--registry-dir", str(reg), "--prompt", "work"],
capture_output=True, text=True, env=env, cwd=str(mam_sandbox), timeout=120)
assert res.returncode == 0, res.stdout + res.stderr
jid = re.search(r"registered job:\s*(\S+)", res.stdout).group(1)
_publish(env, reg, jid, "started")
state_file = Path(mock_herdr)
state = json.loads(state_file.read_text())
state["wait_blocks_s"] = 0.5
state.setdefault("agents", {})["creator-codex-t"] = {
"name": "creator-codex-t", "agent": "codex", "status": "running", "agent_status": "idle",
"pane_id": "w1:p1", "workspace_id": "w1", "pid": 4242, "buffer": "› Ask Codex to do anything"}
state_file.write_text(json.dumps(state, indent=2))
script = ('MAM_DELEGATE_JOB_NO_MAIN=1 source "$1"; JOB_ID="$2"; REGISTRY_DIR="$3"; PY="$4"\n'
'start_stall_watch herdr:creator-codex-t codex\n'
'echo "WATCHER=$STALL_PID"; wait\n')
proc = subprocess.Popen(["bash", "-c", script, "probe", str(WRAPPER), jid, str(reg), sys.executable],
env=env, cwd=str(mam_sandbox), stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
wpid = None
try:
for line in proc.stdout:
if line.startswith("WATCHER="):
wpid = int(line.split("=", 1)[1])
break
assert wpid and _alive(wpid), "the watcher did not start"
time.sleep(2)
proc.terminate() # TERM to the wrapper only
proc.wait(timeout=10)
assert _until(lambda: not _alive(wpid), 30), "the watcher outlived its wrapper"
finally:
if proc.poll() is None:
proc.kill()
if wpid and _alive(wpid):
os.kill(wpid, 9)
out = (reg / f"{jid}.stall.out").read_text()
assert f"wrapper pid {proc.pid} is gone" in out, out
assert _record(reg, jid)["status"] == "running"
# ---------------------------------------------------------------------------
# codex R2 - a job that closed during a blocking wait is not nudged
# ---------------------------------------------------------------------------
class _Src:
"""A source whose facts always say "idle, alone, empty input"."""
def __init__(self):
self.prompts = []
def collect(self, sig):
sig.state_change_seq, sig.agent_pid, sig.background_children, sig.input_empty = 1, 4242, [], True
return sig
def prompt(self, text):
self.prompts.append(text)
def blocked_text(self, pane_id):
return ""
def agent_get(self):
return {"pane_id": "w1:p1"}
def _watch_in_process(tmp_path, monkeypatch, waits, notify):
from lib_py import stall_watch
from lib_py.loop import watcher as w
import argparse
import uuid
jid = uuid.uuid4().hex[:8]
rec = tmp_path / f"{jid}.json"
rec.write_text(json.dumps({"job_id": jid, "status": "running", "dispatch_token": "tok", "attempt_id": 1}))
calls = []
def fake_wait_once(target, until=(), timeout_ms=None, session=None, run=None):
calls.append(until)
return waits[min(len(calls), len(waits)) - 1](rec)
monkeypatch.setattr(stall_watch.watcher, "wait_once", fake_wait_once)
monkeypatch.setenv("MAM_STALL_IDLE_S", "1")
args = argparse.Namespace(job=jid, registry_dir=str(tmp_path), target="creator-codex-t", agent="codex",
herdr_session="", expect_dispatch_token="tok", attempt_id=1, watch_pid=None)
src = _Src()
sw = stall_watch.StallWatch(args, source=src)
notes = []
monkeypatch.setattr(sw, "_notify", lambda detail: notes.append(detail) or notify)
rc = sw.run()
return rc, src.prompts, notes, rec, w
def _idle(rec):
from lib_py.loop import watcher as w
return w.WaitOutcome(w.MATCHED, agent={"agent_status": "idle", "state_change_seq": 1, "pane_id": "w1:p1"})
def _completed_during_the_wait(rec):
from lib_py.loop import watcher as w
time.sleep(1.2) # past S while "blocked in herdr"
d = json.loads(rec.read_text())
d["status"] = "completed"
rec.write_text(json.dumps(d))
return w.WaitOutcome(w.TIMEOUT, error_code="timeout")
def _redispatched_during_the_wait(rec):
from lib_py.loop import watcher as w
time.sleep(1.2)
d = json.loads(rec.read_text())
if d.get("ticks"): # a watcher that ignores the new token is stopped by the close on the next tick
d["status"] = "completed"
d["ticks"] = d.get("ticks", 0) + 1
d["dispatch_token"], d["attempt_id"] = "tok-2", 2
rec.write_text(json.dumps(d))
return w.WaitOutcome(w.TIMEOUT, error_code="timeout")
def test_a_job_completed_during_the_wait_is_not_nudged(tmp_path, monkeypatch):
rc, prompts, notes, _, _ = _watch_in_process(tmp_path, monkeypatch, [_idle, _completed_during_the_wait], True)
assert rc == 0 and prompts == [] and notes == [], (prompts, notes)
def test_a_job_redispatched_during_the_wait_is_not_nudged(tmp_path, monkeypatch):
rc, prompts, notes, rec, _ = _watch_in_process(tmp_path, monkeypatch, [_idle, _redispatched_during_the_wait], True)
assert rc == 0 and prompts == [] and notes == [], (prompts, notes)
assert json.loads(rec.read_text())["status"] == "running" # it left on the token, and closed nothing
def test_a_refused_idle_notice_withholds_the_nudge(tmp_path, monkeypatch):
"""The registry (CAS) or the wire refused the `stalled:idle` progress: no
prompt goes to the pane, and the job is then left alone (nudged once)."""
def _idle_then_close(rec):
from lib_py.loop import watcher as w
time.sleep(1.2)
d = json.loads(rec.read_text())
# the second idle tick nudges (or not); the third finds the job closed and ends the run
if d.get("ticks"):
d["status"] = "completed"
d["ticks"] = d.get("ticks", 0) + 1
rec.write_text(json.dumps(d))
return w.WaitOutcome(w.TIMEOUT, error_code="timeout")
rc, prompts, notes, _, _ = _watch_in_process(tmp_path, monkeypatch, [_idle, _idle_then_close], False)
assert rc == 0 and notes == ["stalled:idle"] and prompts == [], (prompts, notes)