Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 12 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -421,4 +421,15 @@ FodyWeavers.xsd
CoderMind/.genreleases/
release_notes.md
workspace/
.cmind
.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/
69 changes: 32 additions & 37 deletions CoderMind/scripts/code_gen/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@

import os
import re
import signal
import subprocess
import sys
import shutil
Expand All @@ -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
Expand All @@ -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


# ============================================================================
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
92 changes: 76 additions & 16 deletions CoderMind/scripts/common/llm_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import json
import logging
import os as _os
import platform as _platform
import re
import shlex
import signal as _signal
Expand All @@ -25,16 +26,45 @@
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
except Exception:
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
# ----------------------------------------------------------------------------
Expand Down Expand Up @@ -138,8 +168,24 @@ def detect_agent_type(cmd: Optional[str] = None) -> str:
if not cmd or cmd == _PLACEHOLDER_LITERAL:
return "unknown"

first_token = cmd.strip().split()[0]
return _CLI_TO_AGENT.get(first_token, "unknown")
try:
first_token = shlex.split(cmd, posix=False)[0]
except (IndexError, ValueError):
parts = cmd.strip().split(maxsplit=1)
if not parts:
return "unknown"
first_token = parts[0]

# On Windows, cmd may be a quoted, backslash-separated path with an
# executable suffix (e.g. "C:\tools\claude.cmd" or claude.exe) instead of
# the bare "claude" that _CLI_TO_AGENT is keyed on.
executable_name = first_token.strip("\"'").replace("\\", "/").rsplit("/", 1)[-1].lower()
for suffix in (".cmd", ".exe", ".bat", ".ps1"):
if executable_name.endswith(suffix):
executable_name = executable_name[: -len(suffix)]
break

return _CLI_TO_AGENT.get(executable_name, "unknown")


# ============================================================================
Expand Down Expand Up @@ -377,29 +423,43 @@ 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,
encoding="utf-8",
errors="replace",
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
# Also force UTF-8 stdio inside the CLI subprocess
# itself (e.g. Claude/Codex CLI is often a Python
# or Node entrypoint). Without this, a non-tty
# stdout on Windows falls back to the legacy code
# page and the *child* can crash before its output
# ever reaches the `encoding="utf-8"` decode above.
popen_kwargs["env"] = dict(trace_ctx.env or {})
popen_kwargs["env"].setdefault("PYTHONIOENCODING", "utf-8:replace")
popen_kwargs["env"].setdefault("PYTHONUTF8", "1")
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(
Expand Down
22 changes: 22 additions & 0 deletions CoderMind/scripts/common/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,8 +38,30 @@
"""

import os
import sys
from pathlib import Path

# Every bundled script imports this module near the top, which makes it
# the natural single choke point to fix a Windows-only crash: when a
# script's stdout/stderr is not a real console (piped by `cmind script`,
# captured by a Claude Code hook, redirected in a test, ...), CPython
# falls back to `locale.getpreferredencoding()` for stdio instead of
# UTF-8. That's a legacy code page (cp1252, cp936, ...) on most Windows
# installs, so a bare `print()` of any non-ASCII character (e.g. the
# "->" arrow in update_graphs.py's status-guidance text) raises
# UnicodeEncodeError and kills the whole script instead of completing.
# Reconfiguring here, at import time, protects every script uniformly
# regardless of how it ends up being invoked.
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError):
# AttributeError: stream has no reconfigure (e.g. replaced by a
# test harness / io.StringIO). ValueError: stream is detached.
# Either way, this is best-effort — never block script startup.
pass
del _stream

# Import the home-storage helpers. cmind_cli is always installed in
# the same Python environment as the scripts (the wheel ships the
# scripts under ``cmind_cli/core_pack/scripts/``), so the import is
Expand Down
21 changes: 21 additions & 0 deletions CoderMind/src/cmind_cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2020,13 +2020,23 @@ def _run_initial_encode(project_path: Path) -> bool:
console.print(f"[yellow]Could not open log file {log_path}: {exc}[/yellow]")
return False

# Force UTF-8 stdio in the encoder subprocess — see the matching
# comment in the `script()` command for why this is required on
# Windows (non-tty stdout/stderr fall back to a legacy code page).
encoder_env = os.environ.copy()
encoder_env.setdefault("PYTHONIOENCODING", "utf-8:replace")
encoder_env.setdefault("PYTHONUTF8", "1")

try:
proc = subprocess.Popen(
[sys.executable, str(encoder), "--json"],
cwd=str(project_path),
env=encoder_env,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
encoding="utf-8",
errors="replace",
bufsize=1, # line-buffered so the reader thread sees lines promptly
)
except Exception as exc: # noqa: BLE001
Expand Down Expand Up @@ -4691,6 +4701,17 @@ def script(
# tool-venv install dir doesn't accumulate __pycache__ noise.
env = os.environ.copy()
env.setdefault("PYTHONDONTWRITEBYTECODE", "1")
# Force UTF-8 stdio in the child regardless of the host's locale. On
# Windows, a non-tty stdout (always true here: we either pipe it or it's
# inherited into another pipe/redirect) makes CPython fall back to
# locale.getpreferredencoding(), which is a legacy code page (cp1252,
# cp936, ...) on most Windows installs. Any bundled script that prints
# a non-ASCII character (e.g. update_graphs.py's "branch changed: 'a' →
# 'b'") then raises UnicodeEncodeError and crashes outright instead of
# completing. errors="replace" additionally protects the reverse
# direction (decoding non-UTF-8 bytes on stdin) from crashing.
env.setdefault("PYTHONIOENCODING", "utf-8:replace")
env.setdefault("PYTHONUTF8", "1")

# Tee stdout to a per-stage log file so the workspace has a persistent
# record of every script invocation. The log path is resolved from
Expand Down
Loading