From 088d98ad7628f2a77490cf2958bcf6bfa8aae680 Mon Sep 17 00:00:00 2001 From: Aleksej Vasilev Date: Thu, 27 Aug 2026 07:01:57 +0200 Subject: [PATCH 1/3] Fix Windows/MSYS compatibility in agents-consilium The skill assumed a POSIX host in several places, which broke it entirely under Git Bash / MSYS2 with a native Windows python3. Refs #5. Shell/encoding: - config.sh: force sys.stdout.reconfigure(newline='\n') in _cfg_python. Native Windows python3 emits \r\n from print(), so every config lookup consumed line-by-line matched against ids with a trailing CR and failed with "unknown agent id: \r". - common.sh: export PYTHONIOENCODING=utf-8 so embedded non-ASCII output does not raise UnicodeEncodeError on a non-UTF-8 console codepage. - discovery-pass.sh, judge-runner.sh: resolve the backend by passing the config path and agent id through the environment instead of splicing them into the python source, and reconfigure stdout newlines. Removes a shell-injection surface as well as the CRLF hazard. - dedup-findings.py: read/write findings with an explicit encoding="utf-8" (and newline="\n" on write) instead of the process codepage, which silently mojibaked UTF-8 agent output. - steer/adapters/{claude,opencode}.py: decode backend output as UTF-8 with errors="replace". steer package (previously failed at import time on Windows): - util.py: guard "import fcntl" and add msvcrt-based lock_exclusive()/ unlock() fallbacks; mailbox.py now uses those helpers. - util.py: pid_alive() used os.kill(pid, 0), which on Windows maps onto TerminateProcess and killed the process it was probing. Use OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION) + GetExitCodeProcess. kill_process_group() falls back to taskkill /F /T, since os.getpgid and os.killpg do not exist on Windows. - registry.py: add current_uid() (os.getuid() on POSIX, 0 on Windows) and a %LOCALAPPDATA% registry root, so os.uname()/os.getuid() no longer raise AttributeError before the cross-platform logic runs. - jsonrpc.py: start_new_session is POSIX-only; use CREATE_NEW_PROCESS_GROUP on Windows. Resolve argv[0] via shutil.which() so npm-style .cmd/.bat shims and shebang scripts do not fail with WinError 193. - supervisor.py: tolerate a missing os.setsid. POSIX behavior is unchanged: every branch is gated on os.name == "nt" or on the availability of fcntl. --- skills/agents-consilium/scripts/lib/common.sh | 2 + skills/agents-consilium/scripts/lib/config.sh | 5 +- .../scripts/lib/dedup-findings.py | 4 +- .../scripts/lib/discovery-pass.sh | 17 ++-- .../scripts/lib/judge-runner.sh | 17 ++-- .../scripts/lib/steer/adapters/claude.py | 2 + .../scripts/lib/steer/adapters/opencode.py | 2 + .../scripts/lib/steer/jsonrpc.py | 38 ++++++++- .../scripts/lib/steer/mailbox.py | 19 ++--- .../scripts/lib/steer/registry.py | 21 +++-- .../scripts/lib/steer/supervisor.py | 2 + .../scripts/lib/steer/util.py | 77 ++++++++++++++++++- 12 files changed, 168 insertions(+), 38 deletions(-) diff --git a/skills/agents-consilium/scripts/lib/common.sh b/skills/agents-consilium/scripts/lib/common.sh index 4b86687..8d1e9b3 100755 --- a/skills/agents-consilium/scripts/lib/common.sh +++ b/skills/agents-consilium/scripts/lib/common.sh @@ -3,6 +3,8 @@ # Shared utilities for consilium multi-agent scripts # +export PYTHONIOENCODING="${PYTHONIOENCODING:-utf-8}" + # Colors for output RED='\033[0;31m' GREEN='\033[0;32m' diff --git a/skills/agents-consilium/scripts/lib/config.sh b/skills/agents-consilium/scripts/lib/config.sh index e0b6394..a0838d6 100755 --- a/skills/agents-consilium/scripts/lib/config.sh +++ b/skills/agents-consilium/scripts/lib/config.sh @@ -20,7 +20,10 @@ CONSILIUM_CONFIG="${CONSILIUM_CONFIG:-$SKILL_ROOT/config.json}" # Internal: read JSON via python3. # Usage: _cfg_python "script body that reads CONSILIUM_CONFIG" _cfg_python() { - CONSILIUM_CONFIG_PATH="$CONSILIUM_CONFIG" python3 -c "$1" + CONSILIUM_CONFIG_PATH="$CONSILIUM_CONFIG" python3 -c " +import sys +sys.stdout.reconfigure(newline='\n') +$1" } # Validate config file exists and parses as JSON. diff --git a/skills/agents-consilium/scripts/lib/dedup-findings.py b/skills/agents-consilium/scripts/lib/dedup-findings.py index 9c8139e..068d1b6 100755 --- a/skills/agents-consilium/scripts/lib/dedup-findings.py +++ b/skills/agents-consilium/scripts/lib/dedup-findings.py @@ -101,14 +101,14 @@ def main() -> int: sys.stderr.write(f"[dedup] skip empty/missing {p}\n") continue agent, role = parse_filename(p) - text = p.read_text(errors="replace") + text = p.read_text(encoding="utf-8", errors="replace") for f in extract_findings(text): all_findings.append(attach_source_attrs(f, agent, role)) all_findings.sort(key=sort_key) out_path.parent.mkdir(parents=True, exist_ok=True) - with out_path.open("w") as fh: + with out_path.open("w", encoding="utf-8", newline="\n") as fh: fh.write(f'\n') for i, f in enumerate(all_findings, start=1): fh.write(reindex(f, i)) diff --git a/skills/agents-consilium/scripts/lib/discovery-pass.sh b/skills/agents-consilium/scripts/lib/discovery-pass.sh index f22cc17..6955577 100755 --- a/skills/agents-consilium/scripts/lib/discovery-pass.sh +++ b/skills/agents-consilium/scripts/lib/discovery-pass.sh @@ -83,13 +83,16 @@ fi CONSILIUM_CONFIG="${CONSILIUM_CONFIG:-$SKILL_DIR/config.json}" [[ -f "$CONSILIUM_CONFIG" ]] || { echo -e "${RED}Error: config not found: $CONSILIUM_CONFIG${NC}" >&2; exit 4; } -BACKEND="$(python3 -c " -import json, sys -d = json.load(open('$CONSILIUM_CONFIG'))['agents'] -if '$AGENT' not in d: - sys.stderr.write('agent not in config: $AGENT\n'); sys.exit(1) -print(d['$AGENT']['backend']) -")" || exit 4 +BACKEND="$(CONSILIUM_CONFIG_PATH="$CONSILIUM_CONFIG" AGENT_ID="$AGENT" python3 -c ' +import json, os, sys +sys.stdout.reconfigure(newline="\n") +path = os.environ["CONSILIUM_CONFIG_PATH"] +agent = os.environ["AGENT_ID"] +d = json.load(open(path))["agents"] +if agent not in d: + sys.stderr.write(f"agent not in config: {agent}\n"); sys.exit(1) +print(d[agent]["backend"]) +')" || exit 4 BACKEND_SCRIPT="$LIB_DIR/backend_run.sh" [[ -x "$BACKEND_SCRIPT" || -f "$BACKEND_SCRIPT" ]] || { diff --git a/skills/agents-consilium/scripts/lib/judge-runner.sh b/skills/agents-consilium/scripts/lib/judge-runner.sh index 51ce206..89c69d1 100755 --- a/skills/agents-consilium/scripts/lib/judge-runner.sh +++ b/skills/agents-consilium/scripts/lib/judge-runner.sh @@ -78,13 +78,16 @@ done CONSILIUM_CONFIG="${CONSILIUM_CONFIG:-$SKILL_DIR/config.json}" [[ -f "$CONSILIUM_CONFIG" ]] || { echo -e "${RED}Error: config not found: $CONSILIUM_CONFIG${NC}" >&2; exit 4; } -BACKEND="$(python3 -c " -import json, sys -d = json.load(open('$CONSILIUM_CONFIG'))['agents'] -if '$AGENT' not in d: - sys.stderr.write('agent not in config: $AGENT\n'); sys.exit(1) -print(d['$AGENT']['backend']) -")" || exit 4 +BACKEND="$(CONSILIUM_CONFIG_PATH="$CONSILIUM_CONFIG" AGENT_ID="$AGENT" python3 -c ' +import json, os, sys +sys.stdout.reconfigure(newline="\n") +path = os.environ["CONSILIUM_CONFIG_PATH"] +agent = os.environ["AGENT_ID"] +d = json.load(open(path))["agents"] +if agent not in d: + sys.stderr.write(f"agent not in config: {agent}\n"); sys.exit(1) +print(d[agent]["backend"]) +')" || exit 4 BACKEND_SCRIPT="$LIB_DIR/backend_run.sh" [[ -f "$BACKEND_SCRIPT" ]] || { echo -e "${RED}Error: backend runner missing: $BACKEND_SCRIPT${NC}" >&2; exit 4; } diff --git a/skills/agents-consilium/scripts/lib/steer/adapters/claude.py b/skills/agents-consilium/scripts/lib/steer/adapters/claude.py index 86ea2fc..19c2efc 100644 --- a/skills/agents-consilium/scripts/lib/steer/adapters/claude.py +++ b/skills/agents-consilium/scripts/lib/steer/adapters/claude.py @@ -65,6 +65,8 @@ def start(self, task: str) -> None: stderr=subprocess.PIPE, cwd=self.cwd, text=True, + encoding="utf-8", + errors="replace", bufsize=1, start_new_session=True, ) diff --git a/skills/agents-consilium/scripts/lib/steer/adapters/opencode.py b/skills/agents-consilium/scripts/lib/steer/adapters/opencode.py index 1efed0a..8a32197 100644 --- a/skills/agents-consilium/scripts/lib/steer/adapters/opencode.py +++ b/skills/agents-consilium/scripts/lib/steer/adapters/opencode.py @@ -139,6 +139,8 @@ def start(self, task: str) -> None: cwd=self.cwd, env=self._child_env(), text=True, + encoding="utf-8", + errors="replace", bufsize=1, start_new_session=True, ) diff --git a/skills/agents-consilium/scripts/lib/steer/jsonrpc.py b/skills/agents-consilium/scripts/lib/steer/jsonrpc.py index 23bd35f..45a9de6 100644 --- a/skills/agents-consilium/scripts/lib/steer/jsonrpc.py +++ b/skills/agents-consilium/scripts/lib/steer/jsonrpc.py @@ -2,13 +2,40 @@ from __future__ import annotations import json +import os import queue +import shutil import subprocess import threading import time from typing import Any, Callable, Dict, List, Optional, Tuple +def _resolve_argv(argv: List[str]) -> List[str]: + """Resolve argv[0] to an actually-executable path. + + Native Windows Python's subprocess.Popen requires a real PE executable — + it cannot exec a shebang script directly, and does not consult PATHEXT + the way cmd.exe does. shutil.which() honors PATHEXT (finds .cmd/.bat/.exe + shims npm-style CLIs install); a plain-text script found that way still + needs a bash launcher to run its shebang line. + """ + if os.name != "nt" or not argv: + return argv + resolved = shutil.which(argv[0]) or argv[0] + if resolved.lower().endswith((".cmd", ".bat", ".exe", ".com")): + return [resolved] + argv[1:] + try: + with open(resolved, "rb") as f: + head = f.read(2) + if head == b"#!": + bash = shutil.which("bash.exe") or shutil.which("bash") or "bash" + return [bash, resolved] + argv[1:] + except OSError: + pass + return [resolved] + argv[1:] + + class JsonRpcProcess: """ Supervise a child process speaking newline-delimited JSON-RPC 2.0. @@ -45,17 +72,22 @@ def __init__( self._exit_code: Optional[int] = None def start(self) -> None: - self.proc = subprocess.Popen( - self.argv, + popen_kwargs: Dict[str, Any] = dict( stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=self.cwd, env=self.env, text=True, + encoding="utf-8", + errors="replace", bufsize=1, - start_new_session=True, ) + if os.name == "nt": + popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + else: + popen_kwargs["start_new_session"] = True + self.proc = subprocess.Popen(_resolve_argv(self.argv), **popen_kwargs) self._reader = threading.Thread(target=self._read_stdout, daemon=True) self._reader.start() self._stderr_thread = threading.Thread(target=self._read_stderr, daemon=True) diff --git a/skills/agents-consilium/scripts/lib/steer/mailbox.py b/skills/agents-consilium/scripts/lib/steer/mailbox.py index 444cbcc..23974f7 100644 --- a/skills/agents-consilium/scripts/lib/steer/mailbox.py +++ b/skills/agents-consilium/scripts/lib/steer/mailbox.py @@ -1,7 +1,6 @@ """Atomic filesystem mailbox with portable locking and monotonic ordering.""" from __future__ import annotations -import fcntl import json from pathlib import Path from typing import Any, Dict, List, Optional @@ -11,8 +10,10 @@ atomic_write_json, content_hash, ensure_dir, + lock_exclusive, new_id, safe_client_filename, + unlock, secure_touch, utc_now_iso, ) @@ -78,7 +79,7 @@ def enqueue( raise MailboxError("client_id must be a non-empty string") chash = content_hash(content) if content else content_hash("") with self._with_lock() as lf: - fcntl.flock(lf.fileno(), fcntl.LOCK_EX) + lock_exclusive(lf) try: if self.is_closed() and not allow_when_closed: raise MailboxError( @@ -135,7 +136,7 @@ def enqueue( atomic_write_json(self.seq_path, {"next": seq + 1}) return msg finally: - fcntl.flock(lf.fileno(), fcntl.LOCK_UN) + unlock(lf) def _steer_path_for(self, client_id: str) -> Path: return self.run_dir / "steers" / f"{safe_client_filename(client_id)}.json" @@ -160,11 +161,11 @@ def _find_by_client_id_unlocked(self, client_id: str) -> Optional[Dict[str, Any] def _find_by_client_id(self, client_id: str) -> Optional[Dict[str, Any]]: with self._with_lock() as lf: - fcntl.flock(lf.fileno(), fcntl.LOCK_EX) + lock_exclusive(lf) try: return self._find_by_client_id_unlocked(client_id) finally: - fcntl.flock(lf.fileno(), fcntl.LOCK_UN) + unlock(lf) def list_messages(self, *, after_seq: int = 0) -> List[Dict[str, Any]]: out: List[Dict[str, Any]] = [] @@ -192,7 +193,7 @@ def list_messages(self, *, after_seq: int = 0) -> List[Dict[str, Any]]: def update_message(self, seq: int, **fields: Any) -> Dict[str, Any]: with self._with_lock() as lf: - fcntl.flock(lf.fileno(), fcntl.LOCK_EX) + lock_exclusive(lf) try: path = self.dir / f"msg-{seq:06d}.json" if not path.is_file(): @@ -209,7 +210,7 @@ def update_message(self, seq: int, **fields: Any) -> Dict[str, Any]: atomic_write_json(self._steer_path_for(cid), msg) return msg finally: - fcntl.flock(lf.fileno(), fcntl.LOCK_UN) + unlock(lf) def get_by_client_id(self, client_id: str) -> Optional[Dict[str, Any]]: return self._find_by_client_id(client_id) @@ -221,7 +222,7 @@ def fail_open_messages(self, reason: str) -> List[Dict[str, Any]]: """ updated: List[Dict[str, Any]] = [] with self._with_lock() as lf: - fcntl.flock(lf.fileno(), fcntl.LOCK_EX) + lock_exclusive(lf) try: for p in sorted(self.dir.glob("msg-*.json")): try: @@ -240,5 +241,5 @@ def fail_open_messages(self, reason: str) -> List[Dict[str, Any]]: atomic_write_json(self._steer_path_for(cid), msg) updated.append(msg) finally: - fcntl.flock(lf.fileno(), fcntl.LOCK_UN) + unlock(lf) return updated diff --git a/skills/agents-consilium/scripts/lib/steer/registry.py b/skills/agents-consilium/scripts/lib/steer/registry.py index ff33a0a..22ccc9b 100644 --- a/skills/agents-consilium/scripts/lib/steer/registry.py +++ b/skills/agents-consilium/scripts/lib/steer/registry.py @@ -22,10 +22,19 @@ TERMINAL_STATUSES = frozenset({"completed", "failed", "cancelled"}) +def current_uid() -> int: + """os.getuid() on POSIX; 0 on Windows to match path_owner_uid()'s st_uid.""" + return os.getuid() if os.name != "nt" else 0 + + def default_registry_root() -> Path: override = os.environ.get("CONSILIUM_STEER_DIR") if override: return Path(override).expanduser() + if os.name == "nt": + local_appdata = os.environ.get("LOCALAPPDATA") + base = Path(local_appdata) if local_appdata else Path.home() / "AppData" / "Local" + return base / "agents-consilium" / "steer" xdg = os.environ.get("XDG_CACHE_HOME") if xdg: return Path(xdg) / "agents-consilium" / "steer" @@ -91,7 +100,7 @@ def create_run( ensure_dir(rdir, DIR_MODE) for sub in ("mailbox", "control", "steers", "turns"): ensure_dir(rdir / sub, DIR_MODE) - uid = owner_uid if owner_uid is not None else os.getuid() + uid = owner_uid if owner_uid is not None else current_uid() if uid is None: raise RegistryError("owner_uid is required", exit_code=5) meta = { @@ -136,9 +145,9 @@ def _assert_run_dir_safe(self, run_id: str, rdir: Path) -> None: dir_uid = path_owner_uid(rdir) except OSError as e: raise RegistryError(f"cannot stat run dir: {run_id}: {e}", exit_code=5) from e - if dir_uid != os.getuid(): + if dir_uid != current_uid(): raise RegistryError( - f"run dir owner uid {dir_uid} != current uid {os.getuid()}: {run_id}", + f"run dir owner uid {dir_uid} != current uid {current_uid()}: {run_id}", exit_code=5, ) @@ -162,7 +171,7 @@ def load_meta(self, run_id: str) -> Dict[str, Any]: raise RegistryError( f"invalid owner_uid in meta for run: {run_id}", exit_code=5 ) from e - if owner != os.getuid(): + if owner != current_uid(): raise RegistryError(f"run owned by different uid: {run_id}", exit_code=5) # Cross-check directory owner dir_uid = path_owner_uid(rdir) @@ -179,7 +188,7 @@ def update_meta(self, run_id: str, **fields: Any) -> Dict[str, Any]: meta.update(fields) # Never drop owner_uid if meta.get("owner_uid") is None: - meta["owner_uid"] = os.getuid() + meta["owner_uid"] = current_uid() meta["updated_at"] = utc_now_iso() atomic_write_json(rdir / "meta.json", meta) return meta @@ -211,7 +220,7 @@ def recover_run( recovered.update( { "run_id": run_id, - "owner_uid": os.getuid(), + "owner_uid": current_uid(), "recovered_at": utc_now_iso(), "recovery_reason": reason, "updated_at": utc_now_iso(), diff --git a/skills/agents-consilium/scripts/lib/steer/supervisor.py b/skills/agents-consilium/scripts/lib/steer/supervisor.py index 33c29ab..e2026b1 100644 --- a/skills/agents-consilium/scripts/lib/steer/supervisor.py +++ b/skills/agents-consilium/scripts/lib/steer/supervisor.py @@ -1070,6 +1070,8 @@ def main(argv: Optional[list] = None) -> int: # can never signal the caller's shell. try: os.setsid() + except AttributeError: + pass # os.setsid does not exist on Windows; CREATE_NEW_PROCESS_GROUP covers it except OSError: pass # already a session leader task = Path(args.task_file).read_text(encoding="utf-8") diff --git a/skills/agents-consilium/scripts/lib/steer/util.py b/skills/agents-consilium/scripts/lib/steer/util.py index 10c8652..2681d03 100644 --- a/skills/agents-consilium/scripts/lib/steer/util.py +++ b/skills/agents-consilium/scripts/lib/steer/util.py @@ -1,7 +1,6 @@ """Shared utilities: atomic IO, hashing, process groups, secure modes, time helpers.""" from __future__ import annotations -import fcntl import hashlib import json import os @@ -14,6 +13,19 @@ from pathlib import Path from typing import Any, Dict, Iterator, Optional +try: + import fcntl +except ImportError: + fcntl = None + import msvcrt + +if os.name == "nt": + import ctypes + import ctypes.wintypes + + PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 + STILL_ACTIVE = 259 + # Private-by-default modes for steerable registry state. DIR_MODE = 0o700 FILE_MODE = 0o600 @@ -190,6 +202,19 @@ def preview_text(s: Optional[str], n: int) -> str: def pid_alive(pid: int) -> bool: if pid <= 0: return False + if os.name == "nt": + handle = ctypes.windll.kernel32.OpenProcess( + PROCESS_QUERY_LIMITED_INFORMATION, False, pid + ) + if not handle: + return False + try: + code = ctypes.wintypes.DWORD() + if not ctypes.windll.kernel32.GetExitCodeProcess(handle, ctypes.byref(code)): + return False + return code.value == STILL_ACTIVE + finally: + ctypes.windll.kernel32.CloseHandle(handle) try: os.kill(pid, 0) return True @@ -201,6 +226,15 @@ def kill_process_group(pid: int, timeout: float = 5.0) -> None: """Deterministic process-group cancellation. Best-effort, no orphans preferred.""" if pid <= 0: return + if os.name == "nt": + import subprocess + + subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(pid)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + return try: pgid = os.getpgid(pid) except OSError: @@ -294,17 +328,54 @@ def is_loopback_url(url: str) -> bool: return False +def lock_exclusive(fileobj) -> None: + """Portable exclusive lock on an open file object (blocking).""" + if fcntl is not None: + fcntl.flock(fileobj.fileno(), fcntl.LOCK_EX) + else: + while True: + try: + msvcrt.locking(fileobj.fileno(), msvcrt.LK_LOCK, 1) + return + except OSError: + time.sleep(0.05) + + +def unlock(fileobj) -> None: + """Release a lock taken with lock_exclusive().""" + if fcntl is not None: + fcntl.flock(fileobj.fileno(), fcntl.LOCK_UN) + else: + fileobj.seek(0) + msvcrt.locking(fileobj.fileno(), msvcrt.LK_UNLCK, 1) + + @contextmanager def flock_exclusive(lock_path: Path) -> Iterator[None]: """Exclusive flock around a run-level critical section.""" ensure_dir(lock_path.parent) secure_touch(lock_path) with open(lock_path, "a+", encoding="utf-8") as lf: - fcntl.flock(lf.fileno(), fcntl.LOCK_EX) + if fcntl is not None: + fcntl.flock(lf.fileno(), fcntl.LOCK_EX) + else: + deadline = time.time() + 10.0 + while True: + try: + msvcrt.locking(lf.fileno(), msvcrt.LK_LOCK, 1) + break + except OSError: + if time.time() >= deadline: + raise + time.sleep(0.05) try: yield finally: - fcntl.flock(lf.fileno(), fcntl.LOCK_UN) + if fcntl is not None: + fcntl.flock(lf.fileno(), fcntl.LOCK_UN) + else: + lf.seek(0) + msvcrt.locking(lf.fileno(), msvcrt.LK_UNLCK, 1) def eprint(msg: str) -> None: From 34fff5eb12f2250ec0cbad29bb0bcc6ee0ea45eb Mon Sep 17 00:00:00 2001 From: Aleksej Vasilev Date: Thu, 27 Aug 2026 07:02:32 +0200 Subject: [PATCH 2/3] Add cross-platform smoke CI for agents-consilium Runs on ubuntu-latest and windows-latest (Git Bash). Imports every steer module, asserts pid_alive() probes without terminating its target, and checks that config_enabled_agents emits LF-only agent ids whose backends resolve. These are the checks that would have caught the import-time and CRLF regressions in #5. --- .github/workflows/consilium-smoke.yml | 83 +++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 .github/workflows/consilium-smoke.yml diff --git a/.github/workflows/consilium-smoke.yml b/.github/workflows/consilium-smoke.yml new file mode 100644 index 0000000..db66be0 --- /dev/null +++ b/.github/workflows/consilium-smoke.yml @@ -0,0 +1,83 @@ +name: agents-consilium smoke + +on: + pull_request: + paths: + - 'skills/agents-consilium/**' + - '.github/workflows/consilium-smoke.yml' + push: + branches: [main] + paths: + - 'skills/agents-consilium/**' + - '.github/workflows/consilium-smoke.yml' + +jobs: + smoke: + name: smoke (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Import every steer module + working-directory: skills/agents-consilium/scripts/lib + run: | + python -c " + import sys + sys.path.insert(0, '.') + import steer.util, steer.mailbox, steer.registry + import steer.jsonrpc, steer.control, steer.supervisor + from steer.adapters import claude, opencode + print('steer imports OK:', steer.registry.default_registry_root()) + " + + - name: pid_alive must probe, not kill + working-directory: skills/agents-consilium/scripts/lib + run: | + python -c " + import subprocess, sys, time + sys.path.insert(0, '.') + from steer.util import pid_alive + p = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(30)']) + assert pid_alive(p.pid), 'live child reported dead' + time.sleep(0.5) + assert p.poll() is None, 'pid_alive() terminated the process it probed' + assert not pid_alive(9999999), 'bogus pid reported alive' + p.kill() + print('pid_alive OK') + " + + - name: Config helpers emit LF-only agent ids + working-directory: skills/agents-consilium + run: | + source scripts/lib/common.sh + source scripts/lib/config.sh + config_validate + config_enabled_agents > agents.txt + test -s agents.txt || { echo 'no enabled agents parsed'; exit 1; } + if grep -q $'\r' agents.txt; then + echo 'CR found in config_enabled_agents output:' + cat -A agents.txt + exit 1 + fi + while IFS= read -r agent; do + backend="$(config_get_field "$agent" backend)" || { echo "backend lookup failed: [$agent]"; exit 1; } + test -n "$backend" || { echo "empty backend for: [$agent]"; exit 1; } + echo "$agent -> $backend" + done < agents.txt + rm -f agents.txt + echo 'config helpers OK' + + - name: consilium --help + working-directory: skills/agents-consilium + run: bash scripts/consilium --help From 655cd201c253dfaecdf14af6dc13ae1b869e491d Mon Sep 17 00:00:00 2001 From: Aleksej Vasilev Date: Thu, 27 Aug 2026 07:57:25 +0200 Subject: [PATCH 3/3] Harden the Windows paths after review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the previous two commits, from an external review pass. Correctness: - util.py: msvcrt.locking() locks a byte range starting at the CURRENT file offset, unlike flock() which locks the whole file. lock_exclusive() locked at wherever the "a+" handle happened to sit (EOF for a non-empty lock file) while unlock() always seeked to 0, so the two named different bytes and the lock leaked. Both helpers now seek to 0. lock_exclusive() also retried every OSError forever; it now uses LK_NBLCK, re-raises anything that is not EACCES contention, and fails with TimeoutError after a bounded wait. flock_exclusive() delegates to the same primitive instead of repeating it. - util.py: declare argtypes/restype for OpenProcess/GetExitCodeProcess/ CloseHandle. Without them ctypes assumes c_int and truncates a 64-bit HANDLE. pid_alive() no longer reports a live process as dead when the query is denied (ERROR_ACCESS_DENIED) or when GetExitCodeProcess fails. - adapters/{claude,opencode}.py passed start_new_session=True directly to Popen, which raises on Windows — the previous commit only fixed the JSON-RPC child. Added util.detached_popen_kwargs() and routed all three call sites through it. - terminal_guard.py was entirely POSIX-only (signal.SIGHUP, os.killpg, start_new_session) and is invoked for every opencode run, so that backend still failed on Windows. Added a taskkill-based process-tree termination path, a platform-aware termination signal set (SIGBREAK for SIGHUP), and the shared detach kwargs. POSIX invariance: - Dropped errors="replace" from the Popen calls in jsonrpc.py and both adapters. encoding="utf-8" alone fixes the Windows codepage decode; adding errors="replace" would also have relaxed decoding on POSIX, where invalid bytes previously raised. Security: - kill_process_group() and terminal_guard.py invoke taskkill through an absolute %SystemRoot%\System32 path. Resolving the bare name would let a taskkill.exe in the working directory run during cancellation. - kill_process_group() now waits for the tree to actually go away instead of assuming taskkill succeeded. CI: - Added steps for the lock round-trip (on a deliberately non-empty lock file), the detach kwargs, and terminal_guard stopping a completed backend. pid_alive is also checked against a reaped pid. Metadata: - Patch bump to 9.8.1 in plugin.json and marketplace.json, per AGENTS.md. --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- .github/workflows/consilium-smoke.yml | 82 ++++++++++-- .../scripts/lib/steer/adapters/claude.py | 5 +- .../scripts/lib/steer/adapters/opencode.py | 5 +- .../scripts/lib/steer/jsonrpc.py | 8 +- .../scripts/lib/steer/util.py | 121 ++++++++++++------ .../scripts/lib/terminal_guard.py | 74 +++++++++-- 8 files changed, 224 insertions(+), 75 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 96eac3e..2325405 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -13,7 +13,7 @@ { "name": "ai-driven-development", "description": "An umbrella collection of 25 skills + 1 hook for AI-driven development: agent self-configuration (MCP / hooks / settings / skills / plugins / cross-agent repository readiness / safe CLI installation), engineering practices (maintainable complexity, prompt engineering, FPF problem solving, bug-fix protocol, planning gate, repository-history investigation, repository activity summary, C# refactoring, Apple App Store submission review), AI infrastructure (OpenRouter endpoint ranking and routing), research & docs (Semantic Scholar deep research, URL-to-Markdown, ubiquitous-language thesaurus), multi-agent orchestration (consilium: review and stateful delegation), macOS & Windows health & cleanup, and niche utilities (clipboard, Windows QA). Plus a Claude Code Bash safety hook.", - "version": "9.8.0", + "version": "9.8.1", "author": { "name": "CodeAlive-AI", "email": "contact@codealive.ai" diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 52ac914..f7cf594 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ai-driven-development", "description": "An umbrella collection of 25 skills + 1 hook for AI-driven development: agent self-configuration (MCP / hooks / settings / skills / plugins / cross-agent repository readiness / safe CLI installation), engineering practices (maintainable complexity, prompt engineering, FPF problem solving, bug-fix protocol, planning gate, repository-history investigation, repository activity summary, C# refactoring, Apple App Store submission review), AI infrastructure (OpenRouter endpoint ranking and routing), research & docs (Semantic Scholar deep research, URL-to-Markdown, ubiquitous-language thesaurus), multi-agent orchestration (consilium: review and stateful delegation), macOS & Windows health & cleanup, and niche utilities (clipboard, Windows QA). Plus a Claude Code Bash safety hook.", - "version": "9.8.0", + "version": "9.8.1", "author": { "name": "CodeAlive-AI", "email": "contact@codealive.ai" diff --git a/.github/workflows/consilium-smoke.yml b/.github/workflows/consilium-smoke.yml index db66be0..cf4faf7 100644 --- a/.github/workflows/consilium-smoke.yml +++ b/.github/workflows/consilium-smoke.yml @@ -22,6 +22,7 @@ jobs: defaults: run: shell: bash + working-directory: skills/agents-consilium steps: - uses: actions/checkout@v4 @@ -32,33 +33,91 @@ jobs: - name: Import every steer module working-directory: skills/agents-consilium/scripts/lib run: | - python -c " + python - <<'PY' import sys sys.path.insert(0, '.') import steer.util, steer.mailbox, steer.registry import steer.jsonrpc, steer.control, steer.supervisor from steer.adapters import claude, opencode - print('steer imports OK:', steer.registry.default_registry_root()) - " + import terminal_guard + print('imports OK:', steer.registry.default_registry_root()) + PY - - name: pid_alive must probe, not kill + - name: pid_alive probes without terminating working-directory: skills/agents-consilium/scripts/lib run: | - python -c " + python - <<'PY' import subprocess, sys, time sys.path.insert(0, '.') from steer.util import pid_alive - p = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(30)']) - assert pid_alive(p.pid), 'live child reported dead' + child = subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(30)']) + assert pid_alive(child.pid), 'live child reported dead' time.sleep(0.5) - assert p.poll() is None, 'pid_alive() terminated the process it probed' + assert child.poll() is None, 'pid_alive() terminated the process it probed' assert not pid_alive(9999999), 'bogus pid reported alive' - p.kill() + child.kill() + child.wait() + assert not pid_alive(child.pid), 'reaped child still reported alive' print('pid_alive OK') - " + PY + + - name: Lock helpers round-trip on a non-empty lock file + working-directory: skills/agents-consilium/scripts/lib + run: | + python - <<'PY' + import sys, tempfile + from pathlib import Path + sys.path.insert(0, '.') + from steer.util import flock_exclusive, lock_exclusive, unlock + lock = Path(tempfile.mkdtemp()) / 'x.lock' + # Non-empty on purpose: msvcrt.locking() locks a byte range from the + # CURRENT offset, so a lock/unlock pair that disagrees on the offset + # leaks the lock and the next acquisition deadlocks. + lock.write_text('noise noise noise\n', encoding='utf-8') + for _ in range(3): + with flock_exclusive(lock): + pass + with open(lock, 'a+', encoding='utf-8') as fh: + lock_exclusive(fh); unlock(fh) + lock_exclusive(fh); unlock(fh) + print('lock round-trip OK') + PY + + - name: Detach kwargs are valid for this platform + working-directory: skills/agents-consilium/scripts/lib + run: | + python - <<'PY' + import subprocess, sys + sys.path.insert(0, '.') + from steer.util import detached_popen_kwargs + kwargs = detached_popen_kwargs() + print('detach kwargs:', kwargs) + child = subprocess.Popen([sys.executable, '-c', 'pass'], **kwargs) + assert child.wait() == 0 + print('detached spawn OK') + PY + + - name: terminal_guard stops a completed backend + working-directory: skills/agents-consilium/scripts/lib + run: | + python - <<'PY' + import subprocess, sys, time + event = '{"type":"session.complete"}' + backend = f'import time; print({event!r}, flush=True); time.sleep(60)' + started = time.monotonic() + done = subprocess.run( + [sys.executable, 'terminal_guard.py', '--backend', 'opencode', + '--terminal-grace', '1', '--', sys.executable, '-c', backend], + stdin=subprocess.DEVNULL, capture_output=True, text=True, timeout=45, + ) + elapsed = time.monotonic() - started + print(done.stdout, done.stderr, sep='\n') + assert event in done.stdout, 'terminal event was not forwarded' + assert elapsed < 30, f'guard did not stop the completed backend ({elapsed:.1f}s)' + print(f'terminal_guard OK ({elapsed:.1f}s)') + PY - name: Config helpers emit LF-only agent ids - working-directory: skills/agents-consilium run: | source scripts/lib/common.sh source scripts/lib/config.sh @@ -79,5 +138,4 @@ jobs: echo 'config helpers OK' - name: consilium --help - working-directory: skills/agents-consilium run: bash scripts/consilium --help diff --git a/skills/agents-consilium/scripts/lib/steer/adapters/claude.py b/skills/agents-consilium/scripts/lib/steer/adapters/claude.py index 19c2efc..c3c4a7b 100644 --- a/skills/agents-consilium/scripts/lib/steer/adapters/claude.py +++ b/skills/agents-consilium/scripts/lib/steer/adapters/claude.py @@ -9,7 +9,7 @@ from pathlib import Path from typing import Any, Dict, Iterator, List, Optional -from ..util import kill_process_group +from ..util import detached_popen_kwargs, kill_process_group from .base import AdapterEvent, BackendAdapter, DeliveryClass, SteerResult @@ -66,9 +66,8 @@ def start(self, task: str) -> None: cwd=self.cwd, text=True, encoding="utf-8", - errors="replace", bufsize=1, - start_new_session=True, + **detached_popen_kwargs(), ) self._reader = threading.Thread(target=self._read_loop, daemon=True) self._reader.start() diff --git a/skills/agents-consilium/scripts/lib/steer/adapters/opencode.py b/skills/agents-consilium/scripts/lib/steer/adapters/opencode.py index 8a32197..54f22f6 100644 --- a/skills/agents-consilium/scripts/lib/steer/adapters/opencode.py +++ b/skills/agents-consilium/scripts/lib/steer/adapters/opencode.py @@ -27,7 +27,7 @@ from typing import Any, Dict, Iterator, List, Optional from urllib.parse import quote, urlparse -from ..util import is_loopback_url, kill_process_group +from ..util import detached_popen_kwargs, is_loopback_url, kill_process_group from .base import AdapterEvent, BackendAdapter, DeliveryClass, SteerResult # Default OpenCode basic-auth username when OPENCODE_SERVER_USERNAME is unset. @@ -140,9 +140,8 @@ def start(self, task: str) -> None: env=self._child_env(), text=True, encoding="utf-8", - errors="replace", bufsize=1, - start_new_session=True, + **detached_popen_kwargs(), ) # Drain both pipes so a chatty serve process cannot block on a full buffer. self._stderr_thread = threading.Thread( diff --git a/skills/agents-consilium/scripts/lib/steer/jsonrpc.py b/skills/agents-consilium/scripts/lib/steer/jsonrpc.py index 45a9de6..2ae6355 100644 --- a/skills/agents-consilium/scripts/lib/steer/jsonrpc.py +++ b/skills/agents-consilium/scripts/lib/steer/jsonrpc.py @@ -10,6 +10,8 @@ import time from typing import Any, Callable, Dict, List, Optional, Tuple +from .util import detached_popen_kwargs + def _resolve_argv(argv: List[str]) -> List[str]: """Resolve argv[0] to an actually-executable path. @@ -80,13 +82,9 @@ def start(self) -> None: env=self.env, text=True, encoding="utf-8", - errors="replace", bufsize=1, ) - if os.name == "nt": - popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP - else: - popen_kwargs["start_new_session"] = True + popen_kwargs.update(detached_popen_kwargs()) self.proc = subprocess.Popen(_resolve_argv(self.argv), **popen_kwargs) self._reader = threading.Thread(target=self._read_stdout, daemon=True) self._reader.start() diff --git a/skills/agents-consilium/scripts/lib/steer/util.py b/skills/agents-consilium/scripts/lib/steer/util.py index 2681d03..005bd69 100644 --- a/skills/agents-consilium/scripts/lib/steer/util.py +++ b/skills/agents-consilium/scripts/lib/steer/util.py @@ -1,10 +1,12 @@ """Shared utilities: atomic IO, hashing, process groups, secure modes, time helpers.""" from __future__ import annotations +import errno import hashlib import json import os import signal +import subprocess import sys import tempfile import time @@ -25,6 +27,24 @@ PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 STILL_ACTIVE = 259 + ERROR_ACCESS_DENIED = 5 + + # Declare signatures explicitly: without argtypes/restype ctypes defaults to + # c_int, which truncates a 64-bit HANDLE. + _k32 = ctypes.WinDLL("kernel32", use_last_error=True) + _k32.OpenProcess.argtypes = ( + ctypes.wintypes.DWORD, + ctypes.wintypes.BOOL, + ctypes.wintypes.DWORD, + ) + _k32.OpenProcess.restype = ctypes.wintypes.HANDLE + _k32.GetExitCodeProcess.argtypes = ( + ctypes.wintypes.HANDLE, + ctypes.POINTER(ctypes.wintypes.DWORD), + ) + _k32.GetExitCodeProcess.restype = ctypes.wintypes.BOOL + _k32.CloseHandle.argtypes = (ctypes.wintypes.HANDLE,) + _k32.CloseHandle.restype = ctypes.wintypes.BOOL # Private-by-default modes for steerable registry state. DIR_MODE = 0o700 @@ -199,22 +219,34 @@ def preview_text(s: Optional[str], n: int) -> str: return s[: max(0, n - 1)] + "…" +def detached_popen_kwargs() -> Dict[str, Any]: + """Popen kwargs that detach a child from the caller's signal delivery. + + POSIX uses a new session; Windows has no sessions, so the equivalent is a + new process group (start_new_session raises there). + """ + if os.name == "nt": + return {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} + return {"start_new_session": True} + + def pid_alive(pid: int) -> bool: if pid <= 0: return False if os.name == "nt": - handle = ctypes.windll.kernel32.OpenProcess( - PROCESS_QUERY_LIMITED_INFORMATION, False, pid - ) + ctypes.set_last_error(0) + handle = _k32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid) if not handle: - return False + # A live process owned by another user denies the query; only an + # absent pid is evidence of death. + return ctypes.get_last_error() == ERROR_ACCESS_DENIED try: code = ctypes.wintypes.DWORD() - if not ctypes.windll.kernel32.GetExitCodeProcess(handle, ctypes.byref(code)): - return False + if not _k32.GetExitCodeProcess(handle, ctypes.byref(code)): + return True # indeterminate: do not report a live pid as dead return code.value == STILL_ACTIVE finally: - ctypes.windll.kernel32.CloseHandle(handle) + _k32.CloseHandle(handle) try: os.kill(pid, 0) return True @@ -229,11 +261,22 @@ def kill_process_group(pid: int, timeout: float = 5.0) -> None: if os.name == "nt": import subprocess + # Absolute path: resolving "taskkill" through the executable search + # order would let a taskkill.exe in the working directory run instead. + system_root = os.environ.get("SystemRoot", r"C:\Windows") + taskkill = os.path.join(system_root, "System32", "taskkill.exe") + if not os.path.isfile(taskkill): + taskkill = "taskkill" + deadline = time.time() + timeout subprocess.run( - ["taskkill", "/F", "/T", "/PID", str(pid)], + [taskkill, "/F", "/T", "/PID", str(pid)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) + while time.time() < deadline: + if not pid_alive(pid): + return + time.sleep(0.05) return try: pgid = os.getpgid(pid) @@ -328,26 +371,45 @@ def is_loopback_url(url: str) -> bool: return False -def lock_exclusive(fileobj) -> None: - """Portable exclusive lock on an open file object (blocking).""" +# msvcrt.locking() locks a byte range starting at the *current* file offset, +# unlike flock() which locks the whole file. Both helpers therefore seek to 0 +# first so that lock and unlock always name the same byte. +LOCK_TIMEOUT = 30.0 +_LOCK_POLL = 0.05 + + +def lock_exclusive(fileobj, timeout: float = LOCK_TIMEOUT) -> None: + """Portable exclusive lock on an open file object (blocking, bounded).""" if fcntl is not None: fcntl.flock(fileobj.fileno(), fcntl.LOCK_EX) - else: - while True: - try: - msvcrt.locking(fileobj.fileno(), msvcrt.LK_LOCK, 1) - return - except OSError: - time.sleep(0.05) + return + fileobj.seek(0) + deadline = time.time() + timeout + while True: + try: + # LK_NBLCK, not LK_LOCK: LK_LOCK blocks with its own retry policy + # and raises after ~10 attempts, which we cannot distinguish from + # a permanent failure. + msvcrt.locking(fileobj.fileno(), msvcrt.LK_NBLCK, 1) + return + except OSError as e: + if e.errno != errno.EACCES: + raise # permanent failure: bad handle, closed file, ... + if time.time() >= deadline: + raise TimeoutError( + f"could not acquire lock within {timeout:.1f}s" + ) from e + time.sleep(_LOCK_POLL) + fileobj.seek(0) def unlock(fileobj) -> None: """Release a lock taken with lock_exclusive().""" if fcntl is not None: fcntl.flock(fileobj.fileno(), fcntl.LOCK_UN) - else: - fileobj.seek(0) - msvcrt.locking(fileobj.fileno(), msvcrt.LK_UNLCK, 1) + return + fileobj.seek(0) + msvcrt.locking(fileobj.fileno(), msvcrt.LK_UNLCK, 1) @contextmanager @@ -356,26 +418,11 @@ def flock_exclusive(lock_path: Path) -> Iterator[None]: ensure_dir(lock_path.parent) secure_touch(lock_path) with open(lock_path, "a+", encoding="utf-8") as lf: - if fcntl is not None: - fcntl.flock(lf.fileno(), fcntl.LOCK_EX) - else: - deadline = time.time() + 10.0 - while True: - try: - msvcrt.locking(lf.fileno(), msvcrt.LK_LOCK, 1) - break - except OSError: - if time.time() >= deadline: - raise - time.sleep(0.05) + lock_exclusive(lf) try: yield finally: - if fcntl is not None: - fcntl.flock(lf.fileno(), fcntl.LOCK_UN) - else: - lf.seek(0) - msvcrt.locking(lf.fileno(), msvcrt.LK_UNLCK, 1) + unlock(lf) def eprint(msg: str) -> None: diff --git a/skills/agents-consilium/scripts/lib/terminal_guard.py b/skills/agents-consilium/scripts/lib/terminal_guard.py index 37fbacc..56102c4 100644 --- a/skills/agents-consilium/scripts/lib/terminal_guard.py +++ b/skills/agents-consilium/scripts/lib/terminal_guard.py @@ -52,13 +52,67 @@ def is_terminal(line: bytes, backend: str) -> bool: return isinstance(event, dict) and str(event.get("type") or "") in OPENCODE_TERMINAL_TYPES -def stop_process_group(proc: subprocess.Popen[bytes]) -> None: - if proc.poll() is not None: +IS_WINDOWS = os.name == "nt" + + +def detached_popen_kwargs() -> dict[str, Any]: + """Detach the child from the caller's signal delivery. + + POSIX uses a new session; Windows has no sessions, and start_new_session + raises there, so the equivalent is a new process group. + """ + if IS_WINDOWS: + return {"creationflags": subprocess.CREATE_NEW_PROCESS_GROUP} + return {"start_new_session": True} + + +def _taskkill(pid: int, force: bool) -> None: + """Terminate a whole Windows process tree. + + Absolute path: resolving "taskkill" through the executable search order + would let a taskkill.exe in the working directory run instead. + """ + system_root = os.environ.get("SystemRoot", r"C:\Windows") + exe = os.path.join(system_root, "System32", "taskkill.exe") + if not os.path.isfile(exe): + exe = "taskkill" + argv = [exe, "/T", "/PID", str(pid)] + if force: + argv.insert(1, "/F") + subprocess.run(argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + +def signal_tree(proc: subprocess.Popen[bytes], *, force: bool) -> None: + """Signal the child's whole process tree, best effort. + + Windows has no process groups in the POSIX sense and no SIGTERM/SIGKILL + distinction for another process, so both levels map onto taskkill; only + /F differs. + """ + if IS_WINDOWS: + _taskkill(proc.pid, force=force) return try: - os.killpg(proc.pid, signal.SIGTERM) + os.killpg(proc.pid, signal.SIGKILL if force else signal.SIGTERM) except ProcessLookupError: + pass + + +def termination_signals() -> tuple[int, ...]: + """Signals that mean "shut down" on this platform. + + SIGHUP does not exist on Windows; SIGBREAK is its closest analogue. + """ + if IS_WINDOWS: + extra = getattr(signal, "SIGBREAK", None) + return (signal.SIGTERM,) + ((extra,) if extra is not None else ()) + return (signal.SIGTERM, signal.SIGHUP) + + +def stop_process_group(proc: subprocess.Popen[bytes]) -> None: + if proc.poll() is not None: return + signal_tree(proc, force=False) def terminate_and_reap(proc: subprocess.Popen[bytes], timeout: float = 1.0) -> None: @@ -72,10 +126,7 @@ def terminate_and_reap(proc: subprocess.Popen[bytes], timeout: float = 1.0) -> N return except subprocess.TimeoutExpired: pass - try: - os.killpg(proc.pid, signal.SIGKILL) - except ProcessLookupError: - pass + signal_tree(proc, force=True) proc.wait() @@ -102,7 +153,7 @@ def main() -> int: stdin=sys.stdin.buffer, stdout=subprocess.PIPE, stderr=None, - start_new_session=True, + **detached_popen_kwargs(), ) assert proc.stdout is not None @@ -110,7 +161,7 @@ def request_termination(signum: int, _frame: Any) -> None: raise TerminationRequested(signum) previous_handlers: dict[int, Any] = {} - for signum in (signal.SIGTERM, signal.SIGHUP): + for signum in termination_signals(): previous_handlers[signum] = signal.signal(signum, request_termination) forced_shutdown = threading.Event() terminal_timer: threading.Timer | None = None @@ -118,10 +169,7 @@ def request_termination(signum: int, _frame: Any) -> None: def kill_if_still_running() -> None: if proc.poll() is None: - try: - os.killpg(proc.pid, signal.SIGKILL) - except ProcessLookupError: - pass + signal_tree(proc, force=True) def expire_terminal_grace() -> None: nonlocal kill_timer