v0.1.8: two-Spark pipeline + kept-warm serving

- Secondary-Spark models now work in air-gapped mode: the LiteLLM proxy is
  dual-homed onto the default bridge (docker network connect) to reach the
  secondary's published vLLM port; grader containers stay on the --internal
  network with zero egress. The head-only enforcement is replaced by a
  secondary-configured check.
- Extraction runs in parallel with grading when the extractor's model and
  every grader model in the wave sit on different Sparks (separate GPUs).
- Keep-warm: single-wave jobs no longer tear the wave down between decks
  (was a ~6-min 31B reload per deck); a kept-warm wave that fails preflight
  is restarted once. Adjudicator reuses the live wave when its model is
  already serving instead of cycling the shared proxy.
- clear_resident_containers (preJobStopContainers) now stops names on every
  configured Spark; health() reports containers on both Sparks.
- Verified with a mocked dry-run of the full job loop (3 decks: one
  bring-up, zero mid-job teardowns, parallel overlap, stale-wave restart).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jonathan Kirkwood
2026-07-31 15:01:10 -05:00
co-authored by Claude Fable 5
parent 7ec5222834
commit 506e6c79bd
7 changed files with 158 additions and 46 deletions
+6 -2
View File
@@ -120,7 +120,7 @@ Canonical repo: `https://gitea.ten31.ai/Ten31AI/boardroom-map`.
## Status ## Status
**v0.1.7 — live in production.** Deployed on a StartOS box driving a DGX Spark **v0.1.8 — live in production.** Deployed on a StartOS box driving a DGX Spark
in single-spark air-gapped mode (gemma-4-31B panel: munger-lens / in single-spark air-gapped mode (gemma-4-31B panel: munger-lens /
girdley-operator / buffett-owner). First full grading run completed girdley-operator / buffett-owner). First full grading run completed
2026-07-29: a three-deck company history graded end-to-end into a running 2026-07-29: a three-deck company history graded end-to-end into a running
@@ -138,7 +138,11 @@ dashboard, an at-a-glance strip per deck report, and a concise "At a glance"
summary table atop every generated DECK_REPORT.md; v0.1.7 added company summary table atop every generated DECK_REPORT.md; v0.1.7 added company
deletion from the dashboard (wipes the graded history, optionally restores deletion from the dashboard (wipes the graded history, optionally restores
the graded deck files to the inbox) so an evaluation can be re-run from the graded deck files to the inbox) so an evaluation can be re-run from
scratch with a different model panel. scratch with a different model panel; v0.1.8 added the two-Spark pipeline —
secondary-Spark models work in air-gapped mode (dual-homed proxy; graders
keep zero egress), extraction and grading run in parallel when their models
sit on different Sparks, and single-wave jobs keep the vLLMs warm across
decks instead of reloading the 31B (~6 min) per deck.
Known optimization not yet done: the wave is torn down per deck, so the 31B Known optimization not yet done: the wave is torn down per deck, so the 31B
reloads from disk (~6 min) between decks even when the model set is unchanged. reloads from disk (~6 min) between decks even when the model set is unchanged.
+78 -9
View File
@@ -94,6 +94,15 @@ def _token(s: str) -> str:
return t or "deck" return t or "deck"
def _sparks_disjoint(cfg: dict, extractor_alias: str, wpanel: list[dict]) -> bool:
"""True when the extractor's model and every grader model in the wave sit on
different Sparks (separate GPUs) — extraction can then overlap grading."""
catalog = {m["alias"]: m for m in (cfg.get("models") or [])}
ex_spark = (catalog.get(extractor_alias) or {}).get("spark") or "primary"
return all(((catalog.get(r["model"]) or {}).get("spark") or "primary") != ex_spark
for r in wpanel)
def _composite(record) -> float | None: def _composite(record) -> float | None:
"""Best-effort composite lookup on the scoring record (shape owned by scoring.py).""" """Best-effort composite lookup on the scoring record (shape owned by scoring.py)."""
if not isinstance(record, dict): if not isinstance(record, dict):
@@ -125,6 +134,7 @@ class JobRunner:
self.period = None self.period = None
self.decks: list[dict] = [] self.decks: list[dict] = []
self.last_report_path = None self.last_report_path = None
self._live_aliases: set[str] | None = None # kept-warm wave's model aliases
self._thread = None self._thread = None
self._last_sig = None self._last_sig = None
self._last_done_sig = None self._last_done_sig = None
@@ -250,6 +260,7 @@ class JobRunner:
self.period = None self.period = None
self.decks = [] self.decks = []
self.panel = [] self.panel = []
self._live_aliases = None
remote_root = f"{cfg['remoteWorkDir'].rstrip('/')}/jobs/{job_id}" remote_root = f"{cfg['remoteWorkDir'].rstrip('/')}/jobs/{job_id}"
self.log(f"=== Grading job {job_id} begins ===") self.log(f"=== Grading job {job_id} begins ===")
@@ -294,15 +305,16 @@ class JobRunner:
adjudicate, adj_model = False, "" adjudicate, adj_model = False, ""
needed_all = needed | ({adj_model} if adjudicate and adj_model else set()) needed_all = needed | ({adj_model} if adjudicate and adj_model else set())
# Air-gapped mode can't route to second-Spark models (internal net). # Second-Spark models need the secondary configured. (In air-gapped
if cfg.get("networkMode") == "airgapped": # mode the proxy is dual-homed onto the bridge to reach them — the
# grader containers themselves stay on the zero-egress internal net.)
cat = {m["alias"]: m for m in models} cat = {m["alias"]: m for m in models}
offenders = [a for a in needed_all if cat.get(a, {}).get("spark") == "secondary"] on_secondary = [a for a in needed_all if cat.get(a, {}).get("spark") == "secondary"]
if offenders: if on_secondary and not (cfg.get("useBothSparks") and cfg.get("secondarySparkHost")):
raise RuntimeError( raise RuntimeError(
"air-gapped mode requires all models on the head Spark, but these are " f"model(s) {', '.join(sorted(on_secondary))} are assigned to the "
f"on the secondary: {', '.join(sorted(offenders))}. Move them to the " "secondary Spark, but no secondary Spark is configured — "
"primary Spark or switch to local-services mode.") "run Configure Sparks (enable both Sparks) or move them to primary.")
# 3. Infra once per job. # 3. Infra once per job.
serving.clear_resident_containers(cfg, self.log) serving.clear_resident_containers(cfg, self.log)
@@ -350,6 +362,7 @@ class JobRunner:
sc.run(sc.head(cfg), f"rm -rf {remote_root}", timeout=120) sc.run(sc.head(cfg), f"rm -rf {remote_root}", timeout=120)
self.log("[runner] wiped deck text from the Spark") self.log("[runner] wiped deck text from the Spark")
serving.tear_down_all(cfg, self.log) serving.tear_down_all(cfg, self.log)
self._live_aliases = None
# 7. Move the graded originals aside (the company folders stay). # 7. Move the graded originals aside (the company folders stay).
self._drain_inbox(job_id, units) self._drain_inbox(job_id, units)
@@ -376,6 +389,7 @@ class JobRunner:
serving.tear_down_all(cfg, self.log) serving.tear_down_all(cfg, self.log)
except Exception: except Exception:
pass pass
self._live_aliases = None
self._persist() self._persist()
# ------------------------------------------------------------- one deck # ------------------------------------------------------------- one deck
@@ -416,19 +430,63 @@ class JobRunner:
self.log(f"[runner] wave {i}/{len(waves)}: models={sorted(aliases)} " self.log(f"[runner] wave {i}/{len(waves)}: models={sorted(aliases)} "
f"graders={[r['name'] for r in wpanel]}" f"graders={[r['name'] for r in wpanel]}"
f"{' +extractor' if extractor_model in aliases else ''}") f"{' +extractor' if extractor_model in aliases else ''}")
# Single-wave jobs keep the wave serving across decks — reloading a
# 31B from disk between decks costs minutes for the same model set.
reusable = len(waves) == 1
reused = reusable and self._live_aliases == aliases
if reused:
self.log("[runner] wave already serving from the previous deck — reusing")
else:
serving.bring_up_wave(cfg, wave, hf, self.log) serving.bring_up_wave(cfg, wave, hf, self.log)
self._await_serving(cfg, wave)
try: try:
try:
preflight.check_wave(cfg, wave, self.log)
except Exception as e:
if not reused:
raise
# The kept-warm wave went stale (e.g. a vLLM crashed between
# decks) — restart it once and re-check.
self.log(f"[runner] kept-warm wave failed preflight ({e}); restarting it")
self._live_aliases = None
serving.tear_down_wave(cfg, wave, self.log)
serving.bring_up_wave(cfg, wave, hf, self.log)
self._await_serving(cfg, wave) self._await_serving(cfg, wave)
preflight.check_wave(cfg, wave, self.log) preflight.check_wave(cfg, wave, self.log)
if extractor_model in aliases: extract_here = extractor_model in aliases
er = gr_mod.run_extractor(cfg, remote_deck, extractor_model, self.log) if (extract_here and wpanel
and _sparks_disjoint(cfg, extractor_model, wpanel)):
# Extractor and graders sit on different Sparks (separate
# GPUs) — run them concurrently.
self.log("[runner] extractor and graders are on different Sparks — "
"running them in parallel")
holder: dict = {}
t = threading.Thread(
target=lambda: holder.update(
gr_mod.run_extractor(cfg, remote_deck, extractor_model,
self.log)),
daemon=True)
t.start()
res = gr_mod.run_wave_graders(cfg, remote_deck, wpanel, self.log)
self._mark_panel(res)
t.join()
if not holder.get("report"):
raise RuntimeError("extractor produced no extraction.json")
else:
if extract_here:
er = gr_mod.run_extractor(cfg, remote_deck, extractor_model,
self.log)
if not er.get("report"): if not er.get("report"):
raise RuntimeError("extractor produced no extraction.json") raise RuntimeError("extractor produced no extraction.json")
if wpanel: if wpanel:
res = gr_mod.run_wave_graders(cfg, remote_deck, wpanel, self.log) res = gr_mod.run_wave_graders(cfg, remote_deck, wpanel, self.log)
self._mark_panel(res) self._mark_panel(res)
finally: finally:
if reusable:
self._live_aliases = aliases # leave it serving for the next deck
else:
serving.tear_down_wave(cfg, wave, self.log) serving.tear_down_wave(cfg, wave, self.log)
self._live_aliases = None
# --- pull the panel outputs + validate -------------------------------- # --- pull the panel outputs + validate --------------------------------
local_out = os.path.join(local_deck, "out") local_out = os.path.join(local_deck, "out")
@@ -466,13 +524,24 @@ class JobRunner:
try: try:
adj_model = adj_mod.pick_model(cfg) adj_model = adj_mod.pick_model(cfg)
for wave in serving.plan_waves(cfg, {adj_model}): for wave in serving.plan_waves(cfg, {adj_model}):
if self._live_aliases and \
{m["alias"] for m in wave} <= self._live_aliases:
# The adjudicator's model is already serving on the
# kept-warm wave — use it; cycling the containers here
# would tear down the proxy the next deck reuses.
self.log("[runner] adjudicator model already serving — reusing wave")
adj_mod.run_adjudication(cfg, remote_deck, self.log)
continue
serving.bring_up_wave(cfg, wave, hf, self.log) serving.bring_up_wave(cfg, wave, hf, self.log)
try: try:
self._await_serving(cfg, wave) self._await_serving(cfg, wave)
preflight.check_wave(cfg, wave, self.log) preflight.check_wave(cfg, wave, self.log)
adj_mod.run_adjudication(cfg, remote_deck, self.log) adj_mod.run_adjudication(cfg, remote_deck, self.log)
finally: finally:
# Cycling a different model set invalidates the kept-warm
# wave (this replaces/removes the shared proxy).
serving.tear_down_wave(cfg, wave, self.log) serving.tear_down_wave(cfg, wave, self.log)
self._live_aliases = None
local_adj = os.path.join(local_deck, "adjudicator-out") local_adj = os.path.join(local_deck, "adjudicator-out")
sc.pull_dir(head, f"{remote_deck}/adjudicator-out", local_adj) sc.pull_dir(head, f"{remote_deck}/adjudicator-out", local_adj)
apath = os.path.join(local_adj, "ADJUDICATION.md") apath = os.path.join(local_adj, "ADJUDICATION.md")
+26 -11
View File
@@ -15,9 +15,11 @@ Network topology (the confidentiality boundary):
* Head-Spark vLLMs join this network; the proxy reaches them by container name * Head-Spark vLLMs join this network; the proxy reaches them by container name
(bm-vllm-<alias>). Reviewers reach the proxy by name (boardroom-proxy). (bm-vllm-<alias>). Reviewers reach the proxy by name (boardroom-proxy).
* Second-Spark vLLMs publish a host port; the proxy reaches them over the LAN. * Second-Spark vLLMs publish a host port; the proxy reaches them over the LAN.
This only works in local_services mode (an --internal network can't route to In airgapped mode the per-job network is --internal (no LAN route), so the
the LAN), so airgapped jobs must keep all models on the head Spark — enforced proxy gets a second leg on the default bridge (docker network connect) —
in preflight. the proxy can then reach the secondary Spark while the grader containers
stay internal-only with zero egress. Panel traffic to a secondary-Spark
model crosses the LAN between the two Sparks in plaintext HTTP.
Models are served from a pre-populated HF cache mounted from the Spark work dir, Models are served from a pre-populated HF cache mounted from the Spark work dir,
so airgapped serving needs no live download. so airgapped serving needs no live download.
@@ -67,18 +69,19 @@ def remove_network(cfg: dict, log) -> None:
# ---------------------------------------------------------- resident models # ---------------------------------------------------------- resident models
def clear_resident_containers(cfg: dict, log) -> None: def clear_resident_containers(cfg: dict, log) -> None:
"""Stop the user-listed containers on the head Spark so a grading job gets """Stop the user-listed containers on EVERY configured Spark so a grading
the GPU to itself (both Sparks normally run an always-on 31B vLLM). job gets the GPUs to itself (both Sparks normally run an always-on 31B
vLLM). Names that don't exist on a given Spark are silently skipped.
Deliberately NOT restarted after the job: whatever owns them is responsible Deliberately NOT restarted after the job: whatever owns them is responsible
for bringing them back (the Gazette's Fleet job reloads its own models).""" for bringing them back (the Gazette's Fleet job reloads its own models)."""
names = (cfg.get("preJobStopContainers") or "").replace(",", " ").split() names = (cfg.get("preJobStopContainers") or "").replace(",", " ").split()
if not names: if not names:
return return
head = sc.head(cfg)
quoted = " ".join(shlex.quote(n) for n in names) quoted = " ".join(shlex.quote(n) for n in names)
log(f"[serving] freeing the GPU on {head.host}: docker stop {' '.join(names)}") for sp in sc.sparks(cfg):
sc.run(head, f"docker stop {quoted} 2>/dev/null; true", timeout=180) log(f"[serving] freeing the GPU on {sp.host}: docker stop {' '.join(names)}")
sc.run(sp, f"docker stop {quoted} 2>/dev/null; true", timeout=180)
# ------------------------------------------------------------------ vLLM # ------------------------------------------------------------------ vLLM
@@ -173,6 +176,15 @@ def bring_up_wave(cfg: dict, wave: list[dict], hf_token: str | None, log) -> Non
if r.returncode != 0: if r.returncode != 0:
raise RuntimeError(f"LiteLLM router launch failed: {r.stderr or r.stdout}") raise RuntimeError(f"LiteLLM router launch failed: {r.stderr or r.stdout}")
if cfg.get("networkMode") == "airgapped" and \
any(m.get("spark") == "secondary" for m in wave):
# The --internal per-job network can't route to the LAN. Give the proxy
# a second leg on the default bridge so it can reach the secondary
# Spark's published vLLM port. The grader containers stay internal-only.
r = sc.run(head, f"docker network connect bridge {PROXY_NAME} 2>/dev/null; true",
timeout=30)
log("[serving] proxy dual-homed onto the bridge (secondary-Spark routing)")
def tear_down_wave(cfg: dict, wave: list[dict], log) -> None: def tear_down_wave(cfg: dict, wave: list[dict], log) -> None:
names = " ".join(_vllm_name(m["alias"]) for m in wave) names = " ".join(_vllm_name(m["alias"]) for m in wave)
@@ -226,7 +238,10 @@ def reviewer_proxy_base(cfg: dict) -> str:
def health(cfg: dict) -> dict: def health(cfg: dict) -> dict:
head = sc.head(cfg) """Serving containers across ALL configured Sparks (vLLMs may sit on either)."""
r = sc.run(head, "docker ps --filter name=bm-vllm- --filter name=boardroom-proxy " running: list[str] = []
for sp in sc.sparks(cfg):
r = sc.run(sp, "docker ps --filter name=bm-vllm- --filter name=boardroom-proxy "
"--format '{{.Names}} {{.Status}}'", timeout=30) "--format '{{.Names}} {{.Status}}'", timeout=30)
return {"running": (r.stdout or "").strip().splitlines()} running += (r.stdout or "").strip().splitlines()
return {"running": running}
+5 -4
View File
@@ -29,10 +29,11 @@ const inputSpec = InputSpec.of({
name: 'Network Mode', name: 'Network Mode',
description: description:
'Air-gapped: graders reach ONLY the on-Spark model proxy — zero internet, ' + 'Air-gapped: graders reach ONLY the on-Spark model proxy — zero internet, ' +
'board decks never leave your hardware (models must be pre-pulled into the ' + 'board decks never leave your hardware (models must be pre-pulled into ' +
'Spark HF cache, all on the head Spark). Local services: graders may also ' + 'each Spark HF cache; models on either Spark are fine — the proxy routes ' +
'reach LAN services like SearXNG and the second Spark (this network has ' + 'to the second Spark over your LAN). Local services: graders may also ' +
'egress unless you firewall it).', 'reach LAN services like SearXNG (this network has egress unless you ' +
'firewall it).',
default: 'airgapped', default: 'airgapped',
values: { values: {
airgapped: 'Air-gapped (no network, recommended)', airgapped: 'Air-gapped (no network, recommended)',
+12 -8
View File
@@ -61,8 +61,10 @@ export const configShape = z.object({
z.object({ z.object({
alias: z.string(), alias: z.string(),
hfModel: z.string(), hfModel: z.string(),
// Which Spark serves this model. In `airgapped` network mode all models // Which Spark serves this model. Secondary-Spark models work in BOTH
// must be on the head Spark (see networkMode). // network modes (in airgapped mode the proxy is dual-homed onto the
// bridge to reach them; graders stay on the internal network). Panel
// traffic to a secondary model crosses the LAN between the Sparks.
spark: z.enum(['primary', 'secondary']).default('primary'), spark: z.enum(['primary', 'secondary']).default('primary'),
port: z.number().int().positive().default(8001), port: z.number().int().positive().default(8001),
}), }),
@@ -144,9 +146,10 @@ export const configShape = z.object({
// Confidentiality posture for the grader containers: // Confidentiality posture for the grader containers:
// 'airgapped' — graders join an --internal Docker network: they can // 'airgapped' — graders join an --internal Docker network: they can
// reach ONLY the on-Spark model proxy, with zero internet // reach ONLY the on-Spark model proxy, with zero internet
// egress. Models must be pre-pulled into the Spark's HF // egress. Models must be pre-pulled into each Spark's HF
// cache (no live download). All models must be on the head // cache (no live download). Secondary-Spark models are
// Spark. Strongest confidentiality. // reached by the dual-homed proxy over the Spark-to-Spark
// LAN. Strongest confidentiality.
// 'local_services' — graders may also reach configured LAN services // 'local_services' — graders may also reach configured LAN services
// (e.g. SearXNG) and the second Spark. NOTE: this network // (e.g. SearXNG) and the second Spark. NOTE: this network
// has egress unless you firewall it — use only when you // has egress unless you firewall it — use only when you
@@ -172,9 +175,10 @@ export const configShape = z.object({
autoRunOnDrop: z.boolean().default(false), autoRunOnDrop: z.boolean().default(false),
// Name of the per-job Docker network created on the head Spark. // Name of the per-job Docker network created on the head Spark.
networkName: z.string().default('boardroom-net'), networkName: z.string().default('boardroom-net'),
// Space/comma-separated docker container names stopped on the head Spark at // Space/comma-separated docker container names stopped on EVERY configured
// the start of every job to free GPU memory (e.g. an always-on vLLM another // Spark at the start of every job to free GPU memory (e.g. an always-on vLLM
// service runs). NOT restarted afterwards — their owner reloads them. // another service runs); names absent on a Spark are skipped. NOT restarted
// afterwards — their owner reloads them.
preJobStopContainers: z.string().default(''), preJobStopContainers: z.string().default(''),
// --- Portfolio companies --- // --- Portfolio companies ---
+3 -2
View File
@@ -7,9 +7,10 @@ import { v_0_1_4 } from './v_0_1_4'
import { v_0_1_5 } from './v_0_1_5' import { v_0_1_5 } from './v_0_1_5'
import { v_0_1_6 } from './v_0_1_6' import { v_0_1_6 } from './v_0_1_6'
import { v_0_1_7 } from './v_0_1_7' import { v_0_1_7 } from './v_0_1_7'
import { v_0_1_8 } from './v_0_1_8'
/** The current version MUST be the first argument (`current`). */ /** The current version MUST be the first argument (`current`). */
export const versions = VersionGraph.of({ export const versions = VersionGraph.of({
current: v_0_1_7, current: v_0_1_8,
other: [v_0_1_6, v_0_1_5, v_0_1_4, v_0_1_3, v_0_1_2, v_0_1_1, v_0_1_0], other: [v_0_1_7, v_0_1_6, v_0_1_5, v_0_1_4, v_0_1_3, v_0_1_2, v_0_1_1, v_0_1_0],
}) })
+18
View File
@@ -0,0 +1,18 @@
import { VersionInfo } from '@start9labs/start-sdk'
/** Two-Spark pipeline + kept-warm serving. ExVer form `<upstream>:<downstream>`. */
export const v_0_1_8 = VersionInfo.of({
version: '0.1.8:0',
releaseNotes:
'Two-Spark grading pipeline: models pinned to the secondary Spark now work ' +
'in air-gapped mode too (the model proxy is dual-homed onto the bridge to ' +
'reach them; grader containers keep zero egress), so the extractor can run ' +
'on one Spark while the grading panel runs on the other — and when they sit ' +
'on different Sparks, extraction and grading of a deck run in parallel. ' +
'Serving is also kept warm across decks: single-wave jobs no longer tear ' +
'the vLLMs down between decks (previously a ~6-minute 31B reload per deck), ' +
'with an automatic restart if a kept-warm model fails preflight. ' +
'preJobStopContainers now stops resident containers on every configured ' +
'Spark, and Serving/Job health shows containers on both Sparks.',
migrations: {},
})