feat: unified fleet CLI, Main Chat preservation policy, sidechat routing, and file transfers
- Added CHAT_POLICY.md and README.md banner enforcing sidechat-first and file-transfer-first rules. - Added strict Main Chat block to super dm send and super dm wo with --allow-main-chat override. - Implemented file transfer staging and metadata registry in super dm send-file and super dm files (with clean subcommand). - Added full job lifecycle management (show, create, enable, disable, delete, run --follow) to super-cli.py and box-ctl.py. - Audited all jobs in jobs/*.json and redirected automated dispatches away from Main Chat. - Hardened chromebox-watchdog.sh with systemd user session environment exports and stale singleton cleanup. - Added compose_check command choice to muse-chat-api.py.
This commit is contained in:
+70
-103
@@ -17,11 +17,19 @@ import os
|
||||
import json
|
||||
import subprocess
|
||||
import uuid
|
||||
import argparse
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# Paths
|
||||
NETVM_ROOT = Path("/home/super/Projects/NetVM")
|
||||
sys.path.insert(0, str(NETVM_ROOT / "bin"))
|
||||
try:
|
||||
import pipeline_engine
|
||||
HAS_PIPELINE = True
|
||||
except ImportError:
|
||||
HAS_PIPELINE = False
|
||||
|
||||
JOBS_DIR = NETVM_ROOT / "jobs"
|
||||
DM_PY = NETVM_ROOT / "bin" / "dm.py"
|
||||
CHAT_API = NETVM_ROOT / "bin" / "muse-chat-api.py"
|
||||
@@ -211,17 +219,16 @@ def send_dm(agent, target, message, dry_run=False, followup_tags=None):
|
||||
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)
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
|
||||
|
||||
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
|
||||
# Extract message ID from output (format: DM <id> ... SENT and VERIFIED)
|
||||
output = result.stdout.strip()
|
||||
# Try to parse ID from output
|
||||
return output
|
||||
m = re.search(r"DM\s+([a-f0-9]{8})", output)
|
||||
return m.group(1) if m else "verified"
|
||||
|
||||
def create_sidechat(sender_agent, dry_run=False):
|
||||
"""Create a sidechat via muse-chat-api.py in sender's context.
|
||||
@@ -272,12 +279,19 @@ def send_to_current_chat(sender_agent, message, dry_run=False):
|
||||
return None
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print(f"Usage: {sys.argv[0]} <job_name> [--dry-run]", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
p = argparse.ArgumentParser(description="Dispatch a job by sending a DM to an agent.")
|
||||
p.add_argument("job_name", help="Name of the job (without .json)")
|
||||
p.add_argument("--dry-run", action="store_true", help="Print what would be sent without sending")
|
||||
p.add_argument("--pipeline-run", default=os.environ.get("CHAIN_PIPELINE_RUN_ID"),
|
||||
help="Pipeline run ID if running as part of a pipeline")
|
||||
p.add_argument("--step-n", type=int, default=int(os.environ.get("CHAIN_STEP_N", "1")),
|
||||
help="Step sequence number in the pipeline")
|
||||
args = p.parse_args()
|
||||
|
||||
job_name = sys.argv[1]
|
||||
dry_run = "--dry-run" in sys.argv
|
||||
job_name = args.job_name
|
||||
dry_run = args.dry_run
|
||||
pipeline_run_id = args.pipeline_run
|
||||
step_n = args.step_n
|
||||
|
||||
# Load job
|
||||
job = load_job(job_name)
|
||||
@@ -293,6 +307,8 @@ def main():
|
||||
"datetime": datetime.now(timezone.utc).isoformat(),
|
||||
"prev_job_id": os.environ.get("CHAIN_PREV_JOB_ID", ""),
|
||||
"prev_result": os.environ.get("CHAIN_PREV_RESULT", ""),
|
||||
"pipeline_run_id": pipeline_run_id or "",
|
||||
"step_n": step_n,
|
||||
}
|
||||
|
||||
# Follow-up tracking (opt-in via job JSON "followup" block; see helpers).
|
||||
@@ -326,61 +342,33 @@ def main():
|
||||
|
||||
rendered = render_prompt(prompt_template, variables)
|
||||
|
||||
# Standard completion envelope: guarantee agents know how to report completion
|
||||
if "[RESULT" not in rendered:
|
||||
rendered = rendered.rstrip() + f"\n\nWhen finished, end your response with:\n[RESULT {job_id}] <OK|FAILED>: <summary of output>"
|
||||
|
||||
# Format as JOB DM
|
||||
dm_message = f"[JOB {job_id}] {rendered}"
|
||||
|
||||
# Get target
|
||||
# Determine recipient agent and target
|
||||
agent = job.get("agent", "muse")
|
||||
sidechat_cfg = job.get("sidechat", {})
|
||||
sidechat_url = None
|
||||
target = "main"
|
||||
|
||||
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()
|
||||
if pipeline_run_id:
|
||||
if HAS_PIPELINE:
|
||||
p_entry = pipeline_engine.get_pipeline(pipeline_run_id)
|
||||
if not p_entry:
|
||||
p_entry = pipeline_engine.create_pipeline(job_name, run_id=pipeline_run_id)
|
||||
target = p_entry.get("target") or f"pipe-{pipeline_run_id.split('-')[-1]}"
|
||||
else:
|
||||
target = f"pipe-{pipeline_run_id.split('-')[-1]}"
|
||||
elif job.get("dm_target"):
|
||||
target = job.get("dm_target").strip()
|
||||
elif job.get("target"):
|
||||
target = job.get("target").strip()
|
||||
elif job.get("sidechat", {}).get("create"):
|
||||
sc_cfg = job.get("sidechat", {})
|
||||
sc_name = render_prompt(sc_cfg.get("name_template", "job-{job_name}-{date}"), variables)
|
||||
target = sc_name
|
||||
|
||||
# Log job_sent
|
||||
log_event("job_sent", {
|
||||
@@ -389,64 +377,43 @@ def main():
|
||||
"agent": agent,
|
||||
"target": target,
|
||||
"dry_run": dry_run,
|
||||
"pipeline_run_id": pipeline_run_id,
|
||||
"step_n": step_n,
|
||||
})
|
||||
|
||||
# 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)
|
||||
# Dispatch via dm.py (handles main or sidechat with auto-provisioning and verification)
|
||||
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})")
|
||||
print(f"Dispatched job {job_id} to {agent}/{target} (DM: {msg_id})")
|
||||
log_event("job_dispatched", {
|
||||
"job_id": job_id,
|
||||
"dm_id": msg_id,
|
||||
"pipeline_run_id": pipeline_run_id,
|
||||
"step_n": step_n,
|
||||
})
|
||||
if pipeline_run_id and HAS_PIPELINE:
|
||||
pipeline_engine.record_step_dispatch(
|
||||
pipeline_run_id, step_n, job_name, job_id, agent, target, dm_id=msg_id
|
||||
)
|
||||
sc_state = load_sidechat_state()
|
||||
if target in sc_state:
|
||||
val = sc_state[target]
|
||||
t_uuid = val.get("thread_uuid") if isinstance(val, dict) else val
|
||||
if t_uuid:
|
||||
pipeline_engine.update_pipeline_thread(pipeline_run_id, t_uuid)
|
||||
elif dry_run:
|
||||
print(f"[DRY RUN] Job {job_id} would be dispatched to {agent}")
|
||||
print(f"[DRY RUN] Job {job_id} would be dispatched to {agent}/{target}")
|
||||
else:
|
||||
print(f"Failed to dispatch job {job_id}", file=sys.stderr)
|
||||
log_event("job_failed", {
|
||||
"job_id": job_id,
|
||||
"error": "dm_send_failed",
|
||||
"pipeline_run_id": pipeline_run_id,
|
||||
})
|
||||
if pipeline_run_id and HAS_PIPELINE:
|
||||
pipeline_engine.fail_pipeline(pipeline_run_id, "dm_send_failed")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user