Add distributed server implementation and protocol definitions

- Introduced `protocol.py` for shared data models used in server/client communication, including request and response schemas for registration, job submission, and status updates.
- Implemented `server.py` to manage a SQLite job queue and client registry, handling job polling, status updates, and job completion.
- Created a cheat sheet for server usage, detailing commands for starting the server, submitting jobs, and monitoring clients.
- Added several experiment configuration files for various training setups, including geometry vector injections and baseline ensembles.
This commit is contained in:
rpotter6298
2026-04-28 08:24:25 +02:00
parent 4dea45df78
commit 512ebd13b2
42 changed files with 4468 additions and 612 deletions
View File
+287
View File
@@ -0,0 +1,287 @@
"""
v4 batch dispatch — merge a batch.json with a base config and submit jobs.
Each batch entry specifies only what differs from the base config. Each entry
is dispatched as N_REPS independent jobs (full 5-fold CV per rep, different seeds),
writing to {run_name}/rep00/, rep01/, etc.
Usage:
python -m v4.distributed.batch_dispatch \\
--server http://apollo:8765 \\
--token <secret> \\
--config v4/configs/ensemble_fused.json \\
--batch v4/scripts/experiments/my_batch.json \\
[--reps 10] \\
[--seed-start 1234] \\
[--seed-step 100] \\
[--fold-seed-start 100] \\
[--fold-seed-step 100] \\
[--output-root v4/results] \\
[--priority 0] \\
[--dry-run]
batch.json format:
[
{
"run_name": "experiments/lr_sweep/lr1e3", // required
"overrides": { // optional — deep-merged into base
"training": { "lr": 0.001 }
},
"stage_overrides": { // optional — patched by stage name
"nt": { "epochs": 40 }
},
"reps": 10, // optional — overrides --reps
"priority": 0 // optional
}
]
Global flags (can also be set via env vars):
--server HT_SERVER
--token HT_TOKEN
"""
from __future__ import annotations
import argparse
import copy
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import requests
# ──────────────────────────────────────────────────────────────
# Config helpers
# ──────────────────────────────────────────────────────────────
def deep_merge(base: dict, override: dict) -> dict:
"""Recursively merge override into a copy of base.
- Dicts are merged recursively.
- All other types (scalars, lists) are replaced by the override value.
"""
result = copy.deepcopy(base)
for k, v in override.items():
if k in result and isinstance(result[k], dict) and isinstance(v, dict):
result[k] = deep_merge(result[k], v)
else:
result[k] = copy.deepcopy(v)
return result
def apply_stage_overrides(stages: list[dict], stage_overrides: dict) -> list[dict]:
"""Patch individual stages by name without replacing the entire list."""
stages = copy.deepcopy(stages)
for stage in stages:
name = stage.get("name")
if name in stage_overrides:
merged = deep_merge(stage, stage_overrides[name])
stage.clear()
stage.update(merged)
return stages
def build_config(base_cfg: dict, entry: dict, rep: int, seed: int, fold_seed: int,
output_root: str) -> dict:
"""Produce the final merged config for one rep of one batch entry."""
cfg = copy.deepcopy(base_cfg)
# Deep-merge top-level overrides
cfg = deep_merge(cfg, entry.get("overrides", {}))
# Patch individual stages by name
if "stage_overrides" in entry and "stages" in cfg:
cfg["stages"] = apply_stage_overrides(cfg["stages"], entry["stage_overrides"])
# Stamp run_name, model seed, split seed, output_root.
base_run_name = entry["run_name"]
cfg["run_name"] = f"{base_run_name}/rep{rep:02d}"
cfg["seed"] = seed
cfg["fold_seed"] = fold_seed
cfg["output_root"] = output_root
return cfg
def save_dispatched_config(cfg: dict, base_run_name: str, rep: int,
repo_root: Path) -> Path:
"""Write the merged config to v4/configs/dispatched/ and return its path."""
out_dir = repo_root / "v4" / "configs" / "dispatched" / base_run_name
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / f"rep{rep:02d}.json"
path.write_text(json.dumps(cfg, indent=2))
return path
# ──────────────────────────────────────────────────────────────
# Server HTTP helper
# ──────────────────────────────────────────────────────────────
class _API:
def __init__(self, base_url: str, token: str):
self.base_url = base_url.rstrip("/")
self._h = {"x-token": token}
def post(self, path: str, body: dict) -> dict:
r = requests.post(f"{self.base_url}{path}", headers=self._h,
json=body, timeout=10)
r.raise_for_status()
return r.json()
# ──────────────────────────────────────────────────────────────
# Dispatch
# ──────────────────────────────────────────────────────────────
def dispatch_batch(
api: _API,
base_cfg: dict,
batch: list[dict],
*,
default_reps: int,
seed_start: int,
seed_step: int,
fold_seed_start: int,
fold_seed_step: int,
output_root: str,
default_priority: int,
server_path: str,
repo_root: Path,
dry_run: bool,
) -> None:
total = skipped = 0
for entry in batch:
run_name = entry["run_name"]
reps = entry.get("reps", default_reps)
priority = entry.get("priority", default_priority)
print(f"\n[dispatch] {run_name} ({reps} reps)")
for rep in range(reps):
seed = seed_start + rep * seed_step
fold_seed = fold_seed_start + rep * fold_seed_step
cfg = build_config(base_cfg, entry, rep, seed, fold_seed, output_root)
cfg_path = save_dispatched_config(cfg, run_name, rep, repo_root)
rel_path = cfg_path.relative_to(repo_root)
server_cfg_path = str(rel_path)
job_body = {
"run_name": run_name,
"module": "v4.classes.v4_hypertower",
"args": ["--config", server_cfg_path],
"output_dir": output_root,
"priority": priority,
}
if dry_run:
print(
f" [dry-run] rep{rep:02d} seed={seed} "
f"fold_seed={fold_seed} config={server_cfg_path}"
)
total += 1
else:
resp = api.post("/jobs", job_body)
if resp.get("skipped"):
print(f" rep{rep:02d} [skip — results exist on disk]")
skipped += 1
elif resp.get("duplicate"):
print(f" rep{rep:02d} [skip — already queued] job_id={resp['job_id']}")
skipped += 1
else:
print(
f" rep{rep:02d} seed={seed} fold_seed={fold_seed} "
f"job_id={resp['job_id']} config={server_cfg_path}"
)
total += 1
action = "would submit" if dry_run else "submitted"
skip_note = f" ({skipped} already queued/done, skipped)" if skipped else ""
print(f"\n[dispatch] {action} {total} jobs across {len(batch)} experiment(s){skip_note}")
# ──────────────────────────────────────────────────────────────
# Entry point
# ──────────────────────────────────────────────────────────────
def main():
repo_root = Path(__file__).resolve().parents[2]
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
ap.add_argument("--server", default=os.environ.get("HT_SERVER", ""),
help="Server URL (or set HT_SERVER)")
ap.add_argument("--token", default=os.environ.get("HT_TOKEN", ""),
help="Shared secret (or set HT_TOKEN)")
ap.add_argument("--config", required=True,
help="Base config JSON file")
ap.add_argument("--batch", required=True,
help="Batch JSON file listing experiments")
ap.add_argument("--reps", type=int, default=10,
help="Repetitions per experiment (default: 10)")
ap.add_argument("--seed-start", type=int, default=1234,
help="Seed for rep00 (default: 1234)")
ap.add_argument("--seed-step", type=int, default=100,
help="Seed increment per rep (default: 100)")
ap.add_argument("--fold-seed-start", type=int, default=100,
help="Split fold_seed for rep00 (default: 100; matches v3)")
ap.add_argument("--fold-seed-step", type=int, default=100,
help="Split fold_seed increment per rep (default: 100; matches v3)")
ap.add_argument("--output-root", default="v4/results",
help="Output root written into each config (default: v4/results)")
ap.add_argument("--server-path", default="",
help="Absolute path to hypertower root on server "
"(used to build config paths in job args; "
"if omitted, relative paths are used)")
ap.add_argument("--priority", type=int, default=0,
help="Default job priority (default: 0)")
ap.add_argument("--dry-run", action="store_true",
help="Print jobs without submitting")
args = ap.parse_args()
if not args.dry_run:
if not args.server:
ap.error("--server is required (or set HT_SERVER)")
if not args.token:
ap.error("--token is required (or set HT_TOKEN)")
cfg_path = Path(args.config)
if not cfg_path.is_absolute():
cfg_path = repo_root / cfg_path
base_cfg = json.loads(cfg_path.read_text())
batch_path = Path(args.batch)
if not batch_path.is_absolute():
batch_path = repo_root / batch_path
batch = json.loads(batch_path.read_text())
if not isinstance(batch, list):
sys.exit("batch.json must be a JSON array")
for i, entry in enumerate(batch):
if "run_name" not in entry:
sys.exit(f"batch entry {i} is missing required 'run_name'")
api = _API(args.server, args.token) if not args.dry_run else None
dispatch_batch(
api,
base_cfg,
batch,
default_reps=args.reps,
seed_start=args.seed_start,
seed_step=args.seed_step,
fold_seed_start=args.fold_seed_start,
fold_seed_step=args.fold_seed_step,
output_root=args.output_root,
default_priority=args.priority,
server_path=args.server_path,
repo_root=repo_root,
dry_run=args.dry_run,
)
if __name__ == "__main__":
main()
+347
View File
@@ -0,0 +1,347 @@
"""
HyperTower distributed job CLI — submit jobs, view status.
Usage:
# View all connected clients
python -m v4.distributed.cli clients
# View a specific client
python -m v4.distributed.cli clients <client_id>
# Live monitoring
python -m v4.distributed.cli clients --watch
# View jobs (optionally filter by state)
python -m v4.distributed.cli jobs [--state pending|running|done|failed]
# Submit a job
python -m v4.distributed.cli submit \\
--run-name v4/ensemble_fused \\
-- --config v4/configs/ensemble_fused.json
# Submit all jobs from a batch file (JSON)
python -m v4.distributed.cli submit-batch jobs.json
# Cancel a pending job
python -m v4.distributed.cli cancel <job_id>
Global flags (can also be set via env vars):
--server HT_SERVER e.g. http://apollo:8765
--token HT_TOKEN
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from datetime import datetime, timezone
from typing import Optional
import requests
# ──────────────────────────────────────────────────────────────
# HTTP helpers
# ──────────────────────────────────────────────────────────────
class _API:
def __init__(self, base_url: str, token: str):
self.base_url = base_url.rstrip("/")
self._h = {"x-token": token}
def get(self, path: str, **params) -> object:
r = requests.get(f"{self.base_url}{path}", headers=self._h,
params=params, timeout=10)
r.raise_for_status()
return r.json()
def post(self, path: str, body: dict) -> object:
r = requests.post(f"{self.base_url}{path}", headers=self._h,
json=body, timeout=10)
r.raise_for_status()
return r.json()
def delete(self, path: str) -> object:
r = requests.delete(f"{self.base_url}{path}", headers=self._h, timeout=10)
r.raise_for_status()
return r.json()
# ──────────────────────────────────────────────────────────────
# Formatting helpers
# ──────────────────────────────────────────────────────────────
def _ago(ts: Optional[str]) -> str:
if not ts:
return "-"
try:
dt = datetime.fromisoformat(ts)
delta = datetime.now(timezone.utc) - dt
secs = int(delta.total_seconds())
if secs < 60:
return f"{secs}s ago"
elif secs < 3600:
return f"{secs//60}m ago"
else:
return f"{secs//3600}h{(secs%3600)//60}m ago"
except Exception:
return ts
def _table(rows: list[list[str]], headers: list[str]):
widths = [max(len(str(r[i])) for r in ([headers] + rows)) for i in range(len(headers))]
sep = " "
def _row(r):
return sep.join(str(r[i]).ljust(widths[i]) for i in range(len(r)))
print(_row(headers))
print("-" * (sum(widths) + len(sep) * (len(widths) - 1)))
for r in rows:
print(_row(r))
# ──────────────────────────────────────────────────────────────
# Subcommands
# ──────────────────────────────────────────────────────────────
def _clients_table(api: _API) -> str:
clients = api.get("/clients")
if not clients:
return "No clients connected."
rows = []
for c in clients:
s = c["status"]
parts = []
if s.get("fold") is not None:
parts.append(f"fold{s['fold']}")
if s.get("stage"):
parts.append(s["stage"])
if s.get("epoch") is not None:
parts.append(f"ep{s['epoch']}/{s.get('total_epochs', '?')}")
if s.get("last_val_auc") is not None:
parts.append(f"auc={s['last_val_auc']:.4f}")
prog = " ".join(parts) if parts else "-"
rows.append([
c["client_id"],
c["hostname"],
c["gpu_info"][:30],
s["state"],
s.get("run_name") or "-",
prog,
_ago(c["last_seen"]),
])
headers = ["ID", "HOST", "GPU", "STATE", "RUN", "PROGRESS", "SEEN"]
widths = [max(len(str(r[i])) for r in ([headers] + rows)) for i in range(len(headers))]
sep = " "
lines = []
lines.append(sep.join(str(h).ljust(widths[i]) for i, h in enumerate(headers)))
lines.append("-" * (sum(widths) + len(sep) * (len(widths) - 1)))
for r in rows:
lines.append(sep.join(str(r[i]).ljust(widths[i]) for i in range(len(r))))
return "\n".join(lines)
def cmd_clients(api: _API, args):
if hasattr(args, "client_id") and args.client_id:
data = api.get(f"/clients/{args.client_id}")
s = data["status"]
print(f"client_id : {data['client_id']}")
print(f"hostname : {data['hostname']}")
print(f"gpu : {data['gpu_info']}")
print(f"last_seen : {_ago(data['last_seen'])}")
print(f"state : {s['state']}")
if s.get("job_id"):
print(f"job : {s['job_id']} ({s.get('run_name', '')})")
if s.get("fold") is not None:
print(f"progress : fold {s['fold']} stage {s.get('stage', '?')} "
f"ep {s.get('epoch', '?')}/{s.get('total_epochs', '?')} "
f"val_auc={s.get('last_val_auc', '?')}")
return
watch = getattr(args, "watch", False)
interval = getattr(args, "interval", 5)
if not watch:
print(_clients_table(api))
return
try:
while True:
now = datetime.now().strftime("%H:%M:%S")
print(f"\033[H\033[2J", end="")
print(f"HyperTower clients [{now}] (Ctrl-C to exit)\n")
print(_clients_table(api))
time.sleep(interval)
except KeyboardInterrupt:
print("\nStopped.")
def cmd_jobs(api: _API, args):
params = {}
if hasattr(args, "state") and args.state:
params["state"] = args.state
jobs = api.get("/jobs", **params)
if not jobs:
print("No jobs.")
return
try:
clients = api.get("/clients")
id_to_client = {c["client_id"]: c for c in clients}
except Exception:
id_to_client = {}
rows = []
for j in jobs:
attempts = j.get("attempts", 0)
client_id = j.get("assigned_to")
client = id_to_client.get(client_id) if client_id else None
client_label = (client["hostname"] if client else client_id) if client_id else "-"
progress = "-"
if client:
s = client.get("status", {})
parts = []
if s.get("fold") is not None:
parts.append(f"fold{s['fold']}")
if s.get("stage"):
parts.append(s["stage"])
if s.get("epoch") is not None:
parts.append(f"ep{s['epoch']}/{s.get('total_epochs', '?')}")
if parts:
progress = " ".join(parts)
rows.append([
j["job_id"][:12],
j["run_name"],
j["state"],
f"{attempts}" if attempts else "-",
client_label,
progress,
_ago(j["created_at"]),
_ago(j.get("started_at")),
_ago(j.get("completed_at")),
])
_table(rows, ["JOB_ID", "RUN_NAME", "STATE", "TRIES", "CLIENT", "PROGRESS", "CREATED", "STARTED", "DONE"])
pending = sum(1 for j in jobs if j["state"] == "pending")
running = sum(1 for j in jobs if j["state"] == "running")
done = sum(1 for j in jobs if j["state"] == "done")
failed = sum(1 for j in jobs if j["state"] == "failed")
print(f"\n {len(jobs)} total | {pending} pending {running} running {done} done {failed} failed")
def cmd_submit(api: _API, args):
body = {
"run_name": args.run_name,
"module": args.module,
"args": args.run_args,
"output_dir": args.output_dir,
"priority": args.priority,
}
resp = api.post("/jobs", body)
print(f"Queued job {resp['job_id']} ({args.run_name})")
def cmd_submit_batch(api: _API, args):
with open(args.batch_file) as f:
jobs = json.load(f)
for job in jobs:
resp = api.post("/jobs", job)
print(f"Queued {resp['job_id']} ({job['run_name']})")
def cmd_cancel(api: _API, args):
resp = api.delete(f"/jobs/{args.job_id}")
print(f"Cancelled {args.job_id}" if resp.get("ok") else resp)
def cmd_clear(api: _API, args):
body: dict = {}
if args.all:
body["all"] = True
elif args.run_name:
body["run_name"] = args.run_name
else:
body["states"] = args.states or ["done", "failed", "cancelled"]
resp = api.post("/jobs/clear", body)
print(f"Cleared {resp['cleared']} jobs.")
# ──────────────────────────────────────────────────────────────
# Parser
# ──────────────────────────────────────────────────────────────
def main():
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--server", default=os.environ.get("HT_SERVER", ""),
help="Server URL (or set HT_SERVER)")
ap.add_argument("--token", default=os.environ.get("HT_TOKEN", ""),
help="Shared secret (or set HT_TOKEN)")
sub = ap.add_subparsers(dest="cmd", required=True)
# clients
p_cl = sub.add_parser("clients", help="List clients or inspect one")
p_cl.add_argument("client_id", nargs="?")
p_cl.add_argument("--watch", "-w", action="store_true",
help="Live monitoring mode — refresh every --interval seconds")
p_cl.add_argument("--interval", "-n", type=int, default=5,
help="Refresh interval in seconds for --watch (default: 5)")
# jobs
p_j = sub.add_parser("jobs", help="List jobs")
p_j.add_argument("--state", choices=["pending", "running", "done", "failed", "cancelled"])
# submit
p_s = sub.add_parser("submit", help="Submit a single job")
p_s.add_argument("--run-name", required=True)
p_s.add_argument("--module", default="v4.classes.v4_hypertower")
p_s.add_argument("--output-dir", default="v4/results")
p_s.add_argument("--priority", type=int, default=0)
p_s.add_argument("run_args", nargs=argparse.REMAINDER,
help="Args after '--' are forwarded to the module")
# submit-batch
p_b = sub.add_parser("submit-batch", help="Submit jobs from a JSON file")
p_b.add_argument("batch_file")
# cancel
p_c = sub.add_parser("cancel", help="Cancel a pending job")
p_c.add_argument("job_id")
# clear
p_cl2 = sub.add_parser("clear", help="Delete jobs by run-name, state, or everything")
p_cl2.add_argument("--run-name", default=None, help="Delete all jobs with this run-name")
p_cl2.add_argument("--states", nargs="+",
default=None,
choices=["done", "failed", "cancelled", "pending", "running"],
help="Delete jobs in these states (default: done+failed+cancelled)")
p_cl2.add_argument("--all", action="store_true", help="Delete ALL jobs")
args = ap.parse_args()
if not args.server:
ap.error("--server is required (or set HT_SERVER)")
if not args.token:
ap.error("--token is required (or set HT_TOKEN)")
if hasattr(args, "run_args") and args.run_args and args.run_args[0] == "--":
args.run_args = args.run_args[1:]
api = _API(args.server, args.token)
dispatch = {
"clients": cmd_clients,
"jobs": cmd_jobs,
"submit": cmd_submit,
"submit-batch": cmd_submit_batch,
"cancel": cmd_cancel,
"clear": cmd_clear,
}
dispatch[args.cmd](api, args)
if __name__ == "__main__":
main()
+407
View File
@@ -0,0 +1,407 @@
"""
HyperTower distributed job client daemon.
Registers with the server, polls for jobs, syncs code, runs training,
uploads results, and loops. Parses stdout to stream live status.
Usage:
python -m v4.distributed.client \\
--server http://apollo:8765 \\
--token <secret> \\
--server-ssh rpotter@apollo \\
--server-path /home/rpotter/hypertower \\
[--local-path ~/hypertower] \\
[--poll-interval 15]
Compatibility test (verify GPU env, 1-fold dry-run):
python -m v4.distributed.client ... --test --config v4/configs/ensemble_fused.json
"""
from __future__ import annotations
import argparse
import os
import re
import shutil
import signal
import socket
import subprocess
import sys
import threading
import time
from pathlib import Path
from typing import Optional
import requests
from .protocol import (
JobResult,
JobSpec,
PollResponse,
RegisterRequest,
RegisterResponse,
StatusPush,
)
# ──────────────────────────────────────────────────────────────
# Server HTTP wrapper
# ──────────────────────────────────────────────────────────────
class _Server:
def __init__(self, base_url: str, token: str):
self.base_url = base_url.rstrip("/")
self._h = {"x-token": token}
self.client_id: str = ""
def _post(self, path: str, **kw) -> dict:
r = requests.post(f"{self.base_url}{path}", headers=self._h, timeout=15, **kw)
r.raise_for_status()
return r.json()
def register(self, hostname: str, gpu_info: str) -> str:
data = self._post("/register",
json={"hostname": hostname, "gpu_info": gpu_info})
self.client_id = data["client_id"]
self.hostname = hostname
self.gpu_info = gpu_info
return self.client_id
def _reregister(self):
try:
self._post("/register",
json={"hostname": self.hostname, "gpu_info": self.gpu_info},
params={"reuse_id": self.client_id})
except Exception:
pass
def poll(self) -> Optional[JobSpec]:
data = self._post("/poll", params={"client_id": self.client_id})
if data.get("please_reregister"):
self._reregister()
return JobSpec(**data["job"]) if data.get("job") else None
def push_status(self, status: StatusPush):
try:
r = requests.post(
f"{self.base_url}/status/{self.client_id}",
json=status.model_dump(),
headers=self._h,
timeout=5,
)
if r.ok and r.json().get("please_reregister"):
self._reregister()
except Exception:
pass # don't crash job on status push failure
def complete(self, job_id: str, success: bool, error_msg: Optional[str] = None):
self._post("/complete",
json={"job_id": job_id, "success": success, "error_msg": error_msg})
# ──────────────────────────────────────────────────────────────
# GPU info
# ──────────────────────────────────────────────────────────────
def _gpu_info() -> str:
# NVIDIA
try:
out = subprocess.check_output(
["nvidia-smi", "--query-gpu=name,memory.total", "--format=csv,noheader"],
text=True, stderr=subprocess.DEVNULL,
).strip()
if out:
return " | ".join(out.splitlines())
except Exception:
pass
# AMD
try:
out = subprocess.check_output(
["rocm-smi", "--showproductname", "--csv"],
text=True, stderr=subprocess.DEVNULL,
).strip().splitlines()
names = [l for l in out if l and not l.startswith("device")]
if names:
return "AMD: " + " | ".join(names)
except Exception:
pass
# AMD fallback
try:
out = subprocess.check_output(
["rocminfo"],
text=True, stderr=subprocess.DEVNULL,
)
names = [l.split(":", 1)[1].strip() for l in out.splitlines()
if "Marketing Name:" in l]
if names:
return "AMD: " + " | ".join(names)
except Exception:
pass
return "no-gpu"
# ──────────────────────────────────────────────────────────────
# rsync helpers
# ──────────────────────────────────────────────────────────────
def _rsync(src: str, dst: str, delete: bool = False):
cmd = ["rsync", "-az", "--info=progress2"]
if delete:
cmd.append("--delete")
cmd += [src, dst]
subprocess.run(cmd, check=True)
def _sync_code(server_ssh: str, server_path: str, local_path: str):
"""Pull v4/ source from server → local (overwrites local changes)."""
src = f"{server_ssh}:{server_path}/v4/"
dst = f"{local_path}/v4/"
Path(dst).mkdir(parents=True, exist_ok=True)
_rsync(src, dst, delete=True)
def _upload_results(server_ssh: str, server_path: str, local_path: str,
run_name: str, output_dir: str):
src = f"{local_path}/{output_dir}/{run_name}/"
dst = f"{server_ssh}:{server_path}/{output_dir}/{run_name}/"
remote_parent = f"{server_path}/{output_dir}/{Path(run_name).parent}"
subprocess.run(["ssh", server_ssh, f"mkdir -p '{remote_parent}'"], check=True)
_rsync(src, dst)
def _clean_local(local_path: str, run_name: str, output_dir: str):
target = Path(local_path) / output_dir / run_name
if target.exists():
shutil.rmtree(target)
print(f"[client] cleaned {target}", flush=True)
# ──────────────────────────────────────────────────────────────
# Stdout parsers (match v4_hypertower.py / fusion.py print format)
# ──────────────────────────────────────────────────────────────
# " fold2 [nt] ep004/036 [fused_warmup ] loss=0.4321 acc=0.876 val_auc=0.7654"
_EP_RE = re.compile(
r"fold(\d+)\s+\[([^\]]+)\]\s+ep(\d+)/(\d+).*?val_auc=([0-9.nan]+)",
re.IGNORECASE,
)
# "── fold 2/5 train_groups=..."
_FOLD_RE = re.compile(r"fold\s+(\d+)/\d+", re.IGNORECASE)
def _parse_line(line: str) -> dict:
"""Return any structured fields found in a stdout line."""
out = {}
m = _FOLD_RE.search(line)
if m:
out["fold"] = int(m.group(1))
m = _EP_RE.search(line)
if m:
out["fold"] = int(m.group(1))
out["stage"] = m.group(2)
out["epoch"] = int(m.group(3))
out["total_epochs"] = int(m.group(4))
try:
out["last_val_auc"] = float(m.group(5))
except ValueError:
pass
return out
# ──────────────────────────────────────────────────────────────
# Core job runner
# ──────────────────────────────────────────────────────────────
def _run_job(job: JobSpec, server: _Server,
server_ssh: str, server_path: str, local_path: str,
no_sync: bool = False,
extra_args: list[str] | None = None) -> bool:
extra_args = extra_args or []
# 1. Sync code
if no_sync:
print(f"[client] skipping sync (--no-sync)", flush=True)
else:
print(f"[client] syncing v4/ from server...", flush=True)
server.push_status(StatusPush(state="syncing", job_id=job.job_id, run_name=job.run_name))
_sync_code(server_ssh, server_path, local_path)
# 2. Launch training subprocess
cmd = [sys.executable, "-m", job.module] + job.args + extra_args
print(f"[client] running: {' '.join(cmd)}", flush=True)
server.push_status(StatusPush(state="running", job_id=job.job_id, run_name=job.run_name))
log_dir = Path(local_path) / "v4" / "distributed" / "logs"
log_dir.mkdir(parents=True, exist_ok=True)
log_file = log_dir / f"job_{job.job_id}.log"
ctx: dict = {}
def _tail(path: Path):
with open(path, "r") as f:
while True:
raw = f.readline()
if raw:
print(raw, end="", flush=True)
info = _parse_line(raw)
ctx.update(info)
if "epoch" in info:
server.push_status(StatusPush(
state="running",
job_id=job.job_id,
run_name=job.run_name,
fold=ctx.get("fold"),
stage=ctx.get("stage"),
epoch=ctx.get("epoch"),
total_epochs=ctx.get("total_epochs"),
last_val_auc=ctx.get("last_val_auc"),
))
elif proc.poll() is not None:
for raw in f:
print(raw, end="", flush=True)
break
else:
time.sleep(0.05)
with open(log_file, "w") as logf:
proc = subprocess.Popen(
cmd,
stdout=logf,
stderr=logf,
cwd=local_path,
start_new_session=True,
)
tailer = threading.Thread(target=_tail, args=(log_file,), daemon=True)
tailer.start()
proc.wait()
tailer.join(timeout=5)
log_file.unlink(missing_ok=True)
success = proc.returncode == 0
if not success:
try:
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
except ProcessLookupError:
pass
if not success:
print(f"[client] job FAILED (rc={proc.returncode})", flush=True)
return False
# 3. Upload results
if no_sync:
print(f"[client] skipping upload (--no-sync, results already local)", flush=True)
else:
print(f"[client] uploading results...", flush=True)
server.push_status(StatusPush(state="uploading", job_id=job.job_id, run_name=job.run_name))
_upload_results(server_ssh, server_path, local_path, job.run_name, job.output_dir)
_clean_local(local_path, job.run_name, job.output_dir)
print(f"[client] job {job.job_id} complete.", flush=True)
return True
# ──────────────────────────────────────────────────────────────
# Compatibility test
# ──────────────────────────────────────────────────────────────
def _compat_test(server: _Server, server_ssh: str, server_path: str,
local_path: str, config: str, extra_args: list[str],
no_sync: bool = False):
"""Run 1 fold to verify the env works end-to-end."""
print("[client] === compatibility test ===", flush=True)
job = JobSpec(
job_id="compat-test",
run_name="_compat_test",
module="v4.classes.v4_hypertower",
args=["--config", config, "--device", "cpu"] + extra_args,
output_dir="v4/results",
)
ok = _run_job(job, server, server_ssh, server_path, local_path,
no_sync=no_sync)
_clean_local(local_path, "_compat_test", "v4/results")
if ok:
print("[client] compatibility test PASSED ✓", flush=True)
else:
print("[client] compatibility test FAILED ✗", flush=True)
return ok
# ──────────────────────────────────────────────────────────────
# Main daemon
# ──────────────────────────────────────────────────────────────
def main():
ap = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter,
)
ap.add_argument("--server", required=True,
help="Server URL, e.g. http://apollo:8765")
ap.add_argument("--token", default=os.environ.get("HT_TOKEN", ""),
help="Shared secret (or set HT_TOKEN env var)")
ap.add_argument("--server-ssh", required=True,
help="SSH target for rsync, e.g. rpotter@apollo")
ap.add_argument("--server-path", required=True,
help="Absolute path to hypertower root on server")
ap.add_argument("--local-path",
default=str(Path.home() / "hypertower"),
help="Absolute path to hypertower root on this machine")
ap.add_argument("--poll-interval", type=int, default=15,
help="Seconds to wait between polls when idle")
ap.add_argument("--extra-args", nargs=argparse.REMAINDER, default=[],
help="Extra args appended to every job on this client. "
"Use -- to separate: --extra-args -- --device cpu")
ap.add_argument("--no-sync", action="store_true",
help="Skip rsync of v4/ before each job (use when client IS the server)")
ap.add_argument("--test", action="store_true",
help="Run 1-fold compatibility test and exit")
ap.add_argument("--config", default="v4/configs/ensemble_fused.json",
help="Config path for --test mode")
args = ap.parse_args()
if not args.token:
ap.error("--token is required (or set HT_TOKEN)")
hostname = socket.gethostname()
gpu_info = _gpu_info()
server = _Server(args.server, args.token)
client_id = server.register(hostname, gpu_info)
print(f"[client] registered as {client_id} ({hostname} | {gpu_info})", flush=True)
if args.test:
extra = [a for a in args.extra_args if a != "--"]
sys.exit(0 if _compat_test(
server, args.server_ssh, args.server_path,
args.local_path, args.config, extra,
no_sync=args.no_sync,
) else 1)
print(f"[client] polling every {args.poll_interval}s...", flush=True)
while True:
try:
job = server.poll()
if job is None:
server.push_status(StatusPush(state="idle"))
time.sleep(args.poll_interval)
continue
success = _run_job(
job, server,
args.server_ssh, args.server_path, args.local_path,
no_sync=args.no_sync,
extra_args=[a for a in args.extra_args if a != "--"],
)
server.complete(job.job_id, success,
error_msg=None if success else "non-zero exit code")
server.push_status(StatusPush(state="idle"))
except KeyboardInterrupt:
print("\n[client] shutting down", flush=True)
break
except Exception as exc:
print(f"[client] error: {exc}", flush=True)
time.sleep(args.poll_interval)
if __name__ == "__main__":
main()
+17
View File
@@ -0,0 +1,17 @@
[Unit]
Description=HyperTower distributed job server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/home/rpotter/hypertower
Environment=HT_TOKEN=hypertower
ExecStart=/home/rpotter/miniconda3/envs/fundus_imaging/bin/python -m v4.distributed.server --host 0.0.0.0 --port 8765
Restart=on-failure
RestartSec=5
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=default.target
Binary file not shown.
+61
View File
@@ -0,0 +1,61 @@
"""Shared data models for server/client communication."""
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel
class RegisterRequest(BaseModel):
hostname: str
gpu_info: str
class RegisterResponse(BaseModel):
client_id: str
class StatusPush(BaseModel):
state: str # idle | syncing | running | uploading | error
job_id: Optional[str] = None
run_name: Optional[str] = None
fold: Optional[int] = None
stage: Optional[str] = None
epoch: Optional[int] = None
total_epochs: Optional[int] = None
last_val_auc: Optional[float] = None
error: Optional[str] = None
class ClientInfo(BaseModel):
client_id: str
hostname: str
gpu_info: str
status: StatusPush
last_seen: str
class JobSpec(BaseModel):
job_id: str
run_name: str
module: str # e.g. "v4.classes.v4_hypertower"
args: list[str]
output_dir: str = "v4/results"
class PollResponse(BaseModel):
job: Optional[JobSpec] = None
please_reregister: bool = False
class JobResult(BaseModel):
job_id: str
success: bool
error_msg: Optional[str] = None
class JobSubmit(BaseModel):
run_name: str
module: str = "v4.classes.v4_hypertower"
args: list[str]
output_dir: str = "v4/results"
priority: int = 0
+476
View File
@@ -0,0 +1,476 @@
"""
HyperTower distributed job server.
Manages a SQLite job queue and a registry of connected clients.
Clients poll for work, push status updates, and report completion.
Usage:
python -m v4.distributed.server --port 8765 --token <secret>
Environment:
HT_TOKEN — fallback if --token is not passed
"""
from __future__ import annotations
import argparse
import json
import os
import sqlite3
import threading
import time
import uuid
from contextlib import contextmanager
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from fastapi import Depends, FastAPI, Header, HTTPException
import uvicorn
from .protocol import (
ClientInfo,
JobResult,
JobSpec,
JobSubmit,
PollResponse,
RegisterRequest,
RegisterResponse,
StatusPush,
)
# ──────────────────────────────────────────────────────────────
# Global state
# ──────────────────────────────────────────────────────────────
_TOKEN: str = ""
_DB_PATH: Path = Path("v4/distributed/jobs.db")
_REPO_ROOT: Path = Path.cwd()
_CLIENT_TTL: int = 120 # seconds before a client is considered gone
_MAX_ATTEMPTS: int = 3 # max times a job is retried before being left as failed
_clients: dict[str, ClientInfo] = {}
_clients_lock = threading.Lock()
def _reap_stale_clients():
"""Background thread: remove silent clients and re-queue their running jobs."""
while True:
time.sleep(30)
cutoff = datetime.now(timezone.utc).timestamp() - _CLIENT_TTL
with _clients_lock:
stale = [
cid
for cid, c in _clients.items()
if datetime.fromisoformat(c.last_seen).timestamp() < cutoff
]
for cid in stale:
print(
f"[server] reaped stale client {cid} ({_clients[cid].hostname})",
flush=True,
)
del _clients[cid]
known_ids = set(_clients.keys())
with _db() as conn:
rows = conn.execute(
"SELECT job_id, assigned_to FROM jobs WHERE state='running'"
).fetchall()
for row in rows:
if row["assigned_to"] not in known_ids:
conn.execute(
"UPDATE jobs SET state='pending', assigned_to=NULL, started_at=NULL "
"WHERE job_id=?",
(row["job_id"],),
)
print(
f"[server] re-queued job {row['job_id']} "
f"(client {row['assigned_to']} unknown)",
flush=True,
)
# ──────────────────────────────────────────────────────────────
# Database helpers
# ──────────────────────────────────────────────────────────────
@contextmanager
def _db():
conn = sqlite3.connect(str(_DB_PATH))
conn.row_factory = sqlite3.Row
try:
yield conn
conn.commit()
finally:
conn.close()
def _init_db():
_DB_PATH.parent.mkdir(parents=True, exist_ok=True)
with _db() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS jobs (
job_id TEXT PRIMARY KEY,
run_name TEXT NOT NULL,
module TEXT NOT NULL,
args TEXT NOT NULL, -- JSON list
output_dir TEXT NOT NULL DEFAULT 'v4/results',
state TEXT NOT NULL DEFAULT 'pending',
priority INTEGER NOT NULL DEFAULT 0,
assigned_to TEXT,
created_at TEXT NOT NULL,
started_at TEXT,
completed_at TEXT,
error_msg TEXT,
attempts INTEGER NOT NULL DEFAULT 0
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_jobs_state ON jobs(state)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_jobs_priority ON jobs(priority)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_jobs_run_name ON jobs(run_name)")
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_jobs_args ON jobs(args)")
try:
conn.execute(
"ALTER TABLE jobs ADD COLUMN attempts INTEGER NOT NULL DEFAULT 0"
)
except Exception:
pass # column already exists
def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _ensure_client(client_id: str, hostname: str = "", gpu_info: str = "") -> bool:
"""Re-register a client that survived a server restart.
Returns True if the client was unknown (placeholder created)."""
if client_id not in _clients:
_clients[client_id] = ClientInfo(
client_id=client_id,
hostname=hostname or client_id,
gpu_info=gpu_info or "unknown",
status=StatusPush(state="idle"),
last_seen=_now(),
)
print(f"[server] re-registered {client_id} (survived restart)", flush=True)
return True
return False
# ──────────────────────────────────────────────────────────────
# FastAPI app
# ──────────────────────────────────────────────────────────────
app = FastAPI(title="HyperTower Job Server")
def _check_token(x_token: str = Header(...)):
if x_token != _TOKEN:
raise HTTPException(status_code=403, detail="Invalid token")
# ── Registration ──────────────────────────────────────────────
@app.post(
"/register", response_model=RegisterResponse, dependencies=[Depends(_check_token)]
)
def register(req: RegisterRequest, reuse_id: Optional[str] = None):
with _clients_lock:
client_id = (
reuse_id if (reuse_id and reuse_id in _clients) else str(uuid.uuid4())[:8]
)
existing_status = (
_clients[client_id].status
if client_id in _clients
else StatusPush(state="idle")
)
_clients[client_id] = ClientInfo(
client_id=client_id,
hostname=req.hostname,
gpu_info=req.gpu_info,
status=existing_status,
last_seen=_now(),
)
action = "re-registered" if reuse_id else "registered"
print(
f"[server] {action} {client_id} ({req.hostname} | {req.gpu_info})", flush=True
)
return RegisterResponse(client_id=client_id)
# ── Job polling ───────────────────────────────────────────────
@app.post("/poll", response_model=PollResponse, dependencies=[Depends(_check_token)])
def poll(client_id: str):
with _clients_lock:
needs_reregister = _ensure_client(client_id)
_clients[client_id].last_seen = _now()
with _db() as conn:
row = conn.execute(
"SELECT * FROM jobs WHERE state='pending' "
"ORDER BY priority DESC, created_at ASC LIMIT 1"
).fetchone()
if row is None:
return PollResponse(job=None, please_reregister=needs_reregister)
job_id = row["job_id"]
cur = conn.execute(
"UPDATE jobs SET state='pending', assigned_to=NULL, started_at=NULL "
"WHERE assigned_to=? AND state='running' AND job_id!=?",
(client_id, job_id),
)
if cur.rowcount:
print(
f"[server] reset {cur.rowcount} orphaned running job(s) for {client_id}",
flush=True,
)
conn.execute(
"UPDATE jobs SET state='running', assigned_to=?, started_at=? WHERE job_id=?",
(client_id, _now(), job_id),
)
job = JobSpec(
job_id=job_id,
run_name=row["run_name"],
module=row["module"],
args=json.loads(row["args"]),
output_dir=row["output_dir"],
)
with _clients_lock:
_clients[client_id].status = StatusPush(
state="syncing", job_id=job_id, run_name=row["run_name"]
)
print(f"[server] dispatched {job_id} ({row['run_name']}) → {client_id}", flush=True)
return PollResponse(job=job, please_reregister=needs_reregister)
# ── Status ────────────────────────────────────────────────────
@app.post("/status/{client_id}", dependencies=[Depends(_check_token)])
def push_status(client_id: str, status: StatusPush):
with _clients_lock:
needs_reregister = _ensure_client(client_id)
_clients[client_id].status = status
_clients[client_id].last_seen = _now()
return {"ok": True, "please_reregister": needs_reregister}
@app.get("/clients", dependencies=[Depends(_check_token)])
def list_clients():
with _clients_lock:
return list(_clients.values())
@app.get("/clients/{client_id}", dependencies=[Depends(_check_token)])
def get_client(client_id: str):
with _clients_lock:
if client_id not in _clients:
raise HTTPException(status_code=404, detail="Unknown client")
return _clients[client_id]
# ── Job completion ────────────────────────────────────────────
@app.post("/complete", dependencies=[Depends(_check_token)])
def complete(result: JobResult):
with _db() as conn:
if result.success:
conn.execute(
"UPDATE jobs SET state='done', completed_at=?, error_msg=NULL WHERE job_id=?",
(_now(), result.job_id),
)
print(f"[server] job {result.job_id} → done", flush=True)
run_row = conn.execute(
"SELECT run_name FROM jobs WHERE job_id=?", (result.job_id,)
).fetchone()
if run_row:
run_name = run_row["run_name"]
remaining = conn.execute(
"SELECT COUNT(*) FROM jobs WHERE run_name=? AND state != 'done'",
(run_name,),
).fetchone()[0]
if remaining == 0:
total = conn.execute(
"SELECT COUNT(*) FROM jobs WHERE run_name=?", (run_name,)
).fetchone()[0]
conn.execute("DELETE FROM jobs WHERE run_name=?", (run_name,))
print(
f"[server] run '{run_name}' complete ({total} jobs) — cleared",
flush=True,
)
else:
row = conn.execute(
"SELECT attempts FROM jobs WHERE job_id=?", (result.job_id,)
).fetchone()
attempts = (row["attempts"] if row else 0) + 1
if attempts < _MAX_ATTEMPTS:
conn.execute(
"UPDATE jobs SET state='pending', assigned_to=NULL, started_at=NULL, "
"attempts=?, error_msg=? WHERE job_id=?",
(attempts, result.error_msg, result.job_id),
)
print(
f"[server] job {result.job_id} failed (attempt {attempts}/{_MAX_ATTEMPTS}), "
f"re-queuing",
flush=True,
)
else:
conn.execute(
"UPDATE jobs SET state='failed', completed_at=?, attempts=?, error_msg=? "
"WHERE job_id=?",
(_now(), attempts, result.error_msg, result.job_id),
)
print(
f"[server] job {result.job_id} failed permanently after "
f"{attempts} attempts",
flush=True,
)
return {"ok": True}
# ── Job queue management ──────────────────────────────────────
@app.post("/jobs", dependencies=[Depends(_check_token)])
def submit_job(job: JobSubmit):
result_dir = _REPO_ROOT / job.output_dir / job.run_name
if result_dir.exists() and any(result_dir.rglob("summary.json")):
print(f"[server] skipped {job.run_name} (results exist on disk)", flush=True)
return {"job_id": "", "duplicate": False, "skipped": True}
args_json = json.dumps(job.args)
job_id = str(uuid.uuid4())[:12]
with _db() as conn:
cur = conn.execute(
"INSERT OR IGNORE INTO jobs "
"(job_id, run_name, module, args, output_dir, priority, created_at) "
"VALUES (?,?,?,?,?,?,?)",
(job_id, job.run_name, job.module, args_json,
job.output_dir, job.priority, _now()),
)
if cur.rowcount == 0:
existing = conn.execute(
"SELECT job_id FROM jobs WHERE args=?", (args_json,)
).fetchone()
job_id = existing["job_id"]
print(f"[server] duplicate ignored ({job.run_name}) → {job_id}", flush=True)
return {"job_id": job_id, "duplicate": True}
print(f"[server] queued {job_id} ({job.run_name})", flush=True)
return {"job_id": job_id, "duplicate": False}
@app.get("/jobs", dependencies=[Depends(_check_token)])
def list_jobs(state: Optional[str] = None):
with _db() as conn:
if state:
rows = conn.execute(
"SELECT * FROM jobs WHERE state=? ORDER BY created_at DESC", (state,)
).fetchall()
else:
rows = conn.execute(
"SELECT * FROM jobs ORDER BY created_at DESC"
).fetchall()
return [dict(r) for r in rows]
@app.post("/jobs/clear", dependencies=[Depends(_check_token)])
def clear_jobs(body: dict):
with _db() as conn:
if body.get("all"):
cur = conn.execute("DELETE FROM jobs")
elif body.get("run_name"):
cur = conn.execute("DELETE FROM jobs WHERE run_name=?", (body["run_name"],))
else:
states = body.get("states", ["done", "failed", "cancelled"])
placeholders = ",".join("?" * len(states))
cur = conn.execute(
f"DELETE FROM jobs WHERE state IN ({placeholders})", states
)
print(f"[server] cleared {cur.rowcount} jobs", flush=True)
return {"cleared": cur.rowcount}
@app.delete("/jobs/{job_id}", dependencies=[Depends(_check_token)])
def cancel_job(job_id: str):
with _db() as conn:
conn.execute(
"UPDATE jobs SET state='cancelled' WHERE job_id=? AND state='pending'",
(job_id,),
)
return {"ok": True}
# ──────────────────────────────────────────────────────────────
# Entry point
# ──────────────────────────────────────────────────────────────
def main():
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
ap.add_argument("--port", type=int, default=8765)
ap.add_argument("--host", default="0.0.0.0")
ap.add_argument(
"--token",
default=os.environ.get("HT_TOKEN", ""),
help="Shared secret (or set HT_TOKEN env var)",
)
ap.add_argument(
"--db", default="v4/distributed/jobs.db", help="Path to SQLite job database"
)
ap.add_argument(
"--client-ttl",
type=int,
default=120,
help="Seconds of silence before a client is reaped (default: 120)",
)
ap.add_argument(
"--max-attempts",
type=int,
default=3,
help="Max times a failed job is retried before being left as failed (default: 3)",
)
ap.add_argument(
"--root",
default="",
help="Repo root for results-existence checks (default: cwd)",
)
args = ap.parse_args()
if not args.token:
ap.error("--token is required (or set HT_TOKEN)")
global _TOKEN, _DB_PATH, _REPO_ROOT, _CLIENT_TTL, _MAX_ATTEMPTS
_TOKEN = args.token
_DB_PATH = Path(args.db)
_REPO_ROOT = Path(args.root).resolve() if args.root else Path.cwd()
_CLIENT_TTL = args.client_ttl
_MAX_ATTEMPTS = args.max_attempts
_init_db()
reaper = threading.Thread(target=_reap_stale_clients, daemon=True)
reaper.start()
print(
f"[server] listening on {args.host}:{args.port} client_ttl={_CLIENT_TTL}s",
flush=True,
)
uvicorn.run(app, host=args.host, port=args.port, log_level="warning")
if __name__ == "__main__":
main()
+147
View File
@@ -0,0 +1,147 @@
# Distributed Server Cheat Sheet
All commands assume server is running on hades at port 8765.
## Start Server
```bash
python -m v4.distributed.server --token hypertower
```
Run inside tmux so it survives disconnects:
```bash
tmux new -s htserver
python -m v4.distributed.server --token hypertower
# Ctrl-B D to detach
tmux attach -t htserver # reattach later
```
## Local Workflow (hades as server + client)
```bash
# Terminal 1 — server
python -m v4.distributed.server --token hypertower
# Terminal 2 — client (skip rsync, results already local)
python -m v4.distributed.client \
--server http://hades:8765 --token hypertower \
--server-ssh ignored --server-path ignored \
--local-path /home/rpotter/hypertower \
--no-sync
# Terminal 3 — dispatch (exits after queuing; client picks up jobs)
python -m v4.distributed.batch_dispatch \
--server http://hades:8765 --token hypertower \
--config v4/configs/ensemble_fused.json \
--batch v4/scripts/experiments/my_batch.json
```
---
## Start Clients
**Hades (server-local, no sync):**
```bash
python -m v4.distributed.client \
--server http://hades:8765 --token hypertower \
--server-ssh ignored --server-path ignored \
--local-path /home/rpotter/hypertower \
--no-sync
```
**Apollo (remote client):**
```bash
python -m v4.distributed.client \
--server http://hades:8765 --token hypertower \
--server-ssh rpotter@hades \
--server-path /home/rpotter/hypertower \
--local-path /home/odin/hypertower
```
---
## Monitoring
**Live client monitor (refreshes every 5s):**
```bash
python -m v4.distributed.cli --server http://hades:8765 --token hypertower clients --watch
```
**Faster refresh:**
```bash
python -m v4.distributed.cli --server http://hades:8765 --token hypertower clients --watch --interval 2
```
**Inspect a single client:**
```bash
python -m v4.distributed.cli --server http://hades:8765 --token hypertower clients <client_id>
```
**View job queue:**
```bash
python -m v4.distributed.cli --server http://hades:8765 --token hypertower jobs
```
**Filter by state:**
```bash
python -m v4.distributed.cli --server http://hades:8765 --token hypertower jobs --state pending
python -m v4.distributed.cli --server http://hades:8765 --token hypertower jobs --state running
python -m v4.distributed.cli --server http://hades:8765 --token hypertower jobs --state failed
```
---
## Submitting Jobs
**Batch dispatch (dry run first):**
```bash
python -m v4.distributed.batch_dispatch \
--server http://hades:8765 --token hypertower \
--config v4/configs/ensemble_fused.json \
--batch v4/scripts/experiments/fusion_dim_sweep.json \
--dry-run
```
**Live submit:**
```bash
python -m v4.distributed.batch_dispatch \
--server http://hades:8765 --token hypertower \
--config v4/configs/ensemble_fused.json \
--batch v4/scripts/experiments/fusion_dim_sweep.json
```
**Fewer reps (e.g. quick test):**
```bash
python -m v4.distributed.batch_dispatch \
--server http://hades:8765 --token hypertower \
--config v4/configs/ensemble_fused.json \
--batch v4/scripts/experiments/fusion_dim_sweep.json \
--reps 3
```
---
## Queue Management
**Clear failed jobs:**
```bash
python -m v4.distributed.cli --server http://hades:8765 --token hypertower clear --states failed
```
**Clear running jobs (orphan cleanup):**
```bash
python -m v4.distributed.cli --server http://hades:8765 --token hypertower clear --states running
```
**Clear all jobs:**
```bash
python -m v4.distributed.cli --server http://hades:8765 --token hypertower clear --all
```
**Clear by run name:**
```bash
python -m v4.distributed.cli --server http://hades:8765 --token hypertower clear --run-name experiments/fusion_dim_sweep/dim256
```
**Cancel a specific job:**
```bash
python -m v4.distributed.cli --server http://hades:8765 --token hypertower cancel <job_id>
```