Files
multi-agent-mux/.agents/skills/lib_py/delegation/publisher.py
T
GodopuandClaude Fable 5.1 897b505266 feat(issue-18): decide liveness and delivery by the event protocol, not the screen
Contract (no new event types): heartbeat every N s (automatic timer),
unresponsive after K x N s without an event, `started` within T s of the
injection, retained per-job state, and a remote transport.

- registry: heartbeat_sec / heartbeat_misses / ack_timeout_sec / transport
  fields; idle_timeout_sec derives K x N unless explicit (defaults 30/4/60
  keep the historical 120 s idle window).
- heartbeat.py + publish_event --heartbeat N: detached fixed-cadence timer
  publishing signed `progress` heartbeats (threaded wire publish, local
  record first); stopped by the terminal event via pidfile, by re-dispatch
  and by lifetime. Terminal events refuse --heartbeat.
- publish_event: retained `<prefix>/state` (status, last_event, seq, HMAC)
  sent on the same connection after every event (--no-state to skip);
  publisher.publish_raw takes extra messages.
- job_subscriber: subscribes the state topic, recovers terminal status from
  retained state, resets idle on any accepted message, records seq_gap and
  reconnected in the audit log, idle budget before the first event is
  T + K x N (--ack-timeout), --remote mirrors started/terminal events into
  the registry through the transition boundary.
- lib.sh send_keys_safe: an accepted-but-unconfirmed submission is confirmed
  by agent_status working or the job's own `started` (_sks_job_started);
  pane change and busy token are no longer delivery signals.
- wrapper: --heartbeat/--heartbeat-misses/--ack-timeout (MAM_* env),
  injected start command arms the timer, brief states the contract,
  _wait_started (registry / events.log / subscriber log, bounded by T and
  the wall clock) replaces the 10 s inject grace, R1 grace uses only the
  on-disk event age (herdr/pane logged once as a diagnostic),
  remote:<name> sessions skip preflight/idle/pane and dispatch over the
  broker (remote_dispatch).
- remote.py (+ scripts shim): signed retained dispatch (MAM_DISPATCH_SECRET)
  and a runner that mirrors the job into its own registry with the
  orchestrator's identity, publishes started --heartbeat, runs --exec and
  publishes the terminal event.
- loop runner: passes MAM_HEARTBEAT_SEC / MAM_HEARTBEAT_MISSES /
  MAM_ACK_TIMEOUT_S through; YAML rows with transport: remote delegate as
  remote:<name>.
- docs: MESSAGING.md 2.5 contract + remote transport, delegate-job SKILL.md,
  registry.md, loop SKILL.md.
- tests: tests/test_issue18_liveness_contract.py (16); issue 14 pane-signal
  tests flipped to started-based; issue 6 working-agent tests acknowledge
  with started; CLI snapshot/help captures refreshed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VCgu9Fa9iT7TbJUSzdq4Ff
2026-09-13 19:06:12 +09:00

234 lines
9.3 KiB
Python

