diff --git a/Dockerfile.orchestrator b/Dockerfile.orchestrator index c10a509..a57f475 100644 --- a/Dockerfile.orchestrator +++ b/Dockerfile.orchestrator @@ -4,7 +4,7 @@ FROM python:3.11-slim # to create/teardown agent Pods + per-swarm key Secrets. Without it the k8s backend fails (0 agents). # Pinned to the cluster minor (AKS 1.34) per kubectl skew policy. (linux/amd64 — AKS default node arch.) RUN apt-get update \ - && apt-get install -y --no-install-recommends curl ca-certificates \ + && apt-get install -y --no-install-recommends curl ca-certificates git \ && KUBECTL_VERSION="$(curl -fsSL https://dl.k8s.io/release/stable-1.34.txt)" \ && curl -fsSL "https://dl.k8s.io/release/${KUBECTL_VERSION}/bin/linux/amd64/kubectl" -o /usr/local/bin/kubectl \ && chmod +x /usr/local/bin/kubectl \ diff --git a/orchestrator/main.py b/orchestrator/main.py index d3a9eb4..b00cec6 100644 --- a/orchestrator/main.py +++ b/orchestrator/main.py @@ -741,9 +741,20 @@ async def refresh_swarm_run_status(run): # The Queen verdict was computed by the quality gate above (run.metadata['queen']); mark the # selected winner on the deliverable so the result is one coherent pick, not N branches. queen_summary = run.metadata.get("queen") or {} - if isinstance(deliverable, dict) and queen_summary.get("winner"): - deliverable["selected"] = queen_summary["winner"] + winner = queen_summary.get("winner") if isinstance(queen_summary, dict) else None + if isinstance(deliverable, dict) and winner: + deliverable["selected"] = winner deliverable["candidate_count"] = queen_summary.get("candidate_count") + # SC-7: promote the winning artifact to the repo's main → one coherent deliverable on + # main, not N scattered agent branches. No-op when the run has no git grant. + try: + promo = await queen_mod.promote_to_main(run, tasks, winner.get("task_id")) + run.metadata["queen_promotion"] = promo + if isinstance(deliverable, dict) and promo.get("promoted"): + deliverable["promoted_to_main"] = { + "branch": promo.get("branch"), "commit_sha": promo.get("commit_sha")} + except Exception as exc: + logger.warning("queen promote_to_main failed for run %s: %s", run.swarm_id, exc) await swarm_runtime.save_run(run) # Benchmark Group B: grade the run's generated code against its held-out fixture tests in @@ -1939,6 +1950,18 @@ async def create_swarm_run_from_request( if created and run.status == "running": await create_tasks_for_run(run, body) await launch_swarm_agents(run, body) + # Persist the git grant REFERENCE (repo_url + secret_ref — both non-secret; the actual + # credential is resolved from secret_ref at promote time and never stored) so the Queen can + # push the winning artifact to the repo's main at run terminal (SC-7). Never store creds. + _git_grant = agent_launcher._first_git_grant(body) + if _git_grant: + _meta = _git_grant.get("metadata") or {} + run.metadata["git_grant"] = { + "repo_url": _meta.get("repo_url") or _git_grant.get("repo_url"), + "secret_ref": _git_grant.get("secret_ref"), + "base_branch": _meta.get("base_branch", "main"), + } + await swarm_runtime.save_run(run) return { "success": True, diff --git a/orchestrator/queen.py b/orchestrator/queen.py index 85aaeaf..7689d62 100644 --- a/orchestrator/queen.py +++ b/orchestrator/queen.py @@ -154,3 +154,84 @@ async def aggregate_run(run, tasks) -> Dict[str, Any]: except Exception as exc: # Queen never breaks the run's terminal path logger.warning("queen: aggregate_run failed for %s: %s", getattr(run, "swarm_id", "?"), exc) return {"winner": None, "candidate_count": 0, "reason": f"error:{exc!r}"} + + +def _auth_url(repo_url: str, user: Optional[str], pw: Optional[str]) -> str: + """Embed credentials into an http(s) clone URL. URL-encodes the password (handles '@','/', etc). + The result is NEVER logged.""" + import urllib.parse + if not user or not pw or "://" not in repo_url: + return repo_url + scheme, rest = repo_url.split("://", 1) + return f"{scheme}://{urllib.parse.quote(user, safe='')}:{urllib.parse.quote(pw, safe='')}@{rest}" + + +def _git_promote(env: Dict[str, str], base_branch: str, files: List[Any], swarm_id: str) -> Dict[str, Any]: + """Sync git: clone base_branch → overwrite with winner files → commit → push base_branch. + Blocking (run via asyncio.to_thread). Credentials live only in the clone URL, never logged.""" + import os + import shutil + import subprocess + import tempfile + repo_url = env["GIT_REPO_URL"] + auth_url = _auth_url(repo_url, env.get("GIT_USERNAME"), env.get("GIT_PASSWORD")) + workdir = tempfile.mkdtemp(prefix="queen-promote-") + repo_dir = os.path.join(workdir, "repo") + + def git(*args, cwd=None): + return subprocess.run(["git", *args], cwd=cwd or workdir, + capture_output=True, text=True, timeout=180) + + try: + if git("clone", "--depth", "1", "--branch", base_branch, auth_url, "repo").returncode != 0: + return {"promoted": False, "reason": "clone_failed"} + for f in files: + dest = os.path.join(repo_dir, f.path) + os.makedirs(os.path.dirname(dest) or repo_dir, exist_ok=True) + with open(dest, "w", encoding="utf-8") as fh: + fh.write(f.content) + git("config", "user.email", "queen@heicode.swarm", cwd=repo_dir) + git("config", "user.name", "HeiCode Queen", cwd=repo_dir) + git("add", "-A", cwd=repo_dir) + c = git("commit", "-m", f"queen: promote best swarm artifact ({swarm_id})", cwd=repo_dir) + if c.returncode != 0: + return {"promoted": False, "reason": "no_changes"} + if git("push", "origin", base_branch, cwd=repo_dir).returncode != 0: + return {"promoted": False, "reason": "push_failed"} + sha = git("rev-parse", "HEAD", cwd=repo_dir).stdout.strip() + return {"promoted": True, "commit_sha": sha, "branch": base_branch} + except Exception as exc: + return {"promoted": False, "reason": f"error:{exc!r}"} + finally: + shutil.rmtree(workdir, ignore_errors=True) + + +async def promote_to_main(run, tasks, winner_task_id: str) -> Dict[str, Any]: + """SC-7: push the winning candidate's files to the artifact repo's base branch (main), so the + run delivers ONE coherent artifact, not N scattered agent branches. Credentials are resolved + from the grant's secret_ref at call time (never stored/logged). Best-effort: never raises.""" + try: + grant = (run.metadata or {}).get("git_grant") or {} + repo_url = grant.get("repo_url") + if not repo_url: + return {"promoted": False, "reason": "no_git_grant"} + wtask = next((t for t in tasks if getattr(t, "task_id", None) == winner_task_id), None) + if wtask is None: + return {"promoted": False, "reason": "winner_not_found"} + from .quality import collect_generated_files + cf = collect_generated_files([wtask]) + files = (cf.get("impl") or []) + (cf.get("agent_tests") or []) + if not files: + return {"promoted": False, "reason": "winner_no_files"} + from .agent_launcher import resolve_git_grant + env = resolve_git_grant({"resource_grants": [{ + "resource_type": "git", "secret_ref": grant.get("secret_ref"), + "metadata": {"repo_url": repo_url, "base_branch": grant.get("base_branch", "main")}, + }]}) + if not env or "GIT_REPO_URL" not in env: + return {"promoted": False, "reason": "grant_unresolved"} + return await asyncio.to_thread(_git_promote, env, grant.get("base_branch", "main"), + files, getattr(run, "swarm_id", "?")) + except Exception as exc: + logger.warning("queen: promote_to_main failed for %s: %s", getattr(run, "swarm_id", "?"), exc) + return {"promoted": False, "reason": f"error:{exc!r}"} diff --git a/scripts/test-queen.py b/scripts/test-queen.py index 6236ec2..95bf473 100644 --- a/scripts/test-queen.py +++ b/scripts/test-queen.py @@ -11,7 +11,9 @@ os.environ.setdefault("REDIS_FAKE", "1") os.environ.setdefault("ALLOW_MEMORY_STORE", "1") os.environ.setdefault("OPENAI_API_KEY", "test-key") -from orchestrator.queen import Candidate, select_best, should_bounce +import asyncio + +from orchestrator.queen import Candidate, select_best, should_bounce, _auth_url, promote_to_main _failures = [] @@ -58,6 +60,22 @@ check("no threshold → no bounce", should_bounce(_summary(0.0), None, cycles=0, # unscored → honest, don't bounce check("unscored → no bounce", should_bounce(_summary(None), 80.0, cycles=0, max_cycles=2) is False) +# --- SC-7 promote_to_main --- +# _auth_url embeds + URL-encodes creds; passthrough when missing +check("auth url embeds + encodes creds", _auth_url("http://h/r.git", "u", "p@ss") == "http://u:p%40ss@h/r.git") +check("auth url passthrough w/o creds", _auth_url("http://h/r.git", None, None) == "http://h/r.git") + + +class _RunNoGrant: + def __init__(self): + self.metadata = {} + self.swarm_id = "s1" + + +# promote is a no-op (not an error) when the run has no git grant +_promo = asyncio.run(promote_to_main(_RunNoGrant(), [], "t1")) +check("promote without grant → not promoted", _promo.get("promoted") is False and _promo.get("reason") == "no_git_grant") + if _failures: print(f"\nFAILED: {len(_failures)} check(s): {_failures}") raise SystemExit(1)