From 70efa86d1b7dc3335bb9d0879cc2778eaa7afa1f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 6 Aug 2026 18:51:04 +0000 Subject: [PATCH] fix: restore CI to green (pin mcp<2, unmask and fix a racy test) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI has been red on main since 2026-07-28 (run #93, fcad0674). Three independent problems, all reproduced locally in a clean venv. 1. mcp 2.0.0 removed mcp.server.fastmcp. pyproject declared "mcp>=1.2" with no upper bound, so CI resolved 2.x and mythos/mcp_server.py failed to import FastMCP; FastMCP is not exposed from mcp.server either. The except ImportError guard re-raises as RuntimeError at module import time, which killed pytest collection and took the entire suite down rather than just the MCP test. Pin mcp>=1.2,<2 in both the dev and mcp extras; migrating to the 2.x server API is separate work. 2. tests/test_backends.py built an HTTPError with a fake fp lacking close(). HTTPError wraps fp in a finalizer that calls close() on collection, so the AttributeError surfaced as an unraisable exception attributed to whatever unrelated test was running at GC time. Give the fixture a close(). 3. With that noise removed, test_codex_streams_jsonl_events_to_trace_and_sink failed on a real race. CodexCli.run streams stdout to the trace from a daemon thread that it only joins after process.wait() returns, so nothing orders the reader against the fake wait(). The test read the trace once and passed only when it won the race — it did in isolation, not under full-suite load. Poll for the streamed event instead, preserving the assertion's intent that the trace fills during the run rather than after it. Production code is unchanged. Full suite: 106 passed, three consecutive clean runs. compileall, CLI help, /health, and MCP tool registration all verified. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VQpUQYoSDgvMZkVF2NbGiq --- pyproject.toml | 4 ++-- tests/test_backends.py | 7 +++++++ tests/test_sol_staged_pipeline.py | 19 ++++++++++++++++--- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 17e4698..47bac87 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,14 +22,14 @@ dev = [ "httpx>=0.27", "fastapi>=0.110", "uvicorn>=0.29", - "mcp>=1.2", + "mcp>=1.2,<2", ] api = [ "fastapi>=0.110", "uvicorn>=0.29", ] mcp = [ - "mcp>=1.2", + "mcp>=1.2,<2", ] render = [ "manim>=0.19", diff --git a/tests/test_backends.py b/tests/test_backends.py index 16fc0dd..0ff1079 100644 --- a/tests/test_backends.py +++ b/tests/test_backends.py @@ -35,6 +35,13 @@ class ResponseBody: def read(self): return b'{"error":"bad auth"}' + def close(self): + # HTTPError wraps fp in a finalizer that calls close() on GC. Without + # this, the AttributeError surfaces as an unraisable exception inside + # whatever unrelated test happens to be running when the collector + # gets to it. + pass + def raise_httperror(*args, **kwargs): raise HTTPError( url="https://example.test/v1/chat/completions", diff --git a/tests/test_sol_staged_pipeline.py b/tests/test_sol_staged_pipeline.py index 28e33fd..0c66111 100644 --- a/tests/test_sol_staged_pipeline.py +++ b/tests/test_sol_staged_pipeline.py @@ -3,6 +3,7 @@ import subprocess import sys import threading +import time from pathlib import Path import pytest @@ -66,9 +67,21 @@ def __init__(self): self.returncode = 0 def wait(self, timeout=None): - observed["trace_during_wait"] = ( - tmp_path / "trace.jsonl" - ).read_text(encoding="utf-8") + # CodexCli streams stdout to the trace from a daemon thread it only + # joins after wait() returns, so nothing orders that thread against + # this call. Poll instead of reading once: the point of the + # assertion is that the trace fills during the run rather than + # afterwards, and a bare read just races the reader thread. + trace = tmp_path / "trace.jsonl" + deadline = time.monotonic() + 10.0 + text = "" + while time.monotonic() < deadline: + if trace.is_file(): + text = trace.read_text(encoding="utf-8") + if "thread.started" in text: + break + time.sleep(0.01) + observed["trace_during_wait"] = text return self.returncode def kill(self):