"""Publish transports: one connection per event, or one connection reused.
Phase 1 ships the persistent transport as a prototype only. Nothing in
production uses it: the CLI and the shell helpers exit after a single publish,
so reusing a connection buys them nothing. The consumer is the phase 2a engine,
which is long-running and pays the CONNACK cost on every event today.
Both transports raise on failure so the caller's `with_retry` policy stays the
single place that decides how many attempts an event gets.
"""
from __future__ import annotations
import logging
import time
from typing import Any, Optional, Tuple
import paho.mqtt.client as mqtt
from lib_py.delegation import mqtt_common
logger = logging.getLogger("delegate_job.publisher")
CONNECT_ACK_TIMEOUT = 10 # seconds to wait for CONNACK
PUBLISH_ACK_TIMEOUT = 5 # seconds to wait for QoS-1 PUBACK
def _send(client, topic: str, body: bytes, retain: bool) -> None:
"""Publish one QoS-1 message and wait for the broker ACK.
paho queues a publish issued while disconnected and returns
``MQTT_ERR_NO_CONN``; ``wait_for_publish`` then raises ``RuntimeError``,
which is deliberately NOT in the retry tuple (a RuntimeError normally means a
programming error and must not be retried blindly). Left alone, that turned
the one state a persistent connection exists to survive -- the gap between a
drop and paho's reconnect -- into a single-attempt failure. Detect that one
condition explicitly and raise a retryable error instead.
"""
info = client.publish(topic, payload=body, qos=1, retain=retain)
if getattr(info, "rc", mqtt.MQTT_ERR_SUCCESS) == mqtt.MQTT_ERR_NO_CONN:
raise ConnectionError("client is not connected (paho is reconnecting)")
info.wait_for_publish(timeout=PUBLISH_ACK_TIMEOUT)
if not info.is_published():
raise TimeoutError("publish not acknowledged within timeout")
class _BasePublisher(object):
"""Shared retry wrapper so both transports report outcomes identically."""
def publish(self, prepared): # pragma: no cover - interface
raise NotImplementedError
def publish_with_retry(self, prepared, attempts=3) -> Tuple[bool, Optional[str]]:
runner = mqtt_common.with_retry(
self.publish,
attempts=attempts,
exceptions=(OSError, TimeoutError, ConnectionError, ValueError),
)
try:
runner(prepared)
except Exception as exc:
logger.error("publish failed after %d attempts: %s", attempts, exc)
return False, str(exc)
return True, None
def open(self):
return self
def close(self):
return None
class OneShotPublisher(_BasePublisher):
"""Connect, publish, wait for the ACK, disconnect. The current behaviour."""
def __init__(self, client_factory=None):
self.client_factory = client_factory or mqtt_common.make_client
self.connect_count = 0
def publish(self, prepared):
self.publish_raw(prepared.config, prepared.topic, prepared.body, prepared.retain)
def publish_raw(self, config, topic: str, body: bytes, retain: bool, extra=()) -> None:
"""Connect, publish, wait for the ACK, disconnect.
The single implementation of one-shot publishing: ``publish_event``'s
CLI path calls straight into this, so the two entry points cannot drift.
``extra`` messages (``(topic, body, retain)`` tuples, Issue #18's retained
job state) go out over the same connection, each awaited like the first.
"""
client = self.client_factory("publisher", config)
self.connect_count += 1
connected = {"rc": None}
def on_connect(_c, _u, _flags, reason_code, _props):
connected["rc"] = reason_code
client.on_connect = on_connect
client.connect(config.host, config.port, config.keepalive)
client.loop_start()
try:
deadline = time.monotonic() + CONNECT_ACK_TIMEOUT
while connected["rc"] is None and time.monotonic() < deadline:
time.sleep(0.05)
if connected["rc"] is None:
raise TimeoutError("no CONNACK from broker")
if mqtt_common.reason_code_value(connected["rc"]) != 0:
raise ConnectionError("broker refused connection: rc={}".format(connected["rc"]))
_send(client, topic, body, retain)
for x_topic, x_body, x_retain in (extra or ()):
_send(client, x_topic, x_body, x_retain)
finally:
client.loop_stop()
try:
client.disconnect()
except Exception: # pragma: no cover - disconnect best effort
pass
class PersistentPublisher(_BasePublisher):
"""One connection reused across events; paho handles reconnect underneath.
Bound to a single broker: the connection is established from the first
event's config, so an event for a job on a different broker is refused
rather than silently published to the wrong place.
"""
def __init__(self, client_factory=None):
self.client_factory = client_factory or mqtt_common.make_client
self.connect_count = 0
self._client = None
self._config = None
self._fingerprint_bound = None
def __enter__(self):
return self.open()
def __exit__(self, exc_type, exc, tb):
self.close()
return False
def open(self):
return self
@staticmethod
def _fingerprint(config) -> tuple:
"""An immutable snapshot of everything that decides *which connection*.
Endpoint alone is not enough: two jobs can share a host and port while
carrying different credentials or TLS material, and reusing the first
connection would publish under the wrong principal or silently drop a
requested TLS setting. Snapshotting also means later mutation of the
original config object cannot change what the guard believes is bound.
"""
fields = ("host", "port", "username", "password", "tls", "ca_certs",
"certfile", "keyfile", "keepalive", "client_id_prefix")
return tuple((name, getattr(config, name, None)) for name in fields)
@staticmethod
def _describe_mismatch(bound: tuple, incoming: tuple) -> str:
"""Name the differing fields without ever echoing their values."""
differing = [name for (name, old), (_n, new) in zip(bound, incoming) if old != new]
return ", ".join(differing) or "connection settings"
def _ensure_client(self, config):
if self._client is not None:
incoming = self._fingerprint(config)
if incoming != self._fingerprint_bound:
raise ValueError(
"PersistentPublisher is bound to {}:{}; refusing an event whose "
"{} differ".format(
self._config.host, self._config.port,
self._describe_mismatch(self._fingerprint_bound, incoming))
)
return self._client
client = self.client_factory("publisher", config)
self.connect_count += 1
# The handshake result belongs to THIS client, so it lives in a local the
# callback closes over. Holding it on self would let a refused attempt's
# reason code be read by the next attempt: the retry would either reject a
# healthy connection or, worse, accept one whose CONNACK never arrived.
connected = {"rc": None}
def on_connect(_c, _u, _flags, reason_code, _props):
connected["rc"] = reason_code
client.on_connect = on_connect
client.connect(config.host, config.port, config.keepalive)
client.loop_start()
reconnect_delay_set = getattr(client, "reconnect_delay_set", None)
if reconnect_delay_set is not None:
reconnect_delay_set(min_delay=1, max_delay=16)
try:
deadline = time.monotonic() + CONNECT_ACK_TIMEOUT
while connected["rc"] is None and time.monotonic() < deadline:
time.sleep(0.05)
if connected["rc"] is None:
raise TimeoutError("no CONNACK from broker")
if mqtt_common.reason_code_value(connected["rc"]) != 0:
raise ConnectionError("broker refused connection: rc={}".format(connected["rc"]))
except Exception:
self._discard(client) # never leak the half-open client on the way out
raise
self._client = client
self._config = config
self._fingerprint_bound = self._fingerprint(config)
return client
@staticmethod
def _discard(client):
try:
client.loop_stop()
client.disconnect()
except Exception: # pragma: no cover - best effort
pass
def publish(self, prepared):
client = self._ensure_client(prepared.config)
# Both failure shapes (queued-while-disconnected and no PUBACK) are raised
# as retryable errors by _send, so the caller's with_retry can ride out a
# reconnect instead of recording a one-attempt failure.
_send(client, prepared.topic, prepared.body, prepared.retain)
def close(self):
if self._client is None:
return
try:
self._discard(self._client)
finally:
self._client = None
self._config = None
self._fingerprint_bound = None