Files
multi-agent-mux/.agents/skills/lib_py/delegation/cli.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

104 lines
4.2 KiB
Python

"""`python -m lib_py.delegation.cli` — the router the shell helpers call.
Deliberately thin: every subcommand delegates to the existing `main()` of the
module that already owned that behaviour, so exit codes, stdout shape and side
effects cannot drift between the shell path and the script path. The one piece
of real logic here is `ensure-daemon`, which reproduces the shell helper's
stderr wording and exit status for a missing monitor script.
"""
from __future__ import annotations
import argparse
import logging
import sys
from typing import List, Optional
from lib_py.delegation import daemon, mqtt_common, publish_event, registry
logger = logging.getLogger("delegate_job.cli")
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="lib_py.delegation.cli",
description="delegation helpers invoked by lib.sh",
)
sub = parser.add_subparsers(dest="command", required=True)
p_submit = sub.add_parser("submit", help="register a job; prints the job id")
p_submit.add_argument("--prompt", required=True)
p_submit.add_argument("--agent", default="claude-code")
p_submit.add_argument("--agent-session", default="herdr:claude")
p_submit.add_argument("--registry-dir", default=None)
p_pub = sub.add_parser("publish", help="publish a job lifecycle event")
p_pub.add_argument("--job", required=True)
p_pub.add_argument("--event", default="progress", choices=publish_event.VALID_EVENTS)
p_pub.add_argument("--detail", default="")
p_pub.add_argument("--heartbeat", type=int, default=None, metavar="N",
help="start the automatic heartbeat timer (Issue #18)")
p_pub.add_argument("--data", default=None)
p_pub.add_argument("--retained", action="store_true")
p_pub.add_argument("--registry-dir", default=None)
p_pub.add_argument("--attempts", type=int, default=3)
p_pub.add_argument("--expect-dispatch-token", default=None)
p_pub.add_argument("--attempt-id", type=int, default=None)
p_pub.add_argument("-v", "--verbose", action="store_true")
p_daemon = sub.add_parser("ensure-daemon", help="start the subscriber daemon if absent")
p_daemon.add_argument("--job", required=True)
p_daemon.add_argument("--workdir", default=None)
return parser
def main(argv: Optional[List[str]] = None) -> int:
mqtt_common.ensure_env_loaded()
args = _build_parser().parse_args(argv)
if args.command == "submit":
forward = ["register", "--prompt", args.prompt,
"--agent", args.agent, "--agent-session", args.agent_session]
if args.registry_dir:
forward = ["--registry-dir", args.registry_dir] + forward
return registry.main(forward)
if args.command == "publish":
forward = ["--job", args.job, "--event", args.event,
"--detail", args.detail, "--attempts", str(args.attempts)]
if args.data is not None:
forward += ["--data", args.data]
if args.retained:
forward += ["--retained"]
if args.registry_dir:
forward += ["--registry-dir", args.registry_dir]
if args.expect_dispatch_token:
forward += ["--expect-dispatch-token", args.expect_dispatch_token]
if args.attempt_id is not None:
forward += ["--attempt-id", str(args.attempt_id)]
if getattr(args, "heartbeat", None) is not None:
forward += ["--heartbeat", str(args.heartbeat)]
if args.verbose:
forward += ["--verbose"]
return publish_event.main(forward)
if args.command == "ensure-daemon":
try:
pids = daemon.ensure_subscriber_daemon(args.job, args.workdir)
except FileNotFoundError as exc:
# Byte-identical to the legacy shell helper's failure path.
print("ERROR: monitor script not found: {}".format(exc.args[0]), file=sys.stderr)
return 1
if not pids and daemon.daemon_disabled():
print("subscriber daemon not started ({}=1)".format(daemon.NO_DAEMON_ENV),
file=sys.stderr)
for pid in pids:
print(pid)
return 0
return 1
if __name__ == "__main__":
sys.exit(main())