diff --git a/.gitignore b/.gitignore index e5a7360..a45bd42 100644 --- a/.gitignore +++ b/.gitignore @@ -421,4 +421,15 @@ FodyWeavers.xsd CoderMind/.genreleases/ release_notes.md workspace/ -.cmind \ No newline at end of file +.cmind + +# CoderMind ignores (managed by `cmind init/update`) +.cmind/* +!.cmind/config.toml +.rpgkit/ +.venv_dev/ +.cmind_dev_env/ +.vscode/mcp.json +.vscode/tasks.json +.mcp.json +.claude/commands/ diff --git a/CoderMind/scripts/code_gen/test_runner.py b/CoderMind/scripts/code_gen/test_runner.py index 19a0a75..f28ba3f 100644 --- a/CoderMind/scripts/code_gen/test_runner.py +++ b/CoderMind/scripts/code_gen/test_runner.py @@ -10,7 +10,6 @@ import os import re -import signal import subprocess import sys import shutil @@ -22,6 +21,7 @@ from .test_output_parser import TestOutputAnalysis, _parse_stats, _SUMMARY_RE from .test_output_parser import analyze_test_output from common.llm_client import LLMClient +from common.llm_client import _IS_WINDOWS, _kill_process_tree, _set_pdeathsig import json as _json from common.import_normalizer import normalize_files from common.paths import FEATURE_SPEC_FILE, REPO_RPG_FILE @@ -35,13 +35,6 @@ ) -def _set_pdeathsig() -> None: - """Preexec hook: ask the kernel to send SIGTERM to this child when its parent dies (including SIGKILL). Called after fork() but before exec() so it runs in the child's address space. Silently ignored on non-Linux.""" - try: - import ctypes, signal as _s - ctypes.CDLL("libc.so.6").prctl(1, _s.SIGTERM) # PR_SET_PDEATHSIG = 1 - except Exception: - pass # ============================================================================ @@ -416,25 +409,26 @@ def run_pytest( if env: run_env.update(env) + popen_kwargs: Dict[str, Any] = dict( + cwd=repo_root, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=run_env, + ) + if _IS_WINDOWS: + popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + else: + popen_kwargs["start_new_session"] = True # own process group → killpg kills pytest + children + popen_kwargs["preexec_fn"] = _set_pdeathsig # PR_SET_PDEATHSIG: killed even when parent SIGKILL'd + try: - proc = subprocess.Popen( - cmd, - cwd=repo_root, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - env=run_env, - start_new_session=True, # own process group → killpg kills pytest + children - preexec_fn=_set_pdeathsig, # PR_SET_PDEATHSIG: killed even when parent SIGKILL'd - ) + proc = subprocess.Popen(cmd, **popen_kwargs) try: stdout_data, stderr_data = proc.communicate(timeout=timeout) except BaseException: - # Kill the entire pytest process group (covers forked workers, etc.) - try: - os.killpg(os.getpgid(proc.pid), signal.SIGTERM) - except Exception: - proc.kill() + # Kill the entire pytest process tree (covers forked workers, etc.) + _kill_process_tree(proc) proc.wait() raise @@ -558,24 +552,25 @@ def run_project_tests( if env: run_env.update(env) + popen_kwargs: Dict[str, Any] = dict( + cwd=repo_root, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=run_env, + ) + if _IS_WINDOWS: + popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + else: + popen_kwargs["start_new_session"] = True + popen_kwargs["preexec_fn"] = _set_pdeathsig + try: - proc = subprocess.Popen( - cmd, - cwd=repo_root, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - env=run_env, - start_new_session=True, - preexec_fn=_set_pdeathsig, - ) + proc = subprocess.Popen(cmd, **popen_kwargs) try: stdout_data, stderr_data = proc.communicate(timeout=timeout) except BaseException: - try: - os.killpg(os.getpgid(proc.pid), signal.SIGTERM) - except Exception: - proc.kill() + _kill_process_tree(proc) proc.wait() raise diff --git a/CoderMind/scripts/common/llm_client.py b/CoderMind/scripts/common/llm_client.py index e9fddd7..afaa23c 100644 --- a/CoderMind/scripts/common/llm_client.py +++ b/CoderMind/scripts/common/llm_client.py @@ -9,6 +9,7 @@ import json import logging import os as _os +import platform as _platform import re import shlex import signal as _signal @@ -25,9 +26,11 @@ from . import paths as _paths from .paths import REPO_DIR as _REPO_DIR, WORKSPACE_ROOT as _WORKSPACE_ROOT +_IS_WINDOWS = _platform.system() == "Windows" + def _set_pdeathsig() -> None: - """Preexec hook: ask the kernel to send SIGTERM to this child when its parent dies (including SIGKILL). Called after fork() but before exec() so it runs in the child's address space. Silently ignored on non-Linux.""" + """Preexec hook: ask the kernel to send SIGTERM to this child when its parent dies (including SIGKILL). Called after fork() but before exec() so it runs in the child's address space. Silently ignored on non-Linux. Only ever passed as ``preexec_fn`` on POSIX (see ``generate``); Windows never touches this.""" try: import ctypes, signal as _s ctypes.CDLL("libc.so.6").prctl(1, _s.SIGTERM) # PR_SET_PDEATHSIG = 1 @@ -35,6 +38,33 @@ def _set_pdeathsig() -> None: pass +def _kill_process_tree(proc: subprocess.Popen) -> None: + """Kill *proc* and its full descendant tree, cross-platform. + + POSIX: the child was launched via ``start_new_session=True`` so it + leads its own process group; killpg reaches every descendant. + Windows: the child was launched with ``CREATE_NEW_PROCESS_GROUP`` + (``preexec_fn``/``killpg`` don't exist there); ``taskkill /T`` walks + the process tree by PID instead. + """ + if _IS_WINDOWS: + try: + res = subprocess.run( + ["taskkill", "/PID", str(proc.pid), "/T", "/F"], + capture_output=True, + check=False, + ) + if res.returncode != 0: + proc.kill() + except Exception: + proc.kill() + else: + try: + _os.killpg(_os.getpgid(proc.pid), _signal.SIGTERM) + except Exception: + proc.kill() + + # ---------------------------------------------------------------------------- # AI CLI command resolution # ---------------------------------------------------------------------------- @@ -377,29 +407,32 @@ def generate( cmd = shlex.split(self.tool) + trace_ctx.extra_args # Sub-agent runs in the project repo directory. - # start_new_session=True puts the child in its own process - # group so killpg kills the whole tree on parent exit. - # preexec_fn=_set_pdeathsig handles the SIGKILL case via - # PR_SET_PDEATHSIG (kernel sends SIGTERM to child on parent death). - proc = subprocess.Popen( - cmd, + # POSIX: start_new_session=True puts the child in its own + # process group so killpg kills the whole tree on parent + # exit; preexec_fn=_set_pdeathsig handles the SIGKILL case + # via PR_SET_PDEATHSIG (kernel sends SIGTERM to child on + # parent death). Neither is supported on Windows, where + # CREATE_NEW_PROCESS_GROUP + taskkill /T (see + # _kill_process_tree) plays the same role. + popen_kwargs: Dict[str, Any] = dict( stdin=trace_ctx.stdin, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, env=trace_ctx.env, cwd=_REPO_DIR, - start_new_session=True, - preexec_fn=_set_pdeathsig, ) + if _IS_WINDOWS: + popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + else: + popen_kwargs["start_new_session"] = True + popen_kwargs["preexec_fn"] = _set_pdeathsig + proc = subprocess.Popen(cmd, **popen_kwargs) try: stdout, stderr = proc.communicate(timeout=timeout) except BaseException: - # Kill the entire process group (agent + any pytest children) - try: - _os.killpg(_os.getpgid(proc.pid), _signal.SIGTERM) - except Exception: - proc.kill() + # Kill the entire process tree (agent + any pytest children) + _kill_process_tree(proc) proc.wait() raise result = subprocess.CompletedProcess(