feat(sync): --verify exits non-zero on drift; adapter mismatch = loud drift (GAP-21, FU-8a)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-31 11:14:44 +00:00
parent adc196ac20
commit aac383acb7
2 changed files with 90 additions and 18 deletions

View File

@@ -0,0 +1,48 @@
"""FU-8a / GAP-21: sync --verify drift-gate logic (offline)."""
from __future__ import annotations
import importlib.util
from pathlib import Path
_SCRIPT = Path(__file__).resolve().parents[2] / "scripts" / "sync_agents_across_companies.py"
_spec = importlib.util.spec_from_file_location("sync_agents", _SCRIPT)
sync = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(sync)
def _agent(name, adapter="claude_code", cfg=None):
return {"id": f"id-{name}", "name": name, "adapter_type": adapter,
"adapter_config": cfg or {"model": "x"}, "runtime_config": {}, "metadata": {},
"budget_monthly_cents": 0, "icon": "", "title": "", "role": "", "agent_api_keys": []}
def test_verify_exit_code_clean_is_zero():
assert sync._verify_exit_code(plan=[], mismatches=[], missing=[]) == 0
def test_verify_exit_code_drift_is_nonzero():
assert sync._verify_exit_code(plan=[("m", "mi", {"x": 1})], mismatches=[], missing=[]) == 1
def test_verify_exit_code_adapter_mismatch_is_nonzero():
assert sync._verify_exit_code(plan=[], mismatches=["עוזר משפטי"], missing=[]) == 1
def test_verify_exit_code_missing_is_nonzero():
assert sync._verify_exit_code(plan=[], mismatches=[], missing=["סוכן"]) == 1
def test_build_drift_report_flags_adapter_mismatch():
master = [_agent("A", adapter="claude_code")]
mirror_by_name = {"A": _agent("A", adapter="deepseek_local")}
rep = sync.build_drift_report(master, mirror_by_name, mirror_skills=set(), only=None)
assert "A" in rep["mismatches"]
assert rep["plan"] == []
def test_build_drift_report_flags_missing_and_plan():
master = [_agent("A"), _agent("B")]
mirror_by_name = {"B": _agent("B", cfg={"model": "different"})}
rep = sync.build_drift_report(master, mirror_by_name, mirror_skills=set(), only=None)
assert "A" in rep["missing"]
assert any(p[0]["name"] == "B" for p in rep["plan"])

View File

@@ -345,6 +345,33 @@ async def check_instructions(agents: list[dict]) -> bool:
return all_ok return all_ok
def build_drift_report(master_agents, mirror_by_name, mirror_skills, only=None) -> dict:
"""Pure drift computation (no DB, no printing). Returns:
{"plan": [(master, mirror, diff), ...], "mismatches": [name, ...], "missing": [name, ...]}.
adapter_type mismatch and missing-in-mirror are recorded as drift, not skipped silently.
"""
plan, mismatches, missing = [], [], []
for m in master_agents:
if only and m["name"] != only:
continue
mirror = mirror_by_name.get(m["name"])
if not mirror:
missing.append(m["name"])
continue
if m["adapter_type"] != mirror["adapter_type"]:
mismatches.append(m["name"])
continue
diff = compute_diff(m, mirror, mirror_skills)
if diff:
plan.append((m, mirror, diff))
return {"plan": plan, "mismatches": mismatches, "missing": missing}
def _verify_exit_code(plan, mismatches, missing) -> int:
"""0 iff fully in sync; 1 if any drift (needs-sync / adapter mismatch / missing-in-mirror)."""
return 1 if (plan or mismatches or missing) else 0
async def main() -> None: async def main() -> None:
p = argparse.ArgumentParser() p = argparse.ArgumentParser()
g = p.add_mutually_exclusive_group(required=True) g = p.add_mutually_exclusive_group(required=True)
@@ -376,26 +403,23 @@ async def main() -> None:
print(f"=== Mirror has {len(mirror_skills)} local skills available ===\n") print(f"=== Mirror has {len(mirror_skills)} local skills available ===\n")
print(f"=== Drift report ===") print(f"=== Drift report ===")
plan: list[tuple[dict, dict, dict]] = [] # (master, mirror, diff) report = build_drift_report(master_agents, mirror_by_name, mirror_skills, only=args.only)
for m in master_agents: plan = report["plan"]
if args.only and m["name"] != args.only: for name in report["missing"]:
continue print(f"{name:14s} — NOT FOUND in mirror (we never auto-create) — DRIFT")
mirror = mirror_by_name.get(m["name"]) for name in report["mismatches"]:
if not mirror: m = next(a for a in master_agents if a["name"] == name)
print(f"{m['name']:14s} — NOT FOUND in mirror (skipping; we never auto-create)") mi = mirror_by_name[name]
continue print(f"{name:14s} — adapter_type mismatch ({m['adapter_type']} vs {mi['adapter_type']}) "
if m["adapter_type"] != mirror["adapter_type"]: f"— DRIFT (apply skips it; fix manually in both companies)")
print(f"{m['name']:14s} — adapter_type mismatch ({m['adapter_type']} vs {mirror['adapter_type']}) — SKIPPING") for master, mirror, diff in plan:
continue print_diff(master["name"], diff, master["id"], mirror["id"])
diff = compute_diff(m, mirror, mirror_skills)
print_diff(m["name"], diff, m["id"], mirror["id"])
if diff:
plan.append((m, mirror, diff))
if args.verify: if args.verify:
print(f"\n(verify mode — exiting without changes)") code = _verify_exit_code(plan, report["mismatches"], report["missing"])
print(f"\nSummary: {len(plan)} agent(s) need sync, {len(master_agents) - len(plan)} in sync") print(f"\nSummary: {len(plan)} need sync, {len(report['mismatches'])} adapter-mismatch, "
return f"{len(report['missing'])} missing-in-mirror → {'DRIFT' if code else 'IN SYNC'}")
sys.exit(code)
if not plan: if not plan:
print(f"\n✓ All agents in sync — nothing to do.") print(f"\n✓ All agents in sync — nothing to do.")