fix(issue-18): bind the heartbeat timer to the agent process; scope started to the attempt; ignore re-sent finished dispatches

Review round 1 (claude NOT PASS) corrections:
- B2: the timer now watches a process - `--watch-pid`, or by default the
  nearest agent-CLI ancestor of the publishing command - and stops within
  one period once it is gone, so a crashed agent stops heartbeating and is
  closed after K x N; a remote runner watches itself. Documented as process
  liveness (an alive-but-stuck agent keeps heartbeating).
- R1: `_wait_started` accepts an events.log `started` only when it carries
  the current dispatch token (loop jobs share one events.log).
- R2: the remote runner ignores a re-sent dispatch of an attempt that
  already reached its terminal.
- R3: registry.md EOF blank line (git diff --check clean).
- R4: the ack error states the effective budget when the wall clock cut it.
- P3: stop_timer verifies the pidfile pid is this job's timer before
  signalling; a retained state message keeps its own seq watermark so it
  can never make the monotonic guard drop its own event.
- MULTI_AGENT_RULES.md timeout section updated for the contract.
- tests: agent-process death stops the timer, recycled-pid safety,
  attempt-scoped started, re-sent dispatch; acceptance 3 now kills the
  agent process instead of the timer. Help captures refreshed
  (--watch-pid). The wrapper can be sourced with
  MAM_DELEGATE_JOB_NO_MAIN=1 for helper-level tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
This commit is contained in:
GodopuandClaude Fable 5.1 committed 2026-09-13 19:48:03 +09:00
1 parent c17c87439d
commit 0550320590
11 files changed
+268 -27

No files matched your search

