mirror of
https://github.com/DeusData/codebase-memory-mcp.git
synced 2026-11-04 08:13:37 +08:00
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:
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user