Merge pull request #1958 from ilyabrykau-orca/fix/windows-guards-setup-retry

fix(ci): gate Windows guard setups on the index response, not list_projects stats
This commit is contained in:
Martin Vogel
2026-09-01 14:09:27 +02:00
committed by GitHub
4 changed files with 138 additions and 49 deletions
+31
View File
@@ -148,3 +148,34 @@ class McpServer:
self.proc.kill()
except Exception:
pass
def wait_projects_with_stats(server, timeout=90.0, poll=1.0):
"""Poll list_projects until a project row carries node/edge counts.
index_repository returns when the pipeline finishes, but the project
stats are published asynchronously; on slow CI runners the first
list_projects can observe the registration row before its counts land
(`nodes` absent/None). That window is environmental, not a product
regression (#1952), so wait it out instead of failing setup on it.
Returns (projects, last_text): the parsed project list (possibly [])
and the last raw list_projects payload for diagnostics.
"""
deadline = time.time() + timeout
last_txt = ""
while True:
resp = server.call_tool("list_projects", {}, timeout=60)
txt, err = server.tool_text(resp)
projects = []
if not err and txt:
last_txt = txt
try:
projects = json.loads(txt).get("projects") or []
except ValueError:
projects = []
if projects and projects[0].get("nodes") is not None:
return projects, last_txt
if time.time() >= deadline:
return projects, last_txt
time.sleep(poll)
+19 -7
View File
@@ -31,6 +31,7 @@ import shutil
import subprocess
import sys
import tempfile
import time
MATH_TS = (
"export function add(a: number, b: number): number { return a + b; }\n"
@@ -67,13 +68,24 @@ def main():
# CLI path itself works and isolating the failure to argv encoding.
ascii_repo = os.path.join(work, "ascii_repo")
make_fixture(ascii_repo)
env = dict(os.environ)
env["CBM_CACHE_DIR"] = os.path.join(work, "cache_ascii")
ctrl = subprocess.run(
[binary, "cli", "index_repository",
json.dumps({"repo_path": ascii_repo})],
capture_output=True, timeout=120, env=env)
ctrl_out = (ctrl.stdout or b"").decode("utf-8", "replace")
# The control is setup, not the surface under test: a cold runner can
# lose the first one-shot to coordination-daemon startup latency
# (#1952). One retry against a fresh cache separates that from a real
# CLI indexing failure.
ctrl_out = ""
for attempt in ("cache_ascii", "cache_ascii_retry"):
env = dict(os.environ)
env["CBM_CACHE_DIR"] = os.path.join(work, attempt)
ctrl = subprocess.run(
[binary, "cli", "index_repository",
json.dumps({"repo_path": ascii_repo})],
capture_output=True, timeout=120, env=env)
ctrl_out = (ctrl.stdout or b"").decode("utf-8", "replace")
if '"nodes"' in ctrl_out:
break
print("SETUP: ASCII control attempt %r did not index via CLI:\n%s"
% (attempt, ctrl_out[:300]))
time.sleep(2)
if '"nodes"' not in ctrl_out:
print("SETUP FAIL: ASCII control did not index via CLI:\n%s" %
ctrl_out[:300])
+25 -7
View File
@@ -31,6 +31,7 @@ import shutil
import subprocess
import sys
import tempfile
import time
SYMBOL = "someIndexedSymbol"
SRC = "export function %s(a: number): number { return a + 1; }\n" % SYMBOL
@@ -63,9 +64,19 @@ def main():
# repo_path / cwd in the forward-slash drive form Claude Code passes.
repo_fwd = repo.replace("\\", "/")
idx = run_cli(binary, cache, ["cli", "index_repository",
json.dumps({"repo_path": repo_fwd})])
idx_out = (idx.stdout or b"").decode("utf-8", "replace")
# Setup, not the surface under test: retry once so a cold runner's
# coordination-daemon startup latency (#1952) is not misread as a
# broken CLI index. Reindexing the same cache is idempotent.
idx_out = ""
for attempt in (1, 2):
idx = run_cli(binary, cache, ["cli", "index_repository",
json.dumps({"repo_path": repo_fwd})])
idx_out = (idx.stdout or b"").decode("utf-8", "replace")
if '"nodes"' in idx_out:
break
print("SETUP: index attempt %d did not run:\n%s"
% (attempt, idx_out[:300]))
time.sleep(2)
if '"nodes"' not in idx_out:
print("SETUP FAIL: index did not run:\n%s" % idx_out[:300])
return 2
@@ -88,10 +99,17 @@ def main():
# first — the supported way to keep hooks armed outside MCP sessions —
# and retire it afterwards (with a kill-by-pid backstop so a stuck stop
# can never hang CI).
start = run_cli(binary, cache, ["daemon", "start"], timeout=60)
start_out = (start.stdout or b"").decode("utf-8", "replace")
print("daemon start rc=%d %r" % (start.returncode, start_out[:120]))
if start.returncode != 0:
start_out = ""
rc = 1
for attempt in (1, 2):
start = run_cli(binary, cache, ["daemon", "start"], timeout=60)
start_out = (start.stdout or b"").decode("utf-8", "replace")
rc = start.returncode
print("daemon start attempt %d rc=%d %r" % (attempt, rc, start_out[:120]))
if rc == 0:
break
time.sleep(2)
if rc != 0:
print("SETUP FAIL: permanent daemon did not start")
return 2
pid_match = re.search(r"pid (\d+)", start_out)
+63 -35
View File
@@ -34,7 +34,7 @@ import sys
import tempfile
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from mcp_stdio import McpServer # noqa: E402
from mcp_stdio import McpServer, wait_projects_with_stats # noqa: E402
MATH_TS = (
"export function add(a: number, b: number): number { return a + b; }\n"
@@ -246,6 +246,37 @@ def make_fixture(root):
f.write(text.encode("utf-8")) # exact bytes, identical across copies
def no_project_error(index_txt, repo, cache):
"""Assemble the venue diagnostics for an index that left no project row.
The count summary cannot explain a venue-specific empty listing; carry the
index response, the cache contents and the supervisor's worker logs (the
only record of the pipeline's own error) into the CI log.
"""
try:
cache_entries = sorted(os.listdir(cache))
except OSError as exc:
cache_entries = ["<listdir failed: %s>" % exc]
log_tails = []
logs_dir = os.path.join(cache, "logs")
if os.path.isdir(logs_dir):
for log_name in sorted(os.listdir(logs_dir)):
try:
with open(os.path.join(logs_dir, log_name), "rb") as lf:
tail = lf.read()[-800:].decode("utf-8", "replace")
log_tails.append("%s: %s" % (log_name, tail))
except OSError as exc:
log_tails.append("%s: <unreadable: %s>" % (log_name, exc))
try:
repo_entries = sorted(os.listdir(repo))
except OSError as exc:
repo_entries = ["<listdir failed: %s>" % exc]
return {"error": "no project listed after index; index said %r; "
"cache holds %r; repo holds %r; worker logs: %s"
% (index_txt[:400], cache_entries, repo_entries,
" | ".join(log_tails) or "<none>")}
def index_and_count(binary, repo, cache):
"""Index `repo` into an isolated cache and return label-resolved counts."""
os.makedirs(cache, exist_ok=True)
@@ -255,42 +286,29 @@ def index_and_count(binary, repo, cache):
index_txt, err = s.tool_text(resp)
if err:
return {"error": "index tools/call error: %r" % err}
lp = s.call_tool("list_projects", {}, timeout=60)
lp_txt, _ = s.tool_text(lp)
projects = json.loads(lp_txt).get("projects") or []
if not projects:
# The count summary cannot explain a venue-specific empty listing;
# carry the index response and the cache contents into the log.
try:
cache_entries = sorted(os.listdir(cache))
except OSError as exc:
cache_entries = ["<listdir failed: %s>" % exc]
# The supervisor's worker logs carry the actual pipeline error.
log_tails = []
logs_dir = os.path.join(cache, "logs")
if os.path.isdir(logs_dir):
for log_name in sorted(os.listdir(logs_dir)):
try:
with open(os.path.join(logs_dir, log_name), "rb") as lf:
tail = lf.read()[-800:].decode("utf-8", "replace")
log_tails.append("%s: %s" % (log_name, tail))
except OSError as exc:
log_tails.append("%s: <unreadable: %s>" % (log_name, exc))
try:
repo_entries = sorted(os.listdir(repo))
except OSError as exc:
repo_entries = ["<listdir failed: %s>" % exc]
return {"error": "no project listed after index; index said %r; "
"cache holds %r; repo holds %r; worker logs: %s"
% (index_txt[:400], cache_entries, repo_entries,
" | ".join(log_tails) or "<none>")}
p = projects[0]
out = {"name": p.get("name"), "nodes": p.get("nodes"),
"edges": p.get("edges")}
# The index response itself carries the synchronous, authoritative
# counts ("nodes"/"edges"). list_projects publishes its stats columns
# asynchronously — on some venues never within a one-shot session — so
# gating on it misreads a healthy index as a setup failure (#1952).
try:
summary = json.loads(index_txt)
except ValueError:
summary = {}
out = {"name": summary.get("project"), "nodes": summary.get("nodes"),
"edges": summary.get("edges")}
if out["nodes"] is None:
# Payload without counts: fall back to list_projects, polled
# because its stats row can trail the index on slow runners.
projects, _ = wait_projects_with_stats(s)
if not projects:
return no_project_error(index_txt, repo, cache)
p = projects[0]
out = {"name": p.get("name"), "nodes": p.get("nodes"),
"edges": p.get("edges")}
# Definition-level counts prove the parser ran (not just discovery).
# query_graph defaults to TOON text; this scripted consumer requests
# format="json" ({"columns":[...],"rows":[["<n>"]],...}) explicitly.
name = p.get("name")
name = out["name"]
defs = 0
for label in ("Function", "Class", "Method"):
q = "MATCH (n:%s) RETURN count(n)" % label
@@ -331,7 +349,17 @@ def main():
ascii_repo = os.path.join(work, "ascii_repo")
make_fixture(ascii_repo)
base = index_and_count(binary, ascii_repo, os.path.join(work, "c_ascii"))
# The baseline is setup, not the surface under test: a cold runner can
# lose the first index to daemon startup latency (#1952). One retry
# against a fresh cache separates that environmental window from a
# real indexing failure before the guard declares a precondition skip.
base = {}
for attempt in ("c_ascii", "c_ascii_retry"):
base = index_and_count(binary, ascii_repo, os.path.join(work, attempt))
if not base.get("error") and base.get("nodes"):
break
print("SETUP: ASCII baseline attempt %r did not index: %r"
% (attempt, base))
if base.get("error") or not base.get("nodes"):
print("SETUP FAIL: ASCII baseline did not index: %r" % base)
return 2