+2 -1
View File
@@ -168,7 +168,8 @@ To ensure that agents running in TMUX environments do not lose debug logs or pre
- **Identity Verification**: The guard validates process liveness via `pid` + `lstart` to prevent livelocks on PID rollover. - **Identity Verification**: The guard validates process liveness via `pid` + `lstart` to prevent livelocks on PID rollover.
### ⏱️ Timeout Configuration & Alignment Rules ### ⏱️ Timeout Configuration & Alignment Rules
- **Job Execution Limits (`timeout_sec` & `idle_timeout_sec`)**: Each job independently manages its overall execution timeout (`timeout_sec`, default 3600s) and idle timeout without receiving messages (`idle_timeout_sec`, default 120s). - **Job Execution Limits (`timeout_sec` & `idle_timeout_sec`)**: Each job independently manages its overall execution timeout (`timeout_sec`, default 3600s) and idle timeout without receiving messages (`idle_timeout_sec`). Since v4.5.0 (Issue #18) the idle timeout is the liveness contract's K x N: the agent heartbeats every `heartbeat_sec` (N, default 30 — automatic once it publishes `started --heartbeat N`), and `heartbeat_misses` (K, default 4) missed heartbeats close the job as `deadline_timeout` (default 120 s, `--idle-timeout` overrides). `started` must arrive within `ack_timeout_sec` (T, default 60) of the injection or the job fails as `agent_blocked` without re-injection. herdr status and pane text are never used for these decisions.
- **Heartbeat timer and agent life**: the automatic timer is bound to the agent process that ran the start command (the nearest agent-CLI ancestor, or `--watch-pid`) and stops within one period once that process is gone; a crashed agent is therefore closed after K x N like any silent one, while an agent that is alive but stuck keeps heartbeating (process liveness, not work liveness).
- **Monitor Idle Waiting (`SUB_IDLE_TIMEOUT`)**: The idle timeout for the monitor script (`reconcile.sh`), `SUB_IDLE_TIMEOUT`, must always be set generously to `3600s` (1 hour) or more to align with the maximum job budget. This prevents the monitor from terminating early due to idle detection, which would lose control over background tasks before they finish. - **Monitor Idle Waiting (`SUB_IDLE_TIMEOUT`)**: The idle timeout for the monitor script (`reconcile.sh`), `SUB_IDLE_TIMEOUT`, must always be set generously to `3600s` (1 hour) or more to align with the maximum job budget. This prevents the monitor from terminating early due to idle detection, which would lose control over background tasks before they finish.
--- ---
+102 -7
View File
@@ -18,6 +18,15 @@ timer stops on its own when
A terminal publish also stops it directly through the pidfile A terminal publish also stops it directly through the pidfile
(`<registry>/<job_id>.heartbeat.pid`) so no heartbeat can trail a `completed`. (`<registry>/<job_id>.heartbeat.pid`) so no heartbeat can trail a `completed`.
The timer is bound to the agent's life (review B2): it watches one process -
`--watch-pid`, or by default the nearest ancestor of the publishing process
that is an agent CLI (claude, codex, agy, ...; the agent runs the start
command from its shell tool, so that ancestor is the agent itself) - and stops
within one period once that process is gone. A crashed or killed agent
therefore stops heartbeating and is closed after K x N like any silent one;
an agent that is alive but stuck keeps heartbeating, which is the contract's
deliberate trade-off (liveness of the process, not of the work).
Each beat is an ordinary `publish_event` call in this process: it takes the Each beat is an ordinary `publish_event` call in this process: it takes the
next seq, is signed with the job's auth_token, carries the dispatch token and next seq, is signed with the job's auth_token, carries the dispatch token and
attempt id the timer was started with, and is recorded in events.log before attempt id the timer was started with, and is recorded in events.log before
@@ -46,6 +55,64 @@ HEARTBEAT_DETAIL = "heartbeat"
MAX_IN_FLIGHT = 8 MAX_IN_FLIGHT = 8
# The agent CLIs, as lib_py.agents.base names them. Repeated here rather than
# imported: lib_py.delegation may not import a sibling lib_py package (a
# frozen loop binds delegation to a different tree, see __init__).
AGENT_CLI_NAMES = ("claude", "agy", "hermes", "grok", "opencode", "codex")
def _is_agent_cli(command: str) -> bool:
"""Some token before the first option has an agent basename
(`claude ...`, `node .../claude ...`, `/bin/sh .../agy ...`)."""
for tok in command.split():
if tok.startswith("-"):
break
if os.path.basename(tok) in AGENT_CLI_NAMES:
return True
return False
def _process_table():
"""{pid: (ppid, command)} from ps; empty when ps is unavailable."""
try:
out = subprocess.run(["ps", "-axo", "pid=,ppid=,command="], capture_output=True, text=True).stdout
except OSError:
return {}
table = {}
for line in out.splitlines():
parts = line.split(None, 2)
if len(parts) < 2:
continue
try:
pid, ppid = int(parts[0]), int(parts[1])
except ValueError:
continue
table[pid] = (ppid, parts[2] if len(parts) > 2 else "")
return table
def nearest_agent_cli_ancestor(start_pid: Optional[int] = None) -> Optional[int]:
"""PID of the closest ancestor (start_pid included) that is an agent CLI.
The agent executes the injected start command through its shell tool, so
the chain is agent CLI -> shell -> python publish_event; the agent is the
first agent-CLI process met walking up. None when there is no such
ancestor (a test, a plain shell, a remote runner)."""
table = _process_table()
pid = start_pid if start_pid is not None else os.getpid()
seen = set()
while pid and pid > 1 and pid not in seen:
seen.add(pid)
entry = table.get(pid)
if entry is None:
return None
ppid, command = entry
if _is_agent_cli(command):
return pid
pid = ppid
return None
def pidfile_path(job_id: str, registry_dir: str) -> str: def pidfile_path(job_id: str, registry_dir: str) -> str:
return os.path.join(registry_dir, "{}.heartbeat.pid".format(job_id)) return os.path.join(registry_dir, "{}.heartbeat.pid".format(job_id))
@@ -57,7 +124,12 @@ def _pid_alive(pid: int) -> bool:
return False return False
except PermissionError: except PermissionError:
return True return True
try:
state = subprocess.run(["ps", "-o", "stat=", "-p", str(pid)],
capture_output=True, text=True).stdout.strip()
except OSError:
return True return True
return bool(state) and not state.startswith("Z")
def running_timer(job_id: str, registry_dir: str) -> Optional[int]: def running_timer(job_id: str, registry_dir: str) -> Optional[int]:
@@ -72,11 +144,21 @@ def running_timer(job_id: str, registry_dir: str) -> Optional[int]:
return None return None
def _is_timer_process(pid: int, job_id: str) -> bool:
"""The pid really is this job's timer (a stale pidfile after a crash may
name a recycled pid that now belongs to something else)."""
entry = _process_table().get(pid)
if entry is None:
return False
command = entry[1]
return "lib_py.delegation.heartbeat" in command and job_id in command
def stop_timer(job_id: str, registry_dir: str) -> bool: def stop_timer(job_id: str, registry_dir: str) -> bool:
"""Terminate the job's timer if one runs. Returns True when one was signalled.""" """Terminate the job's timer if one runs. Returns True when one was signalled."""
pid = running_timer(job_id, registry_dir) pid = running_timer(job_id, registry_dir)
path = pidfile_path(job_id, registry_dir) path = pidfile_path(job_id, registry_dir)
if pid is None: if pid is None or not _is_timer_process(pid, job_id):
try: try:
os.unlink(path) os.unlink(path)
except OSError: except OSError:
@@ -95,13 +177,17 @@ def stop_timer(job_id: str, registry_dir: str) -> bool:
def start_timer(job_id: str, registry_dir: str, interval: int, def start_timer(job_id: str, registry_dir: str, interval: int,
dispatch_token: Optional[str] = None, attempt_id: Optional[int] = None, dispatch_token: Optional[str] = None, attempt_id: Optional[int] = None,
python: Optional[str] = None) -> int: python: Optional[str] = None, watch_pid: Optional[int] = None) -> int:
"""Spawn the detached timer process and return its pid. """Spawn the detached timer process and return its pid.
A timer that already runs for the job is stopped first: a second `started` A timer that already runs for the job is stopped first: a second `started`
for the same job (a retry of the same command) must not double the rate. for the same job (a retry of the same command) must not double the rate.
``watch_pid`` binds the timer to a process (default: the nearest agent-CLI
ancestor of this process, if any); 0 disables the watch explicitly.
""" """
stop_timer(job_id, registry_dir) stop_timer(job_id, registry_dir)
if watch_pid is None:
watch_pid = nearest_agent_cli_ancestor()
cmd: List[str] = [python or sys.executable, "-m", "lib_py.delegation.heartbeat", cmd: List[str] = [python or sys.executable, "-m", "lib_py.delegation.heartbeat",
"--job", job_id, "--registry-dir", registry_dir, "--job", job_id, "--registry-dir", registry_dir,
"--interval", str(int(interval))] "--interval", str(int(interval))]
@@ -109,6 +195,8 @@ def start_timer(job_id: str, registry_dir: str, interval: int,
cmd += ["--expect-dispatch-token", dispatch_token] cmd += ["--expect-dispatch-token", dispatch_token]
if attempt_id is not None: if attempt_id is not None:
cmd += ["--attempt-id", str(int(attempt_id))] cmd += ["--attempt-id", str(int(attempt_id))]
if watch_pid:
cmd += ["--watch-pid", str(int(watch_pid))]
env = dict(os.environ) env = dict(os.environ)
# The timer imports lib_py from the same tree as this process. # The timer imports lib_py from the same tree as this process.
pkg_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) pkg_root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
@@ -119,11 +207,15 @@ def start_timer(job_id: str, registry_dir: str, interval: int,
start_new_session=True, env=env, cwd=os.getcwd()) start_new_session=True, env=env, cwd=os.getcwd())
with open(pidfile_path(job_id, registry_dir), "w") as fh: with open(pidfile_path(job_id, registry_dir), "w") as fh:
fh.write(str(proc.pid)) fh.write(str(proc.pid))
logger.info("heartbeat timer pid %d for job %s every %ss", proc.pid, job_id, interval) logger.info("heartbeat timer pid %d for job %s every %ss (watching pid %s)",
proc.pid, job_id, interval, watch_pid or "none")
return proc.pid return proc.pid
def _should_stop(job_id: str, registry_dir: str, dispatch_token, attempt_id) -> Optional[str]: def _should_stop(job_id: str, registry_dir: str, dispatch_token, attempt_id,
watch_pid: Optional[int] = None) -> Optional[str]:
if watch_pid and not _pid_alive(watch_pid):
return "watched agent process {} is gone".format(watch_pid)
try: try:
rec = load_job(job_id, registry_dir) rec = load_job(job_id, registry_dir)
except FileNotFoundError: except FileNotFoundError:
@@ -138,7 +230,8 @@ def _should_stop(job_id: str, registry_dir: str, dispatch_token, attempt_id) ->
def run(job_id: str, registry_dir: str, interval: int, dispatch_token=None, def run(job_id: str, registry_dir: str, interval: int, dispatch_token=None,
attempt_id=None, max_lifetime: Optional[float] = None) -> int: attempt_id=None, max_lifetime: Optional[float] = None,
watch_pid: Optional[int] = None) -> int:
"""The timer loop. Runs until a stop condition holds.""" """The timer loop. Runs until a stop condition holds."""
from lib_py.delegation import publish_event # late import: the CLI imports us from lib_py.delegation import publish_event # late import: the CLI imports us
@@ -173,7 +266,7 @@ def run(job_id: str, registry_dir: str, interval: int, dispatch_token=None,
next_due += interval next_due += interval
if next_due < time.monotonic(): if next_due < time.monotonic():
next_due = time.monotonic() + interval # fell far behind: resync next_due = time.monotonic() + interval # fell far behind: resync
reason = _should_stop(job_id, registry_dir, dispatch_token, attempt_id) reason = _should_stop(job_id, registry_dir, dispatch_token, attempt_id, watch_pid)
if reason: if reason:
logger.info("heartbeat timer for %s stopping: %s", job_id, reason) logger.info("heartbeat timer for %s stopping: %s", job_id, reason)
break break
@@ -227,12 +320,14 @@ def main(argv=None) -> int:
parser.add_argument("--expect-dispatch-token", default=None) parser.add_argument("--expect-dispatch-token", default=None)
parser.add_argument("--attempt-id", type=int, default=None) parser.add_argument("--attempt-id", type=int, default=None)
parser.add_argument("--max-lifetime", type=float, default=None) parser.add_argument("--max-lifetime", type=float, default=None)
parser.add_argument("--watch-pid", type=int, default=None,
help="stop once this process (the agent) is gone")
parser.add_argument("-v", "--verbose", action="store_true") parser.add_argument("-v", "--verbose", action="store_true")
args = parser.parse_args(argv) args = parser.parse_args(argv)
mqtt_common.ensure_env_loaded() mqtt_common.ensure_env_loaded()
mqtt_common.setup_logging(logging.DEBUG if args.verbose else logging.INFO) mqtt_common.setup_logging(logging.DEBUG if args.verbose else logging.INFO)
return run(args.job, args.registry_dir, args.interval, args.expect_dispatch_token, return run(args.job, args.registry_dir, args.interval, args.expect_dispatch_token,
args.attempt_id, args.max_lifetime) args.attempt_id, args.max_lifetime, args.watch_pid)
if __name__ == "__main__": if __name__ == "__main__":
@@ -139,6 +139,7 @@ class _Watcher:
# gap is only meaningful after that - the initial last_seq comes from # gap is only meaningful after that - the initial last_seq comes from
# the registry and may be behind the wire (or ahead of a fresh remote). # the registry and may be behind the wire (or ahead of a fresh remote).
self.accepted_any: Set[str] = set() self.accepted_any: Set[str] = set()
self.state_seq: Dict[str, int] = {}
def _note_seq_gap(self, jid: str, expected: int, got: int, via: str, topic: str) -> None: def _note_seq_gap(self, jid: str, expected: int, got: int, via: str, topic: str) -> None:
"""Issue #18: a seq the subscriber never saw is a detected loss.""" """Issue #18: a seq the subscriber never saw is a detected loss."""
@@ -161,11 +162,14 @@ class _Watcher:
if not isinstance(seq, int): if not isinstance(seq, int):
logger.warning("drop state for job %s: missing or invalid seq", jid) logger.warning("drop state for job %s: missing or invalid seq", jid)
return return
last = self.last_seq.get(jid, 0) # A state message never advances the event watermark: MQTT orders
# messages per connection, not across topics, so a state that overtakes
# its own event must not make the monotonic guard drop that event.
last = max(self.last_seq.get(jid, 0), self.state_seq.get(jid, 0))
if jid in self.accepted_any and seq > last + 1: if jid in self.accepted_any and seq > last + 1:
self._note_seq_gap(jid, last + 1, seq, "state", msg.topic) self._note_seq_gap(jid, last + 1, seq, "state", msg.topic)
if seq > last: if seq > self.state_seq.get(jid, 0):
self.last_seq[jid] = seq self.state_seq[jid] = seq
mqtt_common.append_event(jid, { mqtt_common.append_event(jid, {
"event": "received", "event": "received",
"source_event": STATE_EVENT, "source_event": STATE_EVENT,
@@ -297,6 +297,9 @@ def main(argv=None) -> int:
"(Issue #18; not with a terminal event)") "(Issue #18; not with a terminal event)")
parser.add_argument("--no-state", action="store_true", parser.add_argument("--no-state", action="store_true",
help="do not publish the retained <topic_prefix>/state message") help="do not publish the retained <topic_prefix>/state message")
parser.add_argument("--watch-pid", type=int, default=None,
help="bind the heartbeat timer to this process (default: the nearest "
"agent-CLI ancestor; 0 = no watch)")
parser.add_argument("-v", "--verbose", action="store_true") parser.add_argument("-v", "--verbose", action="store_true")
args = parser.parse_args(argv) args = parser.parse_args(argv)
@@ -456,7 +459,8 @@ def main(argv=None) -> int:
hb_attempt = hb_attempt if hb_attempt is not None else rec.get("attempt_id", rec.get("iteration", 1)) hb_attempt = hb_attempt if hb_attempt is not None else rec.get("attempt_id", rec.get("iteration", 1))
try: try:
pid = heartbeat_timer.start_timer(job_id, args.registry_dir, args.heartbeat, pid = heartbeat_timer.start_timer(job_id, args.registry_dir, args.heartbeat,
dispatch_token=hb_token, attempt_id=hb_attempt) dispatch_token=hb_token, attempt_id=hb_attempt,
watch_pid=args.watch_pid)
logger.info("heartbeat timer started (pid %d, every %ss)", pid, args.heartbeat) logger.info("heartbeat timer started (pid %d, every %ss)", pid, args.heartbeat)
except OSError as exc: except OSError as exc:
logger.error("could not start the heartbeat timer: %s", exc) logger.error("could not start the heartbeat timer: %s", exc)
+10 -4
View File
@@ -153,7 +153,7 @@ def clear_dispatch(config, topic: str) -> None:
# remote side # remote side
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def mirror_job(d: Dict[str, Any], registry_dir: str, session: str) -> str: 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.
The record carries the orchestrator's job_id, auth_token, dispatch_token The record carries the orchestrator's job_id, auth_token, dispatch_token
@@ -165,8 +165,9 @@ def mirror_job(d: Dict[str, Any], registry_dir: str, session: str) -> str:
path = mqtt_common._job_path(jid, registry_dir) path = mqtt_common._job_path(jid, registry_dir)
if path.exists(): if path.exists():
old = load_job(jid, registry_dir) old = load_job(jid, registry_dir)
if (old.get("dispatch_token") == d.get("dispatch_token") if old.get("dispatch_token") == d.get("dispatch_token"):
and old.get("status") not in ("completed", "error", "cancelled")): 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) return jid # same attempt, already mirrored (duplicate delivery)
with mqtt_common.registry_lock(registry_dir): with mqtt_common.registry_lock(registry_dir):
path.unlink() path.unlink()
@@ -206,7 +207,8 @@ def _publish(jid: str, registry_dir: str, event: str, detail: str, d: Dict[str,
if d.get("attempt_id") is not None: if d.get("attempt_id") is not None:
argv += ["--attempt-id", str(d["attempt_id"])] argv += ["--attempt-id", str(d["attempt_id"])]
if heartbeat: if heartbeat:
argv += ["--heartbeat", str(heartbeat)] # The runner publishes in-process, so the timer must die with the runner.
argv += ["--heartbeat", str(heartbeat), "--watch-pid", str(os.getpid())]
return publish_event.main(argv) return publish_event.main(argv)
@@ -214,6 +216,10 @@ def handle_dispatch(d: Dict[str, Any], registry_dir: str, session: str,
exec_cmd: Optional[str], python: Optional[str] = None) -> int: exec_cmd: Optional[str], python: Optional[str] = None) -> int:
"""Run one dispatched job to its terminal event. Returns the agent rc.""" """Run one dispatched job to its terminal event. Returns the agent rc."""
jid = mirror_job(d, registry_dir, session) 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"))
return 0
heartbeat = int(d.get("heartbeat_sec") or registry.DEFAULT_HEARTBEAT_SEC) 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), rc_started = _publish(jid, registry_dir, "started", "remote runner {} claimed the job".format(session),
d, heartbeat=heartbeat) d, heartbeat=heartbeat)
@@ -604,6 +604,11 @@ _wait_started() {
wall=$(( ${START_TIME:-$(date +%s)} + ${TIMEOUT:-3600} )) wall=$(( ${START_TIME:-$(date +%s)} + ${TIMEOUT:-3600} ))
[ "$deadline" -gt "$wall" ] && deadline=$wall [ "$deadline" -gt "$wall" ] && deadline=$wall
local ev_log="$REGISTRY_DIR/$job_id.events.log" local ev_log="$REGISTRY_DIR/$job_id.events.log"
# A loop job keeps one events.log across iterations: only a `started` that
# carries THIS attempt's dispatch token counts (review R1).
local started_re='"event": *"started"'
[ -n "${_disp_tok:-}" ] && started_re='"event": *"started".*"dispatch_token": *"'"$_disp_tok"'"'
local budget=$(( deadline - $(date +%s) ))
while :; do while :; do
st="$(_disk_status "$job_id")" st="$(_disk_status "$job_id")"
if [ -n "$st" ] && [ "$st" != "pending" ]; then if [ -n "$st" ] && [ "$st" != "pending" ]; then
@@ -612,7 +617,7 @@ _wait_started() {
fi fi
# publish_event records `started` here before its wire publish and before # publish_event records `started` here before its wire publish and before
# the status sync, so a slow broker cannot delay the acknowledgement. # the status sync, so a slow broker cannot delay the acknowledgement.
if [ -f "$ev_log" ] && grep -Eq '"event": *"started"' "$ev_log" 2>/dev/null; then if [ -f "$ev_log" ] && grep -Eq "$started_re" "$ev_log" 2>/dev/null; then
echo "inject: job $job_id recorded 'started' in events.log; treating the prompt as delivered and continuing." >&2 echo "inject: job $job_id recorded 'started' in events.log; treating the prompt as delivered and continuing." >&2
return 0 return 0
fi fi
@@ -620,7 +625,10 @@ _wait_started() {
echo "inject: subscriber received started for job $job_id; treating the prompt as delivered and continuing." >&2 echo "inject: subscriber received started for job $job_id; treating the prompt as delivered and continuing." >&2
return 0 return 0
fi fi
[ "$(date +%s)" -ge "$deadline" ] && return 1 if [ "$(date +%s)" -ge "$deadline" ]; then
ACK_WAITED_S="$budget"
return 1
fi
sleep 1 sleep 1
done done
} }
@@ -951,7 +959,7 @@ inject_agent() {
local ack_s="${MAM_INJECT_ACK_GRACE_S:-$ACK_TIMEOUT}" local ack_s="${MAM_INJECT_ACK_GRACE_S:-$ACK_TIMEOUT}"
if ! _wait_started "$job_id" "$ack_s" "${4:-}"; then if ! _wait_started "$job_id" "$ack_s" "${4:-}"; then
trap - EXIT trap - EXIT
echo "ERROR: 프롬프트 주입 실패 — 세션 '$sess': no 'started' within ${ack_s}s (ack timeout); not re-injecting" >&2 echo "ERROR: 프롬프트 주입 실패 — 세션 '$sess': no 'started' within ${ACK_WAITED_S:-$ack_s}s (ack timeout ${ack_s}s, bounded by the wall clock); not re-injecting" >&2
return 1 return 1
fi fi
@@ -976,7 +984,7 @@ remote_dispatch() {
fi fi
local ack_s="${MAM_INJECT_ACK_GRACE_S:-$ACK_TIMEOUT}" local ack_s="${MAM_INJECT_ACK_GRACE_S:-$ACK_TIMEOUT}"
if ! _wait_started "$job_id" "$ack_s" "$sub_log"; then if ! _wait_started "$job_id" "$ack_s" "$sub_log"; then
echo "ERROR: remote session '$sess' published no 'started' within ${ack_s}s (ack timeout); not re-dispatching" >&2 echo "ERROR: remote session '$sess' published no 'started' within ${ACK_WAITED_S:-$ack_s}s (ack timeout ${ack_s}s, bounded by the wall clock); not re-dispatching" >&2
return 1 return 1
fi fi
echo "작업이 원격 세션 '$sess'에 전달되었습니다." echo "작업이 원격 세션 '$sess'에 전달되었습니다."
@@ -1069,4 +1077,7 @@ main() {
esac esac
} }
# Tests source this file to exercise single helpers (MAM_DELEGATE_JOB_NO_MAIN=1).
if [ "${MAM_DELEGATE_JOB_NO_MAIN:-0}" != "1" ]; then
main "$@" main "$@"
fi
@@ -205,4 +205,3 @@ $PY scripts/registry.py logs --list # every job, live status
| `transport` | `herdr` (pane injection) or `remote` (signed retained dispatch over the broker) | `herdr` | | `transport` | `herdr` (pane injection) or `remote` (signed retained dispatch over the broker) | `herdr` |
`<registry>/<job>.heartbeat.pid` / `.heartbeat.log` belong to the running heartbeat timer; a terminal publish removes the pidfile. `<registry>/<job>.heartbeat.pid` / `.heartbeat.log` belong to the running heartbeat timer; a terminal publish removes the pidfile.
+1 -1
View File
@@ -196,7 +196,7 @@ orchestrator's disk, take part on equal terms.
| Rule | Contract | Where | | Rule | Contract | Where |
|---|---|---| |---|---|---|
| **Heartbeat obligation** | While working, the agent publishes `progress` at least every **N** s (`heartbeat_sec`, default 30). `publish_event.py --event started --heartbeat N` starts a detached timer that publishes `progress` (`detail: heartbeat`) every N s with the same `dispatch_token`/`attempt_id`, so the agent does not have to remember. The timer stops itself at the terminal event (which also kills it through `<registry>/<job>.heartbeat.pid`), on re-dispatch, or after `timeout_sec`. | `lib_py/delegation/heartbeat.py`, `publish_event.py --heartbeat` | | **Heartbeat obligation** | While working, the agent publishes `progress` at least every **N** s (`heartbeat_sec`, default 30). `publish_event.py --event started --heartbeat N` starts a detached timer that publishes `progress` (`detail: heartbeat`) every N s with the same `dispatch_token`/`attempt_id`, so the agent does not have to remember. The timer stops itself at the terminal event (which also kills it through `<registry>/<job>.heartbeat.pid`), on re-dispatch, after `timeout_sec`, and within one period of the agent process it watches going away (`--watch-pid`, default the nearest agent-CLI ancestor of the publishing command; a remote runner watches itself) - a crashed agent stops heartbeating, an alive-but-stuck one does not. | `lib_py/delegation/heartbeat.py`, `publish_event.py --heartbeat` |
| **Unresponsive** | The orchestrator treats a job as unresponsive after **K x N** s without any event (`heartbeat_misses`, default 4 → 120 s; `--idle-timeout` overrides). The subscriber's idle clock counts every accepted message (`progress` and retained state included); the wrapper's post-idle grace re-reads only the job's events.log (`_recent_disk_heartbeat`, written before every wire publish). herdr status and the pane are logged once as a *diagnostic* (`liveness: ... herdr/pane says busy=yes/no (diagnostic only)`) and never decide. | `multi-agent-mux-delegate-job` (`finalize_unfinished`), `job_subscriber.py` | | **Unresponsive** | The orchestrator treats a job as unresponsive after **K x N** s without any event (`heartbeat_misses`, default 4 → 120 s; `--idle-timeout` overrides). The subscriber's idle clock counts every accepted message (`progress` and retained state included); the wrapper's post-idle grace re-reads only the job's events.log (`_recent_disk_heartbeat`, written before every wire publish). herdr status and the pane are logged once as a *diagnostic* (`liveness: ... herdr/pane says busy=yes/no (diagnostic only)`) and never decide. | `multi-agent-mux-delegate-job` (`finalize_unfinished`), `job_subscriber.py` |
| **Delivery** | After the prompt is handed over (herdr accepted the submission, or the remote dispatch was published), the agent's `started` must arrive within **T** s (`ack_timeout_sec`, default 60): the registry leaves `pending`, a `started` line lands in events.log, or the subscriber prints one. No `started` within T → the inject fails closed: rc 1, registry `error` (`agent_blocked`), and the prompt is **never re-injected**. `send_keys_safe` confirms an accepted-but-unconfirmed submission only by `agent_status == working` or the job's `started`; pane changes and busy tokens are no longer signals. | `_wait_started`, `lib.sh: _sks_job_started` | | **Delivery** | After the prompt is handed over (herdr accepted the submission, or the remote dispatch was published), the agent's `started` must arrive within **T** s (`ack_timeout_sec`, default 60): the registry leaves `pending`, a `started` line lands in events.log, or the subscriber prints one. No `started` within T → the inject fails closed: rc 1, registry `error` (`agent_blocked`), and the prompt is **never re-injected**. `send_keys_safe` confirms an accepted-but-unconfirmed submission only by `agent_status == working` or the job's `started`; pane changes and busy tokens are no longer signals. | `_wait_started`, `lib.sh: _sks_job_started` |
| **Termination** | Only `completed` / `error` (and an operator `cancelled`) end a job. Terminal immutability (Issue #6 CAS) refuses anything that arrives after `deadline_timeout` closed the job. | `engine.py` | | **Termination** | Only `completed` / `error` (and an operator `cancelled`) end a job. Terminal immutability (Issue #6 CAS) refuses anything that arrives after `deadline_timeout` closed the job. | `engine.py` |
+2 -2
View File
@@ -43,9 +43,9 @@
> **마이너 마일스톤 (MINOR Release)**: 에이전트의 생존·전달 판정을 TUI 스크래핑(herdr `agent_status`, pane 문자열, 화면 변화)에서 떼어내어, 에이전트가 스스로 발행하는 이벤트(`started`/`progress`/`completed`/`error`)에 **의무·주기·타임아웃 규칙이 있는 계약**을 부여했다(Issue #18, #17 항목 1). 새 이벤트 종류는 없다. 원격 호스트의 에이전트(pane·디스크 공유 없음)를 같은 계약으로 다루는 `remote:` 전송을 추가했다. 래퍼 옵션 3개(`--heartbeat`, `--heartbeat-misses`, `--ack-timeout`)와 `publish_event --heartbeat`, `job_subscriber --remote`, `remote.py` CLI 가 추가되어 MINOR 로 올린다. 기본값(N=30, K=4 → idle 120 s, T=60)은 종전 idle 기본값과 같으므로 기존 호출은 동작이 유지된다. > **마이너 마일스톤 (MINOR Release)**: 에이전트의 생존·전달 판정을 TUI 스크래핑(herdr `agent_status`, pane 문자열, 화면 변화)에서 떼어내어, 에이전트가 스스로 발행하는 이벤트(`started`/`progress`/`completed`/`error`)에 **의무·주기·타임아웃 규칙이 있는 계약**을 부여했다(Issue #18, #17 항목 1). 새 이벤트 종류는 없다. 원격 호스트의 에이전트(pane·디스크 공유 없음)를 같은 계약으로 다루는 `remote:` 전송을 추가했다. 래퍼 옵션 3개(`--heartbeat`, `--heartbeat-misses`, `--ack-timeout`)와 `publish_event --heartbeat`, `job_subscriber --remote`, `remote.py` CLI 가 추가되어 MINOR 로 올린다. 기본값(N=30, K=4 → idle 120 s, T=60)은 종전 idle 기본값과 같으므로 기존 호출은 동작이 유지된다.
#### 🌟 주요 개선 사항 #### 🌟 주요 개선 사항
1. **heartbeat 의무의 자동화 (`lib_py/delegation/heartbeat.py`, `publish_event.py --heartbeat N`)**: `started --heartbeat N` 이 분리 세션의 타이머를 띄워 N초마다 `progress`(detail `heartbeat`)를 같은 `dispatch_token`/`attempt_id` 로 서명·발행한다. 터미널 이벤트가 pidfile(`<registry>/<job>.heartbeat.pid`)로 타이머를 먼저 멈추고, 타이머는 레코드 터미널·재dispatch·`timeout_sec` 초과 시 스스로 끝난다. 각 beat 는 와이어 이전에 events.log 에 기록되므로 디스크 생존 판정에도 그대로 쓰인다. 래퍼가 주입하는 시작 명령이 `--heartbeat N` 을 포함하고, 브리프에 계약(N/K/T)이 명시된다. 1. **heartbeat 의무의 자동화 (`lib_py/delegation/heartbeat.py`, `publish_event.py --heartbeat N`)**: `started --heartbeat N` 이 분리 세션의 타이머를 띄워 N초마다 `progress`(detail `heartbeat`)를 같은 `dispatch_token`/`attempt_id` 로 서명·발행한다. 터미널 이벤트가 pidfile(`<registry>/<job>.heartbeat.pid`)로 타이머를 먼저 멈추고(pid 재사용 대비 cmdline 확인), 타이머는 레코드 터미널·재dispatch·`timeout_sec` 초과·**감시 대상 에이전트 프로세스 소멸**(`--watch-pid`, 기본은 시작 명령을 실행한 가장 가까운 에이전트 CLI 조상; 원격 러너는 자기 자신) 시 스스로 끝난다. 죽은 에이전트는 heartbeat 를 멈추고 K×N 뒤 닫히며, 살아 있지만 멈춘 에이전트는 계속 heartbeat 를 낸다(프로세스 생존 계약). 각 beat 는 와이어 이전에 events.log 에 기록되므로 디스크 생존 판정에도 그대로 쓰인다. 래퍼가 주입하는 시작 명령이 `--heartbeat N` 을 포함하고, 브리프에 계약(N/K/T)이 명시된다.
2. **무응답 판정 = 마지막 이벤트로부터 K×N (`registry.py`, 래퍼 `finalize_unfinished`)**: 레지스트리 레코드에 `heartbeat_sec`·`heartbeat_misses`·`ack_timeout_sec`·`transport` 가 추가되고 `idle_timeout_sec` 은 명시하지 않으면 K×N 으로 유도된다. 래퍼의 R1 유예는 events.log 의 최근 이벤트(K×N 이내)만을 증거로 삼고, herdr 상태·pane busy 토큰은 진입 시 한 번 `liveness: ... (diagnostic only)` 로 기록만 한다. 헤드리스 에이전트(항상 idle, 빈 pane)도 heartbeat 만으로 K×N 을 넘겨 살아 있고, heartbeat 가 끊기면 `deadline_timeout` 으로 닫히며 이후 `completed` 는 CAS 가 거부한다. 2. **무응답 판정 = 마지막 이벤트로부터 K×N (`registry.py`, 래퍼 `finalize_unfinished`)**: 레지스트리 레코드에 `heartbeat_sec`·`heartbeat_misses`·`ack_timeout_sec`·`transport` 가 추가되고 `idle_timeout_sec` 은 명시하지 않으면 K×N 으로 유도된다. 래퍼의 R1 유예는 events.log 의 최근 이벤트(K×N 이내)만을 증거로 삼고, herdr 상태·pane busy 토큰은 진입 시 한 번 `liveness: ... (diagnostic only)` 로 기록만 한다. 헤드리스 에이전트(항상 idle, 빈 pane)도 heartbeat 만으로 K×N 을 넘겨 살아 있고, heartbeat 가 끊기면 `deadline_timeout` 으로 닫히며 이후 `completed` 는 CAS 가 거부한다.
3. **전달 확인 = T 내 `started` (`_wait_started`, `lib.sh: _sks_job_started`)**: 주입 후 T초(`--ack-timeout`, 기본 60; 벽시계 상한) 안에 레지스트리가 `pending` 을 벗어나거나 events.log/구독자 로그에 `started` 가 없으면 rc 1·`agent_blocked` 로 fail-closed 하고 재주입하지 않는다. `send_keys_safe` 는 accepted-but-unconfirmed 제출을 `agent_status working` 또는 job 의 `started` 로만 확인하며 pane 변화·busy 토큰 신호는 제거됐다(무신호 rc 4 는 유지). `MAM_INJECT_ACK_GRACE_S` 는 T 의 override 로 남는다. 3. **전달 확인 = T 내 `started` (`_wait_started`, `lib.sh: _sks_job_started`)**: 주입 후 T초(`--ack-timeout`, 기본 60; 벽시계 상한) 안에 레지스트리가 `pending` 을 벗어나거나 events.log(이번 attempt 의 dispatch_token 을 가진 `started` 만)/구독자 로그에 `started` 가 없으면 rc 1·`agent_blocked` 로 fail-closed 하고 재주입하지 않는다. `send_keys_safe` 는 accepted-but-unconfirmed 제출을 `agent_status working` 또는 job 의 `started` 로만 확인하며 pane 변화·busy 토큰 신호는 제거됐다(무신호 rc 4 는 유지). `MAM_INJECT_ACK_GRACE_S` 는 T 의 override 로 남는다.
4. **와이어 내구성 (`publish_event.py: build_state_payload`, `job_subscriber.py`)**: 모든 이벤트 뒤에 같은 연결로 retained `<prefix>/state`(status·last_event·seq, HMAC 서명)를 발행한다. 구독자는 state 토픽도 구독해 (재)접속 즉시 터미널 상태를 복구하고, 모든 메시지(progress·state 포함)로 idle 창을 리셋하며, seq 점프를 감사 로그 `seq_gap` 으로 남기고, 재접속을 `reconnected` 로 기록한다. `--no-state` 로 끌 수 있다. 4. **와이어 내구성 (`publish_event.py: build_state_payload`, `job_subscriber.py`)**: 모든 이벤트 뒤에 같은 연결로 retained `<prefix>/state`(status·last_event·seq, HMAC 서명)를 발행한다. 구독자는 state 토픽도 구독해 (재)접속 즉시 터미널 상태를 복구하고, 모든 메시지(progress·state 포함)로 idle 창을 리셋하며, seq 점프를 감사 로그 `seq_gap` 으로 남기고, 재접속을 `reconnected` 로 기록한다. `--no-state` 로 끌 수 있다.
5. **원격 전송 (`lib_py/delegation/remote.py`, `scripts/remote.py`, 래퍼 `remote:<name>`)**: `--agent-session remote:<name>` 이면 preflight·idle 핸드셰이크·pane 주입을 건너뛰고 `MAM_DISPATCH_SECRET` 로 서명한 retained `<prefix>/dispatch`(브리프·auth_token·dispatch_token·attempt_id·계약)를 발행한 뒤 T 내 `started` 를 기다린다. 다른 호스트의 `remote.py run --session <name> [--exec CMD] [--once]` 러너가 자기 레지스트리에 같은 id·identity 로 잡을 미러링하고, dispatch 를 retire 하고, `started --heartbeat N` 후 명령을 실행해 터미널 이벤트를 발행한다. 구독자의 `--remote`(transport=remote 잡은 자동)가 `started`·터미널을 오케스트레이터 레지스트리에 반영한다. 서명 없는 dispatch 는 거부된다. 루프 러너는 YAML 행의 `transport: remote` 를 `remote:<name>` 으로 넘기고, `MAM_HEARTBEAT_SEC`/`MAM_HEARTBEAT_MISSES`/`MAM_ACK_TIMEOUT_S` 를 계약 옵션으로 전달한다. 5. **원격 전송 (`lib_py/delegation/remote.py`, `scripts/remote.py`, 래퍼 `remote:<name>`)**: `--agent-session remote:<name>` 이면 preflight·idle 핸드셰이크·pane 주입을 건너뛰고 `MAM_DISPATCH_SECRET` 로 서명한 retained `<prefix>/dispatch`(브리프·auth_token·dispatch_token·attempt_id·계약)를 발행한 뒤 T 내 `started` 를 기다린다. 다른 호스트의 `remote.py run --session <name> [--exec CMD] [--once]` 러너가 자기 레지스트리에 같은 id·identity 로 잡을 미러링하고, dispatch 를 retire 하고, `started --heartbeat N` 후 명령을 실행해 터미널 이벤트를 발행한다. 구독자의 `--remote`(transport=remote 잡은 자동)가 `started`·터미널을 오케스트레이터 레지스트리에 반영한다. 서명 없는 dispatch 는 거부된다. 루프 러너는 YAML 행의 `transport: remote` 를 `remote:<name>` 으로 넘기고, `MAM_HEARTBEAT_SEC`/`MAM_HEARTBEAT_MISSES`/`MAM_ACK_TIMEOUT_S` 를 계약 옵션으로 전달한다.
6. **문서**: MESSAGING.md §2.5 계약 표·원격 전송, delegate-job SKILL.md(옵션·종료 코드·프로토콜), registry.md(계약 필드), loop SKILL.md(환경변수·원격 행). 6. **문서**: MESSAGING.md §2.5 계약 표·원격 전송, delegate-job SKILL.md(옵션·종료 코드·프로토콜), registry.md(계약 필드), loop SKILL.md(환경변수·원격 행).
+5 -1
View File
@@ -7,7 +7,8 @@ usage: publish_event.py [-h] (--job JOB | --pick-pending)
[--side-effect KIND=REFERENCE] [--side-effect KIND=REFERENCE]
[--attempt-id ATTEMPT_ID] [--attempt-id ATTEMPT_ID]
[--expect-dispatch-token EXPECT_DISPATCH_TOKEN] [--expect-dispatch-token EXPECT_DISPATCH_TOKEN]
[--heartbeat N] [--no-state] [-v] [--heartbeat N] [--no-state] [--watch-pid WATCH_PID]
[-v]
Publish a Job event to MQTT Publish a Job event to MQTT
@@ -39,4 +40,7 @@ optional arguments:
the job ends (Issue #18; not with a terminal event) the job ends (Issue #18; not with a terminal event)
--no-state do not publish the retained <topic_prefix>/state --no-state do not publish the retained <topic_prefix>/state
message message
--watch-pid WATCH_PID
bind the heartbeat timer to this process (default: the
nearest agent-CLI ancestor; 0 = no watch)
-v, --verbose -v, --verbose
+119 -2
View File
@@ -220,6 +220,61 @@ def test_heartbeat_timer_stops_on_its_own_when_the_job_is_redispatched(tmp_path)
_kill_timer(reg, jid) _kill_timer(reg, jid)
def test_heartbeat_stops_when_the_watched_agent_process_dies(tmp_path):
"""Review B2: the timer follows the agent's life. The 'agent' here is a
shell that runs the start command with --watch-pid $$ and then idles;
killing that shell must stop the heartbeats within about one period."""
reg = tmp_path / "jobs"
env = _env(tmp_path, UNREACHABLE)
jid = subprocess.run([sys.executable, str(REGISTRY_PY), "--registry-dir", str(reg), "register",
"--prompt", "hb"], capture_output=True, text=True, env=env, cwd=str(tmp_path)).stdout.strip()
agent = subprocess.Popen(
["bash", "-c", f'"{sys.executable}" "{PUBLISH_PY}" --registry-dir "{reg}" --job {jid} --event started '
f'--heartbeat 1 --attempts 1 --watch-pid $$ >/dev/null 2>&1; sleep 120'],
cwd=str(tmp_path), env=env)
try:
deadline = time.time() + 15
while time.time() < deadline and not _timer_pid(reg, jid):
time.sleep(0.1)
pid = _timer_pid(reg, jid)
assert pid and _alive(pid)
time.sleep(2.5)
before = len([e for e in _events(reg, jid) if e["event"] == "progress"])
assert before >= 2
agent.kill() # the agent crashes
agent.wait(timeout=10)
deadline = time.time() + 6
while time.time() < deadline and _alive(pid):
time.sleep(0.1)
assert not _alive(pid), "timer must stop once the watched agent is gone"
time.sleep(1.5)
after = len([e for e in _events(reg, jid) if e["event"] == "progress"])
assert after <= before + 2, f"heartbeats kept coming after the agent died: {before} -> {after}"
assert _job(reg, jid)["status"] == "running", "the timer stops; the orchestrator's K x N closes the job"
finally:
if agent.poll() is None:
agent.kill()
_kill_timer(reg, jid)
def test_stop_timer_never_signals_a_recycled_pid(tmp_path, monkeypatch):
"""A stale pidfile (timer crashed) may name a pid that now belongs to
something else; stop_timer must only signal a heartbeat timer of this job."""
from lib_py.delegation import heartbeat
reg = tmp_path / "jobs"
reg.mkdir()
bystander = subprocess.Popen(["sleep", "30"])
try:
(reg / "job1.heartbeat.pid").write_text(str(bystander.pid))
assert heartbeat.running_timer("job1", str(reg)) == bystander.pid
assert heartbeat.stop_timer("job1", str(reg)) is False
assert bystander.poll() is None, "the bystander must not be killed"
assert not (reg / "job1.heartbeat.pid").exists(), "the stale pidfile is removed"
finally:
bystander.kill()
def test_heartbeat_is_refused_on_a_terminal_event(tmp_path, monkeypatch): def test_heartbeat_is_refused_on_a_terminal_event(tmp_path, monkeypatch):
from lib_py.delegation import publish_event as pe, registry from lib_py.delegation import publish_event as pe, registry
@@ -402,6 +457,54 @@ def test_send_keys_safe_ignores_pane_change_and_busy_token(tmp_path):
assert "PASTE_PATH_USED" not in res.stderr assert "PASTE_PATH_USED" not in res.stderr
def test_wait_started_only_accepts_this_attempts_started(tmp_path):
"""Review R1: a loop job keeps one events.log across iterations. A
`started` left by the previous attempt (other dispatch token) must not
acknowledge the new injection; one with the current token must."""
reg = tmp_path / "jobs"
reg.mkdir()
jid = "abcd1234"
(reg / f"{jid}.json").write_text(json.dumps({"job_id": jid, "status": "pending", "dispatch_token": "new-token"}))
log = reg / f"{jid}.events.log"
log.write_text(json.dumps({"event": "started", "seq": 1, "data": {"dispatch_token": "old-token"}}) + "\n")
env = _env(tmp_path, {"MAM_DELEGATE_JOB_NO_MAIN": "1"})
def wait_started(extra: str) -> subprocess.CompletedProcess:
# the wrapper sets -e: capture the helper's rc instead of dying on it
script = (f'source "{WRAPPER}"; set +e; REGISTRY_DIR="{reg}"; PY="{sys.executable}"; TIMEOUT=60; START_TIME=$(date +%s); '
f'_disp_tok="new-token"; {extra} _wait_started {jid} 3; echo "rc=$?"')
return subprocess.run(["bash", "-c", script], capture_output=True, text=True, env=env, cwd=str(tmp_path), timeout=60)
res = wait_started("")
assert "rc=1" in res.stdout, f"old attempt's started must not count\n{res.stdout}\n{res.stderr}"
with open(log, "a") as fh:
fh.write(json.dumps({"event": "started", "seq": 2, "data": {"dispatch_token": "new-token"}}) + "\n")
res = wait_started("")
assert "rc=0" in res.stdout and "recorded 'started' in events.log" in res.stderr, res.stderr
def test_remote_runner_ignores_a_resent_dispatch_of_a_finished_attempt(tmp_path, monkeypatch):
"""Review R2: a retained dispatch delivered again (runner restart, broker
replay) must not re-run an attempt that already reached its terminal."""
from lib_py.delegation import remote, registry
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("MAM_ENV_FILE", str(tmp_path / ".mam.env.absent"))
monkeypatch.setenv("DELEGATE_JOB_LOGS_DIR", str(tmp_path / "logs"))
reg = tmp_path / "jobs"
d = {"job_id": "feed1234", "brief": "b", "dispatch_token": "tok-1", "attempt_id": 1, "auth_token": None,
"timeout_sec": 30, "heartbeat_sec": 5, "heartbeat_misses": 2, "ack_timeout_sec": 5}
assert remote.mirror_job(d, str(reg), "s") == "feed1234"
assert remote.mirror_job(d, str(reg), "s") == "feed1234", "same attempt, still open: idempotent"
from lib_py.delegation import mqtt_common
mqtt_common.update_job_status("feed1234", str(reg), _source="registry_cli", status="completed")
assert remote.mirror_job(d, str(reg), "s") is None, "finished attempt: the re-sent dispatch is ignored"
assert remote.handle_dispatch(d, str(reg), "s", "echo should-not-run") == 0
d2 = dict(d, dispatch_token="tok-2", attempt_id=2)
assert remote.mirror_job(d2, str(reg), "s") == "feed1234", "a new attempt is mirrored afresh"
assert _job(reg, "feed1234")["dispatch_token"] == "tok-2" and _job(reg, "feed1234")["status"] == "pending"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 5. wrapper acceptance criteria (broker) # 5. wrapper acceptance criteria (broker)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -490,14 +593,28 @@ def test_acceptance3_lost_heartbeats_close_the_job_and_a_late_completed_is_refus
["--heartbeat", "1", "--heartbeat-misses", "3", "--ack-timeout", "40"], timeout_s=60) ["--heartbeat", "1", "--heartbeat-misses", "3", "--ack-timeout", "40"], timeout_s=60)
jid = _wait_job_id(reg) jid = _wait_job_id(reg)
t0 = None t0 = None
agent = None
try: try:
time.sleep(1) time.sleep(1)
assert _publish(reg, jid, "started", env, "--heartbeat", "1").returncode == 0 tok = _job(reg, jid)["dispatch_token"]
# the agent: a shell that runs the injected start command (timer bound
# to it via --watch-pid $$) and then keeps "working" silently
agent = subprocess.Popen(
["bash", "-c", f'"{sys.executable}" "{PUBLISH_PY}" --registry-dir "{reg}" --job {jid} --event started '
f'--expect-dispatch-token {tok} --heartbeat 1 --watch-pid $$; sleep 300'],
cwd=str(tmp_path), env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
deadline = time.time() + 30
while time.time() < deadline and not _timer_pid(reg, jid):
time.sleep(0.2)
assert _timer_pid(reg, jid), "the start command must have armed the timer"
time.sleep(4) time.sleep(4)
_kill_timer(reg, jid) # the agent dies: no more heartbeats agent.kill() # the agent dies: its timer stops, no more heartbeats
agent.wait(timeout=10)
t0 = time.time() t0 = time.time()
stdout, stderr = proc.communicate(timeout=120) stdout, stderr = proc.communicate(timeout=120)
finally: finally:
if agent is not None and agent.poll() is None:
agent.kill()
_kill_timer(reg, jid) _kill_timer(reg, jid)
if proc.poll() is None: if proc.poll() is None:
proc.kill() proc.kill()