diff --git a/.gitignore b/.gitignore index e5390b4..534a291 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ javascript/ **/__pycache__/ *.pyc .claude/ +.startos/ +start-technologies/ diff --git a/orchestrator/bm_config.py b/orchestrator/bm_config.py index 330f110..1de44d4 100644 --- a/orchestrator/bm_config.py +++ b/orchestrator/bm_config.py @@ -69,6 +69,10 @@ CONFIG_DEFAULTS = { "wipeRemoteDocs": True, "autoRunOnDrop": False, "networkName": "boardroom-net", + # Space/comma-separated docker container names stopped on the head Spark at + # the start of every job to free GPU memory (e.g. an always-on vLLM another + # service runs). NOT restarted afterwards — their owner reloads them. + "preJobStopContainers": "", # Portfolio companies (authoritative source of pinned targets / aliases). # pinnedTargets: [{kpi, target, unit, direction: gte|lte, profitability}] # kpiAliases: newline-separated "canonical=alias1;alias2" lines. diff --git a/orchestrator/jobs.py b/orchestrator/jobs.py index c17f652..a4a4e8c 100644 --- a/orchestrator/jobs.py +++ b/orchestrator/jobs.py @@ -305,6 +305,7 @@ class JobRunner: "primary Spark or switch to local-services mode.") # 3. Infra once per job. + serving.clear_resident_containers(cfg, self.log) gr_mod.ensure_grader_image(cfg, self.log) serving.ensure_network(cfg, self.log) preflight.check_searxng(cfg, self.log) diff --git a/orchestrator/preflight.py b/orchestrator/preflight.py index 53e91ac..3fff957 100644 --- a/orchestrator/preflight.py +++ b/orchestrator/preflight.py @@ -9,6 +9,7 @@ container attached to the same network. from __future__ import annotations import shlex +import time import spark_client as sc import serving @@ -27,40 +28,94 @@ def _probe_in_net(cfg: dict, inner_cmd: str, timeout: int) -> sc.subprocess.Comp return sc.run(sc.head(cfg), cmd, timeout=timeout) -def check_wave(cfg: dict, wave: list[dict], log) -> None: - """The proxy answers AND each model alias in the wave returns a completion.""" +def _container_running(cfg: dict, name: str) -> bool: + """True only for a stably running container — a crash-looping one under + `--restart unless-stopped` reports Status "running"/"restarting" between + crashes, so treat repeated restarts as dead.""" + cmd = ("docker inspect -f '{{.State.Status}} {{.RestartCount}}' " + + shlex.quote(name) + " 2>/dev/null") + r = sc.run(sc.head(cfg), cmd, timeout=30) + parts = (r.stdout or "").strip().split() + if len(parts) != 2: + return False + status, restarts = parts[0], parts[1] + if status != "running": + return False + try: + return int(restarts) < 3 + except ValueError: + return False + + +def _container_logs(cfg: dict, name: str, tail: int = 30) -> str: + cmd = f"docker logs --tail {tail} " + shlex.quote(name) + " 2>&1" + r = sc.run(sc.head(cfg), cmd, timeout=30) + return (r.stdout or "").strip() + + +def check_wave(cfg: dict, wave: list[dict], log, timeout: int = 900) -> None: + """The proxy answers AND each model alias in the wave returns a completion. + + Both are POLLED up to `timeout` seconds: `docker run -d` returns long before + LiteLLM has booted (~10-30s) or vLLM has loaded a 31B model into GPU memory + (minutes). A crashed container fails fast with its log tail instead of + burning the whole timeout. The proxy requires its master key even on + /models, so every probe authenticates.""" base = serving.reviewer_proxy_base(cfg).rstrip("/") # http://boardroom-proxy:PORT/v1 + deadline = time.time() + timeout - # 1. Proxy reachable at all. - reach = f"curl -sf -m 8 {shlex.quote(base + '/models')} -o /dev/null && echo OK || echo FAIL" - r = _probe_in_net(cfg, reach, timeout=40) - if "OK" not in (r.stdout or ""): - raise RuntimeError( - f"model proxy not answering at {base} from inside the network. " - f"Check that the router container ({serving.PROXY_NAME}) came up.") - log("[preflight] model proxy answering") + # 1. Proxy reachable (LiteLLM boot). + reach = ( + f"curl -sf -m 8 -H 'authorization: Bearer sk-local' " + f"{shlex.quote(base + '/models')} -o /dev/null && echo OK || echo FAIL" + ) + while True: + r = _probe_in_net(cfg, reach, timeout=40) + if "OK" in (r.stdout or ""): + log("[preflight] model proxy answering") + break + if not _container_running(cfg, serving.PROXY_NAME): + raise RuntimeError( + f"router container {serving.PROXY_NAME} is not running. Last log lines:\n" + + _container_logs(cfg, serving.PROXY_NAME)) + if time.time() > deadline: + raise RuntimeError( + f"model proxy did not answer at {base} within {timeout}s " + f"(container is up — check its logs on the Spark).") + log("[preflight] proxy still booting…") + time.sleep(10) - # 2. Each alias must actually return a completion. - dead = [] + # 2. Each alias must actually return a completion (this is what waits out + # the multi-minute vLLM model load). for m in wave: alias = m["alias"] + vllm_name = f"bm-vllm-{alias}" payload = '{"model":"%s","messages":[{"role":"user","content":"ping"}],"max_tokens":1}' % alias probe = ( f"curl -sf -m 60 -X POST {shlex.quote(base + '/chat/completions')} " f"-H 'content-type: application/json' -H 'authorization: Bearer sk-local' " f"-d {shlex.quote(payload)} 2>/dev/null | head -c 600" ) - rr = _probe_in_net(cfg, probe, timeout=90) - out = rr.stdout or "" - if '"choices"' not in out and '"content"' not in out: - dead.append(alias) - else: - log(f"[preflight] model {alias} responded") - if dead: - raise RuntimeError( - "These models are not responding through the proxy: " + ", ".join(dead) + - ". Check the corresponding vLLM container(s) on the Spark — they may have " - "failed to load, OOM'd, or (in air-gapped mode) the model isn't in the HF cache.") + attempt = 0 + while True: + rr = _probe_in_net(cfg, probe, timeout=90) + out = rr.stdout or "" + if '"choices"' in out or '"content"' in out: + log(f"[preflight] model {alias} responded") + break + if not _container_running(cfg, vllm_name): + raise RuntimeError( + f"vLLM container {vllm_name} is not running — it likely crashed " + f"during startup or model load. Last log lines:\n" + + _container_logs(cfg, vllm_name)) + if time.time() > deadline: + raise RuntimeError( + f"model {alias} did not answer through the proxy within {timeout}s. " + f"Last probe output: {out[:200] or '(empty)'}") + attempt += 1 + if attempt % 4 == 1: + log(f"[preflight] {alias} still loading…") + time.sleep(15) def check_searxng(cfg: dict, log) -> None: diff --git a/orchestrator/serving.py b/orchestrator/serving.py index 75134c2..1411092 100644 --- a/orchestrator/serving.py +++ b/orchestrator/serving.py @@ -65,6 +65,22 @@ def remove_network(cfg: dict, log) -> None: sc.run(head, f"docker network rm {shlex.quote(net_name(cfg))} 2>/dev/null; true", timeout=30) +# ---------------------------------------------------------- resident models +def clear_resident_containers(cfg: dict, log) -> None: + """Stop the user-listed containers on the head Spark so a grading job gets + the GPU to itself (both Sparks normally run an always-on 31B vLLM). + + Deliberately NOT restarted after the job: whatever owns them is responsible + for bringing them back (the Gazette's Fleet job reloads its own models).""" + names = (cfg.get("preJobStopContainers") or "").replace(",", " ").split() + if not names: + return + head = sc.head(cfg) + quoted = " ".join(shlex.quote(n) for n in names) + log(f"[serving] freeing the GPU on {head.host}: docker stop {' '.join(names)}") + sc.run(head, f"docker stop {quoted} 2>/dev/null; true", timeout=180) + + # ------------------------------------------------------------------ vLLM def _vllm_run(cfg: dict, model: dict, hf_token: str | None) -> tuple[sc.Spark, str]: """Build the docker run command for one model on its assigned Spark.""" @@ -75,6 +91,10 @@ def _vllm_run(cfg: dict, model: dict, hf_token: str | None) -> tuple[sc.Spark, s parser = cfg.get("toolCallParser", "hermes") tools = (f"--enable-auto-tool-choice --tool-call-parser {shlex.quote(parser)} " if parser else "") env = f"-e HF_TOKEN={shlex.quote(hf_token)} " if hf_token else "" + if cfg.get("networkMode") == "airgapped": + # The --internal network has no DNS/egress; without offline mode the HF + # hub client dies on name resolution even with the model fully cached. + env += "-e HF_HUB_OFFLINE=1 -e TRANSFORMERS_OFFLINE=1 -e VLLM_NO_USAGE_STATS=1 -e DO_NOT_TRACK=1 " cache = _hf_cache(cfg) name = _vllm_name(alias) diff --git a/orchestrator/templates/index.html b/orchestrator/templates/index.html index 3eb7d41..38b8d11 100644 --- a/orchestrator/templates/index.html +++ b/orchestrator/templates/index.html @@ -34,6 +34,9 @@ padding:8px 12px;font-size:13px;cursor:pointer} button:hover{background:#2c3650} button.primary{background:#3a2f12;border-color:#6b551f;color:#ffdf9a} button.mini{padding:2px 8px;font-size:11px;border-radius:7px} + a.mini{display:inline-block;background:#222a3a;border:1px solid var(--edge);border-radius:7px; + padding:2px 8px;font-size:11px;color:var(--ink);text-decoration:none;cursor:pointer} + a.mini:hover{background:#2c3650} select,input[type=text]{background:#0c0f16;color:var(--ink);border:1px solid var(--edge); border-radius:9px;padding:7px 10px;font-size:13px} .drop{border:1.5px dashed var(--edge);border-radius:12px;padding:18px;text-align:center;color:var(--dim);cursor:pointer;margin-top:10px} @@ -303,15 +306,21 @@ function renderPortfolio(){ async function openCompany(slug,silent){ try{ const d=await getJSON('/api/companies/'+encodeURIComponent(slug)); + const changed = currentSlug!==slug; currentSlug=slug; - renderDetail(d); + renderDetail(d,changed); document.getElementById('companyCard').style.display=''; if(!silent) document.getElementById('companyCard').scrollIntoView({behavior:'smooth'}); }catch(e){ if(!silent) alert('load company failed: '+(e.message||e)); } } -function closeCompany(){ currentSlug=null; document.getElementById('companyCard').style.display='none'; } +function closeCompany(){ + currentSlug=null; scorecardOpen=false; + document.getElementById('coViewer').innerHTML=''; + document.getElementById('scBtn').textContent='View SCORECARD.md'; + document.getElementById('companyCard').style.display='none'; +} -function renderDetail(d){ +function renderDetail(d,changed){ const co=d.company||{}, recs=d.records||[]; document.getElementById('coName').textContent=co.name||co.slug||'?'; document.getElementById('coBadges').innerHTML=co.auto_created?'unregistered':''; @@ -345,30 +354,41 @@ function renderDetail(d){ `
${esc(r.ok?t:('error: '+t))}`);
+ setViewer('Deck report — '+deckId, `${esc(r.ok?t:('error: '+t))}`,
+ base+'/report', `${currentSlug}-${deckId}-report.md`);
}catch(e){ alert(e); }
}
async function viewDeckJson(deckId){
if(!currentSlug) return;
try{
- const d=await getJSON('/api/companies/'+encodeURIComponent(currentSlug)+
- '/decks/'+encodeURIComponent(deckId));
+ const d=await getJSON(deckBase(deckId));
setViewer('Deck record — '+deckId,
`${esc(JSON.stringify(d,null,2))}${esc(JSON.stringify(d,null,2))}`,
+ deckBase(deckId), `${currentSlug}-${deckId}.json`);
}catch(e){ alert('load record failed: '+(e.message||e)); }
}
let scorecardOpen=false;
@@ -377,18 +397,23 @@ async function toggleScorecard(){
const v=document.getElementById('coViewer'), btn=document.getElementById('scBtn');
if(scorecardOpen){ v.innerHTML=''; scorecardOpen=false; btn.textContent='View SCORECARD.md'; return; }
try{
- const r=await fetch('/api/companies/'+encodeURIComponent(currentSlug)+'/scorecard');
+ const url='/api/companies/'+encodeURIComponent(currentSlug)+'/scorecard';
+ const r=await fetch(url);
const t=await r.text();
- setViewer('SCORECARD.md', `${esc(r.ok?t:('error: '+t))}`);
+ setViewer('SCORECARD.md', `${esc(r.ok?t:('error: '+t))}`,
+ url, `${currentSlug}-SCORECARD.md`);
scorecardOpen=true; btn.textContent='Hide SCORECARD.md';
}catch(e){ alert(e); }
}
-function setViewer(title,html){
+function setViewer(title,html,dlHref,dlName){
scorecardOpen=false;
document.getElementById('scBtn').textContent='View SCORECARD.md';
+ const dl=dlHref?`⬇ download`:'';
document.getElementById('coViewer').innerHTML=
- `