#!/usr/bin/env python3 """ job-dispatch.py: Dispatch a job by sending a DM to an agent. Usage: job-dispatch.py [--dry-run] Reads /home/super/Projects/NetVM/jobs/.json, renders the prompt template, sends DM via dm.py, logs to job-log.jsonl. Part of the JOB system (see docs/JOB-SPEC.md). """ import sys import re import os import json import subprocess import uuid from datetime import datetime, timezone from pathlib import Path # Paths NETVM_ROOT = Path("/home/super/Projects/NetVM") JOBS_DIR = NETVM_ROOT / "jobs" DM_PY = NETVM_ROOT / "bin" / "dm.py" CHAT_API = NETVM_ROOT / "bin" / "muse-chat-api.py" NETVM_EXEC = "/home/super/Projects/NetVM/bin/netvm-exec.sh" JOB_LOG = NETVM_ROOT / "job-log.jsonl" SIDECHAT_STATE = NETVM_ROOT / "job-sidechats.json" def load_sidechat_state(): if SIDECHAT_STATE.exists(): try: return json.loads(SIDECHAT_STATE.read_text()) except Exception: return {} return {} def save_sidechat_state(state): tmp = SIDECHAT_STATE.with_suffix(".tmp") tmp.write_text(json.dumps(state, indent=2)) tmp.replace(SIDECHAT_STATE) def extract_uuid(url): m = re.search(r"/thread/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})", url or "") return m.group(1) if m else None def get_current_url(agent="opm"): try: cmd = [NETVM_EXEC, agent, "--", "python3", str(CHAT_API), "--account", agent, "url"] r = subprocess.run(cmd, capture_output=True, text=True, timeout=30) if r.returncode == 0: return r.stdout.strip() except Exception: pass return None def use_sidechat_uuid(agent, thread_uuid, dry_run=False): if dry_run: return False cmd = [NETVM_EXEC, agent, "--", "python3", str(CHAT_API), "--account", agent, "sidechat", "use", thread_uuid] try: r = subprocess.run(cmd, capture_output=True, text=True, timeout=30) return r.returncode == 0 except Exception: return False # Add bin to path for rate_limiter sys.path.insert(0, str(NETVM_ROOT / "bin")) try: from rate_limiter import rate_limit_wait HAS_RATE_LIMITER = True except ImportError: HAS_RATE_LIMITER = False def log_event(event_type, data): """Append event to job-log.jsonl""" entry = { "ts": datetime.now(timezone.utc).isoformat(), "type": event_type, **data } with open(JOB_LOG, "a") as f: f.write(json.dumps(entry) + "\n") def load_job(job_name): """Load job JSON definition""" # Try .json first, then .yaml (for compatibility) job_file = JOBS_DIR / f"{job_name}.json" if not job_file.exists(): job_file = JOBS_DIR / f"{job_name}.yaml" if not job_file.exists(): print(f"Error: Job '{job_name}' not found in {JOBS_DIR}", file=sys.stderr) sys.exit(1) print(f"Warning: YAML not supported (no PyYAML). Convert {job_file} to JSON.", file=sys.stderr) sys.exit(1) with open(job_file) as f: return json.load(f) def render_prompt(template, variables): """Render prompt template with variables""" # Simple {var} substitution result = template for key, value in variables.items(): result = result.replace(f"{{{key}}}", str(value)) return result # ---- follow-up tracking (DM follow-up system integration) ---------------- # Jobs opt in via a "followup" block in the job JSON: # # "followup": { # "expect_reply": true, # required: enables tracking # "timeout": "1h", # duration ("30s","15m","2h","1d") or seconds # # int; default "1h" (3600s) # "nudges": 2, # 0..10, default 2 # "escalate": "opm", # identity string, default "opm" # "route": "646-pip-coord" # optional route_id # } # # The dispatcher translates this into dm.py --tag flags using the canonical # vocabulary (box-threads/DEPLOY-DECISIONS.md). dm.py strips the tags from # delivered text and creates a dm_followup request-store record after # SENT+VERIFIED. Jobs without a followup block behave exactly as today. # # LIMITATIONS (v1): # - The heartbeat job NEVER gets follow-ups (loopback health check). # Hardcoded guard below; a followup block on heartbeat is ignored loudly. # - Sidechat sends (muse-chat-api.py direct path) do not go through dm.py, # so --tag flags cannot attach. v2 needs a record-creation path that does # not send (e.g. POST /api/box/followups, or a bl->VM queue; bl cannot # currently SSH to the VM). The dispatcher logs a warning when a # sidechat-targeted job has followup enabled. HEARTBEAT_JOB_NAME = "heartbeat" def parse_followup_duration(value): """Parse a followup timeout into seconds. Accepts int (seconds) or strings like '30s', '15m', '2h', '1d'. Returns int seconds. Raises ValueError on bad input.""" if isinstance(value, int) and not isinstance(value, bool): s = value elif isinstance(value, str): m = re.fullmatch(r"(\d+)\s*([smhd])?", value.strip().lower()) if not m: raise ValueError("bad duration %r" % (value,)) n = int(m.group(1)) unit = m.group(2) or "s" s = n * {"s": 1, "m": 60, "h": 3600, "d": 86400}[unit] else: raise ValueError("timeout must be int seconds or duration string") if not 60 <= s <= 604800: raise ValueError("timeout must be 60..604800s (1m..7d), got %d" % s) return s def build_followup_tags(followup): """Translate a job's followup block into dm.py --tag arguments. Returns a flat list like ['--tag', 'reply:timeout=3600', ...]. Returns [] if followup is falsy or expect_reply is not true. Raises ValueError on invalid config (caller logs a warning and sends the DM untagged -- the job itself must never fail over this).""" if not followup or not followup.get("expect_reply"): return [] args = [] # Bare trigger. dm.py's parse_tags splits each --tag on '='; an empty # value means "present". If the deployed dm.py requires a non-empty # value for this key, use 'reply:expected=true' instead. args += ["--tag", "reply:expected="] if "timeout" in followup: s = parse_followup_duration(followup["timeout"]) args += ["--tag", "reply:timeout=%d" % s] if "nudges" in followup: n = followup["nudges"] if not isinstance(n, int) or isinstance(n, bool) or not 0 <= n <= 10: raise ValueError("nudges must be int 0..10") args += ["--tag", "reply:nudges=%d" % n] if "escalate" in followup: e = followup["escalate"] if not isinstance(e, str) or not re.fullmatch(r"[a-z0-9_-]{1,64}", e): raise ValueError("escalate must be an identity string") args += ["--tag", "reply:escalate=%s" % e] if "route" in followup: r = followup["route"] if not isinstance(r, str) or not re.fullmatch(r"[a-z0-9_-]{1,64}", r): raise ValueError("route must be a route_id string") args += ["--tag", "route:%s" % r] # 'thread' is intentionally not settable from job JSON; it names a # specific existing thread and is filled by the dispatcher when known. return args def send_dm(agent, target, message, dry_run=False, followup_tags=None): """Send DM via dm.py. followup_tags: flat ['--tag', 'k=v', ...] list from build_followup_tags(), or None.""" if dry_run: print(f"[DRY RUN] Would send to {agent} ({target}):") if followup_tags: print(f"[DRY RUN] With follow-up tags: {' '.join(followup_tags)}") print(message[:200] + "..." if len(message) > 200 else message) return "dry-run-id" # Rate limit if HAS_RATE_LIMITER: rate_limit_wait(agent) cmd = ([str(DM_PY), "send", "--agent", "opm", "--to", agent, "--target", target] + (followup_tags or []) + [message]) result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) if result.returncode != 0: print(f"DM send failed: {result.stderr}", file=sys.stderr) return None # Extract message ID from output (format: SENT [id]) # dm.py prints the ID on success output = result.stdout.strip() # Try to parse ID from output return output def create_sidechat(sender_agent, dry_run=False): """Create a sidechat via muse-chat-api.py in sender's context. Returns True on success (browser now on new sidechat), False on failure.""" if dry_run: print(f"[DRY RUN] Would create sidechat for {sender_agent}") return True cmd = [NETVM_EXEC, sender_agent, "--", "python3", str(CHAT_API), "--account", sender_agent, "sidechat", "create"] try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=90) output = result.stdout.strip() err = result.stderr.strip() # Success if we see "Created:" (URL may be /thread/new placeholder) if "Created:" in output: print(f"Sidechat created", file=sys.stderr) return True print(f"Sidechat create failed. stdout: {output[:300]}", file=sys.stderr) print(f"Sidechat create stderr: {err[:300]}", file=sys.stderr) print(f"Return code: {result.returncode}", file=sys.stderr) return False except Exception as e: print(f"Sidechat creation failed: {e}", file=sys.stderr) return False def send_to_current_chat(sender_agent, message, dry_run=False): """Send message to current chat via muse-chat-api.py (no navigation). Used after sidechat create - browser is already on the new chat.""" if dry_run: print(f"[DRY RUN] Would send to current chat: {message[:100]}...") return "dry-run-id" if HAS_RATE_LIMITER: rate_limit_wait(sender_agent) cmd = [NETVM_EXEC, sender_agent, "--", "python3", str(CHAT_API), "--account", sender_agent, "send", message] try: result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) if result.returncode == 0: return "sent-to-sidechat" print(f"Send failed: {result.stderr[:200]}", file=sys.stderr) return None except Exception as e: print(f"Send failed: {e}", file=sys.stderr) return None def main(): if len(sys.argv) < 2: print(f"Usage: {sys.argv[0]} [--dry-run]", file=sys.stderr) sys.exit(1) job_name = sys.argv[1] dry_run = "--dry-run" in sys.argv # Load job job = load_job(job_name) # Generate job_id job_id = f"{job_name}-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:8]}" # Variables for template variables = { "job_id": job_id, "job_name": job_name, "date": datetime.now(timezone.utc).strftime("%Y-%m-%d"), "datetime": datetime.now(timezone.utc).isoformat(), "prev_job_id": os.environ.get("CHAIN_PREV_JOB_ID", ""), "prev_result": os.environ.get("CHAIN_PREV_RESULT", ""), } # Follow-up tracking (opt-in via job JSON "followup" block; see helpers). # The heartbeat job is a loopback health check and must never be tracked. followup_cfg = job.get("followup") followup_tags = [] if followup_cfg: if job_name == HEARTBEAT_JOB_NAME: print(f"Warning: job '{job_name}' must not use follow-up " f"tracking (loopback); ignoring followup block", file=sys.stderr) log_event("job_followup_skipped", {"job_id": job_id, "reason": "heartbeat_loopback"}) else: try: followup_tags = build_followup_tags(followup_cfg) if followup_tags: log_event("job_followup_armed", {"job_id": job_id, "tags": followup_tags}) except ValueError as e: print(f"Warning: invalid followup block: {e}; " f"sending untagged", file=sys.stderr) log_event("job_followup_invalid", {"job_id": job_id, "error": str(e)}) # Render prompt prompt_template = job.get("prompt_template", "") if not prompt_template: print(f"Error: Job '{job_name}' has no prompt_template", file=sys.stderr) sys.exit(1) rendered = render_prompt(prompt_template, variables) # Format as JOB DM dm_message = f"[JOB {job_id}] {rendered}" # Get target agent = job.get("agent", "muse") sidechat_cfg = job.get("sidechat", {}) sidechat_url = None use_sidechat = sidechat_cfg.get("create", False) sidechat_created = False if use_sidechat: reuse_key = sidechat_cfg.get("reuse_key") name_tmpl = sidechat_cfg.get("name_template", "job-{job_name}-{date}") sc_name = render_prompt(name_tmpl, variables) # UUID-based reuse: check state file for existing thread state = load_sidechat_state() if reuse_key else {} stored_uuid = state.get(reuse_key) if reuse_key else None reused = False if stored_uuid and not dry_run: print(f"Trying reuse of thread {stored_uuid}...", file=sys.stderr) if use_sidechat_uuid("opm", stored_uuid, dry_run=dry_run): # Verify we're actually on the right thread cur_url = get_current_url("opm") if stored_uuid in (cur_url or ""): log_event("job_sidechat_reused", {"job_id": job_id, "thread_uuid": stored_uuid, "reuse_key": reuse_key}) print(f"Reusing sidechat thread: {stored_uuid}", file=sys.stderr) sidechat_created = True target = "sidechat" reused = True capture_uuid = False else: print(f"UUID mismatch after use, creating new", file=sys.stderr) else: print(f"Stored thread unreachable, creating new", file=sys.stderr) if not reused: print(f"Creating sidechat for job {job_id}...", file=sys.stderr) sidechat_created = create_sidechat("opm", dry_run=dry_run) if sidechat_created: log_event("job_sidechat_created", {"job_id": job_id, "sidechat_name": sc_name, "reuse_key": reuse_key}) print(f"Sidechat created: {sc_name}", file=sys.stderr) target = "sidechat" # Capture UUID after send for future reuse capture_uuid = bool(reuse_key and not dry_run) else: print(f"Warning: Failed to create sidechat, falling back to main", file=sys.stderr) target = "main" capture_uuid = False else: target = "main" # dm_target override: job JSON can specify a dm.py --target # (sidechat name/UUID) for tracked sends to a thread. _dt = job.get("dm_target") if _dt and isinstance(_dt, str) and _dt.strip(): target = _dt.strip() # Log job_sent log_event("job_sent", { "job_id": job_id, "job_name": job_name, "agent": agent, "target": target, "dry_run": dry_run, }) # Send DM: to sidechat via direct API, or to main via dm.py if use_sidechat and sidechat_created: msg_id = send_to_current_chat("opm", dm_message, dry_run=dry_run) # Log as dispatched (no dm.py ID, but sent) if msg_id and not dry_run: print(f"Dispatched job {job_id} to sidechat (direct send)") log_event("job_dispatched", { "job_id": job_id, "target": "sidechat", "method": "direct", }) # Capture thread UUID for reuse_key mapping if capture_uuid and reuse_key: import time as _t for _ in range(15): _t.sleep(1) cur = get_current_url("opm") thread_uuid = extract_uuid(cur) if thread_uuid: state = load_sidechat_state() state[reuse_key] = thread_uuid save_sidechat_state(state) log_event("job_sidechat_mapped", {"reuse_key": reuse_key, "thread_uuid": thread_uuid}) print(f"Mapped reuse_key {reuse_key} -> {thread_uuid}", file=sys.stderr) break if followup_tags and not dry_run: # v1 limitation: sidechat sends bypass dm.py, so --tag flags # cannot attach and no dm_followup record is created. The # job is still dispatched; tracking is skipped loudly. print(f"Warning: follow-up tracking not supported for " f"sidechat sends (v1); job {job_id} dispatched " f"without tracking", file=sys.stderr) log_event("job_followup_skipped", {"job_id": job_id, "reason": "sidechat_path_v1"}) # Skip the dm.py dispatch block below import sys as _sys2 _sys2.exit(0) else: msg_id = send_dm(agent, target, dm_message, dry_run=dry_run, followup_tags=followup_tags) if msg_id and not dry_run: print(f"Dispatched job {job_id} to {agent} (DM: {msg_id})") log_event("job_dispatched", { "job_id": job_id, "dm_id": msg_id, }) elif dry_run: print(f"[DRY RUN] Job {job_id} would be dispatched to {agent}") else: print(f"Failed to dispatch job {job_id}", file=sys.stderr) log_event("job_failed", { "job_id": job_id, "error": "dm_send_failed", }) sys.exit(1) if __name__ == "__main__": main()