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
104 lines
4.2 KiB
Python
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())
|