fix(issue-18): admit remote attempts atomically, end timed-out commands with their group, fence heartbeats off the terminal, renew liveness only on news
Review round 2 (planner-reviewer-codex-01, NOT PASS at d09c162):
- R1 remote.mirror_job: admission under the registry lock. The same
dispatch_token is idempotent (a finished attempt never runs again), a
lower attempt or the same attempt under another token is refused before
any write, and only a strictly newer attempt replaces the record
(register_job(supersede_older_attempt=True); events.log kept, seqs stay
monotonic).
- R2 remote.handle_dispatch: the --exec command runs in its own session;
at timeout_sec, or when the runner is interrupted (SIGTERM now unwinds
it), the whole process group is terminated (TERM, then KILL) before the
error is published.
- R3 terminal barrier: a terminal publish raises an attempt-scoped barrier
under the lock that allocates seqs, stops the heartbeat timer, gives it
MAM_HEARTBEAT_DRAIN_S (default 1 s) to exit and then kills it, and only
then takes its own seq. Beats are admitted under that lock
(next_seq(admit=...)); a beat still on the wire is cancelled with the
timer rather than holding the terminal back; and a late non-terminal
event never publishes a running state over a terminal record.
- R4 job_subscriber: a retained state renews the idle clock only when its
seq is newer than everything already accepted; re-delivered or older
snapshots still recover a terminal status.
- tests: five regressions, all failing on 77f2829; T1-14's wall clock
15 -> 30 s against public-broker latency.
- docs: MESSAGING 2.5 (barrier, state renewal, detection vs final close),
heartbeat docstring, loop SKILL.md (remote rows need the Python runner),
VERSIONS v4.5.0 items 1/4/5 and the test count (25).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HCGpJXhSZasJLU38ksDghe
This commit is contained in:
1 parent
77f2829b8e
commit
135d76f903
11 files changed
+577
-67
No files matched your search
@@ -33,6 +33,7 @@ import logging
|
||||
import os
|
||||
import queue
|
||||
import shlex
|
||||
import signal
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
@@ -153,15 +154,39 @@ def clear_dispatch(config, topic: str) -> None:
|
||||
# remote side
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _attempt_of(record: Dict[str, Any]) -> int:
|
||||
try:
|
||||
return int(record.get("attempt_id", record.get("iteration", 1)) or 1)
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
|
||||
|
||||
def mirror_job(d: Dict[str, Any], registry_dir: str, session: str) -> Optional[str]:
|
||||
"""Create the local job record a remote agent publishes against.
|
||||
"""Create the local job record a remote agent publishes against, or refuse
|
||||
the dispatch (None).
|
||||
|
||||
The record carries the orchestrator's job_id, auth_token, dispatch_token
|
||||
and attempt_id, so `publish_event` on this host produces events the
|
||||
orchestrator's subscriber accepts. A previous attempt's record (a
|
||||
re-dispatch) is retired first: creation is the only write allowed here.
|
||||
orchestrator's subscriber accepts. Admission (review round 2, codex R1): a
|
||||
valid signature proves where a dispatch came from, not that it is current,
|
||||
so
|
||||
|
||||
* the same dispatch_token is idempotent while its attempt is open, and is
|
||||
never run again once that attempt reached a terminal;
|
||||
* a lower attempt, or the same attempt under another token, is a stale or
|
||||
conflicting copy: refused before anything is written, so the record and
|
||||
its events.log stay as they are;
|
||||
* only a strictly newer attempt replaces the record, and that check and
|
||||
the write are one step under the registry lock
|
||||
(``register_job(supersede_older_attempt=True)``). The events.log is kept:
|
||||
the history of earlier attempts is not erased.
|
||||
"""
|
||||
jid = d["job_id"]
|
||||
try:
|
||||
incoming = int(d.get("attempt_id") or 1)
|
||||
except (TypeError, ValueError):
|
||||
logger.warning("drop dispatch for %s: attempt_id %r is not a number", jid, d.get("attempt_id"))
|
||||
return None
|
||||
path = mqtt_common._job_path(jid, registry_dir)
|
||||
if path.exists():
|
||||
old = load_job(jid, registry_dir)
|
||||
@@ -169,28 +194,32 @@ def mirror_job(d: Dict[str, Any], registry_dir: str, session: str) -> Optional[s
|
||||
if old.get("status") in ("completed", "error", "cancelled"):
|
||||
return None # this attempt already ran to its end: a re-sent dispatch is not run again
|
||||
return jid # same attempt, already mirrored (duplicate delivery)
|
||||
with mqtt_common.registry_lock(registry_dir):
|
||||
path.unlink()
|
||||
log = mqtt_common._job_path(jid, registry_dir).with_suffix(".events.log")
|
||||
if log.exists():
|
||||
log.unlink()
|
||||
registry.register_job(
|
||||
prompt=d.get("brief", ""),
|
||||
agent=d.get("agent") or "remote",
|
||||
agent_session=SESSION_PREFIX + session,
|
||||
role=d.get("role") or "Worker",
|
||||
timeout_sec=int(d.get("timeout_sec") or 3600),
|
||||
idle_timeout_sec=d.get("idle_timeout_sec"),
|
||||
registry_dir=registry_dir,
|
||||
job_id=jid,
|
||||
auth_token=d.get("auth_token"),
|
||||
heartbeat_sec=int(d.get("heartbeat_sec") or registry.DEFAULT_HEARTBEAT_SEC),
|
||||
heartbeat_misses=int(d.get("heartbeat_misses") or registry.DEFAULT_HEARTBEAT_MISSES),
|
||||
ack_timeout_sec=int(d.get("ack_timeout_sec") or registry.DEFAULT_ACK_TIMEOUT_SEC),
|
||||
dispatch_token=d.get("dispatch_token"),
|
||||
attempt_id=int(d.get("attempt_id") or 1),
|
||||
transport="remote",
|
||||
)
|
||||
if incoming <= _attempt_of(old):
|
||||
logger.warning("refusing dispatch for %s attempt %s: this runner already holds attempt %s "
|
||||
"(a stale or conflicting copy)", jid, incoming, _attempt_of(old))
|
||||
return None
|
||||
try:
|
||||
registry.register_job(
|
||||
prompt=d.get("brief", ""),
|
||||
agent=d.get("agent") or "remote",
|
||||
agent_session=SESSION_PREFIX + session,
|
||||
role=d.get("role") or "Worker",
|
||||
timeout_sec=int(d.get("timeout_sec") or 3600),
|
||||
idle_timeout_sec=d.get("idle_timeout_sec"),
|
||||
registry_dir=registry_dir,
|
||||
job_id=jid,
|
||||
auth_token=d.get("auth_token"),
|
||||
heartbeat_sec=int(d.get("heartbeat_sec") or registry.DEFAULT_HEARTBEAT_SEC),
|
||||
heartbeat_misses=int(d.get("heartbeat_misses") or registry.DEFAULT_HEARTBEAT_MISSES),
|
||||
ack_timeout_sec=int(d.get("ack_timeout_sec") or registry.DEFAULT_ACK_TIMEOUT_SEC),
|
||||
dispatch_token=d.get("dispatch_token"),
|
||||
attempt_id=incoming,
|
||||
transport="remote",
|
||||
supersede_older_attempt=True,
|
||||
)
|
||||
except FileExistsError as exc: # lost the race to a newer (or equal) attempt under the lock
|
||||
logger.warning("refusing dispatch for %s: %s", jid, exc)
|
||||
return None
|
||||
brief_dir = os.path.join(registry_dir, jid)
|
||||
os.makedirs(brief_dir, exist_ok=True)
|
||||
with open(os.path.join(brief_dir, "brief.md"), "w", encoding="utf-8") as fh:
|
||||
@@ -212,13 +241,68 @@ def _publish(jid: str, registry_dir: str, event: str, detail: str, d: Dict[str,
|
||||
return publish_event.main(argv)
|
||||
|
||||
|
||||
def _group_alive(pgid: int) -> bool:
|
||||
try:
|
||||
os.killpg(pgid, 0)
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _terminate_group(proc: "subprocess.Popen", grace: float = 5.0) -> None:
|
||||
"""TERM, then KILL, the agent command's own process group (it runs in its
|
||||
own session) and wait until the group is gone. Only that owned group is
|
||||
signalled (review round 2, codex R2)."""
|
||||
for sig, wait in ((signal.SIGTERM, grace), (signal.SIGKILL, 2.0)):
|
||||
try:
|
||||
os.killpg(proc.pid, sig)
|
||||
except OSError:
|
||||
return # the group is already gone
|
||||
deadline = time.monotonic() + wait
|
||||
while time.monotonic() < deadline:
|
||||
proc.poll() # reap the shell itself
|
||||
if not _group_alive(proc.pid):
|
||||
return
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
def _run_agent_command(exec_cmd: str, env: Dict[str, str], timeout: float):
|
||||
"""Run the agent command in its own session; returns (rc, stdout, stderr,
|
||||
timed_out). At the deadline - or when the runner itself is interrupted -
|
||||
the whole process group is terminated before this returns, so no
|
||||
descendant keeps working past the job's timeout or overlaps the next
|
||||
dispatch (review round 2, codex R2: `subprocess.run(shell=True,
|
||||
timeout=...)` killed only the shell)."""
|
||||
proc = subprocess.Popen(exec_cmd, shell=True, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
text=True, cwd=os.getcwd(), start_new_session=True)
|
||||
try:
|
||||
out, err = proc.communicate(timeout=timeout)
|
||||
return proc.returncode, out, err, False
|
||||
except subprocess.TimeoutExpired:
|
||||
_terminate_group(proc)
|
||||
try:
|
||||
out, err = proc.communicate(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
# a descendant that left the group still holds the pipes: stop reading
|
||||
for stream in (proc.stdout, proc.stderr):
|
||||
try:
|
||||
stream.close()
|
||||
except Exception:
|
||||
pass
|
||||
out, err = "", ""
|
||||
return 124, out, err, True
|
||||
except BaseException:
|
||||
_terminate_group(proc)
|
||||
raise
|
||||
|
||||
|
||||
def handle_dispatch(d: Dict[str, Any], registry_dir: str, session: str,
|
||||
exec_cmd: Optional[str], python: Optional[str] = None) -> int:
|
||||
"""Run one dispatched job to its terminal event. Returns the agent rc."""
|
||||
jid = mirror_job(d, registry_dir, session)
|
||||
if jid is None:
|
||||
logger.info("dispatch for %s attempt %s already finished here; ignoring the re-sent copy",
|
||||
d.get("job_id"), d.get("attempt_id"))
|
||||
logger.info("dispatch for %s attempt %s not admitted (already finished here, or a stale or "
|
||||
"conflicting copy); not running it", d.get("job_id"), d.get("attempt_id"))
|
||||
return 0
|
||||
heartbeat = int(d.get("heartbeat_sec") or registry.DEFAULT_HEARTBEAT_SEC)
|
||||
rc_started = _publish(jid, registry_dir, "started", "remote runner {} claimed the job".format(session),
|
||||
@@ -245,17 +329,15 @@ def handle_dispatch(d: Dict[str, Any], registry_dir: str, session: str,
|
||||
+ (["--attempt-id", str(d["attempt_id"])] if d.get("attempt_id") is not None else [])),
|
||||
})
|
||||
timeout = float(job.get("timeout_sec", 3600))
|
||||
try:
|
||||
res = subprocess.run(exec_cmd, shell=True, env=env, capture_output=True, text=True,
|
||||
timeout=timeout, cwd=os.getcwd())
|
||||
rc = res.returncode
|
||||
tail = (res.stdout or "").strip().splitlines()
|
||||
rc, out, err, timed_out = _run_agent_command(exec_cmd, env, timeout)
|
||||
if timed_out:
|
||||
detail = "agent command exceeded timeout_sec {}".format(int(timeout))
|
||||
else:
|
||||
tail = (out or "").strip().splitlines()
|
||||
detail = tail[-1][:200] if tail else "exit {}".format(rc)
|
||||
if rc != 0:
|
||||
err = (res.stderr or "").strip().splitlines()
|
||||
detail = "exit {}: {}".format(rc, (err[-1] if err else detail)[:200])
|
||||
except subprocess.TimeoutExpired:
|
||||
rc, detail = 124, "agent command exceeded timeout_sec {}".format(int(timeout))
|
||||
errs = (err or "").strip().splitlines()
|
||||
detail = "exit {}: {}".format(rc, (errs[-1] if errs else detail)[:200])
|
||||
cur = load_job(jid, registry_dir)
|
||||
if cur.get("status") not in ("completed", "error", "cancelled"):
|
||||
_publish(jid, registry_dir, "completed" if rc == 0 else "error", detail, d)
|
||||
@@ -316,6 +398,16 @@ def run_runner(session: str, registry_dir: str, exec_cmd: Optional[str] = None,
|
||||
client.reconnect_delay_set(min_delay=1, max_delay=16)
|
||||
mqtt_common.with_retry(lambda: client.connect(cfg.host, cfg.port, cfg.keepalive),
|
||||
attempts=3, base_delay=0.2, max_delay=1.0)()
|
||||
# Review round 2 (codex R2): SIGTERM unwinds the runner like an interrupt,
|
||||
# so a command still running is terminated together with its process group
|
||||
# (_run_agent_command) instead of being left behind as an orphan.
|
||||
def _on_term(signum, _frame):
|
||||
raise SystemExit(128 + signum)
|
||||
|
||||
try:
|
||||
previous_term = signal.signal(signal.SIGTERM, _on_term)
|
||||
except ValueError: # not the main thread (a caller driving the runner in-process)
|
||||
previous_term = None
|
||||
client.loop_start()
|
||||
deadline = time.monotonic() + timeout if timeout else None
|
||||
handled = 0
|
||||
@@ -339,6 +431,8 @@ def run_runner(session: str, registry_dir: str, exec_cmd: Optional[str] = None,
|
||||
if once:
|
||||
return 0
|
||||
finally:
|
||||
if previous_term is not None:
|
||||
signal.signal(signal.SIGTERM, previous_term)
|
||||
client.loop_stop()
|
||||
try:
|
||||
client.disconnect()
|
||||
|
||||
Reference in new issue
Block a user