From 2b485bc855222b47e978767f95ee220dde561249 Mon Sep 17 00:00:00 2001 From: Le-Anh-Duy Date: Thu, 9 Jul 2026 01:27:57 +0700 Subject: [PATCH 1/4] Fix Windows compatibility: subprocess preexec_fn is POSIX-only subprocess.Popen(preexec_fn=..., start_new_session=True) raised "preexec_fn is not supported on Windows platforms" for every LLM call and test-runner invocation, causing /cmind.encode and friends to fail outright on Windows. Branch on platform: POSIX keeps start_new_session + preexec_fn (killpg-based tree kill); Windows uses CREATE_NEW_PROCESS_GROUP and a new _kill_process_tree() helper that shells out to `taskkill /T /F` to reap the process tree instead. Verified end-to-end: reinstalled the patched package as the local cmind-cli tool and ran a full /cmind.encode against an external repo on Windows; it now completes successfully (previously failed on every LLM call). --- .gitignore | 13 ++++- CoderMind/scripts/code_gen/test_runner.py | 69 +++++++++++------------ CoderMind/scripts/common/llm_client.py | 59 ++++++++++++++----- 3 files changed, 89 insertions(+), 52 deletions(-) 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..55c9866 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,31 @@ 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: + subprocess.run( + ["taskkill", "/PID", str(proc.pid), "/T", "/F"], + capture_output=True, + check=False, + ) + except Exception: + proc.kill() + else: + try: + _os.killpg(_os.getpgid(proc.pid), _signal.SIGTERM) + except Exception: + proc.kill() + + # ---------------------------------------------------------------------------- # AI CLI command resolution # ---------------------------------------------------------------------------- @@ -377,29 +405,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( From c6e2362134e59d34a3543a4c782234dcec295a37 Mon Sep 17 00:00:00 2001 From: Qingtao Li <89620019+QingtaoLi1@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:04:54 +0800 Subject: [PATCH 2/4] Potential fix for pull request finding On Windows, _kill_process_tree ignores taskkill's return code. If taskkill fails (e.g. access denied / process already exited / PID reuse edge), this function returns without killing the process, but callers then do proc.wait(), which can hang indefinitely. Please fall back to proc.kill() when taskkill returns non-zero (or use check=True and catch CalledProcessError). Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- CoderMind/scripts/common/llm_client.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CoderMind/scripts/common/llm_client.py b/CoderMind/scripts/common/llm_client.py index 55c9866..afaa23c 100644 --- a/CoderMind/scripts/common/llm_client.py +++ b/CoderMind/scripts/common/llm_client.py @@ -49,11 +49,13 @@ def _kill_process_tree(proc: subprocess.Popen) -> None: """ if _IS_WINDOWS: try: - subprocess.run( + 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: From 09aaabe2963682ee27a03d51d6c1248a4867118f Mon Sep 17 00:00:00 2001 From: Le-Anh-Duy Date: Fri, 10 Jul 2026 15:51:14 +0700 Subject: [PATCH 3/4] Fix Windows UnicodeEncodeError crashing cmind hooks and status checks On Windows, a bundled script's stdout/stderr is almost never a real console (cmind script pipes it, hooks capture it, the initial-encode kickoff pipes it), so 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 raises UnicodeEncodeError and kills the script outright. Confirmed live: update_graphs.py's `status` command prints an MCP-tools guidance line containing "->" (functional areas -> groups -> features) unconditionally whenever an RPG exists and is readable -- not an edge case, it fires on the ordinary "status looks fine" path, which is why the SessionStart/post-commit hooks were silently failing ("[CoderMind] RPG status unavailable"). Reproduced end-to-end against a minimal sample project: `cmind script update_graphs.py status` crashes with the unfixed build and succeeds with the fixed one. Whether the same class of crash is what broke /cmind.encode specifically for a Windows reviewer is not confirmed (that command's own print(json.dumps(...)) calls are ASCII-safe by default) -- see tests/repro_windows_utf8/README.md for the full writeup of what's verified vs. still a hypothesis, and a from-scratch repro that needs no LLM calls. - common/paths.py: reconfigure sys.stdout/stderr to UTF-8 at import time. Every bundled script imports this module near the top, making it a single choke point that protects all of them uniformly regardless of how they end up being invoked (cmind script wrapper, a hook, a test, direct invocation, ...). - cmind_cli/__init__.py: also set PYTHONIOENCODING=utf-8:replace / PYTHONUTF8=1 on the child env in script() and _run_initial_encode(), and encoding="utf-8"/errors="replace" on the encoder's Popen, as defense in depth for non-Python or third-party children. - llm_client.py: same encoding/errors on the LLM CLI subprocess's Popen, so decoding its stdout/stderr never raises UnicodeDecodeError either. - test_hooks_install.py: regression test pinning that the diverged-branch status guidance (which also contains the arrow character) no longer crashes update_graphs.py on a piped stdout. - tests/repro_windows_utf8/: standalone, LLM-free repro (two two-line scripts) demonstrating the crash and the fix in under a second. Co-authored-by: Claude Sonnet 5 --- CoderMind/scripts/common/llm_client.py | 11 +++ CoderMind/scripts/common/paths.py | 22 +++++ CoderMind/src/cmind_cli/__init__.py | 21 ++++ CoderMind/tests/repro_windows_utf8/README.md | 95 +++++++++++++++++++ .../tests/repro_windows_utf8/repro_child.py | 17 ++++ .../tests/repro_windows_utf8/repro_runner.py | 47 +++++++++ CoderMind/tests/test_hooks_install.py | 37 ++++++++ 7 files changed, 250 insertions(+) create mode 100644 CoderMind/tests/repro_windows_utf8/README.md create mode 100644 CoderMind/tests/repro_windows_utf8/repro_child.py create mode 100644 CoderMind/tests/repro_windows_utf8/repro_runner.py diff --git a/CoderMind/scripts/common/llm_client.py b/CoderMind/scripts/common/llm_client.py index afaa23c..6bfbad9 100644 --- a/CoderMind/scripts/common/llm_client.py +++ b/CoderMind/scripts/common/llm_client.py @@ -419,11 +419,22 @@ def generate( stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + encoding="utf-8", + errors="replace", env=trace_ctx.env, cwd=_REPO_DIR, ) 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 diff --git a/CoderMind/scripts/common/paths.py b/CoderMind/scripts/common/paths.py index bebd0d0..6ad9b20 100644 --- a/CoderMind/scripts/common/paths.py +++ b/CoderMind/scripts/common/paths.py @@ -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 diff --git a/CoderMind/src/cmind_cli/__init__.py b/CoderMind/src/cmind_cli/__init__.py index cee62da..75cbcdc 100644 --- a/CoderMind/src/cmind_cli/__init__.py +++ b/CoderMind/src/cmind_cli/__init__.py @@ -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 @@ -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 diff --git a/CoderMind/tests/repro_windows_utf8/README.md b/CoderMind/tests/repro_windows_utf8/README.md new file mode 100644 index 0000000..236d1de --- /dev/null +++ b/CoderMind/tests/repro_windows_utf8/README.md @@ -0,0 +1,95 @@ +# Windows `UnicodeEncodeError` crash — repro, evidence, and what's still a guess + +## The bug + +On Windows, when a Python script's stdout/stderr is **not a real console** +(it's piped or captured by a parent process — which is exactly what +`cmind script ...`, the SessionStart/post-commit hooks, and +`cmind init`'s optional immediate-encode step all do), CPython falls back +to `locale.getpreferredencoding(False)` for stdio instead of UTF-8. That's +a legacy code page (`cp1252` on most Western-locale Windows installs, +`cp936` on Chinese installs, ...) rather than UTF-8. A bare `print()` of +any character outside that code page then raises `UnicodeEncodeError` +and kills the whole script instead of completing. + +## Reproduce it (cheap — no LLM calls, < 1 second) + +``` +python repro_runner.py # before the fix: child crashes, exit code 1 +python repro_runner.py --fix # after the fix: child completes, exit code 0 +``` + +`repro_runner.py` spawns `repro_child.py` (a two-line script that prints +a line containing "→", U+2192) using the same +`subprocess.run(cmd, env=env, stdout=PIPE, stderr=STDOUT)` shape that +`cmind_cli/__init__.py`'s `script()` command uses for every bundled +script. `--fix` adds `PYTHONIOENCODING=utf-8:replace` / +`PYTHONUTF8=1` to the child's env — the same fix now applied at +`CoderMind/scripts/common/paths.py` import time (see the main fix, not +in this directory). + +## What's confirmed vs. what's a hypothesis + +**Confirmed — this crash is real and already happened on this machine, +in this repo:** + +- The SessionStart hook (`cmind script update_graphs.py status`) crashed + with this exact `UnicodeEncodeError` (character `→`) at the start + of the session where this fix was written — visible as the hook's + fallback text, `[CoderMind] RPG status unavailable`. +- `update_graphs.py`'s `_format_status_for_agent()` prints a line + containing "→" **unconditionally** whenever an RPG exists and is + readable (not just on the rarer "branch changed" path) — so this + isn't an edge case, it fires on the ordinary "status looks fine" path. +- Proof: 3 pre-existing tests were silently failing before this fix, + for exactly this reason (their captured `stdout` was empty because the + child crashed before finishing) — + `test_hooks_install.py::test_update_graphs_status_with_rpg`, + `test_step3_polish.py::test_status_text_omits_branch_when_detached`, + `test_step3_polish.py::test_status_text_shows_branch_when_in_sync`. + All three pass after the fix, untouched otherwise. + +**Not confirmed — a reasoned hypothesis, not a verified fact:** + +- Whether this exact bug is what broke `/cmind.encode` for the reviewer + who reported "input errors" on Windows. The specific `"→"` print above + lives in `update_graphs.py` (used by hooks / `/cmind.update_rpg`), which + is **not** on `/cmind.encode`'s own call path (`run_encode.py` / + `check_encode.py`). Their own top-level `print(json.dumps(...))` calls + are ASCII-safe by default (`json.dumps` escapes non-ASCII to `\uXXXX`). +- For `/cmind.encode` to hit the *same class* of crash, some other + `print()`/`logger.warning()` deeper in the pipeline (`workflow.py`, + `rpg_encoding.py`, `semantic_parsing.py`, or a log of the raw LLM + response) would have to emit a non-ASCII character while stdout is + piped. That's plausible but content-dependent — it doesn't require + the *repo being encoded* to contain anything unusual. LLMs routinely + emit "invisible" non-ASCII characters even for plain English content: + curly quotes (`" "`), em/en dashes (`—`/`–`), arrows (`→`), bullets + (`•`), ellipses (`…`). Whether a given `/cmind.encode` run trips the + bug is effectively a coin flip driven by what the LLM happened to + generate that call, which is consistent with one person's run + succeeding and another's failing on the same code. +- Because the actual fix (`common/paths.py`, imported by every bundled + script) isn't tied to any single print site, it protects the whole + pipeline regardless of which exact line would have crashed — so + pinpointing the precise trigger for the reviewer's report isn't + required for the fix to be correct, only for fully explaining their + report. + +## Is "force everything to UTF-8" safe for text that's *supposed* to + contain special characters? + +Yes — this is not a lossy workaround. `encoding="utf-8"` is a universal +encoding: every valid Unicode code point (Vietnamese, Chinese, arrows, +emoji, anything) round-trips through it losslessly. The legacy Windows +code pages (`cp1252`, `cp936`, ...) are the ones that *can't* represent +arbitrary Unicode — UTF-8 is what actually lets special/non-English +content print correctly instead of crashing. + +`errors="replace"` only matters for the (rare, genuinely abnormal) case +of a byte sequence that isn't valid UTF-8 at all — e.g. a subprocess +that itself used some other encoding internally. In that case one +unrecognizable byte becomes the replacement character `�` instead of +raising and killing the whole script. That's a deliberate trade-off: +losing the glyph for one malformed byte is preferable to the entire +`/cmind.encode` run (or hook) aborting outright. diff --git a/CoderMind/tests/repro_windows_utf8/repro_child.py b/CoderMind/tests/repro_windows_utf8/repro_child.py new file mode 100644 index 0000000..cd93d31 --- /dev/null +++ b/CoderMind/tests/repro_windows_utf8/repro_child.py @@ -0,0 +1,17 @@ +"""Minimal stand-in for a bundled CoderMind script. + +Mirrors the exact pattern that used to exist in +CoderMind/scripts/update_graphs.py's _format_status_for_agent(): a plain +``print()`` of a string containing a non-ASCII character. That function +prints this unconditionally whenever an RPG exists and is readable (the +"->" in the "branch changed: 'a' -> 'b'" note, and the "->" in the +"functional areas -> groups -> features" guidance line), so it isn't an +exotic edge case -- it's on the common "status looks fine" path. + +No LLM call, no network, no CoderMind imports: this reproduces the crash +class with two lines of stdlib print(), on purpose, so it can be run in +under a second to confirm/deny the theory without spending any tokens +on an actual /cmind.encode run. +""" +print("[CoderMind] branch changed: 'old-branch' → 'new-branch'.") +print("status: ok") diff --git a/CoderMind/tests/repro_windows_utf8/repro_runner.py b/CoderMind/tests/repro_windows_utf8/repro_runner.py new file mode 100644 index 0000000..1cee2ed --- /dev/null +++ b/CoderMind/tests/repro_windows_utf8/repro_runner.py @@ -0,0 +1,47 @@ +"""Reproduces the exact subprocess launch shape CoderMind uses to run every +bundled script, to confirm the Windows UnicodeEncodeError theory cheaply +(no LLM calls, no CoderMind imports, runs in well under a second): + + CoderMind/src/cmind_cli/__init__.py, function `script()`: + env = os.environ.copy() + ... + proc = subprocess.run(cmd, env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + +The same shape (piped/captured stdout, not a real console) is used by: + * the SessionStart / post-commit hooks: `cmind script update_graphs.py status` + * /cmind.encode: `cmind script rpg_encoder/run_encode.py --json` + * `cmind init`'s optional immediate encode: `_run_initial_encode()` + +Usage:: + + python repro_runner.py # before the fix: child crashes + python repro_runner.py --fix # after the fix: child completes + +Before the fix, this prints a UnicodeEncodeError traceback and exit code 1 +-- the same traceback that was observed live from the real SessionStart +hook in this repo (see README.md in this directory for the full writeup). +""" +import os +import subprocess +import sys + +child = os.path.join(os.path.dirname(__file__), "repro_child.py") + +env = os.environ.copy() +env.setdefault("PYTHONDONTWRITEBYTECODE", "1") + +if "--fix" in sys.argv: + env.setdefault("PYTHONIOENCODING", "utf-8:replace") + env.setdefault("PYTHONUTF8", "1") + print("=== running WITH the fix (PYTHONIOENCODING=utf-8:replace) ===") +else: + print("=== running WITHOUT the fix ===") + +proc = subprocess.run( + [sys.executable, child], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, +) +sys.stdout.buffer.write(proc.stdout) +print(f"\n--- child exit code: {proc.returncode} ---") diff --git a/CoderMind/tests/test_hooks_install.py b/CoderMind/tests/test_hooks_install.py index a222fd7..85d0af8 100644 --- a/CoderMind/tests/test_hooks_install.py +++ b/CoderMind/tests/test_hooks_install.py @@ -476,6 +476,43 @@ def test_update_graphs_status_text_on_corrupt_rpg_says_unavailable(project): assert "/cmind.encode" in text +def test_update_graphs_status_diverged_branch_survives_non_ascii_guidance(project): + """Regression: the diverged-branch guidance line contains "->" (U+2192). + + On Windows, a subprocess whose stdout is piped (exactly what happens + here via `subprocess.run(..., capture_output=True)`, and what the real + SessionStart hook / `cmind script` wrapper do too) makes CPython fall + back to a legacy code page for stdio instead of UTF-8. Printing this + guidance line used to raise UnicodeEncodeError and crash the whole + script instead of completing — this test pins that it no longer does. + """ + subprocess.run(["git", "init", "-q", "-b", "new-branch"], cwd=project, check=True) + subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=project, check=True) + subprocess.run(["git", "config", "user.name", "Test"], cwd=project, check=True) + (project / "README.md").write_text("hello\n") + subprocess.run(["git", "add", "README.md"], cwd=project, check=True) + subprocess.run(["git", "commit", "-q", "-m", "init"], cwd=project, check=True) + + data_dir = project / ".cmind" / "data" + data_dir.mkdir(parents=True) + (data_dir / "rpg.json").write_text(json.dumps({ + "repo_name": "demo", + "edges": [], + "root": {"id": "root", "children": []}, + "meta": {"git": { + "head_commit": "0" * 40, + "head_short": "0000000", + "head_branch": "old-branch", + }}, + })) + + result = _run_status(project) + assert result.returncode == 0, f"stdout={result.stdout!r} stderr={result.stderr!r}" + assert "UnicodeEncodeError" not in result.stderr + assert "branch changed: 'old-branch'" in result.stdout + assert "'new-branch'" in result.stdout + + # --------------------------------------------------------------------------- # _setup_gitignore — unified .gitignore management # --------------------------------------------------------------------------- From a086533a647824714fd2581f4197648ba3cc6b42 Mon Sep 17 00:00:00 2001 From: Le-Anh-Duy Date: Mon, 13 Jul 2026 20:25:19 +0700 Subject: [PATCH 4/4] Fix detect_agent_type() to handle Windows CLI paths Reviewer-suggested patch (@QingtaoLi1): on Windows the configured AI CLI command is often a quoted, backslash-separated path with an executable suffix (e.g. "C:\tools\claude.cmd") rather than the bare name _CLI_TO_AGENT is keyed on, so detection always fell through to "unknown". Co-Authored-By: Claude Sonnet 5 --- CoderMind/scripts/common/llm_client.py | 20 ++++++++- .../tests/test_llm_client_agent_detect.py | 43 +++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 CoderMind/tests/test_llm_client_agent_detect.py diff --git a/CoderMind/scripts/common/llm_client.py b/CoderMind/scripts/common/llm_client.py index 6bfbad9..1e469e6 100644 --- a/CoderMind/scripts/common/llm_client.py +++ b/CoderMind/scripts/common/llm_client.py @@ -168,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") # ============================================================================ diff --git a/CoderMind/tests/test_llm_client_agent_detect.py b/CoderMind/tests/test_llm_client_agent_detect.py new file mode 100644 index 0000000..dddc2be --- /dev/null +++ b/CoderMind/tests/test_llm_client_agent_detect.py @@ -0,0 +1,43 @@ +"""Regression test for detect_agent_type() Windows path/quote handling. + +On Windows the configured AI CLI command is often a quoted, backslash-separated +path with an executable suffix (e.g. `"C:\\tools\\claude.cmd" --flag`) rather +than the bare command name `_CLI_TO_AGENT` is keyed on. +""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "scripts")) + +from common.llm_client import detect_agent_type + + +def test_detect_agent_type_bare_command(): + assert detect_agent_type("claude --flag") == "claude" + + +def test_detect_agent_type_windows_quoted_path_with_suffix(): + assert detect_agent_type('"C:\\tools\\claude.cmd" --flag') == "claude" + + +def test_detect_agent_type_windows_exe_suffix_no_quotes(): + assert detect_agent_type("C:\\tools\\Gemini.EXE --flag") == "gemini" + + +def test_detect_agent_type_unknown(): + assert detect_agent_type("some-other-tool --flag") == "unknown" + + +def test_detect_agent_type_empty(): + assert detect_agent_type("") == "unknown" + + +if __name__ == "__main__": + test_detect_agent_type_bare_command() + test_detect_agent_type_windows_quoted_path_with_suffix() + test_detect_agent_type_windows_exe_suffix_no_quotes() + test_detect_agent_type_unknown() + test_detect_agent_type_empty() + print("ok")