* c3+pull: detect in-progress downloads + per-repo lock (#617) Repeated ① Bring [D] presses spawned N concurrent `hf download` runs racing into the same `.incomplete` staging dir (5-deep live), each rmtree-ing and re-fetching the others' partial. Two disk-truth gaps behind that: 1. No dedup. `download_model` now acquires an atomic per-repo lock (`mkdir <pull_dir>/.download.lock` + a `pid` file = holder PID + UTC start) BEFORE the staging rmtree. A 2nd concurrent call for the same slug is REFUSED with `failure="in-progress"` (not a race); swap_apply surfaces it as `in_progress`, and `pull.sh --apply-swap` exits **rc=3** ("already downloading", distinct from rc=1 failure). A STALE lock (dead holder — a crashed/SIGKILL'd download that skipped `finally`) is reclaimed on the next call, so a leaked lock self-heals — more robust than a signal trap (a SIGKILL skips traps too). Released in `finally` on every return path. 2. No in-progress detection. Presence probes only saw the FINAL dir (empty until hf moves files out of `.incomplete`), so a mid-flight download read as absent → the false "download did not complete". `services.bring_download_in_ progress` now reads the lock (live PID) + `.incomplete` bytes for a best-effort pct; c3's fit-check render + [D] no-op guard + [k] cancel are disk-aware, so a running download is REFLECTED (and not duplicated) even across a c3 restart or a bare `pull.sh` started outside the session — the in-memory tracker (#643) couldn't see those. download_model is the shared chokepoint (bring apply-swap AND catalog fetch), so both paths get the lock; the body moved verbatim to `_download_model_impl` behind the locking wrapper — no behaviour change beyond the added guard. Tests: scripts/tests/test-download-lock.sh (live-refuse + stale-reclaim + release); c3 test_services (in-progress live/stale/absent + pct) + test_app_headless (disk-detected → downloading render, [D] no-op, [k] kill). 245 services/registry + 131 app blast-radius tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfF565T9eSLaqGzidyJ1Pm * downloader: refuse a fresh pidless lock (close acquire-window race) The lock acquires with mkdir then writes its pid file a beat later. A 2nd caller landing in that window saw the lock dir but no pid → read_active returned None → the wrapper treated it as stale and rmtree'd + reclaimed it, letting BOTH callers proceed — the exact duplicate-download race the lock exists to prevent. Now: a pid-present-but-dead lock still reclaims (unchanged), but a PIDLESS lock only reclaims once it's older than a 10s grace — a fresh pidless lock is a holder mid-acquire and is refused, not stolen. Guard adds PASS 4 (fresh pidless → refuse) + PASS 5 (old pidless → reclaim). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EfF565T9eSLaqGzidyJ1Pm --------- Co-authored-by: noonghunna <10742901+noonghunna@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
318 lines
14 KiB
Bash
Executable File
318 lines
14 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
#
|
||
# pull.sh — v0.8.0 Pull-Gate orchestrator (PR #147, STEP P4).
|
||
#
|
||
# Derives an HF repo via [A], gates it through the LOCKED 6-stratum abort
|
||
# taxonomy (deriver-errors → --profile-like → [C0] → [C2a] → eligibility →
|
||
# [B]→[C1] → Path-A [D] dry-run), and on a curated, [D]-emittable, gate-
|
||
# passing Path A run hands the validated registry key to the existing #141
|
||
# generator for real emission. Path B (--dry-run / any non-curated slug)
|
||
# prints a §7-caveated verdict and NEVER calls [D] / downloads.
|
||
#
|
||
# Honest by construction (design §1): every non-eligible / non-pass outcome
|
||
# hard-stops with a precise structured reason; only `exact × fits-clean`
|
||
# reaches `proceed` silently (§4.1). `--force-download` is a no-op + notice
|
||
# this phase (download/telemetry deferred to the Loop phase).
|
||
#
|
||
# Usage:
|
||
# scripts/pull.sh <hf-slug> --profile-like <COMPOSE_REGISTRY-key> [opts]
|
||
#
|
||
# # Path A — curated pull-and-emit:
|
||
# scripts/pull.sh Lorbus/Qwen3.6-27B-int4-AutoRound \
|
||
# --profile-like vllm/minimal --out /tmp/qwen.yml
|
||
#
|
||
# # Path B — universal evaluate (never emits/downloads):
|
||
# scripts/pull.sh some-org/Some-Llama-7B --profile-like vllm/minimal --dry-run
|
||
#
|
||
# # Failure on-ramp — submit a captured failed pull (a SEPARATE, consented
|
||
# # verb: the ONLY step that touches the network, and only after an
|
||
# # explicit y; reuses the shipped dedup; needs no slug/--profile-like):
|
||
# scripts/pull.sh --submit-last # the most-recent capture
|
||
# scripts/pull.sh --submit <capture-dir> # an explicit bundle dir
|
||
#
|
||
# Opts: --yes --force-download --experimental-arch --trust-remote-code
|
||
# --hf-home DIR --out FILE (Path A) --hardware SM (override nvidia-smi)
|
||
#
|
||
# All decision logic lives in scripts/lib/profiles/pull.py (this is a thin
|
||
# argv pass-through, matching the generate-compose.sh / diagnose-profile.sh
|
||
# pattern). Exit: 0 = download-eligible / clean verdict; 3 = needs a flag
|
||
# (confirm→proceed / advisory); 2 = honest hard-stop; 64 = usage.
|
||
#
|
||
# --profile-like ... --dry-run --json (structured swap_path) — TUI contract
|
||
# -------------------------------------------------------------------------
|
||
# ADDITIVE: a machine-readable form of the `--profile-like ... --dry-run`
|
||
# gate verdict. Emits a single JSON object:
|
||
#
|
||
# {"fit_verdict": <raw_verdict|terminal|abort_reason>,
|
||
# "arch": <config architectures[0] | null>,
|
||
# "eligible": <bool — the gate said download-eligible / clean verdict>,
|
||
# "note": <the human detail line, verbatim>,
|
||
# "swap_path": {"route": "B"|"C"|null,
|
||
# "sibling_slug": <curated model slug | null>,
|
||
# "quant_match": <quant the user must match | null>,
|
||
# "drop_spec_config": <bool>}}
|
||
#
|
||
# CRITICAL (per the contract): the BRING_YOUR_OWN swap path that the human
|
||
# gate bakes into the NOTE message *string* is surfaced here as STRUCTURED
|
||
# `swap_path` FIELDS — derived from the SAME `arch_model_xref` registry the
|
||
# gate's own `_hint` consults (read-only reuse, never a sentence parse), so
|
||
# the TUI reads fields instead of parsing prose.
|
||
#
|
||
# STRICTLY ADDITIVE: `--json` is intercepted HERE in the wrapper and stripped
|
||
# before the gate would ever see it; WITHOUT `--json` this script's behaviour
|
||
# (and pull.py's argv) is byte-for-byte unchanged. `--json` forces the
|
||
# evaluate-only Path-B gate (it never emits/downloads), matching the
|
||
# `--dry-run` contract surface.
|
||
set -euo pipefail
|
||
|
||
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
|
||
|
||
# Intercept --json (strictly additive). Absent -> the original exec path,
|
||
# byte-identical. Present -> strip it and hand the remaining argv to the
|
||
# structured-emit helper (which forces the evaluate-only gate).
|
||
_pull_json=0
|
||
_pull_apply_swap=0
|
||
_pull_emit_only=0
|
||
_pull_args=()
|
||
for _a in "$@"; do
|
||
if [ "$_a" = "--json" ]; then
|
||
_pull_json=1
|
||
elif [ "$_a" = "--apply-swap" ]; then
|
||
_pull_apply_swap=1
|
||
elif [ "$_a" = "--emit-only" ]; then
|
||
_pull_emit_only=1
|
||
else
|
||
_pull_args+=("$_a")
|
||
fi
|
||
done
|
||
|
||
# --apply-swap: the BYO Route-C weight-swap ACTION (strictly additive, like
|
||
# --json). It NEVER runs the locked 6-stratum gate — a curated-arch fine-tune
|
||
# hard-stops there at stratum-5 `no-fit-model`, which is correct (nothing to
|
||
# price). Instead it downloads the brought weights SHA-verified and emits a
|
||
# serve-locally compose that CLONES the --profile-like sibling with --model
|
||
# re-pointed at those weights. The c3 [D] press on a Route-C fit-check is the
|
||
# explicit opt-in; without --apply-swap this script's behaviour is unchanged.
|
||
#
|
||
# --emit-only (with --apply-swap): skip the SHA-verified download and JUST emit
|
||
# the serve-locally compose (do_download=False). For when the weights are
|
||
# already on disk — c3's ② Serve uses this so a present-weights serve needs no
|
||
# [D] download step (the mount points at the existing pull dir).
|
||
if [ "$_pull_apply_swap" -eq 1 ]; then
|
||
export _PULL_EMIT_ONLY="$_pull_emit_only"
|
||
exec python3 - "${ROOT_DIR}" "${_pull_args[@]+"${_pull_args[@]}"}" <<'PY'
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import os
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
root = Path(sys.argv[1])
|
||
if str(root) not in sys.path:
|
||
sys.path.insert(0, str(root))
|
||
|
||
from scripts.lib.profiles import swap_apply as SA # the apply-swap action
|
||
|
||
ap = argparse.ArgumentParser(prog="pull.sh --apply-swap", add_help=False)
|
||
ap.add_argument("slug")
|
||
ap.add_argument("--profile-like", required=True, dest="profile_like")
|
||
ap.add_argument("--hf-home", default=None)
|
||
# tolerate (ignore) the flags a caller may carry over from the fit-check line
|
||
for _f in ("--dry-run", "--yes", "--force-download", "--experimental-arch",
|
||
"--trust-remote-code", "--recommend"):
|
||
ap.add_argument(_f, action="store_true")
|
||
ap.add_argument("--hardware", default=None)
|
||
ap.add_argument("--hardware-gpus", default=None)
|
||
ap.add_argument("--out", default=None)
|
||
args, _unknown = ap.parse_known_args(sys.argv[2:])
|
||
|
||
_emit_only = os.environ.get("_PULL_EMIT_ONLY") == "1"
|
||
print(f"[apply-swap] resolving Route-C swap for {args.slug} "
|
||
f"(profile-like={args.profile_like})"
|
||
+ (" [emit-only — weights on disk, no download]" if _emit_only else ""),
|
||
flush=True)
|
||
res = SA.apply_swap(root, args.slug, args.profile_like, hf_home=args.hf_home,
|
||
do_download=not _emit_only)
|
||
if not res.get("ok"):
|
||
# rc=3 = "already downloading" (distinct from rc=1 failure): a concurrent
|
||
# download of this slug holds the pull-dir lock; do NOT start a duplicate
|
||
# (club-3090 #617). Callers (c3) treat rc=3 as "reflect the live download".
|
||
if res.get("in_progress"):
|
||
print(f"[apply-swap] already in progress ({res.get('detail','')}) "
|
||
"— not starting a duplicate", flush=True)
|
||
sys.exit(3)
|
||
print(f"[apply-swap] ERROR: {res.get('error')}", file=sys.stderr, flush=True)
|
||
if res.get("detail"):
|
||
print(f"[apply-swap] detail: {res['detail']}", file=sys.stderr, flush=True)
|
||
sys.exit(1)
|
||
|
||
mtp = "MTP kept" if res.get("has_mtp_head") else "MTP dropped (no head)"
|
||
print(f"[apply-swap] sibling={res['sibling_slug']} served-as={res['served_model_name']}"
|
||
f" ({mtp})", flush=True)
|
||
print(f"[apply-swap] weights: {res['weights_dir']}", flush=True)
|
||
# The final, machine-parseable line the c3 lane greps for → serve this compose.
|
||
print(f"[apply-swap] compose: {res['compose_path']}", flush=True)
|
||
print("[apply-swap] ok", flush=True)
|
||
PY
|
||
fi
|
||
|
||
if [ "$_pull_json" -eq 0 ]; then
|
||
exec python3 "${ROOT_DIR}/scripts/lib/profiles/pull.py" "$@"
|
||
fi
|
||
|
||
# --json path: run the SHIPPED gate (run_pull, evaluate-only) and serialize
|
||
# its verdict + a structurally-derived swap_path. pull.py / deriver /
|
||
# generate_compose are imported READ-ONLY (no edit) — this reuses their
|
||
# logic, never reimplements it.
|
||
exec python3 - "${ROOT_DIR}" "${_pull_args[@]+"${_pull_args[@]}"}" <<'PY'
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
root = Path(sys.argv[1])
|
||
if str(root) not in sys.path:
|
||
sys.path.insert(0, str(root))
|
||
|
||
from scripts.lib.profiles import pull as P # P4 gate — READ-ONLY
|
||
from scripts.lib.profiles import deriver as D # P2 — READ-ONLY
|
||
from scripts.lib import generate_compose as gc # [D] — READ-ONLY
|
||
|
||
# Mirror the gate parser's REQUIRED surface (slug + --profile-like) plus the
|
||
# evaluate-only flags that are meaningful for the JSON verdict. argparse
|
||
# error() defaults to exit 2; the JSON contract wants a clean usage code, so
|
||
# fall back to 64 (matching pull.py's _UsageExit64Parser intent).
|
||
class _Ap(argparse.ArgumentParser):
|
||
def error(self, message):
|
||
self.exit(64, f"pull.sh --json: error: {message}\n")
|
||
|
||
|
||
ap = _Ap(prog="pull.sh --json")
|
||
ap.add_argument("slug")
|
||
ap.add_argument("--profile-like", required=True, dest="profile_like")
|
||
ap.add_argument("--dry-run", action="store_true") # implied; accepted
|
||
ap.add_argument("--yes", action="store_true")
|
||
ap.add_argument("--force-download", action="store_true")
|
||
ap.add_argument("--experimental-arch", action="store_true")
|
||
ap.add_argument("--trust-remote-code", action="store_true")
|
||
ap.add_argument("--hf-home")
|
||
ap.add_argument("--hardware", type=float, default=None)
|
||
ap.add_argument("--hardware-gpus", default=None)
|
||
# Tolerate (and ignore for the verdict) Path-A-only / presentation flags so a
|
||
# caller can append --json to an existing command line without a usage error.
|
||
ap.add_argument("--out")
|
||
ap.add_argument("--recommend", action="store_true")
|
||
args = ap.parse_args(sys.argv[2:])
|
||
|
||
gpu_topology = None
|
||
if args.hardware_gpus:
|
||
vram: list[int] = []
|
||
names: list[str] = []
|
||
for tok in args.hardware_gpus.split(","):
|
||
tok = tok.strip()
|
||
if not tok:
|
||
continue
|
||
v, n = (tok.split(":", 1) if ":" in tok else (tok, "GPU"))
|
||
vram.append(int(float(v)))
|
||
names.append(n.strip() or "GPU")
|
||
if vram:
|
||
gpu_topology = (len(vram), vram, names)
|
||
|
||
|
||
def _swap_path(slug, der, eligible):
|
||
"""Surface the BRING_YOUR_OWN swap path as STRUCTURED fields, derived
|
||
from the SAME `arch_model_xref` registry the gate's `_hint` consults
|
||
(read-only) — never by parsing the human NOTE sentence.
|
||
|
||
route "C": the uncurated arch maps to a curated hybrid/MoE model we
|
||
serve — reuse that model's compose (point --model at the
|
||
weights). sibling_slug/quant_match populated.
|
||
route "B": no curated sibling — the self-contained GGUF fallback
|
||
(copy the closest llama.cpp/ik compose).
|
||
route null: not a swap situation (curated hit, or the verdict is not a
|
||
no-fit-model eligibility stop).
|
||
"""
|
||
blank = {"route": None, "sibling_slug": None,
|
||
"quant_match": None, "drop_spec_config": False}
|
||
if der is None or der.error is not None:
|
||
return blank
|
||
# A curated Tier-1 hit needs no swap (its own compose serves it).
|
||
if der.tier1 is not None:
|
||
return blank
|
||
# Only the no-fit-model eligibility stop carries a swap path.
|
||
if eligible or der.generic_dense_eligible:
|
||
return blank
|
||
arch = (der.profile or {}).get("arch")
|
||
try:
|
||
rt = gc._load_yaml(root, "scripts/lib/profiles/profile_runtime.yml")
|
||
canon, row = gc.resolve_arch_from_config(rt, gc.load_arches(root), arch)
|
||
except Exception:
|
||
canon, row = None, None
|
||
sibling = None
|
||
family = None
|
||
if row is not None:
|
||
family = row.get("family")
|
||
slugs = (((rt.get("arch_model_xref") or {}).get(canon) or {})
|
||
.get("model_slugs") or [])
|
||
if slugs:
|
||
sibling = slugs[0]
|
||
# Route C iff the arch resolves to a curated hybrid/MoE sibling we serve
|
||
# (the families pull.py prices via the curated path, NOT generic-dense).
|
||
if sibling is not None and family in P._FAMILY_WEIGHT_FIELDS:
|
||
quant_match = None
|
||
try:
|
||
from scripts.lib.profiles.compat import load_profiles
|
||
model = load_profiles().models.get(sibling)
|
||
if model is not None and getattr(model, "weights", None):
|
||
# Prefer the quant FORMAT the curated compose loads (what the
|
||
# user must match on their own repo); fall back to the slug.
|
||
first_slug = next(iter(model.weights))
|
||
meta = model.weights[first_slug] or {}
|
||
quant_match = meta.get("format") or first_slug
|
||
except Exception:
|
||
quant_match = None
|
||
# Keep --speculative-config IFF the brought checkpoint actually carries
|
||
# an MTP head (deriver.detect_mtp_head: config declares MTP layers + a
|
||
# dedicated mtp weights file). The old blanket drop served head-
|
||
# preserving fine-tunes (e.g. ThinkingCap AutoRound) MTP-off; a plain
|
||
# re-quant without the head still drops.
|
||
has_mtp = bool((der.profile or {}).get("has_mtp_head"))
|
||
return {"route": "C", "sibling_slug": sibling,
|
||
"quant_match": quant_match,
|
||
"drop_spec_config": not has_mtp}
|
||
# No curated sibling -> the self-contained GGUF fallback (route B).
|
||
return {"route": "B", "sibling_slug": None,
|
||
"quant_match": "gguf", "drop_spec_config": False}
|
||
|
||
|
||
# Derive once (read-only) for arch + the swap-path xref; run the SHIPPED gate
|
||
# (evaluate-only: --json never emits/downloads) for the authoritative verdict.
|
||
der = D.derive(args.slug, hf_home=args.hf_home)
|
||
res = P.run_pull(
|
||
args.slug, args.profile_like,
|
||
dry_run=True, # --json is evaluate-only by contract
|
||
yes=args.yes, force_download=args.force_download,
|
||
experimental_arch=args.experimental_arch,
|
||
trust_remote_code=args.trust_remote_code,
|
||
hf_home=args.hf_home,
|
||
hardware_sm=args.hardware, gpu_topology=gpu_topology,
|
||
)
|
||
|
||
# fit_verdict: the most specific machine signal the gate produced — the raw
|
||
# [B] verdict when [B] was reached, else the terminal, else the abort reason.
|
||
fit_verdict = res.raw_verdict or res.terminal or res.abort_reason
|
||
|
||
obj = {
|
||
"fit_verdict": fit_verdict,
|
||
"arch": (der.profile or {}).get("arch") if der.error is None else None,
|
||
"eligible": bool(res.ok),
|
||
"note": res.detail or "",
|
||
"swap_path": _swap_path(args.slug, der, bool(res.ok)),
|
||
}
|
||
print(json.dumps(obj, sort_keys=True))
|
||
sys.exit(0 if res.ok else 0) # JSON emit always exits 0 (verdict is in-band)
|
||
PY
|